diff options
Diffstat (limited to 'packages/meshbay-hub/src/meshbay_hub')
13 files changed, 253 insertions, 72 deletions
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 17f41fe..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,7 +390,8 @@ 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.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', 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 b9a4b29..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,7 +388,8 @@ export default { 'cast.copied': 'URL copied', 'cast.scanning': 'Scanning for devices…', 'cast.no_devices': 'No devices found', - 'cast.playing_on': 'Playing on {device}', + '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', 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 2b22c00..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,7 +388,8 @@ export default { 'cast.copied': 'URL copiada', 'cast.scanning': 'Buscando dispositivos…', 'cast.no_devices': 'No se encontraron dispositivos', - 'cast.playing_on': 'Reproduciendo en {device}', + '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', 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 fb6eb80..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,7 +389,8 @@ 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.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', 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 d9de9bb..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,7 +389,8 @@ export default { 'cast.copied': 'URL copiato', 'cast.scanning': 'Ricerca dispositivi…', 'cast.no_devices': 'Nessun dispositivo trovato', - 'cast.playing_on': 'In riproduzione su {device}', + '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', 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 542840f..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,7 +386,8 @@ export default { 'cast.copied': 'URLをコピーしました', 'cast.scanning': 'デバイスを検索中…', 'cast.no_devices': 'デバイスが見つかりません', - 'cast.playing_on': '{device} で再生中', + 'cast.casting_to': 'キャスト先', + 'cast.seek': '再生位置', 'cast.waiting': 'テレビを待っています…', 'cast.back30': '30秒戻る', 'cast.fwd30': '30秒進む', 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 a6b9487..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,7 +390,8 @@ export default { 'cast.copied': 'URL gekopieerd', 'cast.scanning': 'Apparaten zoeken…', 'cast.no_devices': 'Geen apparaten gevonden', - 'cast.playing_on': 'Speelt af op {device}', + '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', 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 131f8a4..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,7 +401,8 @@ 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.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', 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 c6a0888..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,7 +390,8 @@ export default { 'cast.copied': 'URL copiada', 'cast.scanning': 'Procurando dispositivos…', 'cast.no_devices': 'Nenhum dispositivo encontrado', - 'cast.playing_on': 'Reproduzindo em {device}', + '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', 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 b568db1..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,7 +383,8 @@ export default { 'cast.copied': '链接已复制', 'cast.scanning': '正在搜索设备…', 'cast.no_devices': '未找到设备', - 'cast.playing_on': '正在 {device} 上播放', + 'cast.casting_to': '投放到', + 'cast.seek': '播放位置', 'cast.waiting': '正在等待电视…', 'cast.back30': '后退 30 秒', 'cast.fwd30': '前进 30 秒', diff --git a/packages/meshbay-hub/src/meshbay_hub/static/style.css b/packages/meshbay-hub/src/meshbay_hub/static/style.css index dc121ab..e1e152e 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/style.css +++ b/packages/meshbay-hub/src/meshbay_hub/static/style.css @@ -2086,8 +2086,12 @@ button:disabled { opacity: 0.5; cursor: not-allowed; } /* 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. */ + 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; @@ -2095,67 +2099,191 @@ button:disabled { opacity: 0.5; cursor: not-allowed; } flex-direction: column; align-items: center; justify-content: center; - gap: 18px; - padding: 16px; - background: #0b1222; - border-radius: 4px; + 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; - min-height: 260px; + overflow: hidden; } -.video-remote-device { +.video-remote-head { display: flex; - gap: 8px; + flex-direction: column; align-items: center; - font-size: 1.05rem; + 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-time { +.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 { - width: min(100%, 640px); + --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; - gap: 16px; align-items: center; + gap: 36px; } -.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; +.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-btn:disabled { opacity: 0.4; 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 { - min-width: 56px; - min-height: 56px; - display: flex; - align-items: center; - justify-content: center; + 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 { - border: 0; - background: none; - color: #fca5a5; + 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; - text-decoration: underline; } +.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. */ 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 a9d41b6..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 @@ -1907,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} @@ -1919,35 +1977,16 @@ function VideoPlayer({ entry, transportRef, gekRef, onClose, onDownload }) { `} </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> + <${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"> @@ -1987,4 +2026,4 @@ function VideoPlayer({ entry, transportRef, gekRef, onClose, onDownload }) { * live. */ -export { VideoPlayer }; +export { VideoPlayer, CastRemote }; |