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`
${visible ? children : null}
`; } // ── 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, emptyIcon = 'video', }) { const [blobUrl, setBlobUrl] = useState(() => _thumbBlobCache.get(thumbHash) || null); const [retryToken, setRetryToken] = useState(0); useEffect(() => { const listener = () => setRetryToken((n) => n + 1); _thumbRetryListeners.add(listener); return () => _thumbRetryListeners.delete(listener); }, []); // 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, retryToken]); if (!blobUrl) return html`
<${Icon} name=${emptyIcon} />
`; return html`${alt`; } // ── TMDB metadata, fetched once per visible tile ──────────────────────────── // An operator correcting a wrong match (TmdbSearchOverlay below) changes // what `media_meta_req` returns for a file every already-mounted tile/modal // already has cached in its own useMediaMeta state — nothing would ever // refetch otherwise, since fileId/active don't change. Bumping this and // telling every subscribed hook to redo its fetch is simpler than trying to // know which files 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(); } const _thumbRetryListeners = new Set(); function bumpThumbGeneration() { for (const fn of _thumbRetryListeners) fn(); } function useMediaMeta(transportRef, fileId, 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 fileId/active changed at all. const listener = () => { setMeta(null); setRefetchToken((n) => n + 1); }; _mediaMetaListeners.add(listener); return () => _mediaMetaListeners.delete(listener); }, []); useEffect(() => { if (!active || !fileId) return; let cancelled = false; (async () => { const transport = transportRef.current; if (!transport || !transport.connected) return; try { const resp = await transport.fetchMediaMeta(fileId); if (!cancelled) setMeta(resp); } catch { if (!cancelled) setMeta({ confidence: 0 }); } })(); return () => { cancelled = true; }; }, [fileId, 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 tRef = repEntry._tRef || transportRef; const gRef = repEntry._gRef || gekRef; const meta = useMediaMeta(tRef, repEntry.id, true); const confident = Boolean(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`
${!ready && html`
`}
${metaReady && html` <${MediaThumb} thumbHash=${posterHash} alt=${title} cls="video-poster" transportRef=${tRef} gekRef=${gRef} onReady=${handleImageReady} /> `}
${ready && html`
${(confident && meta.title) || title} ${!confident && html`?`}
${confident && meta.release_date ? yearOf(meta.release_date) : ''} ${confident && meta.first_air_date ? yearOf(meta.first_air_date) : ''} ${subtitle ? ` · ${subtitle}` : ''}
${repEntry.groupName && html`
${repEntry.groupName}
`}
`}
`; } // ── season tab bar (docs/mediacenter.md §5.4's fix for a mis-scoped overview) ─ function SeasonTabs({ seasons, selected, onSelect }) { return html`
${seasons.map((s) => html` `)}
`; } // ── 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, fileId, 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(fileId, tmdbId, mediaType, signFn); bumpMediaMetaGeneration(); onApplied(); } catch (err) { setError(err.message); setApplying(false); } }, [applying, fileId, mediaType, transportRef, onApplied]); return html`
{ if (e.target.classList.contains('video-search-overlay')) onClose(); }}>
${t('video.search_title')}
setQuery(e.target.value)} />

${mediaType === 'tv' ? t('video.search_apply_hint') : t('video.search_apply_hint_movie')}

${error && html`

${error}

`} ${results && results.length === 0 && !searching && html`

${t('video.search_no_results')}

`} ${results && results.length > 0 && html`
${results.map((r) => html` `)}
`}
`; } function VideoDetailModal({ title, meta, repEntry, show, files, transportRef, gekRef, onClose, onPlay, isNodeAdmin, }) { const confident = Boolean(meta && meta.confidence && meta.tmdb_id); const [searching, setSearching] = useState(false); const [rematching, setRematching] = useState(false); const mediaType = show ? 'tv' : 'movie'; // §10.1/V13: drop this file's cached match on the node and let it // re-resolve with the current matcher — the one-click alternative to the // full search-and-pick flow above. const doRematch = useCallback(async () => { if (rematching) return; setRematching(true); try { await transportRef.current.rematchTmdbMatch(repEntry.id, buildSignFn(transportRef)); bumpMediaMetaGeneration(); } catch { /* leave the current match in place */ } setRematching(false); }, [rematching, repEntry, transportRef]); // 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`
{ if (e.target.classList.contains('video-overlay')) onClose(); }}>
${(confident && meta.title) || title}
${meta !== null && !confident && html`

${t('video.no_match')}

`}

${t('video.source_file', { name: `${repEntry.path}/${repEntry.name}` })} ${confident && html` · TMDB #${meta.tmdb_id}`}

${confident && html`

${(seasonConfident && seasonMeta.overview) || meta.overview}

${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)}` : ''}

${meta.cast && meta.cast.length > 0 && html`

${meta.cast.slice(0, 6).map((c) => c.name).join(', ')}

`} `} ${isNodeAdmin && html`
`} ${showMultiSeason && html` <${SeasonTabs} seasons=${show.seasons} selected=${selectedSeason} onSelect=${setSelectedSeason} /> `} ${!show && (!files || files.length <= 1) && html` `} ${!show && files && files.length > 1 && html`
${[...files] .sort((a, b) => (b.height || 0) - (a.height || 0) || (b.size || 0) - (a.size || 0)) .map((f) => html` `)}
`} ${show && html`
${(showMultiSeason ? show.seasons.filter((s) => s.season === selectedSeason) : show.seasons) .map((s) => html`
${!showMultiSeason && html`
${s.season === 0 ? t('video.specials') : t('video.season_n', { n: s.season })}
`} ${s.episodes.map((ep) => html` `)}
`)}
`}
${searching && html` <${TmdbSearchOverlay} initialQuery=${(confident && meta.title) || title} mediaType=${mediaType} fileId=${repEntry.id} 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]); // The movie counterpart of mergedShows (§10.1/V12): two files that TMDB // resolves to the same id — the usual case being one film present at two // resolutions, or the same rip in two folders — collapse to one card // whose detail modal lists the versions. Same keying discipline as // mergedShows: the first constituent's id is stable and unique, so an // unmerged movie keeps the exact key its card already had and a merge // updates props instead of remounting (which would throw away an // already-resolved poster). const mergedMovies = useMemo(() => { const byTmdbId = new Map(); const standalone = []; for (const e of movies) { const meta = metaByGroup[`movie:${e.id}`]; const tmdbId = meta && meta.confidence && meta.tmdb_id; if (tmdbId) { if (!byTmdbId.has(tmdbId)) byTmdbId.set(tmdbId, []); byTmdbId.get(tmdbId).push(e); } else { standalone.push([e]); } } return [...byTmdbId.values(), ...standalone].map((files) => ({ key: `movie:${files[0].id}`, title: files[0].display_title || files[0].name, files, })); }, [movies, metaByGroup]); const openDetail = (title, repEntry, show, files) => setDetail({ title, repEntry, show, files }); const detailTRef = detail && detail.repEntry._tRef ? detail.repEntry._tRef : transportRef; const detailGRef = detail && detail.repEntry._gRef ? detail.repEntry._gRef : gekRef; const detailMeta = useMediaMeta(detailTRef, detail ? detail.repEntry.id : null, !!detail); return html`
${mergedMovies.map((m) => { const repEntry = m.files.find((e) => e.thumb_hash) || m.files[0]; const subtitle = m.files.length > 1 ? t('video.versions', { n: m.files.length }) : formatDuration(repEntry.duration); // With TMDB off and only one file there is nothing the detail modal // would add for a movie (no overview, no season list) — straight to // the player. More than one file always needs the version picker. const straightToPlayer = !tmdbEnabled && m.files.length === 1; return html` <${LazyTile} key=${m.key}> <${PosterCard} title=${m.title} subtitle=${subtitle} repEntry=${repEntry} groupKey=${`movie:${m.files[0].id}`} onMetaResolved=${handleMetaResolved} transportRef=${transportRef} gekRef=${gekRef} onOpen=${() => (straightToPlayer ? onPreview(repEntry) : openDetail(m.title, repEntry, null, m.files))} /> `; })} ${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)} /> `; })}
${detail && html` <${VideoDetailModal} title=${detail.title} meta=${detailMeta} repEntry=${detail.repEntry} show=${detail.show} files=${detail.files} transportRef=${detailTRef} gekRef=${detailGRef} 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 tRef = entry._tRef || transportRef; const gRef = entry._gRef || gekRef; 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`
onPreview(entry)}> <${LazyTile} cls="video-flat-thumb-slot"> <${MediaThumb} thumbHash=${entry.thumb_hash} alt=${entry.display_title || entry.name} cls="video-flat-thumb" transportRef=${tRef} gekRef=${gRef} />
${label}
${formatDuration(entry.duration)} ${formatResolution(entry.width, entry.height)} ${' · '}${formatSize(entry.size)}
${entry.groupName && html` e.stopPropagation()}>${entry.groupName} `}
`; } function FlatShowFolder({ show, transportRef, gekRef, onPreview }) { const [open, setOpen] = useState(false); return html`
setOpen((v) => !v)}>
<${Icon} name="folder" />
${show.title}
${t('video.n_episodes', { n: show.episodes.length })}
<${Icon} name="chevron" cls=${open ? 'video-flat-chevron open' : 'video-flat-chevron'} />
${open && show.seasons.map((s) => html`
${s.season === 0 ? t('video.specials') : t('video.season_n', { n: s.season })}
${s.episodes.map((ep) => html` <${FlatMovieRow} key=${ep.id} entry=${ep} seasonContext=${show.title} transportRef=${transportRef} gekRef=${gekRef} onPreview=${onPreview} /> `)}
`)}
`; } 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`
${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} />`)}
`; } // ── shell ──────────────────────────────────────────────────────────────────── function VideoApp({ groupId, transportRef, gekRef, status, entries, onPreview, videoRoot, tmdbConfig, isNodeAdmin, hideFilter, }) { const [mode, setMode] = useState(loadViewMode); const [filter, setFilter] = useState(''); // Which of movies/shows to show at all — independent of the text filter // below, and applied first: a title match within a type nobody asked to // see is still not what "Movies only" means. const [typeFilter, setTypeFilter] = useState('all'); const tmdbEnabled = tmdbConfig ? tmdbConfig.enabled : true; useEffect(() => { setMode(loadViewMode()); }, [groupId]); useEffect(() => { setFilter(''); setTypeFilter('all'); }, [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(() => (typeFilter === 'series' ? [] : !needle ? movies : movies.filter( (e) => (e.display_title || e.name).toLowerCase().includes(needle))), [movies, needle, typeFilter]); const filteredShows = useMemo(() => (typeFilter === 'movies' ? [] : !needle ? shows : shows.filter( (s) => s.title.toLowerCase().includes(needle))), [shows, needle, typeFilter]); return html` ${(status === 'discovering' || status === 'connecting' || status === 'fetching') && html`

${' '}${t('status.connecting_short')}

`} ${status === 'offline' && html`

${t('group.offline_title')} ${t('group.offline_hint')}

`} ${status === 'connected' && !videoRoot && html`

${t('video.no_root_configured')}

`} ${status === 'connected' && videoRoot && html`
${!hideFilter && html``}
${filteredMovies.length === 0 && filteredShows.length === 0 && html`

${needle ? t('group.empty_filter') : t('video.empty')}

`} ${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} />`} `} `; } // MediaThumb and LazyTile are also used by music-app.js (docs/musicbay.md // §7.1): the same "decrypt a thumb_hash via the chunk path into a cached // blob" and "mount only once actually scrolled near" mechanisms apply to a // track's cover art unchanged, so Music imports them here rather than // re-implementing (apps.md §4's checklist). export { VideoApp, MediaThumb, LazyTile, groupVideoEntries, bumpMediaMetaGeneration, bumpThumbGeneration };