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 | |
| 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')
12 files changed, 248 insertions, 7 deletions
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js index c78bb52..17f41fe 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js @@ -390,6 +390,12 @@ export default { 'cast.copied': 'URL kopiert', 'cast.scanning': 'Suche nach Geräten…', 'cast.no_devices': 'Keine Geräte gefunden', + 'cast.playing_on': 'Wiedergabe auf {device}', + 'cast.waiting': 'Warten auf den Fernseher…', + 'cast.back30': '30 Sekunden zurück', + 'cast.fwd30': '30 Sekunden vor', + 'cast.play': 'Abspielen', + 'cast.pause': 'Pause', // Settings 'settings.title': 'Einstellungen', diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js index f5879b3..b9a4b29 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js @@ -388,6 +388,12 @@ export default { 'cast.copied': 'URL copied', 'cast.scanning': 'Scanning for devices…', 'cast.no_devices': 'No devices found', + 'cast.playing_on': 'Playing on {device}', + 'cast.waiting': 'Waiting for the television…', + 'cast.back30': 'Back 30 seconds', + 'cast.fwd30': 'Forward 30 seconds', + 'cast.play': 'Play', + 'cast.pause': 'Pause', // Settings 'settings.title': 'Settings', diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js index a35aa96..2b22c00 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js @@ -388,6 +388,12 @@ export default { 'cast.copied': 'URL copiada', 'cast.scanning': 'Buscando dispositivos…', 'cast.no_devices': 'No se encontraron dispositivos', + 'cast.playing_on': 'Reproduciendo en {device}', + 'cast.waiting': 'Esperando al televisor…', + 'cast.back30': 'Retroceder 30 segundos', + 'cast.fwd30': 'Avanzar 30 segundos', + 'cast.play': 'Reproducir', + 'cast.pause': 'Pausa', // Settings 'settings.title': 'Ajustes', diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js index 2e823cd..fb6eb80 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js @@ -389,6 +389,12 @@ export default { 'cast.copied': 'URL copiée', 'cast.scanning': "Recherche d'appareils…", 'cast.no_devices': 'Aucun appareil trouvé', + 'cast.playing_on': 'Lecture sur {device}', + 'cast.waiting': 'En attente de la télévision…', + 'cast.back30': 'Reculer de 30 secondes', + 'cast.fwd30': 'Avancer de 30 secondes', + 'cast.play': 'Lecture', + 'cast.pause': 'Pause', // Settings 'settings.title': 'Paramètres', diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js index 84879e8..d9de9bb 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js @@ -389,6 +389,12 @@ export default { 'cast.copied': 'URL copiato', 'cast.scanning': 'Ricerca dispositivi…', 'cast.no_devices': 'Nessun dispositivo trovato', + 'cast.playing_on': 'In riproduzione su {device}', + 'cast.waiting': 'In attesa del televisore…', + 'cast.back30': 'Indietro di 30 secondi', + 'cast.fwd30': 'Avanti di 30 secondi', + 'cast.play': 'Riproduci', + 'cast.pause': 'Pausa', // Settings 'settings.title': 'Impostazioni', diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js index 9167efe..542840f 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js @@ -386,6 +386,12 @@ export default { 'cast.copied': 'URLをコピーしました', 'cast.scanning': 'デバイスを検索中…', 'cast.no_devices': 'デバイスが見つかりません', + 'cast.playing_on': '{device} で再生中', + 'cast.waiting': 'テレビを待っています…', + 'cast.back30': '30秒戻る', + 'cast.fwd30': '30秒進む', + 'cast.play': '再生', + 'cast.pause': '一時停止', // Settings 'settings.title': '設定', diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js index 4845c03..a6b9487 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js @@ -390,6 +390,12 @@ export default { 'cast.copied': 'URL gekopieerd', 'cast.scanning': 'Apparaten zoeken…', 'cast.no_devices': 'Geen apparaten gevonden', + 'cast.playing_on': 'Speelt af op {device}', + 'cast.waiting': 'Wachten op de televisie…', + 'cast.back30': '30 seconden terug', + 'cast.fwd30': '30 seconden vooruit', + 'cast.play': 'Afspelen', + 'cast.pause': 'Pauzeren', // Settings 'settings.title': 'Instellingen', diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js index e3f21d3..131f8a4 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js @@ -401,6 +401,12 @@ export default { 'cast.copied': 'URL skopiowany', 'cast.scanning': 'Wyszukiwanie urządzeń…', 'cast.no_devices': 'Nie znaleziono urządzeń', + 'cast.playing_on': 'Odtwarzanie na {device}', + 'cast.waiting': 'Czekam na telewizor…', + 'cast.back30': '30 sekund wstecz', + 'cast.fwd30': '30 sekund do przodu', + 'cast.play': 'Odtwórz', + 'cast.pause': 'Wstrzymaj', // Settings 'settings.title': 'Ustawienia', diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js index 00510c6..c6a0888 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js @@ -390,6 +390,12 @@ export default { 'cast.copied': 'URL copiada', 'cast.scanning': 'Procurando dispositivos…', 'cast.no_devices': 'Nenhum dispositivo encontrado', + 'cast.playing_on': 'Reproduzindo em {device}', + 'cast.waiting': 'Aguardando a TV…', + 'cast.back30': 'Voltar 30 segundos', + 'cast.fwd30': 'Avançar 30 segundos', + 'cast.play': 'Reproduzir', + 'cast.pause': 'Pausar', // Settings 'settings.title': 'Configurações', diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js index 51a6572..b568db1 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js @@ -383,6 +383,12 @@ export default { 'cast.copied': '链接已复制', 'cast.scanning': '正在搜索设备…', 'cast.no_devices': '未找到设备', + 'cast.playing_on': '正在 {device} 上播放', + 'cast.waiting': '正在等待电视…', + 'cast.back30': '后退 30 秒', + 'cast.fwd30': '前进 30 秒', + 'cast.play': '播放', + 'cast.pause': '暂停', // Settings 'settings.title': '设置', diff --git a/packages/meshbay-hub/src/meshbay_hub/static/style.css b/packages/meshbay-hub/src/meshbay_hub/static/style.css index 23013a4..dc121ab 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/style.css +++ b/packages/meshbay-hub/src/meshbay_hub/static/style.css @@ -2084,6 +2084,79 @@ button:disabled { opacity: 0.5; cursor: not-allowed; } outline: none; } +/* The player as a remote while a television plays the film. Opaque, over + the local picture, which keeps running underneath only to pace the + download. */ +.video-remote { + position: absolute; + inset: 0; + z-index: 2; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 18px; + padding: 16px; + background: #0b1222; + border-radius: 4px; + color: #e2e8f0; + min-height: 260px; +} + +.video-remote-device { + display: flex; + gap: 8px; + align-items: center; + font-size: 1.05rem; + text-align: center; +} + +.video-remote-time { + font-variant-numeric: tabular-nums; + color: #94a3b8; +} + +.video-remote-seek { + width: min(100%, 640px); +} + +.video-remote-buttons { + display: flex; + gap: 16px; + align-items: center; +} + +.video-remote-btn { + min-width: 64px; + min-height: 44px; + padding: 8px 14px; + border: 1px solid #334155; + border-radius: 999px; + background: #1e293b; + color: inherit; + font: inherit; + cursor: pointer; +} + +.video-remote-btn:disabled { opacity: 0.4; cursor: default; } + +.video-remote-main { + min-width: 56px; + min-height: 56px; + display: flex; + align-items: center; + justify-content: center; +} + +.video-remote-stop { + border: 0; + background: none; + color: #fca5a5; + font: inherit; + cursor: pointer; + text-decoration: underline; +} + /* Where the film was picked up again. Sits over the picture rather than pushing it down, and clears itself once the viewer is watching. */ .video-container { position: relative; } 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"> |