feat(anime): filter episodes and mark them watched by hand

- Add a filter box above the episode list for a number, range, or name substring, with a "N of M" counter
- Read watch marks from the immersion tracker stats and show them per episode, with a watched count in the header, refreshed on window focus
- Add a right-click menu to mark one episode or a whole catch-up span (this and everything below) watched/unwatched
- Add setWatched/getWatchState IPC plumbing so a manual mark creates the stats row for an episode that was never played
This commit is contained in:
2026-08-03 01:00:54 -07:00
parent dd0307cf75
commit 7db247ef5b
23 changed files with 1601 additions and 111 deletions
+74
View File
@@ -0,0 +1,74 @@
/**
* Filtering for the episode list.
*
* A season's worth of episodes does not fit on screen, and sources hand them
* over in whatever order they please, so the list needs a way to jump straight
* to one. The query is deliberately forgiving: a number finds that episode, a
* range finds a span of them, and anything else is a substring of the name.
*/
export interface FilterableEpisode {
/** Effective episode number, after the list's fallback numbering. */
number: number | null;
name: string;
}
export type EpisodeFilter =
| { kind: 'number'; value: number; text: string }
| { kind: 'range'; from: number; to: number }
| { kind: 'text'; text: string };
const NUMBER = String.raw`\d{1,4}(?:\.\d+)?`;
const RANGE_PATTERN = new RegExp(`^(${NUMBER})\\s*[-–—~]\\s*(${NUMBER})$`);
const NUMBER_PATTERN = new RegExp(`^(${NUMBER})$`);
function normalize(value: string): string {
return value.replace(/\s+/g, ' ').trim();
}
/**
* Read a query into what it asks for, or null when it asks for nothing.
*
* A bare number stays a text match as well as a numeric one: sources put the
* number in the name often enough ("Episode 12 - …") that a source reporting no
* numbers at all would otherwise filter to nothing.
*/
export function parseEpisodeFilter(query: string): EpisodeFilter | null {
const text = normalize(query);
if (!text) return null;
const range = text.match(RANGE_PATTERN);
if (range) {
const first = Number.parseFloat(range[1]!);
const second = Number.parseFloat(range[2]!);
// "18-12" is a typo, not an empty range.
return { kind: 'range', from: Math.min(first, second), to: Math.max(first, second) };
}
const number = text.match(NUMBER_PATTERN);
if (number) {
return { kind: 'number', value: Number.parseFloat(number[1]!), text: text.toLowerCase() };
}
return { kind: 'text', text: text.toLowerCase() };
}
export function matchesEpisodeFilter(episode: FilterableEpisode, filter: EpisodeFilter): boolean {
const name = episode.name.toLowerCase();
switch (filter.kind) {
case 'number':
return episode.number === filter.value || name.includes(filter.text);
case 'range':
return (
episode.number !== null && episode.number >= filter.from && episode.number <= filter.to
);
case 'text':
return name.includes(filter.text);
}
}
export function filterEpisodes<T extends FilterableEpisode>(episodes: T[], query: string): T[] {
const filter = parseEpisodeFilter(query);
if (!filter) return episodes;
return episodes.filter((episode) => matchesEpisodeFilter(episode, filter));
}