diff options
Diffstat (limited to 'packages/meshbay-hub/src/meshbay_hub/static')
18 files changed, 649 insertions, 29 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..9f93919 --- /dev/null +++ b/packages/meshbay-hub/src/meshbay_hub/static/cast-session.js @@ -0,0 +1,220 @@ +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 one film, one +// picture or one track at a time and nothing else; the interface itself stays +// on this device. `owner` says which of the three the television is showing +// now ('video', 'photo', 'music'), so that the music bar stops treating the +// receiver as its own once a film or a photo has taken it. + +let current = null; +let owner = null; +const listeners = new Set(); + +function notify() { + for (const fn of listeners) fn(current); +} + +export const castSession = { + get device() { return current; }, + get owner() { return owner; }, + get supported() { return Boolean(platform.capabilities.lanCast && platform.cast.available); }, + /** Whether a whole file (a photo, a track) can be sent: an older shell cannot. */ + get canSendFiles() { return this.supported && platform.cast.canSendFiles; }, + /** The television now shows what `who` sends it. */ + claim(who) { + owner = who; + notify(); + }, + 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() { + owner = null; + 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 file on the television ───────────────────────────────────────────────── +// +// A photo or a music track, whole, handed to the relay and loaded on the +// session's television — connected if it is not yet, reloaded if it is. The +// relay, not the page, tells the receiver what the file is; the page only +// names it (title, artist, album, cover) and says where in it to begin. + +export async function loadFile({ owner: who, startAt = 0, ...file }) { + const device = current; + if (!device || !castSession.canSendFiles) throw new Error('No television to cast to'); + castSession.claim(who); + const sent = await platform.cast.file(file); + if (!sent) throw new Error('The cast relay did not take the file'); + const status = await platform.cast.status(); + if (status && status.chromecast && status.chromecast.connected) { + await platform.cast.chromecastReload({ mediaUrl: sent.url, startAt }); + } else { + await platform.cast.chromecastConnect({ deviceId: device.id, mediaUrl: sent.url, startAt }); + } +} + +// ── 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 () => { + if (gen !== photoGen || !current || !castSession.canSendFiles) return; + const bytes = await scaledForTv(blob); + if (gen !== photoGen) return; + await loadFile({ owner: 'photo', bytes, type: 'image/jpeg' }); + }).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. `up` opens the menu above the +// button, for one at the bottom of the window. + +export function CastButton({ + variant = 'page', cls = 'tb-btn tb-btn-icon', iconCls = '', onChosen, up = false, +}) { + 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'}${up ? ' cast-menu-up' : ''}`; + 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/music-app.js b/packages/meshbay-hub/src/meshbay_hub/static/music-app.js index 67be344..f2c4374 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/music-app.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/music-app.js @@ -10,6 +10,7 @@ import { usePager, Pager, pageSizeFrom } from './pager.js'; import { Menu, MenuDots, useMenu } from './menu.js'; import { PlaylistMenuButton, NameModal, usePlaylists } from './playlist-menu.js'; import * as P from './playlists.js'; +import { CastButton } from './cast-session.js'; // -- Music -------------------------------------------------------------------- // @@ -747,6 +748,8 @@ function MusicApp({ lists=${playlists} reload=${reloadPlaylists} onPlayQueue=${onPlayQueue} onSync=${syncNow} />`} <${Pager} pager=${pager} /> + ${/* The television the music bar plays to (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/music-player.js b/packages/meshbay-hub/src/meshbay_hub/static/music-player.js index e8124fa..4587990 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/music-player.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/music-player.js @@ -6,6 +6,8 @@ import { t } from './i18n.js'; import { Icon } from './icon.js'; import { useStickyBand } from './sticky.js'; import { CHUNK_SIZE, pipelinedDownload } from './file-utils.js'; +import { playback, cast as castBridge } from './platform.js'; +import { castSession, useCastDevice, loadFile, CastButton } from './cast-session.js'; /** * The Music app's persistent player bar (docs/MESHBAY_DESIGN.md §9.8). @@ -29,6 +31,14 @@ import { CHUNK_SIZE, pipelinedDownload } from './file-utils.js'; * through `transport.requestAudioTranscode` first — a node-side, * cached-after-once AAC/M4A conversion — before the same download/blob path * runs. + * + * With a television chosen (cast-session.js), the same decrypted track is + * handed to the cast relay instead of the <audio> element, with its cover, + * and the bar becomes the receiver's remote: its play, pause, seek, previous + * and next drive the television, its clock is the television's, and the end + * of a track there moves the queue on as `ended` does here (docs/MESHBAY_DESIGN.md + * §11.4). The element keeps the track's source for its duration, and never + * plays while casting. */ const MIME_BY_EXT = { @@ -217,6 +227,13 @@ function MusicPlayerBar({ getConnection, queue, onClose, userPrefs, onSaveQueue // one of them being off is a property of that group rather than of each of // its tracks in turn — see advancePastFailure below (docs/playlists.md §11.3). const downGroupsRef = useRef(new Set()); + // When the page last came back from hidden. A phone with its screen off + // drops every connection at once, so a track that fails for lack of one + // while the page sleeps, or just after it wakes, says nothing about its + // group: it is retried rather than skipped (see the load effect below). + const wokeAtRef = useRef(0); + const [retryNonce, setRetryNonce] = useState(0); + const WAKE_GRACE_MS = 30000; const currentTrack = tracks[order[pos]] || null; @@ -272,6 +289,23 @@ function MusicPlayerBar({ getConnection, queue, onClose, userPrefs, onSaveQueue for (const { url } of blobCacheRef.current.values()) URL.revokeObjectURL(url); }, []); + // Held for as long as a track plays or loads: without it a phone freezes the + // page a minute after the screen goes off, and the next track never loads. + // Let go a few seconds late, because skipping a track that failed passes + // through "not playing", and Android refuses to take the hold back from the + // background: a playlist would stop at its first bad file. + const keepAliveRef = useRef({ held: false, timer: null }); + const active = playing || loading; + useEffect(() => { + const k = keepAliveRef.current; + if (!active) return; + clearTimeout(k.timer); + if (!k.held) { k.held = true; playback.keepAlive(true); } + return () => { + k.timer = setTimeout(() => { k.held = false; playback.keepAlive(false); }, 5000); + }; + }, [active]); + // Screen Wake Lock, opt-in only (Settings → music_keep_screen_on) and only // while a track is actually playing — off by default because the ordinary // expectation, matching Spotify/Deezer, is that the phone locks on its own @@ -326,6 +360,64 @@ function MusicPlayerBar({ getConnection, queue, onClose, userPrefs, onSaveQueue } }, []); + // ── Casting ────────────────────────────────────────────────────────────── + const castDevice = useCastDevice(); + const remote = Boolean(castDevice) && castSession.canSendFiles; + const remoteRef = useRef(remote); + // Which track the television was last given, where it was when last + // polled, and which track's end has already moved the queue on: the + // receiver keeps reporting FINISHED until it is given something else. + const remoteTrackRef = useRef(null); + const remotePosRef = useRef(null); + const finishedRef = useRef(null); + // True while a track is on its way to the television: the receiver's + // status still describes the previous one until it lands. + const sendingRef = useRef(false); + + // The track's cover, for the television to show beside it — found as the + // album card finds it (music-app.js): this track's own, else another queued + // track of the same album that carries one, else the cover the node found + // for the album. The node answers that last question from its cache, and + // asks MusicBrainz only where the group has it on and the track is tagged. + // A cover that cannot be had is no reason not to play the track. + const fetchCover = useCallback(async (entry) => { + try { + const { transport, gek } = await getConnection(entry.groupId); + if (!transport || !transport.connected) return null; + const sibling = entry.album && tracks.find((tr) => tr.thumb_hash + && tr.groupId === entry.groupId && tr.album === entry.album && tr.artist === entry.artist); + let hash = entry.thumb_hash || (sibling && sibling.thumb_hash) || null; + if (!hash && transport.fetchMusicMeta) { + const meta = await transport.fetchMusicMeta(entry.id).catch(() => null); + hash = (meta && meta.cover_thumb_hash) || null; + } + if (!hash) return null; + const chunks = await pipelinedDownload(transport, gek, hash, 1); + return { bytes: new Uint8Array(await new Blob(chunks).arrayBuffer()), type: 'image/jpeg' }; + } catch { + return null; + } + }, [getConnection, tracks]); + + const sendToTv = useCallback(async (entry, startAt = 0) => { + const cached = blobCacheRef.current.get(entry.id); + if (!cached) throw new Error(t('music.err_playback')); + sendingRef.current = true; + try { + const cover = await fetchCover(entry); + remoteTrackRef.current = entry.id; + remotePosRef.current = startAt; + finishedRef.current = null; + await loadFile({ + owner: 'music', startAt, + bytes: new Uint8Array(await cached.blob.arrayBuffer()), type: cached.mime, cover, + title: entry.display_title || entry.name, artist: entry.artist || '', album: entry.album || '', + }); + } finally { + sendingRef.current = false; + } + }, [fetchCover]); + const fetchTrackBlob = useCallback(async (entry) => { const cached = blobCacheRef.current.get(entry.id); if (cached) return cached.url; @@ -345,7 +437,11 @@ function MusicPlayerBar({ getConnection, queue, onClose, userPrefs, onSaveQueue } if (!transport) throw groupDown(); if (!transport.connected) await transport.waitForReconnect(); - if (!transport.connected) throw groupDown(); + if (!transport.connected) { + const e = groupDown(); + e.transport = transport; + throw e; + } let downloadId = entry.id; let downloadSize = entry.size; @@ -366,7 +462,8 @@ function MusicPlayerBar({ getConnection, queue, onClose, userPrefs, onSaveQueue const chunks = await pipelinedDownload(transport, gek, downloadId, totalChunks); const blob = new Blob(chunks, { type: mime }); const url = URL.createObjectURL(blob); - blobCacheRef.current.set(entry.id, { url, order: blobInsertRef.current++ }); + // The blob itself too: a cast hands its bytes to the relay. + blobCacheRef.current.set(entry.id, { url, blob, mime, order: blobInsertRef.current++ }); evictOldBlobs(); return url; }, [getConnection, evictOldBlobs]); @@ -441,8 +538,46 @@ function MusicPlayerBar({ getConnection, queue, onClose, userPrefs, onSaveQueue // effect above (pos/order/tracks all just changed together) and also // after skipNext/skipPrev/onEnded update `pos` alone. useEffect(() => { + const onVisibility = () => { + if (document.visibilityState === 'visible') wokeAtRef.current = Date.now(); + }; + document.addEventListener('visibilitychange', onVisibility); + return () => document.removeEventListener('visibilitychange', onVisibility); + }, []); + + const asleepOrJustWoke = () => document.visibilityState === 'hidden' + || Date.now() - wokeAtRef.current < WAKE_GRACE_MS; + + // Retries the current track once the page is back and a connection may be: + // the screen coming on, the network coming back, or the track's own + // transport finishing its reconnect, whichever comes first. A visible page + // also retries on a timer, so a failure that outlives the grace window + // ends on the ordinary error path instead of a spinner nobody can clear. + const retryWhenAwake = (myToken, transport) => { + let stop = () => {}; + let timer = null; + const fire = () => { + stop(); + if (loadTokenRef.current === myToken) setRetryNonce((n) => n + 1); + }; + const onVisibility = () => { if (document.visibilityState === 'visible') fire(); }; + document.addEventListener('visibilitychange', onVisibility); + window.addEventListener('online', fire); + const unsubscribe = transport ? transport.addReconnectListener(fire) : () => {}; + if (document.visibilityState === 'visible') timer = setTimeout(fire, 10000); + stop = () => { + clearTimeout(timer); + document.removeEventListener('visibilitychange', onVisibility); + window.removeEventListener('online', fire); + unsubscribe(); + }; + if (transport && transport.connected) fire(); + }; + + useEffect(() => { if (!currentTrack) return; const myToken = ++loadTokenRef.current; + let waiting = false; setLoading(true); setError(''); (async () => { @@ -453,23 +588,53 @@ function MusicPlayerBar({ getConnection, queue, onClose, userPrefs, onSaveQueue if (!audio) return; audio.src = url; audio.currentTime = 0; + if (remoteRef.current) { + // To the television; the element keeps the source for its duration. + audio.pause(); + setCurrentTime(0); + try { + await sendToTv(currentTrack, 0); + } catch (castErr) { + castErr.isCast = true; + throw castErr; + } + if (loadTokenRef.current !== myToken) return; + consecutiveFailuresRef.current = 0; + setPlaying(true); + prefetchNext(pos); + return; + } await audio.play(); consecutiveFailuresRef.current = 0; setPlaying(true); prefetchNext(pos); } catch (err) { if (loadTokenRef.current !== myToken) return; + // Autoplay refused (no recent tap): the track is loaded and fine, it + // waits for the play button. Not an error, and no reason to skip it. + if (err && err.name === 'NotAllowedError') { setPlaying(false); prefetchNext(pos); return; } + // The television did not take it: that says nothing about the track, + // so the queue stays where it is, with the reason on screen. + if (err && err.isCast) { setError(err.message || String(err)); setPlaying(false); return; } + if (err && err.isGroupDown && asleepOrJustWoke()) { + // Our own connection is what went, not the group: keep the spinner + // and this track, and try it again once we are back. + waiting = true; + setPlaying(false); + retryWhenAwake(myToken, err.transport); + return; + } setError(err.message || String(err)); setPlaying(false); // `fetchTrackBlob` throws `err_transport` when no node answered for // this track's group; anything else is about the file itself. advancePastFailure(err.isGroupDown ? currentTrack.groupId : null); } finally { - if (loadTokenRef.current === myToken) setLoading(false); + if (loadTokenRef.current === myToken && !waiting) setLoading(false); } })(); // eslint-disable-next-line - }, [currentTrack && currentTrack.id]); + }, [currentTrack && currentTrack.id, retryNonce]); // Belt and suspenders alongside the catch block above: `.play()` rejecting // is the common path for a source the browser can't decode at all, but a @@ -479,6 +644,9 @@ function MusicPlayerBar({ getConnection, queue, onClose, userPrefs, onSaveQueue // stream). Both paths converge on the same bounded advance, so whichever // fires first, the queue moves on exactly once. const handleMediaError = useCallback(() => { + // Casting, the element only holds the source for its duration: whether it + // could decode it says nothing about what the television is playing. + if (remoteRef.current) return; const audio = audioRef.current; const mediaError = audio && audio.error; setError((mediaError && mediaError.message) || t('music.err_playback')); @@ -505,27 +673,111 @@ function MusicPlayerBar({ getConnection, queue, onClose, userPrefs, onSaveQueue if (order.length === 0) return; // A few seconds in: restart the current track, the way most players do, // rather than always jumping to the previous one. + if (remoteRef.current && castSession.owner === 'music' && (remotePosRef.current || 0) > 3) { + castBridge.chromecastSeek(0).catch(() => {}); + remotePosRef.current = 0; + setCurrentTime(0); + return; + } const audio = audioRef.current; - if (audio && audio.currentTime > 3) { audio.currentTime = 0; return; } + if (!remoteRef.current && audio && audio.currentTime > 3) { audio.currentTime = 0; return; } if (pos > 0) { skipTo(pos - 1); return; } if (repeat === 'all') { skipTo(order.length - 1); } }, [pos, order.length, repeat, skipTo]); const onEnded = useCallback(() => { if (repeat === 'one') { + if (remoteRef.current && currentTrack) { + sendToTv(currentTrack, 0).catch((e) => { setError(e.message || String(e)); setPlaying(false); }); + return; + } const audio = audioRef.current; if (audio) { audio.currentTime = 0; audio.play().catch(() => {}); } return; } skipNext(); - }, [repeat, skipNext]); + }, [repeat, skipNext, currentTrack, sendToTv]); + const onEndedRef = useRef(onEnded); + onEndedRef.current = onEnded; const togglePlaying = useCallback(() => { + if (remoteRef.current && currentTrack) { + // A film or a photo has the television: playing again gives it back + // the track, from where it was. + if (castSession.owner !== 'music') { + sendToTv(currentTrack, remotePosRef.current || 0) + .then(() => { setError(''); setPlaying(true); }) + .catch((e) => { setError(e.message || String(e)); setPlaying(false); }); + return; + } + (playing ? castBridge.chromecastPause() : castBridge.chromecastPlay()) + .then(() => setPlaying(!playing)).catch(() => {}); + return; + } const audio = audioRef.current; if (!audio) return; if (playing) { audio.pause(); setPlaying(false); } - else { audio.play().then(() => setPlaying(true)).catch(() => {}); } - }, [playing]); + else { audio.play().then(() => { setError(''); setPlaying(true); }).catch(() => {}); } + }, [playing, currentTrack, sendToTv]); + + // A television chosen, or let go, in the middle of a track: the track goes + // on from where it was, on the other side. + useEffect(() => { + const was = remoteRef.current; + remoteRef.current = remote; + if (was === remote || !currentTrack) return; + const audio = audioRef.current; + if (remote) { + const at = audio ? audio.currentTime : 0; + const wasPlaying = playing; + if (audio) audio.pause(); + // Still loading: the load effect sends it once it has it. + if (!blobCacheRef.current.has(currentTrack.id)) return; + sendToTv(currentTrack, at) + .then(async () => { + if (!wasPlaying) await castBridge.chromecastPause().catch(() => {}); + setPlaying(wasPlaying); + }) + .catch((e) => { setError(e.message || String(e)); setPlaying(false); }); + } else { + const at = remotePosRef.current; + remoteTrackRef.current = null; + if (audio && at != null && isFinite(at)) audio.currentTime = at; + if (playing && audio) audio.play().catch(() => setPlaying(false)); + } + // eslint-disable-next-line + }, [remote]); + + // The television's clock and state, once a second, for as long as the + // track there is ours. + useEffect(() => { + if (!remote) return undefined; + let stopped = false; + const tick = async () => { + if (sendingRef.current || castSession.owner !== 'music' || !remoteTrackRef.current) return; + const s = await castBridge.status().catch(() => null); + const c = s && s.chromecast; + if (stopped || sendingRef.current || castSession.owner !== 'music' || !c) return; + if (!c.connected) { setPlaying(false); return; } + if (c.position != null) { remotePosRef.current = c.position; setCurrentTime(c.position); } + const state = c.playerState; + if (state === 'PLAYING' || state === 'BUFFERING') setPlaying(true); + else if (state === 'PAUSED') setPlaying(false); + else if (state === 'IDLE' && String(c.idleReason || '').toLowerCase() === 'finished' + && finishedRef.current !== remoteTrackRef.current) { + finishedRef.current = remoteTrackRef.current; + onEndedRef.current(); + } + }; + const id = setInterval(tick, 1000); + tick(); + // A film or a photo took the television: the track there has stopped. + // Not when casting stops altogether — the track then carries on here. + const unsubscribe = castSession.subscribe(() => { + if (castSession.device && castSession.owner !== 'music') setPlaying(false); + }); + return () => { stopped = true; clearInterval(id); unsubscribe(); }; + }, [remote]); // Reshuffling keeps the currently playing track in place — turning shuffle // on mid-album must not interrupt what's already playing. Which track that @@ -546,19 +798,36 @@ function MusicPlayerBar({ getConnection, queue, onClose, userPrefs, onSaveQueue }, []); const seek = useCallback((e) => { + const to = parseFloat(e.target.value); + if (remoteRef.current) { + if (castSession.owner !== 'music') return; + castBridge.chromecastSeek(to).catch(() => {}); + remotePosRef.current = to; + setCurrentTime(to); + return; + } const audio = audioRef.current; - if (audio && isFinite(audio.duration)) audio.currentTime = parseFloat(e.target.value); + if (audio && isFinite(audio.duration)) audio.currentTime = to; }, []); const handleClose = useCallback(() => { const audio = audioRef.current; if (audio) audio.pause(); + // The television stops with the bar; it stays chosen for what comes next. + if (remoteRef.current && castSession.owner === 'music') { + castSession.claim(null); + castBridge.chromecastDisconnect().catch(() => {}); + castBridge.stop().catch(() => {}); + } if (onClose) onClose(); }, [onClose]); if (!currentTrack) return null; const title = currentTrack.display_title || currentTrack.name; + // The element's, once it has read the file; the index's until then — and + // while casting, a file this device cannot decode still has a length. + const shownDuration = (isFinite(duration) && duration > 0) ? duration : (currentTrack.duration || 0); const repeatLabel = repeat === 'off' ? t('music.player_repeat_off') : repeat === 'all' ? t('music.player_repeat_all') : t('music.player_repeat_one'); @@ -600,17 +869,18 @@ function MusicPlayerBar({ getConnection, queue, onClose, userPrefs, onSaveQueue </div> <div class="music-player-seek"> <span class="music-player-time">${formatTime(currentTime)}</span> - <input type="range" min="0" max=${duration || 0} step="1" value=${currentTime} - disabled=${!duration} onInput=${seek} /> - <span class="music-player-time">${formatTime(duration)}</span> + <input type="range" min="0" max=${shownDuration} step="1" value=${currentTime} + disabled=${!shownDuration} onInput=${seek} /> + <span class="music-player-time">${formatTime(shownDuration)}</span> </div> <div class="music-player-volume"> <${Icon} name="volume" /> <input type="range" min="0" max="1" step="0.05" value=${volume} - title=${t('music.player_volume')} + title=${t('music.player_volume')} disabled=${remote} onInput=${(e) => setVolume(parseFloat(e.target.value))} /> </div> <div class="music-player-extra"> + <${CastButton} cls="music-player-btn" up /> <button class="music-player-btn" title=${t('music.player_queue')} onClick=${() => setShowQueue(true)}> <${Icon} name="menu" /> </button> 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 c131495..28817c7 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/platform.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/platform.js @@ -204,6 +204,16 @@ export const folder = { async forget() { return bridge && bridge.folder ? bridge.folder.forget() : false; }, }; +/** + * Music is playing: the shell keeps what playback needs awake (on a phone, the + * page itself with the screen off). A no-op in a browser. + */ +export const playback = { + keepAlive(on) { + if (bridge && bridge.playback) bridge.playback.keepAlive(on).catch(() => {}); + }, +}; + /** Pick a directory to add as a group root. Returns { path, name } or null. */ export const rootPicker = { available: Boolean(bridge && bridge.rootPicker), @@ -450,6 +460,17 @@ export const cast = { if (!bridge || !bridge.cast || !bridge.cast.subtitle) return null; return bridge.cast.subtitle(sub); }, + // A whole file for the receiver — a photo already scaled to the television, + // a music track — as `{ bytes, type, cover?: { bytes, type }, title?, + // artist?, album? }`; answers `{ url }` to load. Absent from an older + // shell, so callers check `canSendFiles`. + get canSendFiles() { + return Boolean(bridge && bridge.cast && bridge.cast.file); + }, + async file(f) { + if (!this.canSendFiles) return null; + return bridge.cast.file(f); + }, async finish() { if (!bridge || !bridge.cast) return false; return bridge.cast.finish(); @@ -510,6 +531,15 @@ export const cast = { if (!this.canControl) return null; return bridge.cast.chromecastPlay(); }, + // Seeking inside a file the receiver plays (a track); a film seeks by + // restarting the relay instead. + get canSeek() { + return Boolean(bridge && bridge.cast && bridge.cast.chromecastSeek); + }, + async chromecastSeek(seconds) { + if (!this.canSeek) return null; + return bridge.cast.chromecastSeek(seconds); + }, }; /** diff --git a/packages/meshbay-hub/src/meshbay_hub/static/style.css b/packages/meshbay-hub/src/meshbay_hub/static/style.css index e1e152e..9383043 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/style.css +++ b/packages/meshbay-hub/src/meshbay_hub/static/style.css @@ -2043,7 +2043,23 @@ 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; } +/* From the music bar, at the bottom of the window: the menu opens upward. */ +.cast-menu-up { top: auto !important; bottom: calc(100% + 4px); } +.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..37ea727 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. @@ -1310,6 +1316,7 @@ function VideoPlayer({ entry, transportRef, gekRef, onClose, onDownload }) { }).then(async (result) => { if (castRestartGenRef.current !== gen) return; if (!result) return; + if (device || castSession.device) castSession.claim('video'); setCastUrl(result.url); const status = await platform.cast.status(); if (status && status.chromecast && status.chromecast.connected) { @@ -1758,7 +1765,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 +1906,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; |