aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-hub/src/meshbay_hub/static
diff options
context:
space:
mode:
Diffstat (limited to 'packages/meshbay-hub/src/meshbay_hub/static')
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/apps.js2
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/group-page.js33
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/group-settings.js106
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/icon.js11
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/de.js31
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/en.js31
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/es.js31
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js31
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/it.js31
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js31
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js31
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js33
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js31
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js31
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/music-app.jsbin0 -> 14495 bytes
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/music-player.js320
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/style.css250
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/transport.js83
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/video-app.js11
19 files changed, 1125 insertions, 3 deletions
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/apps.js b/packages/meshbay-hub/src/meshbay_hub/static/apps.js
index 88b4a0f..08dc353 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/apps.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/apps.js
@@ -1,6 +1,7 @@
import { ChatPanel } from './chat-app.js';
import { FilesPanel } from './files-app.js';
import { VideoApp } from './video-app.js';
+import { MusicApp } from './music-app.js';
/**
* Every group "application", in tab order.
@@ -18,6 +19,7 @@ const APPS = [
{ key: 'chat', icon: 'chat', labelKey: 'group.tab_chat', Component: ChatPanel },
{ key: 'files', icon: 'folder', labelKey: 'group.tab_files', Component: FilesPanel },
{ key: 'video', icon: 'video', labelKey: 'group.tab_video', Component: VideoApp },
+ { key: 'music', icon: 'music', labelKey: 'group.tab_music', Component: MusicApp },
];
/** The registry filtered to what this group has enabled, in registry order. */
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 aa6b29c..01ab4e0 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/group-page.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/group-page.js
@@ -11,6 +11,7 @@ import {
import { visibleApps } from './apps.js';
import { FilePreview } from './files-app.js';
import { VideoPlayer } from './video-player.js';
+import { MusicPlayerBar } from './music-player.js';
import { GroupSettingsPanel } from './group-settings.js';
/**
@@ -86,6 +87,18 @@ function GroupPage({ groupId, group, token, username, userId, userPrefs,
// Which folder is the Videos app's entry point for this group — ''
// (the default) means the whole group index. Set from Files, per-group.
const [videoRoot, setVideoRoot] = useState('');
+ // MusicBrainz on/off (per-group) + whether a contact string is configured
+ // (node-wide) — docs/musicbay.md §3.2, same shape as tmdbConfig above.
+ const [musicbrainzConfig, setMusicbrainzConfig] = useState(null);
+ // What music-app.js hands over when a track/album is clicked — owned here
+ // (not by music-app.js) so playback survives switching tabs, the same
+ // reasoning the video/preview modals are shell-owned. `nonce` makes
+ // "play this same album again from track 0" a distinct value every time,
+ // so MusicPlayerBar's queue-init effect always re-runs.
+ const [musicQueue, setMusicQueue] = useState(null);
+ const onPlayQueue = useCallback((tracks, startIndex) => {
+ setMusicQueue({ tracks, startIndex, nonce: Date.now() });
+ }, []);
// 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
@@ -214,6 +227,10 @@ function GroupPage({ groupId, group, token, username, userId, userPrefs,
language: ack.tmdb_language || '',
});
setVideoRoot(ack.video_root || '');
+ setMusicbrainzConfig({
+ enabled: ack.musicbrainz_enabled !== false,
+ contactConfigured: !!ack.musicbrainz_contact_configured,
+ });
// Changed while we are connected, by an operator who may be someone
// else entirely. Without this the button stays until a reconnection,
// and a button that is still there is a button people press.
@@ -227,6 +244,10 @@ function GroupPage({ groupId, group, token, username, userId, userPrefs,
transport.onTmdbConfig = (cfg) => setTmdbConfig((prev) => ({ ...(prev || {}), ...cfg }));
transport.onTmdbEnabled = (enabled) => setTmdbConfig((prev) => ({ ...(prev || {}), enabled }));
transport.onVideoRoot = (path) => setVideoRoot(path);
+ transport.onMusicbrainzConfig = (cfg) =>
+ setMusicbrainzConfig((prev) => ({ ...(prev || {}), ...cfg }));
+ transport.onMusicbrainzEnabled = (enabled) =>
+ setMusicbrainzConfig((prev) => ({ ...(prev || {}), enabled }));
// The node's own scan (a root added while we were already connected,
// or reconcile catching one back up) — never the entries, just
// enough to animate the sidebar dot. Guaranteed a final push at the
@@ -418,6 +439,7 @@ function GroupPage({ groupId, group, token, username, userId, userPrefs,
onRefreshIndex: refreshIndex, onActivity: touchActivity,
videoRoot, onVideoRoot: (path) => setVideoRoot(path),
tmdbConfig,
+ musicbrainzConfig, onPlayQueue,
};
return html`
@@ -534,6 +556,9 @@ function GroupPage({ groupId, group, token, username, userId, userPrefs,
tmdbConfig=${tmdbConfig}
onTmdbConfig=${(cfg) => setTmdbConfig((prev) => ({ ...(prev || {}), ...cfg }))}
onTmdbEnabled=${(enabled) => setTmdbConfig((prev) => ({ ...(prev || {}), enabled }))}
+ musicbrainzConfig=${musicbrainzConfig}
+ onMusicbrainzConfig=${(cfg) => setMusicbrainzConfig((prev) => ({ ...(prev || {}), ...cfg }))}
+ onMusicbrainzEnabled=${(enabled) => setMusicbrainzConfig((prev) => ({ ...(prev || {}), enabled }))}
entries=${entries} nodeDirs=${nodeDirs}
videoRoot=${videoRoot}
onVideoRoot=${(path) => setVideoRoot(path)}
@@ -566,6 +591,14 @@ function GroupPage({ groupId, group, token, username, userId, userPrefs,
onClose=${() => setVideoEntry(null)}
onDownload=${() => downloadFileForModal(videoEntry)} />
`}
+ ${/* Outside the tab-switched area on purpose (docs/musicbay.md §2.3):
+ 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. */
+ musicQueue && html`
+ <${MusicPlayerBar} transportRef=${transportRef} gekRef=${gekRef} queue=${musicQueue} />
+ `}
</div>
`;
}
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/group-settings.js b/packages/meshbay-hub/src/meshbay_hub/static/group-settings.js
index 88ab68e..03162d9 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/group-settings.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/group-settings.js
@@ -34,6 +34,7 @@ function GroupSettingsPanel({ groupId, group, token, transportRef, gekRef,
enabledApps, onEnabledApps,
scanSettings, onScanSettings,
tmdbConfig, onTmdbConfig, onTmdbEnabled,
+ musicbrainzConfig, onMusicbrainzConfig, onMusicbrainzEnabled,
entries, nodeDirs, videoRoot, onVideoRoot,
onPaired, onLeft }) {
const [members, setMembers] = useState([]);
@@ -358,6 +359,74 @@ function GroupSettingsPanel({ groupId, group, token, transportRef, gekRef,
saveTmdbConfig();
}, [isNodeAdmin, connected, tmdbConfig, saveTmdbConfig]);
+ const [mbBusy, setMbBusy] = useState(false);
+ const [mbMsg, setMbMsg] = useState('');
+ const [mbContactDraft, setMbContactDraft] = useState('');
+ const [mbEnabledBusy, setMbEnabledBusy] = useState(false);
+ const mbEnabled = musicbrainzConfig ? musicbrainzConfig.enabled : true;
+
+ /**
+ * Whether MusicBrainz is used at all — per-group from the start
+ * (docs/musicbay.md §3.2/§6). Same shape as saveTmdbEnabled.
+ */
+ const saveMusicbrainzEnabled = useCallback(async (nextEnabled) => {
+ const transport = transportRef && transportRef.current;
+ setMbMsg('');
+ setMbEnabledBusy(true);
+ try {
+ if (!transport || !transport.connected) {
+ throw new Error('Not connected to the node');
+ }
+ const sk = transport.sessionKeys && transport.sessionKeys.skEdB64;
+ const signFn = (sk && window.MeshBayKeys)
+ ? (transcript) => window.MeshBayKeys.signBytes(sk, transcript)
+ : null;
+ await transport.setMusicbrainzEnabled(nextEnabled, signFn);
+ if (onMusicbrainzEnabled) onMusicbrainzEnabled(nextEnabled);
+ } catch (err) {
+ setMbMsg(err.message);
+ } finally {
+ setMbEnabledBusy(false);
+ }
+ }, [transportRef, onMusicbrainzEnabled]);
+
+ /**
+ * The node-wide MusicBrainz contact string (docs/musicbay.md §3.2) — not
+ * a secret, unlike TMDB's token, but still cleared from the draft field
+ * after a save: the node never echoes it back
+ * (musicbrainz_config_ack carries only whether one is set), so there is
+ * nothing to keep showing.
+ */
+ const saveMusicbrainzConfig = useCallback(async () => {
+ const transport = transportRef && transportRef.current;
+ setMbMsg('');
+ setMbBusy(true);
+ try {
+ if (!transport || !transport.connected) {
+ throw new Error('Not connected to the node');
+ }
+ const sk = transport.sessionKeys && transport.sessionKeys.skEdB64;
+ const signFn = (sk && window.MeshBayKeys)
+ ? (transcript) => window.MeshBayKeys.signBytes(sk, transcript)
+ : null;
+ const contact = mbContactDraft.trim();
+ await transport.setMusicbrainzConfig(contact || undefined, signFn);
+ setMbContactDraft('');
+ if (onMusicbrainzConfig) {
+ onMusicbrainzConfig({
+ contactConfigured: contact
+ ? true
+ : (musicbrainzConfig ? musicbrainzConfig.contactConfigured : false),
+ });
+ }
+ setMbMsg(t('settings_node.scan_saved'));
+ } catch (err) {
+ setMbMsg(err.message);
+ } finally {
+ setMbBusy(false);
+ }
+ }, [transportRef, onMusicbrainzConfig, mbContactDraft, musicbrainzConfig]);
+
// Every folder anywhere in the group's shared index, deepest included —
// `entries[].path` is each file's containing directory (files-app.js's own
// convention), so every ancestor prefix of it is a real folder, and
@@ -688,6 +757,43 @@ function GroupSettingsPanel({ groupId, group, token, transportRef, gekRef,
</div>
`}
+ ${/* Same two-part shape as TMDB above: the on/off switch is per-group,
+ the contact string stays node-wide (docs/musicbay.md §3.2) —
+ one operator identity, not a per-group concern. Unlike TMDB
+ there is no token field: MusicBrainz's read endpoints need no
+ credential, just a descriptive User-Agent contact. */
+ isNodeAdmin && connected && html`
+ <div class="settings-section">
+ <h3 class="settings-heading">${t('settings_node.musicbrainz_title')}</h3>
+ <p class="settings-hint">${t('settings_node.musicbrainz_hint')}</p>
+ <div class="settings-row">
+ <label class="settings-label">
+ <input type="checkbox" checked=${mbEnabled} disabled=${mbEnabledBusy}
+ onChange=${(e) => saveMusicbrainzEnabled(e.target.checked)} />
+ ${' '}${mbEnabled ? t('settings_node.musicbrainz_enabled') : t('settings_node.musicbrainz_disabled')}
+ </label>
+ </div>
+ <div class="settings-row">
+ <label class="settings-label">
+ ${t('settings_node.musicbrainz_contact_label')}
+ <input type="text" placeholder=${t('settings_node.musicbrainz_contact_placeholder')}
+ value=${mbContactDraft} disabled=${mbBusy}
+ onInput=${e => setMbContactDraft(e.target.value)} />
+ </label>
+ <p class="settings-hint">
+ ${musicbrainzConfig && musicbrainzConfig.contactConfigured
+ ? t('settings_node.musicbrainz_contact_set')
+ : t('settings_node.musicbrainz_contact_unset')}
+ </p>
+ </div>
+ <button class="btn btn-small btn-secondary" style="margin-top:8px"
+ disabled=${mbBusy} onClick=${() => saveMusicbrainzConfig()}>
+ ${mbBusy ? t('settings_node.scan_saving') : t('settings_node.musicbrainz_save')}
+ </button>
+ ${mbMsg && html`<p class="settings-hint">${mbMsg}</p>`}
+ </div>
+ `}
+
${/* Which folder is the Videos app's entry point for this group —
per-group like uploads, not node-wide like TMDB (mediacenter.md
§5.6). Until one is chosen, the Videos tab says so instead of
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/icon.js b/packages/meshbay-hub/src/meshbay_hub/static/icon.js
index 84a5195..0ecbd70 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/icon.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/icon.js
@@ -71,6 +71,17 @@ const ICON_PATHS = {
'M2 21h.01'],
video: ['M3.5 6.5a1.5 1.5 0 0 1 1.5-1.5h14a1.5 1.5 0 0 1 1.5 1.5v11a1.5 1.5 0 0 1-1.5 1.5h-14a1.5 1.5 0 0 1-1.5-1.5z',
'M10 9.5v5l4.5-2.5z'],
+ music: ['M9 18V5l12-2v13',
+ 'M4 18a2 2 0 1 0 4 0 2 2 0 1 0 -4 0',
+ 'M16 16a2 2 0 1 0 4 0 2 2 0 1 0 -4 0'],
+ pause: ['M7 5.5v13', 'M17 5.5v13'],
+ 'skip-next': ['M5 4l10 8-10 8z', 'M19 5v14'],
+ 'skip-prev': ['M19 4L9 12l10 8z', 'M5 5v14'],
+ shuffle: ['M16 3h5v5', 'M4 20L21 3', 'M21 16v5h-5', 'M15 15l6 6', 'M4 4l5 5'],
+ repeat: ['M17 1l4 4-4 4', 'M3 11V9a4 4 0 0 1 4-4h14',
+ 'M7 23l-4-4 4-4', 'M21 13v2a4 4 0 0 1-4 4H3'],
+ volume: ['M11 5L6 9H2v6h4l5 4z', 'M15.54 8.46a5 5 0 0 1 0 7.07',
+ 'M19.07 4.93a10 10 0 0 1 0 14.14'],
};
// The M of the wordmark is a picture; the rest is text. Resolved from this
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 7f05441..cb542a6 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js
@@ -77,6 +77,7 @@ export default {
'group.tab_files': 'Dateien',
'group.tab_chat': 'Chat',
'group.tab_video': 'Videos',
+ 'group.tab_music': 'Musik',
'group.tab_members': 'Mitglieder',
'group.tab_settings': "Einstellungen",
'members.danger_leave_hint': "Sie verlieren den Zugriff auf die Dateien und den Chat dieser Gruppe.",
@@ -176,6 +177,27 @@ export default {
'video.search_no_results': 'Keine Treffer gefunden.',
'video.search_apply_hint': 'Gilt für alle Dateien, die derzeit unter diesem Titel gruppiert sind.',
+ // Musik
+ 'music.mode_grid': 'Alben',
+ 'music.mode_flat': 'Flache Liste',
+ 'music.empty': 'Keine Musik gefunden.',
+ 'music.unknown_album': 'Unbekanntes Album',
+ 'music.play_all': 'Alle abspielen',
+ 'music.n_tracks': {
+ one: '{n} Titel',
+ other: '{n} Titel',
+ },
+ 'music.err_transport': 'Transport nicht verbunden',
+ 'music.player_play': 'Abspielen',
+ 'music.player_pause': 'Pause',
+ 'music.player_prev': 'Vorheriger',
+ 'music.player_next': 'Nächster',
+ 'music.player_shuffle': 'Zufallswiedergabe',
+ 'music.player_repeat_off': 'Wiederholung aus',
+ 'music.player_repeat_all': 'Alle wiederholen',
+ 'music.player_repeat_one': 'Einzelnen wiederholen',
+ 'music.player_volume': 'Lautstärke',
+
// LAN-Cast
'cast.start': 'Auf Gerät übertragen',
'cast.stop': 'Übertragung beenden',
@@ -605,6 +627,15 @@ export default {
'settings_node.video_root_title': 'Videos-Stammordner',
'settings_node.video_root_hint': 'Welcher Ordner (oder Unterordner) als Einstiegspunkt der Videos-App für diese Gruppe dient. In Videos wird nichts angezeigt, und es werden keine TMDB-Abfragen ausgeführt, bis einer ausgewählt wurde.',
'settings_node.video_root_none': '— keiner ausgewählt —',
+ 'settings_node.musicbrainz_title': 'MusicBrainz-Metadaten',
+ 'settings_node.musicbrainz_hint': 'Ermöglicht der Musik-App, Cover und kanonische Benennungen von MusicBrainz anzuzeigen, wenn ein Titel kein brauchbares eingebettetes Cover hat. Aus bedeutet nur Tag-/Dateiname-basiertes Durchsuchen, ohne Anfrage an Dritte.',
+ 'settings_node.musicbrainz_enabled': 'Aktiviert',
+ 'settings_node.musicbrainz_disabled': 'Deaktiviert',
+ 'settings_node.musicbrainz_contact_label': 'Kontakt (erforderlich, damit MusicBrainz antwortet)',
+ 'settings_node.musicbrainz_contact_placeholder': 'du@beispiel.de oder eine Projekt-URL',
+ 'settings_node.musicbrainz_contact_set': 'Ein Kontakt ist konfiguriert.',
+ 'settings_node.musicbrainz_contact_unset': 'Kein Kontakt konfiguriert — MusicBrainz-Abfragen bleiben deaktiviert, bis einer festgelegt ist.',
+ 'settings_node.musicbrainz_save': 'Speichern',
'settings_node.video_root_save': 'Speichern',
'settings_node.video_root_change_confirm': 'Das Ändern des Videos-Stammordners ersetzt, was jedes Mitglied im Videos-Tab sieht. Fortfahren?',
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 7f31bb5..e05b398 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js
@@ -78,6 +78,7 @@ export default {
'group.tab_files': 'Files',
'group.tab_chat': 'Chat',
'group.tab_video': 'Videos',
+ 'group.tab_music': 'Music',
'group.tab_members': 'Members',
'group.tab_settings': "Settings",
'members.danger_leave_hint': "You will lose access to this group's files and chat.",
@@ -174,6 +175,27 @@ export default {
'video.search_no_results': 'No matches found.',
'video.search_apply_hint': 'Applies to every file currently grouped under this title.',
+ // Music
+ 'music.mode_grid': 'Albums',
+ 'music.mode_flat': 'Flat list',
+ 'music.empty': 'No music found.',
+ 'music.unknown_album': 'Unknown album',
+ 'music.play_all': 'Play all',
+ 'music.n_tracks': {
+ one: '{n} track',
+ other: '{n} tracks',
+ },
+ 'music.err_transport': 'Transport not connected',
+ 'music.player_play': 'Play',
+ 'music.player_pause': 'Pause',
+ 'music.player_prev': 'Previous',
+ 'music.player_next': 'Next',
+ 'music.player_shuffle': 'Shuffle',
+ 'music.player_repeat_off': 'Repeat off',
+ 'music.player_repeat_all': 'Repeat all',
+ 'music.player_repeat_one': 'Repeat one',
+ 'music.player_volume': 'Volume',
+
// LAN cast
'cast.start': 'Cast to device',
'cast.stop': 'Stop casting',
@@ -430,6 +452,15 @@ export default {
'settings_node.video_root_title': 'Videos root folder',
'settings_node.video_root_hint': 'Which folder (or subfolder) the Videos app treats as its entry point for this group. Nothing shows in Videos, and no TMDB lookups run, until one is chosen.',
'settings_node.video_root_none': '— none chosen —',
+ 'settings_node.musicbrainz_title': 'MusicBrainz metadata',
+ 'settings_node.musicbrainz_hint': 'Lets the Music app show cover art and canonical naming from MusicBrainz when a track has no usable embedded cover. Off means tag/filename-only browsing, with no request to a third party.',
+ 'settings_node.musicbrainz_enabled': 'Enabled',
+ 'settings_node.musicbrainz_disabled': 'Disabled',
+ 'settings_node.musicbrainz_contact_label': 'Contact (required for MusicBrainz to answer requests)',
+ 'settings_node.musicbrainz_contact_placeholder': 'you@example.com or a project URL',
+ 'settings_node.musicbrainz_contact_set': 'A contact is configured.',
+ 'settings_node.musicbrainz_contact_unset': 'No contact configured — MusicBrainz lookups stay off until one is set.',
+ 'settings_node.musicbrainz_save': 'Save',
'settings_node.video_root_save': 'Save',
'settings_node.video_root_change_confirm': 'Changing the Videos root replaces what every member sees in the Videos tab. Continue?',
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 d7f0f1e..113ae7a 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js
@@ -75,6 +75,7 @@ export default {
'group.tab_files': 'Archivos',
'group.tab_chat': 'Chat',
'group.tab_video': 'Vídeos',
+ 'group.tab_music': 'Música',
'group.tab_members': 'Miembros',
'group.tab_settings': "Ajustes",
'members.danger_leave_hint': "Perderá el acceso a los archivos y al chat de este grupo.",
@@ -174,6 +175,27 @@ export default {
'video.search_no_results': 'No se encontraron coincidencias.',
'video.search_apply_hint': 'Se aplica a todos los archivos agrupados actualmente bajo este título.',
+ // Música
+ 'music.mode_grid': 'Álbumes',
+ 'music.mode_flat': 'Lista plana',
+ 'music.empty': 'No se encontró música.',
+ 'music.unknown_album': 'Álbum desconocido',
+ 'music.play_all': 'Reproducir todo',
+ 'music.n_tracks': {
+ one: '{n} pista',
+ other: '{n} pistas',
+ },
+ 'music.err_transport': 'Transporte no conectado',
+ 'music.player_play': 'Reproducir',
+ 'music.player_pause': 'Pausa',
+ 'music.player_prev': 'Anterior',
+ 'music.player_next': 'Siguiente',
+ 'music.player_shuffle': 'Aleatorio',
+ 'music.player_repeat_off': 'Repetición desactivada',
+ 'music.player_repeat_all': 'Repetir todo',
+ 'music.player_repeat_one': 'Repetir una',
+ 'music.player_volume': 'Volumen',
+
// LAN cast
'cast.start': 'Enviar a dispositivo',
'cast.stop': 'Detener envío',
@@ -600,6 +622,15 @@ export default {
'settings_node.video_root_title': 'Carpeta raíz de Vídeos',
'settings_node.video_root_hint': 'Qué carpeta (o subcarpeta) trata la app Vídeos como su punto de entrada para este grupo. No se muestra nada en Vídeos, ni se realizan búsquedas en TMDB, hasta que se elija una.',
'settings_node.video_root_none': '— ninguna elegida —',
+ 'settings_node.musicbrainz_title': 'Metadatos de MusicBrainz',
+ 'settings_node.musicbrainz_hint': 'Permite que la app de Música muestre carátulas y nombres canónicos de MusicBrainz cuando una pista no tiene una carátula incrustada utilizable. Desactivado significa navegación solo por etiquetas/nombre de archivo, sin solicitudes a terceros.',
+ 'settings_node.musicbrainz_enabled': 'Activado',
+ 'settings_node.musicbrainz_disabled': 'Desactivado',
+ 'settings_node.musicbrainz_contact_label': 'Contacto (necesario para que MusicBrainz responda)',
+ 'settings_node.musicbrainz_contact_placeholder': 'tu@ejemplo.com o una URL de proyecto',
+ 'settings_node.musicbrainz_contact_set': 'Hay un contacto configurado.',
+ 'settings_node.musicbrainz_contact_unset': 'Sin contacto configurado — las búsquedas de MusicBrainz permanecen desactivadas hasta que se configure uno.',
+ 'settings_node.musicbrainz_save': 'Guardar',
'settings_node.video_root_save': 'Guardar',
'settings_node.video_root_change_confirm': 'Cambiar la raíz de Vídeos reemplaza lo que ve cada miembro en la pestaña Vídeos. ¿Continuar?',
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 2befdb9..6a57174 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js
@@ -76,6 +76,7 @@ export default {
'group.tab_files': 'Fichiers',
'group.tab_chat': 'Discussion',
'group.tab_video': 'Vidéos',
+ 'group.tab_music': 'Musique',
'group.tab_members': 'Membres',
'group.tab_settings': "Paramètres",
'members.danger_leave_hint': "Vous perdrez l’accès aux fichiers et à la discussion de ce groupe.",
@@ -175,6 +176,27 @@ export default {
'video.search_no_results': 'Aucune correspondance trouvée.',
'video.search_apply_hint': 'S’applique à tous les fichiers actuellement regroupés sous ce titre.',
+ // Musique
+ 'music.mode_grid': 'Albums',
+ 'music.mode_flat': 'Liste à plat',
+ 'music.empty': 'Aucune musique trouvée.',
+ 'music.unknown_album': 'Album inconnu',
+ 'music.play_all': 'Tout lire',
+ 'music.n_tracks': {
+ one: '{n} piste',
+ other: '{n} pistes',
+ },
+ 'music.err_transport': 'Transport non connecté',
+ 'music.player_play': 'Lecture',
+ 'music.player_pause': 'Pause',
+ 'music.player_prev': 'Précédent',
+ 'music.player_next': 'Suivant',
+ 'music.player_shuffle': 'Lecture aléatoire',
+ 'music.player_repeat_off': 'Répétition désactivée',
+ 'music.player_repeat_all': 'Tout répéter',
+ 'music.player_repeat_one': 'Répéter le morceau',
+ 'music.player_volume': 'Volume',
+
// LAN cast
'cast.start': 'Diffuser sur un appareil',
'cast.stop': 'Arrêter la diffusion',
@@ -616,6 +638,15 @@ export default {
'settings_node.video_root_title': 'Dossier racine des Vidéos',
'settings_node.video_root_hint': "Quel dossier (ou sous-dossier) sert de point d'entrée à l'application Vidéos pour ce groupe. Rien ne s'affiche dans Vidéos, et aucune recherche TMDB n'est effectuée, tant qu'aucun n'est choisi.",
'settings_node.video_root_none': '— aucun choisi —',
+ 'settings_node.musicbrainz_title': 'Métadonnées MusicBrainz',
+ 'settings_node.musicbrainz_hint': "Permet à l'app Musique d'afficher les pochettes et les noms canoniques depuis MusicBrainz quand un morceau n'a pas de pochette intégrée utilisable. Désactivé signifie une navigation par tags/nom de fichier uniquement, sans requête vers un tiers.",
+ 'settings_node.musicbrainz_enabled': 'Activé',
+ 'settings_node.musicbrainz_disabled': 'Désactivé',
+ 'settings_node.musicbrainz_contact_label': 'Contact (requis pour que MusicBrainz réponde)',
+ 'settings_node.musicbrainz_contact_placeholder': 'vous@exemple.com ou une URL de projet',
+ 'settings_node.musicbrainz_contact_set': 'Un contact est configuré.',
+ 'settings_node.musicbrainz_contact_unset': "Aucun contact configuré — les recherches MusicBrainz restent désactivées tant que rien n'est renseigné.",
+ 'settings_node.musicbrainz_save': 'Enregistrer',
'settings_node.video_root_save': 'Enregistrer',
'settings_node.video_root_change_confirm': "Changer la racine des Vidéos remplace ce que chaque membre voit dans l'onglet Vidéos. Continuer ?",
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 bdfda75..257318a 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js
@@ -76,6 +76,7 @@ export default {
'group.tab_files': 'File',
'group.tab_chat': 'Chat',
'group.tab_video': 'Video',
+ 'group.tab_music': 'Musica',
'group.tab_members': 'Membri',
'group.tab_settings': "Impostazioni",
'members.danger_leave_hint': "Perderai l’accesso ai file e alla chat di questo gruppo.",
@@ -175,6 +176,27 @@ export default {
'video.search_no_results': 'Nessuna corrispondenza trovata.',
'video.search_apply_hint': 'Si applica a tutti i file attualmente raggruppati sotto questo titolo.',
+ // Musica
+ 'music.mode_grid': 'Album',
+ 'music.mode_flat': 'Elenco semplice',
+ 'music.empty': 'Nessuna musica trovata.',
+ 'music.unknown_album': 'Album sconosciuto',
+ 'music.play_all': 'Riproduci tutto',
+ 'music.n_tracks': {
+ one: '{n} traccia',
+ other: '{n} tracce',
+ },
+ 'music.err_transport': 'Trasporto non connesso',
+ 'music.player_play': 'Riproduci',
+ 'music.player_pause': 'Pausa',
+ 'music.player_prev': 'Precedente',
+ 'music.player_next': 'Successivo',
+ 'music.player_shuffle': 'Riproduzione casuale',
+ 'music.player_repeat_off': 'Ripetizione disattivata',
+ 'music.player_repeat_all': 'Ripeti tutto',
+ 'music.player_repeat_one': 'Ripeti brano',
+ 'music.player_volume': 'Volume',
+
// LAN cast
'cast.start': 'Trasmetti al dispositivo',
'cast.stop': 'Interrompi trasmissione',
@@ -614,6 +636,15 @@ export default {
'settings_node.video_root_title': 'Cartella radice di Video',
'settings_node.video_root_hint': "Quale cartella (o sottocartella) l'app Video considera come punto di ingresso per questo gruppo. In Video non viene mostrato nulla, e non viene eseguita alcuna ricerca TMDB, finché non ne viene scelta una.",
'settings_node.video_root_none': '— nessuna scelta —',
+ 'settings_node.musicbrainz_title': 'Metadati MusicBrainz',
+ 'settings_node.musicbrainz_hint': "Permette all'app Musica di mostrare copertine e nomi canonici da MusicBrainz quando una traccia non ha una copertina incorporata utilizzabile. Disattivato significa navigazione basata solo su tag/nome file, senza richieste a terzi.",
+ 'settings_node.musicbrainz_enabled': 'Attivato',
+ 'settings_node.musicbrainz_disabled': 'Disattivato',
+ 'settings_node.musicbrainz_contact_label': 'Contatto (necessario perché MusicBrainz risponda)',
+ 'settings_node.musicbrainz_contact_placeholder': 'tu@esempio.com o un URL di progetto',
+ 'settings_node.musicbrainz_contact_set': 'È configurato un contatto.',
+ 'settings_node.musicbrainz_contact_unset': 'Nessun contatto configurato — le ricerche MusicBrainz restano disattivate finché non ne viene impostato uno.',
+ 'settings_node.musicbrainz_save': 'Salva',
'settings_node.video_root_save': 'Salva',
'settings_node.video_root_change_confirm': 'Cambiare la radice di Video sostituisce ciò che ogni membro vede nella scheda Video. Continuare?',
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 f00a0ec..44f9037 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js
@@ -74,6 +74,7 @@ export default {
'group.tab_files': 'ファイル',
'group.tab_chat': 'チャット',
'group.tab_video': '動画',
+ 'group.tab_music': '音楽',
'group.tab_members': 'メンバー',
'group.tab_settings': "設定",
'members.danger_leave_hint': "このグループのファイルとチャットにアクセスできなくなります。",
@@ -172,6 +173,27 @@ export default {
'video.search_no_results': '一致する結果が見つかりません。',
'video.search_apply_hint': '現在このタイトルでグループ化されているすべてのファイルに適用されます。',
+ // 音楽
+ 'music.mode_grid': 'アルバム',
+ 'music.mode_flat': 'フラットリスト',
+ 'music.empty': '音楽が見つかりません。',
+ 'music.unknown_album': '不明なアルバム',
+ 'music.play_all': 'すべて再生',
+ 'music.n_tracks': {
+ one: '{n}曲',
+ other: '{n}曲',
+ },
+ 'music.err_transport': 'トランスポートが接続されていません',
+ 'music.player_play': '再生',
+ 'music.player_pause': '一時停止',
+ 'music.player_prev': '前へ',
+ 'music.player_next': '次へ',
+ 'music.player_shuffle': 'シャッフル',
+ 'music.player_repeat_off': 'リピートオフ',
+ 'music.player_repeat_all': 'すべてリピート',
+ 'music.player_repeat_one': '1曲リピート',
+ 'music.player_volume': '音量',
+
// LAN cast
'cast.start': 'デバイスにキャスト',
'cast.stop': 'キャストを停止',
@@ -598,6 +620,15 @@ export default {
'settings_node.video_root_title': '動画のルートフォルダ',
'settings_node.video_root_hint': 'このグループで動画アプリの起点とするフォルダ(またはサブフォルダ)です。選択されるまで、動画には何も表示されず、TMDB への問い合わせも行われません。',
'settings_node.video_root_none': '— 未選択 —',
+ 'settings_node.musicbrainz_title': 'MusicBrainzのメタデータ',
+ 'settings_node.musicbrainz_hint': 'トラックに使用可能な埋め込みカバーがない場合、MusicBrainzのカバーアートと正式名称をMusicアプリで表示できるようにします。オフにするとタグ・ファイル名のみでの閲覧になり、第三者へのリクエストは発生しません。',
+ 'settings_node.musicbrainz_enabled': '有効',
+ 'settings_node.musicbrainz_disabled': '無効',
+ 'settings_node.musicbrainz_contact_label': '連絡先(MusicBrainzが応答するために必要)',
+ 'settings_node.musicbrainz_contact_placeholder': 'you@example.com またはプロジェクトのURL',
+ 'settings_node.musicbrainz_contact_set': '連絡先が設定されています。',
+ 'settings_node.musicbrainz_contact_unset': '連絡先が設定されていません — 設定されるまでMusicBrainzの検索は無効のままです。',
+ 'settings_node.musicbrainz_save': '保存',
'settings_node.video_root_save': '保存',
'settings_node.video_root_change_confirm': '動画のルートフォルダを変更すると、全メンバーの動画タブの表示内容が変わります。続行しますか?',
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 d9ed12e..2fedd70 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js
@@ -77,6 +77,7 @@ export default {
'group.tab_files': 'Bestanden',
'group.tab_chat': 'Chat',
'group.tab_video': "Video's",
+ 'group.tab_music': 'Muziek',
'group.tab_members': 'Leden',
'group.tab_settings': "Instellingen",
'members.danger_leave_hint': "U verliest de toegang tot de bestanden en de chat van deze groep.",
@@ -176,6 +177,27 @@ export default {
'video.search_no_results': 'Geen overeenkomsten gevonden.',
'video.search_apply_hint': 'Geldt voor alle bestanden die momenteel onder deze titel zijn gegroepeerd.',
+ // Muziek
+ 'music.mode_grid': 'Albums',
+ 'music.mode_flat': 'Platte lijst',
+ 'music.empty': 'Geen muziek gevonden.',
+ 'music.unknown_album': 'Onbekend album',
+ 'music.play_all': 'Alles afspelen',
+ 'music.n_tracks': {
+ one: '{n} nummer',
+ other: '{n} nummers',
+ },
+ 'music.err_transport': 'Transport niet verbonden',
+ 'music.player_play': 'Afspelen',
+ 'music.player_pause': 'Pauzeren',
+ 'music.player_prev': 'Vorige',
+ 'music.player_next': 'Volgende',
+ 'music.player_shuffle': 'Shuffle',
+ 'music.player_repeat_off': 'Herhalen uit',
+ 'music.player_repeat_all': 'Alles herhalen',
+ 'music.player_repeat_one': 'Nummer herhalen',
+ 'music.player_volume': 'Volume',
+
// LAN cast
'cast.start': 'Naar apparaat casten',
'cast.stop': 'Casten stoppen',
@@ -616,6 +638,15 @@ export default {
'settings_node.video_root_title': "Hoofdmap voor Video's",
'settings_node.video_root_hint': "Welke map (of submap) de Video's-app als startpunt gebruikt voor deze groep. Er wordt niets getoond in Video's, en er worden geen TMDB-opzoekingen uitgevoerd, totdat er een gekozen is.",
'settings_node.video_root_none': '— geen gekozen —',
+ 'settings_node.musicbrainz_title': 'MusicBrainz-metadata',
+ 'settings_node.musicbrainz_hint': "Laat de Muziek-app hoesfoto's en canonieke namen van MusicBrainz tonen wanneer een nummer geen bruikbare ingesloten hoes heeft. Uit betekent alleen bladeren op tag/bestandsnaam, zonder verzoek aan derden.",
+ 'settings_node.musicbrainz_enabled': 'Ingeschakeld',
+ 'settings_node.musicbrainz_disabled': 'Uitgeschakeld',
+ 'settings_node.musicbrainz_contact_label': 'Contact (vereist zodat MusicBrainz kan antwoorden)',
+ 'settings_node.musicbrainz_contact_placeholder': 'jij@voorbeeld.com of een project-URL',
+ 'settings_node.musicbrainz_contact_set': 'Er is een contact ingesteld.',
+ 'settings_node.musicbrainz_contact_unset': 'Geen contact ingesteld — MusicBrainz-opzoekingen blijven uit totdat er een is ingesteld.',
+ 'settings_node.musicbrainz_save': 'Opslaan',
'settings_node.video_root_save': 'Opslaan',
'settings_node.video_root_change_confirm': "Het wijzigen van de hoofdmap voor Video's vervangt wat elk lid ziet in het tabblad Video's. Doorgaan?",
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 cbd27b1..dac2359 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js
@@ -81,6 +81,7 @@ export default {
'group.tab_files': 'Pliki',
'group.tab_chat': 'Czat',
'group.tab_video': 'Wideo',
+ 'group.tab_music': 'Muzyka',
'group.tab_members': 'Członkowie',
'group.tab_settings': "Ustawienia",
'members.danger_leave_hint': "Utracisz dostęp do plików i czatu tej grupy.",
@@ -183,6 +184,29 @@ export default {
'video.search_no_results': 'Nie znaleziono dopasowań.',
'video.search_apply_hint': 'Dotyczy wszystkich plików obecnie zgrupowanych pod tym tytułem.',
+ // Muzyka
+ 'music.mode_grid': 'Albumy',
+ 'music.mode_flat': 'Lista płaska',
+ 'music.empty': 'Nie znaleziono muzyki.',
+ 'music.unknown_album': 'Nieznany album',
+ 'music.play_all': 'Odtwórz wszystko',
+ 'music.n_tracks': {
+ one: '{n} utwór',
+ few: '{n} utwory',
+ many: '{n} utworów',
+ other: '{n} utworu',
+ },
+ 'music.err_transport': 'Transport niepołączony',
+ 'music.player_play': 'Odtwórz',
+ 'music.player_pause': 'Pauza',
+ 'music.player_prev': 'Poprzedni',
+ 'music.player_next': 'Następny',
+ 'music.player_shuffle': 'Losowo',
+ 'music.player_repeat_off': 'Powtarzanie wyłączone',
+ 'music.player_repeat_all': 'Powtórz wszystko',
+ 'music.player_repeat_one': 'Powtórz utwór',
+ 'music.player_volume': 'Głośność',
+
// LAN cast
'cast.start': 'Przesyłaj na urządzenie',
'cast.stop': 'Zatrzymaj przesyłanie',
@@ -639,6 +663,15 @@ export default {
'settings_node.video_root_title': 'Katalog główny Wideo',
'settings_node.video_root_hint': 'Który katalog (lub podkatalog) aplikacja Wideo traktuje jako punkt wejścia dla tej grupy. W Wideo nic się nie wyświetla i nie są wykonywane żadne zapytania do TMDB, dopóki nie zostanie wybrany.',
'settings_node.video_root_none': '— nie wybrano —',
+ 'settings_node.musicbrainz_title': 'Metadane MusicBrainz',
+ 'settings_node.musicbrainz_hint': 'Pozwala aplikacji Muzyka pokazywać okładki i kanoniczne nazwy z MusicBrainz, gdy utwór nie ma użytecznej wbudowanej okładki. Wyłączone oznacza przeglądanie tylko na podstawie tagów/nazwy pliku, bez żądań do strony trzeciej.',
+ 'settings_node.musicbrainz_enabled': 'Włączone',
+ 'settings_node.musicbrainz_disabled': 'Wyłączone',
+ 'settings_node.musicbrainz_contact_label': 'Kontakt (wymagany, aby MusicBrainz odpowiadał)',
+ 'settings_node.musicbrainz_contact_placeholder': 'ty@przyklad.com lub URL projektu',
+ 'settings_node.musicbrainz_contact_set': 'Kontakt jest skonfigurowany.',
+ 'settings_node.musicbrainz_contact_unset': 'Brak skonfigurowanego kontaktu — wyszukiwania MusicBrainz pozostają wyłączone, dopóki nie zostanie ustawiony.',
+ 'settings_node.musicbrainz_save': 'Zapisz',
'settings_node.video_root_save': 'Zapisz',
'settings_node.video_root_change_confirm': 'Zmiana katalogu głównego Wideo zastępuje to, co widzi każdy członek w karcie Wideo. Kontynuować?',
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 e7bf45c..4251a4b 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
@@ -77,6 +77,7 @@ export default {
'group.tab_files': 'Arquivos',
'group.tab_chat': 'Conversa',
'group.tab_video': 'Vídeos',
+ 'group.tab_music': 'Música',
'group.tab_members': 'Membros',
'group.tab_settings': "Configurações",
'members.danger_leave_hint': "Você perderá o acesso aos arquivos e ao chat deste grupo.",
@@ -176,6 +177,27 @@ export default {
'video.search_no_results': 'Nenhuma correspondência encontrada.',
'video.search_apply_hint': 'Aplica-se a todos os arquivos atualmente agrupados sob este título.',
+ // Música
+ 'music.mode_grid': 'Álbuns',
+ 'music.mode_flat': 'Lista simples',
+ 'music.empty': 'Nenhuma música encontrada.',
+ 'music.unknown_album': 'Álbum desconhecido',
+ 'music.play_all': 'Reproduzir tudo',
+ 'music.n_tracks': {
+ one: '{n} faixa',
+ other: '{n} faixas',
+ },
+ 'music.err_transport': 'Transporte não conectado',
+ 'music.player_play': 'Reproduzir',
+ 'music.player_pause': 'Pausar',
+ 'music.player_prev': 'Anterior',
+ 'music.player_next': 'Próxima',
+ 'music.player_shuffle': 'Aleatório',
+ 'music.player_repeat_off': 'Repetição desativada',
+ 'music.player_repeat_all': 'Repetir tudo',
+ 'music.player_repeat_one': 'Repetir faixa',
+ 'music.player_volume': 'Volume',
+
// LAN cast
'cast.start': 'Transmitir para dispositivo',
'cast.stop': 'Parar transmissão',
@@ -601,6 +623,15 @@ export default {
'settings_node.video_root_title': 'Pasta raiz de Vídeos',
'settings_node.video_root_hint': 'Qual pasta (ou subpasta) o app Vídeos trata como ponto de entrada para este grupo. Nada é exibido em Vídeos, e nenhuma busca no TMDB é feita, até que uma seja escolhida.',
'settings_node.video_root_none': '— nenhuma escolhida —',
+ 'settings_node.musicbrainz_title': 'Metadados do MusicBrainz',
+ 'settings_node.musicbrainz_hint': 'Permite que o app Música mostre capas e nomes canônicos do MusicBrainz quando uma faixa não tem uma capa incorporada utilizável. Desativado significa navegação apenas por tags/nome de arquivo, sem solicitação a terceiros.',
+ 'settings_node.musicbrainz_enabled': 'Ativado',
+ 'settings_node.musicbrainz_disabled': 'Desativado',
+ 'settings_node.musicbrainz_contact_label': 'Contato (necessário para o MusicBrainz responder)',
+ 'settings_node.musicbrainz_contact_placeholder': 'voce@exemplo.com ou uma URL de projeto',
+ 'settings_node.musicbrainz_contact_set': 'Um contato está configurado.',
+ 'settings_node.musicbrainz_contact_unset': 'Nenhum contato configurado — as buscas no MusicBrainz permanecem desativadas até que um seja definido.',
+ 'settings_node.musicbrainz_save': 'Salvar',
'settings_node.video_root_save': 'Salvar',
'settings_node.video_root_change_confirm': 'Alterar a raiz de Vídeos substitui o que cada membro vê na aba Vídeos. Continuar?',
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 33a0e25..7e7676b 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
@@ -74,6 +74,7 @@ export default {
'group.tab_files': '文件',
'group.tab_chat': '聊天',
'group.tab_video': '视频',
+ 'group.tab_music': '音乐',
'group.tab_members': '成员',
'group.tab_settings': "设置",
'members.danger_leave_hint': "您将无法再访问该群组的文件和聊天。",
@@ -169,6 +170,27 @@ export default {
'video.search_no_results': '未找到匹配项。',
'video.search_apply_hint': '将应用于当前归类在此标题下的所有文件。',
+ // 音乐
+ 'music.mode_grid': '专辑',
+ 'music.mode_flat': '平铺列表',
+ 'music.empty': '未找到音乐。',
+ 'music.unknown_album': '未知专辑',
+ 'music.play_all': '全部播放',
+ 'music.n_tracks': {
+ one: '{n} 首曲目',
+ other: '{n} 首曲目',
+ },
+ 'music.err_transport': '传输未连接',
+ 'music.player_play': '播放',
+ 'music.player_pause': '暂停',
+ 'music.player_prev': '上一首',
+ 'music.player_next': '下一首',
+ 'music.player_shuffle': '随机播放',
+ 'music.player_repeat_off': '不重复',
+ 'music.player_repeat_all': '全部重复',
+ 'music.player_repeat_one': '单曲重复',
+ 'music.player_volume': '音量',
+
// LAN cast
'cast.start': '投射到设备',
'cast.stop': '停止投射',
@@ -584,6 +606,15 @@ export default {
'settings_node.video_root_title': '视频根目录',
'settings_node.video_root_hint': '该文件夹(或子文件夹)将作为此群组"视频"应用的入口。在选择之前,"视频"中不会显示任何内容,也不会执行任何 TMDB 查询。',
'settings_node.video_root_none': '— 未选择 —',
+ 'settings_node.musicbrainz_title': 'MusicBrainz 元数据',
+ 'settings_node.musicbrainz_hint': '当曲目没有可用的内嵌封面时,允许音乐应用显示来自 MusicBrainz 的封面和规范名称。关闭表示仅按标签/文件名浏览,不向第三方发送请求。',
+ 'settings_node.musicbrainz_enabled': '已启用',
+ 'settings_node.musicbrainz_disabled': '已禁用',
+ 'settings_node.musicbrainz_contact_label': '联系方式(MusicBrainz 需要它才能响应请求)',
+ 'settings_node.musicbrainz_contact_placeholder': 'you@example.com 或项目 URL',
+ 'settings_node.musicbrainz_contact_set': '已配置联系方式。',
+ 'settings_node.musicbrainz_contact_unset': '未配置联系方式 — 在设置之前,MusicBrainz 查询将保持关闭。',
+ 'settings_node.musicbrainz_save': '保存',
'settings_node.video_root_save': '保存',
'settings_node.video_root_change_confirm': '更改视频根目录会替换每位成员在"视频"标签页中看到的内容。是否继续?',
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/music-app.js b/packages/meshbay-hub/src/meshbay_hub/static/music-app.js
new file mode 100644
index 0000000..85f1938
--- /dev/null
+++ b/packages/meshbay-hub/src/meshbay_hub/static/music-app.js
Binary files differ
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/music-player.js b/packages/meshbay-hub/src/meshbay_hub/static/music-player.js
new file mode 100644
index 0000000..386d3f6
--- /dev/null
+++ b/packages/meshbay-hub/src/meshbay_hub/static/music-player.js
@@ -0,0 +1,320 @@
+import {
+ html, useState, useEffect, useRef, useCallback,
+} from './vendor/htm-preact.js';
+import { t } from './i18n.js';
+import { Icon } from './icon.js';
+import { CHUNK_SIZE, pipelinedDownload } from './file-utils.js';
+
+/**
+ * The Music app's persistent player bar (docs/musicbay.md §2.3, §7.2).
+ *
+ * Owned and rendered by group-page.js, *not* by music-app.js: it is the one
+ * piece of this feature that lives outside the tab-switched area, so
+ * playback survives navigating to Chat or Files, exactly the way the
+ * video/preview modals are shell-owned rather than owned by whichever app
+ * opened them. music-app.js never touches audio state directly — it only
+ * calls `onPlayQueue(tracks, startIndex)`, threaded down from group-page.js,
+ * to hand this component a new queue.
+ *
+ * No streaming, no MSE, no node-side transcode pool: a track is a few
+ * megabytes, so it is downloaded and decrypted once through the same chunk
+ * pipeline Files already uses (file-utils.js's pipelinedDownload), then
+ * played from a blob URL — the deliberate simplification recorded in
+ * musicbay.md §2.2.
+ */
+
+const MIME_BY_EXT = {
+ mp3: 'audio/mpeg', flac: 'audio/flac', ogg: 'audio/ogg', opus: 'audio/opus',
+ wav: 'audio/wav', aac: 'audio/aac', m4a: 'audio/mp4',
+};
+
+function guessMime(name) {
+ const ext = (name || '').split('.').pop().toLowerCase();
+ return MIME_BY_EXT[ext] || 'audio/mpeg';
+}
+
+function formatTime(seconds) {
+ if (!isFinite(seconds) || seconds < 0) return '0:00';
+ const total = Math.floor(seconds);
+ const m = Math.floor(total / 60);
+ const s = total % 60;
+ return `${m}:${String(s).padStart(2, '0')}`;
+}
+
+function shuffledOrder(n, keepFirst) {
+ const order = Array.from({ length: n }, (_, i) => i);
+ // Fisher-Yates, then move keepFirst to the front so shuffling on doesn't
+ // interrupt whatever is already playing.
+ for (let i = order.length - 1; i > 0; i--) {
+ const j = Math.floor(Math.random() * (i + 1));
+ [order[i], order[j]] = [order[j], order[i]];
+ }
+ if (keepFirst != null) {
+ const at = order.indexOf(keepFirst);
+ if (at > 0) { order.splice(at, 1); order.unshift(keepFirst); }
+ }
+ return order;
+}
+
+// Bounded: only the currently playing track plus a one-track read-ahead are
+// ever worth holding in memory. Older blob URLs are revoked, not merely
+// dropped — otherwise every track played in a session leaks its object URL.
+const MAX_CACHED_BLOBS = 3;
+
+function loadVolume() {
+ try {
+ const v = parseFloat(localStorage.getItem('meshbay_music_volume'));
+ return isFinite(v) && v >= 0 && v <= 1 ? v : 1;
+ } catch { return 1; }
+}
+function saveVolume(v) {
+ try { localStorage.setItem('meshbay_music_volume', String(v)); } catch { /* per-device only */ }
+}
+function loadShuffle() {
+ try { return localStorage.getItem('meshbay_music_shuffle') === '1'; } catch { return false; }
+}
+function saveShuffle(v) {
+ try { localStorage.setItem('meshbay_music_shuffle', v ? '1' : '0'); } catch { /* per-device only */ }
+}
+function loadRepeat() {
+ try {
+ const v = localStorage.getItem('meshbay_music_repeat');
+ return v === 'all' || v === 'one' ? v : 'off';
+ } catch { return 'off'; }
+}
+function saveRepeat(v) {
+ try { localStorage.setItem('meshbay_music_repeat', v); } catch { /* per-device only */ }
+}
+
+function MusicPlayerBar({ transportRef, gekRef, queue }) {
+ const audioRef = useRef(null);
+ const blobCacheRef = useRef(new Map()); // file id -> { url, order: insertion index }
+ const blobInsertRef = useRef(0);
+ const loadTokenRef = useRef(0);
+
+ const [tracks, setTracks] = useState([]);
+ const [order, setOrder] = useState([]);
+ const [pos, setPos] = useState(0); // index into `order`
+ const [playing, setPlaying] = useState(false);
+ const [loading, setLoading] = useState(false);
+ const [error, setError] = useState('');
+ const [shuffle, setShuffle] = useState(loadShuffle);
+ const [repeat, setRepeat] = useState(loadRepeat); // 'off' | 'all' | 'one'
+ const [volume, setVolume] = useState(loadVolume);
+ const [currentTime, setCurrentTime] = useState(0);
+ const [duration, setDuration] = useState(0);
+
+ const currentTrack = tracks[order[pos]] || null;
+
+ const evictOldBlobs = useCallback(() => {
+ const cache = blobCacheRef.current;
+ while (cache.size > MAX_CACHED_BLOBS) {
+ let oldestId = null, oldestOrder = Infinity;
+ for (const [id, v] of cache) {
+ if (v.order < oldestOrder) { oldestOrder = v.order; oldestId = id; }
+ }
+ if (oldestId == null) break;
+ URL.revokeObjectURL(cache.get(oldestId).url);
+ cache.delete(oldestId);
+ }
+ }, []);
+
+ const fetchTrackBlob = useCallback(async (entry) => {
+ const cached = blobCacheRef.current.get(entry.id);
+ if (cached) return cached.url;
+ const transport = transportRef.current;
+ if (!transport || !transport.connected) throw new Error(t('music.err_transport'));
+ const totalChunks = Math.ceil(entry.size / CHUNK_SIZE);
+ const chunks = await pipelinedDownload(transport, gekRef.current, entry.id, totalChunks);
+ const blob = new Blob(chunks, { type: guessMime(entry.name) });
+ const url = URL.createObjectURL(blob);
+ blobCacheRef.current.set(entry.id, { url, order: blobInsertRef.current++ });
+ evictOldBlobs();
+ return url;
+ }, [transportRef, gekRef, evictOldBlobs]);
+
+ // Silently warms the cache for the next track so pressing "next" doesn't
+ // visibly wait (musicbay.md §2.2) — best-effort, never surfaces an error.
+ const prefetchNext = useCallback((fromPos) => {
+ const nextEntry = tracks[order[fromPos + 1]];
+ if (!nextEntry || blobCacheRef.current.has(nextEntry.id)) return;
+ fetchTrackBlob(nextEntry).catch(() => {});
+ }, [tracks, order, fetchTrackBlob]);
+
+ // (Re)initialize the queue whenever the shell hands over a new one.
+ // `queue.nonce` changes on every call to onPlayQueue, including "play the
+ // same album again from track 0" — a fresh Date.now() each time, so this
+ // effect always re-runs rather than bailing out on reference equality.
+ useEffect(() => {
+ if (!queue) return;
+ const n = queue.tracks.length;
+ const initialOrder = shuffle ? shuffledOrder(n, queue.startIndex) : Array.from({ length: n }, (_, i) => i);
+ const startPos = shuffle ? 0 : queue.startIndex;
+ setTracks(queue.tracks);
+ setOrder(initialOrder);
+ setPos(startPos);
+ setError('');
+ // Playback itself starts from the effect below, keyed on [tracks, order, pos].
+ }, [queue]);
+
+ // Loads and plays whatever `pos` now points to. Runs after the queue
+ // effect above (pos/order/tracks all just changed together) and also
+ // after skipNext/skipPrev/onEnded update `pos` alone.
+ useEffect(() => {
+ if (!currentTrack) return;
+ const myToken = ++loadTokenRef.current;
+ setLoading(true);
+ setError('');
+ (async () => {
+ try {
+ const url = await fetchTrackBlob(currentTrack);
+ if (loadTokenRef.current !== myToken) return; // superseded by a newer skip
+ const audio = audioRef.current;
+ if (!audio) return;
+ audio.src = url;
+ audio.currentTime = 0;
+ await audio.play();
+ setPlaying(true);
+ prefetchNext(pos);
+ } catch (err) {
+ if (loadTokenRef.current !== myToken) return;
+ setError(err.message || String(err));
+ setPlaying(false);
+ } finally {
+ if (loadTokenRef.current === myToken) setLoading(false);
+ }
+ })();
+ // eslint-disable-next-line
+ }, [currentTrack && currentTrack.id]);
+
+ useEffect(() => {
+ const audio = audioRef.current;
+ if (audio) audio.volume = volume;
+ saveVolume(volume);
+ }, [volume]);
+
+ const skipTo = useCallback((newPos) => setPos(newPos), []);
+
+ const skipNext = useCallback(() => {
+ if (order.length === 0) return;
+ if (pos + 1 < order.length) { skipTo(pos + 1); return; }
+ if (repeat === 'all') { skipTo(0); return; }
+ setPlaying(false); // end of queue, nothing to repeat
+ }, [pos, order.length, repeat, skipTo]);
+
+ const skipPrev = useCallback(() => {
+ 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.
+ const audio = audioRef.current;
+ if (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') {
+ const audio = audioRef.current;
+ if (audio) { audio.currentTime = 0; audio.play().catch(() => {}); }
+ return;
+ }
+ skipNext();
+ }, [repeat, skipNext]);
+
+ const togglePlaying = useCallback(() => {
+ const audio = audioRef.current;
+ if (!audio) return;
+ if (playing) { audio.pause(); setPlaying(false); }
+ else { audio.play().then(() => setPlaying(true)).catch(() => {}); }
+ }, [playing]);
+
+ const toggleShuffle = useCallback(() => {
+ setShuffle((prev) => {
+ const next = !prev;
+ saveShuffle(next);
+ // Reshuffling keeps the currently playing track in place — turning
+ // shuffle on mid-album must not interrupt what's already playing.
+ const currentId = tracks[order[pos]] && tracks[order[pos]].id;
+ const currentIdx = tracks.findIndex((tr) => tr.id === currentId);
+ const newOrder = next
+ ? shuffledOrder(tracks.length, currentIdx)
+ : Array.from({ length: tracks.length }, (_, i) => i);
+ setOrder(newOrder);
+ setPos(next ? 0 : currentIdx);
+ return next;
+ });
+ }, [tracks, order, pos]);
+
+ const cycleRepeat = useCallback(() => {
+ setRepeat((prev) => {
+ const next = prev === 'off' ? 'all' : prev === 'all' ? 'one' : 'off';
+ saveRepeat(next);
+ return next;
+ });
+ }, []);
+
+ const seek = useCallback((e) => {
+ const audio = audioRef.current;
+ if (audio && isFinite(audio.duration)) audio.currentTime = parseFloat(e.target.value);
+ }, []);
+
+ if (!currentTrack) return null;
+
+ const title = currentTrack.display_title || currentTrack.name;
+ const repeatLabel = repeat === 'off' ? t('music.player_repeat_off')
+ : repeat === 'all' ? t('music.player_repeat_all') : t('music.player_repeat_one');
+
+ return html`
+ <div class="music-player-bar">
+ <audio ref=${audioRef}
+ onTimeUpdate=${(e) => setCurrentTime(e.target.currentTime)}
+ onDurationChange=${(e) => setDuration(e.target.duration)}
+ onEnded=${onEnded} />
+ <div class="music-player-info">
+ <${Icon} name="music" cls="music-player-icon" />
+ <div class="music-player-text">
+ <div class="music-player-title">${title}</div>
+ <div class="music-player-sub">
+ ${[currentTrack.artist, currentTrack.album].filter(Boolean).join(' · ')}
+ </div>
+ </div>
+ </div>
+ <div class="music-player-transport">
+ <button class="music-player-btn ${shuffle ? 'active' : ''}"
+ title=${t('music.player_shuffle')} onClick=${toggleShuffle}>
+ <${Icon} name="shuffle" />
+ </button>
+ <button class="music-player-btn" title=${t('music.player_prev')} onClick=${skipPrev}>
+ <${Icon} name="skip-prev" />
+ </button>
+ <button class="music-player-btn music-player-play" title=${playing ? t('music.player_pause') : t('music.player_play')}
+ onClick=${togglePlaying} disabled=${loading}>
+ ${loading ? html`<span class="spinner"></span>` : html`<${Icon} name=${playing ? 'pause' : 'play'} />`}
+ </button>
+ <button class="music-player-btn" title=${t('music.player_next')} onClick=${skipNext}>
+ <${Icon} name="skip-next" />
+ </button>
+ <button class="music-player-btn ${repeat !== 'off' ? 'active' : ''} music-player-repeat-${repeat}"
+ title=${repeatLabel} onClick=${cycleRepeat}>
+ <${Icon} name="repeat" />
+ ${repeat === 'one' && html`<span class="music-repeat-badge">1</span>`}
+ </button>
+ </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>
+ </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')}
+ onInput=${(e) => setVolume(parseFloat(e.target.value))} />
+ </div>
+ ${error && html`<div class="music-player-error">${error}</div>`}
+ </div>
+ `;
+}
+
+export { MusicPlayerBar, formatTime };
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/style.css b/packages/meshbay-hub/src/meshbay_hub/static/style.css
index 446a751..b4e4089 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/style.css
+++ b/packages/meshbay-hub/src/meshbay_hub/static/style.css
@@ -2685,3 +2685,253 @@ a.transfer-name {
object-fit: cover;
border-radius: 4px;
}
+
+/* ── Music app (docs/musicbay.md) ─────────────────────────────────────────
+ Reuses .video-overlay/.video-top-bar/.video-title/.video-close, .tb-btn/
+ .tb-search, .video-flat-list/.video-flat-row/.video-flat-info/
+ .video-flat-title/.video-flat-sub/.video-flat-folder/.video-flat-chevron
+ and .video-thumb-empty as-is — only what's genuinely different from
+ Videos (album art is square, not 2:3; there is a persistent player bar;
+ grouping is artist -> album, not movie/show) gets its own rule below. */
+
+.music-artist-section { margin-bottom: 22px; }
+.music-artist-heading {
+ font-size: 0.95em;
+ font-weight: 600;
+ margin: 0 0 10px;
+ color: var(--text);
+}
+
+.music-grid {
+ display: grid;
+ grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
+ gap: 16px;
+}
+.music-tile-slot { min-height: 200px; }
+
+.music-card {
+ cursor: pointer;
+ border-radius: 8px;
+ overflow: hidden;
+ background: var(--bg-raised);
+ border: 1px solid var(--border);
+ transition: border-color 0.12s, transform 0.12s;
+}
+.music-card:hover { border-color: var(--accent); transform: translateY(-2px); }
+
+.music-cover {
+ width: 100%;
+ aspect-ratio: 1 / 1;
+ object-fit: cover;
+ display: block;
+ background: var(--bg-surface);
+}
+.music-cover.video-thumb-empty { aspect-ratio: 1 / 1; }
+
+.music-card-info { padding: 8px 10px; }
+.music-card-title {
+ font-size: 0.88em;
+ font-weight: 600;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+.music-card-sub {
+ font-size: 0.78em;
+ color: var(--text-dim);
+ margin-top: 2px;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+/* Detail modal — sits inside the shared .video-overlay */
+
+.music-detail {
+ width: min(560px, 92vw);
+ max-height: calc(100vh - 100px);
+ margin-top: 60px;
+ background: var(--bg-surface);
+ border-radius: 10px;
+ overflow: hidden;
+ display: flex;
+ flex-direction: column;
+}
+.music-detail .video-top-bar {
+ position: static;
+ background: var(--bg-raised);
+ border-bottom: 1px solid var(--border);
+}
+.music-detail .video-title { color: var(--text); }
+.music-detail .video-close { background: var(--bg-surface); color: var(--text); }
+.music-detail .video-close:hover { background: var(--border); }
+
+.music-detail-body { padding: 16px 20px; overflow-y: auto; }
+.music-detail-header {
+ display: flex;
+ align-items: flex-start;
+ gap: 14px;
+ margin-bottom: 14px;
+}
+.music-detail-cover {
+ width: 96px;
+ height: 96px;
+ aspect-ratio: 1 / 1;
+ object-fit: cover;
+ border-radius: 6px;
+ flex-shrink: 0;
+ background: var(--bg-raised);
+}
+.music-detail-meta { min-width: 0; }
+.music-detail-artist { font-weight: 600; font-size: 0.95em; }
+.music-detail-date { font-size: 0.8em; color: var(--text-dim); margin: 2px 0 8px; }
+
+.music-tracklist { display: flex; flex-direction: column; gap: 2px; }
+.music-track-row {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ width: 100%;
+ padding: 6px 8px;
+ background: none;
+ border: 1px solid transparent;
+ border-radius: 6px;
+ color: var(--text);
+ font-size: 0.85em;
+ cursor: pointer;
+ text-align: left;
+}
+.music-track-row:hover { background: var(--bg-raised); border-color: var(--border); }
+.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; }
+
+/* Mode B — flat list. .video-flat-thumb-slot's sizing is reused verbatim;
+ only the empty-box icon differs, so it gets a name of its own. */
+.music-flat-thumb {
+ width: 64px;
+ height: 40px;
+ flex-shrink: 0;
+ border-radius: 4px;
+ border: 1px solid var(--border);
+}
+
+/* ── Persistent player bar (docs/musicbay.md §2.3) ────────────────────────
+ `position: sticky`, not `fixed` — deliberately: CLAUDE.md's own history
+ records more than one layout bug from a fixed-position element quietly
+ double-reserving space against a page that also sized itself against the
+ viewport. Sticky stays in normal flow, so it never needs a height this
+ stylesheet has to know about anywhere else. */
+.music-player-bar {
+ position: sticky;
+ bottom: 0;
+ z-index: 50;
+ display: flex;
+ align-items: center;
+ gap: 16px;
+ flex-wrap: wrap;
+ margin-top: 16px;
+ padding: 8px 16px;
+ background: var(--bg-raised);
+ border: 1px solid var(--border);
+ border-radius: 10px;
+}
+
+.music-player-info {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ min-width: 0;
+ flex: 1 1 160px;
+}
+.music-player-icon { width: 20px; height: 20px; color: var(--text-dim); flex-shrink: 0; }
+.music-player-text { min-width: 0; }
+.music-player-title {
+ font-size: 0.85em;
+ font-weight: 600;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+.music-player-sub {
+ font-size: 0.75em;
+ color: var(--text-dim);
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.music-player-transport {
+ display: flex;
+ align-items: center;
+ gap: 4px;
+ flex-shrink: 0;
+}
+.music-player-btn {
+ background: none;
+ border: none;
+ color: var(--text-dim);
+ width: 32px;
+ height: 32px;
+ border-radius: 50%;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ cursor: pointer;
+ position: relative;
+}
+.music-player-btn:hover { background: var(--bg-surface); color: var(--text); }
+.music-player-btn.active { color: var(--accent); }
+.music-player-btn .icon { width: 16px; height: 16px; }
+.music-player-play {
+ width: 38px;
+ height: 38px;
+ background: var(--accent);
+ color: var(--accent-text);
+}
+.music-player-play:hover { background: var(--accent-hover); color: var(--accent-text); }
+.music-player-play:disabled { opacity: 0.6; cursor: not-allowed; }
+.music-repeat-badge {
+ position: absolute;
+ bottom: 1px;
+ right: 1px;
+ font-size: 0.55em;
+ font-weight: 700;
+ line-height: 1;
+ background: var(--accent);
+ color: var(--accent-text);
+ border-radius: 50%;
+ width: 11px;
+ height: 11px;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+}
+
+.music-player-seek {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ flex: 2 1 200px;
+ min-width: 120px;
+}
+.music-player-time { font-size: 0.75em; color: var(--text-dim); flex-shrink: 0; width: 34px; }
+.music-player-time:last-child { text-align: right; }
+.music-player-seek input[type="range"] { flex: 1; }
+
+.music-player-volume {
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ flex-shrink: 0;
+}
+.music-player-volume .icon { width: 16px; height: 16px; color: var(--text-dim); }
+.music-player-volume input[type="range"] { width: 80px; }
+
+.music-player-error { flex-basis: 100%; font-size: 0.78em; color: var(--error); }
+
+@media (max-width: 640px) {
+ .music-player-bar { gap: 8px; }
+ .music-player-seek { order: 4; flex-basis: 100%; }
+ .music-player-volume { display: none; }
+}
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/transport.js b/packages/meshbay-hub/src/meshbay_hub/static/transport.js
index 763e279..e0e4a64 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/transport.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/transport.js
@@ -109,6 +109,8 @@ class MeshBayTransport {
set onTmdbConfig(fn) { this._onTmdbConfig = fn; }
set onTmdbEnabled(fn) { this._onTmdbEnabled = fn; }
set onVideoRoot(fn) { this._onVideoRoot = fn; }
+ set onMusicbrainzConfig(fn) { this._onMusicbrainzConfig = fn; }
+ set onMusicbrainzEnabled(fn) { this._onMusicbrainzEnabled = fn; }
set onIndexProgress(fn) { this._onIndexProgress = fn; }
get sessionKeys() { return this._sessionKeys; }
@@ -603,6 +605,63 @@ class MeshBayTransport {
return msg;
}
+ /**
+ * MusicBrainz metadata for one track's path (Music app, docs/musicbay.md
+ * §4.3) — same shape as fetchMediaMeta, minus a season/episode concept:
+ * album-level (release), resolved from the track's own artist/album
+ * fields already in the index. `confidence: 0` means no confident match
+ * (or MusicBrainz off for this group, or nothing configured) — the caller
+ * falls back to the embedded/no cover it already had, not an error.
+ */
+ async fetchMusicMeta(path) {
+ const msg = await this._sendAndWait({ type: 'music_meta_req', v: '0.8', path });
+ if (msg.type === 'error') throw new Error(msg.detail);
+ return msg;
+ }
+
+ /**
+ * Set/clear the node-wide MusicBrainz contact string — the User-Agent
+ * identity MusicBrainz's usage policy asks for, not a credential (there
+ * is none, docs/musicbay.md §3.1). Signed like setTmdbConfig: this turns
+ * on outbound third-party network traffic the operator has to agree to.
+ * `contact: ''` explicitly clears it (reverting to "no calls at all");
+ * omit it (undefined/null) to leave whatever is stored unchanged.
+ */
+ async setMusicbrainzConfig(contact, signFn) {
+ const msg = await this._sendAndWait({
+ type: 'musicbrainz_config', v: '0.8',
+ contact: contact === undefined ? null : contact,
+ });
+ if (msg.type === 'error') throw new Error(msg.detail);
+ if (msg.type === 'admin_challenge') {
+ // Must match the node's subject byte-for-byte (webrtc_server.py
+ // _do_musicbrainz_config) — not a secret like tmdb_config's token,
+ // but still kept out of the audit log as free text: only whether one
+ // was supplied travels in the subject.
+ const subject = `contact_configured=${contact ? 'yes' : 'no'}`;
+ return this._authorizeAdminOp(msg, 'musicbrainz_config', subject, signFn);
+ }
+ return msg;
+ }
+
+ /**
+ * Whether MusicBrainz lookups run for this group at all — per-group from
+ * the start (docs/musicbay.md §3.2/§6). Signed like setTmdbEnabled.
+ */
+ async setMusicbrainzEnabled(enabled, signFn) {
+ const msg = await this._sendAndWait({
+ type: 'musicbrainz_enabled', v: '0.8', enabled: Boolean(enabled),
+ });
+ if (msg.type === 'error') throw new Error(msg.detail);
+ if (msg.type === 'admin_challenge') {
+ // Python's f"{bool}" is "True"/"False", not JS's lowercase — must
+ // match webrtc_server.py _do_musicbrainz_enabled byte-for-byte.
+ const subject = enabled ? 'True' : 'False';
+ return this._authorizeAdminOp(msg, 'musicbrainz_enabled', subject, signFn);
+ }
+ return msg;
+ }
+
async fetchStreamSegment(fileId, segmentIndex, segmentDuration) {
const msg = await this._sendAndWait({
type: 'stream_seg',
@@ -1320,6 +1379,9 @@ class MeshBayTransport {
? `chunk:${obj.file_id}:${obj.chunk_index}`
: obj.type === 'ping' ? `ping:${obj.token}`
: obj.type === 'media_meta_req' ? `media_meta:${obj.path}`
+ // Same reordering hazard as media_meta_req: an album grid fires
+ // one music_meta_req per visible tile, several at a time.
+ : obj.type === 'music_meta_req' ? `music_meta:${obj.path}`
// Same reordering hazard as media_meta_req: a season-tab bar or a
// search box can have more than one of these in flight at once.
: obj.type === 'season_meta_req' ? `season_meta:${obj.tmdb_id}:${obj.season}`
@@ -1458,6 +1520,17 @@ class MeshBayTransport {
this._onVideoRoot(msg.path || '');
}
+ // Node-wide, like tmdb_config_ack above — no token equivalent to hide,
+ // only whether a contact string is configured (docs/musicbay.md §3.2).
+ if (msg.type === 'musicbrainz_config_ack' && this._onMusicbrainzConfig) {
+ this._onMusicbrainzConfig({ contactConfigured: Boolean(msg.contact_configured) });
+ }
+
+ // Per-group, like tmdb_enabled_ack above.
+ if (msg.type === 'musicbrainz_enabled_ack' && this._onMusicbrainzEnabled) {
+ this._onMusicbrainzEnabled(Boolean(msg.enabled));
+ }
+
// The operator's node is scanning — never the entries themselves, just
// enough to animate a presence dot. Pushed periodically while it runs,
// plus once more on the transition back to idle (daemon.py
@@ -1560,6 +1633,16 @@ class MeshBayTransport {
// Same reasoning as media_meta_resp: keyed, not arrival-order, and
// "nobody's waiting any more" must not fall through either.
+ if (msg.type === 'music_meta_resp') {
+ const key = `music_meta:${msg.path}`;
+ for (const [, handler] of this._pending) {
+ if (handler._key === key) { handler.resolve(msg); return; }
+ }
+ return;
+ }
+
+ // Same reasoning as media_meta_resp: keyed, not arrival-order, and
+ // "nobody's waiting any more" must not fall through either.
if (msg.type === 'season_meta_resp') {
const key = `season_meta:${msg.tmdb_id}:${msg.season}`;
for (const [, handler] of this._pending) {
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/video-app.js b/packages/meshbay-hub/src/meshbay_hub/static/video-app.js
index ece95ea..de61839 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/video-app.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/video-app.js
@@ -144,7 +144,7 @@ function LazyTile({ cls = 'video-tile-slot', children }) {
const _thumbBlobCache = new Map();
function MediaThumb({
- thumbHash, transportRef, gekRef, alt, cls = 'video-thumb', onReady,
+ thumbHash, transportRef, gekRef, alt, cls = 'video-thumb', onReady, emptyIcon = 'video',
}) {
const [blobUrl, setBlobUrl] = useState(() => _thumbBlobCache.get(thumbHash) || null);
@@ -183,7 +183,7 @@ function MediaThumb({
return () => { cancelled = true; };
}, [thumbHash]);
- if (!blobUrl) return html`<div class="${cls} video-thumb-empty"><${Icon} name="video" /></div>`;
+ if (!blobUrl) return html`<div class="${cls} video-thumb-empty"><${Icon} name=${emptyIcon} /></div>`;
return html`<img class=${cls} src=${blobUrl} alt=${alt || ''} loading="lazy" />`;
}
@@ -813,4 +813,9 @@ function VideoApp({
`;
}
-export { VideoApp };
+// MediaThumb and LazyTile are also used by music-app.js (docs/musicbay.md
+// §7.1): the same "decrypt a thumb_hash via the chunk path into a cached
+// blob" and "mount only once actually scrolled near" mechanisms apply to a
+// track's cover art unchanged, so Music imports them here rather than
+// re-implementing (apps.md §4's checklist).
+export { VideoApp, MediaThumb, LazyTile };