aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-hub/src/meshbay_hub/static
diff options
context:
space:
mode:
Diffstat (limited to 'packages/meshbay-hub/src/meshbay_hub/static')
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/app.js5
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/file-utils.js4
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/icon.js4
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/de.js7
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/en.js7
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/es.js7
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js7
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/it.js7
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js7
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js7
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js7
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js7
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js7
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/platform.js13
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/style.css201
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/video-player.js220
16 files changed, 506 insertions, 11 deletions
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 };