diff options
Diffstat (limited to 'packages/meshbay-hub/src')
18 files changed, 508 insertions, 13 deletions
diff --git a/packages/meshbay-hub/src/meshbay_hub/__init__.py b/packages/meshbay-hub/src/meshbay_hub/__init__.py index 4d3d6dd..cf6868d 100644 --- a/packages/meshbay-hub/src/meshbay_hub/__init__.py +++ b/packages/meshbay-hub/src/meshbay_hub/__init__.py @@ -1,3 +1,3 @@ """MeshBay Hub — identity authority and group registry.""" -__version__ = "0.17.0" +__version__ = "0.18.0" diff --git a/packages/meshbay-hub/src/meshbay_hub/api/hub.py b/packages/meshbay-hub/src/meshbay_hub/api/hub.py index a1afe38..2a3efaf 100644 --- a/packages/meshbay-hub/src/meshbay_hub/api/hub.py +++ b/packages/meshbay-hub/src/meshbay_hub/api/hub.py @@ -88,7 +88,7 @@ async def hub_pubkey(): # reads MBK1/MBK2. An older client would still write them, and every such # bundle is one no browser of the account can open again. MIN_CLIENT_VERSION = "0.17.0" -RECOMMENDED_CLIENT_VERSION = "0.17.0" +RECOMMENDED_CLIENT_VERSION = "0.18.0" @router.get("/version") diff --git a/packages/meshbay-hub/src/meshbay_hub/static/app.js b/packages/meshbay-hub/src/meshbay_hub/static/app.js index 72dd123..6f97ca5 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/app.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/app.js @@ -592,7 +592,10 @@ function HomePage({ groups, notifications, onMarkRead, onPurge, allowPublicGroup const [setupDismissed, setSetupDismissed] = useState(false); if (groups.length === 0) { - if (platform.isNative && !setupDismissed) { + // Setting up a node needs one to administer: the desktop application, not + // any native shell. A phone has a bridge and no node, and what it needs + // with no groups is the invitations and the join link below. + if (platform.capabilities.nodeAdmin && !setupDismissed) { return html`<${SetupWelcome} onDismiss=${() => setSetupDismissed(true)} />`; } diff --git a/packages/meshbay-hub/src/meshbay_hub/static/file-utils.js b/packages/meshbay-hub/src/meshbay_hub/static/file-utils.js index aa45a25..2ff2bd4 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/file-utils.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/file-utils.js @@ -392,6 +392,10 @@ async function pipelinedDownload(transport, gekKey, fileId, totalChunks, onChunk throw err; } const chunkMsg = await inflight[nextRecv]; + // Released as soon as it is read: a resolved promise left here holds its + // ciphertext, and the array holds every chunk of the file — so a download + // written straight to disk was also held whole in the page until it ended. + inflight[nextRecv] = undefined; // One shape, and a refusal for anything else. There used to be two fallbacks // below this: a base64 `ct_b64` chunk, which was the real wire format until // the binary switch in Phase 9.15 and which no node has sent since, and a diff --git a/packages/meshbay-hub/src/meshbay_hub/static/icon.js b/packages/meshbay-hub/src/meshbay_hub/static/icon.js index c80258c..4fa4357 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/icon.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/icon.js @@ -73,6 +73,10 @@ const ICON_PATHS = { 'chevron-left': ['M14.5 6l-6 6 6 6'], 'chevron-right': ['M9.5 6l6 6-6 6'], close: ['M6 6l12 12M18 6L6 18'], + // A circle nearly closed, its arrow at the top pointing the way it turns; + // the remote writes the seconds inside. + skipback: ['M12 4.5a8 8 0 1 1-6.93 4', 'M14.5 2l-2.5 2.5 2.5 2.5'], + skipfwd: ['M12 4.5a8 8 0 1 0 6.93 4', 'M9.5 2l2.5 2.5-2.5 2.5'], chat: ['M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z'], folder: ['M3.5 6.6a1 1 0 0 1 1-1h4.2l2 2.4h7.8a1 1 0 0 1 1 1v9.4a1 1 0 0 1-1 1h-14a1 1 0 0 1-1-1z'], 'bell-off': ['M18 9a6 6 0 0 0-12 0c0 6-2.5 7.5-2.5 7.5h17S18 15 18 9', 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..bdc4dd5 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,13 @@ export default { 'cast.copied': 'URL kopiert', 'cast.scanning': 'Suche nach Geräten…', 'cast.no_devices': 'Keine Geräte gefunden', + 'cast.casting_to': 'Wiedergabe auf', + 'cast.seek': 'Position im Film', + '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..658f388 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,13 @@ export default { 'cast.copied': 'URL copied', 'cast.scanning': 'Scanning for devices…', 'cast.no_devices': 'No devices found', + 'cast.casting_to': 'Casting to', + 'cast.seek': 'Position in the film', + '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..9e73017 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,13 @@ export default { 'cast.copied': 'URL copiada', 'cast.scanning': 'Buscando dispositivos…', 'cast.no_devices': 'No se encontraron dispositivos', + 'cast.casting_to': 'Transmitiendo a', + 'cast.seek': 'Posición en la película', + '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..5d18d41 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,13 @@ export default { 'cast.copied': 'URL copiée', 'cast.scanning': "Recherche d'appareils…", 'cast.no_devices': 'Aucun appareil trouvé', + 'cast.casting_to': 'Diffusion sur', + 'cast.seek': 'Position dans le film', + '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..c4d2acc 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,13 @@ export default { 'cast.copied': 'URL copiato', 'cast.scanning': 'Ricerca dispositivi…', 'cast.no_devices': 'Nessun dispositivo trovato', + 'cast.casting_to': 'Trasmissione su', + 'cast.seek': 'Posizione nel film', + '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..fe52bfa 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,13 @@ export default { 'cast.copied': 'URLをコピーしました', 'cast.scanning': 'デバイスを検索中…', 'cast.no_devices': 'デバイスが見つかりません', + 'cast.casting_to': 'キャスト先', + 'cast.seek': '再生位置', + '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..90b71f0 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,13 @@ export default { 'cast.copied': 'URL gekopieerd', 'cast.scanning': 'Apparaten zoeken…', 'cast.no_devices': 'Geen apparaten gevonden', + 'cast.casting_to': 'Casten naar', + 'cast.seek': 'Positie in de film', + '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..8c31cf3 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,13 @@ export default { 'cast.copied': 'URL skopiowany', 'cast.scanning': 'Wyszukiwanie urządzeń…', 'cast.no_devices': 'Nie znaleziono urządzeń', + 'cast.casting_to': 'Przesyłanie do', + 'cast.seek': 'Pozycja w filmie', + '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..2f951ee 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,13 @@ export default { 'cast.copied': 'URL copiada', 'cast.scanning': 'Procurando dispositivos…', 'cast.no_devices': 'Nenhum dispositivo encontrado', + 'cast.casting_to': 'Transmitindo para', + 'cast.seek': 'Posição no filme', + '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..ea02545 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,13 @@ export default { 'cast.copied': '链接已复制', 'cast.scanning': '正在搜索设备…', 'cast.no_devices': '未找到设备', + 'cast.casting_to': '投放到', + 'cast.seek': '播放位置', + '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/platform.js b/packages/meshbay-hub/src/meshbay_hub/static/platform.js index 749f93e..c131495 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/platform.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/platform.js @@ -497,6 +497,19 @@ export const cast = { if (!bridge || !bridge.cast) return false; return bridge.cast.chromecastDisconnect(); }, + // Remote control of the receiver. Absent from an older bridge, so callers + // check `canControl` rather than catching a TypeError. + get canControl() { + return Boolean(bridge && bridge.cast && bridge.cast.chromecastPause && bridge.cast.chromecastPlay); + }, + async chromecastPause() { + if (!this.canControl) return null; + return bridge.cast.chromecastPause(); + }, + async chromecastPlay() { + if (!this.canControl) return null; + return bridge.cast.chromecastPlay(); + }, }; /** diff --git a/packages/meshbay-hub/src/meshbay_hub/static/style.css b/packages/meshbay-hub/src/meshbay_hub/static/style.css index 23013a4..e1e152e 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/style.css +++ b/packages/meshbay-hub/src/meshbay_hub/static/style.css @@ -2084,6 +2084,207 @@ 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; the container is given room for it on a portrait phone, where + the picture alone would be a strip. */ +.video-container-remote { min-height: min(72vh, 560px); } + +.video-remote { + --remote-accent: #23b1f0; + position: absolute; + inset: 0; + z-index: 2; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 32px; + padding: 24px 20px; + border-radius: 12px; + background: + radial-gradient(120% 70% at 50% 0%, rgba(35, 177, 240, 0.16) 0%, rgba(35, 177, 240, 0) 60%), + #0a1224; + color: #e2e8f0; + overflow: hidden; +} + +.video-remote-head { + display: flex; + flex-direction: column; + align-items: center; + gap: 6px; + text-align: center; + max-width: 100%; +} + +.video-remote-badge { + display: inline-flex; + align-items: center; + justify-content: center; + width: 64px; + height: 64px; + margin-bottom: 8px; + border-radius: 50%; + background: rgba(35, 177, 240, 0.14); + box-shadow: 0 0 0 1px rgba(35, 177, 240, 0.35), 0 0 32px rgba(35, 177, 240, 0.25); + color: var(--remote-accent); + font-size: 30px; +} + +.video-remote-label { + font-size: 0.75rem; + letter-spacing: 0.12em; + text-transform: uppercase; + color: #94a3b8; +} + +.video-remote-device { + font-size: 1.35rem; + font-weight: 600; + color: #f8fafc; + max-width: 100%; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.video-remote-state { + display: inline-flex; + align-items: center; + gap: 6px; + min-height: 1.2em; + font-size: 0.85rem; + color: #94a3b8; +} + +.video-remote-track { width: min(100%, 560px); } + +.video-remote-times { + display: flex; + justify-content: space-between; + margin-top: 8px; + font-size: 0.85rem; + font-variant-numeric: tabular-nums; + color: #94a3b8; +} + +/* The track is drawn by hand so the part already played is filled: `--pct` + is set inline from the position. */ +.video-remote-seek { + --pct: 0%; + -webkit-appearance: none; + appearance: none; + display: block; + width: 100%; + height: 28px; + margin: 0; + background: transparent; + cursor: pointer; +} +.video-remote-seek:disabled { cursor: default; opacity: 0.5; } +.video-remote-seek::-webkit-slider-runnable-track { + height: 6px; + border-radius: 3px; + background: linear-gradient(to right, var(--remote-accent) var(--pct), rgba(148, 163, 184, 0.25) var(--pct)); +} +.video-remote-seek::-moz-range-track { + height: 6px; + border-radius: 3px; + background: linear-gradient(to right, var(--remote-accent) var(--pct), rgba(148, 163, 184, 0.25) var(--pct)); +} +.video-remote-seek::-webkit-slider-thumb { + -webkit-appearance: none; + width: 18px; + height: 18px; + margin-top: -6px; + border: 0; + border-radius: 50%; + background: #ffffff; + box-shadow: 0 0 0 4px rgba(35, 177, 240, 0.35), 0 2px 6px rgba(0, 0, 0, 0.4); +} +.video-remote-seek::-moz-range-thumb { + width: 18px; + height: 18px; + border: 0; + border-radius: 50%; + background: #ffffff; + box-shadow: 0 0 0 4px rgba(35, 177, 240, 0.35), 0 2px 6px rgba(0, 0, 0, 0.4); +} + +.video-remote-buttons { + display: flex; + align-items: center; + gap: 36px; +} + +.video-remote-skip, +.video-remote-main { + position: relative; + display: inline-flex; + align-items: center; + justify-content: center; + border: 0; + border-radius: 50%; + cursor: pointer; + -webkit-tap-highlight-color: transparent; + transition: transform 0.1s ease, background-color 0.15s ease; +} +.video-remote-skip:active:not(:disabled), +.video-remote-main:active:not(:disabled) { transform: scale(0.92); } +.video-remote-skip:disabled, +.video-remote-main:disabled { opacity: 0.35; cursor: default; } + +.video-remote-skip { + width: 56px; + height: 56px; + background: transparent; + color: #e2e8f0; + font-size: 44px; +} +.video-remote-skip:hover:not(:disabled) { background: rgba(148, 163, 184, 0.12); } +.video-remote-skip .icon { stroke-width: 1.4; } +.video-remote-skip-n { + position: absolute; + left: 0; + right: 0; + top: 50%; + transform: translateY(-38%); + font-size: 12px; + font-weight: 700; + text-align: center; + pointer-events: none; +} + +.video-remote-main { + width: 76px; + height: 76px; + background: var(--remote-accent); + color: #04121f; + font-size: 34px; + box-shadow: 0 8px 24px rgba(35, 177, 240, 0.35); +} +.video-remote-main:hover:not(:disabled) { background: #4cc3f5; } +.video-remote-main .icon { stroke-width: 2.6; } +/* The triangle is a closed path: filled, it reads as play at a glance. */ +.video-remote-main.is-paused .icon path { fill: currentColor; } +.video-remote-main.is-paused .icon { transform: translateX(2px); } + +.video-remote-stop { + display: inline-flex; + align-items: center; + gap: 8px; + padding: 10px 20px; + border: 1px solid rgba(148, 163, 184, 0.35); + border-radius: 999px; + background: transparent; + color: #cbd5e1; + font: inherit; + font-size: 0.9rem; + cursor: pointer; +} +.video-remote-stop:hover { border-color: #fca5a5; color: #fecaca; } + /* 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 2a4d2ea..2c424fb 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/video-player.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/video-player.js @@ -280,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 @@ -438,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, @@ -764,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. @@ -798,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)); @@ -1064,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'); }); @@ -1178,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); } @@ -1190,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); @@ -1220,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); @@ -1436,6 +1539,24 @@ function VideoPlayer({ entry, transportRef, gekRef, onClose, onDownload }) { // 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([]); @@ -1602,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(); @@ -1630,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" />` @@ -1687,12 +1880,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); @@ -1777,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} @@ -1788,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"> @@ -1826,4 +2026,4 @@ function VideoPlayer({ entry, transportRef, gekRef, onClose, onDownload }) { * live. */ -export { VideoPlayer }; +export { VideoPlayer, CastRemote }; |