aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-hub/src/meshbay_hub/static/cast-session.js
diff options
context:
space:
mode:
authorChristophe Besson <cbesson@gmail.com>2026-10-05 02:29:27 +0200
committerChristophe Besson <cbesson@gmail.com>2026-10-05 02:29:27 +0200
commitee9798fe3b88face2fa34f14874770f509c788a5 (patch)
treef63e9506882863a885a907796eb977ec69137718 /packages/meshbay-hub/src/meshbay_hub/static/cast-session.js
parent243bcf017b4957237b3ddf556bd0a37296eb8798 (diff)
downloadmeshbay-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.js59
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');