diff options
| author | Christophe Besson <cbesson@gmail.com> | 2026-10-03 13:59:44 +0200 |
|---|---|---|
| committer | Christophe Besson <cbesson@gmail.com> | 2026-10-03 14:24:55 +0200 |
| commit | d4cd99df1fdecb28d60c10c87f72a93634128db8 (patch) | |
| tree | d0426aea997f804d4b62d1fc329cdea9c2599804 /packages/meshbay-hub/src/meshbay_hub/static/video-player.js | |
| parent | ab0ba3db76d1accec1ca944e62b325c8d92592a6 (diff) | |
| download | meshbay-d4cd99df1fdecb28d60c10c87f72a93634128db8.tar.gz | |
Player: remote control while casting
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 <noreply@anthropic.com>
Diffstat (limited to 'packages/meshbay-hub/src/meshbay_hub/static/video-player.js')
| -rw-r--r-- | packages/meshbay-hub/src/meshbay_hub/static/video-player.js | 122 |
1 files changed, 115 insertions, 7 deletions
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` <div class="video-overlay video-player-overlay" onClick=${(e) => { 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 }) { <button class="video-close ${castActive ? 'cast-active' : ''}" onClick=${async () => { if (castActive) { - await platform.cast.chromecastDisconnect().catch(() => {}); - await platform.cast.stop(); - setCastActive(false); castActiveRef.current = false; - setCastUrl(null); - setCastDeviceName(null); - castDeviceRef.current = null; + await stopCast(); } else if (castCodecRef.current && initSegmentRef.current) { if (castPickerOpen) { setCastPickerOpen(false); @@ -1841,6 +1918,37 @@ function VideoPlayer({ entry, transportRef, gekRef, onClose, onDownload }) { default /> `} </video> + ${remoteMode && html` + <div class="video-remote"> + <div class="video-remote-device"> + <${Icon} name="cast" /> ${t('cast.playing_on', { device: castDeviceName })} + </div> + <div class="video-remote-time"> + ${remoteShown === null || remoteTarget !== null + || (remote && (remote.playerState === 'BUFFERING' || remote.playerState === 'LOADING')) + ? html`<span class="spinner"></span>${' '}` : ''} + ${remoteShown === null ? t('cast.waiting') + : `${formatClock(remoteShown)} / ${formatClock(durationRef.current)}`} + </div> + <input class="video-remote-seek" type="range" min="0" step="1" + max=${Math.max(1, Math.floor(durationRef.current))} + value=${Math.floor(remoteShown || 0)} + disabled=${remoteShown === null || !(durationRef.current > 0)} + onInput=${(e) => setRemoteDrag(+e.target.value)} + onChange=${(e) => { setRemoteDrag(null); remoteSeek(+e.target.value); }} /> + <div class="video-remote-buttons"> + <button class="video-remote-btn" disabled=${remoteShown === null} + onClick=${() => remoteSeek(remoteShown - 30)} title="${t('cast.back30')}">−30 s</button> + <button class="video-remote-btn video-remote-main" disabled=${!remote} + onClick=${remoteToggle} + title="${remote && remote.playerState === 'PAUSED' ? t('cast.play') : t('cast.pause')}"> + <${Icon} name=${remote && remote.playerState === 'PAUSED' ? 'play' : 'pause'} /></button> + <button class="video-remote-btn" disabled=${remoteShown === null} + onClick=${() => remoteSeek(remoteShown + 30)} title="${t('cast.fwd30')}">+30 s</button> + </div> + <button class="video-remote-stop" onClick=${stopCast}>${t('cast.stop')}</button> + </div> + `} ${phase === 'loading' && html` <div class="video-loading"> <div class="video-loading-label"> |