From 459fc93e98f23e326c2fa77fe86ba74c2bae77f0 Mon Sep 17 00:00:00 2001 From: Christophe Besson Date: Tue, 15 Sep 2026 17:01:06 +0200 Subject: feat(hub): Videos and Music list their cards a page at a time MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Previous/next arrows in the pinned toolbar, on group pages and in Search. Page size is an account preference (Settings → Defaults), 50 by default, 10 to 200 in steps of 10. Co-Authored-By: Claude Opus 5 --- .../src/meshbay_hub/static/video-app.js | 165 ++++++++++++--------- 1 file changed, 99 insertions(+), 66 deletions(-) (limited to 'packages/meshbay-hub/src/meshbay_hub/static/video-app.js') diff --git a/packages/meshbay-hub/src/meshbay_hub/static/video-app.js b/packages/meshbay-hub/src/meshbay_hub/static/video-app.js index 18b99af..d4af4ef 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/video-app.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/video-app.js @@ -5,6 +5,7 @@ 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 ─────────────────────────────────────────────────────────────────── // @@ -845,60 +846,61 @@ function VideoDetailModal({ `; } +// 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({ - movies, shows, transportRef, gekRef, onPreview, tmdbEnabled, isNodeAdmin, onNeedConn, + units, transportRef, gekRef, onPreview, tmdbEnabled, isNodeAdmin, onNeedConn, onMetaResolved, }) { 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; @@ -910,8 +912,11 @@ function PosterGrid({ return html`
- ${movies.map((e) => html` - <${LazyTile} key=${e.id}> + ${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}`} @@ -926,8 +931,9 @@ function PosterGrid({ ? openDetail(e.display_title || e.name, e, null) : onPreview(e))} /> - `)} - ${mergedShows.map((s) => { + `; + } + 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 @@ -945,13 +951,13 @@ function PosterGrid({ ? (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}> + <${LazyTile} key=${u.key}> <${PosterCard} title=${s.title} subtitle=${subtitle} repEntry=${repEntry} sourceEntries=${s.episodes} groupKey=${s.title} - onMetaResolved=${handleMetaResolved} + onMetaResolved=${onMetaResolved} onNeedConn=${onNeedConn} tmdbEnabled=${tmdbEnabled} transportRef=${transportRef} gekRef=${gekRef} onOpen=${() => openDetail(s.title, repEntry, s)} /> @@ -1042,12 +1048,8 @@ function FlatShowFolder({ show, transportRef, gekRef, onPreview, onNeedConn }) { `; } -function FlatList({ movies, shows, transportRef, gekRef, onPreview, onNeedConn }) { - 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)); - +// `items` is one page of rows, already sorted by VideoApp. +function FlatList({ items, transportRef, gekRef, onPreview, onNeedConn }) { return html`
${items.map((it) => it.kind === 'movie' @@ -1066,10 +1068,12 @@ function FlatList({ movies, shows, transportRef, gekRef, onPreview, onNeedConn } function VideoApp({ groupId, transportRef, gekRef, status, entries, availableEntries, onPreview, videoDirectories, tmdbConfig, isNodeAdmin, - hideFilter, onNeedConn, + hideFilter, onNeedConn, userPrefs, pageResetKey, }) { const [mode, setMode] = useState(loadViewMode); const [filter, setFilter] = useState(''); + // raw (per-folder-parsed-title) show title -> its own resolved media_meta_resp. + const [metaByGroup, setMetaByGroup] = 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. @@ -1095,6 +1099,34 @@ function VideoApp({ const filteredShows = useMemo(() => (typeFilter === 'movies' ? [] : !needle ? shows : shows.filter( (s) => s.title.toLowerCase().includes(needle))), [shows, needle, typeFilter]); + const handleMetaResolved = useCallback((groupKey, meta) => { + setMetaByGroup((prev) => (prev[groupKey] === meta ? prev : { ...prev, [groupKey]: meta })); + }, []); + const mergedShows = useMemo(() => { + return mergeShowsByTmdb(filteredShows, metaByGroup); + }, [filteredShows, metaByGroup]); + + // Everything this mode would draw, in the order it draws it, so a page is + // exactly a stretch of what the reader would otherwise have scrolled. The + // flat list sorts films and shows together and does not merge. + const units = useMemo(() => { + const movieUnits = filteredMovies.map((e) => ({ + kind: 'movie', key: `movie:${e.id}`, sortKey: e.display_title || e.name, entry: e })); + if (mode === 'poster') { + return [...movieUnits, + ...mergedShows.map((s) => ({ kind: 'show', key: `show:${s.key}`, show: s }))]; + } + return [...movieUnits, + ...filteredShows.map((s) => ({ kind: 'show', key: `show:${s.title}`, sortKey: s.title, show: s }))] + .sort((a, b) => a.sortKey.localeCompare(b.sortKey)); + }, [mode, filteredMovies, filteredShows, mergedShows]); + + const pager = usePager(units.length, pageSizeFrom(userPrefs), + `${groupId}|${mode}|${typeFilter}|${needle}|${pageResetKey || ''}`); + const pageUnits = useMemo(() => { + return units.slice(pager.start, pager.end); + }, [units, pager.start, pager.end]); + return html` ${(status === 'discovering' || status === 'connecting' || status === 'fetching') && html`

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

@@ -1129,6 +1161,7 @@ function VideoApp({ ${t('video.filter_series')}
+ <${Pager} pager=${pager} /> ${!hideFilter && html`