mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-08-17 00:18:41 -07:00
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:
@@ -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
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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'), []);
|
||||
});
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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');
|
||||
});
|
||||
@@ -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}`;
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user