From d4cd99df1fdecb28d60c10c87f72a93634128db8 Mon Sep 17 00:00:00 2001 From: Christophe Besson Date: Sat, 3 Oct 2026 13:59:44 +0200 Subject: Player: remote control while casting MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Shows the receiver's position with play/pause, ±30 s and a scrubber; a seek restarts the relay where asked. Co-Authored-By: Claude Opus 5.5 --- .../src/meshbay_hub/static/video-player.js | 122 +++++++++++++++++++-- 1 file changed, 115 insertions(+), 7 deletions(-) (limited to 'packages/meshbay-hub/src/meshbay_hub/static/video-player.js') diff --git a/packages/meshbay-hub/src/meshbay_hub/static/video-player.js b/packages/meshbay-hub/src/meshbay_hub/static/video-player.js index 243e0c2..a9d41b6 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/video-player.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/video-player.js @@ -438,6 +438,15 @@ function VideoPlayer({ entry, transportRef, gekRef, onClose, onDownload }) { const castRestartPendingRef = useRef(false); const castDeviceRef = useRef(null); const castRestartGenRef = useRef(0); + // The receiver as last polled: { playerState, position } with the position + // on the film's timeline, or null before it has answered. + const [remote, setRemote] = useState(null); + // Where the viewer sent the television and it has not arrived yet. Shown in + // place of the receiver's position, which until the reload still belongs to + // the stream being abandoned and would make the scrubber jump back. + const [remoteTarget, setRemoteTarget] = useState(null); + const [remoteDrag, setRemoteDrag] = useState(null); + const remoteFilmPosRef = useRef(null); // Segments of the *new* stream that arrive while its seek is still landing // (`reinitAt`/`resumeAt` wait on the SourceBuffer). Null when not holding. const heldRef = useRef(null); @@ -1255,6 +1264,7 @@ function VideoPlayer({ entry, transportRef, gekRef, onClose, onDownload }) { } else { navigator.clipboard.writeText(result.url).catch(() => {}); } + if (castRestartGenRef.current === gen) setRemoteTarget(null); }).catch((err) => { if (castRestartGenRef.current !== gen) return; console.error('[cast] restart failed:', err); @@ -1655,6 +1665,75 @@ function VideoPlayer({ entry, transportRef, gekRef, onClose, onDownload }) { }; }, []); + // ── Remote ──────────────────────────────────────────────────────────── + // + // While a television plays the film, this player is its remote. The phone + // and the receiver do not play in step — they start apart and drift — so a + // scrubber on the local playhead lied about where the film was, and a seek + // from it landed off by that much. Everything here reads the receiver and + // tells it what to do; seeking is the ordinary seek, which restarts the + // relay and reloads the receiver at the new position. + const remoteMode = castActive && castDeviceName !== null && platform.cast.canControl; + useEffect(() => { + if (!remoteMode) { + setRemote(null); setRemoteTarget(null); setRemoteDrag(null); + remoteFilmPosRef.current = null; + return undefined; + } + let stopped = false; + const poll = async () => { + try { + const s = await platform.cast.status(); + const c = s && s.chromecast; + if (stopped) return; + if (c && c.connected && c.position != null && !castRestartPendingRef.current) { + const position = streamStartRef.current + c.position; + remoteFilmPosRef.current = position; + setRemote({ playerState: c.playerState, position }); + } else { + setRemote((r) => (r && c && c.connected ? { ...r, playerState: c.playerState } : r)); + } + } catch { /* asked again on the next tick */ } + }; + poll(); + const id = setInterval(poll, 1000); + return () => { stopped = true; clearInterval(id); }; + }, [remoteMode]); + + const stopCast = async () => { + await platform.cast.chromecastDisconnect().catch(() => {}); + await platform.cast.stop(); + setCastActive(false); castActiveRef.current = false; + setCastUrl(null); + setCastDeviceName(null); + castDeviceRef.current = null; + }; + + const remoteShown = remoteDrag ?? remoteTarget ?? (remote ? remote.position : null); + const remoteSeek = (to) => { + const duration = durationRef.current; + const target = Math.max(0, duration > 0 ? Math.min(to, duration - 1) : to); + setRemoteTarget(target); + if (requestSeekRef.current) requestSeekRef.current(target); + }; + const remoteToggle = async () => { + const v = videoRef.current; + try { + if (remote && remote.playerState === 'PAUSED') { + await platform.cast.chromecastPlay(); + if (v) v.play().catch(() => {}); + } else { + await platform.cast.chromecastPause(); + // The local copy only paces the download: left running, it would go + // on fetching for a television that is not watching. + if (v) v.pause(); + } + setRemote((r) => (r ? { ...r, playerState: r.playerState === 'PAUSED' ? 'PLAYING' : 'PAUSED' } : r)); + } catch (err) { + console.warn('[cast] remote command failed:', err); + } + }; + return html`
{ if (e.target.classList.contains('video-overlay')) onClose(); @@ -1683,7 +1762,10 @@ function VideoPlayer({ entry, transportRef, gekRef, onClose, onDownload }) { setAudioTrack(track.i); const v = videoRef.current; const seek = requestSeekRef.current; - if (v && seek) seek(v.currentTime); + // Where the television is, when one plays the film: the + // phone's playhead has drifted from it. + const at = remoteFilmPosRef.current ?? (v && v.currentTime); + if (at != null && seek) seek(at); }}> ${track.i === audioTrack ? html`<${Icon} name="check" />` @@ -1740,12 +1822,7 @@ function VideoPlayer({ entry, transportRef, gekRef, onClose, onDownload }) { + + +
+ + + `} ${phase === 'loading' && html`
-- cgit v1.2.3