diff options
| author | Christophe Besson <cbesson@gmail.com> | 2026-10-05 02:29:27 +0200 |
|---|---|---|
| committer | Christophe Besson <cbesson@gmail.com> | 2026-10-05 02:29:27 +0200 |
| commit | ee9798fe3b88face2fa34f14874770f509c788a5 (patch) | |
| tree | f63e9506882863a885a907796eb977ec69137718 /packages/meshbay-hub/src/meshbay_hub/static/cast-session.js | |
| parent | 243bcf017b4957237b3ddf556bd0a37296eb8798 (diff) | |
| download | meshbay-ee9798fe3b88face2fa34f14874770f509c788a5.tar.gz | |
feat(cast): music on the TV, the music bar as its remote
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 <noreply@anthropic.com>
Diffstat (limited to 'packages/meshbay-hub/src/meshbay_hub/static/cast-session.js')
| -rw-r--r-- | packages/meshbay-hub/src/meshbay_hub/static/cast-session.js | 59 |
1 files changed, 44 insertions, 15 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'); |