summaryrefslogtreecommitdiffstats
path: root/packages/meshbay-hub/src/meshbay_hub/static/video-app.js
diff options
context:
space:
mode:
Diffstat (limited to 'packages/meshbay-hub/src/meshbay_hub/static/video-app.js')
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/video-app.js599
1 files changed, 599 insertions, 0 deletions
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/video-app.js b/packages/meshbay-hub/src/meshbay_hub/static/video-app.js
new file mode 100644
index 0000000..250bf8b
--- /dev/null
+++ b/packages/meshbay-hub/src/meshbay_hub/static/video-app.js
@@ -0,0 +1,599 @@
+import {
+ html, useState, useEffect, useRef, useMemo, useCallback,
+} from './vendor/htm-preact.js';
+import { t } from './i18n.js';
+import { Icon } from './icon.js';
+import { formatSize, pipelinedDownload } from './file-utils.js';
+
+// ── Videos ───────────────────────────────────────────────────────────────────
+//
+// A poster-grid (TMDB-enriched) or flat (thumbnail-only) browser for a
+// group's video files, per docs/mediacenter.md. Grouping: one card per movie,
+// one card per show — shows are grouped by `display_title` (already
+// resolved/corroborated at index time, §3.4), not by folder path, since a
+// client-side path convention would have to guess how many roots/subfolders
+// deep a show folder sits, which display_title already settled once.
+//
+// TMDB metadata is fetched lazily, only for a tile once it is actually
+// visible (LazyTile below) — apps.md §5's virtualization requirement for a
+// grid of many tiles. Thumbnails go through the same `file_req`/chunk path
+// as a real file (docs/mediacenter.md §5.3) via MediaThumb, reusing
+// chat-app.js's ChatImage pattern.
+
+const VIEW_MODE_KEY = 'meshbay_video_view_mode';
+
+function loadViewMode() {
+ try { return localStorage.getItem(VIEW_MODE_KEY) === 'flat' ? 'flat' : 'poster'; }
+ catch { return 'poster'; }
+}
+function saveViewMode(mode) {
+ try { localStorage.setItem(VIEW_MODE_KEY, mode); } catch { /* per-device convenience only */ }
+}
+
+function formatDuration(seconds) {
+ if (!seconds) return '';
+ const total = Math.round(seconds);
+ const h = Math.floor(total / 3600);
+ const m = Math.floor((total % 3600) / 60);
+ return h > 0 ? `${h}h${String(m).padStart(2, '0')}` : `${m}min`;
+}
+
+function formatResolution(width, height) {
+ if (!width || !height) return '';
+ if (height >= 2100) return '4K';
+ if (height >= 1000) return `${height}p`;
+ return `${width}x${height}`;
+}
+
+function yearOf(dateStr) {
+ return dateStr ? String(dateStr).slice(0, 4) : '';
+}
+
+// ── grouping ─────────────────────────────────────────────────────────────────
+
+// Nothing shows until an operator has actually chosen a root in Settings
+// (§5.6/V-whatever this is now): the node itself runs no TMDB/thumbnail
+// work for this group before that either (daemon.py's
+// _enrich_new_video_entries), so falling back to "the whole index" here
+// would just show files nothing has enriched.
+function underVideoRoot(entry, videoRoot) {
+ if (!videoRoot) return false;
+ const p = entry.path || '';
+ return p === videoRoot || p.startsWith(videoRoot + '/');
+}
+
+function buildSeasons(episodes) {
+ const sorted = [...episodes].sort((a, b) => (a.season - b.season) || (a.episode - b.episode));
+ const bySeason = new Map();
+ for (const ep of sorted) {
+ if (!bySeason.has(ep.season)) bySeason.set(ep.season, []);
+ bySeason.get(ep.season).push(ep);
+ }
+ return [...bySeason.entries()].sort((a, b) => a[0] - b[0])
+ .map(([season, seasonEpisodes]) => ({ season, episodes: seasonEpisodes }));
+}
+
+function groupVideoEntries(entries, videoRoot) {
+ const movies = [];
+ const showsByTitle = new Map();
+ for (const e of entries) {
+ if (e.type !== 'video') continue;
+ if (!underVideoRoot(e, videoRoot)) continue;
+ if (e.season != null && e.episode != null) {
+ const title = e.display_title || e.name;
+ if (!showsByTitle.has(title)) showsByTitle.set(title, { title, episodes: [] });
+ showsByTitle.get(title).episodes.push(e);
+ } else {
+ movies.push(e);
+ }
+ }
+ movies.sort((a, b) => (a.display_title || a.name).localeCompare(b.display_title || b.name));
+ const shows = [...showsByTitle.values()].sort((a, b) => a.title.localeCompare(b.title));
+ for (const show of shows) {
+ show.episodes.sort((a, b) => (a.season - b.season) || (a.episode - b.episode));
+ show.seasons = buildSeasons(show.episodes);
+ }
+ return { movies, shows };
+}
+
+// ── lazy-mount tile (apps.md §5 virtualization) ─────────────────────────────
+
+const LAZY_TILE_MARGIN = 300;
+
+function LazyTile({ cls = 'video-tile-slot', children }) {
+ const ref = useRef(null);
+ const [visible, setVisible] = useState(false);
+
+ useEffect(() => {
+ if (visible || !ref.current) return;
+ // A tile that is already on screen (or within the margin) the moment
+ // it mounts — the overwhelmingly common case, since a merge (§V6) or a
+ // tab revisit mounts tiles into a grid that was already scrolled to
+ // wherever the operator was looking — doesn't need to wait for
+ // IntersectionObserver's own first callback at all: that first
+ // delivery is only a *microtask/next-paint* guarantee, not an
+ // immediate one, and was observed live taking upwards of 30 seconds
+ // (matching the browser's own periodic intersection-computation
+ // cadence exactly) — which read as "the poster never finishes
+ // loading" even though every fetch behind it had already completed.
+ // Checked synchronously so a genuinely below-the-fold tile still only
+ // mounts once actually scrolled near.
+ const rect = ref.current.getBoundingClientRect();
+ const viewportHeight = window.innerHeight || document.documentElement.clientHeight;
+ const alreadyNear = rect.bottom >= -LAZY_TILE_MARGIN && rect.top <= viewportHeight + LAZY_TILE_MARGIN;
+ if (alreadyNear) { setVisible(true); return; }
+ const obs = new IntersectionObserver((obsEntries) => {
+ if (obsEntries.some((oe) => oe.isIntersecting)) { setVisible(true); obs.disconnect(); }
+ }, { rootMargin: `${LAZY_TILE_MARGIN}px` });
+ obs.observe(ref.current);
+ return () => obs.disconnect();
+ }, [visible]);
+
+ return html`<div ref=${ref} class=${cls}>${visible ? children : null}</div>`;
+}
+
+// ── thumbnail/poster image, decrypted via the chunk path ────────────────────
+//
+// Cached per session by thumb_hash (a content hash, so it never goes stale):
+// the same poster reused across a season's worth of episode tiles is
+// decrypted once, not once per tile. Blob URLs are not revoked — the number
+// of distinct thumbnails one session ever visits is bounded by the library
+// size, and reference-counting revocation across many tile mounts/unmounts
+// would cost real complexity for a benefit that only matters in a very long
+// session.
+const _thumbBlobCache = new Map();
+
+function MediaThumb({
+ thumbHash, transportRef, gekRef, alt, cls = 'video-thumb', onReady,
+}) {
+ const [blobUrl, setBlobUrl] = useState(() => _thumbBlobCache.get(thumbHash) || null);
+
+ // Re-checks the cache by the CURRENT thumbHash on every change rather than
+ // trusting the `blobUrl` state variable — a PosterCard swaps this same
+ // component instance's thumbHash prop from the raw fallback frame to the
+ // TMDB poster once metadata resolves, and gating on "is blobUrl already
+ // set" (from the *previous* hash) would leave the fallback frame on
+ // screen forever instead of ever fetching the poster.
+ //
+ // `onReady` fires exactly once per settled thumbHash — cache hit, fetch
+ // success, fetch failure, or no hash at all — so a caller that hides this
+ // component until its image has actually arrived (PosterCard) always
+ // gets unstuck, even when there's nothing to show.
+ useEffect(() => {
+ const cached = _thumbBlobCache.get(thumbHash);
+ if (cached) { setBlobUrl(cached); if (onReady) onReady(cached); return; }
+ setBlobUrl(null);
+ if (!thumbHash) { if (onReady) onReady(null); return; }
+ let cancelled = false;
+ (async () => {
+ const transport = transportRef.current;
+ if (!transport || !transport.connected) { if (onReady) onReady(null); return; }
+ try {
+ const chunks = await pipelinedDownload(transport, gekRef.current, thumbHash, 1);
+ if (cancelled) return;
+ const url = URL.createObjectURL(new Blob(chunks, { type: 'image/jpeg' }));
+ _thumbBlobCache.set(thumbHash, url);
+ setBlobUrl(url);
+ if (onReady) onReady(url);
+ } catch {
+ /* leave the placeholder — a transient fetch failure isn't an error state */
+ if (!cancelled && onReady) onReady(null);
+ }
+ })();
+ return () => { cancelled = true; };
+ }, [thumbHash]);
+
+ if (!blobUrl) return html`<div class="${cls} video-thumb-empty"><${Icon} name="video" /></div>`;
+ return html`<img class=${cls} src=${blobUrl} alt=${alt || ''} loading="lazy" />`;
+}
+
+// ── TMDB metadata, fetched once per visible tile ────────────────────────────
+
+function useMediaMeta(transportRef, path, active) {
+ const [meta, setMeta] = useState(null);
+ useEffect(() => {
+ if (!active || !path) return;
+ let cancelled = false;
+ (async () => {
+ const transport = transportRef.current;
+ if (!transport || !transport.connected) return;
+ try {
+ const resp = await transport.fetchMediaMeta(path);
+ if (!cancelled) setMeta(resp);
+ } catch { if (!cancelled) setMeta({ confidence: 0 }); }
+ })();
+ return () => { cancelled = true; };
+ }, [path, active]);
+ return meta;
+}
+
+// ── Mode A: poster grid ──────────────────────────────────────────────────────
+
+function PosterCard({ title, subtitle, repEntry, transportRef, gekRef, onOpen, groupKey, onMetaResolved }) {
+ const meta = useMediaMeta(transportRef, repEntry.path, true);
+ const confident = meta && meta.confidence && meta.tmdb_id;
+ const metaReady = meta !== null;
+
+ // Reports this tile's own resolution upward so PosterGrid can notice two
+ // differently-parsed folders (a show split across release groups that
+ // named its seasons inconsistently, §3.4/V6) resolving to the same TMDB
+ // id, and merge them into one card — never required for the generic
+ // per-folder display to work, only an enhancement once it's known safe.
+ useEffect(() => {
+ if (meta && onMetaResolved) onMetaResolved(groupKey, meta);
+ }, [meta, groupKey]);
+
+ const posterHash = confident && meta.poster_thumb_hash ? meta.poster_thumb_hash : repEntry.thumb_hash;
+
+ // Which hash MediaThumb has actually confirmed ready — compared against
+ // the *current* posterHash below, rather than a separate boolean reset
+ // by its own effect on posterHash change. That second-writer shape had a
+ // real bug: the instant metaReady flips true, posterHash jumps from the
+ // raw fallback frame to the resolved poster in the very same commit that
+ // mounts MediaThumb for it — and when that poster's bytes are already in
+ // MediaThumb's session cache (a revisit within the same tab, no reload),
+ // its onReady fires synchronously from that same mount effect. Effects
+ // run children-first, so the "reset on posterHash change" effect fired
+ // *after* it, in the same commit, unconditionally setting the flag back
+ // to false — with no later event left to ever set it true again. The
+ // card stayed a spinner forever despite the image already being loaded.
+ // Deriving readiness from a direct comparison has no such ordering to
+ // get wrong: whichever of the two fires, in whichever order, the render
+ // that follows sees the same answer.
+ const [readyHash, setReadyHash] = useState(null);
+ const handleImageReady = useCallback(() => setReadyHash(posterHash), [posterHash]);
+ const imageReady = readyHash === posterHash;
+
+ // Nothing is shown until BOTH the TMDB lookup and the final chosen image
+ // (the poster once matched, the file's own frame otherwise) have
+ // actually settled. Revealing the raw per-file frame first and swapping
+ // it for the poster a moment later — or showing a card that a moment
+ // later gets absorbed into a neighbour once §V6's merge kicks in — was
+ // exactly the flash an operator flagged as hard on the eyes. A slow
+ // lookup (a big, freshly-scanned library) just means the spinner stays a
+ // little longer, never a partially-drawn card.
+ const ready = metaReady && imageReady;
+
+ return html`
+ <div class="video-card" onClick=${onOpen}>
+ ${!ready && html`
+ <div class="video-poster video-poster-loading"><span class="spinner"></span></div>
+ `}
+ <div class="video-poster-slot" style=${ready ? '' : 'display:none'}>
+ ${metaReady && html`
+ <${MediaThumb} thumbHash=${posterHash} alt=${title}
+ cls="video-poster" transportRef=${transportRef} gekRef=${gekRef}
+ onReady=${handleImageReady} />
+ `}
+ </div>
+ ${ready && html`
+ <div class="video-card-info">
+ <div class="video-card-title">${(confident && meta.title) || title}</div>
+ <div class="video-card-sub">
+ ${confident && meta.release_date ? yearOf(meta.release_date) : ''}
+ ${confident && meta.first_air_date ? yearOf(meta.first_air_date) : ''}
+ ${subtitle ? ` · ${subtitle}` : ''}
+ </div>
+ </div>
+ `}
+ </div>
+ `;
+}
+
+function VideoDetailModal({ title, meta, repEntry, show, transportRef, gekRef, onClose, onPlay }) {
+ const confident = meta && meta.confidence && meta.tmdb_id;
+ return html`
+ <div class="video-overlay" onClick=${(e) => {
+ if (e.target.classList.contains('video-overlay')) onClose();
+ }}>
+ <div class="video-detail">
+ <div class="video-top-bar">
+ <span class="video-title">${(confident && meta.title) || title}</span>
+ <button class="video-close" onClick=${onClose} title=${t('video.close')}>
+ <${Icon} name="close" /></button>
+ </div>
+ <div class="video-detail-body">
+ ${confident && html`
+ <p class="video-detail-overview">${meta.overview}</p>
+ <p class="video-detail-facts">
+ ${meta.vote_average ? `★ ${meta.vote_average.toFixed(1)}` : ''}
+ ${meta.genres && meta.genres.length ? ` · ${meta.genres.join(', ')}` : ''}
+ ${meta.director ? ` · ${t('video.director')}: ${meta.director}` : ''}
+ </p>
+ ${meta.cast && meta.cast.length > 0 && html`
+ <p class="video-detail-cast">
+ ${meta.cast.slice(0, 6).map((c) => c.name).join(', ')}
+ </p>
+ `}
+ `}
+ ${!show && html`
+ <button class="admin-btn" onClick=${() => onPlay(repEntry)}>
+ <${Icon} name="play" /> ${t('group.play')}
+ ${repEntry.duration ? ` (${formatDuration(repEntry.duration)})` : ''}
+ </button>
+ `}
+ ${show && html`
+ <div class="video-season-list">
+ ${show.seasons.map((s) => html`
+ <div class="video-season" key=${s.season}>
+ <div class="video-season-header">
+ ${s.season === 0 ? t('video.specials') : t('video.season_n', { n: s.season })}
+ </div>
+ ${s.episodes.map((ep) => html`
+ <button class="video-episode-row" key=${ep.id} onClick=${() => onPlay(ep)}>
+ <${LazyTile} cls="video-episode-thumb-slot">
+ <${MediaThumb} thumbHash=${ep.thumb_hash} alt=${ep.display_title || ep.name}
+ cls="video-episode-thumb" transportRef=${transportRef} gekRef=${gekRef} />
+ </${LazyTile}>
+ <span class="video-episode-label">
+ S${ep.season}E${String(ep.episode).padStart(2, '0')}
+ ${' · '}${ep.display_title || ep.name}
+ </span>
+ <span class="video-episode-meta">
+ ${formatDuration(ep.duration)} ${formatResolution(ep.width, ep.height)}
+ </span>
+ </button>
+ `)}
+ </div>
+ `)}
+ </div>
+ `}
+ </div>
+ </div>
+ </div>
+ `;
+}
+
+function PosterGrid({ movies, shows, transportRef, gekRef, onPreview, tmdbEnabled }) {
+ const [detail, setDetail] = useState(null); // { title, repEntry, show? }
+ // raw (per-folder-parsed-title) show title -> its own resolved media_meta_resp.
+ const [metaByGroup, setMetaByGroup] = useState({});
+
+ const handleMetaResolved = useCallback((groupKey, meta) => {
+ setMetaByGroup((prev) => (prev[groupKey] === meta ? prev : { ...prev, [groupKey]: meta }));
+ }, []);
+
+ // Two raw groups (grouped by parsed display_title, §4.1) resolving to the
+ // same confident TMDB id are almost certainly one show whose seasons
+ // were released under differently-named folders — confirmed live: one
+ // operator's show had its two seasons parsed as "Ovni" and "OVNIs" by
+ // two different release groups, showing as two identical-looking cards
+ // once both matched the same real show (§3.4/V6). Merged here once both
+ // are actually known — never required for the fallback to work: a group
+ // with no confident match yet, or ever, still shows on its own, exactly
+ // the generic per-folder display needs.
+ const mergedShows = useMemo(() => {
+ const byTmdbId = new Map();
+ const standalone = [];
+ for (const s of shows) {
+ const meta = metaByGroup[s.title];
+ const tmdbId = meta && meta.confidence && meta.tmdb_id;
+ if (tmdbId) {
+ if (!byTmdbId.has(tmdbId)) byTmdbId.set(tmdbId, []);
+ byTmdbId.get(tmdbId).push(s);
+ } else {
+ standalone.push([s]);
+ }
+ }
+ return [...byTmdbId.values(), ...standalone].map((groups) => {
+ const episodes = groups.flatMap((g) => g.episodes);
+ return {
+ // groups[0].title, not a joined string of every constituent's
+ // title: a fresh key here would make this a brand-new PosterCard
+ // (and LazyTile) the instant a second raw group merges into an
+ // already-visible one — throwing away its already-fired
+ // IntersectionObserver and already-resolved metadata/poster for no
+ // reason, and reintroducing exactly the flash the "ready" gating
+ // above exists to prevent. groups[0].title is already unique
+ // (raw titles are, via groupVideoEntries' showsByTitle) and, for
+ // the overwhelmingly common unmerged case, is the same key the
+ // card already had — so nothing about this changes when no merge
+ // ever happens.
+ key: groups[0].title,
+ title: groups[0].title,
+ episodes,
+ seasons: buildSeasons(episodes),
+ };
+ });
+ }, [shows, metaByGroup]);
+
+ const openDetail = (title, repEntry, show) => setDetail({ title, repEntry, show });
+ const detailMeta = useMediaMeta(transportRef, detail ? detail.repEntry.path : null, !!detail);
+
+ return html`
+ <div class="video-grid">
+ ${movies.map((e) => html`
+ <${LazyTile} key=${e.id}>
+ <${PosterCard} title=${e.display_title || e.name}
+ subtitle=${formatDuration(e.duration)} repEntry=${e}
+ groupKey=${`movie:${e.id}`}
+ transportRef=${transportRef} gekRef=${gekRef}
+ onOpen=${() => (tmdbEnabled
+ // With TMDB off there is nothing the detail modal would show
+ // for a movie (no overview, no season list to pick from,
+ // unlike a show) — so it would just be an extra click in
+ // front of a Play button. Straight to the player instead.
+ ? openDetail(e.display_title || e.name, e, null)
+ : onPreview(e))} />
+ </${LazyTile}>
+ `)}
+ ${mergedShows.map((s) => {
+ // Prefer an episode that actually has a thumbnail over blindly
+ // episodes[0]: if that specific file's enrichment hasn't produced
+ // one yet (or failed), the card showed an empty placeholder even
+ // though sibling episodes — visible right there in Flat list —
+ // have one. The TMDB path (or its absence) is the same regardless
+ // of which episode's own file supplies the fallback frame.
+ const repEntry = s.episodes.find((e) => e.thumb_hash) || s.episodes[0];
+ // Known up front from the already-parsed index fields (§3.4), no
+ // TMDB needed: a card covering exactly one season says so before
+ // a click, rather than an anonymous episode count — the generic
+ // "N episodes" stays for a merged, multi-season, or special-only
+ // card, where a single number would misrepresent it.
+ const singleSeason = s.seasons.length === 1 ? s.seasons[0].season : null;
+ const subtitle = singleSeason != null
+ ? (singleSeason === 0 ? t('video.specials') : t('video.season_n', { n: singleSeason }))
+ : t('video.n_episodes', { n: s.episodes.length });
+ return html`
+ <${LazyTile} key=${s.key}>
+ <${PosterCard} title=${s.title}
+ subtitle=${subtitle}
+ repEntry=${repEntry}
+ groupKey=${s.title}
+ onMetaResolved=${handleMetaResolved}
+ transportRef=${transportRef} gekRef=${gekRef}
+ onOpen=${() => openDetail(s.title, repEntry, s)} />
+ </${LazyTile}>
+ `; })}
+ </div>
+ ${detail && html`
+ <${VideoDetailModal} title=${detail.title} meta=${detailMeta}
+ repEntry=${detail.repEntry} show=${detail.show}
+ transportRef=${transportRef} gekRef=${gekRef}
+ onClose=${() => setDetail(null)}
+ onPlay=${(entry) => { setDetail(null); onPreview(entry); }} />
+ `}
+ `;
+}
+
+// ── Mode B: flat, thumbnail-only, no TMDB ────────────────────────────────────
+
+// Within a season group, every episode's own display_title is usually
+// just the show name again (guessit rarely finds a per-episode subtitle
+// for this kind of release) — repeating "OVNI" twelve times in a row said
+// nothing an episode number wouldn't say better. Shown only when this row
+// is actually inside a season group (`seasonContext` set); a real,
+// distinct per-episode title (a show that *does* carry one) still wins
+// over the generic "Episode N" label.
+function FlatMovieRow({ entry, transportRef, gekRef, onPreview, seasonContext }) {
+ const isEpisode = seasonContext && entry.season != null && entry.episode != null;
+ const hasOwnTitle = entry.display_title && entry.display_title !== seasonContext;
+ const label = isEpisode
+ ? (hasOwnTitle ? `${t('video.episode_n', { n: entry.episode })} · ${entry.display_title}`
+ : t('video.episode_n', { n: entry.episode }))
+ : (entry.display_title || entry.name);
+
+ return html`
+ <div class="video-flat-row" onClick=${() => onPreview(entry)}>
+ <${LazyTile} cls="video-flat-thumb-slot">
+ <${MediaThumb} thumbHash=${entry.thumb_hash} alt=${entry.display_title || entry.name}
+ cls="video-flat-thumb" transportRef=${transportRef} gekRef=${gekRef} />
+ </${LazyTile}>
+ <div class="video-flat-info">
+ <div class="video-flat-title">${label}</div>
+ <div class="video-flat-sub">
+ ${formatDuration(entry.duration)} ${formatResolution(entry.width, entry.height)}
+ ${' · '}${formatSize(entry.size)}
+ </div>
+ </div>
+ </div>
+ `;
+}
+
+function FlatShowFolder({ show, transportRef, gekRef, onPreview }) {
+ const [open, setOpen] = useState(false);
+ return html`
+ <div class="video-flat-folder">
+ <div class="video-flat-row" onClick=${() => setOpen((v) => !v)}>
+ <div class="video-flat-thumb video-thumb-empty"><${Icon} name="folder" /></div>
+ <div class="video-flat-info">
+ <div class="video-flat-title">${show.title}</div>
+ <div class="video-flat-sub">${t('video.n_episodes', { n: show.episodes.length })}</div>
+ </div>
+ <${Icon} name="chevron" cls=${open ? 'video-flat-chevron open' : 'video-flat-chevron'} />
+ </div>
+ ${open && show.seasons.map((s) => html`
+ <div class="video-flat-season" key=${s.season}>
+ <div class="video-season-header">
+ ${s.season === 0 ? t('video.specials') : t('video.season_n', { n: s.season })}
+ </div>
+ ${s.episodes.map((ep) => html`
+ <${FlatMovieRow} key=${ep.id} entry=${ep} seasonContext=${show.title}
+ transportRef=${transportRef} gekRef=${gekRef} onPreview=${onPreview} />
+ `)}
+ </div>
+ `)}
+ </div>
+ `;
+}
+
+function FlatList({ movies, shows, transportRef, gekRef, onPreview }) {
+ const items = [
+ ...movies.map((e) => ({ key: e.display_title || e.name, kind: 'movie', entry: e })),
+ ...shows.map((s) => ({ key: s.title, kind: 'show', show: s })),
+ ].sort((a, b) => a.key.localeCompare(b.key));
+
+ return html`
+ <div class="video-flat-list">
+ ${items.map((it) => it.kind === 'movie'
+ ? html`<${FlatMovieRow} key=${it.entry.id} entry=${it.entry}
+ transportRef=${transportRef} gekRef=${gekRef} onPreview=${onPreview} />`
+ : html`<${FlatShowFolder} key=${it.show.title} show=${it.show}
+ transportRef=${transportRef} gekRef=${gekRef} onPreview=${onPreview} />`)}
+ </div>
+ `;
+}
+
+// ── shell ────────────────────────────────────────────────────────────────────
+
+function VideoApp({
+ groupId, transportRef, gekRef, status, entries, onPreview, videoRoot, tmdbConfig,
+}) {
+ const [mode, setMode] = useState(loadViewMode);
+ const [filter, setFilter] = useState('');
+ const tmdbEnabled = tmdbConfig ? tmdbConfig.enabled : true;
+
+ useEffect(() => { setMode(loadViewMode()); }, [groupId]);
+ useEffect(() => { setFilter(''); }, [groupId]);
+
+ const setModeAndSave = (m) => { setMode(m); saveViewMode(m); };
+
+ const { movies, shows } = useMemo(
+ () => groupVideoEntries(entries, videoRoot), [entries, videoRoot]);
+
+ const needle = filter.trim().toLowerCase();
+ const filteredMovies = useMemo(() => (!needle ? movies : movies.filter(
+ (e) => (e.display_title || e.name).toLowerCase().includes(needle))), [movies, needle]);
+ const filteredShows = useMemo(() => (!needle ? shows : shows.filter(
+ (s) => s.title.toLowerCase().includes(needle))), [shows, needle]);
+
+ return html`
+ ${(status === 'discovering' || status === 'connecting' || status === 'fetching') && html`
+ <p class="page-message"><span class="spinner"></span>${' '}${t('status.connecting_short')}</p>
+ `}
+ ${status === 'offline' && html`
+ <p class="page-message">${t('group.offline_title')} ${t('group.offline_hint')}</p>
+ `}
+ ${status === 'connected' && !videoRoot && html`
+ <p class="page-message">${t('video.no_root_configured')}</p>
+ `}
+ ${status === 'connected' && videoRoot && html`
+ <div class="video-toolbar">
+ <button class="tb-btn ${mode === 'poster' ? 'active' : ''}"
+ onClick=${() => setModeAndSave('poster')}>
+ ${t('video.mode_poster')}
+ </button>
+ <button class="tb-btn ${mode === 'flat' ? 'active' : ''}"
+ onClick=${() => setModeAndSave('flat')}>
+ ${t('video.mode_flat')}
+ </button>
+ <div class="tb-search">
+ <${Icon} name="search" />
+ <input type="text" placeholder="${t('group.filter')}"
+ value=${filter} onInput=${(e) => setFilter(e.target.value)} />
+ </div>
+ </div>
+ ${filteredMovies.length === 0 && filteredShows.length === 0 && html`
+ <p class="page-message">${needle ? t('group.empty_filter') : t('video.empty')}</p>
+ `}
+ ${mode === 'poster'
+ ? html`<${PosterGrid} movies=${filteredMovies} shows=${filteredShows}
+ transportRef=${transportRef} gekRef=${gekRef} onPreview=${onPreview}
+ tmdbEnabled=${tmdbEnabled} />`
+ : html`<${FlatList} movies=${filteredMovies} shows=${filteredShows}
+ transportRef=${transportRef} gekRef=${gekRef} onPreview=${onPreview} />`}
+ `}
+ `;
+}
+
+export { VideoApp };