diff options
| author | Christophe Besson <cbesson@gmail.com> | 2026-10-05 01:16:12 +0200 |
|---|---|---|
| committer | Christophe Besson <cbesson@gmail.com> | 2026-10-05 01:16:12 +0200 |
| commit | 243bcf017b4957237b3ddf556bd0a37296eb8798 (patch) | |
| tree | 7d0820796737aad4dcfb89de7bec1e7271759af6 /packages/meshbay-hub/src | |
| parent | ac8ed437659d765120d1b0cc9f83e942549b479f (diff) | |
| download | meshbay-243bcf017b4957237b3ddf556bd0a37296eb8798.tar.gz | |
feat(cast): photos on the TV, and a television chosen once for the session
A cast button in Videos' toolbar, at the top of Photos, in an album's bar and
in the lightbox, in a group and in Search alike. A television chosen there is
kept for the session: a film opened plays on it with the player as its remote
from the start, and a photo opened in the lightbox is shown on it, scaled to
1920x1080, upright, as JPEG. The lightbox gains a slideshow.
The relay serves one photo at /image behind the stream's token, on the desktop
and on Android; the shell, not the page, decides that the receiver loads it as
a picture.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Diffstat (limited to 'packages/meshbay-hub/src')
16 files changed, 310 insertions, 16 deletions
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/cast-session.js b/packages/meshbay-hub/src/meshbay_hub/static/cast-session.js new file mode 100644 index 0000000..007722c --- /dev/null +++ b/packages/meshbay-hub/src/meshbay_hub/static/cast-session.js @@ -0,0 +1,191 @@ +import { html, useState, useEffect, useRef } from './vendor/htm-preact.js'; +import { t } from './i18n.js'; +import { Icon } from './icon.js'; +import * as platform from './platform.js'; + +// ── The television this session casts to ───────────────────────────────────── +// +// docs/MESHBAY_DESIGN.md §11.4. Chosen once, from any cast button — Videos' +// toolbar, a photo album's bar, the lightbox, the player — and from then on a +// film opened plays there with the player as its remote, and a photo opened +// in the lightbox is shown there. In memory only: a television chosen +// yesterday may be off today, and choosing it again is one tap. +// +// The receiver is the default media receiver, which shows a film or a +// picture and nothing else; the interface itself stays on this device. + +let current = null; +const listeners = new Set(); + +function notify() { + for (const fn of listeners) fn(current); +} + +export const castSession = { + get device() { return current; }, + get supported() { return Boolean(platform.capabilities.lanCast && platform.cast.available); }, + choose(device) { + current = device ? { id: device.id, name: device.name } : null; + notify(); + }, + /** Forget the television without touching it: whoever stopped it already has. */ + forget() { this.choose(null); }, + /** Stop casting: the television goes back to its idle screen. */ + async end() { + this.choose(null); + photoGen++; + await platform.cast.chromecastDisconnect().catch(() => {}); + await platform.cast.stop().catch(() => {}); + }, + subscribe(fn) { + listeners.add(fn); + return () => listeners.delete(fn); + }, +}; + +export function useCastDevice() { + const [device, setDevice] = useState(castSession.device); + useEffect(() => castSession.subscribe(setDevice), []); + return device; +} + +// ── A photo on the television ──────────────────────────────────────────────── +// +// Scaled to fit 1920×1080 and re-encoded as JPEG before it leaves: a camera's +// original is 20 MB and 6000 px wide, more than a receiver decodes in good +// time, and some are formats it does not decode at all. EXIF orientation is +// applied here, so a portrait photo arrives upright and the receiver centres +// it. Photos asked for while one is still on its way are skipped to the +// newest: paging quickly through an album shows where the reader stopped, +// not every photo passed on the way. + +const TV_WIDTH = 1920; +const TV_HEIGHT = 1080; +const JPEG_QUALITY = 0.9; + +async function scaledForTv(blob) { + const bitmap = await createImageBitmap(blob, { imageOrientation: 'from-image' }); + try { + const scale = Math.min(1, TV_WIDTH / bitmap.width, TV_HEIGHT / bitmap.height); + const canvas = document.createElement('canvas'); + canvas.width = Math.max(1, Math.round(bitmap.width * scale)); + canvas.height = Math.max(1, Math.round(bitmap.height * scale)); + canvas.getContext('2d').drawImage(bitmap, 0, 0, canvas.width, canvas.height); + const jpeg = await new Promise((resolve, reject) => canvas.toBlob( + (b) => (b ? resolve(b) : reject(new Error('JPEG encoding failed'))), + 'image/jpeg', JPEG_QUALITY)); + return new Uint8Array(await jpeg.arrayBuffer()); + } finally { + bitmap.close(); + } +} + +let photoGen = 0; +let photoChain = Promise.resolve(); + +/** Show `blob` on the session's television, if there is one. */ +export function showPhoto(blob) { + const gen = ++photoGen; + photoChain = photoChain.then(async () => { + const device = current; + if (gen !== photoGen || !device || !platform.cast.canShowImages) return; + const bytes = await scaledForTv(blob); + if (gen !== photoGen) return; + const shown = await platform.cast.image({ bytes, type: 'image/jpeg' }); + if (!shown || gen !== photoGen) return; + const status = await platform.cast.status(); + if (status && status.chromecast && status.chromecast.connected) { + await platform.cast.chromecastReload({ mediaUrl: shown.url }); + } else { + await platform.cast.chromecastConnect({ deviceId: device.id, mediaUrl: shown.url }); + } + }).catch((err) => console.warn('[cast] photo not shown:', err)); + return photoChain; +} + +// ── The cast button ────────────────────────────────────────────────────────── +// +// One button, wherever it is put: with no television chosen it lists the +// receivers as they answer; with one, it says which and offers to stop. +// `overlay` is for the dark full-screen views (lightbox), `page` for a +// toolbar. `cls` and `iconCls` are the button's and its icon's own classes, +// so it matches its neighbours. +// `onChosen` runs after a television is picked, for the view that has +// something to show on it straight away. + +export function CastButton({ variant = 'page', cls = 'tb-btn tb-btn-icon', iconCls = '', onChosen }) { + const device = useCastDevice(); + const [open, setOpen] = useState(false); + const [devices, setDevices] = useState([]); + const [scanning, setScanning] = useState(false); + const wrapRef = useRef(null); + + useEffect(() => { + if (!open || device) return undefined; + setDevices([]); + setScanning(true); + return platform.cast.discover((list, still) => { + setDevices(list || []); + setScanning(still); + }); + }, [open, device]); + + // Closed by a press anywhere else, as any menu is. + useEffect(() => { + if (!open) return undefined; + const outside = (e) => { + if (wrapRef.current && !wrapRef.current.contains(e.target)) setOpen(false); + }; + document.addEventListener('pointerdown', outside); + return () => document.removeEventListener('pointerdown', outside); + }, [open]); + + if (!castSession.supported) return null; + + const overlay = variant === 'overlay'; + const menuCls = overlay ? 'cast-picker' : 'ctx-menu cast-menu'; + const itemCls = overlay ? 'cast-picker-item' : 'ctx-menu-item'; + const title = device ? `${t('cast.casting_to')} ${device.name}` : t('cast.start'); + + return html` + <div class="cast-wrapper" ref=${wrapRef}> + <button class="${cls} cast-btn ${device ? 'casting' : ''}" + onClick=${() => setOpen(!open)} title=${title} aria-label=${title} + aria-expanded=${open ? 'true' : 'false'}> + <${Icon} name="cast" cls=${iconCls} /></button> + ${open && html` + <div class=${menuCls} role="menu"> + ${device && html` + <div class="${itemCls} cast-menu-current"> + <${Icon} name="cast" /> ${t('cast.casting_to')} ${device.name} + </div> + <button class=${itemCls} role="menuitem" onClick=${() => { + setOpen(false); + castSession.end(); + }}> + <${Icon} name="close" /> ${t('cast.stop')} + </button> + `} + ${!device && devices.map((d) => html` + <button class=${itemCls} role="menuitem" key=${d.id} onClick=${() => { + setOpen(false); + castSession.choose(d); + if (onChosen) onChosen(d); + }}> + <${Icon} name="cast" /> ${d.name} + </button> + `)} + ${!device && scanning && html` + <div class="${itemCls} cast-picker-scanning"> + <span class="spinner" style="width:14px;height:14px"></span> + ${t('cast.scanning')} + </div> + `} + ${!device && !scanning && devices.length === 0 && html` + <div class="${itemCls} cast-picker-empty">${t('cast.no_devices')}</div> + `} + </div> + `} + </div> + `; +} 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 bdc4dd5..c72de49 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js @@ -377,6 +377,8 @@ export default { 'photo.prev': 'Vorherige (←)', 'photo.next': 'Nächste (→)', 'photo.zip_album': 'Album als ZIP herunterladen', + 'photo.slideshow': 'Diashow', + 'photo.slideshow_stop': 'Diashow beenden', 'photo.zoom_in': 'Vergrößern', 'photo.zoom_out': 'Verkleinern', 'photo.zoom_fit_title': 'An Fenster anpassen', 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 658f388..a799b34 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js @@ -375,6 +375,8 @@ export default { 'photo.prev': 'Previous (←)', 'photo.next': 'Next (→)', 'photo.zip_album': 'Download album as zip', + 'photo.slideshow': 'Slideshow', + 'photo.slideshow_stop': 'Stop slideshow', 'photo.zoom_in': 'Zoom in', 'photo.zoom_out': 'Zoom out', 'photo.zoom_fit_title': 'Fit to window', 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 9e73017..8e49240 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js @@ -375,6 +375,8 @@ export default { 'photo.prev': 'Anterior (←)', 'photo.next': 'Siguiente (→)', 'photo.zip_album': 'Descargar álbum como zip', + 'photo.slideshow': 'Presentación', + 'photo.slideshow_stop': 'Detener la presentación', 'photo.zoom_in': 'Acercar', 'photo.zoom_out': 'Alejar', 'photo.zoom_fit_title': 'Ajustar a la ventana', 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 5d18d41..1432f11 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js @@ -376,6 +376,8 @@ export default { 'photo.prev': 'Précédent (←)', 'photo.next': 'Suivant (→)', 'photo.zip_album': "Télécharger l'album en zip", + 'photo.slideshow': 'Diaporama', + 'photo.slideshow_stop': 'Arrêter le diaporama', 'photo.zoom_in': 'Zoomer', 'photo.zoom_out': 'Dézoomer', 'photo.zoom_fit_title': 'Ajuster à la fenêtre', 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 c4d2acc..579f8da 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js @@ -376,6 +376,8 @@ export default { 'photo.prev': 'Precedente (←)', 'photo.next': 'Successiva (→)', 'photo.zip_album': "Scarica l'album come zip", + 'photo.slideshow': 'Presentazione', + 'photo.slideshow_stop': 'Interrompi la presentazione', 'photo.zoom_in': 'Ingrandisci', 'photo.zoom_out': 'Riduci', 'photo.zoom_fit_title': 'Adatta alla finestra', 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 fe52bfa..cea7bfc 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js @@ -373,6 +373,8 @@ export default { 'photo.prev': '前へ(←)', 'photo.next': '次へ(→)', 'photo.zip_album': 'アルバムをzipでダウンロード', + 'photo.slideshow': 'スライドショー', + 'photo.slideshow_stop': 'スライドショーを停止', 'photo.zoom_in': '拡大', 'photo.zoom_out': '縮小', 'photo.zoom_fit_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 90b71f0..007154a 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js @@ -377,6 +377,8 @@ export default { 'photo.prev': 'Vorige (←)', 'photo.next': 'Volgende (→)', 'photo.zip_album': 'Album downloaden als zip', + 'photo.slideshow': 'Diavoorstelling', + 'photo.slideshow_stop': 'Diavoorstelling stoppen', 'photo.zoom_in': 'Inzoomen', 'photo.zoom_out': 'Uitzoomen', 'photo.zoom_fit_title': 'Passend maken', 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 8c31cf3..7969cd0 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js @@ -388,6 +388,8 @@ export default { 'photo.prev': 'Poprzednie (←)', 'photo.next': 'Następne (→)', 'photo.zip_album': 'Pobierz album jako zip', + 'photo.slideshow': 'Pokaz slajdów', + 'photo.slideshow_stop': 'Zatrzymaj pokaz slajdów', 'photo.zoom_in': 'Powiększ', 'photo.zoom_out': 'Pomniejsz', 'photo.zoom_fit_title': 'Dopasuj do okna', 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 2f951ee..52d9d4c 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 @@ -377,6 +377,8 @@ export default { 'photo.prev': 'Anterior (←)', 'photo.next': 'Próxima (→)', 'photo.zip_album': 'Baixar álbum como zip', + 'photo.slideshow': 'Apresentação de slides', + 'photo.slideshow_stop': 'Parar a apresentação', 'photo.zoom_in': 'Aumentar zoom', 'photo.zoom_out': 'Diminuir zoom', 'photo.zoom_fit_title': 'Ajustar à janela', 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 ea02545..3739dcf 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 @@ -370,6 +370,8 @@ export default { 'photo.prev': '上一张(←)', 'photo.next': '下一张(→)', 'photo.zip_album': '将相册下载为 zip', + 'photo.slideshow': '幻灯片放映', + 'photo.slideshow_stop': '停止幻灯片放映', 'photo.zoom_in': '放大', 'photo.zoom_out': '缩小', 'photo.zoom_fit_title': '适应窗口', diff --git a/packages/meshbay-hub/src/meshbay_hub/static/photos-app.js b/packages/meshbay-hub/src/meshbay_hub/static/photos-app.js index 73b8c15..00bc288 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/photos-app.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/photos-app.js @@ -9,6 +9,7 @@ import { import { transfers } from './transfers.js'; import { MediaThumb, LazyTile } from './media-tiles.js'; import { SourceTag } from './group-name.js'; +import { CastButton, castSession, useCastDevice, showPhoto } from './cast-session.js'; // ── Photos ─────────────────────────────────────────────────────────────────── // @@ -21,6 +22,8 @@ import { SourceTag } from './group-name.js'; // keyboard arrows, EXIF info when present) and a "zip this album" button // that reuses Files' own zip mechanism unchanged (file-utils.js's // downloadDirectory, lifted out of files-app.js for exactly this reuse). +// With a television chosen (cast-session.js), the photo the lightbox shows +// is the photo the television shows, and a slideshow pages through both. function underAnyPhotoRoot(entry, photoRoots) { const p = entry.path || ''; @@ -133,6 +136,10 @@ function PhotoTile({ entry, transportRef, gekRef, onOpen }) { // _thumbBlobCache — clicking back and forth between two photos decrypts // each once, not once per visit. const _fullBlobCache = new Map(); +// The same photos as blobs, for the television: a cast scales the picture +// itself, and reading it back through its URL would be a fetch the page's +// content policy has no reason to allow. +const _fullBlobs = new Map(); // Zoom is only ever meaningful for the lightbox's own full-resolution // image — nowhere else in the app shows one, so there is nothing to gate @@ -142,6 +149,10 @@ const ZOOM_STEP = 25; const ZOOM_MIN = 25; const ZOOM_MAX = 400; +// How long a slideshow stays on a photo, counted from when it has loaded: +// a slow node must not skip a photo nobody saw. +const SLIDESHOW_MS = 5000; + function Lightbox({ photos, index, transportRef, gekRef, onClose, onNav }) { const entry = photos[index]; const tRef = entry._tRef || transportRef; @@ -155,7 +166,9 @@ function Lightbox({ photos, index, transportRef, gekRef, onClose, onNav }) { // same way the browser renders the <img> itself. const [zoomPercent, setZoomPercent] = useState(null); const [naturalSize, setNaturalSize] = useState(null); + const [slideshow, setSlideshow] = useState(false); const slotRef = useRef(null); + const castDevice = useCastDevice(); useEffect(() => { const cached = _fullBlobCache.get(entry.id); @@ -171,8 +184,10 @@ function Lightbox({ photos, index, transportRef, gekRef, onClose, onNav }) { const chunks = await pipelinedDownload( transport, gRef.current, entry.id, totalChunks); if (cancelled) return; - const url = URL.createObjectURL(new Blob(chunks)); + const blob = new Blob(chunks); + const url = URL.createObjectURL(blob); _fullBlobCache.set(entry.id, url); + _fullBlobs.set(entry.id, blob); setBlobUrl(url); } catch { /* leave the placeholder — a transient fetch failure isn't fatal, next/close still work */ @@ -187,6 +202,23 @@ function Lightbox({ photos, index, transportRef, gekRef, onClose, onNav }) { // level chosen for one picture saying nothing about the next. useEffect(() => { setZoomPercent(null); setNaturalSize(null); }, [entry.id]); + // The television follows the lightbox, from the moment one is chosen. + useEffect(() => { + const blob = _fullBlobs.get(entry.id); + if (castDevice && blobUrl && blob) showPhoto(blob); + }, [castDevice, blobUrl, entry.id]); + + // Next photo once this one has been on screen for SLIDESHOW_MS; the last + // photo ends the slideshow rather than wrapping round. + useEffect(() => { + if (!slideshow || !blobUrl) return undefined; + const id = setTimeout(() => { + if (index < photos.length - 1) onNav(1); + else setSlideshow(false); + }, SLIDESHOW_MS); + return () => clearTimeout(id); + }, [slideshow, blobUrl, index, photos.length, onNav]); + useEffect(() => { const onKey = (e) => { if (e.key === 'Escape') onClose(); @@ -245,6 +277,13 @@ function Lightbox({ photos, index, transportRef, gekRef, onClose, onNav }) { <button class="photo-icon-btn photo-icon-btn-text ${zoomPercent === 100 ? 'active' : ''}" disabled=${!blobUrl} onClick=${zoomActual} title=${t('photo.zoom_100')}>100%</button> </div> + <button class="video-close ${slideshow ? 'cast-active' : ''}" + disabled=${photos.length < 2} + onClick=${() => setSlideshow(!slideshow)} + title=${slideshow ? t('photo.slideshow_stop') : t('photo.slideshow')} + aria-pressed=${slideshow ? 'true' : 'false'}> + <${Icon} name=${slideshow ? 'pause' : 'play'} /></button> + <${CastButton} variant="overlay" cls="video-close" /> <button class="video-close" onClick=${onClose} title=${t('video.close')}> <${Icon} name="close" /></button> </div> @@ -300,8 +339,14 @@ function AlbumView({ album, entries, transportRef, gekRef, setError, onBack, rea ${year && html`<span class="photo-album-heading-year">${year}</span>`} </div> </div> - ${!readOnly && html`<button class="photo-icon-btn" onClick=${zip} title=${t('photo.zip_album')}> - <${Icon} name="archive" cls="photo-icon-btn-icon" /></button>`} + <div class="photo-album-actions"> + ${/* Choosing a television from here shows the album on it: the first + photo opens, since nothing is on screen yet to send. */''} + <${CastButton} cls="photo-icon-btn" iconCls="photo-icon-btn-icon" + onChosen=${() => setLightboxIndex((i) => (i === null ? 0 : i))} /> + ${!readOnly && html`<button class="photo-icon-btn" onClick=${zip} title=${t('photo.zip_album')}> + <${Icon} name="archive" cls="photo-icon-btn-icon" /></button>`} + </div> </div> <div class="photo-grid"> ${album.photos.map((e, i) => html` @@ -353,17 +398,19 @@ function PhotosApp({ <p class="page-message">${t('photo.no_roots_configured')}</p> `} ${status === 'connected' && (photoDirectories || []).length > 0 && !openAlbum && html` - ${/* The filter is the only thing in it, so under `hideFilter` there is - no toolbar rather than an empty one — an empty band still pins, - and would hold a strip of the page open under the search field - for nothing. */ - !hideFilter && html` + ${/* The filter and the cast button are all it holds, so with neither — + Search hides the filter, a browser cannot cast — there is no + toolbar rather than an empty one: an empty band still pins, and + would hold a strip of the page open under the search field for + nothing. */ + (!hideFilter || castSession.supported) && html` <div class="photo-toolbar"> - <div class="tb-search"> + <${CastButton} /> + ${!hideFilter && html`<div class="tb-search"> <${Icon} name="search" /> <input type="text" placeholder="${t('group.filter')}" value=${filter} onInput=${(e) => setFilter(e.target.value)} /> - </div> + </div>`} </div> `} ${filteredAlbums.length === 0 && html` diff --git a/packages/meshbay-hub/src/meshbay_hub/static/platform.js b/packages/meshbay-hub/src/meshbay_hub/static/platform.js index 0174edc..3a0c628 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/platform.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/platform.js @@ -460,6 +460,15 @@ export const cast = { if (!bridge || !bridge.cast || !bridge.cast.subtitle) return null; return bridge.cast.subtitle(sub); }, + // A photo for the receiver: `{ bytes, type }`, already scaled to the + // television. Absent from an older shell, so callers check `canShowImages`. + get canShowImages() { + return Boolean(bridge && bridge.cast && bridge.cast.image); + }, + async image(img) { + if (!this.canShowImages) return null; + return bridge.cast.image(img); + }, async finish() { if (!bridge || !bridge.cast) return false; return bridge.cast.finish(); diff --git a/packages/meshbay-hub/src/meshbay_hub/static/style.css b/packages/meshbay-hub/src/meshbay_hub/static/style.css index e1e152e..9be414f 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/style.css +++ b/packages/meshbay-hub/src/meshbay_hub/static/style.css @@ -2043,7 +2043,21 @@ button:disabled { opacity: 0.5; cursor: not-allowed; } cursor: text; outline: none; } .cast-url-input:focus { border-color: rgba(59, 130, 246, 0.6); } -.cast-wrapper { display: inline-block; } +.cast-wrapper { display: inline-block; position: relative; } +.video-close:disabled { opacity: 0.4; cursor: default; } +.video-close.cast-btn.casting { background: rgba(59, 130, 246, 0.35); } +.video-close.cast-btn.casting:hover { background: rgba(59, 130, 246, 0.5); } +.cast-picker-item.cast-menu-current { color: #94a3b8; cursor: default; } +.cast-picker-item.cast-menu-current:hover { background: none; } + +/* The cast button in a page's toolbar (cast-session.js): the same panel as + any other menu there, hung under the button rather than at a point. */ +.cast-btn.casting { color: var(--accent); border-color: var(--accent); } +.ctx-menu.cast-menu { position: absolute; top: calc(100% + 4px); right: 0; z-index: 150; } +.ctx-menu-item.cast-menu-current { color: var(--text-dim); cursor: default; } +.ctx-menu-item.cast-menu-current:hover { background: none; } +.ctx-menu-item svg { width: 16px; height: 16px; flex-shrink: 0; } +.photo-album-actions { display: flex; align-items: center; gap: 4px; } .cast-picker { position: absolute; top: calc(100% + 4px); right: 0; z-index: 100; background: rgba(28, 28, 32, 0.97); border: 1px solid rgba(255, 255, 255, 0.14); diff --git a/packages/meshbay-hub/src/meshbay_hub/static/video-app.js b/packages/meshbay-hub/src/meshbay_hub/static/video-app.js index 4757395..aa1edf2 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/video-app.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/video-app.js @@ -7,6 +7,7 @@ import { formatSize } from './file-utils.js'; import { SourceTag } from './group-name.js'; import { usePager, Pager, pageSizeFrom } from './pager.js'; import { LazyTile, MediaThumb } from './media-tiles.js'; +import { CastButton } from './cast-session.js'; // ── Videos ─────────────────────────────────────────────────────────────────── // @@ -1083,6 +1084,8 @@ function VideoApp({ </button> </div> <${Pager} pager=${pager} /> + ${/* The television a film opened from here plays on (cast-session.js). */''} + <${CastButton} /> ${!hideFilter && html`<div class="tb-search"> <${Icon} name="search" /> <input type="text" placeholder="${t('group.filter')}" 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 2c424fb..ed1f18c 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/video-player.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/video-player.js @@ -6,6 +6,7 @@ import { Icon } from './icon.js'; import { formatSize, CHUNK_SIZE, pipelinedDownload } from './file-utils.js'; import { loadAuth } from './hub-client.js'; import * as platform from './platform.js'; +import { castSession } from './cast-session.js'; // Seconds of already-watched video kept in the SourceBuffer, and the queue depth // past which we start making room before being forced to. @@ -484,17 +485,22 @@ function VideoPlayer({ entry, transportRef, gekRef, onClose, onDownload }) { // Where the stream the node is sending begins, in film time. The same number // the SourceBuffer gets as its `timestampOffset`. const streamStartRef = useRef(0); - const [castActive, setCastActive] = useState(false); + // A television already chosen this session (cast-session.js) takes the film + // from its first segment: the cast starts as a restart would, pending until + // the init segment arrives, and the player opens as its remote. Read once — + // a television chosen while this film plays is chosen here, by the picker. + const [autoCast] = useState(() => (castSession.supported ? castSession.device : null)); + const [castActive, setCastActive] = useState(Boolean(autoCast)); const [castUrl, setCastUrl] = useState(null); const [castPickerOpen, setCastPickerOpen] = useState(false); const [castDevices, setCastDevices] = useState([]); const [castScanning, setCastScanning] = useState(false); - const [castDeviceName, setCastDeviceName] = useState(null); - const castActiveRef = useRef(false); + const [castDeviceName, setCastDeviceName] = useState(autoCast ? autoCast.name : null); + const castActiveRef = useRef(Boolean(autoCast)); const castCodecRef = useRef(null); const initSegmentRef = useRef(null); - const castRestartPendingRef = useRef(false); - const castDeviceRef = useRef(null); + const castRestartPendingRef = useRef(Boolean(autoCast)); + const castDeviceRef = useRef(autoCast); 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. @@ -1758,7 +1764,10 @@ function VideoPlayer({ entry, transportRef, gekRef, onClose, onDownload }) { return () => { stopped = true; clearInterval(id); }; }, [remoteMode]); + // Stopping here is stopping for the session: the next film or photo stays + // on this device until a television is chosen again. const stopCast = async () => { + castSession.forget(); await platform.cast.chromecastDisconnect().catch(() => {}); await platform.cast.stop(); setCastActive(false); castActiveRef.current = false; @@ -1896,6 +1905,7 @@ function VideoPlayer({ entry, transportRef, gekRef, onClose, onDownload }) { ${castDevices.map(d => html` <button class="cast-picker-item" onClick=${() => { setCastPickerOpen(false); + castSession.choose(d); setCastDeviceName(d.name); setCastActive(true); castActiveRef.current = true; castRestartPendingRef.current = true; |