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``;
}
// ── 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.
function useMediaMeta(transportRef, fileId, active, enrichSig) {
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]);
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, repEntry.display_title || '');
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`
`;
}
// ── 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();
}}>
${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 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.
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 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 || '') : '');
return html`
${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}>
`; })}
${detail && html`
<${VideoDetailModal} title=${detail.title} meta=${detailMeta}
repEntry=${detail.repEntry} show=${detail.show}
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 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 }) {
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`
`}
${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 };