aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-hub/src/meshbay_hub/static/video-player.js
diff options
context:
space:
mode:
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.js257
1 files changed, 229 insertions, 28 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 2b274b5..2c424fb 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/video-player.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/video-player.js
@@ -270,12 +270,6 @@ function reconnectPlan(playhead, range, ended, castActive) {
return { mode: 'seek', at: playhead };
}
-function _mseSupported(codec) {
- if (!window.MediaSource) return false;
- const mime = `video/mp4; codecs="${codec}"`;
- return MediaSource.isTypeSupported(mime);
-}
-
/** Seconds as h:mm:ss, or m:ss under an hour. */
function formatClock(seconds) {
const s = Math.max(0, Math.floor(seconds || 0));
@@ -286,6 +280,64 @@ function formatClock(seconds) {
}
/**
+ * The player as a remote, while a television plays the film.
+ *
+ * Only what it is told: the receiver's position on the film's timeline (null
+ * until the receiver has answered), its state, and whether a seek is on its
+ * way. The scrubber reports a drag as it moves and a seek once let go.
+ */
+function CastRemote({ device, position, duration, playerState, moving,
+ onDrag, onSeek, onToggle, onStop }) {
+ const known = position !== null;
+ const paused = playerState === 'PAUSED';
+ const busy = !known || moving || playerState === 'BUFFERING' || playerState === 'LOADING';
+ const pct = known && duration > 0 ? Math.min(100, (position / duration) * 100) : 0;
+ return html`
+ <div class="video-remote">
+ <div class="video-remote-head">
+ <span class="video-remote-badge"><${Icon} name="cast" /></span>
+ <span class="video-remote-label">${t('cast.casting_to')}</span>
+ <span class="video-remote-device">${device}</span>
+ <span class="video-remote-state">
+ ${busy ? html`<span class="spinner"></span>${' '}${t('cast.waiting')}` : ''}
+ </span>
+ </div>
+ <div class="video-remote-track">
+ <input class="video-remote-seek" type="range" min="0" step="1"
+ style=${`--pct:${pct}%`}
+ max=${Math.max(1, Math.floor(duration))}
+ value=${Math.floor(position || 0)}
+ disabled=${!known || !(duration > 0)}
+ aria-label=${t('cast.seek')}
+ onInput=${(e) => onDrag(+e.target.value)}
+ onChange=${(e) => onSeek(+e.target.value)} />
+ <div class="video-remote-times">
+ <span>${known ? formatClock(position) : '–:––'}</span>
+ <span>${duration > 0 ? formatClock(duration) : '–:––'}</span>
+ </div>
+ </div>
+ <div class="video-remote-buttons">
+ <button class="video-remote-skip" disabled=${!known}
+ onClick=${() => onSeek(position - 30)}
+ title=${t('cast.back30')} aria-label=${t('cast.back30')}>
+ <${Icon} name="skipback" /><span class="video-remote-skip-n">30</span></button>
+ <button class="video-remote-main ${paused ? 'is-paused' : ''}" disabled=${!playerState}
+ onClick=${onToggle}
+ title=${paused ? t('cast.play') : t('cast.pause')}
+ aria-label=${paused ? t('cast.play') : t('cast.pause')}>
+ <${Icon} name=${paused ? 'play' : 'pause'} /></button>
+ <button class="video-remote-skip" disabled=${!known}
+ onClick=${() => onSeek(position + 30)}
+ title=${t('cast.fwd30')} aria-label=${t('cast.fwd30')}>
+ <${Icon} name="skipfwd" /><span class="video-remote-skip-n">30</span></button>
+ </div>
+ <button class="video-remote-stop" onClick=${onStop}>
+ <${Icon} name="close" /> ${t('cast.stop')}</button>
+ </div>
+ `;
+}
+
+/**
* Where *this account on this device* last left off in a given file.
*
* localStorage rather than the node: it needs no protocol, no storage anyone
@@ -444,6 +496,19 @@ 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);
+ const holdGenRef = useRef(0);
const landingPlayheadRef = useRef(false);
// The current Screen Wake Lock sentinel, if the browser granted one — see
// the effect below. Null on any platform/context that does not support it,
@@ -770,6 +835,7 @@ function VideoPlayer({ entry, transportRef, gekRef, onClose, onDownload }) {
ranges: describeRanges(),
});
awaitingInitRef.current = true;
+ heldRef.current = null; holdGenRef.current++;
// A seek supersedes a resume that had been asked for and not landed:
// this one empties the buffer, and taking the resume branch on its
// `stream_init` would leave the film we navigated away from in place.
@@ -804,6 +870,7 @@ function VideoPlayer({ entry, transportRef, gekRef, onClose, onDownload }) {
});
resumingRef.current = true;
awaitingInitRef.current = true;
+ heldRef.current = null; holdGenRef.current++;
seekTargetRef.current = null;
outstandingRef.current = STREAM_WINDOW;
console.log('[resume] request', +target.toFixed(1));
@@ -1070,7 +1137,23 @@ function VideoPlayer({ entry, transportRef, gekRef, onClose, onDownload }) {
castRestartPendingRef.current = true;
}
const land = resuming ? resumeAt : reinitAt;
- land(msg.start || 0).catch(() => {
+ // What follows this message on the ordered channel is the new
+ // stream, its header first. The landing waits on the SourceBuffer,
+ // and those segments used to arrive in that gap and be dropped as
+ // the old film's: the player kept its header from the start of the
+ // film and never noticed, but a cast relay restarted here got a
+ // stream beginning at a moof — no ftyp, no moov — and the receiver
+ // gave up on it within a second (measured on a phone). So they are
+ // held, and taken in order once the landing is done.
+ const hold = ++holdGenRef.current;
+ heldRef.current = [];
+ land(msg.start || 0).then(async () => {
+ while (holdGenRef.current === hold && heldRef.current && heldRef.current.length) {
+ await consumeSegment(heldRef.current.shift());
+ }
+ if (holdGenRef.current === hold) heldRef.current = null;
+ }).catch(() => {
+ if (holdGenRef.current === hold) heldRef.current = null;
setError(t('video.err_transport'));
setPhase('error');
});
@@ -1184,6 +1267,12 @@ function VideoPlayer({ entry, transportRef, gekRef, onClose, onDownload }) {
// for credit that cannot come. A race, which is why the same seek
// worked twice and hung on the third.
outstandingRef.current = Math.max(0, outstandingRef.current - 1);
+ // The new stream, arriving while its seek lands: kept, not dropped.
+ // Counted above already, so the replay must not count it again.
+ if (heldRef.current) {
+ if (!msg.file_id || msg.file_id === entry.id) heldRef.current.push(msg);
+ return;
+ }
if (awaitingInitRef.current) {
console.log('[seek] dropping segment (awaitingInit), outstanding:', outstandingRef.current);
}
@@ -1196,6 +1285,13 @@ function VideoPlayer({ entry, transportRef, gekRef, onClose, onDownload }) {
// would otherwise be decrypted against the wrong file — which fails,
// loudly, in the console, for something that is simply not ours.
if (msg.file_id && msg.file_id !== entry.id) return;
+ await consumeSegment(msg);
+ };
+
+ // Everything a segment goes through once it is known to belong to the
+ // stream being played: by arrival, or replayed after a landing.
+ const consumeSegment = async (msg) => {
+ if (cancelled) return;
try {
const plaintext = await window.MeshBayCrypto.decryptChunkBin(
gekRef.current, entry.id, msg.segment_index, msg.nonce, msg.ct);
@@ -1226,6 +1322,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);
@@ -1439,6 +1536,37 @@ function VideoPlayer({ entry, transportRef, gekRef, onClose, onDownload }) {
}
}, [phase]);
+ // The scan lives as long as the picker is open: closing it, or picking a
+ // receiver, stops the updates. Receivers are listed as they answer, and the
+ // spinner sits below them so one landing never moves the row being aimed at.
+ // While casting, the film plays on the receiver; the local element keeps
+ // playing — its playhead is what paces the stream the relay forwards — but
+ // silently. It went on with its sound, so a phone in the room doubled the
+ // television's audio, screen off included. What the viewer had set comes
+ // back when the cast ends.
+ const mutedBeforeCastRef = useRef(null);
+ useEffect(() => {
+ const v = videoRef.current;
+ if (!v) return;
+ if (castActive) {
+ if (mutedBeforeCastRef.current === null) mutedBeforeCastRef.current = v.muted;
+ v.muted = true;
+ } else if (mutedBeforeCastRef.current !== null) {
+ v.muted = mutedBeforeCastRef.current;
+ mutedBeforeCastRef.current = null;
+ }
+ }, [castActive]);
+
+ useEffect(() => {
+ if (!castPickerOpen) return undefined;
+ setCastDevices([]);
+ setCastScanning(true);
+ return platform.cast.discover((devices, scanning) => {
+ setCastDevices(devices || []);
+ setCastScanning(scanning);
+ });
+ }, [castPickerOpen]);
+
useEffect(() => {
return () => {
if (blobUrlRef.current) {
@@ -1595,6 +1723,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();
@@ -1623,7 +1820,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" />`
@@ -1680,23 +1880,12 @@ 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);
} else {
setCastPickerOpen(true);
- setCastScanning(true);
- setCastDevices([]);
- platform.cast.discover().then((devices) => {
- setCastDevices(devices || []);
- setCastScanning(false);
- }).catch(() => setCastScanning(false));
}
}
}}
@@ -1704,12 +1893,6 @@ function VideoPlayer({ entry, transportRef, gekRef, onClose, onDownload }) {
<${Icon} name="cast" /></button>
${castPickerOpen && html`
<div class="cast-picker">
- ${castScanning && html`
- <div class="cast-picker-item cast-picker-scanning">
- <span class="spinner" style="width:14px;height:14px"></span>
- ${t('cast.scanning')}
- </div>
- `}
${castDevices.map(d => html`
<button class="cast-picker-item" onClick=${() => {
setCastPickerOpen(false);
@@ -1724,6 +1907,12 @@ function VideoPlayer({ entry, transportRef, gekRef, onClose, onDownload }) {
<${Icon} name="cast" /> ${d.name}
</button>
`)}
+ ${castScanning && html`
+ <div class="cast-picker-item cast-picker-scanning">
+ <span class="spinner" style="width:14px;height:14px"></span>
+ ${t('cast.scanning')}
+ </div>
+ `}
${!castScanning && castDevices.length === 0 && html`
<div class="cast-picker-item cast-picker-empty">
${t('cast.no_devices')}
@@ -1776,7 +1965,7 @@ function VideoPlayer({ entry, transportRef, gekRef, onClose, onDownload }) {
</div>
${(phase === 'streaming' || phase === 'loading') && html`
- <div class="video-container">
+ <div class="video-container ${remoteMode ? 'video-container-remote' : ''}">
<video ref=${videoRef} controls autoplay>
${subtitleUrl && html`
<track key=${subtitleUrl} kind="subtitles" src=${subtitleUrl}
@@ -1787,6 +1976,18 @@ function VideoPlayer({ entry, transportRef, gekRef, onClose, onDownload }) {
default />
`}
</video>
+ ${remoteMode && html`
+ <${CastRemote}
+ device=${castDeviceName}
+ position=${remoteShown}
+ duration=${durationRef.current}
+ playerState=${remote ? remote.playerState : null}
+ moving=${remoteTarget !== null}
+ onDrag=${setRemoteDrag}
+ onSeek=${(to) => { setRemoteDrag(null); remoteSeek(to); }}
+ onToggle=${remoteToggle}
+ onStop=${stopCast} />
+ `}
${phase === 'loading' && html`
<div class="video-loading">
<div class="video-loading-label">
@@ -1825,4 +2026,4 @@ function VideoPlayer({ entry, transportRef, gekRef, onClose, onDownload }) {
* live.
*/
-export { VideoPlayer };
+export { VideoPlayer, CastRemote };