From ee9798fe3b88face2fa34f14874770f509c788a5 Mon Sep 17 00:00:00 2001 From: Christophe Besson Date: Mon, 5 Oct 2026 02:29:27 +0200 Subject: feat(cast): music on the TV, the music bar as its remote MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A cast button in Music's toolbar and in the music bar. With a television chosen, each decrypted track goes to the relay with its cover — found as the album card finds it — and plays there as music with its title, artist and album; the bar's play, pause, seek, previous and next drive the receiver, its clock is the receiver's, and the end of a track there moves the queue on. A film or a photo taking the television pauses the bar; stopping the cast carries the track on locally. Photos and tracks now share one path: a whole file sent to the relay in pieces (binary frames on Android, written to disk there), served at /file with byte ranges and its cover at /cover, and loaded as what the relay says it is. cast:image is gone; cast:chromecast:seek is new. Co-Authored-By: Claude Opus 5.5 --- .../src/meshbay_hub/static/cast-session.js | 59 ++++-- .../src/meshbay_hub/static/music-app.js | 3 + .../src/meshbay_hub/static/music-player.js | 212 ++++++++++++++++++++- .../meshbay-hub/src/meshbay_hub/static/platform.js | 25 ++- .../meshbay-hub/src/meshbay_hub/static/style.css | 2 + .../src/meshbay_hub/static/video-player.js | 1 + 6 files changed, 270 insertions(+), 32 deletions(-) (limited to 'packages/meshbay-hub/src') 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`
${formatTime(currentTime)} - - ${formatTime(duration)} + + ${formatTime(shownDuration)}
<${Icon} name="volume" /> setVolume(parseFloat(e.target.value))} />
+ <${CastButton} cls="music-player-btn" up /> 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) { -- cgit v1.2.3