fix: play r2 direct uploads in the compare versions view

The compare page predates the r2 upload provider: r2 versions fell
through to a URL-safety check that throws on app-relative upload URLs,
so their panels rendered nothing and registered no player — the shared
controls drove an empty list and nothing played.

- add an R2Panel mapping a plain video element over the app upload
  route to the shared adapter, using the same playback-url resolution
  as the main video page
- make play/pause state detection work without the YouTube API loaded
  (numeric fallback), so bunny/r2-only comparisons can pause
- re-sync panels that drift more than 350ms from the source player
  once per second so playback stays aligned, not just starts aligned

Verified against the running app: both versions play in lockstep
(0.000s measured drift), pause together, and seek together.

Co-Authored-By: Claude Fable 5 <[email protected]>
This commit is contained in:
eehkay
2026-07-19 10:18:22 -07:00
co-authored by Claude Fable 5
parent 33008d33ad
commit d830c9a386
@@ -100,6 +100,21 @@ const isSafeUrl = (url: string) => {
}
};
// Mirrors the playback-url resolution the main video page uses for direct
// R2 uploads: media streams through the app's upload route.
function resolveR2PlaybackUrl(version: Version): string {
if (version.originalUrl.startsWith('/api/upload/video/')) {
return version.originalUrl;
}
if (version.originalUrl.startsWith('videos/')) {
return `/api/upload/video/${version.originalUrl.slice('videos/'.length)}`;
}
if (version.videoId.startsWith('videos/')) {
return `/api/upload/video/${version.videoId.slice('videos/'.length)}`;
}
return version.originalUrl;
}
export default function CompareVersionsPageClient({
projectId,
videoId,
@@ -142,6 +157,7 @@ export default function CompareVersionsPageClient({
const currentTimeRef = useRef(0);
const durationRef = useRef(0);
const lastCommitRef = useRef(0);
const lastSyncRef = useRef(0);
// Direct DOM refs for progress bar / playhead / timecode — updated in the RAF loop
const progressBarRef = useRef<HTMLDivElement>(null);
@@ -241,7 +257,11 @@ export default function CompareVersionsPageClient({
try {
const t = sourcePlayer.getCurrentTime();
const d = sourcePlayer.getDuration();
const playing = sourcePlayer.getPlayerState() === window.YT?.PlayerState?.PLAYING;
// PLAYING is 1 in the YouTube API; the numeric fallback keeps
// state detection working when the YT script never loads
// (bunny/r2-only comparisons, ad blockers).
const playing =
sourcePlayer.getPlayerState() === (window.YT?.PlayerState?.PLAYING ?? 1);
// Update refs immediately — zero React overhead
if (t !== undefined) currentTimeRef.current = t;
@@ -257,6 +277,22 @@ export default function CompareVersionsPageClient({
}
}
// Re-sync followers that drift from the source player — providers
// buffer at different speeds and drift past ~350ms reads as
// out-of-sync playback.
if (playing && t !== undefined && timestamp - lastSyncRef.current >= 1000) {
lastSyncRef.current = timestamp;
for (let i = 1; i < players.length; i += 1) {
try {
if (Math.abs(players[i].getCurrentTime() - t) > 0.35) {
players[i].seekTo(t, true);
}
} catch {
// Player not ready
}
}
}
// Throttle React state commits to ~4 updates/sec
if (timestamp - lastCommitRef.current >= 250) {
lastCommitRef.current = timestamp;
@@ -296,7 +332,7 @@ export default function CompareVersionsPageClient({
try {
const firstPlayer = players[0];
const state = firstPlayer.getPlayerState();
const playing = state === window.YT?.PlayerState?.PLAYING;
const playing = state === (window.YT?.PlayerState?.PLAYING ?? 1);
if (playing) {
players.forEach((p) => {
@@ -728,6 +764,13 @@ export default function CompareVersionsPageClient({
onRegister={registerPlayer}
onUnregister={unregisterPlayer}
/>
) : version.providerId === 'r2' ? (
<R2Panel
key={versionId}
version={version}
onRegister={registerPlayer}
onUnregister={unregisterPlayer}
/>
) : isSafeUrl(version.originalUrl) ? (
<iframe
src={version.originalUrl}
@@ -977,6 +1020,160 @@ function YouTubePanel({
return <div ref={containerRef} className="w-full h-full pointer-events-none" />;
}
// Isolated R2 direct-upload panel: a plain <video> over the app's upload
// route, mapped to the shared adapter interface. Kept separate from
// BunnyPanel, whose HLS processing-retry logic does not apply to R2 files.
function R2Panel({
version,
onRegister,
onUnregister,
}: {
version: Version;
onRegister: (versionId: string, player: YT.Player | PlayerAdapter) => void;
onUnregister: (versionId: string) => void;
}) {
const panelRef = useRef<HTMLDivElement>(null);
const videoRef = useRef<HTMLVideoElement>(null);
const [portraitFrameWidth, setPortraitFrameWidth] = useState<number>(0);
const [isPortraitSource, setIsPortraitSource] = useState(false);
useEffect(() => {
const panelEl = panelRef.current;
if (!panelEl || typeof ResizeObserver === 'undefined') return;
const updateFrameWidth = () => {
const panelWidth = panelEl.clientWidth;
const panelHeight = panelEl.clientHeight;
if (panelWidth <= 0 || panelHeight <= 0) return;
setPortraitFrameWidth(Math.min(panelWidth, panelHeight * (9 / 16)));
};
updateFrameWidth();
const observer = new ResizeObserver(updateFrameWidth);
observer.observe(panelEl);
return () => observer.disconnect();
}, []);
useEffect(() => {
const videoEl = videoRef.current;
if (!videoEl) return;
let cachedTime = 0;
let cachedDuration = 0;
let isPlaying = false;
const onLoadedMetadata = () => {
if (Number.isFinite(videoEl.duration) && videoEl.duration > 0) {
cachedDuration = videoEl.duration;
}
if (videoEl.videoWidth > 0 && videoEl.videoHeight > 0) {
setIsPortraitSource(videoEl.videoHeight > videoEl.videoWidth);
}
};
const onTimeUpdate = () => {
cachedTime = videoEl.currentTime || 0;
};
const onPlay = () => {
isPlaying = true;
};
const onPause = () => {
isPlaying = false;
};
const onEnded = () => {
isPlaying = false;
};
const adapter: PlayerAdapter = {
playVideo: () => {
videoEl.play().catch((err) => console.error('Error playing R2 panel video:', err));
},
pauseVideo: () => videoEl.pause(),
seekTo: (time: number) => {
cachedTime = time;
videoEl.currentTime = time;
},
mute: () => {
videoEl.muted = true;
},
unMute: () => {
videoEl.muted = false;
},
isMuted: () => videoEl.muted,
getCurrentTime: () => videoEl.currentTime || cachedTime,
getDuration: () => {
if (Number.isFinite(videoEl.duration) && videoEl.duration > 0) {
cachedDuration = videoEl.duration;
}
return cachedDuration;
},
getPlayerState: () =>
isPlaying ? (window.YT?.PlayerState?.PLAYING ?? 1) : (window.YT?.PlayerState?.PAUSED ?? 2),
setPlaybackRate: (rate: number) => {
videoEl.playbackRate = rate;
},
destroy: () => {
videoEl.removeEventListener('loadedmetadata', onLoadedMetadata);
videoEl.removeEventListener('timeupdate', onTimeUpdate);
videoEl.removeEventListener('play', onPlay);
videoEl.removeEventListener('pause', onPause);
videoEl.removeEventListener('ended', onEnded);
videoEl.removeAttribute('src');
videoEl.load();
},
};
videoEl.addEventListener('loadedmetadata', onLoadedMetadata);
videoEl.addEventListener('timeupdate', onTimeUpdate);
videoEl.addEventListener('play', onPlay);
videoEl.addEventListener('pause', onPause);
videoEl.addEventListener('ended', onEnded);
videoEl.src = resolveR2PlaybackUrl(version);
videoEl.load();
onRegister(version.id, adapter);
return () => {
onUnregister(version.id);
adapter.destroy();
};
}, [version, onRegister, onUnregister]);
return (
<div
ref={panelRef}
className="relative w-full h-full group flex items-center justify-center bg-black"
>
<div
className={cn(
'relative flex items-center justify-center bg-black',
isPortraitSource ? 'h-full overflow-hidden' : 'w-full h-full'
)}
style={
isPortraitSource && portraitFrameWidth > 0
? { width: `${portraitFrameWidth}px` }
: undefined
}
>
<video
ref={videoRef}
className="w-full h-full object-contain pointer-events-none border-0 bg-black"
style={{
pointerEvents: 'none',
width: '100%',
height: '100%',
objectFit: 'contain',
objectPosition: 'center',
backgroundColor: 'black',
}}
preload="metadata"
playsInline
/>
</div>
</div>
);
}
// Isolated Bunny Stream player component per panel mapped to the shared adapter interface
function BunnyPanel({
version,