aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-hub/src/meshbay_hub/static/video-player.js
diff options
context:
space:
mode:
authorChristophe Besson <cbesson@gmail.com>2026-10-03 13:59:44 +0200
committerChristophe Besson <cbesson@gmail.com>2026-10-03 14:24:55 +0200
commitd4cd99df1fdecb28d60c10c87f72a93634128db8 (patch)
treed0426aea997f804d4b62d1fc329cdea9c2599804 /packages/meshbay-hub/src/meshbay_hub/static/video-player.js
parentab0ba3db76d1accec1ca944e62b325c8d92592a6 (diff)
downloadmeshbay-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.js122
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">