diff options
Diffstat (limited to 'packages/meshbay-hub/src/meshbay_hub')
6 files changed, 270 insertions, 32 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 index 007722c..9f93919 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/cast-session.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/cast-session.js @@ -11,10 +11,14 @@ import * as platform from './platform.js'; // 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. +// 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() { @@ -23,7 +27,15 @@ function notify() { 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(); @@ -32,6 +44,7 @@ export const castSession = { 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(() => {}); @@ -49,6 +62,27 @@ export function useCastDevice() { 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 @@ -87,18 +121,10 @@ let photoChain = Promise.resolve(); export function showPhoto(blob) { const gen = ++photoGen; photoChain = photoChain.then(async () => { - const device = current; - if (gen !== photoGen || !device || !platform.cast.canShowImages) return; + if (gen !== photoGen || !current || !castSession.canSendFiles) 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 }); - } + await loadFile({ owner: 'photo', bytes, type: 'image/jpeg' }); }).catch((err) => console.warn('[cast] photo not shown:', err)); return photoChain; } @@ -111,9 +137,12 @@ export function showPhoto(blob) { // 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. +// 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 }) { +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([]); @@ -143,7 +172,7 @@ export function CastButton({ variant = 'page', cls = 'tb-btn tb-btn-icon', iconC if (!castSession.supported) return null; const overlay = variant === 'overlay'; - const menuCls = overlay ? 'cast-picker' : 'ctx-menu cast-menu'; + 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'); 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 e97ce00..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,7 +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 } from './platform.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). @@ -30,6 +31,14 @@ import { playback } from './platform.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 = { @@ -351,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; @@ -395,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]); @@ -520,6 +588,22 @@ 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); @@ -529,6 +613,9 @@ function MusicPlayerBar({ getConnection, queue, onClose, userPrefs, onSaveQueue // 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. @@ -557,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')); @@ -583,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(() => { setError(''); setPlaying(true); }).catch(() => {}); } - }, [playing]); + }, [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 @@ -624,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'); @@ -678,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/platform.js b/packages/meshbay-hub/src/meshbay_hub/static/platform.js index 3a0c628..28817c7 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/platform.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/platform.js @@ -460,14 +460,16 @@ 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); + // 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 image(img) { - if (!this.canShowImages) return null; - return bridge.cast.image(img); + async file(f) { + if (!this.canSendFiles) return null; + return bridge.cast.file(f); }, async finish() { if (!bridge || !bridge.cast) return false; @@ -529,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 9be414f..9383043 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/style.css +++ b/packages/meshbay-hub/src/meshbay_hub/static/style.css @@ -2054,6 +2054,8 @@ button:disabled { opacity: 0.5; cursor: not-allowed; } 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; } 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 ed1f18c..37ea727 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/video-player.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/video-player.js @@ -1316,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) { |