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';
import { SourceTag } from './group-name.js';
import { usePager, Pager, pageSizeFrom } from './pager.js';
// ── Videos ───────────────────────────────────────────────────────────────────
//
// A poster-grid (TMDB-enriched) or flat (thumbnail-only) browser for a
// group's video files, per docs/MESHBAY_DESIGN.md §9.7. 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) — the virtualization requirement for a grid of
// many tiles. Thumbnails go through the same `file_req`/chunk path
// as a real file (docs/MESHBAY_DESIGN.md §6.5) 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, directories) {
const dirs = directories || [];
if (!dirs.length) return false;
const p = entry.path || '';
return dirs.some((d) => p === d || p.startsWith(d + '/'));
}
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 }));
}
// The season a show's detail modal opens on.
//
// Deliberately not the representative episode's. `repEntry` is picked for its
// *thumbnail* — `episodes.find((e) => e.thumb_hash)` in the poster grid, so
// the card has a fallback frame when TMDB has no poster — which makes its
// season an accident of which files the node has managed to thumbnail so far.
// Found live on a show whose early seasons had none: the modal opened on
// season 6. The two uses of "representative" were never the same thing, and
// only one of them is about what the reader is looking at.
//
// Specials first is not what anyone means by the beginning of a show, so
// season 0 wins only when it is all there is.
function defaultSeason(show) {
if (!show || !show.seasons.length) return null;
// The lowest number rather than the first entry: `buildSeasons` does sort
// ascending, but reading the answer off that ordering makes this quietly
// depend on a caller keeping it, and there is nothing to gain by that.
const numbers = show.seasons.map((s) => s.season);
const real = numbers.filter((n) => n !== 0);
return Math.min(...(real.length ? real : numbers));
}
function groupVideoEntries(entries, videoDirectories) {
const movies = [];
const showsByTitle = new Map();
for (const e of entries) {
if (e.type !== 'video') continue;
if (!underVideoRoot(e, videoDirectories)) 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 (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',
reloadKey,
}) {
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; };
// `reloadKey` — bumped by the caller when the transport behind `transportRef`
// was replaced (a search-page pool reconnect). Without it, a thumb whose
// first fetch hit a not-yet-connected transport and bailed above would stay
// an empty placeholder for good, since neither `thumbHash` nor `retryToken`
// changes when only the connection does.
}, [thumbHash, retryToken, reloadKey]);
if (!blobUrl) return html`
<${Icon} name=${emptyIcon} />
`;
return html``;
}
// ── 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();
}
// `enrichSig` — a value that changes when the entry's index-time
// enrichment lands (display_title fills in). The node answers confidence 0
// for a not-yet-enriched video (it can't tell it apart from a movie and
// would otherwise storm TMDB with its raw filename), so the client must
// refetch once the enriched fields arrive on an index delta — the fileId
// (a content hash) never changes, so nothing else would trigger it.
// `reloadKey` — an optional value the caller bumps when the transport behind
// `transportRef` was swapped out (search-page's connection pool evicted and
// later rebuilt this group's connection). The first fetch after a mount onto a
// not-yet-connected transport returns early below; nothing else here would ever
// re-run it, so the tile would sit on a spinner for good. Threading the group's
// `_connGen` in as `reloadKey` is what unsticks it.
function useMediaMeta(transportRef, fileId, active, enrichSig, reloadKey) {
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, enrichSig, reloadKey]);
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, onNeedConn,
sourceEntries, tmdbEnabled = true,
}) {
const tRef = repEntry._tRef || transportRef;
const gRef = repEntry._gRef || gekRef;
// Search view only: ask for this group's connection the moment the tile
// actually mounts (it is inside a LazyTile, so that means "scrolled near").
// Groups the pre-connect walk did not reach light up here instead of never.
// A no-op in a single-group page — there is no groupId and no onNeedConn.
useEffect(() => {
if (onNeedConn && repEntry.groupId) {
Promise.resolve(onNeedConn(repEntry.groupId)).catch(() => {});
}
}, [onNeedConn, repEntry.groupId]);
const meta = useMediaMeta(
tRef, repEntry.id, true, repEntry.display_title || '', repEntry._connGen);
const confident = Boolean(meta && meta.confidence && meta.tmdb_id);
const metaReady = meta !== null;
// "Unmatched" is a statement about a lookup that ran and came back with
// nothing — it tells an operator which titles are worth a "Fix match".
// With TMDB switched off for this group no lookup was ever made, so every
// card would carry the dashed outline and the "?" at once, flagging as a
// failure exactly the thing the operator chose. The poster grid then just
// shows what it does have — the file's own frame, presented plainly, the
// same as Flat does. Defaults to `true` so a caller that forgets the prop
// keeps the badge rather than silently losing it.
const flagUnmatched = tmdbEnabled && !confident;
// 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`
`;
}
// ── the synopsis, at a fixed height ──────────────────────────────────────────
//
// Clamped to three lines, the third cut short by the "read more" link floated
// into it — the float is what shortens that one line box, which is why the
// link is written *before* the text and the CSS reserves the two lines above
// it with a zero-width float of its own. There is no way to ask for this in
// `-webkit-line-clamp`, which only ever puts its ellipsis at the end of the
// last line.
//
// `reserve` pins the same three lines from below, so a multi-season show's
// synopsis is a constant height rather than a range: TMDB writes two lines
// for one season and twelve for the next, and the season menu underneath has
// to stay put across that.
//
// Whether three lines is all of it depends on how wide the modal is, so it is
// measured rather than counted, and re-measured on a resize.
function OverviewText({ text, reserve }) {
const [expanded, setExpanded] = useState(false);
const [overflows, setOverflows] = useState(false);
const ref = useRef(null);
// A different season is a different synopsis: re-collapse, or a long
// season read expanded leaves the next one's two lines expanded too.
useEffect(() => { setExpanded(false); }, [text]);
// Only the clamped element can be measured — expanded, scrollHeight and
// clientHeight agree and the "show less" link would remove itself.
useEffect(() => {
if (expanded) return undefined;
const measure = () => {
const el = ref.current;
if (el) setOverflows(el.scrollHeight > el.clientHeight + 1);
};
measure();
window.addEventListener('resize', measure);
return () => window.removeEventListener('resize', measure);
}, [text, expanded]);
const showToggle = !expanded && overflows;
const cls = ['video-detail-overview',
expanded ? '' : 'clamped',
showToggle ? 'has-more' : '',
reserve && !expanded ? 'reserved' : ''].filter(Boolean).join(' ');
return html`
${showToggle && html`
`}
${text}
${expanded && html`
`}
`;
}
// ── season picker (docs/MESHBAY_DESIGN.md §9.7's per-season text) ────────────
//
// A row of tabs, which this was, scrolls horizontally once a show has more
// seasons than fit — a scrollbar nobody finds, hiding the seasons that matter
// most on the narrowest screens. One trigger and a menu is a fixed height
// whether the show ran three seasons or twenty-five, which is also what keeps
// the episode list below from moving when the season changes.
// Where the panel goes, measured from the trigger. It cannot simply be an
// absolutely positioned child: the modal clips (`overflow: hidden`, for its
// rounded corners), and a show with a dozen seasons opens a panel taller than
// the room left under the picker on anything but a tall window — the last
// seasons then sit outside the modal where no scroll can reach them. Fixed to
// the viewport and measured, it also flips above the trigger when that is
// where the space is.
const SEASON_PANEL_MAX = 320;
const SEASON_PANEL_MIN = 120;
const SEASON_PANEL_GAP = 12;
function placeSeasonPanel(el) {
if (!el) return null;
const r = el.getBoundingClientRect();
const below = window.innerHeight - r.bottom - SEASON_PANEL_GAP;
const above = r.top - SEASON_PANEL_GAP;
const down = below >= Math.min(SEASON_PANEL_MAX, above);
const room = Math.max(SEASON_PANEL_MIN, Math.min(SEASON_PANEL_MAX, down ? below : above));
return {
left: `${r.left}px`,
width: `${r.width}px`,
top: down ? `${r.bottom + 4}px` : 'auto',
bottom: down ? 'auto' : `${window.innerHeight - r.top + 4}px`,
maxHeight: `${room}px`,
};
}
function SeasonMenu({ seasons, selected, selectedYear, onSelect }) {
const [open, setOpen] = useState(false);
const [pos, setPos] = useState(null);
const ref = useRef(null);
useEffect(() => {
if (!open) return undefined;
// Same shape as app.js's UserMenu: the trigger's own click is inside
// `ref`, so toggling still works with this listener on the document.
const close = (e) => {
if (ref.current && !ref.current.contains(e.target)) setOpen(false);
};
// Esc closes the menu and stops there — the detail modal behind it must
// not take the same keystroke as "close the modal".
const onKey = (e) => {
if (e.key !== 'Escape') return;
e.stopPropagation();
setOpen(false);
};
// Nothing scrolls under an open panel — the detail body does not scroll
// for a show (only its episode list does) and the page behind the overlay
// cannot — so a resize is the only thing that can invalidate the
// measurement taken when it opened.
const replace = () => setPos(placeSeasonPanel(ref.current));
document.addEventListener('click', close);
document.addEventListener('keydown', onKey, true);
window.addEventListener('resize', replace);
return () => {
document.removeEventListener('click', close);
document.removeEventListener('keydown', onKey, true);
window.removeEventListener('resize', replace);
};
}, [open]);
// Measured in the click, not in an effect after it: an effect would render
// the panel once at the wrong place and move it on the next frame.
const toggle = useCallback(() => {
setOpen((wasOpen) => {
if (!wasOpen) setPos(placeSeasonPanel(ref.current));
return !wasOpen;
});
}, []);
const label = (n) => (n === 0 ? t('video.specials') : t('video.season_n', { n }));
return html`
${open && pos && html`
${seasons.map((s) => html`
`)}
`}
`;
}
// ── operator: correct a wrong automatic TMDB match ──────────────────────────
// Same shape as group-settings.js's own signFn construction (setAppDirectories,
// 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();
}}>
`;
}
function VideoDetailModal({
title, meta, repEntry, show, transportRef, gekRef, onClose, onPlay, isNodeAdmin,
tmdbEnabled = true,
}) {
const confident = Boolean(meta && meta.confidence && meta.tmdb_id);
const [searching, setSearching] = useState(false);
const [rematching, setRematching] = useState(false);
const mediaType = show ? 'tv' : 'movie';
// 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 show is opened in this same modal instance —
// `show` changes identity, and selectedSeason must not silently keep
// pointing at whatever the previous show's season 4 was.
const [selectedSeason, setSelectedSeason] = useState(null);
useEffect(() => { setSelectedSeason(defaultSeason(show)); }, [show]);
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;
// One line, joined rather than concatenated with leading separators: a
// title TMDB has no rating for used to open the line with " · ".
const facts = !confident ? '' : [
meta.vote_average ? `★ ${meta.vote_average.toFixed(1)}` : null,
meta.genres && meta.genres.length ? meta.genres.join(', ') : null,
// The show's own year, not the selected season's — the season's air date
// rides along with the season picker, where it names what it belongs to.
yearOf(meta.first_air_date || meta.release_date) || null,
meta.director ? `${t('video.director')}: ${meta.director}` : null,
].filter(Boolean).join(' · ');
return html`
{
if (e.target.classList.contains('video-overlay')) onClose();
}}>
${(confident && meta.title) || title}
${/* Same reason as PosterCard's flagUnmatched: with TMDB off for
this group nothing was looked up, so "no confident match" is
not a fact about this title. A show still opens this modal
with TMDB off — it is where its season list lives — so the
notice has to be suppressed here too, not only on the card. */''}
${tmdbEnabled && meta !== null && !confident && html`
`}
`}
${/* Both of these act on a TMDB match, and with TMDB off for this
group there is none to act on: "Fix match" opens a search the
node answers with an empty result list
(docs/MESHBAY_DESIGN.md §9.7's silent degradation), and
"Rematch" drops a cached match that was never made. Two
buttons that cannot do anything, offered to the one person
who already knows why. */''}
${isNodeAdmin && tmdbEnabled && html`
${searching && html`
<${TmdbSearchOverlay} initialQuery=${(confident && meta.title) || title} mediaType=${mediaType}
fileId=${repEntry.id} transportRef=${transportRef} gekRef=${gekRef}
onClose=${() => setSearching(false)}
onApplied=${() => setSearching(false)} />
`}
`;
}
// 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 two spellings 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.
//
// Called by VideoApp rather than by the grid: a page is cut from the merged
// list, so a show has to be one card before it is counted. A merge still needs
// both halves to have been on screen, since only a mounted card reports its
// match.
function mergeShowsByTmdb(shows, metaByGroup) {
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),
};
});
}
// `units` is one page of cards, in drawing order: `{ kind: 'movie', entry }`
// or `{ kind: 'show', show }`.
function PosterGrid({
units, transportRef, gekRef, onPreview, tmdbEnabled, isNodeAdmin, onNeedConn, onMetaResolved,
}) {
const [detail, setDetail] = useState(null); // { title, repEntry, show? }
const openDetail = (title, repEntry, show) => setDetail({ title, repEntry, show });
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,
detail ? (detail.repEntry.display_title || '') : '',
detail ? detail.repEntry._connGen : 0);
return html`
${units.map((u) => {
if (u.kind === 'movie') {
const e = u.entry;
return html`
<${LazyTile} key=${u.key}>
<${PosterCard} title=${e.display_title || e.name}
subtitle=${formatDuration(e.duration)} repEntry=${e}
groupKey=${`movie:${e.id}`}
sourceEntries=${e}
transportRef=${transportRef} gekRef=${gekRef}
onNeedConn=${onNeedConn} tmdbEnabled=${tmdbEnabled}
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}>
`;
}
const s = u.show;
// 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=${u.key}>
<${PosterCard} title=${s.title}
subtitle=${subtitle}
repEntry=${repEntry}
sourceEntries=${s.episodes}
groupKey=${s.title}
onMetaResolved=${onMetaResolved}
onNeedConn=${onNeedConn} tmdbEnabled=${tmdbEnabled}
transportRef=${transportRef} gekRef=${gekRef}
onOpen=${() => openDetail(s.title, repEntry, s)} />
${LazyTile}>
`; })}
${detail && html`
<${VideoDetailModal} title=${detail.title} meta=${detailMeta}
repEntry=${detail.repEntry} show=${detail.show}
transportRef=${detailTRef} gekRef=${detailGRef} isNodeAdmin=${isNodeAdmin}
tmdbEnabled=${tmdbEnabled}
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 the show name 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, onNeedConn }) {
const tRef = entry._tRef || transportRef;
const gRef = entry._gRef || gekRef;
useEffect(() => {
if (onNeedConn && entry.groupId) {
Promise.resolve(onNeedConn(entry.groupId)).catch(() => {});
}
}, [onNeedConn, entry.groupId]);
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`
`;
}
// `items` is one page of rows, already sorted by VideoApp.
function FlatList({ items, transportRef, gekRef, onPreview, onNeedConn }) {
return html`
`}
${mode === 'poster'
? html`<${PosterGrid} units=${pageUnits} onMetaResolved=${handleMetaResolved}
transportRef=${transportRef} gekRef=${gekRef} onPreview=${onPreview}
tmdbEnabled=${tmdbEnabled} isNodeAdmin=${isNodeAdmin} onNeedConn=${onNeedConn} />`
: html`<${FlatList} items=${pageUnits}
transportRef=${transportRef} gekRef=${gekRef} onPreview=${onPreview}
onNeedConn=${onNeedConn} />`}
`}
`;
}
// MediaThumb and LazyTile are also used by music-app.js
// (docs/MESHBAY_DESIGN.md §9.8): 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 (docs/MESHBAY_DESIGN.md §9.4's checklist).
export { VideoApp, MediaThumb, LazyTile, groupVideoEntries, bumpMediaMetaGeneration, bumpThumbGeneration };