aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-hub/src
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
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')
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/cast-session.js59
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/music-app.js3
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/music-player.js212
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/platform.js25
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/style.css2
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/video-player.js1
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) {