Files
jellytau/src/lib/stores/tv.ts
T
dtourolle eb76c96e94 feat(player): skipping an episode marks it watched, not paused
Skipping to the next episode left a mid-episode resume point behind, so
the skipped episode reappeared in Continue Watching with a partial
progress bar. Skipping means "done with this one", not "stopped here".

- reportSkippedEpisode marks the outgoing episode played instead of
  reporting a stop position, and arms a one-shot suppression consumed by
  the player's stop handler, so VideoPlayer's post-navigation unmount
  stop report can't overwrite the 100% progress with the partial one.
- Continue Watching drops resume entries superseded by Next Up: an
  in-progress episode whose series has a next-up entry strictly later in
  series order (season, then episode) is hidden from the Home and TV
  rows. Movies, series without a next-up entry, and items with unknown
  or mixed ordering are always kept.

Adds UR-059, DR-088, DR-089.

TRACES: UR-059 | DR-088, DR-089
2026-07-25 09:20:56 +02:00

172 lines
5.6 KiB
TypeScript

// TV library landing page data store.
// Powers the focused TV landing: hero + horizontal sliders.
// TRACES: UR-007, UR-023, UR-034, UR-059 | DR-007, DR-038, DR-039, DR-089
import { writable, derived } from "svelte/store";
import type { MediaItem } from "$lib/api/types";
import { auth } from "./auth";
import { buildHeroMix } from "$lib/utils/heroMix";
import { filterSupersededResumeItems } from "./continueWatchingFilter";
/** A single "by genre" row: the genre name plus the series in it. */
export interface GenreRow {
id: string;
name: string;
items: MediaItem[];
}
interface TvState {
// Episodes the user can resume (continue watching).
continueWatching: MediaItem[];
// Next unwatched episode per in-progress series.
nextUp: MediaItem[];
// Recently added items in the TV library (series/seasons/episodes).
recentlyAdded: MediaItem[];
// One slider per genre (top genres by show count).
genreRows: GenreRow[];
// Mix used for the hero banner.
heroItems: MediaItem[];
isLoading: boolean;
error: string | null;
}
const SECTION_LIMIT = 16;
// How many genre sliders to show, and how many genres to probe to find them.
const MAX_GENRE_ROWS = 8;
const MAX_GENRES_PROBED = 20;
function createTvStore() {
const initialState: TvState = {
continueWatching: [],
nextUp: [],
recentlyAdded: [],
genreRows: [],
heroItems: [],
isLoading: false,
error: null,
};
const { subscribe, set, update } = writable<TvState>(initialState);
/** Artwork check for hero candidates: own/parent backdrop or primary image. */
const hasArt = (i: MediaItem) =>
!!(i.backdropImageTags && i.backdropImageTags.length > 0) ||
!!(i.parentBackdropImageTags && i.parentBackdropImageTags.length > 0) ||
!!i.imageId;
async function loadSections(libraryId: string) {
update(s => ({
...s,
isLoading: s.continueWatching.length === 0 && s.recentlyAdded.length === 0,
error: null,
}));
try {
const repo = auth.getRepository();
const [resume, nextUp, latest, surprise] = await Promise.all([
repo.getResumeItems(libraryId, SECTION_LIMIT),
repo.getNextUpEpisodes(undefined, SECTION_LIMIT),
repo.getLatestItems(libraryId, SECTION_LIMIT),
// Random pool so the hero rotation changes between visits (SortBy=Random
// shuffles server-side online, and via SQLite RANDOM() offline).
repo
.getItems(libraryId, {
includeItemTypes: ["Series"],
sortBy: "Random",
recursive: true,
limit: SECTION_LIMIT,
})
.then(r => r.items)
.catch(() => [] as MediaItem[]),
]);
// Resume items are already video-only from the server, but keep episodes
// (and the occasional movie that lives in a mixed library) defensively.
// Then drop episodes the user has moved past — a stale partial position
// behind the series' Next Up entry isn't something to continue.
const continueWatching = filterSupersededResumeItems(
resume.filter(i => i.kind === "episode" || i.kind === "movie"),
nextUp
);
// Mix the hero: in-progress episodes first (most personal), then next-up,
// recent additions, and random series from across the library.
const heroItems = buildHeroMix([continueWatching, nextUp, latest, surprise], hasArt);
update(s => ({
...s,
continueWatching,
nextUp,
recentlyAdded: latest,
heroItems,
isLoading: false,
}));
// Genre rows are secondary — load them after the main sections paint so
// the page isn't blocked on N per-genre queries.
loadGenreRows(libraryId);
} catch (error) {
const message = error instanceof Error ? error.message : "Failed to load TV sections";
update(s => ({ ...s, isLoading: false, error: message }));
console.error("Failed to load TV sections:", error);
}
}
/**
* Build one slider per genre, showing the top-rated series in each. We probe
* a bounded set of genres in parallel, drop empty ones, then keep the genres
* with the most shows (so niche/near-empty genres don't crowd the page).
*/
async function loadGenreRows(libraryId: string) {
try {
const repo = auth.getRepository();
const genres = await repo.getGenres(libraryId);
if (genres.length === 0) return;
const probed = genres.slice(0, MAX_GENRES_PROBED);
const rows = await Promise.all(
probed.map(async (genre): Promise<GenreRow> => {
try {
const result = await repo.getItems(libraryId, {
includeItemTypes: ["Series"],
genres: [genre.name],
sortBy: "CommunityRating",
sortOrder: "Descending",
recursive: true,
limit: SECTION_LIMIT,
});
return { id: genre.id, name: genre.name, items: result.items };
} catch (e) {
console.warn(`Failed to load genre row "${genre.name}":`, e);
return { id: genre.id, name: genre.name, items: [] };
}
})
);
const genreRows = rows
.filter(row => row.items.length > 0)
.sort((a, b) => b.items.length - a.items.length)
.slice(0, MAX_GENRE_ROWS);
update(s => ({ ...s, genreRows }));
} catch (e) {
console.warn("Failed to load TV genre rows:", e);
}
}
function reset() {
set(initialState);
}
return {
subscribe,
loadSections,
reset,
};
}
export const tv = createTvStore();
export const tvHeroItems = derived(tv, $t => $t.heroItems);
export const isTvLoading = derived(tv, $t => $t.isLoading);