summaryrefslogtreecommitdiffstats
path: root/packages/meshbay-hub/src
diff options
context:
space:
mode:
authorChristophe Besson <cbesson@gmail.com>2026-08-24 19:46:06 +0200
committerChristophe Besson <cbesson@gmail.com>2026-08-24 19:46:06 +0200
commit62253b9592a83ce152d0c64471e20514218fb132 (patch)
treed41b84661da3252045937f5a90dc45ca7b029edc /packages/meshbay-hub/src
parentd053d083627f1f9f010752f8ad67941e22d49f27 (diff)
downloadmeshbay-62253b9592a83ce152d0c64471e20514218fb132.tar.gz
feat(hub): consolidate loose tracks, "&"/"and" fold, player close/queue
Album-grid readability, part two: - groupMusicEntries (music-app.js): an album bucket left with exactly one track - a real album tag, but only one song from it, not the whole release - clutters the grid the same way an untagged loose track does. Both kinds now fold into one "<artist> - Various" tile per artist, unless there is only one leftover track overall, where relabeling buys nothing and the track keeps its own name (or the generic placeholder, if it never had one). - foldKey also normalizes "&" vs "and" ("Artist & The Band" / "Artist and The Band" is one act, tagged both ways across different rips of the same catalogue) alongside the existing case/whitespace fold. - music-player.js: a close button pauses and tears the player down; an unmount cleanup effect (pause, revoke every cached blob URL) fires either way, whether that's the close button or the shell tearing the bar down on its own. A "current queue" button opens an overlay listing the whole playing queue with the current track highlighted, click any to jump to it - works identically regardless of how the queue was built (an album, the consolidated misc bucket, a single standalone track), since it only ever reads the player's own live tracks/order/pos. - group-page.js: this component is not remounted when switching to a *different* group on the same /group/:id route (only the groupId prop changes) - so without an explicit reset, music from one group would carry into the next one opened. Resets musicQueue to null on groupId change; a tab switch inside one group still leaves it alone. - Scrubbed real artist/band names that had leaked into code comments and test fixtures (enrich_audio.py's docstrings, several test_enrich_audio.py assertions, a music-app.js comment) - replaced with generic placeholders, no behavioural change. - i18n: music.various, music.player_close, music.player_queue, music.queue_title added across all ten locales. Client-side only except none of this touches the node at all. npm run sync-ui re-run. Full suite: 1129 passed, no regressions. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KBi7ALLGfwcjBXt57yNMcy
Diffstat (limited to 'packages/meshbay-hub/src')
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/group-page.js14
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/de.js4
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/en.js4
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/es.js4
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js4
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/it.js4
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js4
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js4
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js4
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js4
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js4
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/music-app.js56
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/music-player.js66
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/style.css3
14 files changed, 161 insertions, 18 deletions
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/group-page.js b/packages/meshbay-hub/src/meshbay_hub/static/group-page.js
index 01ab4e0..d2d56b5 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/group-page.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/group-page.js
@@ -99,6 +99,12 @@ function GroupPage({ groupId, group, token, username, userId, userPrefs,
const onPlayQueue = useCallback((tracks, startIndex) => {
setMusicQueue({ tracks, startIndex, nonce: Date.now() });
}, []);
+ // This component instance is not remounted when switching to a *different*
+ // group on the same /group/:id route (only groupId as a prop changes, see
+ // the connect effect's own comment below) — so leaving music playing here
+ // would carry it into whatever group is opened next. Tab switches inside
+ // one group must not stop it; leaving the group itself must.
+ useEffect(() => { setMusicQueue(null); }, [groupId]);
// Paired ≠ operator account. `is_node_admin` says the hub account owning this
// node is the one connecting; this says the node pinned *this browser's* key
// as an operator key. Only the second one lets you sign an invite, and only
@@ -595,9 +601,13 @@ function GroupPage({ groupId, group, token, username, userId, userPrefs,
once something has been played this session, the bar stays
mounted and keeps playing regardless of which tab is active —
switching to Chat or Files must not stop the music. Renders
- nothing of its own until onPlayQueue has been called once. */
+ nothing of its own until onPlayQueue has been called once.
+ Its own close button, and the effect above on leaving the
+ group, both go through the same setMusicQueue(null) — the
+ bar's own unmount cleanup is what actually stops playback. */
musicQueue && html`
- <${MusicPlayerBar} transportRef=${transportRef} gekRef=${gekRef} queue=${musicQueue} />
+ <${MusicPlayerBar} transportRef=${transportRef} gekRef=${gekRef} queue=${musicQueue}
+ onClose=${() => setMusicQueue(null)} />
`}
</div>
`;
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js
index cb542a6..70d2b95 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js
@@ -182,6 +182,7 @@ export default {
'music.mode_flat': 'Flache Liste',
'music.empty': 'Keine Musik gefunden.',
'music.unknown_album': 'Unbekanntes Album',
+ 'music.various': 'Verschiedenes',
'music.play_all': 'Alle abspielen',
'music.n_tracks': {
one: '{n} Titel',
@@ -197,6 +198,9 @@ export default {
'music.player_repeat_all': 'Alle wiederholen',
'music.player_repeat_one': 'Einzelnen wiederholen',
'music.player_volume': 'Lautstärke',
+ 'music.player_close': 'Player schließen',
+ 'music.player_queue': 'Aktuelle Wiedergabeliste',
+ 'music.queue_title': 'Wird wiedergegeben',
// LAN-Cast
'cast.start': 'Auf Gerät übertragen',
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js
index e05b398..0ee0972 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js
@@ -180,6 +180,7 @@ export default {
'music.mode_flat': 'Flat list',
'music.empty': 'No music found.',
'music.unknown_album': 'Unknown album',
+ 'music.various': 'Various',
'music.play_all': 'Play all',
'music.n_tracks': {
one: '{n} track',
@@ -195,6 +196,9 @@ export default {
'music.player_repeat_all': 'Repeat all',
'music.player_repeat_one': 'Repeat one',
'music.player_volume': 'Volume',
+ 'music.player_close': 'Close player',
+ 'music.player_queue': 'Current queue',
+ 'music.queue_title': 'Playing now',
// LAN cast
'cast.start': 'Cast to device',
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js
index 113ae7a..69e2687 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js
@@ -180,6 +180,7 @@ export default {
'music.mode_flat': 'Lista plana',
'music.empty': 'No se encontró música.',
'music.unknown_album': 'Álbum desconocido',
+ 'music.various': 'Varios',
'music.play_all': 'Reproducir todo',
'music.n_tracks': {
one: '{n} pista',
@@ -195,6 +196,9 @@ export default {
'music.player_repeat_all': 'Repetir todo',
'music.player_repeat_one': 'Repetir una',
'music.player_volume': 'Volumen',
+ 'music.player_close': 'Cerrar reproductor',
+ 'music.player_queue': 'Cola actual',
+ 'music.queue_title': 'Reproduciendo ahora',
// LAN cast
'cast.start': 'Enviar a dispositivo',
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js
index 6a57174..8d52d37 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js
@@ -181,6 +181,7 @@ export default {
'music.mode_flat': 'Liste à plat',
'music.empty': 'Aucune musique trouvée.',
'music.unknown_album': 'Album inconnu',
+ 'music.various': 'Divers',
'music.play_all': 'Tout lire',
'music.n_tracks': {
one: '{n} piste',
@@ -196,6 +197,9 @@ export default {
'music.player_repeat_all': 'Tout répéter',
'music.player_repeat_one': 'Répéter le morceau',
'music.player_volume': 'Volume',
+ 'music.player_close': 'Fermer le lecteur',
+ 'music.player_queue': 'File en cours',
+ 'music.queue_title': 'En cours de lecture',
// LAN cast
'cast.start': 'Diffuser sur un appareil',
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js
index 257318a..dfd4bbc 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js
@@ -181,6 +181,7 @@ export default {
'music.mode_flat': 'Elenco semplice',
'music.empty': 'Nessuna musica trovata.',
'music.unknown_album': 'Album sconosciuto',
+ 'music.various': 'Vari',
'music.play_all': 'Riproduci tutto',
'music.n_tracks': {
one: '{n} traccia',
@@ -196,6 +197,9 @@ export default {
'music.player_repeat_all': 'Ripeti tutto',
'music.player_repeat_one': 'Ripeti brano',
'music.player_volume': 'Volume',
+ 'music.player_close': 'Chiudi lettore',
+ 'music.player_queue': 'Coda attuale',
+ 'music.queue_title': 'In riproduzione',
// LAN cast
'cast.start': 'Trasmetti al dispositivo',
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js
index 44f9037..10d2088 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js
@@ -178,6 +178,7 @@ export default {
'music.mode_flat': 'フラットリスト',
'music.empty': '音楽が見つかりません。',
'music.unknown_album': '不明なアルバム',
+ 'music.various': 'その他',
'music.play_all': 'すべて再生',
'music.n_tracks': {
one: '{n}曲',
@@ -193,6 +194,9 @@ export default {
'music.player_repeat_all': 'すべてリピート',
'music.player_repeat_one': '1曲リピート',
'music.player_volume': '音量',
+ 'music.player_close': 'プレーヤーを閉じる',
+ 'music.player_queue': '再生中のキュー',
+ 'music.queue_title': '再生中',
// LAN cast
'cast.start': 'デバイスにキャスト',
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js
index 2fedd70..3342559 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js
@@ -182,6 +182,7 @@ export default {
'music.mode_flat': 'Platte lijst',
'music.empty': 'Geen muziek gevonden.',
'music.unknown_album': 'Onbekend album',
+ 'music.various': 'Diversen',
'music.play_all': 'Alles afspelen',
'music.n_tracks': {
one: '{n} nummer',
@@ -197,6 +198,9 @@ export default {
'music.player_repeat_all': 'Alles herhalen',
'music.player_repeat_one': 'Nummer herhalen',
'music.player_volume': 'Volume',
+ 'music.player_close': 'Speler sluiten',
+ 'music.player_queue': 'Huidige wachtrij',
+ 'music.queue_title': 'Nu aan het afspelen',
// LAN cast
'cast.start': 'Naar apparaat casten',
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js
index dac2359..1b52a8b 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js
@@ -189,6 +189,7 @@ export default {
'music.mode_flat': 'Lista płaska',
'music.empty': 'Nie znaleziono muzyki.',
'music.unknown_album': 'Nieznany album',
+ 'music.various': 'Różne',
'music.play_all': 'Odtwórz wszystko',
'music.n_tracks': {
one: '{n} utwór',
@@ -206,6 +207,9 @@ export default {
'music.player_repeat_all': 'Powtórz wszystko',
'music.player_repeat_one': 'Powtórz utwór',
'music.player_volume': 'Głośność',
+ 'music.player_close': 'Zamknij odtwarzacz',
+ 'music.player_queue': 'Aktualna kolejka',
+ 'music.queue_title': 'Teraz odtwarzane',
// LAN cast
'cast.start': 'Przesyłaj na urządzenie',
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js
index 4251a4b..1b8c50c 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js
@@ -182,6 +182,7 @@ export default {
'music.mode_flat': 'Lista simples',
'music.empty': 'Nenhuma música encontrada.',
'music.unknown_album': 'Álbum desconhecido',
+ 'music.various': 'Diversos',
'music.play_all': 'Reproduzir tudo',
'music.n_tracks': {
one: '{n} faixa',
@@ -197,6 +198,9 @@ export default {
'music.player_repeat_all': 'Repetir tudo',
'music.player_repeat_one': 'Repetir faixa',
'music.player_volume': 'Volume',
+ 'music.player_close': 'Fechar player',
+ 'music.player_queue': 'Fila atual',
+ 'music.queue_title': 'Tocando agora',
// LAN cast
'cast.start': 'Transmitir para dispositivo',
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js
index 7e7676b..5cc6cb3 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js
@@ -175,6 +175,7 @@ export default {
'music.mode_flat': '平铺列表',
'music.empty': '未找到音乐。',
'music.unknown_album': '未知专辑',
+ 'music.various': '其他',
'music.play_all': '全部播放',
'music.n_tracks': {
one: '{n} 首曲目',
@@ -190,6 +191,9 @@ export default {
'music.player_repeat_all': '全部重复',
'music.player_repeat_one': '单曲重复',
'music.player_volume': '音量',
+ 'music.player_close': '关闭播放器',
+ 'music.player_queue': '当前队列',
+ 'music.queue_title': '正在播放',
// LAN cast
'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 87e1319..7e16b53 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/music-app.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/music-app.js
@@ -36,13 +36,15 @@ function saveViewMode(mode) {
// -- grouping -------------------------------------------------------------
-// Two tags differing only in case ("Racing With The Sun" vs "Racing with
-// the sun" -- found live, a single mistagged track split one real album
-// into two cards) are the same artist/album for grouping purposes. Folded
-// for the *key* only; the first-seen spelling is kept as the display
-// string, so this never rewrites anyone's tags.
+// Two tags differing only in case (found live: a single mistagged track
+// split one real album into two cards) are the same artist/album for
+// grouping purposes. Same for an "&" vs "and" spelling of the same act,
+// tagged both ways across different rips of the same catalogue. Folded for
+// the *key* only; the first-seen spelling is kept as the display string, so
+// this never rewrites anyone's tags.
function foldKey(s) {
- return (s || '').trim().replace(/\s+/g, ' ').toLowerCase();
+ return (s || '').trim().replace(/\s+/g, ' ').toLowerCase()
+ .replace(/\s*&\s*/g, ' and ').replace(/\s+/g, ' ').trim();
}
function groupMusicEntries(entries) {
@@ -74,18 +76,42 @@ function groupMusicEntries(entries) {
tracks.sort(byTitle);
const artists = [...byArtistKey.values()].map(({ artist, albumsByKey, loose }) => {
- const albums = [...albumsByKey.values()].sort((a, b) => a.album.localeCompare(b.album));
- for (const album of albums) {
+ const sortedAlbums = [...albumsByKey.values()].sort((a, b) => a.album.localeCompare(b.album));
+
+ // A "singleton" -- an album bucket down to exactly one track, because
+ // that's a real album tag but this person only has one song from it,
+ // not the whole release -- clutters the grid exactly the way an
+ // untagged loose track does. Found live: one artist's folder listing a
+ // dozen near-empty one-track album cards alongside the genuine
+ // multi-track albums. Both kinds fold into a single "<artist> -
+ // Various" tile, unless there is only one leftover track overall, where
+ // relabeling away a real album name (or minting "Various" for one
+ // file) buys nothing.
+ const realAlbums = [];
+ const misc = [...loose];
+ const miscSourceAlbums = [];
+ for (const album of sortedAlbums) {
+ if (album.tracks.length > 1) { realAlbums.push(album); continue; }
+ misc.push(...album.tracks);
+ miscSourceAlbums.push(album);
+ }
+ for (const album of realAlbums) {
album.tracks.sort((a, b) => (trackNo(a) - trackNo(b)) || byTitle(a, b));
}
- if (loose.length) {
- // Appended after every real album, never sorted in among them by
- // whatever the placeholder text happens to alphabetize to -- it
- // isn't a release, and translating "Unknown album" must not move it.
- loose.sort((a, b) => (trackNo(a) - trackNo(b)) || byTitle(a, b));
- albums.push({ artist, album: t('music.unknown_album'), isUnknown: true, tracks: loose });
+
+ if (misc.length === 1) {
+ // Keep the one leftover's own real album name if it had one; the
+ // generic placeholder only for a single untagged track with nothing
+ // else to call it.
+ realAlbums.push(miscSourceAlbums[0]
+ || { artist, album: t('music.unknown_album'), isUnknown: true, tracks: misc });
+ } else if (misc.length > 1) {
+ misc.sort((a, b) => (trackNo(a) - trackNo(b)) || byTitle(a, b));
+ realAlbums.push({
+ artist, album: `${artist} - ${t('music.various')}`, isUnknown: true, tracks: misc,
+ });
}
- return { artist, albums };
+ return { artist, albums: realAlbums };
}).sort((a, b) => a.artist.localeCompare(b.artist));
const albums = artists.flatMap((a) => a.albums);
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 386d3f6..eac011a 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/music-player.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/music-player.js
@@ -86,7 +86,39 @@ function saveRepeat(v) {
try { localStorage.setItem('meshbay_music_repeat', v); } catch { /* per-device only */ }
}
-function MusicPlayerBar({ transportRef, gekRef, queue }) {
+// The current queue, tracklist form -- "go back to what's playing" without
+// switching tabs or hunting for the album/folder it came from. Works the
+// same regardless of how the queue was built (an album, a consolidated
+// misc/loose bucket, a single standalone track).
+function QueuePanel({ tracks, order, pos, onSelect, onClose }) {
+ return html`
+ <div class="video-overlay" onClick=${(e) => {
+ if (e.target.classList.contains('video-overlay')) onClose();
+ }}>
+ <div class="music-detail">
+ <div class="video-top-bar">
+ <span class="video-title">${t('music.queue_title')}</span>
+ <button class="video-close" onClick=${onClose} title=${t('video.close')}>
+ <${Icon} name="close" /></button>
+ </div>
+ <div class="music-detail-body">
+ <div class="music-tracklist">
+ ${order.map((idx, i) => html`
+ <button class="music-track-row ${i === pos ? 'active' : ''}" key=${tracks[idx].id}
+ onClick=${() => { onSelect(i); onClose(); }}>
+ <span class="music-track-no">${i + 1}</span>
+ <span class="music-track-title">${tracks[idx].display_title || tracks[idx].name}</span>
+ <span class="music-track-duration">${formatTime(tracks[idx].duration || 0)}</span>
+ </button>
+ `)}
+ </div>
+ </div>
+ </div>
+ </div>
+ `;
+}
+
+function MusicPlayerBar({ transportRef, gekRef, queue, onClose }) {
const audioRef = useRef(null);
const blobCacheRef = useRef(new Map()); // file id -> { url, order: insertion index }
const blobInsertRef = useRef(0);
@@ -103,9 +135,23 @@ function MusicPlayerBar({ transportRef, gekRef, queue }) {
const [volume, setVolume] = useState(loadVolume);
const [currentTime, setCurrentTime] = useState(0);
const [duration, setDuration] = useState(0);
+ const [showQueue, setShowQueue] = useState(false);
const currentTrack = tracks[order[pos]] || null;
+ // Stops playback the moment this bar goes away for any reason -- the
+ // close button below, or the shell tearing it down on its own (leaving
+ // the group, switching to a different one). A component removed from the
+ // DOM should already stop an <audio> element, but that's the browser's
+ // behaviour to rely on, not this app's to assert; pausing explicitly, and
+ // releasing every cached blob URL rather than leaving them for the tab's
+ // lifetime, costs nothing and isn't optional either way.
+ useEffect(() => () => {
+ const audio = audioRef.current;
+ if (audio) { audio.pause(); audio.src = ''; }
+ for (const { url } of blobCacheRef.current.values()) URL.revokeObjectURL(url);
+ }, []);
+
const evictOldBlobs = useCallback(() => {
const cache = blobCacheRef.current;
while (cache.size > MAX_CACHED_BLOBS) {
@@ -258,6 +304,12 @@ function MusicPlayerBar({ transportRef, gekRef, queue }) {
if (audio && isFinite(audio.duration)) audio.currentTime = parseFloat(e.target.value);
}, []);
+ const handleClose = useCallback(() => {
+ const audio = audioRef.current;
+ if (audio) audio.pause();
+ if (onClose) onClose();
+ }, [onClose]);
+
if (!currentTrack) return null;
const title = currentTrack.display_title || currentTrack.name;
@@ -312,8 +364,20 @@ function MusicPlayerBar({ transportRef, gekRef, queue }) {
title=${t('music.player_volume')}
onInput=${(e) => setVolume(parseFloat(e.target.value))} />
</div>
+ <div class="music-player-extra">
+ <button class="music-player-btn" title=${t('music.player_queue')} onClick=${() => setShowQueue(true)}>
+ <${Icon} name="menu" />
+ </button>
+ <button class="music-player-btn" title=${t('music.player_close')} onClick=${handleClose}>
+ <${Icon} name="close" />
+ </button>
+ </div>
${error && html`<div class="music-player-error">${error}</div>`}
</div>
+ ${showQueue && html`
+ <${QueuePanel} tracks=${tracks} order=${order} pos=${pos}
+ onSelect=${skipTo} onClose=${() => setShowQueue(false)} />
+ `}
`;
}
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/style.css b/packages/meshbay-hub/src/meshbay_hub/static/style.css
index 3d411f7..59ec919 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/style.css
+++ b/packages/meshbay-hub/src/meshbay_hub/static/style.css
@@ -2821,6 +2821,7 @@ a.transfer-name {
text-align: left;
}
.music-track-row:hover { background: var(--bg-raised); border-color: var(--border); }
+.music-track-row.active { border-color: var(--accent); color: var(--accent); }
.music-track-no { width: 24px; flex-shrink: 0; color: var(--text-dim); text-align: right; }
.music-track-title { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.music-track-duration { color: var(--text-dim); font-size: 0.9em; flex-shrink: 0; }
@@ -2947,6 +2948,8 @@ a.transfer-name {
.music-player-volume .icon { width: 16px; height: 16px; color: var(--text-dim); }
.music-player-volume input[type="range"] { width: 80px; }
+.music-player-extra { display: flex; align-items: center; gap: 2px; flex-shrink: 0; }
+
.music-player-error { flex-basis: 100%; font-size: 0.78em; color: var(--error); }
@media (max-width: 640px) {