- A press that begins while a field in the row is edited only ends the editing (rowOpen latches it at pointerdown); the card layout's row press is now tested. - A related video reset in the player forgets the session's stored position. - Removing the playing video passes over known-dead items and steps the way it went. - Prev/Next off only because of dead items stay focusable and say why (HU/EN). - The docked bar's scroller leaves room for focus rings. - A focus-opened About card is not closed by its own scroll into view. - EN "Clear watch progress" for the video reset, distinct from a page's "Reset view". - The signed-out title read goes to the page yt-dlp read; the retitle writes are conditional. - Tests: the cap of three errors, the bar's own Fullscreen, the group glyph, the backfill's reads.
62 lines
2.8 KiB
TypeScript
62 lines
2.8 KiB
TypeScript
import type { Video } from "./api";
|
|
|
|
// What the player does with its queue after the item it is PLAYING is removed from the list it was
|
|
// opened from. The player's launch queue is frozen at mount (see PlayerModal), so nothing external
|
|
// can take the item out for it: leaving it in would let prev/next step back onto a video that no
|
|
// longer exists, and unmounting the player instead would drop you out of the list you are triaging.
|
|
//
|
|
// Pure, because the decision is the whole of the behaviour: which item plays next, and when there is
|
|
// nothing left to play.
|
|
export interface AfterRemoval {
|
|
/** The queue with the removed item gone. */
|
|
queue: Video[];
|
|
/** The item to play next. */
|
|
playingId: string;
|
|
/** Which way that item lies from the removed one: what it carries on along if it cannot play. */
|
|
way: 1 | -1;
|
|
}
|
|
|
|
/** `null` means nothing is left — the caller closes the player.
|
|
*
|
|
* The next item is the one that FOLLOWED the removed one (that is what "delete and keep going"
|
|
* means when you are working down a list), the one before it when the last item goes. A video known
|
|
* to be unplayable (`dead`, see `stepIndex`) is passed over, not loaded again: the nearest playable
|
|
* one after, else before. None playable: the follower all the same, where the player shows why. */
|
|
export function afterRemoval(
|
|
queue: Video[] | undefined,
|
|
index: number,
|
|
victimId: string,
|
|
dead: ReadonlySet<string> = new Set(),
|
|
): AfterRemoval | null {
|
|
const rest = (queue ?? []).filter((v) => v.id !== victimId);
|
|
if (rest.length === 0) return null;
|
|
// The removed item's slot: what followed it now stands there (past the end when it was the last).
|
|
const at = Math.min(Math.max(index, 0), rest.length);
|
|
const pick = (j: number, way: 1 | -1) => ({ queue: rest, playingId: rest[j]!.id, way });
|
|
for (let j = at; j < rest.length; j++) if (!dead.has(rest[j]!.id)) return pick(j, 1);
|
|
for (let j = at - 1; j >= 0; j--) if (!dead.has(rest[j]!.id)) return pick(j, -1);
|
|
return at < rest.length ? pick(at, 1) : pick(rest.length - 1, -1);
|
|
}
|
|
|
|
/** The nearest item from `index` going `dir` that is not known to be unplayable, or -1 when there is
|
|
* none that way. `wrap` goes round the ends (Loop "All"), never back onto `index` itself.
|
|
*
|
|
* A video YouTube refused to play (embed-blocked, removed) is remembered for the player session, so
|
|
* Previous / Next pass over it instead of loading it again only to be bounced off it. */
|
|
export function stepIndex(
|
|
queue: readonly Video[],
|
|
index: number,
|
|
dir: 1 | -1,
|
|
dead: ReadonlySet<string>,
|
|
wrap = false,
|
|
): number {
|
|
const n = queue.length;
|
|
for (let k = 1; k < n; k++) {
|
|
const raw = index + dir * k;
|
|
if (!wrap && (raw < 0 || raw >= n)) return -1;
|
|
const j = ((raw % n) + n) % n;
|
|
if (!dead.has(queue[j]!.id)) return j;
|
|
}
|
|
return -1;
|
|
}
|