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
+81
View File
@@ -0,0 +1,81 @@
/**
* A small right-click menu.
*
* Electron's native menus are a main-process affair and the browser window is
* plain HTML, so the menu is built in the page. It is a singleton: opening one
* closes whatever was open before, and any click, scroll, resize or Escape
* dismisses it.
*/
export interface ContextMenuItem {
label: string;
onSelect: () => void;
/** Shown greyed out and not selectable. */
disabled?: boolean;
/** Draws a divider above this item. */
separated?: boolean;
}
let open: HTMLDivElement | null = null;
export function closeContextMenu(): void {
open?.remove();
open = null;
}
/**
* Show `items` at the pointer. The menu is placed inside the viewport rather
* than at the raw coordinates, so a right-click near an edge is still readable.
*/
export function showContextMenu(x: number, y: number, items: ContextMenuItem[]): void {
closeContextMenu();
if (items.length === 0) return;
const menu = document.createElement('div');
menu.className = 'context-menu';
menu.setAttribute('role', 'menu');
for (const item of items) {
const button = document.createElement('button');
button.type = 'button';
button.className = 'context-menu-item';
button.setAttribute('role', 'menuitem');
button.textContent = item.label;
if (item.separated) button.dataset.separated = 'true';
if (item.disabled) {
button.disabled = true;
} else {
button.addEventListener('click', () => {
closeContextMenu();
item.onSelect();
});
}
menu.append(button);
}
document.body.append(menu);
open = menu;
const { width, height } = menu.getBoundingClientRect();
const left = Math.max(4, Math.min(x, window.innerWidth - width - 4));
const top = Math.max(4, Math.min(y, window.innerHeight - height - 4));
menu.style.left = `${left}px`;
menu.style.top = `${top}px`;
menu.querySelector<HTMLButtonElement>('.context-menu-item:not(:disabled)')?.focus();
}
// Registered once: a menu that outlives the click that dismissed it is worse
// than no menu at all.
document.addEventListener('pointerdown', (event) => {
if (open && !open.contains(event.target as Node)) closeContextMenu();
});
document.addEventListener('keydown', (event) => {
if (!open || event.key !== 'Escape') return;
// The detail page also closes on Escape, from a listener on this same node,
// so stopping propagation alone would not spare it.
event.stopImmediatePropagation();
closeContextMenu();
});
window.addEventListener('blur', closeContextMenu);
window.addEventListener('resize', closeContextMenu);
document.addEventListener('scroll', closeContextMenu, true);
+14 -103
View File
@@ -1,10 +1,7 @@
import { capture, LatestRequest, safeUploadDate } from './browse-state';
import { LatestRequest } from './browse-state';
import { describe, el } from './dom';
import type {
AnimeBrowserAPI,
AnimeBrowserEntry,
AnimeBrowserEpisode,
} from '../types/anime-browser';
import { createEpisodeList, type SelectedAnime } from './episode-list';
import type { AnimeBrowserAPI, AnimeBrowserEntry } from '../types/anime-browser';
interface DetailPanelOptions {
api: AnimeBrowserAPI;
@@ -19,100 +16,15 @@ export function createDetailPanel({ api, setStatus }: DetailPanelOptions) {
const detailTitle = el<HTMLHeadingElement>('detail-title');
const detailChips = el<HTMLDivElement>('detail-chips');
const detailDescription = el<HTMLParagraphElement>('detail-description');
const episodes = el<HTMLOListElement>('episodes');
const episodesCount = el<HTMLSpanElement>('episodes-count');
let selectedAnime: { url: string; title: string; sourceId: string } | null = null;
let selectedAnime: SelectedAnime | null = null;
let resultsScrollTop = 0;
const requests = new LatestRequest();
const playbacks = new LatestRequest();
function formatEpisodeIndex(episode: AnimeBrowserEpisode, fallbackIndex: number): string {
const value = episode.number ?? fallbackIndex;
return Number.isInteger(value) ? String(value).padStart(2, '0') : value.toFixed(1);
}
async function playEpisode(
button: HTMLButtonElement,
episode: AnimeBrowserEpisode,
): Promise<void> {
const anime = selectedAnime;
if (!anime) return;
// Only the newest click owns the button states and the status line; an
// earlier episode resolving late must not overwrite them.
const playback = playbacks.begin();
for (const other of episodes.querySelectorAll<HTMLButtonElement>('.cue')) {
other.removeAttribute('data-state');
}
button.dataset.state = 'loading';
setStatus(`Resolving ${episode.name}`);
const attempt = await capture(() =>
api.playEpisode({
sourceId: anime.sourceId,
animeUrl: anime.url,
animeTitle: anime.title,
episodeUrl: episode.url,
episodeName: episode.name,
episodeNumber: episode.number,
}),
);
if (!playbacks.isCurrent(playback)) return;
if (!attempt.ok) {
button.removeAttribute('data-state');
setStatus(describe(attempt.error), 'error');
return;
}
const result = attempt.value;
if (result.ok) {
button.dataset.state = 'playing';
setStatus(
result.quality ? `Playing ${episode.name} · ${result.quality}` : `Playing ${episode.name}`,
'ok',
);
} else {
button.removeAttribute('data-state');
setStatus(result.error ?? 'Could not play that episode.', 'error');
}
}
function renderEpisodes(list: AnimeBrowserEpisode[]): void {
episodesCount.textContent = list.length === 0 ? '' : `${list.length}`;
episodes.replaceChildren(
...list.map((episode, index) => {
const item = document.createElement('li');
const button = document.createElement('button');
button.type = 'button';
button.className = 'cue';
const cueIndex = document.createElement('span');
cueIndex.className = 'cue-index';
cueIndex.textContent = formatEpisodeIndex(episode, list.length - index);
const name = document.createElement('span');
name.className = 'cue-name';
name.textContent = episode.name;
if (episode.uploadedAt !== null) {
const uploaded = safeUploadDate(episode.uploadedAt);
if (uploaded) {
const sub = document.createElement('span');
sub.className = 'cue-sub';
sub.textContent = uploaded;
name.append(sub);
}
}
button.append(cueIndex, name);
button.addEventListener('click', () => void playEpisode(button, episode));
item.append(button);
return item;
}),
);
}
const episodeList = createEpisodeList({
api,
setStatus,
selectedAnime: () => selectedAnime,
});
async function open(entry: AnimeBrowserEntry): Promise<void> {
const request = requests.begin();
@@ -124,12 +36,11 @@ export function createDetailPanel({ api, setStatus }: DetailPanelOptions) {
detailTitle.textContent = entry.title;
detailDescription.textContent = 'Loading…';
detailChips.replaceChildren();
episodes.replaceChildren();
episodesCount.textContent = '';
episodeList.clear();
detailCover.src = entry.thumbnailUrl ?? '';
try {
const [details, episodeList] = await Promise.all([
const [details, episodes] = await Promise.all([
api.getDetails(entry.url, entry.sourceId),
api.getEpisodes(entry.url, entry.sourceId),
]);
@@ -158,8 +69,8 @@ export function createDetailPanel({ api, setStatus }: DetailPanelOptions) {
}
detailChips.replaceChildren(...chips);
renderEpisodes(episodeList);
setStatus(`${details.title} · ${episodeList.length} episodes`);
episodeList.render(episodes);
setStatus(`${details.title} · ${episodes.length} episodes`);
} catch (error) {
if (!requests.isCurrent(request)) return;
detailDescription.textContent = '';
@@ -169,7 +80,7 @@ export function createDetailPanel({ api, setStatus }: DetailPanelOptions) {
function close(): void {
requests.cancel();
playbacks.cancel();
episodeList.clear();
detail.classList.add('hidden');
results.classList.remove('hidden');
results.scrollTop = resultsScrollTop;
+88 -1
View File
@@ -96,7 +96,7 @@
.episodes-head {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 12px;
margin-bottom: 10px;
padding-bottom: 8px;
border-bottom: 1px solid var(--line);
@@ -117,6 +117,21 @@
color: var(--faint);
}
/* How much of the series the stats history already has a watch mark for. */
.episodes-watched {
font-family: var(--mono);
font-size: 11px;
color: var(--ok);
}
/* Pushed to the far end so the counters stay next to the heading. */
.episodes-filter {
margin-left: auto;
width: clamp(160px, 24vw, 260px);
padding: 5px 10px;
font-size: 12px;
}
/*
* The cue rail: episodes read as subtitle cues on a timeline, because that is
* what they are about to become. The rail is the spine, the index is the cue
@@ -205,6 +220,29 @@
margin-top: 2px;
}
/*
* Watched episodes stay readable rather than being hidden: the mark says "you
* have been here", it does not remove the episode from the list.
*/
.cue[data-watched='true'] .cue-name,
.cue[data-watched='true'] .cue-index {
color: var(--faint);
}
.cue[data-watched='true']::after {
background: var(--ok);
border-color: var(--ok);
}
.cue-watched {
margin-left: 10px;
font-family: var(--mono);
font-size: 10px;
letter-spacing: 0.06em;
color: var(--ok);
white-space: nowrap;
}
.cue[data-state='loading'] {
background: var(--panel-elevated);
}
@@ -220,6 +258,55 @@
border-color: var(--mine);
}
/* ---------- right-click menu ---------- */
.context-menu {
position: fixed;
z-index: 40;
min-width: 210px;
padding: 4px;
border: 1px solid var(--line);
border-radius: 10px;
/* Opaque on purpose: the panel colours are translucent, and episode rows
showing through a menu makes both unreadable. */
background: var(--ctp-mantle);
box-shadow: 0 20px 44px -22px var(--shadow);
animation: detail-in 0.12s ease both;
}
.context-menu-item {
display: block;
width: 100%;
padding: 7px 10px;
border: none;
border-radius: 6px;
background: none;
color: inherit;
font: inherit;
font-size: 12px;
text-align: left;
white-space: nowrap;
cursor: pointer;
}
.context-menu-item[data-separated='true'] {
margin-top: 4px;
border-top: 1px solid var(--line);
padding-top: 9px;
border-radius: 0 0 6px 6px;
}
.context-menu-item:hover:not(:disabled),
.context-menu-item:focus-visible:not(:disabled) {
background: var(--ctp-surface0);
outline: none;
}
.context-menu-item:disabled {
color: var(--faint);
cursor: default;
}
/* ---------- status bar ---------- */
.statusbar {
+61
View File
@@ -0,0 +1,61 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { filterEpisodes, parseEpisodeFilter } from './episode-filter';
const episode = (number: number | null, name: string) => ({ number, name });
const SEASON = [
episode(1, 'Episode 1 - Departure'),
episode(2, 'Episode 2 - The Long Road'),
episode(12, 'Episode 12 - Homecoming'),
episode(12.5, 'Episode 12.5 - Recap'),
episode(18, 'Episode 18 - Finale'),
episode(null, 'OVA: Beach Special'),
];
test('an empty query keeps the whole list', () => {
assert.deepEqual(filterEpisodes(SEASON, ' '), SEASON);
assert.equal(parseEpisodeFilter(''), null);
});
test('a number matches that episode, not every episode containing the digits', () => {
assert.deepEqual(
filterEpisodes(SEASON, '12').map((item) => item.number),
[12, 12.5],
);
});
test('a number still matches names when the source reported no numbers', () => {
const unnumbered = [episode(null, 'Episode 3'), episode(null, 'Episode 4')];
assert.deepEqual(
filterEpisodes(unnumbered, '3').map((item) => item.name),
['Episode 3'],
);
});
test('a range keeps the episodes inside it, in either order', () => {
assert.deepEqual(
filterEpisodes(SEASON, '12-18').map((item) => item.number),
[12, 12.5, 18],
);
assert.deepEqual(
filterEpisodes(SEASON, '18 12').map((item) => item.number),
[12, 12.5, 18],
);
});
test('a range skips episodes the source gave no number', () => {
assert.deepEqual(filterEpisodes([episode(null, 'OVA')], '1-99'), []);
});
test('anything else is a case-insensitive substring of the name', () => {
assert.deepEqual(
filterEpisodes(SEASON, 'home').map((item) => item.number),
[12],
);
assert.deepEqual(
filterEpisodes(SEASON, 'BEACH').map((item) => item.name),
['OVA: Beach Special'],
);
assert.deepEqual(filterEpisodes(SEASON, 'nothing here'), []);
});
+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));
}
+330
View File
@@ -0,0 +1,330 @@
import { capture, LatestRequest, safeUploadDate } from './browse-state';
import { describe, el } from './dom';
import { closeContextMenu, showContextMenu, type ContextMenuItem } from './context-menu';
import { filterEpisodes } from './episode-filter';
import { describeMarkCount, episodesInScope } from './episode-marks';
import type { AnimeBrowserAPI, AnimeBrowserEpisode } from '../types/anime-browser';
export interface SelectedAnime {
url: string;
title: string;
sourceId: string;
}
interface EpisodeListOptions {
api: AnimeBrowserAPI;
setStatus: (message: string, tone?: 'info' | 'ok' | 'error') => void;
/** The anime the list belongs to, or null once the detail page is closed. */
selectedAnime: () => SelectedAnime | null;
}
/**
* An episode, the number the filter matches on, and the number the rail shows.
*
* They are separate on purpose: the rail falls back to the listing position so
* every row has an index, but a special the source gave no number to must not
* become findable under a number it does not have.
*/
interface ListedEpisode {
episode: AnimeBrowserEpisode;
number: number | null;
displayIndex: number;
name: string;
}
export function createEpisodeList({ api, setStatus, selectedAnime }: EpisodeListOptions) {
const episodes = el<HTMLOListElement>('episodes');
const episodesCount = el<HTMLSpanElement>('episodes-count');
const episodesWatched = el<HTMLSpanElement>('episodes-watched');
const filterInput = el<HTMLInputElement>('episodes-filter');
let listed: ListedEpisode[] = [];
/** Episode urls the stats history reports as already watched. */
let watched = new Set<string>();
/**
* Which episode is resolving or playing. Kept here rather than only on the
* button, so filtering mid-playback repaints the cue instead of dropping it.
*/
let cueState: { url: string; state: 'loading' | 'playing' } | null = null;
const watchStateRequests = new LatestRequest();
const playbacks = new LatestRequest();
function formatEpisodeIndex(item: ListedEpisode): string {
const value = item.number ?? item.displayIndex;
return Number.isInteger(value) ? String(value).padStart(2, '0') : value.toFixed(1);
}
/** Push `cueState` onto whichever rows are on screen right now. */
function applyCueState(): void {
for (const row of episodes.querySelectorAll<HTMLButtonElement>('.cue')) {
if (cueState && row.dataset.episodeUrl === cueState.url) row.dataset.state = cueState.state;
else row.removeAttribute('data-state');
}
}
async function playEpisode(episode: AnimeBrowserEpisode) {
const anime = selectedAnime();
if (!anime) return;
// Only the newest click owns the cue states and the status line; an earlier
// episode resolving late must not overwrite them.
const playback = playbacks.begin();
cueState = { url: episode.url, state: 'loading' };
applyCueState();
setStatus(`Resolving ${episode.name}`);
const attempt = await capture(() =>
api.playEpisode({
sourceId: anime.sourceId,
animeUrl: anime.url,
animeTitle: anime.title,
episodeUrl: episode.url,
episodeName: episode.name,
episodeNumber: episode.number,
}),
);
if (!playbacks.isCurrent(playback)) return;
if (!attempt.ok) {
cueState = null;
applyCueState();
setStatus(describe(attempt.error), 'error');
return;
}
const result = attempt.value;
if (result.ok) {
cueState = { url: episode.url, state: 'playing' };
applyCueState();
setStatus(
result.quality ? `Playing ${episode.name} · ${result.quality}` : `Playing ${episode.name}`,
'ok',
);
} else {
cueState = null;
applyCueState();
setStatus(result.error ?? 'Could not play that episode.', 'error');
}
}
function createRow(item: ListedEpisode): HTMLLIElement {
const { episode } = item;
const row = document.createElement('li');
const button = document.createElement('button');
button.type = 'button';
button.className = 'cue';
button.dataset.episodeUrl = episode.url;
if (watched.has(episode.url)) button.dataset.watched = 'true';
if (cueState?.url === episode.url) button.dataset.state = cueState.state;
const cueIndex = document.createElement('span');
cueIndex.className = 'cue-index';
cueIndex.textContent = formatEpisodeIndex(item);
const name = document.createElement('span');
name.className = 'cue-name';
name.textContent = episode.name;
// Inline after the title rather than off at the row's far edge, where it
// reads as belonging to no episode in particular.
if (watched.has(episode.url)) {
const mark = document.createElement('span');
mark.className = 'cue-watched';
mark.textContent = '✓ watched';
name.append(mark);
}
if (episode.uploadedAt !== null) {
const uploaded = safeUploadDate(episode.uploadedAt);
if (uploaded) {
const sub = document.createElement('span');
sub.className = 'cue-sub';
sub.textContent = uploaded;
name.append(sub);
}
}
button.append(cueIndex, name);
button.addEventListener('click', () => void playEpisode(episode));
button.addEventListener('contextmenu', (event) => {
event.preventDefault();
openRowMenu(event, item);
});
row.append(button);
return row;
}
/**
* The right-click menu on one episode: this episode, or this one and every
* episode listed below it, which for a newest-first source is everything
* older.
*/
function openRowMenu(event: MouseEvent, item: ListedEpisode): void {
const index = listed.indexOf(item);
if (index < 0) return;
const isWatched = watched.has(item.episode.url);
const below = episodesInScope(listed, index, 'below');
const items: ContextMenuItem[] = [
{
label: isWatched ? 'Mark unwatched' : 'Mark watched',
onSelect: () => void applyMark([item], !isWatched),
},
];
// The oldest episode has nothing below it, so the span entries would only
// repeat the single one above them.
if (below.length > 1) {
const count = below.length - 1;
items.push(
{
label: `Mark this and ${count} below watched`,
separated: true,
onSelect: () => void applyMark(below, true),
},
{
label: `Mark this and ${count} below unwatched`,
onSelect: () => void applyMark(below, false),
},
);
}
showContextMenu(event.clientX, event.clientY, items);
}
/**
* Write the mark, then repaint from what the store reports rather than from
* what was asked for: an episode the write could not record must not show a
* mark that is not there.
*/
async function applyMark(items: ListedEpisode[], mark: boolean): Promise<void> {
const anime = selectedAnime();
if (!anime || items.length === 0) return;
const request = watchStateRequests.begin();
const attempt = await capture(() =>
api.setWatched({
sourceId: anime.sourceId,
animeUrl: anime.url,
animeTitle: anime.title,
watched: mark,
episodes: items.map((item) => ({
episodeUrl: item.episode.url,
episodeName: item.episode.name,
episodeNumber: item.episode.number,
})),
}),
);
if (!watchStateRequests.isCurrent(request)) return;
if (!attempt.ok) {
setStatus(describe(attempt.error), 'error');
return;
}
const marked = new Set(
attempt.value.filter((state) => state.watched).map((state) => state.episodeUrl),
);
// Counted from what came back, not from what was asked for, so the status
// line cannot claim more than the store actually recorded.
let changed = 0;
for (const item of items) {
const isMarked = marked.has(item.episode.url);
if (isMarked) watched.add(item.episode.url);
else watched.delete(item.episode.url);
if (isMarked === mark) changed += 1;
}
paint();
// Nothing came back marked when marking is what was asked for: the write
// had nowhere to land, which is what a disabled stats history looks like.
if (mark && marked.size === 0) {
setStatus('Watch marks need immersion tracking enabled.', 'error');
return;
}
setStatus(describeMarkCount(changed, mark), 'ok');
}
/** Repaints the rows the current filter leaves, and the two counters. */
function paint(): void {
const query = filterInput.value;
const visible = filterEpisodes(listed, query);
episodes.replaceChildren(...visible.map(createRow));
if (listed.length === 0) {
episodesCount.textContent = '';
} else {
episodesCount.textContent =
visible.length === listed.length
? `${listed.length}`
: `${visible.length} of ${listed.length}`;
}
const watchedCount = listed.filter((item) => watched.has(item.episode.url)).length;
episodesWatched.textContent = watchedCount > 0 ? `${watchedCount} watched` : '';
episodesWatched.classList.toggle('hidden', watchedCount === 0);
filterInput.classList.toggle('hidden', listed.length === 0);
}
/**
* Ask the stats history which of these episodes are already watched.
*
* Playback marks an episode watched partway through a session, so this also
* runs when the window comes back to the front: an episode finished in mpv
* while the browser sat behind it shows its mark on the way back.
*/
async function refreshWatchState(): Promise<void> {
const anime = selectedAnime();
if (!anime || listed.length === 0) return;
const request = watchStateRequests.begin();
const attempt = await capture(() =>
api.getWatchState({
sourceId: anime.sourceId,
animeUrl: anime.url,
episodeUrls: listed.map((item) => item.episode.url),
}),
);
if (!watchStateRequests.isCurrent(request)) return;
// Watch marks are decoration; a failed lookup leaves the list as it is.
if (!attempt.ok) return;
watched = new Set(
attempt.value.filter((state) => state.watched).map((state) => state.episodeUrl),
);
paint();
}
function render(list: AnimeBrowserEpisode[]): void {
listed = list.map((episode, index) => ({
episode,
number: episode.number,
displayIndex: list.length - index,
name: episode.name,
}));
paint();
void refreshWatchState();
}
function clear(): void {
// A menu opened against the list that is going away has nothing left to act on.
closeContextMenu();
watchStateRequests.cancel();
playbacks.cancel();
listed = [];
watched = new Set();
cueState = null;
filterInput.value = '';
paint();
}
filterInput.addEventListener('input', paint);
// Escape inside the filter clears it instead of closing the detail page.
filterInput.addEventListener('keydown', (event) => {
if (event.key !== 'Escape' || filterInput.value === '') return;
event.stopPropagation();
filterInput.value = '';
paint();
});
window.addEventListener('focus', () => void refreshWatchState());
return { render, clear, refreshWatchState };
}
+27
View File
@@ -0,0 +1,27 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { describeMarkCount, episodesInScope } from './episode-marks';
const LIST = ['e12', 'e11', 'e10', 'e9'];
test('the one scope takes only the episode itself', () => {
assert.deepEqual(episodesInScope(LIST, 1, 'one'), ['e11']);
});
test('the below scope takes the episode and everything listed after it', () => {
assert.deepEqual(episodesInScope(LIST, 1, 'below'), ['e11', 'e10', 'e9']);
assert.deepEqual(episodesInScope(LIST, 0, 'below'), LIST);
assert.deepEqual(episodesInScope(LIST, 3, 'below'), ['e9']);
});
test('an index outside the list marks nothing', () => {
assert.deepEqual(episodesInScope(LIST, -1, 'below'), []);
assert.deepEqual(episodesInScope(LIST, 4, 'one'), []);
});
test('describeMarkCount agrees with itself about plurals and direction', () => {
assert.equal(describeMarkCount(1, true), 'Marked 1 episode watched');
assert.equal(describeMarkCount(3, true), 'Marked 3 episodes watched');
assert.equal(describeMarkCount(1, false), 'Cleared the watch mark on 1 episode');
assert.equal(describeMarkCount(12, false), 'Cleared the watch mark on 12 episodes');
});
+21
View File
@@ -0,0 +1,21 @@
/**
* Which episodes a manual watch mark applies to.
*
* Sources list newest first, so "everything below" the row you right-clicked is
* the back catalogue: the natural shape of "I have watched up to here". The
* span is taken from the full list rather than from what the filter leaves,
* because a filter narrows what you are looking at, not what you have watched.
*/
export type MarkScope = 'one' | 'below';
export function episodesInScope<T>(episodes: T[], index: number, scope: MarkScope): T[] {
if (index < 0 || index >= episodes.length) return [];
return scope === 'one' ? [episodes[index]!] : episodes.slice(index);
}
/** "3 episodes" / "1 episode", for the status line after a bulk mark. */
export function describeMarkCount(count: number, watched: boolean): string {
const noun = count === 1 ? 'episode' : 'episodes';
return watched ? `Marked ${count} ${noun} watched` : `Cleared the watch mark on ${count} ${noun}`;
}
+9
View File
@@ -151,6 +151,15 @@
<div class="episodes-head">
<h3 class="episodes-title">Episodes</h3>
<span class="episodes-count" id="episodes-count"></span>
<span class="episodes-watched hidden" id="episodes-watched"></span>
<input
class="text-input episodes-filter hidden"
id="episodes-filter"
type="search"
placeholder="Filter: 12, 12-18, or a name"
autocomplete="off"
aria-label="Filter episodes"
/>
</div>
<ol class="cue-rail" id="episodes"></ol>
</div>