aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-hub/src/meshbay_hub/static/video-app.js
diff options
context:
space:
mode:
authorChristophe Besson <cbesson@gmail.com>2026-09-15 17:01:06 +0200
committerChristophe Besson <cbesson@gmail.com>2026-09-15 17:01:06 +0200
commit459fc93e98f23e326c2fa77fe86ba74c2bae77f0 (patch)
treef506a127cc3e0779c4d8cf9f27fe659d81bb787e /packages/meshbay-hub/src/meshbay_hub/static/video-app.js
parentfce0942cb7d81dbdbae70e50b0164fead7e8bc49 (diff)
downloadmeshbay-459fc93e98f23e326c2fa77fe86ba74c2bae77f0.tar.gz
feat(hub): Videos and Music list their cards a page at a time
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 <noreply@anthropic.com>
Diffstat (limited to 'packages/meshbay-hub/src/meshbay_hub/static/video-app.js')
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/video-app.js165
1 files changed, 99 insertions, 66 deletions
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/video-app.js b/packages/meshbay-hub/src/meshbay_hub/static/video-app.js
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`
<div class="video-grid">
- ${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))} />
</${LazyTile}>
- `)}
- ${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`
<div class="video-flat-list">
${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`
<p class="page-message"><span class="spinner"></span>${' '}${t('status.connecting_short')}</p>
@@ -1129,6 +1161,7 @@ function VideoApp({
${t('video.filter_series')}
</button>
</div>
+ <${Pager} pager=${pager} />
${!hideFilter && html`<div class="tb-search">
<${Icon} name="search" />
<input type="text" placeholder="${t('group.filter')}"
@@ -1139,10 +1172,10 @@ function VideoApp({
<p class="page-message">${needle ? t('group.empty_filter') : t('video.empty')}</p>
`}
${mode === 'poster'
- ? html`<${PosterGrid} movies=${filteredMovies} shows=${filteredShows}
+ ? html`<${PosterGrid} units=${pageUnits} onMetaResolved=${handleMetaResolved}
transportRef=${transportRef} gekRef=${gekRef} onPreview=${onPreview}
tmdbEnabled=${tmdbEnabled} isNodeAdmin=${isNodeAdmin} onNeedConn=${onNeedConn} />`
- : html`<${FlatList} movies=${filteredMovies} shows=${filteredShows}
+ : html`<${FlatList} items=${pageUnits}
transportRef=${transportRef} gekRef=${gekRef} onPreview=${onPreview}
onNeedConn=${onNeedConn} />`}
`}