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-15 21:44:51 -07:00
parent 3dd0750b98
commit 8d55d64ee0
23 changed files with 1601 additions and 111 deletions
+42 -1
View File
@@ -1,6 +1,10 @@
import { IPC_CHANNELS } from '../../shared/ipc/contracts';
import type { AnimeBrowserRuntime } from './anime-browser-runtime';
import type { AnimeBrowserPlayRequest } from '../../types/anime-browser';
import type {
AnimeBrowserPlayRequest,
AnimeBrowserSetWatchedRequest,
AnimeBrowserWatchStateRequest,
} from '../../types/anime-browser';
export interface AnimeBrowserIpcDeps {
// Structurally typed so tests can pass a fake without importing Electron.
@@ -39,6 +43,12 @@ export function registerAnimeBrowserIpcHandlers(deps: AnimeBrowserIpcDeps): void
handle(channels.animeBrowserGetEpisodes, (_event, animeUrl, sourceId) =>
runtime.getEpisodes(String(animeUrl), toOptionalId(sourceId)),
);
handle(channels.animeBrowserGetWatchState, (_event, request) =>
runtime.getWatchState(toWatchStateRequest(request)),
);
handle(channels.animeBrowserSetWatched, (_event, request) =>
runtime.setWatched(toSetWatchedRequest(request)),
);
handle(channels.animeBrowserListAvailableExtensions, () => runtime.listAvailableExtensions());
handle(channels.animeBrowserInstallExtension, (_event, pkg) =>
runtime.installExtension(String(pkg)),
@@ -60,6 +70,37 @@ export function registerAnimeBrowserIpcHandlers(deps: AnimeBrowserIpcDeps): void
);
}
/** Coerce a watch-state request; the renderer's arrays arrive untyped. */
function toWatchStateRequest(value: unknown): AnimeBrowserWatchStateRequest {
const request = (value ?? {}) as Partial<AnimeBrowserWatchStateRequest>;
return {
sourceId: String(request.sourceId ?? ''),
animeUrl: String(request.animeUrl ?? ''),
episodeUrls: Array.isArray(request.episodeUrls)
? request.episodeUrls.filter((url): url is string => typeof url === 'string')
: [],
};
}
/** Coerce a manual watch-mark request, including its per-episode entries. */
function toSetWatchedRequest(value: unknown): AnimeBrowserSetWatchedRequest {
const request = (value ?? {}) as Partial<AnimeBrowserSetWatchedRequest>;
const episodes = Array.isArray(request.episodes) ? request.episodes : [];
return {
sourceId: String(request.sourceId ?? ''),
animeUrl: String(request.animeUrl ?? ''),
animeTitle: String(request.animeTitle ?? ''),
episodes: episodes.map((episode) => ({
episodeUrl: String(episode?.episodeUrl ?? ''),
episodeName: String(episode?.episodeName ?? ''),
// NaN and Infinity are numbers as far as typeof is concerned, and either
// one would reach the stats row as a nonsense episode number.
episodeNumber: Number.isFinite(episode?.episodeNumber) ? episode.episodeNumber! : null,
})),
watched: request.watched === true,
};
}
/**
* A source id the renderer may omit. Absent means "use the current selection",
* so an empty value must stay undefined rather than becoming the string "".