aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-hub/src/meshbay_hub
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
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')
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/de.js6
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/en.js6
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/es.js6
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js6
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/it.js6
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js6
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js6
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js6
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js6
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js6
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/style.css73
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/video-player.js122
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">