aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-hub/src
diff options
context:
space:
mode:
authorChristophe Besson <cbesson@gmail.com>2026-08-24 10:04:46 +0200
committerChristophe Besson <cbesson@gmail.com>2026-08-24 10:04:46 +0200
commit6af05abf410bbd038ce7fa6915a659defc509071 (patch)
tree09b1c941fa446b077ff51282fa18250998528263 /packages/meshbay-hub/src
parentc4981454078a59f776d484f0f1828f2fc5eaad09 (diff)
downloadmeshbay-6af05abf410bbd038ce7fa6915a659defc509071.tar.gz
feat(node,hub): add Videos group app (poster grid, flat list, TMDB metadata)
Implements docs/mediacenter.md: a "Videos" group application built on the existing files index rather than a separate catalogue. On the node side, new indexer enrichment (technical probe, filename/season parsing, thumbnail generation) runs per-file once an operator has chosen a video_root for the group, plus a TMDB client for on-demand poster/metadata lookups (never client-side, thumbnails delivered over the existing chunk path). On the hub side, a new video-app.js renders a lazily-mounted poster grid or a thumbnail-only flat list, with TMDB entirely optional per group. Along the way: the global apps registry now drives Settings' default-tab picker instead of a hardcoded list, and the video_root is configured from group Settings (like uploads) rather than from Files, with the node refusing to run any TMDB/thumbnail work until one is set. Fixes several bugs found via live testing against a real library, notably a race between two effects writing the same "image ready" state that could leave a poster grid spinning forever on a same-tab revisit — see mediacenter.md §5.4 for the full account of each one.
Diffstat (limited to 'packages/meshbay-hub/src')
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/api/webapp.py2
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/app.js4
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/apps.js2
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/group-page.js37
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/group-settings.js211
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/icon.js2
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/de.js29
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/en.js29
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/es.js29
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js29
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/it.js29
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js29
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js29
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js31
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js29
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js29
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/style.css225
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/transport.js109
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/video-app.js599
19 files changed, 1466 insertions, 17 deletions
diff --git a/packages/meshbay-hub/src/meshbay_hub/api/webapp.py b/packages/meshbay-hub/src/meshbay_hub/api/webapp.py
index e11e718..36d94bb 100644
--- a/packages/meshbay-hub/src/meshbay_hub/api/webapp.py
+++ b/packages/meshbay-hub/src/meshbay_hub/api/webapp.py
@@ -33,7 +33,7 @@ _ASSETS = ("style.css", "keyderive.js", "crypto.js", "transport.js", "app.js",
# app.js or group-page.js, so a change to any of them is a change
# to what the browser must fetch.
"icon.js", "file-utils.js", "hub-client.js", "apps.js",
- "chat-app.js", "files-app.js", "video-player.js",
+ "chat-app.js", "files-app.js", "video-player.js", "video-app.js",
"group-settings.js", "group-page.js")
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/app.js b/packages/meshbay-hub/src/meshbay_hub/static/app.js
index 044457b..58ffe8e 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/app.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/app.js
@@ -16,6 +16,7 @@ import {
refreshAccessToken,
} from './hub-client.js';
import { GroupPage } from './group-page.js';
+import { APPS } from './apps.js';
// ── Constants ────────────────────────────────────────────────────────────────
@@ -1731,8 +1732,7 @@ function SettingsPage({ user, theme, onThemeChange, groups, onPrefsChange }) {
<span class="settings-label">${t('settings.default_tab')}</span>
<select class="settings-select" value=${defaultTab}
onChange=${changeDefaultTab}>
- <option value="chat">${t('group.tab_chat')}</option>
- <option value="files">${t('group.tab_files')}</option>
+ ${APPS.map((a) => html`<option key=${a.key} value=${a.key}>${t(a.labelKey)}</option>`)}
<option value="settings">${t('group.tab_settings')}</option>
</select>
</div>
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/apps.js b/packages/meshbay-hub/src/meshbay_hub/static/apps.js
index 0db713c..88b4a0f 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/apps.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/apps.js
@@ -1,5 +1,6 @@
import { ChatPanel } from './chat-app.js';
import { FilesPanel } from './files-app.js';
+import { VideoApp } from './video-app.js';
/**
* Every group "application", in tab order.
@@ -16,6 +17,7 @@ import { FilesPanel } from './files-app.js';
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 },
];
/** 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 29f3602..55f8e36 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/group-page.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/group-page.js
@@ -80,6 +80,12 @@ function GroupPage({ groupId, group, token, username, userId, userPrefs,
// to the operator in Settings, not enforced from here (indexer.py owns
// that). Null until the handshake ack arrives.
const [scanSettings, setScanSettings] = useState(null);
+ // TMDB on/off + whether a custom token is set, node-wide (not per-group) —
+ // docs/mediacenter.md §5.5. Null until the handshake ack arrives.
+ const [tmdbConfig, setTmdbConfig] = useState(null);
+ // 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('');
// 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
@@ -127,20 +133,24 @@ function GroupPage({ groupId, group, token, username, userId, userPrefs,
cacheGroupIndex(groupId, group ? group.name : groupId, fresh);
}, [groupId, group]);
- // additions/deletions only (daemon.py _broadcast_index_change, once there
- // is a previous snapshot to diff against) — applied on top of whatever
- // applyIndex last put in `entries`, instead of replacing the whole table
- // for one changed file.
+ // additions/deletions/updates (daemon.py _broadcast_index_change, once
+ // there is a previous snapshot to diff against) — applied on top of
+ // whatever applyIndex last put in `entries`, instead of replacing the
+ // whole table for one changed file. `updates` is the Videos app's async
+ // enrichment (duration/thumb_hash/display_title/...) arriving for a file
+ // already in the table — same id, new fields (see group_index.py diff()).
const applyIndexDelta = useCallback((deltaMsg) => {
setEntries((prev) => {
const deletions = new Set(deltaMsg.deletions || []);
const kept = prev.filter((e) => !deletions.has(e.id));
+ const updates = new Map((deltaMsg.updates || []).map((e) => [e.id, e]));
+ const updated = kept.map((e) => updates.get(e.id) || e);
// The index is keyed by content hash: an addition whose id is already
// present is the same duplicate-content case indexer.py's own
// reconcile sweep leaves alone, not a second row for one file.
- const keptIds = new Set(kept.map((e) => e.id));
+ const keptIds = new Set(updated.map((e) => e.id));
const additions = (deltaMsg.additions || []).filter((e) => !keptIds.has(e.id));
- const fresh = kept.concat(additions);
+ const fresh = updated.concat(additions);
cacheGroupIndex(groupId, group ? group.name : groupId, fresh);
return fresh;
});
@@ -196,11 +206,19 @@ function GroupPage({ groupId, group, token, username, userId, userPrefs,
setMemberUpload(ack.member_upload !== false);
setEnabledApps(ack.enabled_apps || null);
setScanSettings(ack.scan_settings || null);
+ setTmdbConfig({
+ enabled: ack.tmdb_enabled !== false,
+ tokenCustomized: !!ack.tmdb_token_customized,
+ language: ack.tmdb_language || '',
+ });
+ setVideoRoot(ack.video_root || '');
// 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.
transport.onUploadPolicy = (allowed) => setMemberUpload(allowed);
transport.onAppsEnabled = (apps) => setEnabledApps(apps);
+ transport.onTmdbConfig = (cfg) => setTmdbConfig(cfg);
+ transport.onVideoRoot = (path) => setVideoRoot(path);
// 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
@@ -390,6 +408,8 @@ function GroupPage({ groupId, group, token, username, userId, userPrefs,
entries, nodeDirs, nodeRoots, setEntries, setNodeDirs, setNodeRoots, applyIndex,
isNodeAdmin, operatorPaired, mayUpload, userId, setError, onPreview,
onRefreshIndex: refreshIndex, onActivity: touchActivity,
+ videoRoot, onVideoRoot: (path) => setVideoRoot(path),
+ tmdbConfig,
};
return html`
@@ -503,6 +523,11 @@ function GroupPage({ groupId, group, token, username, userId, userPrefs,
onEnabledApps=${(keys) => setEnabledApps(keys)}
scanSettings=${scanSettings}
onScanSettings=${(s) => setScanSettings(s)}
+ tmdbConfig=${tmdbConfig}
+ onTmdbConfig=${(cfg) => setTmdbConfig(cfg)}
+ entries=${entries} nodeDirs=${nodeDirs}
+ videoRoot=${videoRoot}
+ onVideoRoot=${(path) => setVideoRoot(path)}
onLeft=${onLeft}
onPaired=${() => setOperatorPaired(true)} />
`}
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 0b64a03..4ae832f 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/group-settings.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/group-settings.js
@@ -1,12 +1,22 @@
import {
- html, useState, useEffect, useCallback,
+ html, useState, useEffect, useCallback, useMemo,
} from './vendor/htm-preact.js';
-import { t } from './i18n.js';
+import { t, getLocale, LOCALES } from './i18n.js';
import { Icon } from './icon.js';
import { hubFetch, navigate } from './hub-client.js';
import { APPS } from './apps.js';
import * as platform from './platform.js';
+// MeshBay's own locale codes (i18n.js LOCALES) to the language tag TMDB
+// expects — the two don't share a format (MeshBay's "en" vs TMDB's
+// required region, "en-US"). Used only to pre-fill the TMDB language field
+// with the operator's own current UI language, a reasonable default they
+// can still change; the node never guesses this on its own.
+const TMDB_LANGUAGE_BY_LOCALE = {
+ en: 'en-US', fr: 'fr-FR', es: 'es-ES', 'pt-BR': 'pt-BR', 'zh-CN': 'zh-CN',
+ ja: 'ja-JP', de: 'de-DE', it: 'it-IT', nl: 'nl-NL', pl: 'pl-PL',
+};
+
// ── Members Panel ────────────────────────────────────────────────────────
/**
@@ -23,6 +33,8 @@ function GroupSettingsPanel({ groupId, group, token, transportRef, gekRef,
memberUpload, onMemberUpload,
enabledApps, onEnabledApps,
scanSettings, onScanSettings,
+ tmdbConfig, onTmdbConfig,
+ entries, nodeDirs, videoRoot, onVideoRoot,
onPaired, onLeft }) {
const [members, setMembers] = useState([]);
const [adminId, setAdminId] = useState('');
@@ -244,6 +256,120 @@ function GroupSettingsPanel({ groupId, group, token, transportRef, gekRef,
}
}, [transportRef, onScanSettings, reconcileMinutes, debounceSeconds]);
+ const [tmdbBusy, setTmdbBusy] = useState(false);
+ const [tmdbMsg, setTmdbMsg] = useState('');
+ const [tmdbTokenDraft, setTmdbTokenDraft] = useState('');
+ const tmdbEnabled = tmdbConfig ? tmdbConfig.enabled : true;
+ // Pre-filled from the operator's own current UI language the first time
+ // this renders with nothing configured yet — a sensible default, not a
+ // claim about what the node is actually using until they hit Save.
+ const [tmdbLanguage, setTmdbLanguage] = useState(
+ () => (tmdbConfig && tmdbConfig.language)
+ || TMDB_LANGUAGE_BY_LOCALE[getLocale()] || 'en-US');
+ useEffect(() => {
+ if (tmdbConfig && tmdbConfig.language) setTmdbLanguage(tmdbConfig.language);
+ }, [tmdbConfig && tmdbConfig.language]);
+
+ /**
+ * TMDB on/off, an optional custom API token, and the language TMDB is
+ * queried in — node-wide, not per-group (docs/mediacenter.md §5.5). Same
+ * shape as saveScanSettings: signed, and the toggle does not claim
+ * success until the node confirms it. The token field is cleared after a
+ * save either way: it is never echoed back by the node (tmdb_config_ack
+ * carries only whether one is set, never the value), so there is
+ * nothing to keep showing.
+ */
+ const saveTmdbConfig = useCallback(async (nextEnabled) => {
+ const transport = transportRef && transportRef.current;
+ setTmdbMsg('');
+ setTmdbBusy(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 token = tmdbTokenDraft.trim();
+ await transport.setTmdbConfig(nextEnabled, token || undefined, tmdbLanguage, signFn);
+ setTmdbTokenDraft('');
+ if (onTmdbConfig) {
+ onTmdbConfig({
+ enabled: nextEnabled,
+ tokenCustomized: token
+ ? true
+ : (tmdbConfig ? tmdbConfig.tokenCustomized : false),
+ language: tmdbLanguage,
+ });
+ }
+ setTmdbMsg(t('settings_node.scan_saved'));
+ } catch (err) {
+ setTmdbMsg(err.message);
+ } finally {
+ setTmdbBusy(false);
+ }
+ }, [transportRef, onTmdbConfig, tmdbTokenDraft, tmdbConfig, tmdbLanguage]);
+
+ // 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
+ // `nodeDirs` covers ones with nothing in them yet. A flat, depth-indented
+ // <select> rather than a live folder browser: choosing the Videos root is
+ // a rare, one-off decision, not something worth a whole navigable tree for.
+ const videoRootFolders = useMemo(() => {
+ const set = new Set();
+ const addAncestors = (path) => {
+ if (!path) return;
+ const parts = path.split('/');
+ for (let i = 1; i <= parts.length; i++) set.add(parts.slice(0, i).join('/'));
+ };
+ for (const e of (entries || [])) addAncestors(e.path);
+ for (const d of (nodeDirs || [])) addAncestors(d);
+ return [...set].sort();
+ }, [entries, nodeDirs]);
+
+ const [videoRootDraft, setVideoRootDraft] = useState(videoRoot || '');
+ useEffect(() => { setVideoRootDraft(videoRoot || ''); }, [videoRoot]);
+ const [videoRootBusy, setVideoRootBusy] = useState(false);
+ const [videoRootMsg, setVideoRootMsg] = useState('');
+
+ /**
+ * Which folder is the Videos app's entry point for this group — same
+ * shape as toggleApp/saveScanSettings: signed, and the picker does not
+ * claim success until the node confirms it.
+ *
+ * Changing an *already-set* root is destructive to every member's Videos
+ * tab (a different set of files, possibly none in common) — the operator
+ * confirms that explicitly. Setting it for the first time is not: there is
+ * nothing yet to lose.
+ */
+ const saveVideoRoot = useCallback(async () => {
+ const next = videoRootDraft;
+ const current = videoRoot || '';
+ if (next === current) return;
+ if (current && !confirm(t('settings_node.video_root_change_confirm'))) return;
+ const transport = transportRef && transportRef.current;
+ setVideoRootMsg('');
+ setVideoRootBusy(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.setVideoRoot(next, signFn);
+ if (onVideoRoot) onVideoRoot(next);
+ setVideoRootMsg(t('settings_node.scan_saved'));
+ } catch (err) {
+ setVideoRootMsg(err.message);
+ } finally {
+ setVideoRootBusy(false);
+ }
+ }, [transportRef, onVideoRoot, videoRootDraft, videoRoot]);
+
const [removing, setRemoving] = useState('');
/**
@@ -463,6 +589,87 @@ function GroupSettingsPanel({ groupId, group, token, transportRef, gekRef,
</div>
`}
+ ${/* TMDB on/off + custom token, node-wide (docs/mediacenter.md §5.5) —
+ new outbound third-party traffic the node did not have before
+ the Videos app, so it is a signed operator setting like the
+ rest, not a display preference. */
+ isNodeAdmin && connected && html`
+ <div class="settings-section">
+ <h3 class="settings-heading">${t('settings_node.tmdb_title')}</h3>
+ <p class="settings-hint">${t('settings_node.tmdb_hint')}</p>
+ <div class="settings-row">
+ <label class="settings-label">
+ <input type="checkbox" checked=${tmdbEnabled} disabled=${tmdbBusy}
+ onChange=${(e) => saveTmdbConfig(e.target.checked)} />
+ ${' '}${tmdbEnabled ? t('settings_node.tmdb_enabled') : t('settings_node.tmdb_disabled')}
+ </label>
+ </div>
+ <div class="settings-row">
+ <label class="settings-label">
+ ${t('settings_node.tmdb_token_label')}
+ <input type="password" placeholder=${t('settings_node.tmdb_token_placeholder')}
+ value=${tmdbTokenDraft} disabled=${tmdbBusy}
+ onInput=${e => setTmdbTokenDraft(e.target.value)} />
+ </label>
+ <p class="settings-hint">
+ ${tmdbConfig && tmdbConfig.tokenCustomized
+ ? t('settings_node.tmdb_token_customized')
+ : t('settings_node.tmdb_token_default')}
+ </p>
+ </div>
+ <div class="settings-row">
+ <label class="settings-label">
+ ${t('settings_node.tmdb_language_label')}
+ <select value=${tmdbLanguage} disabled=${tmdbBusy}
+ onChange=${e => setTmdbLanguage(e.target.value)}>
+ ${LOCALES.map(l => html`
+ <option key=${l.code} value=${TMDB_LANGUAGE_BY_LOCALE[l.code]}>
+ ${l.name}
+ </option>
+ `)}
+ </select>
+ </label>
+ <p class="settings-hint">${t('settings_node.tmdb_language_hint')}</p>
+ </div>
+ <button class="btn btn-small btn-secondary" style="margin-top:8px"
+ disabled=${tmdbBusy} onClick=${() => saveTmdbConfig(tmdbEnabled)}>
+ ${tmdbBusy ? t('settings_node.scan_saving') : t('settings_node.tmdb_save')}
+ </button>
+ ${tmdbMsg && html`<p class="settings-hint">${tmdbMsg}</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
+ listing anything, and the node runs no TMDB/thumbnail work for
+ this group at all (daemon.py's _enrich_new_video_entries). */
+ isNodeAdmin && connected && html`
+ <div class="settings-section">
+ <h3 class="settings-heading">${t('settings_node.video_root_title')}</h3>
+ <p class="settings-hint">${t('settings_node.video_root_hint')}</p>
+ <div class="settings-row">
+ <label class="settings-label">
+ <select value=${videoRootDraft} disabled=${videoRootBusy}
+ onChange=${e => setVideoRootDraft(e.target.value)}>
+ <option value="">${t('settings_node.video_root_none')}</option>
+ ${videoRootFolders.map(p => html`
+ <option key=${p} value=${p}>
+ ${'  '.repeat(p.split('/').length - 1)}${p.split('/').pop()}
+ </option>
+ `)}
+ </select>
+ </label>
+ </div>
+ <button class="btn btn-small btn-secondary" style="margin-top:8px"
+ disabled=${videoRootBusy || videoRootDraft === (videoRoot || '')}
+ onClick=${saveVideoRoot}>
+ ${videoRootBusy ? t('settings_node.scan_saving') : t('settings_node.video_root_save')}
+ </button>
+ ${videoRootMsg && html`<p class="settings-hint">${videoRootMsg}</p>`}
+ </div>
+ `}
+
${/* Roots management (Electron-only, when node is local) */
nodeDetected && nodeRoots.length > 0 && html`
<div class="settings-section">
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/icon.js b/packages/meshbay-hub/src/meshbay_hub/static/icon.js
index 4dc24c0..84a5195 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/icon.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/icon.js
@@ -69,6 +69,8 @@ const ICON_PATHS = {
cast: ['M2 16.1A5 5 0 0 1 6.9 21', 'M2 12.05A9 9 0 0 1 12.95 21',
'M2 8V6a2 2 0 0 1 2-2h16a2 2 0 0 1 2 2v12a2 2 0 0 1-2 2h-6',
'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'],
};
// 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 866212f..6634cfb 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js
@@ -76,6 +76,7 @@ export default {
'group.default_name': 'Gruppe',
'group.tab_files': 'Dateien',
'group.tab_chat': 'Chat',
+ 'group.tab_video': 'Videos',
'group.tab_members': 'Mitglieder',
'group.tab_settings': "Einstellungen",
'members.danger_leave_hint': "Sie verlieren den Zugriff auf die Dateien und den Chat dieser Gruppe.",
@@ -156,6 +157,18 @@ export default {
'group.upload_indexing': 'wird indiziert …',
'video.err_transport': 'Transport nicht verbunden',
'video.err_mse': 'Codec wird für das Streaming nicht unterstützt: {codec}',
+ 'video.mode_poster': 'Poster',
+ 'video.mode_flat': 'Flache Liste',
+ 'video.empty': 'Keine Videos gefunden.',
+ 'video.no_root_configured': 'Für diese Gruppe ist noch kein Videos-Stammordner festgelegt — ein Operator kann in den Einstellungen einen auswählen.',
+ 'video.director': 'Regie',
+ 'video.specials': 'Specials',
+ 'video.season_n': 'Staffel {n}',
+ 'video.episode_n': 'Episode {n}',
+ 'video.n_episodes': {
+ one: '{n} Episode',
+ other: '{n} Episoden',
+ },
// LAN-Cast
'cast.start': 'Auf Gerät übertragen',
@@ -572,6 +585,22 @@ export default {
'settings_node.scan_save': 'Speichern',
'settings_node.scan_saving': 'Wird gespeichert…',
'settings_node.scan_saved': 'Gespeichert.',
+ 'settings_node.tmdb_title': 'TMDB-Metadaten',
+ 'settings_node.tmdb_hint': 'Ermöglicht der Videos-App, Poster, Beschreibungen und Besetzung von TMDB anzuzeigen. Deaktiviert bedeutet: nur Vorschaubilder, keine Anfrage an einen Drittanbieter.',
+ 'settings_node.tmdb_enabled': 'Aktiviert',
+ 'settings_node.tmdb_disabled': 'Deaktiviert',
+ 'settings_node.tmdb_token_label': 'Eigenes API-Token (optional)',
+ 'settings_node.tmdb_token_placeholder': 'Leer lassen, um das mitgelieferte Standard-Token zu verwenden',
+ 'settings_node.tmdb_token_customized': 'Ein eigenes Token ist gesetzt.',
+ 'settings_node.tmdb_token_default': 'Verwendet das mitgelieferte Standard-Token.',
+ 'settings_node.tmdb_save': 'Speichern',
+ 'settings_node.tmdb_language_label': 'Sprache',
+ 'settings_node.tmdb_language_hint': 'Gilt für alle — ein gemeinsamer Cache, keine Anfrage pro Betrachter.',
+ '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.video_root_save': 'Speichern',
+ 'settings_node.video_root_change_confirm': 'Das Ändern des Videos-Stammordners ersetzt, was jedes Mitglied im Videos-Tab sieht. Fortfahren?',
// Create-group wizard
'wizard.title': 'Gruppe erstellen',
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 a83b067..ebdb3e2 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js
@@ -77,6 +77,7 @@ export default {
'group.default_name': 'Group',
'group.tab_files': 'Files',
'group.tab_chat': 'Chat',
+ 'group.tab_video': 'Videos',
'group.tab_members': 'Members',
'group.tab_settings': "Settings",
'members.danger_leave_hint': "You will lose access to this group's files and chat.",
@@ -154,6 +155,18 @@ export default {
'group.upload_indexing': 'indexing…',
'video.err_transport': 'Transport not connected',
'video.err_mse': 'Codec not supported for streaming: {codec}',
+ 'video.mode_poster': 'Posters',
+ 'video.mode_flat': 'Flat list',
+ 'video.empty': 'No videos found.',
+ 'video.no_root_configured': 'No Videos root folder is set for this group yet — an operator can choose one in Settings.',
+ 'video.director': 'Director',
+ 'video.specials': 'Specials',
+ 'video.season_n': 'Season {n}',
+ 'video.episode_n': 'Episode {n}',
+ 'video.n_episodes': {
+ one: '{n} episode',
+ other: '{n} episodes',
+ },
// LAN cast
'cast.start': 'Cast to device',
@@ -396,6 +409,22 @@ export default {
'settings_node.scan_save': 'Save',
'settings_node.scan_saving': 'Saving…',
'settings_node.scan_saved': 'Saved.',
+ 'settings_node.tmdb_title': 'TMDB metadata',
+ 'settings_node.tmdb_hint': 'Lets the Videos app show posters, overviews and cast from TMDB. Off means thumbnail-only browsing, with no request to a third party.',
+ 'settings_node.tmdb_enabled': 'Enabled',
+ 'settings_node.tmdb_disabled': 'Disabled',
+ 'settings_node.tmdb_token_label': 'Custom API token (optional)',
+ 'settings_node.tmdb_token_placeholder': 'Leave blank to use the shipped default',
+ 'settings_node.tmdb_token_customized': 'A custom token is set.',
+ 'settings_node.tmdb_token_default': 'Using the shipped default token.',
+ 'settings_node.tmdb_save': 'Save',
+ 'settings_node.tmdb_language_label': 'Language',
+ 'settings_node.tmdb_language_hint': 'Applies to everyone — one shared cache, not a per-viewer request.',
+ '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.video_root_save': 'Save',
+ 'settings_node.video_root_change_confirm': 'Changing the Videos root replaces what every member sees in the Videos tab. Continue?',
// Members
'members.col_role': 'Role',
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 1621182..d028279 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js
@@ -74,6 +74,7 @@ export default {
'group.default_name': 'Grupo',
'group.tab_files': 'Archivos',
'group.tab_chat': 'Chat',
+ 'group.tab_video': 'Vídeos',
'group.tab_members': 'Miembros',
'group.tab_settings': "Ajustes",
'members.danger_leave_hint': "Perderá el acceso a los archivos y al chat de este grupo.",
@@ -154,6 +155,18 @@ export default {
'group.upload_indexing': 'indexando…',
'video.err_transport': 'Transporte no conectado',
'video.err_mse': 'Códec no compatible con la reproducción en continuo: {codec}',
+ 'video.mode_poster': 'Pósteres',
+ 'video.mode_flat': 'Lista plana',
+ 'video.empty': 'No se encontraron vídeos.',
+ 'video.no_root_configured': 'Aún no se ha definido una carpeta raíz de Vídeos para este grupo — un operador puede elegir una en Configuración.',
+ 'video.director': 'Director',
+ 'video.specials': 'Especiales',
+ 'video.season_n': 'Temporada {n}',
+ 'video.episode_n': 'Episodio {n}',
+ 'video.n_episodes': {
+ one: '{n} episodio',
+ other: '{n} episodios',
+ },
// LAN cast
'cast.start': 'Enviar a dispositivo',
@@ -567,6 +580,22 @@ export default {
'settings_node.scan_save': 'Guardar',
'settings_node.scan_saving': 'Guardando…',
'settings_node.scan_saved': 'Guardado.',
+ 'settings_node.tmdb_title': 'Metadatos de TMDB',
+ 'settings_node.tmdb_hint': 'Permite que la aplicación de Vídeos muestre pósteres, sinopsis y reparto desde TMDB. Desactivado significa solo miniaturas, sin ninguna solicitud a un tercero.',
+ 'settings_node.tmdb_enabled': 'Activado',
+ 'settings_node.tmdb_disabled': 'Desactivado',
+ 'settings_node.tmdb_token_label': 'Token de API personalizado (opcional)',
+ 'settings_node.tmdb_token_placeholder': 'Déjelo en blanco para usar el token predeterminado',
+ 'settings_node.tmdb_token_customized': 'Hay un token personalizado configurado.',
+ 'settings_node.tmdb_token_default': 'Usando el token predeterminado.',
+ 'settings_node.tmdb_save': 'Guardar',
+ 'settings_node.tmdb_language_label': 'Idioma',
+ 'settings_node.tmdb_language_hint': 'Se aplica a todos — una caché compartida, no una solicitud por espectador.',
+ '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.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?',
// Create group wizard
'wizard.title': 'Crear grupo',
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 6e21a06..621650d 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js
@@ -75,6 +75,7 @@ export default {
'group.default_name': 'Groupe',
'group.tab_files': 'Fichiers',
'group.tab_chat': 'Discussion',
+ 'group.tab_video': 'Vidéos',
'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.",
@@ -155,6 +156,18 @@ export default {
'group.upload_indexing': 'indexation…',
'video.err_transport': 'Transport non connecté',
'video.err_mse': 'Codec non pris en charge pour la diffusion : {codec}',
+ 'video.mode_poster': 'Affiches',
+ 'video.mode_flat': 'Liste à plat',
+ 'video.empty': 'Aucune vidéo trouvée.',
+ 'video.no_root_configured': "Aucun dossier racine des Vidéos n'est encore défini pour ce groupe — un opérateur peut en choisir un dans les Paramètres.",
+ 'video.director': 'Réalisateur',
+ 'video.specials': 'Bonus',
+ 'video.season_n': 'Saison {n}',
+ 'video.episode_n': 'Épisode {n}',
+ 'video.n_episodes': {
+ one: '{n} épisode',
+ other: '{n} épisodes',
+ },
// LAN cast
'cast.start': 'Diffuser sur un appareil',
@@ -583,6 +596,22 @@ export default {
'settings_node.scan_save': 'Enregistrer',
'settings_node.scan_saving': 'Enregistrement…',
'settings_node.scan_saved': 'Enregistré.',
+ 'settings_node.tmdb_title': 'Métadonnées TMDB',
+ 'settings_node.tmdb_hint': "Permet à l'application Vidéos d'afficher affiches, résumés et distribution depuis TMDB. Désactivé signifie navigation par vignettes uniquement, sans aucune requête vers un tiers.",
+ 'settings_node.tmdb_enabled': 'Activé',
+ 'settings_node.tmdb_disabled': 'Désactivé',
+ 'settings_node.tmdb_token_label': "Jeton d'API personnalisé (facultatif)",
+ 'settings_node.tmdb_token_placeholder': 'Laisser vide pour utiliser le jeton fourni par défaut',
+ 'settings_node.tmdb_token_customized': 'Un jeton personnalisé est défini.',
+ 'settings_node.tmdb_token_default': 'Utilise le jeton fourni par défaut.',
+ 'settings_node.tmdb_save': 'Enregistrer',
+ 'settings_node.tmdb_language_label': 'Langue',
+ 'settings_node.tmdb_language_hint': "S'applique à tout le monde — un cache partagé, pas une requête par personne.",
+ '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.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 ?",
// Create group wizard
'wizard.title': 'Créer un groupe',
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 edd15ab..71fb9ba 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js
@@ -75,6 +75,7 @@ export default {
'group.default_name': 'Gruppo',
'group.tab_files': 'File',
'group.tab_chat': 'Chat',
+ 'group.tab_video': 'Video',
'group.tab_members': 'Membri',
'group.tab_settings': "Impostazioni",
'members.danger_leave_hint': "Perderai l’accesso ai file e alla chat di questo gruppo.",
@@ -155,6 +156,18 @@ export default {
'group.upload_indexing': 'indicizzazione…',
'video.err_transport': 'Trasporto non connesso',
'video.err_mse': 'Codec non supportato per lo streaming: {codec}',
+ 'video.mode_poster': 'Locandine',
+ 'video.mode_flat': 'Elenco semplice',
+ 'video.empty': 'Nessun video trovato.',
+ 'video.no_root_configured': 'Per questo gruppo non è ancora impostata una cartella radice di Video — un operatore può sceglierne una nelle Impostazioni.',
+ 'video.director': 'Regista',
+ 'video.specials': 'Speciali',
+ 'video.season_n': 'Stagione {n}',
+ 'video.episode_n': 'Episodio {n}',
+ 'video.n_episodes': {
+ one: '{n} episodio',
+ other: '{n} episodi',
+ },
// LAN cast
'cast.start': 'Trasmetti al dispositivo',
@@ -581,6 +594,22 @@ export default {
'settings_node.scan_save': 'Salva',
'settings_node.scan_saving': 'Salvataggio…',
'settings_node.scan_saved': 'Salvato.',
+ 'settings_node.tmdb_title': 'Metadati TMDB',
+ 'settings_node.tmdb_hint': "Permette all'app Video di mostrare locandine, trame e cast da TMDB. Disattivato significa solo miniature, senza alcuna richiesta verso terzi.",
+ 'settings_node.tmdb_enabled': 'Attivato',
+ 'settings_node.tmdb_disabled': 'Disattivato',
+ 'settings_node.tmdb_token_label': 'Token API personalizzato (facoltativo)',
+ 'settings_node.tmdb_token_placeholder': 'Lascia vuoto per usare il token predefinito',
+ 'settings_node.tmdb_token_customized': 'È impostato un token personalizzato.',
+ 'settings_node.tmdb_token_default': 'In uso il token predefinito.',
+ 'settings_node.tmdb_save': 'Salva',
+ 'settings_node.tmdb_language_label': 'Lingua',
+ 'settings_node.tmdb_language_hint': 'Vale per tutti — una cache condivisa, non una richiesta per spettatore.',
+ '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.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?',
// Create-group wizard
'wizard.title': 'Crea gruppo',
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 00fffed..3336705 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js
@@ -73,6 +73,7 @@ export default {
'group.default_name': 'グループ',
'group.tab_files': 'ファイル',
'group.tab_chat': 'チャット',
+ 'group.tab_video': '動画',
'group.tab_members': 'メンバー',
'group.tab_settings': "設定",
'members.danger_leave_hint': "このグループのファイルとチャットにアクセスできなくなります。",
@@ -152,6 +153,18 @@ export default {
'group.upload_indexing': 'インデックスを作成中…',
'video.err_transport': 'トランスポートが接続されていません',
'video.err_mse': 'ストリーミング再生に対応していないコーデックです:{codec}',
+ 'video.mode_poster': 'ポスター表示',
+ 'video.mode_flat': 'フラット表示',
+ 'video.empty': '動画が見つかりません。',
+ 'video.no_root_configured': 'このグループにはまだ動画のルートフォルダが設定されていません — 操作者が設定画面で選択できます。',
+ 'video.director': '監督',
+ 'video.specials': '特別編',
+ 'video.season_n': 'シーズン{n}',
+ 'video.episode_n': '第{n}話',
+ 'video.n_episodes': {
+ one: '{n}話',
+ other: '{n}話',
+ },
// LAN cast
'cast.start': 'デバイスにキャスト',
@@ -565,6 +578,22 @@ export default {
'settings_node.scan_save': '保存',
'settings_node.scan_saving': '保存中…',
'settings_node.scan_saved': '保存しました。',
+ 'settings_node.tmdb_title': 'TMDBメタデータ',
+ 'settings_node.tmdb_hint': '動画アプリでTMDBのポスター、あらすじ、キャストを表示できるようにします。無効にするとサムネイルのみの表示になり、第三者への通信は発生しません。',
+ 'settings_node.tmdb_enabled': '有効',
+ 'settings_node.tmdb_disabled': '無効',
+ 'settings_node.tmdb_token_label': 'カスタムAPIトークン(任意)',
+ 'settings_node.tmdb_token_placeholder': '空欄にすると同梱のデフォルトトークンを使用します',
+ 'settings_node.tmdb_token_customized': 'カスタムトークンが設定されています。',
+ 'settings_node.tmdb_token_default': '同梱のデフォルトトークンを使用しています。',
+ 'settings_node.tmdb_save': '保存',
+ 'settings_node.tmdb_language_label': '言語',
+ 'settings_node.tmdb_language_hint': '全員に適用されます — 共有キャッシュであり、視聴者ごとのリクエストではありません。',
+ 'settings_node.video_root_title': '動画のルートフォルダ',
+ 'settings_node.video_root_hint': 'このグループで動画アプリの起点とするフォルダ(またはサブフォルダ)です。選択されるまで、動画には何も表示されず、TMDB への問い合わせも行われません。',
+ 'settings_node.video_root_none': '— 未選択 —',
+ 'settings_node.video_root_save': '保存',
+ 'settings_node.video_root_change_confirm': '動画のルートフォルダを変更すると、全メンバーの動画タブの表示内容が変わります。続行しますか?',
// Wizard
'wizard.title': 'グループを作成',
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 2837ebb..28be024 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js
@@ -76,6 +76,7 @@ export default {
'group.default_name': 'Groep',
'group.tab_files': 'Bestanden',
'group.tab_chat': 'Chat',
+ 'group.tab_video': "Video's",
'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.",
@@ -156,6 +157,18 @@ export default {
'group.upload_indexing': 'indexeren…',
'video.err_transport': 'Transport niet verbonden',
'video.err_mse': 'Codec wordt niet ondersteund voor streamen: {codec}',
+ 'video.mode_poster': 'Posters',
+ 'video.mode_flat': 'Platte lijst',
+ 'video.empty': "Geen video's gevonden.",
+ 'video.no_root_configured': "Er is nog geen hoofdmap voor Video's ingesteld voor deze groep — een operator kan er een kiezen bij Instellingen.",
+ 'video.director': 'Regisseur',
+ 'video.specials': "Extra's",
+ 'video.season_n': 'Seizoen {n}',
+ 'video.episode_n': 'Aflevering {n}',
+ 'video.n_episodes': {
+ one: '{n} aflevering',
+ other: '{n} afleveringen',
+ },
// LAN cast
'cast.start': 'Naar apparaat casten',
@@ -583,6 +596,22 @@ export default {
'settings_node.scan_save': 'Opslaan',
'settings_node.scan_saving': 'Bezig met opslaan…',
'settings_node.scan_saved': 'Opgeslagen.',
+ 'settings_node.tmdb_title': 'TMDB-metadata',
+ 'settings_node.tmdb_hint': "Laat de Video's-app posters, samenvattingen en cast van TMDB tonen. Uit betekent alleen miniaturen, zonder enig verzoek aan een derde partij.",
+ 'settings_node.tmdb_enabled': 'Ingeschakeld',
+ 'settings_node.tmdb_disabled': 'Uitgeschakeld',
+ 'settings_node.tmdb_token_label': 'Eigen API-token (optioneel)',
+ 'settings_node.tmdb_token_placeholder': 'Leeg laten om de meegeleverde standaard te gebruiken',
+ 'settings_node.tmdb_token_customized': 'Er is een eigen token ingesteld.',
+ 'settings_node.tmdb_token_default': 'Gebruikt de meegeleverde standaardtoken.',
+ 'settings_node.tmdb_save': 'Opslaan',
+ 'settings_node.tmdb_language_label': 'Taal',
+ 'settings_node.tmdb_language_hint': 'Geldt voor iedereen — één gedeelde cache, geen verzoek per kijker.',
+ '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.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?",
// Create group wizard
'wizard.title': 'Groep aanmaken',
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 760f29c..d4eb325 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js
@@ -80,6 +80,7 @@ export default {
'group.default_name': 'Grupa',
'group.tab_files': 'Pliki',
'group.tab_chat': 'Czat',
+ 'group.tab_video': 'Wideo',
'group.tab_members': 'Członkowie',
'group.tab_settings': "Ustawienia",
'members.danger_leave_hint': "Utracisz dostęp do plików i czatu tej grupy.",
@@ -161,6 +162,20 @@ export default {
'group.upload_indexing': 'indeksowanie…',
'video.err_transport': 'Transport nie jest połączony',
'video.err_mse': 'Kodek nieobsługiwany przy odtwarzaniu strumieniowym: {codec}',
+ 'video.mode_poster': 'Plakaty',
+ 'video.mode_flat': 'Lista płaska',
+ 'video.empty': 'Nie znaleziono żadnych filmów.',
+ 'video.no_root_configured': 'Dla tej grupy nie wybrano jeszcze katalogu głównego Wideo — operator może go wybrać w Ustawieniach.',
+ 'video.director': 'Reżyser',
+ 'video.specials': 'Dodatki',
+ 'video.season_n': 'Sezon {n}',
+ 'video.episode_n': 'Odcinek {n}',
+ 'video.n_episodes': {
+ one: '{n} odcinek',
+ few: '{n} odcinki',
+ many: '{n} odcinków',
+ other: '{n} odcinka',
+ },
// LAN cast
'cast.start': 'Przesyłaj na urządzenie',
@@ -604,6 +619,22 @@ export default {
'settings_node.scan_save': 'Zapisz',
'settings_node.scan_saving': 'Zapisywanie…',
'settings_node.scan_saved': 'Zapisano.',
+ 'settings_node.tmdb_title': 'Metadane TMDB',
+ 'settings_node.tmdb_hint': 'Pozwala aplikacji Wideo pokazywać plakaty, opisy i obsadę z TMDB. Wyłączone oznacza przeglądanie tylko z miniaturami, bez żadnego żądania do zewnętrznego serwisu.',
+ 'settings_node.tmdb_enabled': 'Włączone',
+ 'settings_node.tmdb_disabled': 'Wyłączone',
+ 'settings_node.tmdb_token_label': 'Własny token API (opcjonalnie)',
+ 'settings_node.tmdb_token_placeholder': 'Pozostaw puste, aby użyć domyślnego tokenu',
+ 'settings_node.tmdb_token_customized': 'Ustawiono własny token.',
+ 'settings_node.tmdb_token_default': 'Używany jest domyślny token.',
+ 'settings_node.tmdb_save': 'Zapisz',
+ 'settings_node.tmdb_language_label': 'Język',
+ 'settings_node.tmdb_language_hint': 'Dotyczy wszystkich — jedna współdzielona pamięć podręczna, bez osobnego żądania dla każdego widza.',
+ '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.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ć?',
// Create-group wizard
'wizard.title': 'Utwórz grupę',
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 0001051..bba07a2 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
@@ -76,6 +76,7 @@ export default {
'group.default_name': 'Grupo',
'group.tab_files': 'Arquivos',
'group.tab_chat': 'Conversa',
+ 'group.tab_video': 'Vídeos',
'group.tab_members': 'Membros',
'group.tab_settings': "Configurações",
'members.danger_leave_hint': "Você perderá o acesso aos arquivos e ao chat deste grupo.",
@@ -156,6 +157,18 @@ export default {
'group.upload_indexing': 'indexando…',
'video.err_transport': 'Transporte não conectado',
'video.err_mse': 'Codec sem suporte para transmissão: {codec}',
+ 'video.mode_poster': 'Pôsteres',
+ 'video.mode_flat': 'Lista simples',
+ 'video.empty': 'Nenhum vídeo encontrado.',
+ 'video.no_root_configured': 'Ainda não há uma pasta raiz de Vídeos definida para este grupo — um operador pode escolher uma em Configurações.',
+ 'video.director': 'Diretor',
+ 'video.specials': 'Especiais',
+ 'video.season_n': 'Temporada {n}',
+ 'video.episode_n': 'Episódio {n}',
+ 'video.n_episodes': {
+ one: '{n} episódio',
+ other: '{n} episódios',
+ },
// LAN cast
'cast.start': 'Transmitir para dispositivo',
@@ -568,6 +581,22 @@ export default {
'settings_node.scan_save': 'Salvar',
'settings_node.scan_saving': 'Salvando…',
'settings_node.scan_saved': 'Salvo.',
+ 'settings_node.tmdb_title': 'Metadados do TMDB',
+ 'settings_node.tmdb_hint': 'Permite que o app de Vídeos mostre pôsteres, sinopses e elenco do TMDB. Desativado significa navegação apenas com miniaturas, sem nenhuma solicitação a terceiros.',
+ 'settings_node.tmdb_enabled': 'Ativado',
+ 'settings_node.tmdb_disabled': 'Desativado',
+ 'settings_node.tmdb_token_label': 'Token de API personalizado (opcional)',
+ 'settings_node.tmdb_token_placeholder': 'Deixe em branco para usar o token padrão',
+ 'settings_node.tmdb_token_customized': 'Um token personalizado está definido.',
+ 'settings_node.tmdb_token_default': 'Usando o token padrão.',
+ 'settings_node.tmdb_save': 'Salvar',
+ 'settings_node.tmdb_language_label': 'Idioma',
+ 'settings_node.tmdb_language_hint': 'Aplica-se a todos — um cache compartilhado, não uma solicitação por espectador.',
+ '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.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?',
// Create group wizard
'wizard.title': 'Criar grupo',
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 ad8bf53..c9199c4 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
@@ -73,6 +73,7 @@ export default {
'group.default_name': '群组',
'group.tab_files': '文件',
'group.tab_chat': '聊天',
+ 'group.tab_video': '视频',
'group.tab_members': '成员',
'group.tab_settings': "设置",
'members.danger_leave_hint': "您将无法再访问该群组的文件和聊天。",
@@ -149,6 +150,18 @@ export default {
'group.upload_indexing': '建立索引中…',
'video.err_transport': '传输未连接',
'video.err_mse': '该编解码器不支持流式播放:{codec}',
+ 'video.mode_poster': '海报视图',
+ 'video.mode_flat': '平铺列表',
+ 'video.empty': '未找到视频。',
+ 'video.no_root_configured': '此群组尚未设置视频根目录 — 操作员可以在设置中选择一个。',
+ 'video.director': '导演',
+ 'video.specials': '特别篇',
+ 'video.season_n': '第 {n} 季',
+ 'video.episode_n': '第 {n} 集',
+ 'video.n_episodes': {
+ one: '{n} 集',
+ other: '{n} 集',
+ },
// LAN cast
'cast.start': '投射到设备',
@@ -551,6 +564,22 @@ export default {
'settings_node.scan_save': '保存',
'settings_node.scan_saving': '保存中…',
'settings_node.scan_saved': '已保存。',
+ 'settings_node.tmdb_title': 'TMDB 元数据',
+ 'settings_node.tmdb_hint': '允许视频应用显示来自 TMDB 的海报、简介和演职人员。关闭后仅显示缩略图浏览,不会向第三方发送任何请求。',
+ 'settings_node.tmdb_enabled': '已启用',
+ 'settings_node.tmdb_disabled': '已禁用',
+ 'settings_node.tmdb_token_label': '自定义 API 令牌(可选)',
+ 'settings_node.tmdb_token_placeholder': '留空以使用内置的默认令牌',
+ 'settings_node.tmdb_token_customized': '已设置自定义令牌。',
+ 'settings_node.tmdb_token_default': '正在使用内置的默认令牌。',
+ 'settings_node.tmdb_save': '保存',
+ 'settings_node.tmdb_language_label': '语言',
+ 'settings_node.tmdb_language_hint': '适用于所有人 — 共享同一个缓存,不是按观看者分别请求。',
+ 'settings_node.video_root_title': '视频根目录',
+ 'settings_node.video_root_hint': '该文件夹(或子文件夹)将作为此群组"视频"应用的入口。在选择之前,"视频"中不会显示任何内容,也不会执行任何 TMDB 查询。',
+ 'settings_node.video_root_none': '— 未选择 —',
+ 'settings_node.video_root_save': '保存',
+ 'settings_node.video_root_change_confirm': '更改视频根目录会替换每位成员在"视频"标签页中看到的内容。是否继续?',
// Create group wizard
'wizard.title': '创建群组',
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/style.css b/packages/meshbay-hub/src/meshbay_hub/static/style.css
index 2cb27d0..e1900d9 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/style.css
+++ b/packages/meshbay-hub/src/meshbay_hub/static/style.css
@@ -1000,6 +1000,20 @@ button:disabled { opacity: 0.5; cursor: not-allowed; }
color: var(--text);
font-size: 0.95em;
}
+.settings-label input[type="password"],
+.settings-label input[type="text"],
+.settings-label select {
+ display: block;
+ width: 100%;
+ max-width: 320px;
+ margin-top: 4px;
+ padding: 6px 8px;
+ border-radius: 6px;
+ border: 1px solid var(--border);
+ background: var(--bg-base);
+ color: var(--text);
+ font-size: 0.95em;
+}
.settings-value {
font-size: 0.9em;
@@ -2399,3 +2413,214 @@ a.transfer-name {
border-radius: 8px;
font-size: 0.85em;
}
+
+/* ── Videos app (video-app.js) ──────────────────────────────────────────────── */
+
+.video-toolbar {
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ margin-bottom: 14px;
+}
+.video-toolbar .tb-search { margin-left: auto; }
+
+/* Mode A — poster grid */
+
+.video-grid {
+ display: grid;
+ grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));
+ gap: 16px;
+}
+
+.video-tile-slot {
+ min-height: 260px;
+}
+
+.video-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;
+}
+.video-card:hover { border-color: var(--accent); transform: translateY(-2px); }
+
+.video-poster {
+ width: 100%;
+ aspect-ratio: 2 / 3;
+ object-fit: cover;
+ display: block;
+ background: var(--bg-surface);
+}
+
+.video-poster-loading {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+}
+
+.video-poster-slot { display: contents; }
+
+.video-thumb {
+ width: 100%;
+ aspect-ratio: 2 / 3;
+ object-fit: cover;
+ display: block;
+ background: var(--bg-surface);
+}
+.video-thumb-empty {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ color: var(--text-dim);
+}
+.video-thumb-empty .icon { width: 28px; height: 28px; }
+
+.video-card-info { padding: 8px 10px; }
+.video-card-title {
+ font-size: 0.88em;
+ font-weight: 600;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+.video-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 existing .video-overlay */
+
+.video-detail {
+ width: min(720px, 92vw);
+ max-height: calc(100vh - 100px);
+ margin-top: 60px;
+ background: var(--bg-surface);
+ border-radius: 10px;
+ overflow: hidden;
+ display: flex;
+ flex-direction: column;
+}
+.video-detail .video-top-bar {
+ position: static;
+ background: var(--bg-raised);
+ border-bottom: 1px solid var(--border);
+}
+.video-detail .video-title { color: var(--text); }
+.video-detail .video-close { background: var(--bg-surface); color: var(--text); }
+.video-detail .video-close:hover { background: var(--border); }
+
+.video-detail-body {
+ padding: 16px 20px;
+ overflow-y: auto;
+}
+.video-detail-overview {
+ font-size: 0.9em;
+ color: var(--text);
+ line-height: 1.5;
+}
+.video-detail-facts {
+ font-size: 0.82em;
+ color: var(--text-dim);
+ margin-top: 6px;
+}
+.video-detail-cast {
+ font-size: 0.82em;
+ color: var(--text-secondary);
+ margin-top: 6px;
+}
+
+.video-season-list { margin-top: 14px; }
+.video-season { margin-bottom: 14px; }
+.video-season-header {
+ font-weight: 600;
+ font-size: 0.85em;
+ color: var(--text-dim);
+ margin-bottom: 6px;
+ text-transform: uppercase;
+ letter-spacing: 0.02em;
+}
+.video-episode-row {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ width: 100%;
+ padding: 8px 10px;
+ background: none;
+ border: 1px solid var(--border);
+ border-radius: 6px;
+ color: var(--text);
+ font-size: 0.85em;
+ cursor: pointer;
+ text-align: left;
+ margin-bottom: 4px;
+}
+.video-episode-row:hover { border-color: var(--accent); color: var(--accent); }
+.video-episode-row .icon { width: 14px; height: 14px; flex-shrink: 0; }
+.video-episode-thumb-slot { width: 64px; height: 40px; flex-shrink: 0; }
+.video-episode-thumb {
+ width: 64px;
+ height: 40px;
+ object-fit: cover;
+ border-radius: 4px;
+ display: block;
+ background: var(--bg-raised);
+}
+.video-episode-thumb.video-thumb-empty { border: 1px solid var(--border); }
+.video-episode-label {
+ flex: 1;
+ min-width: 0;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+.video-episode-meta {
+ color: var(--text-dim);
+ font-size: 0.9em;
+ flex-shrink: 0;
+}
+
+/* Mode B — flat list */
+
+.video-flat-list { display: flex; flex-direction: column; gap: 4px; }
+
+.video-flat-row {
+ display: flex;
+ align-items: center;
+ gap: 12px;
+ padding: 6px 8px;
+ border-radius: 6px;
+ cursor: pointer;
+}
+.video-flat-row:hover { background: var(--bg-raised); }
+
+.video-flat-thumb-slot { width: 64px; height: 40px; flex-shrink: 0; }
+.video-flat-thumb {
+ width: 64px;
+ height: 40px;
+ object-fit: cover;
+ border-radius: 4px;
+ display: block;
+ background: var(--bg-raised);
+}
+.video-flat-thumb.video-thumb-empty { border: 1px solid var(--border); }
+
+.video-flat-info { min-width: 0; flex: 1; }
+.video-flat-title {
+ font-size: 0.88em;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+.video-flat-sub { font-size: 0.78em; color: var(--text-dim); margin-top: 1px; }
+
+.video-flat-folder { border-bottom: 1px solid var(--border); padding-bottom: 4px; margin-bottom: 4px; }
+.video-flat-chevron { transition: transform 0.12s; flex-shrink: 0; }
+.video-flat-chevron.open { transform: rotate(180deg); }
+.video-flat-season { padding-left: 24px; margin-bottom: 8px; }
+.video-flat-season .video-season-header { margin: 6px 0 4px; }
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/transport.js b/packages/meshbay-hub/src/meshbay_hub/static/transport.js
index 4f6b656..dd39df8 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/transport.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/transport.js
@@ -106,6 +106,8 @@ class MeshBayTransport {
set onIndexDelta(fn) { this._onIndexDelta = fn; }
set onUploadPolicy(fn) { this._onUploadPolicy = fn; }
set onAppsEnabled(fn) { this._onAppsEnabled = fn; }
+ set onTmdbConfig(fn) { this._onTmdbConfig = fn; }
+ set onVideoRoot(fn) { this._onVideoRoot = fn; }
set onIndexProgress(fn) { this._onIndexProgress = fn; }
get sessionKeys() { return this._sessionKeys; }
@@ -465,6 +467,66 @@ class MeshBayTransport {
return msg;
}
+ /**
+ * TMDB metadata for one file (Videos app, docs/mediacenter.md §5.4).
+ * `path` is root+relpath, exactly what index_sync/index_delta already
+ * gave this browser — never a raw filesystem path constructed here.
+ * `confidence: 0` (no tmdb_id, no fields) means no confident match —
+ * the caller falls back to a thumbnail-only card (§4.1), not an error.
+ */
+ async fetchMediaMeta(path) {
+ const msg = await this._sendAndWait({ type: 'media_meta_req', v: '0.5', path });
+ if (msg.type === 'error') throw new Error(msg.detail);
+ return msg;
+ }
+
+ /**
+ * Turn TMDB lookups on/off node-wide, optionally set/clear a custom API
+ * token, and optionally set the language TMDB is queried in (e.g.
+ * "fr-FR") — one for the whole node, same reasoning as the token: one
+ * shared cache, not a per-viewer request. Signed like setAppsEnabled/
+ * setMemberUpload — an unsigned toggle would let any member turn on
+ * outbound third-party network traffic the operator never agreed to
+ * (docs/mediacenter.md §5.5, §8). `token: ''` explicitly clears a
+ * previously-set custom token; omit it (undefined/null), like
+ * `language`, to leave whatever is stored unchanged.
+ */
+ async setTmdbConfig(enabled, token, language, signFn) {
+ const msg = await this._sendAndWait({
+ type: 'tmdb_config', v: '0.5', enabled: Boolean(enabled),
+ token: token === undefined ? null : token,
+ language: language === undefined ? null : language,
+ });
+ 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_tmdb_config): Python's f"{bool}" is "True"/"False", not JS's
+ // lowercase — and the token itself is never part of the subject
+ // (it would end up in the audit log in plaintext), only whether one
+ // was supplied. The language is not a secret, so it appears as-is.
+ const subject = `enabled=${enabled ? 'True' : 'False'},` +
+ `custom_token=${token ? 'yes' : 'no'},language=${language || 'default'}`;
+ return this._authorizeAdminOp(msg, 'tmdb_config', subject, signFn);
+ }
+ return msg;
+ }
+
+ /**
+ * Which folder (possibly a subfolder of a shared root) the Videos app
+ * treats as its entry point for this group. `path: ''` means the whole
+ * group index. Signed like setAppsEnabled — it decides what every
+ * member's Videos tab shows.
+ */
+ async setVideoRoot(path, signFn) {
+ const clean = (path || '').replace(/^\/+|\/+$/g, '');
+ const msg = await this._sendAndWait({ type: 'video_root', v: '0.5', path: clean });
+ if (msg.type === 'error') throw new Error(msg.detail);
+ if (msg.type === 'admin_challenge') {
+ return this._authorizeAdminOp(msg, 'video_root', clean, signFn);
+ }
+ return msg;
+ }
+
async fetchStreamSegment(fileId, segmentIndex, segmentDuration) {
const msg = await this._sendAndWait({
type: 'stream_seg',
@@ -1173,9 +1235,15 @@ class MeshBayTransport {
// *while* other traffic is in flight, so the fallback below would hand
// a pong to whatever was waiting — resolving a history request with a
// message that has no messages in it, and emptying the conversation.
+ // media_meta_req is the same shape as file_req: video-app.js fires
+ // one per visible poster-grid tile, several at a time — matching by
+ // arrival order handed one tile's TMDB result to a different tile
+ // whenever two responses reordered (reproduced live: which of two
+ // shows got the confident match flipped across reloads).
_key: obj.type === 'file_req'
? `chunk:${obj.file_id}:${obj.chunk_index}`
- : obj.type === 'ping' ? `ping:${obj.token}` : null,
+ : obj.type === 'ping' ? `ping:${obj.token}`
+ : obj.type === 'media_meta_req' ? `media_meta:${obj.path}` : null,
resolve: (msg) => { clearTimeout(timeout); this._pending.delete(id); resolve(msg); },
reject: (err) => { clearTimeout(timeout); this._pending.delete(id); reject(err); },
});
@@ -1276,6 +1344,23 @@ class MeshBayTransport {
this._onAppsEnabled(msg.apps || []);
}
+ // Node-wide (not per-group) — the operator changed whether TMDB is
+ // called at all, or supplied/cleared a custom token. `token_customized`
+ // only says whether one is set, never the token itself.
+ if (msg.type === 'tmdb_config_ack' && this._onTmdbConfig) {
+ this._onTmdbConfig({
+ enabled: Boolean(msg.enabled),
+ tokenCustomized: Boolean(msg.token_customized),
+ language: msg.language || '',
+ });
+ }
+
+ // Same shape: the operator changed which folder is the Videos app's
+ // entry point for this group.
+ if (msg.type === 'video_root_ack' && this._onVideoRoot) {
+ this._onVideoRoot(msg.path || '');
+ }
+
// 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
@@ -1316,11 +1401,11 @@ class MeshBayTransport {
return;
}
- // Incremental update — additions/deletions only, never the whole index.
- // Only ever arrives after the full index this browser already has (the
- // node's first push to a newly connected peer is always index_sync, see
- // daemon.py _broadcast_index_change), so there is always a base to
- // apply it to.
+ // Incremental update — additions/deletions/updates, never the whole
+ // index. Only ever arrives after the full index this browser already
+ // has (the node's first push to a newly connected peer is always
+ // index_sync, see daemon.py _broadcast_index_change), so there is
+ // always a base to apply it to.
if (msg.type === 'index_delta') {
if (this._onIndexDelta) this._onIndexDelta(msg);
return;
@@ -1364,6 +1449,18 @@ class MeshBayTransport {
return;
}
+ if (msg.type === 'media_meta_resp') {
+ const key = `media_meta:${msg.path}`;
+ for (const [, handler] of this._pending) {
+ if (handler._key === key) { handler.resolve(msg); return; }
+ }
+ // Nobody asked for this path any more (tile scrolled out and a fresh
+ // request superseded it, most likely) — must not fall through to the
+ // oldest pending request, which would hand a different tile's promise
+ // a TMDB result for a path it never asked about.
+ return;
+ }
+
// chat_hist_resp answers a `chat_hist` request, but under a different
// type string — unlike index_sync, which is asked for and answered under
// the same name, so the generic fallback below happens to work for it by
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/video-app.js b/packages/meshbay-hub/src/meshbay_hub/static/video-app.js
new file mode 100644
index 0000000..250bf8b
--- /dev/null
+++ b/packages/meshbay-hub/src/meshbay_hub/static/video-app.js
@@ -0,0 +1,599 @@
+import {
+ html, useState, useEffect, useRef, useMemo, useCallback,
+} from './vendor/htm-preact.js';
+import { t } from './i18n.js';
+import { Icon } from './icon.js';
+import { formatSize, pipelinedDownload } from './file-utils.js';
+
+// ── Videos ───────────────────────────────────────────────────────────────────
+//
+// A poster-grid (TMDB-enriched) or flat (thumbnail-only) browser for a
+// group's video files, per docs/mediacenter.md. Grouping: one card per movie,
+// one card per show — shows are grouped by `display_title` (already
+// resolved/corroborated at index time, §3.4), not by folder path, since a
+// client-side path convention would have to guess how many roots/subfolders
+// deep a show folder sits, which display_title already settled once.
+//
+// TMDB metadata is fetched lazily, only for a tile once it is actually
+// visible (LazyTile below) — apps.md §5's virtualization requirement for a
+// grid of many tiles. Thumbnails go through the same `file_req`/chunk path
+// as a real file (docs/mediacenter.md §5.3) via MediaThumb, reusing
+// chat-app.js's ChatImage pattern.
+
+const VIEW_MODE_KEY = 'meshbay_video_view_mode';
+
+function loadViewMode() {
+ try { return localStorage.getItem(VIEW_MODE_KEY) === 'flat' ? 'flat' : 'poster'; }
+ catch { return 'poster'; }
+}
+function saveViewMode(mode) {
+ try { localStorage.setItem(VIEW_MODE_KEY, mode); } catch { /* per-device convenience only */ }
+}
+
+function formatDuration(seconds) {
+ if (!seconds) return '';
+ const total = Math.round(seconds);
+ const h = Math.floor(total / 3600);
+ const m = Math.floor((total % 3600) / 60);
+ return h > 0 ? `${h}h${String(m).padStart(2, '0')}` : `${m}min`;
+}
+
+function formatResolution(width, height) {
+ if (!width || !height) return '';
+ if (height >= 2100) return '4K';
+ if (height >= 1000) return `${height}p`;
+ return `${width}x${height}`;
+}
+
+function yearOf(dateStr) {
+ return dateStr ? String(dateStr).slice(0, 4) : '';
+}
+
+// ── grouping ─────────────────────────────────────────────────────────────────
+
+// Nothing shows until an operator has actually chosen a root in Settings
+// (§5.6/V-whatever this is now): the node itself runs no TMDB/thumbnail
+// work for this group before that either (daemon.py's
+// _enrich_new_video_entries), so falling back to "the whole index" here
+// would just show files nothing has enriched.
+function underVideoRoot(entry, videoRoot) {
+ if (!videoRoot) return false;
+ const p = entry.path || '';
+ return p === videoRoot || p.startsWith(videoRoot + '/');
+}
+
+function buildSeasons(episodes) {
+ const sorted = [...episodes].sort((a, b) => (a.season - b.season) || (a.episode - b.episode));
+ const bySeason = new Map();
+ for (const ep of sorted) {
+ if (!bySeason.has(ep.season)) bySeason.set(ep.season, []);
+ bySeason.get(ep.season).push(ep);
+ }
+ return [...bySeason.entries()].sort((a, b) => a[0] - b[0])
+ .map(([season, seasonEpisodes]) => ({ season, episodes: seasonEpisodes }));
+}
+
+function groupVideoEntries(entries, videoRoot) {
+ const movies = [];
+ const showsByTitle = new Map();
+ for (const e of entries) {
+ if (e.type !== 'video') continue;
+ if (!underVideoRoot(e, videoRoot)) continue;
+ if (e.season != null && e.episode != null) {
+ const title = e.display_title || e.name;
+ if (!showsByTitle.has(title)) showsByTitle.set(title, { title, episodes: [] });
+ showsByTitle.get(title).episodes.push(e);
+ } else {
+ movies.push(e);
+ }
+ }
+ movies.sort((a, b) => (a.display_title || a.name).localeCompare(b.display_title || b.name));
+ const shows = [...showsByTitle.values()].sort((a, b) => a.title.localeCompare(b.title));
+ for (const show of shows) {
+ show.episodes.sort((a, b) => (a.season - b.season) || (a.episode - b.episode));
+ show.seasons = buildSeasons(show.episodes);
+ }
+ return { movies, shows };
+}
+
+// ── lazy-mount tile (apps.md §5 virtualization) ─────────────────────────────
+
+const LAZY_TILE_MARGIN = 300;
+
+function LazyTile({ cls = 'video-tile-slot', children }) {
+ const ref = useRef(null);
+ const [visible, setVisible] = useState(false);
+
+ useEffect(() => {
+ if (visible || !ref.current) return;
+ // A tile that is already on screen (or within the margin) the moment
+ // it mounts — the overwhelmingly common case, since a merge (§V6) or a
+ // tab revisit mounts tiles into a grid that was already scrolled to
+ // wherever the operator was looking — doesn't need to wait for
+ // IntersectionObserver's own first callback at all: that first
+ // delivery is only a *microtask/next-paint* guarantee, not an
+ // immediate one, and was observed live taking upwards of 30 seconds
+ // (matching the browser's own periodic intersection-computation
+ // cadence exactly) — which read as "the poster never finishes
+ // loading" even though every fetch behind it had already completed.
+ // Checked synchronously so a genuinely below-the-fold tile still only
+ // mounts once actually scrolled near.
+ const rect = ref.current.getBoundingClientRect();
+ const viewportHeight = window.innerHeight || document.documentElement.clientHeight;
+ const alreadyNear = rect.bottom >= -LAZY_TILE_MARGIN && rect.top <= viewportHeight + LAZY_TILE_MARGIN;
+ if (alreadyNear) { setVisible(true); return; }
+ const obs = new IntersectionObserver((obsEntries) => {
+ if (obsEntries.some((oe) => oe.isIntersecting)) { setVisible(true); obs.disconnect(); }
+ }, { rootMargin: `${LAZY_TILE_MARGIN}px` });
+ obs.observe(ref.current);
+ return () => obs.disconnect();
+ }, [visible]);
+
+ return html`<div ref=${ref} class=${cls}>${visible ? children : null}</div>`;
+}
+
+// ── thumbnail/poster image, decrypted via the chunk path ────────────────────
+//
+// Cached per session by thumb_hash (a content hash, so it never goes stale):
+// the same poster reused across a season's worth of episode tiles is
+// decrypted once, not once per tile. Blob URLs are not revoked — the number
+// of distinct thumbnails one session ever visits is bounded by the library
+// size, and reference-counting revocation across many tile mounts/unmounts
+// would cost real complexity for a benefit that only matters in a very long
+// session.
+const _thumbBlobCache = new Map();
+
+function MediaThumb({
+ thumbHash, transportRef, gekRef, alt, cls = 'video-thumb', onReady,
+}) {
+ const [blobUrl, setBlobUrl] = useState(() => _thumbBlobCache.get(thumbHash) || null);
+
+ // Re-checks the cache by the CURRENT thumbHash on every change rather than
+ // trusting the `blobUrl` state variable — a PosterCard swaps this same
+ // component instance's thumbHash prop from the raw fallback frame to the
+ // TMDB poster once metadata resolves, and gating on "is blobUrl already
+ // set" (from the *previous* hash) would leave the fallback frame on
+ // screen forever instead of ever fetching the poster.
+ //
+ // `onReady` fires exactly once per settled thumbHash — cache hit, fetch
+ // success, fetch failure, or no hash at all — so a caller that hides this
+ // component until its image has actually arrived (PosterCard) always
+ // gets unstuck, even when there's nothing to show.
+ useEffect(() => {
+ const cached = _thumbBlobCache.get(thumbHash);
+ if (cached) { setBlobUrl(cached); if (onReady) onReady(cached); return; }
+ setBlobUrl(null);
+ if (!thumbHash) { if (onReady) onReady(null); return; }
+ let cancelled = false;
+ (async () => {
+ const transport = transportRef.current;
+ if (!transport || !transport.connected) { if (onReady) onReady(null); return; }
+ try {
+ const chunks = await pipelinedDownload(transport, gekRef.current, thumbHash, 1);
+ if (cancelled) return;
+ const url = URL.createObjectURL(new Blob(chunks, { type: 'image/jpeg' }));
+ _thumbBlobCache.set(thumbHash, url);
+ setBlobUrl(url);
+ if (onReady) onReady(url);
+ } catch {
+ /* leave the placeholder — a transient fetch failure isn't an error state */
+ if (!cancelled && onReady) onReady(null);
+ }
+ })();
+ return () => { cancelled = true; };
+ }, [thumbHash]);
+
+ if (!blobUrl) return html`<div class="${cls} video-thumb-empty"><${Icon} name="video" /></div>`;
+ return html`<img class=${cls} src=${blobUrl} alt=${alt || ''} loading="lazy" />`;
+}
+
+// ── TMDB metadata, fetched once per visible tile ────────────────────────────
+
+function useMediaMeta(transportRef, path, active) {
+ const [meta, setMeta] = useState(null);
+ useEffect(() => {
+ if (!active || !path) return;
+ let cancelled = false;
+ (async () => {
+ const transport = transportRef.current;
+ if (!transport || !transport.connected) return;
+ try {
+ const resp = await transport.fetchMediaMeta(path);
+ if (!cancelled) setMeta(resp);
+ } catch { if (!cancelled) setMeta({ confidence: 0 }); }
+ })();
+ return () => { cancelled = true; };
+ }, [path, active]);
+ return meta;
+}
+
+// ── Mode A: poster grid ──────────────────────────────────────────────────────
+
+function PosterCard({ title, subtitle, repEntry, transportRef, gekRef, onOpen, groupKey, onMetaResolved }) {
+ const meta = useMediaMeta(transportRef, repEntry.path, true);
+ const confident = meta && meta.confidence && meta.tmdb_id;
+ const metaReady = meta !== null;
+
+ // Reports this tile's own resolution upward so PosterGrid can notice two
+ // differently-parsed folders (a show split across release groups that
+ // named its seasons inconsistently, §3.4/V6) resolving to the same TMDB
+ // id, and merge them into one card — never required for the generic
+ // per-folder display to work, only an enhancement once it's known safe.
+ useEffect(() => {
+ if (meta && onMetaResolved) onMetaResolved(groupKey, meta);
+ }, [meta, groupKey]);
+
+ const posterHash = confident && meta.poster_thumb_hash ? meta.poster_thumb_hash : repEntry.thumb_hash;
+
+ // Which hash MediaThumb has actually confirmed ready — compared against
+ // the *current* posterHash below, rather than a separate boolean reset
+ // by its own effect on posterHash change. That second-writer shape had a
+ // real bug: the instant metaReady flips true, posterHash jumps from the
+ // raw fallback frame to the resolved poster in the very same commit that
+ // mounts MediaThumb for it — and when that poster's bytes are already in
+ // MediaThumb's session cache (a revisit within the same tab, no reload),
+ // its onReady fires synchronously from that same mount effect. Effects
+ // run children-first, so the "reset on posterHash change" effect fired
+ // *after* it, in the same commit, unconditionally setting the flag back
+ // to false — with no later event left to ever set it true again. The
+ // card stayed a spinner forever despite the image already being loaded.
+ // Deriving readiness from a direct comparison has no such ordering to
+ // get wrong: whichever of the two fires, in whichever order, the render
+ // that follows sees the same answer.
+ const [readyHash, setReadyHash] = useState(null);
+ const handleImageReady = useCallback(() => setReadyHash(posterHash), [posterHash]);
+ const imageReady = readyHash === posterHash;
+
+ // Nothing is shown until BOTH the TMDB lookup and the final chosen image
+ // (the poster once matched, the file's own frame otherwise) have
+ // actually settled. Revealing the raw per-file frame first and swapping
+ // it for the poster a moment later — or showing a card that a moment
+ // later gets absorbed into a neighbour once §V6's merge kicks in — was
+ // exactly the flash an operator flagged as hard on the eyes. A slow
+ // lookup (a big, freshly-scanned library) just means the spinner stays a
+ // little longer, never a partially-drawn card.
+ const ready = metaReady && imageReady;
+
+ return html`
+ <div class="video-card" onClick=${onOpen}>
+ ${!ready && html`
+ <div class="video-poster video-poster-loading"><span class="spinner"></span></div>
+ `}
+ <div class="video-poster-slot" style=${ready ? '' : 'display:none'}>
+ ${metaReady && html`
+ <${MediaThumb} thumbHash=${posterHash} alt=${title}
+ cls="video-poster" transportRef=${transportRef} gekRef=${gekRef}
+ onReady=${handleImageReady} />
+ `}
+ </div>
+ ${ready && html`
+ <div class="video-card-info">
+ <div class="video-card-title">${(confident && meta.title) || title}</div>
+ <div class="video-card-sub">
+ ${confident && meta.release_date ? yearOf(meta.release_date) : ''}
+ ${confident && meta.first_air_date ? yearOf(meta.first_air_date) : ''}
+ ${subtitle ? ` · ${subtitle}` : ''}
+ </div>
+ </div>
+ `}
+ </div>
+ `;
+}
+
+function VideoDetailModal({ title, meta, repEntry, show, transportRef, gekRef, onClose, onPlay }) {
+ const confident = meta && meta.confidence && meta.tmdb_id;
+ return html`
+ <div class="video-overlay" onClick=${(e) => {
+ if (e.target.classList.contains('video-overlay')) onClose();
+ }}>
+ <div class="video-detail">
+ <div class="video-top-bar">
+ <span class="video-title">${(confident && meta.title) || title}</span>
+ <button class="video-close" onClick=${onClose} title=${t('video.close')}>
+ <${Icon} name="close" /></button>
+ </div>
+ <div class="video-detail-body">
+ ${confident && html`
+ <p class="video-detail-overview">${meta.overview}</p>
+ <p class="video-detail-facts">
+ ${meta.vote_average ? `★ ${meta.vote_average.toFixed(1)}` : ''}
+ ${meta.genres && meta.genres.length ? ` · ${meta.genres.join(', ')}` : ''}
+ ${meta.director ? ` · ${t('video.director')}: ${meta.director}` : ''}
+ </p>
+ ${meta.cast && meta.cast.length > 0 && html`
+ <p class="video-detail-cast">
+ ${meta.cast.slice(0, 6).map((c) => c.name).join(', ')}
+ </p>
+ `}
+ `}
+ ${!show && html`
+ <button class="admin-btn" onClick=${() => onPlay(repEntry)}>
+ <${Icon} name="play" /> ${t('group.play')}
+ ${repEntry.duration ? ` (${formatDuration(repEntry.duration)})` : ''}
+ </button>
+ `}
+ ${show && html`
+ <div class="video-season-list">
+ ${show.seasons.map((s) => html`
+ <div class="video-season" key=${s.season}>
+ <div class="video-season-header">
+ ${s.season === 0 ? t('video.specials') : t('video.season_n', { n: s.season })}
+ </div>
+ ${s.episodes.map((ep) => html`
+ <button class="video-episode-row" key=${ep.id} onClick=${() => onPlay(ep)}>
+ <${LazyTile} cls="video-episode-thumb-slot">
+ <${MediaThumb} thumbHash=${ep.thumb_hash} alt=${ep.display_title || ep.name}
+ cls="video-episode-thumb" transportRef=${transportRef} gekRef=${gekRef} />
+ </${LazyTile}>
+ <span class="video-episode-label">
+ S${ep.season}E${String(ep.episode).padStart(2, '0')}
+ ${' · '}${ep.display_title || ep.name}
+ </span>
+ <span class="video-episode-meta">
+ ${formatDuration(ep.duration)} ${formatResolution(ep.width, ep.height)}
+ </span>
+ </button>
+ `)}
+ </div>
+ `)}
+ </div>
+ `}
+ </div>
+ </div>
+ </div>
+ `;
+}
+
+function PosterGrid({ movies, shows, transportRef, gekRef, onPreview, tmdbEnabled }) {
+ const [detail, setDetail] = useState(null); // { title, repEntry, show? }
+ // raw (per-folder-parsed-title) show title -> its own resolved media_meta_resp.
+ const [metaByGroup, setMetaByGroup] = useState({});
+
+ const handleMetaResolved = useCallback((groupKey, meta) => {
+ setMetaByGroup((prev) => (prev[groupKey] === meta ? prev : { ...prev, [groupKey]: meta }));
+ }, []);
+
+ // Two raw groups (grouped by parsed display_title, §4.1) resolving to the
+ // same confident TMDB id are almost certainly one show whose seasons
+ // were released under differently-named folders — confirmed live: one
+ // operator's show had its two seasons parsed as "Ovni" and "OVNIs" by
+ // two different release groups, showing as two identical-looking cards
+ // once both matched the same real show (§3.4/V6). Merged here once both
+ // are actually known — never required for the fallback to work: a group
+ // with no confident match yet, or ever, still shows on its own, exactly
+ // the generic per-folder display needs.
+ const mergedShows = useMemo(() => {
+ const byTmdbId = new Map();
+ const standalone = [];
+ for (const s of shows) {
+ const meta = metaByGroup[s.title];
+ const tmdbId = meta && meta.confidence && meta.tmdb_id;
+ if (tmdbId) {
+ if (!byTmdbId.has(tmdbId)) byTmdbId.set(tmdbId, []);
+ byTmdbId.get(tmdbId).push(s);
+ } else {
+ standalone.push([s]);
+ }
+ }
+ return [...byTmdbId.values(), ...standalone].map((groups) => {
+ const episodes = groups.flatMap((g) => g.episodes);
+ return {
+ // groups[0].title, not a joined string of every constituent's
+ // title: a fresh key here would make this a brand-new PosterCard
+ // (and LazyTile) the instant a second raw group merges into an
+ // already-visible one — throwing away its already-fired
+ // IntersectionObserver and already-resolved metadata/poster for no
+ // reason, and reintroducing exactly the flash the "ready" gating
+ // above exists to prevent. groups[0].title is already unique
+ // (raw titles are, via groupVideoEntries' showsByTitle) and, for
+ // the overwhelmingly common unmerged case, is the same key the
+ // card already had — so nothing about this changes when no merge
+ // ever happens.
+ key: groups[0].title,
+ title: groups[0].title,
+ episodes,
+ seasons: buildSeasons(episodes),
+ };
+ });
+ }, [shows, metaByGroup]);
+
+ const openDetail = (title, repEntry, show) => setDetail({ title, repEntry, show });
+ const detailMeta = useMediaMeta(transportRef, detail ? detail.repEntry.path : null, !!detail);
+
+ return html`
+ <div class="video-grid">
+ ${movies.map((e) => html`
+ <${LazyTile} key=${e.id}>
+ <${PosterCard} title=${e.display_title || e.name}
+ subtitle=${formatDuration(e.duration)} repEntry=${e}
+ groupKey=${`movie:${e.id}`}
+ transportRef=${transportRef} gekRef=${gekRef}
+ onOpen=${() => (tmdbEnabled
+ // With TMDB off there is nothing the detail modal would show
+ // for a movie (no overview, no season list to pick from,
+ // unlike a show) — so it would just be an extra click in
+ // front of a Play button. Straight to the player instead.
+ ? openDetail(e.display_title || e.name, e, null)
+ : onPreview(e))} />
+ </${LazyTile}>
+ `)}
+ ${mergedShows.map((s) => {
+ // Prefer an episode that actually has a thumbnail over blindly
+ // episodes[0]: if that specific file's enrichment hasn't produced
+ // one yet (or failed), the card showed an empty placeholder even
+ // though sibling episodes — visible right there in Flat list —
+ // have one. The TMDB path (or its absence) is the same regardless
+ // of which episode's own file supplies the fallback frame.
+ const repEntry = s.episodes.find((e) => e.thumb_hash) || s.episodes[0];
+ // Known up front from the already-parsed index fields (§3.4), no
+ // TMDB needed: a card covering exactly one season says so before
+ // a click, rather than an anonymous episode count — the generic
+ // "N episodes" stays for a merged, multi-season, or special-only
+ // card, where a single number would misrepresent it.
+ const singleSeason = s.seasons.length === 1 ? s.seasons[0].season : null;
+ const subtitle = singleSeason != null
+ ? (singleSeason === 0 ? t('video.specials') : t('video.season_n', { n: singleSeason }))
+ : t('video.n_episodes', { n: s.episodes.length });
+ return html`
+ <${LazyTile} key=${s.key}>
+ <${PosterCard} title=${s.title}
+ subtitle=${subtitle}
+ repEntry=${repEntry}
+ groupKey=${s.title}
+ onMetaResolved=${handleMetaResolved}
+ transportRef=${transportRef} gekRef=${gekRef}
+ onOpen=${() => openDetail(s.title, repEntry, s)} />
+ </${LazyTile}>
+ `; })}
+ </div>
+ ${detail && html`
+ <${VideoDetailModal} title=${detail.title} meta=${detailMeta}
+ repEntry=${detail.repEntry} show=${detail.show}
+ transportRef=${transportRef} gekRef=${gekRef}
+ onClose=${() => setDetail(null)}
+ onPlay=${(entry) => { setDetail(null); onPreview(entry); }} />
+ `}
+ `;
+}
+
+// ── Mode B: flat, thumbnail-only, no TMDB ────────────────────────────────────
+
+// Within a season group, every episode's own display_title is usually
+// just the show name again (guessit rarely finds a per-episode subtitle
+// for this kind of release) — repeating "OVNI" twelve times in a row said
+// nothing an episode number wouldn't say better. Shown only when this row
+// is actually inside a season group (`seasonContext` set); a real,
+// distinct per-episode title (a show that *does* carry one) still wins
+// over the generic "Episode N" label.
+function FlatMovieRow({ entry, transportRef, gekRef, onPreview, seasonContext }) {
+ const isEpisode = seasonContext && entry.season != null && entry.episode != null;
+ const hasOwnTitle = entry.display_title && entry.display_title !== seasonContext;
+ const label = isEpisode
+ ? (hasOwnTitle ? `${t('video.episode_n', { n: entry.episode })} · ${entry.display_title}`
+ : t('video.episode_n', { n: entry.episode }))
+ : (entry.display_title || entry.name);
+
+ return html`
+ <div class="video-flat-row" onClick=${() => onPreview(entry)}>
+ <${LazyTile} cls="video-flat-thumb-slot">
+ <${MediaThumb} thumbHash=${entry.thumb_hash} alt=${entry.display_title || entry.name}
+ cls="video-flat-thumb" transportRef=${transportRef} gekRef=${gekRef} />
+ </${LazyTile}>
+ <div class="video-flat-info">
+ <div class="video-flat-title">${label}</div>
+ <div class="video-flat-sub">
+ ${formatDuration(entry.duration)} ${formatResolution(entry.width, entry.height)}
+ ${' · '}${formatSize(entry.size)}
+ </div>
+ </div>
+ </div>
+ `;
+}
+
+function FlatShowFolder({ show, transportRef, gekRef, onPreview }) {
+ const [open, setOpen] = useState(false);
+ return html`
+ <div class="video-flat-folder">
+ <div class="video-flat-row" onClick=${() => setOpen((v) => !v)}>
+ <div class="video-flat-thumb video-thumb-empty"><${Icon} name="folder" /></div>
+ <div class="video-flat-info">
+ <div class="video-flat-title">${show.title}</div>
+ <div class="video-flat-sub">${t('video.n_episodes', { n: show.episodes.length })}</div>
+ </div>
+ <${Icon} name="chevron" cls=${open ? 'video-flat-chevron open' : 'video-flat-chevron'} />
+ </div>
+ ${open && show.seasons.map((s) => html`
+ <div class="video-flat-season" key=${s.season}>
+ <div class="video-season-header">
+ ${s.season === 0 ? t('video.specials') : t('video.season_n', { n: s.season })}
+ </div>
+ ${s.episodes.map((ep) => html`
+ <${FlatMovieRow} key=${ep.id} entry=${ep} seasonContext=${show.title}
+ transportRef=${transportRef} gekRef=${gekRef} onPreview=${onPreview} />
+ `)}
+ </div>
+ `)}
+ </div>
+ `;
+}
+
+function FlatList({ movies, shows, transportRef, gekRef, onPreview }) {
+ const items = [
+ ...movies.map((e) => ({ key: e.display_title || e.name, kind: 'movie', entry: e })),
+ ...shows.map((s) => ({ key: s.title, kind: 'show', show: s })),
+ ].sort((a, b) => a.key.localeCompare(b.key));
+
+ return html`
+ <div class="video-flat-list">
+ ${items.map((it) => it.kind === 'movie'
+ ? html`<${FlatMovieRow} key=${it.entry.id} entry=${it.entry}
+ transportRef=${transportRef} gekRef=${gekRef} onPreview=${onPreview} />`
+ : html`<${FlatShowFolder} key=${it.show.title} show=${it.show}
+ transportRef=${transportRef} gekRef=${gekRef} onPreview=${onPreview} />`)}
+ </div>
+ `;
+}
+
+// ── shell ────────────────────────────────────────────────────────────────────
+
+function VideoApp({
+ groupId, transportRef, gekRef, status, entries, onPreview, videoRoot, tmdbConfig,
+}) {
+ const [mode, setMode] = useState(loadViewMode);
+ const [filter, setFilter] = useState('');
+ const tmdbEnabled = tmdbConfig ? tmdbConfig.enabled : true;
+
+ useEffect(() => { setMode(loadViewMode()); }, [groupId]);
+ useEffect(() => { setFilter(''); }, [groupId]);
+
+ const setModeAndSave = (m) => { setMode(m); saveViewMode(m); };
+
+ const { movies, shows } = useMemo(
+ () => groupVideoEntries(entries, videoRoot), [entries, videoRoot]);
+
+ const needle = filter.trim().toLowerCase();
+ const filteredMovies = useMemo(() => (!needle ? movies : movies.filter(
+ (e) => (e.display_title || e.name).toLowerCase().includes(needle))), [movies, needle]);
+ const filteredShows = useMemo(() => (!needle ? shows : shows.filter(
+ (s) => s.title.toLowerCase().includes(needle))), [shows, needle]);
+
+ return html`
+ ${(status === 'discovering' || status === 'connecting' || status === 'fetching') && html`
+ <p class="page-message"><span class="spinner"></span>${' '}${t('status.connecting_short')}</p>
+ `}
+ ${status === 'offline' && html`
+ <p class="page-message">${t('group.offline_title')} ${t('group.offline_hint')}</p>
+ `}
+ ${status === 'connected' && !videoRoot && html`
+ <p class="page-message">${t('video.no_root_configured')}</p>
+ `}
+ ${status === 'connected' && videoRoot && html`
+ <div class="video-toolbar">
+ <button class="tb-btn ${mode === 'poster' ? 'active' : ''}"
+ onClick=${() => setModeAndSave('poster')}>
+ ${t('video.mode_poster')}
+ </button>
+ <button class="tb-btn ${mode === 'flat' ? 'active' : ''}"
+ onClick=${() => setModeAndSave('flat')}>
+ ${t('video.mode_flat')}
+ </button>
+ <div class="tb-search">
+ <${Icon} name="search" />
+ <input type="text" placeholder="${t('group.filter')}"
+ value=${filter} onInput=${(e) => setFilter(e.target.value)} />
+ </div>
+ </div>
+ ${filteredMovies.length === 0 && filteredShows.length === 0 && html`
+ <p class="page-message">${needle ? t('group.empty_filter') : t('video.empty')}</p>
+ `}
+ ${mode === 'poster'
+ ? html`<${PosterGrid} movies=${filteredMovies} shows=${filteredShows}
+ transportRef=${transportRef} gekRef=${gekRef} onPreview=${onPreview}
+ tmdbEnabled=${tmdbEnabled} />`
+ : html`<${FlatList} movies=${filteredMovies} shows=${filteredShows}
+ transportRef=${transportRef} gekRef=${gekRef} onPreview=${onPreview} />`}
+ `}
+ `;
+}
+
+export { VideoApp };