fix(player): marks give way to newer lists, Watch later items mark, and auto-advance has its own glyphs

A session's marks are weighed per field against when each page of a never-refetched list was read:
a page read before the mark (a Show more / Load more on its way) takes it, one read after keeps the
server's word. The live search lays them over its rows too, not only the related list.

Items of the built-in Watch later list ARE the bookmark, so adding, removing and moving them go
through features/video/videoWrites and mark the video; the contract test covers those writes and
catches a write that is named without being called on the spot.

The player's watched toggle follows a mark confirmed elsewhere since it opened (the related row's
menu, a notice's Undo); Restart on the related row after a description link goes back to it.
The file viewer tells the help sheet its keys wait under a layer; refusalReason replaces the last
two open-coded reads.

Auto-advance wears list glyphs of its own (the skip arrows are Previous / Next beside it), and the
auto-advance and loop cycles are `lit`, not toggles: no aria-pressed, the name says the mode. Both
places render from one list of controls, so the docked tooltips name the mode too. The English cap
message and admin hint quote "Show more"; the hint says refunds are limited.
This commit is contained in:
2026-10-25 04:01:43 +01:00
parent b25179c06a
commit 84db570951
31 changed files with 699 additions and 189 deletions
+16 -4
View File
@@ -3,7 +3,12 @@ import { useLatest, useStableCallback } from "../../lib/useLatest";
import { useTranslation } from "react-i18next";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { qk } from "../../lib/queryKeys";
import { writeVideoState } from "../../features/video/videoWrites";
import {
addPlaylistItem,
movePlaylistItem,
removePlaylistItem,
writeVideoState,
} from "../../features/video/videoWrites";
import {
DndContext,
KeyboardSensor,
@@ -301,6 +306,12 @@ function PlaylistDetail() {
refetchOnWindowFocus: true,
});
const detail = detailQuery.data;
// Which kind of list an item write goes to (the Watch later list's items are the bookmark):
// the rail's list knows every one, the open detail the one on screen.
const listRef = (id: number) => ({
id,
kind: playlists.find((p) => p.id === id)?.kind ?? (detail?.id === id ? detail.kind : undefined),
});
// The page's background menu (DESIGN "Background menu"), live only while a list is on screen.
const pageMenu = usePageMenu(selectedId != null && !!detail);
const pagePin = useComfortPin(pageMenu.open);
@@ -503,7 +514,7 @@ function PlaylistDetail() {
old ? { ...old, items: old.items.filter((v) => v.id !== video.id) } : old,
);
try {
await api.removeFromPlaylist(listId, video.id);
await removePlaylistItem(qc, listRef(listId), video.id);
refreshAll(listId);
} catch (e) {
// Said here, as moveItemTo does, or a removal fails in silence (inside the full-screen player
@@ -538,7 +549,8 @@ function PlaylistDetail() {
[video.id],
now.items.map((v) => v.id),
);
if (back.keys.length) return api.addToPlaylist(listId, video.id, back.positions[0]);
if (back.keys.length)
return addPlaylistItem(qc, listRef(listId), video.id, back.positions[0]);
})
.catch((e: unknown) => notifyActionError(e, t("playlists.undoRemoveFailed")))
.finally(() => {
@@ -566,7 +578,7 @@ function PlaylistDetail() {
try {
// ONE request. The two-call version (add there, remove here) leaves the video in BOTH lists
// whenever the second call fails — see the endpoint's docstring.
await api.moveToPlaylist(selectedId, video.id, target.id);
await movePlaylistItem(qc, listRef(selectedId), video.id, target);
// Remembered only on SUCCESS: a shortcut that points at a move which failed would be a
// shortcut to failing again.
setLastMoveTargetId(target.id);