diff options
| author | Christophe Besson <cbesson@gmail.com> | 2026-08-24 14:33:38 +0200 |
|---|---|---|
| committer | Christophe Besson <cbesson@gmail.com> | 2026-08-24 14:33:38 +0200 |
| commit | 317f09328ed8bf20148b707470c9b0fe82e59575 (patch) | |
| tree | ba8daf5dcf050d44b7b0e766babbfda8fadac59f /packages/meshbay-hub/src/meshbay_hub | |
| parent | b6e2dcea65124673da047f9b3c92bc5e25980d63 (diff) | |
| parent | 0b0da86f1f9d6f0b1a27b5e1e1658c42de9f356a (diff) | |
| download | meshbay-317f09328ed8bf20148b707470c9b0fe82e59575.tar.gz | |
Merge branch 'docs/mediacenter-videos-app': Videos group app
Poster grid / flat list browsing, TMDB metadata enrichment, thumbnail
generation and caching, season-specific overviews, manual match
correction, and the create-group wizard's app-selection step.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LAmyXtc6dAADsH23ydXQpY
Diffstat (limited to 'packages/meshbay-hub/src/meshbay_hub')
19 files changed, 1977 insertions, 27 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..f9bcd43 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 ──────────────────────────────────────────────────────────────── @@ -838,6 +839,15 @@ function CreateGroupWizard({ token, username, onCreated }) { const [joinPolicy, setJoinPolicy] = useState('invite'); const [roots, setRoots] = useState([]); const [uploadIdx, setUploadIdx] = useState(0); + // Every registered app, on by default — narrowing this down here means + // members never briefly see one the operator meant to leave off, the way + // toggling it afterward from Settings would. + const [enabledApps, setEnabledApps] = useState(() => APPS.map(a => a.key)); + const toggleWizardApp = useCallback((key) => { + setEnabledApps(prev => prev.includes(key) + ? prev.filter(k => k !== key) + : [...prev, key]); + }, []); // Step 2 progress const [setupSteps, setSetupSteps] = useState([]); @@ -914,8 +924,13 @@ function CreateGroupWizard({ token, username, onCreated }) { const steps = [ { label: t('wizard.step_create_hub'), status: 'pending' }, { label: t('wizard.step_attach'), status: 'pending' }, - { label: t('wizard.step_index'), status: 'pending' }, ]; + // Only a step at all when it does something — the common case (every + // app left on, the default) has nothing to set and no reason to show a + // step for it. + if (enabledApps.length < APPS.length) + steps.push({ label: t('wizard.step_apps'), status: 'pending' }); + steps.push({ label: t('wizard.step_index'), status: 'pending' }); if (roots.length > 1) steps.push({ label: t('wizard.step_add_roots'), status: 'pending' }); steps.push({ label: t('wizard.step_gek'), status: 'pending' }); @@ -979,11 +994,24 @@ function CreateGroupWizard({ token, username, onCreated }) { update('done'); advance(); - // 3. Wait for the node's own initial scan of this group to finish — + // 3. Narrow the enabled apps down, if the operator unchecked any — + // before the scan below, so a member who joins while it is still + // running never briefly sees an app meant to be off. Same + // not-hosted-yet race as steps 4+ below: the group is attached, but + // may not have reached groups_ctx yet. + if (enabledApps.length < APPS.length) { + update('running'); + await withRetry(() => platform.node.call( + 'PUT', `/api/groups/${gid}/apps`, { apps: enabledApps })); + update('done'); + advance(); + } + + // 4. Wait for the node's own initial scan of this group to finish — // the group is not usable for anything below (extra roots, GEK) until // this finishes, so nobody lands on a page that looks broken, or hits // a "not configured" error from racing ahead of it. Can take tens of - // minutes on a slow disk (see the StarWars benchmark) — the node + // minutes on a slow disk with a large library — the node // keeps scanning on its own either way (test_hot_reload_survives_ // client_close.py); this step is only about not lying about it. update('running'); @@ -991,7 +1019,7 @@ function CreateGroupWizard({ token, username, onCreated }) { update('done'); advance(); - // 4. Add extra roots (if >1) + // 5. Add extra roots (if >1) if (roots.length > 1) { update('running'); for (let i = 0; i < roots.length; i++) { @@ -1006,13 +1034,13 @@ function CreateGroupWizard({ token, username, onCreated }) { advance(); } - // 5. GEK init + // 6. GEK init update('running'); await withRetry(() => platform.node.call('POST', `/api/groups/${gid}/gek`)); update('done'); advance(); - // 6. Generate pairing code + // 7. Generate pairing code update('running'); const pairResult = await platform.node.call('POST', '/api/operator/pair'); if (pairResult && pairResult.code) { @@ -1030,7 +1058,7 @@ function CreateGroupWizard({ token, username, onCreated }) { update('error'); setSetupError(platform.bridgeMessage(err)); } - }, [name, description, joinPolicy, roots, uploadIdx, token, onCreated]); + }, [name, description, joinPolicy, roots, uploadIdx, enabledApps, token, onCreated]); // Step 0: Node detection if (step === 0) { @@ -1064,7 +1092,7 @@ function CreateGroupWizard({ token, username, onCreated }) { // Step 1: Group details + directories if (step === 1) { - const canProceed = name.trim() && roots.length > 0; + const canProceed = name.trim() && roots.length > 0 && enabledApps.length > 0; return html`<div class="page-content"> <h2>${t('wizard.title')}</h2> ${error && html`<div class="error-msg" style="margin-bottom:16px">${error}</div>`} @@ -1111,6 +1139,25 @@ function CreateGroupWizard({ token, username, onCreated }) { </div> <div class="settings-section"> + <h3 class="settings-heading">${t('members.apps_title')}</h3> + <p style="font-size:0.85em;color:var(--text-dim);margin-bottom:8px"> + ${t('members.apps_hint')}</p> + <ul class="apps-toggle-list"> + ${APPS.map(a => html` + <li key=${a.key} class="settings-row"> + <label class="settings-label"> + <input type="checkbox" checked=${enabledApps.includes(a.key)} + onChange=${() => toggleWizardApp(a.key)} /> + ${' '}${t(a.labelKey)} + </label> + </li> + `)} + </ul> + ${enabledApps.length === 0 && html` + <p class="error-msg">${t('members.apps_need_one')}</p>`} + </div> + + <div class="settings-section"> <h3 class="settings-heading">${t('wizard.directories')}</h3> <p style="font-size:0.85em;color:var(--text-dim);margin-bottom:8px"> ${t('wizard.directories_hint')}</p> @@ -1160,8 +1207,8 @@ function CreateGroupWizard({ token, username, onCreated }) { ${setupSteps.map((s, i) => html` <div class="wizard-step wizard-step-${s.status}" key=${i}> <span class="wizard-step-icon"> - ${s.status === 'done' ? '✓' : - s.status === 'running' ? '●' : + ${s.status === 'running' ? html`<span class="spinner"></span>` : + s.status === 'done' ? '✓' : s.status === 'error' ? '✗' : '○'} </span> <span>${s.label}</span> @@ -1731,8 +1778,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..22532fa 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, useRef, } 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,138 @@ 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]); + + // A node that has never had a language explicitly set would otherwise + // query TMDB with none at all — which TMDB itself resolves to English, + // regardless of who the operator is — even though this form already + // *suggests* their own UI language as the value. Applied once, + // automatically, the first time the operator (the only one who can sign + // this) is actually connected to see it: a real default tied to whoever + // runs this particular node, never a single hardcoded language for every + // node. `tmdbConfig.language` being set at all — from this or from an + // explicit save — is what stops it from ever firing again, so "unless + // manually changed" holds regardless of which of the two set it first. + const autoLanguageSetRef = useRef(false); + useEffect(() => { + if (!isNodeAdmin || !connected || !tmdbConfig || tmdbConfig.language) return; + if (autoLanguageSetRef.current) return; + autoLanguageSetRef.current = true; + saveTmdbConfig(tmdbEnabled); + }, [isNodeAdmin, connected, tmdbConfig, tmdbEnabled, saveTmdbConfig]); + + // 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 +607,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..7f05441 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,24 @@ 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', + }, + 'video.fix_match': 'Übereinstimmung korrigieren…', + 'video.search_title': 'TMDB-Übereinstimmung korrigieren', + 'video.search_placeholder': 'TMDB durchsuchen…', + 'video.search_button': 'Suchen', + 'video.search_no_results': 'Keine Treffer gefunden.', + 'video.search_apply_hint': 'Gilt für alle Dateien, die derzeit unter diesem Titel gruppiert sind.', // LAN-Cast 'cast.start': 'Auf Gerät übertragen', @@ -572,6 +591,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', @@ -591,6 +626,7 @@ export default { 'wizard.setting_up': 'Ihre Gruppe wird eingerichtet…', 'wizard.step_create_hub': 'Gruppe auf dem Hub erstellen', 'wizard.step_attach': 'An Node anbinden', + 'wizard.step_apps': 'Anwendungen auswählen', 'wizard.step_add_roots': 'Verzeichnisse hinzufügen', 'wizard.step_gek': 'Verschlüsselungsschlüssel initialisieren', 'wizard.step_pair': 'Kopplung einrichten', 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..7f31bb5 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,24 @@ 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', + }, + 'video.fix_match': 'Fix match…', + 'video.search_title': 'Correct the TMDB match', + 'video.search_placeholder': 'Search TMDB…', + 'video.search_button': 'Search', + 'video.search_no_results': 'No matches found.', + 'video.search_apply_hint': 'Applies to every file currently grouped under this title.', // LAN cast 'cast.start': 'Cast to device', @@ -361,6 +380,7 @@ export default { 'wizard.setting_up': 'Setting up your group...', 'wizard.step_create_hub': 'Creating group on hub', 'wizard.step_attach': 'Attaching to node', + 'wizard.step_apps': 'Choosing applications', 'wizard.step_add_roots': 'Adding directories', 'wizard.step_gek': 'Initializing encryption key', 'wizard.step_pair': 'Setting up pairing', @@ -396,6 +416,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..d7f0f1e 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,24 @@ 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', + }, + 'video.fix_match': 'Corregir coincidencia…', + 'video.search_title': 'Corregir la coincidencia de TMDB', + 'video.search_placeholder': 'Buscar en TMDB…', + 'video.search_button': 'Buscar', + 'video.search_no_results': 'No se encontraron coincidencias.', + 'video.search_apply_hint': 'Se aplica a todos los archivos agrupados actualmente bajo este título.', // LAN cast 'cast.start': 'Enviar a dispositivo', @@ -567,6 +586,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', @@ -586,6 +621,7 @@ export default { 'wizard.setting_up': 'Configurando su grupo…', 'wizard.step_create_hub': 'Creando grupo en el hub', 'wizard.step_attach': 'Conectando al node', + 'wizard.step_apps': 'Eligiendo aplicaciones', 'wizard.step_add_roots': 'Añadiendo directorios', 'wizard.step_gek': 'Inicializando clave de cifrado', 'wizard.step_pair': 'Configurando emparejamiento', 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..2befdb9 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,24 @@ 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', + }, + 'video.fix_match': 'Corriger la correspondance…', + 'video.search_title': 'Corriger la correspondance TMDB', + 'video.search_placeholder': 'Rechercher sur TMDB…', + 'video.search_button': 'Rechercher', + 'video.search_no_results': 'Aucune correspondance trouvée.', + 'video.search_apply_hint': 'S’applique à tous les fichiers actuellement regroupés sous ce titre.', // LAN cast 'cast.start': 'Diffuser sur un appareil', @@ -583,6 +602,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', @@ -602,6 +637,7 @@ export default { 'wizard.setting_up': 'Configuration de votre groupe…', 'wizard.step_create_hub': 'Création du groupe sur le hub', 'wizard.step_attach': 'Rattachement au node', + 'wizard.step_apps': 'Choix des applications', 'wizard.step_add_roots': 'Ajout des répertoires', 'wizard.step_gek': 'Initialisation de la clé de chiffrement', 'wizard.step_pair': 'Mise en place de l\'appariement', 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..bdfda75 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,24 @@ 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', + }, + 'video.fix_match': 'Correggi corrispondenza…', + 'video.search_title': 'Correggi la corrispondenza TMDB', + 'video.search_placeholder': 'Cerca su TMDB…', + 'video.search_button': 'Cerca', + 'video.search_no_results': 'Nessuna corrispondenza trovata.', + 'video.search_apply_hint': 'Si applica a tutti i file attualmente raggruppati sotto questo titolo.', // LAN cast 'cast.start': 'Trasmetti al dispositivo', @@ -581,6 +600,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', @@ -600,6 +635,7 @@ export default { 'wizard.setting_up': 'Configurazione del gruppo…', 'wizard.step_create_hub': 'Creazione del gruppo sul hub', 'wizard.step_attach': 'Collegamento al node', + 'wizard.step_apps': 'Scelta delle applicazioni', 'wizard.step_add_roots': 'Aggiunta delle directory', 'wizard.step_gek': 'Inizializzazione della chiave di cifratura', 'wizard.step_pair': 'Configurazione del pairing', 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..f00a0ec 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,24 @@ 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}話', + }, + 'video.fix_match': '一致を修正…', + 'video.search_title': 'TMDBの一致を修正', + 'video.search_placeholder': 'TMDBを検索…', + 'video.search_button': '検索', + 'video.search_no_results': '一致する結果が見つかりません。', + 'video.search_apply_hint': '現在このタイトルでグループ化されているすべてのファイルに適用されます。', // LAN cast 'cast.start': 'デバイスにキャスト', @@ -565,6 +584,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': 'グループを作成', @@ -584,6 +619,7 @@ export default { 'wizard.setting_up': 'グループをセットアップ中…', 'wizard.step_create_hub': 'hub 上にグループを作成中', 'wizard.step_attach': 'node に接続中', + 'wizard.step_apps': 'アプリを選択中', 'wizard.step_add_roots': 'ディレクトリを追加中', 'wizard.step_gek': '暗号化鍵を初期化中', 'wizard.step_pair': 'ペアリングをセットアップ中', 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..d9ed12e 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,24 @@ 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', + }, + 'video.fix_match': 'Overeenkomst corrigeren…', + 'video.search_title': 'TMDB-overeenkomst corrigeren', + 'video.search_placeholder': 'Zoeken in TMDB…', + 'video.search_button': 'Zoeken', + 'video.search_no_results': 'Geen overeenkomsten gevonden.', + 'video.search_apply_hint': 'Geldt voor alle bestanden die momenteel onder deze titel zijn gegroepeerd.', // LAN cast 'cast.start': 'Naar apparaat casten', @@ -583,6 +602,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', @@ -602,6 +637,7 @@ export default { 'wizard.setting_up': 'Uw groep wordt ingesteld…', 'wizard.step_create_hub': 'Groep aanmaken op hub', 'wizard.step_attach': 'Koppelen aan node', + 'wizard.step_apps': 'Apps kiezen', 'wizard.step_add_roots': 'Mappen toevoegen', 'wizard.step_gek': 'Versleutelingssleutel initialiseren', 'wizard.step_pair': 'Koppeling instellen', 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..cbd27b1 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,26 @@ 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', + }, + 'video.fix_match': 'Popraw dopasowanie…', + 'video.search_title': 'Popraw dopasowanie TMDB', + 'video.search_placeholder': 'Szukaj w TMDB…', + 'video.search_button': 'Szukaj', + 'video.search_no_results': 'Nie znaleziono dopasowań.', + 'video.search_apply_hint': 'Dotyczy wszystkich plików obecnie zgrupowanych pod tym tytułem.', // LAN cast 'cast.start': 'Przesyłaj na urządzenie', @@ -604,6 +625,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ę', @@ -623,6 +660,7 @@ export default { 'wizard.setting_up': 'Konfigurowanie grupy…', 'wizard.step_create_hub': 'Tworzenie grupy na hub', 'wizard.step_attach': 'Podłączanie do node', + 'wizard.step_apps': 'Wybieranie aplikacji', 'wizard.step_add_roots': 'Dodawanie katalogów', 'wizard.step_gek': 'Inicjalizacja klucza szyfrowania', 'wizard.step_pair': 'Konfigurowanie parowania', 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..e7bf45c 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,24 @@ 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', + }, + 'video.fix_match': 'Corrigir correspondência…', + 'video.search_title': 'Corrigir a correspondência do TMDB', + 'video.search_placeholder': 'Pesquisar no TMDB…', + 'video.search_button': 'Pesquisar', + 'video.search_no_results': 'Nenhuma correspondência encontrada.', + 'video.search_apply_hint': 'Aplica-se a todos os arquivos atualmente agrupados sob este título.', // LAN cast 'cast.start': 'Transmitir para dispositivo', @@ -568,6 +587,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', @@ -587,6 +622,7 @@ export default { 'wizard.setting_up': 'Configurando seu grupo…', 'wizard.step_create_hub': 'Criando grupo no hub', 'wizard.step_attach': 'Anexando ao node', + 'wizard.step_apps': 'Escolhendo aplicativos', 'wizard.step_add_roots': 'Adicionando diretórios', 'wizard.step_gek': 'Inicializando chave de criptografia', 'wizard.step_pair': 'Configurando pareamento', 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..33a0e25 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,24 @@ 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} 集', + }, + 'video.fix_match': '修正匹配…', + 'video.search_title': '更正 TMDB 匹配', + 'video.search_placeholder': '搜索 TMDB…', + 'video.search_button': '搜索', + 'video.search_no_results': '未找到匹配项。', + 'video.search_apply_hint': '将应用于当前归类在此标题下的所有文件。', // LAN cast 'cast.start': '投射到设备', @@ -551,6 +570,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': '创建群组', @@ -571,6 +606,7 @@ export default { 'wizard.setting_up': '正在设置您的群组…', 'wizard.step_create_hub': '在 hub 上创建群组', 'wizard.step_attach': '关联到 node', + 'wizard.step_apps': '选择应用', 'wizard.step_add_roots': '添加目录', 'wizard.step_gek': '初始化加密密钥', 'wizard.step_pair': '设置配对', diff --git a/packages/meshbay-hub/src/meshbay_hub/static/style.css b/packages/meshbay-hub/src/meshbay_hub/static/style.css index 2cb27d0..446a751 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,275 @@ 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; } + +/* Season tab bar — docs/mediacenter.md §5.4's per-season overview view */ +.video-season-tabs { + display: flex; + gap: 4px; + margin: 10px 0; + overflow-x: auto; +} +.video-season-tab { + padding: 5px 12px; + border: 1px solid var(--border); + border-radius: 999px; + background: var(--bg-surface); + color: var(--text-dim); + font-size: 0.85em; + white-space: nowrap; + cursor: pointer; +} +.video-season-tab:hover { border-color: var(--accent); color: var(--text); } +.video-season-tab.active { border-color: var(--accent); color: var(--accent); background: var(--bg-raised); } + +.video-fix-match { margin: 8px 0; font-size: 0.85em; } + +/* Operator search-and-correct overlay, layered on top of the detail modal. + Must beat .video-overlay's own z-index: 200 — both are position: fixed, + so without an explicit higher value here this one loses the stacking + order despite being the later sibling in the DOM. */ +.video-search-overlay { z-index: 210; } +.video-search-panel { max-width: 480px; } +.video-search-form { display: flex; gap: 8px; } +.video-search-form input { + flex: 1; + padding: 7px 10px; + border: 1px solid var(--border); + border-radius: 6px; + background: var(--bg-surface); + color: var(--text); +} +.video-search-hint { font-size: 0.78em; color: var(--text-dim); margin: 6px 0 2px; } +.video-search-error { font-size: 0.85em; color: var(--error); } +.video-search-results { display: flex; flex-direction: column; gap: 4px; margin-top: 10px; } +.video-search-result { + display: flex; + align-items: center; + gap: 10px; + padding: 6px; + border: 1px solid transparent; + border-radius: 6px; + background: none; + text-align: left; + cursor: pointer; +} +.video-search-result:hover:not(:disabled) { border-color: var(--accent); background: var(--bg-raised); } +.video-search-result:disabled { opacity: 0.5; cursor: not-allowed; } +.video-search-result-thumb { width: 46px; height: 68px; flex-shrink: 0; } +.video-search-result-poster { + width: 100%; + height: 100%; + object-fit: cover; + border-radius: 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..b4bfe87 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,121 @@ 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; + } + + /** + * One season's own overview/air_date/poster (docs/mediacenter.md §5.4's + * per-season view) — a show's own tmdb_meta is one static field that does + * not necessarily describe every season alike, found live: a 3-season + * show whose overview read as season-3-specific for every season. + * Keyed like media_meta_req: a season-tab bar can fire a request per tab + * before the previous one lands, and matching by arrival order would hand + * one season's data to a different season's tab whenever two responses + * reordered. + */ + async fetchSeasonMeta(tmdbId, season) { + const msg = await this._sendAndWait({ + type: 'season_meta_req', v: '0.6', tmdb_id: tmdbId, season, + }); + if (msg.type === 'error') throw new Error(msg.detail); + return msg; + } + + /** + * Raw TMDB search candidates for an operator correcting a wrong automatic + * match — unlike fetchMediaMeta, this never collapses to one best guess: + * a human picks from several, so several is the point. Read-only, not an + * admin op: it looks nothing up in this node's own state and changes + * nothing, so it needs no signature (mirrors why media_meta_req isn't + * signed either). + */ + async searchTmdb(mediaType, query) { + const msg = await this._sendAndWait({ + type: 'tmdb_search_req', v: '0.6', media_type: mediaType, query, + }); + if (msg.type === 'error') throw new Error(msg.detail); + return msg; + } + + /** + * Correct a wrong automatic TMDB match. Signed like setVideoRoot/ + * setTmdbConfig: it replaces what every member sees for a show/movie, + * node-wide (media_cache is shared, not per-viewer) — an unsigned + * override would let any member vandalize another show's metadata. + * Applies to every file sharing the representative one's display_title, + * not just the file the operator happened to be looking at (webrtc_ + * server.py's _admin_exec_tmdb_override). + */ + async overrideTmdbMatch(path, tmdbId, mediaType, signFn) { + const msg = await this._sendAndWait({ + type: 'tmdb_override', v: '0.6', path, tmdb_id: tmdbId, media_type: mediaType, + }); + if (msg.type === 'error') throw new Error(msg.detail); + if (msg.type === 'admin_challenge') { + const subject = `path=${path},tmdb_id=${tmdbId},media_type=${mediaType}`; + return this._authorizeAdminOp(msg, 'tmdb_override', subject, signFn); + } + 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 +1290,20 @@ 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}` + // 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}` + : obj.type === 'tmdb_search_req' ? `tmdb_search:${obj.media_type}:${obj.query}` + : null, resolve: (msg) => { clearTimeout(timeout); this._pending.delete(id); resolve(msg); }, reject: (err) => { clearTimeout(timeout); this._pending.delete(id); reject(err); }, }); @@ -1276,6 +1404,34 @@ 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: an operator corrected a wrong automatic TMDB match, and + // everyone connected needs to know their poster grid/detail modal for + // this show is now stale — falls through so the operator's own + // admin_response promise resolves on this same message, exactly like + // member_upload_ack/apps_enabled_ack above. + if (msg.type === 'tmdb_override_ack' && this._onTmdbOverride) { + this._onTmdbOverride({ + path: msg.path || '', tmdbId: msg.tmdb_id || '', mediaType: msg.media_type || '', + }); + } + + // 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 +1472,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 +1520,36 @@ 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; + } + + // 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) { + if (handler._key === key) { handler.resolve(msg); return; } + } + return; + } + + if (msg.type === 'tmdb_search_resp') { + const key = `tmdb_search:${msg.media_type}:${msg.query}`; + for (const [, handler] of this._pending) { + if (handler._key === key) { handler.resolve(msg); return; } + } + 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..00643f8 --- /dev/null +++ b/packages/meshbay-hub/src/meshbay_hub/static/video-app.js @@ -0,0 +1,816 @@ +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 ──────────────────────────── + +// An operator correcting a wrong match (TmdbSearchOverlay below) changes +// what `media_meta_req` returns for a path every already-mounted tile/modal +// already has cached in its own useMediaMeta state — nothing would ever +// refetch otherwise, since path/active don't change. Bumping this and +// telling every subscribed hook to redo its fetch is simpler than trying to +// know which paths a given override actually affects (that's server-side +// knowledge — display_title grouping — this module doesn't have). +const _mediaMetaListeners = new Set(); +function bumpMediaMetaGeneration() { + for (const fn of _mediaMetaListeners) fn(); +} + +function useMediaMeta(transportRef, path, active) { + const [meta, setMeta] = useState(null); + const [refetchToken, setRefetchToken] = useState(0); + + useEffect(() => { + // Clears immediately (so the spinner shows right away, not only once + // the new fetch resolves) and bumps the token, which re-runs the fetch + // effect below regardless of whether path/active changed at all. + const listener = () => { setMeta(null); setRefetchToken((n) => n + 1); }; + _mediaMetaListeners.add(listener); + return () => _mediaMetaListeners.delete(listener); + }, []); + + 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, refetchToken]); + return meta; +} + +// ── per-season TMDB metadata (overview/air_date/poster), season-tab view ─── +// +// Found live: a show's own tmdb_meta.overview is one static field that does +// not necessarily describe every season alike (a season-3-specific +// promotional summary read as the synopsis for all three seasons). Cached +// for the session by tmdb_id+season, mirroring _thumbBlobCache — the same +// season is revisited every time its tab is reselected. +const _seasonMetaCache = new Map(); + +function useSeasonMeta(transportRef, tmdbId, season, active) { + const cacheKey = active && tmdbId != null && season != null ? `${tmdbId}:${season}` : null; + const [meta, setMeta] = useState(() => (cacheKey ? _seasonMetaCache.get(cacheKey) || null : null)); + useEffect(() => { + if (!cacheKey) return; + const cached = _seasonMetaCache.get(cacheKey); + if (cached) { setMeta(cached); return; } + setMeta(null); + let cancelled = false; + (async () => { + const transport = transportRef.current; + if (!transport || !transport.connected) return; + try { + const resp = await transport.fetchSeasonMeta(tmdbId, season); + if (cancelled) return; + _seasonMetaCache.set(cacheKey, resp); + setMeta(resp); + } catch { if (!cancelled) setMeta({ confidence: 0 }); } + })(); + return () => { cancelled = true; }; + }, [cacheKey]); + 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> + `; +} + +// ── season tab bar (docs/mediacenter.md §5.4's fix for a mis-scoped overview) ─ + +function SeasonTabs({ seasons, selected, onSelect }) { + return html` + <div class="video-season-tabs"> + ${seasons.map((s) => html` + <button key=${s.season} + class="video-season-tab ${selected === s.season ? 'active' : ''}" + onClick=${() => onSelect(s.season)}> + ${s.season === 0 ? t('video.specials') : t('video.season_n', { n: s.season })} + </button> + `)} + </div> + `; +} + +// ── operator: correct a wrong automatic TMDB match ────────────────────────── + +// Same shape as group-settings.js's own signFn construction (setVideoRoot, +// setTmdbConfig, ...) — there is no group-wide "sign this" helper to share, +// each caller builds one from the connection it already has. +function buildSignFn(transportRef) { + const transport = transportRef.current; + const sk = transport && transport.sessionKeys && transport.sessionKeys.skEdB64; + return (sk && window.MeshBayKeys) + ? (transcript) => window.MeshBayKeys.signBytes(sk, transcript) + : null; +} + +function TmdbSearchOverlay({ + initialQuery, mediaType, path, transportRef, gekRef, onClose, onApplied, +}) { + const [query, setQuery] = useState(initialQuery || ''); + const [results, setResults] = useState(null); // null = not searched yet + const [searching, setSearching] = useState(false); + const [applying, setApplying] = useState(false); + const [error, setError] = useState(''); + + const runSearch = useCallback(async (e) => { + if (e) e.preventDefault(); + const q = query.trim(); + if (!q || searching) return; + setSearching(true); + setError(''); + try { + const transport = transportRef.current; + const resp = await transport.searchTmdb(mediaType, q); + setResults(resp.results || []); + } catch (err) { + setError(err.message); + setResults([]); + } finally { + setSearching(false); + } + }, [query, mediaType, transportRef, searching]); + + const apply = useCallback(async (tmdbId) => { + if (applying) return; + setApplying(true); + setError(''); + try { + const signFn = buildSignFn(transportRef); + await transportRef.current.overrideTmdbMatch(path, tmdbId, mediaType, signFn); + bumpMediaMetaGeneration(); + onApplied(); + } catch (err) { + setError(err.message); + setApplying(false); + } + }, [applying, path, mediaType, transportRef, onApplied]); + + return html` + <div class="video-overlay video-search-overlay" onClick=${(e) => { + if (e.target.classList.contains('video-search-overlay')) onClose(); + }}> + <div class="video-detail video-search-panel"> + <div class="video-top-bar"> + <span class="video-title">${t('video.search_title')}</span> + <button class="video-close" onClick=${onClose} title=${t('video.close')}> + <${Icon} name="close" /></button> + </div> + <div class="video-detail-body"> + <form class="video-search-form" onSubmit=${runSearch}> + <input type="text" value=${query} autofocus + placeholder=${t('video.search_placeholder')} + onInput=${(e) => setQuery(e.target.value)} /> + <button class="admin-btn" type="submit" disabled=${searching || !query.trim()}> + ${searching ? html`<span class="spinner"></span>` : t('video.search_button')} + </button> + </form> + <p class="video-search-hint">${t('video.search_apply_hint')}</p> + ${error && html`<p class="video-search-error">${error}</p>`} + ${results && results.length === 0 && !searching && html` + <p class="page-message">${t('video.search_no_results')}</p> + `} + ${results && results.length > 0 && html` + <div class="video-search-results"> + ${results.map((r) => html` + <button class="video-search-result" key=${r.tmdb_id} + disabled=${applying} + onClick=${() => apply(r.tmdb_id)}> + <div class="video-search-result-thumb"> + <${MediaThumb} thumbHash=${r.poster_thumb_hash} alt=${r.title} + cls="video-search-result-poster" transportRef=${transportRef} gekRef=${gekRef} /> + </div> + <div class="video-flat-info"> + <div class="video-flat-title">${r.title}</div> + <div class="video-flat-sub">${r.year}</div> + </div> + </button> + `)} + </div> + `} + </div> + </div> + </div> + `; +} + +function VideoDetailModal({ + title, meta, repEntry, show, transportRef, gekRef, onClose, onPlay, isNodeAdmin, +}) { + const confident = meta && meta.confidence && meta.tmdb_id; + const [searching, setSearching] = useState(false); + const mediaType = show ? 'tv' : 'movie'; + + // Reset whenever a different file/show is opened in this same modal + // instance — repEntry/show change identity, selectedSeason must not + // silently keep pointing at whatever the previous show's season 4 was. + const [selectedSeason, setSelectedSeason] = useState(null); + useEffect(() => { + if (!show) { setSelectedSeason(null); return; } + const preferred = repEntry.season != null && show.seasons.some((s) => s.season === repEntry.season) + ? repEntry.season + : (show.seasons.find((s) => s.season !== 0) || show.seasons[0]).season; + setSelectedSeason(preferred); + }, [show, repEntry]); + + const showMultiSeason = Boolean(show && show.seasons.length > 1); + const seasonMeta = useSeasonMeta( + transportRef, confident ? meta.tmdb_id : null, selectedSeason, + showMultiSeason && Boolean(confident) && selectedSeason != null); + const seasonConfident = showMultiSeason && seasonMeta && seasonMeta.confidence; + + 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">${(seasonConfident && seasonMeta.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}` : ''} + ${seasonConfident && seasonMeta.air_date ? ` · ${yearOf(seasonMeta.air_date)}` : ''} + </p> + ${meta.cast && meta.cast.length > 0 && html` + <p class="video-detail-cast"> + ${meta.cast.slice(0, 6).map((c) => c.name).join(', ')} + </p> + `} + `} + ${isNodeAdmin && html` + <button class="admin-btn video-fix-match" onClick=${() => setSearching(true)}> + ${t('video.fix_match')} + </button> + `} + ${showMultiSeason && html` + <${SeasonTabs} seasons=${show.seasons} selected=${selectedSeason} + onSelect=${setSelectedSeason} /> + `} + ${!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"> + ${(showMultiSeason ? show.seasons.filter((s) => s.season === selectedSeason) : show.seasons) + .map((s) => html` + <div class="video-season" key=${s.season}> + ${!showMultiSeason && html` + <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> + ${searching && html` + <${TmdbSearchOverlay} initialQuery=${(confident && meta.title) || title} mediaType=${mediaType} + path=${repEntry.path} transportRef=${transportRef} gekRef=${gekRef} + onClose=${() => setSearching(false)} + onApplied=${() => setSearching(false)} /> + `} + `; +} + +function PosterGrid({ movies, shows, transportRef, gekRef, onPreview, tmdbEnabled, isNodeAdmin }) { + 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} isNodeAdmin=${isNodeAdmin} + 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, isNodeAdmin, +}) { + 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} isNodeAdmin=${isNodeAdmin} />` + : html`<${FlatList} movies=${filteredMovies} shows=${filteredShows} + transportRef=${transportRef} gekRef=${gekRef} onPreview=${onPreview} />`} + `} + `; +} + +export { VideoApp }; |