fix(playlists): the player's Remove follows the list it was launched from, the player survives a change of selection, and a gone list is said in the reader's language

- D3 snapshotted the removal's TARGET, but whether the player offered Remove still followed the
  selected list: launched from Watch later (no Remove, by design), selecting another list under it
  made Remove appear — and delete from Watch later. The snapshot now carries the launch list's
  editability too.
- Selecting another list under an open player (the command palette works over it) put the page into
  "Loading…", and the player was rendered only in the loaded branch — it vanished mid-playback and
  came back at its launch video. It now sits at one place in the tree whatever the page is doing.
- A removal takes the video out of the CACHED list as well as the rows: the player's progress
  checkpoint rewrote that entry before the refetch landed, handing the membership guard a list that
  still held the video — the row came back and the undo history went.
- The watched-state callback refreshed the selected list, not the launch list (D3's sibling).
- `_own_playlist`'s 404 is structured (`{"code": "playlist", "reason": "not_found"}`) and localised:
  the bare "Unknown playlist" reached the Hungarian UI in English, which also made round 1's new
  `removeFailed` string unreachable. Both Remove and Move report through `notifyActionError` now.
- The "viewer moved on under the confirm" test stepped back only once, which lands on the same video
  whether or not the removed one left the queue; it now takes the second step that tells them apart.
This commit is contained in:
2026-09-11 03:19:07 +02:00
parent 6de2cd950d
commit dcd8a9566a
9 changed files with 438 additions and 264 deletions
+4 -1
View File
@@ -50,7 +50,10 @@ def _check_pull_cooldown(user: User) -> None:
def _own_playlist(db: Session, user: User, playlist_id: int) -> Playlist:
pl = db.get(Playlist, playlist_id)
if pl is None or pl.user_id != user.id:
raise HTTPException(status_code=404, detail="Unknown playlist")
# Structured, so the UI can say it in the reader's language: this 404 is exactly the one a
# user meets in practice (the list was deleted in another tab), and a bare "Unknown
# playlist" reached the Hungarian UI in English. One reason for "gone" and "not yours" alike.
raise HTTPException(status_code=404, detail={"code": "playlist", "reason": "not_found"})
return pl
+16
View File
@@ -128,6 +128,22 @@ def test_move_into_someone_elses_list_404s_and_changes_nothing(db, make_user, as
assert _ids(db, theirs.id) == []
def test_a_list_that_is_not_yours_answers_with_a_reason_the_ui_can_translate(
db, make_user, as_user
):
# The one 404 a user meets in practice (the list went away in another tab). It used to be a bare
# English "Unknown playlist", which the Hungarian UI then showed verbatim.
user, other = make_user(), make_user()
_seed_videos(db, ["v1"])
theirs = _playlist(db, other, "Theirs", ["v1"])
r = as_user(user).delete(f"/api/playlists/{theirs.id}/items/v1")
assert r.status_code == 404
assert r.json()["detail"] == {"code": "playlist", "reason": "not_found"}
db.expire_all()
assert _ids(db, theirs.id) == ["v1"]
def test_move_marks_both_linked_sides_dirty(db, make_user, as_user):
"""A move edits two YouTube mirrors at once — the source lost an item, the target gained one.
Marking only the source is the asymmetric fix this repo keeps re-learning."""
@@ -216,6 +216,11 @@ describe("PlayerModal — removing the playing video from its playlist", () => {
expect(lastStart()?.videoId).toBe("C");
prev();
await waitFor(() => expect(lastStart()?.videoId).toBe("B"));
// …and A is gone from the queue, not just skipped over: B is its head now, so stepping back
// again goes nowhere. (One step back lands on B either way — it took the second to tell.)
prev();
await settle();
expect(lastStart()?.videoId).toBe("B");
expect(started.filter((s) => s.videoId === "A")).toHaveLength(1); // never played again
});
+86 -7
View File
@@ -10,9 +10,11 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
const h = vi.hoisted(() => ({
notify: vi.fn(),
selectedId: 1 as number | null,
mounted: false,
player: null as null | {
video: { id: string; position_seconds: number };
onClose: () => void;
onState: (id: string, status: string) => void;
onRemoveFromList?: (v: unknown) => Promise<boolean>;
},
}));
@@ -34,12 +36,21 @@ vi.mock("../lib/notifications", async (importOriginal) => ({
vi.mock("./PlaylistsProvider", () => ({ usePlaylists: () => ({ selectedId: h.selectedId }) }));
vi.mock("./ConfirmProvider", () => ({ useConfirm: () => async () => true }));
vi.mock("./usePlaylistActions", () => ({ usePlaylistActions: () => ({ remove: vi.fn() }) }));
vi.mock("./PlayerModal", () => ({
default: (props: NonNullable<typeof h.player>) => {
h.player = props;
return null;
},
}));
vi.mock("./PlayerModal", async () => {
const { useEffect } = await import("react");
return {
default: function PlayerStandIn(props: NonNullable<typeof h.player>) {
h.player = props;
useEffect(() => {
h.mounted = true;
return () => {
h.mounted = false;
};
}, []);
return null;
},
};
});
import Playlists from "./Playlists";
import { api, HttpError, type Video } from "../lib/api";
@@ -72,10 +83,11 @@ const vid = (id: string, position_seconds: number): Video =>
watch_url: `https://www.youtube.com/watch?v=${id}`,
}) as unknown as Video;
const WATCH_LATER = 9;
const list = (id: number, items: Video[]) => ({
id,
name: `List ${id}`,
kind: "user",
kind: id === WATCH_LATER ? "watch_later" : "user",
yt_playlist_id: null,
item_count: items.length,
items,
@@ -107,6 +119,7 @@ const play = async (title: string) =>
beforeEach(() => {
h.selectedId = 1;
h.player = null;
h.mounted = false;
h.notify.mockClear();
});
afterEach(() => {
@@ -171,4 +184,70 @@ describe("Playlists — removing a video from inside the player", () => {
expect(done).toBe(false);
expect(h.notify).toHaveBeenCalledWith(expect.objectContaining({ level: "error" }));
});
it("offers Remove by the list it was launched from, not by whichever list is selected now", async () => {
// Watch later deliberately has no Remove. Gated on the SELECTED list, it appeared on a player
// launched from Watch later as soon as another list was selected under it — and deleted from
// Watch later, since the removal itself targets the launch list.
h.selectedId = WATCH_LATER;
const { rerender } = mount({ [WATCH_LATER]: [vid("V", 0)], 2: [vid("W", 0)] });
await play("Video V");
await waitFor(() => expect(h.player).not.toBeNull());
expect(h.player!.onRemoveFromList).toBeUndefined();
h.selectedId = 2;
rerender();
await screen.findByRole("button", { name: "card.play — Video W" });
expect(h.player!.onRemoveFromList).toBeUndefined();
});
it("takes the removed video out of the cached list, so a progress checkpoint cannot bring it back", async () => {
// The player steps to the next item after a removal and stores the last one's position into
// every cached list. Landing before the refetch, that write handed the page a list still holding
// the video, and the page — seeing a different set of ids — reset: the row came back.
vi.spyOn(api, "removeFromPlaylist").mockResolvedValue(undefined as never);
mount({ 1: [vid("V", 0), vid("W", 0)] });
await play("Video V");
await waitFor(() => expect(h.player).not.toBeNull());
vi.mocked(api.playlist).mockReturnValue(new Promise(() => {})); // the refetch is still out
await act(async () => {
await h.player!.onRemoveFromList!(vid("V", 0));
});
await act(async () => {
patchCachedProgress(qc, new Map([["V", 99]]));
await new Promise((r) => setTimeout(r, 0));
});
expect(screen.queryByRole("button", { name: "card.play — Video V" })).toBeNull();
expect(screen.getByRole("button", { name: "card.play — Video W" })).toBeTruthy();
});
});
describe("Playlists — the player outlives a change of selection", () => {
it("stays mounted while a newly selected list is still loading", async () => {
// The page swaps itself for "Loading…" until the new list arrives; with the player rendered only
// in the loaded branch, that took it down mid-playback and put it back at its launch video.
const { rerender } = mount({ 1: [vid("V", 0)] });
await play("Video V");
await waitFor(() => expect(h.mounted).toBe(true));
vi.mocked(api.playlist).mockReturnValue(new Promise(() => {}));
h.selectedId = 2;
rerender();
await screen.findByText("playlists.loading");
expect(h.mounted).toBe(true);
});
it("refreshes the list it was launched from when the viewer marks the video in it", async () => {
// Invalidating the SELECTED list left the launch list's row showing the old status.
vi.spyOn(api, "setState").mockResolvedValue(undefined as never);
const { rerender } = mount({ 1: [vid("V", 0)], 2: [vid("W", 0)] });
await play("Video V");
await waitFor(() => expect(h.player).not.toBeNull());
h.selectedId = 2;
rerender();
await screen.findByRole("button", { name: "card.play — Video W" });
await act(async () => {
h.player!.onState("V", "watched");
await new Promise((r) => setTimeout(r, 0));
});
expect(qc.getQueryState(qk.playlist(1))?.isInvalidated).toBe(true);
});
});
+300 -255
View File
@@ -40,8 +40,9 @@ import {
X,
Youtube,
} from "lucide-react";
import { api, HttpError, type Playlist, type Video } from "../lib/api";
import { api, HttpError, type Playlist, type PlaylistDetail, type Video } from "../lib/api";
import { notify } from "../lib/notifications";
import { notifyActionError } from "../lib/actionErrors";
import { notifyYouTubeActionError } from "../lib/youtubeErrors";
import { playlistName } from "../lib/playlistName";
import { useUndoable } from "../lib/useUndoable";
@@ -227,12 +228,15 @@ export default function Playlists() {
// normal case. Only onClose takes the player down now. `listId` is part of that snapshot for the
// same reason: the selection can change under an open player (the command palette opens over it),
// and "remove this from the playlist" means the list it was LAUNCHED from, not whichever is
// selected by the time the button is pressed.
// selected by the time the button is pressed. So does whether that button exists at all
// (`editable`): gating it on the current selection offered Remove on a player launched from
// Watch later — which deliberately has none — as soon as another list was selected under it.
const [playing, setPlaying] = useState<{
video: Video;
queue: Video[];
index: number;
listId: number | null;
editable: boolean;
} | null>(null);
// Where the last move went. `LS.playlist` is the SELECTED (source) list and `updated_at` moves on
// any edit, so neither answers "where do I keep sending things"; this does, and it is a per-account
@@ -301,6 +305,8 @@ export default function Playlists() {
// push/delete-on-YouTube paths. Folding it in here would have taken drag and remove away from
// every account without the scope, which they have always had.
const canEdit = editable;
const canEditRef = useRef(canEdit);
canEditRef.current = canEdit;
const [pushing, setPushing] = useState(false);
const [reverting, setReverting] = useState(false);
@@ -310,7 +316,13 @@ export default function Playlists() {
const queue = shownRef.current;
const index = queue.findIndex((v) => v.id === id);
if (index < 0) return;
setPlaying({ video: queue[index]!, queue, index, listId: selectedRef.current });
setPlaying({
video: queue[index]!,
queue,
index,
listId: selectedRef.current,
editable: canEditRef.current,
});
}, []);
const onPlay = useCallback((v: Video) => openPlayer(v.id), [openPlayer]);
const onRemove = useCallback((v: Video) => {
@@ -551,6 +563,14 @@ export default function Playlists() {
order.rebase((list) => list.filter((v) => v.id !== video.id));
lastSetRef.current = `${listId}:${idsKey(next)}`;
}
// …and out of the CACHED list too, not just the rows. Anything that rewrites that cache entry
// before the refetch lands handed the membership guard a list that still held the video — the
// player's own progress checkpoint does exactly that when a removal steps it to the next item —
// so the guard reset the order: the removed row came back and the undo history the rebase had
// kept went with it. (A failure resyncs below, which puts the row back where it belongs.)
qc.setQueryData<PlaylistDetail>(qk.playlist(listId), (old) =>
old ? { ...old, items: old.items.filter((v) => v.id !== video.id) } : old,
);
try {
await api.removeFromPlaylist(listId, video.id);
refreshAll(listId);
@@ -561,7 +581,7 @@ export default function Playlists() {
// confirmed removal fails in silence (inside the full-screen player there is not even a row
// snapping back to show it).
if (e instanceof HttpError && (e.status === 404 || e.status === 403))
notify({ level: "error", message: e.detail || t("playlists.removeFailed") });
notifyActionError(e, t("playlists.removeFailed"));
refreshAll(listId); // …and resync, which restores any row the optimistic removal took out
return false;
}
@@ -597,7 +617,7 @@ export default function Playlists() {
// error dialog for 400/409/422 and 5xx and raises the connectivity notice for a bad gateway,
// so toasting unconditionally reported a transport fault twice (E10 review round 2).
if (e instanceof HttpError && (e.status === 404 || e.status === 403))
notify({ level: "error", message: e.detail || t("playlists.moveFailed") });
notifyActionError(e, t("playlists.moveFailed"));
}
// BOTH sides changed, and so did this video's membership. `qk.playlist(selectedId)` is an exact
// key, so it cannot cover the target — invalidate the whole `["playlist"]` prefix, exactly as
@@ -636,7 +656,10 @@ export default function Playlists() {
function playerState(id: string, status: string) {
api.setState(id, status).then(() => {
qc.invalidateQueries({ queryKey: qk.playlist(selectedId) });
// Every cached list, not `qk.playlist(selectedId)`: the player may have been launched from a
// list that is no longer the selected one (see `playing`), and that list holds the row whose
// status just changed. Only lists actually on screen refetch.
qc.invalidateQueries({ queryKey: qk.playlist() });
qc.invalidateQueries({ queryKey: qk.feed() });
});
}
@@ -767,293 +790,315 @@ export default function Playlists() {
: []),
];
// The open player, at ONE place in the tree whichever state the page below it is in. Selecting
// another list under an open player (the command palette works over it) puts the page into
// "Loading…" until that list arrives; with the player rendered only in the loaded branch, that
// unmounted it mid-playback and remounted it at the video it was launched with.
const player = playing && (
<Suspense fallback={null}>
<PlayerModal
video={playing.video}
queue={playing.queue}
startIndex={playing.index}
startAt={null}
onClose={closePlayer}
onState={playerState}
onRemoveFromList={playing.editable ? removeFromPlayer : undefined}
/>
</Suspense>
);
if (selectedId == null) {
return <div className="text-muted text-sm p-4">{t("playlists.pickOne")}</div>;
return (
<>
<div className="text-muted text-sm p-4">{t("playlists.pickOne")}</div>
{player}
</>
);
}
// The page used to render "Loading…" forever when the detail query failed — an error read as a
// slow network that never arrived.
if (detailQuery.isError) {
return (
<div className="text-sm p-4 flex flex-col items-start gap-3">
<span className="inline-flex items-center gap-2 text-muted">
<AlertTriangle className="w-4 h-4 text-danger" />
{t("playlists.loadFailed")}
</span>
<Button variant="outline" size="xs" onClick={() => detailQuery.refetch()}>
{t("common.retry")}
</Button>
</div>
<>
<div className="text-sm p-4 flex flex-col items-start gap-3">
<span className="inline-flex items-center gap-2 text-muted">
<AlertTriangle className="w-4 h-4 text-danger" />
{t("playlists.loadFailed")}
</span>
<Button variant="outline" size="xs" onClick={() => detailQuery.refetch()}>
{t("common.retry")}
</Button>
</div>
{player}
</>
);
}
if (!detail) {
return <div className="text-muted text-sm p-4">{t("playlists.loading")}</div>;
return (
<>
<div className="text-muted text-sm p-4">{t("playlists.loading")}</div>
{player}
</>
);
}
// No top padding: the sticky column header must sit flush at the scroller top so it doesn't
// shift up when `position: sticky` engages on the first scroll (the fixed toolbar band above
// gives the breathing room). Same reason as the channel table — see Channels.tsx.
return (
// No top padding: the sticky column header must sit flush at the scroller top so it doesn't
// shift up when `position: sticky` engages on the first scroll (the fixed toolbar band above
// gives the breathing room). Same reason as the channel table — see Channels.tsx.
<div className="px-4 pb-4">
{/* Header (title, actions, view controls) is fixed chrome — it portals into the shell's
<>
<div className="px-4 pb-4">
{/* Header (title, actions, view controls) is fixed chrome — it portals into the shell's
fixed band, so only the video list scrolls. */}
<PageToolbar>
{/* Two tight rows beside a small cover: title + actions on the first, the meta line and the
<PageToolbar>
{/* Two tight rows beside a small cover: title + actions on the first, the meta line and the
view controls sharing the second. This band is FIXED chrome (it never scrolls), so every
row it sheds is list height won back — the old layout stacked three rows next to the
cover plus a fourth block under it, for roughly twice this height. */}
<div className="px-4 py-2 max-w-5xl mx-auto">
<div className="flex gap-3 items-center">
<div className="w-20 h-11.25 rounded-lg bg-card overflow-hidden shrink-0">
{items[0]?.thumbnail_url && (
<img src={items[0].thumbnail_url} alt="" className="w-full h-full object-cover" />
)}
</div>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2 min-w-0">
{renaming ? (
<input
autoFocus
value={renameValue}
onChange={(e) => setRenameValue(e.target.value)}
onBlur={saveRename}
onKeyDown={(e) => {
if (e.key === "Enter") saveRename();
if (e.key === "Escape") setRenaming(false);
}}
aria-label={t("playlists.rename")}
className="min-w-0 flex-1 text-lg font-semibold bg-surface border border-border rounded-md px-2 py-0.5 focus:outline-hidden focus:ring-2 focus:ring-accent/40"
/>
) : (
<>
<h2 className="text-lg font-semibold truncate">{plName(detail)}</h2>
{editable && (
<button
onClick={() => {
setRenameValue(detail.name);
setRenaming(true);
}}
title={t("playlists.rename")}
aria-label={t("playlists.rename")}
className="shrink-0 text-muted hover:text-fg"
>
<Pencil className="w-3.5 h-3.5" />
</button>
)}
{detail.yt_playlist_id && (
<a
href={`https://www.youtube.com/playlist?list=${detail.yt_playlist_id}`}
target="_blank"
rel="noreferrer noopener"
title={t("playlists.openOnYoutube")}
aria-label={t("playlists.openOnYoutube")}
className="shrink-0 text-muted hover:text-accent"
>
<ExternalLink className="w-3.5 h-3.5" />
</a>
)}
</>
<div className="px-4 py-2 max-w-5xl mx-auto">
<div className="flex gap-3 items-center">
<div className="w-20 h-11.25 rounded-lg bg-card overflow-hidden shrink-0">
{items[0]?.thumbnail_url && (
<img src={items[0].thumbnail_url} alt="" className="w-full h-full object-cover" />
)}
<div className="ml-auto flex gap-1.5 items-center shrink-0">
{linked && detail.dirty && (
<span
title={t("playlists.unsyncedHint")}
className="inline-flex items-center gap-1.5 text-2xs text-accent mr-1"
>
<span className="w-1.5 h-1.5 rounded-full bg-accent" />
{t("playlists.unsynced")}
</span>
)}
<button
onClick={() => shown.length && openPlayer(shown[0]!.id)}
disabled={!shown.length}
className="inline-flex items-center gap-1.5 text-xs font-semibold px-3 py-1.5 rounded-lg btn-primary disabled:opacity-40 transition"
>
<Play className="w-3.5 h-3.5 fill-current" /> {t("playlists.playAll")}
</button>
{editable && canWrite && (
<button
onClick={pushToYoutube}
disabled={pushing}
title={linked ? t("playlists.pushToYoutube") : t("playlists.exportToYoutube")}
aria-label={
linked ? t("playlists.pushToYoutube") : t("playlists.exportToYoutube")
}
className={`inline-flex items-center justify-center p-2 rounded-lg border transition disabled:opacity-40 ${
detail.dirty
? "border-accent text-accent hover:bg-accent/10"
: "border-border text-muted hover:text-fg hover:border-accent"
}`}
>
{pushing ? (
<RefreshCw className="w-3.5 h-3.5 animate-spin" />
) : linked ? (
<Youtube className="w-3.5 h-3.5" />
) : (
<Upload className="w-3.5 h-3.5" />
)}
</button>
)}
{linked && detail.dirty && (
<Button
variant="outline"
size="icon-sm"
onClick={revertToYoutube}
disabled={reverting}
title={t("playlists.revert")}
aria-label={t("playlists.revert")}
>
<RotateCcw className={`w-3.5 h-3.5 ${reverting ? "animate-spin" : ""}`} />
</Button>
)}
{editable && (
<Button
variant="outline-danger"
size="icon-sm"
onClick={deletePlaylist}
title={t("playlists.delete")}
aria-label={t("playlists.delete")}
icon={<Trash2 className="w-3.5 h-3.5" />}
/>
)}
</div>
</div>
<div className="flex items-center gap-2 mt-1 flex-wrap">
<span className="text-xs text-muted">
{t("playlists.itemCount", { count: items.length })}
{totalSeconds > 0 && ` · ${formatRuntime(totalSeconds)}`}
{filterActive && ` · ${t("playlists.filteredCount", { count: shown.length })}`}
</span>
{(items.length > 1 || viewActive) && (
<>
<span aria-hidden="true" className="text-xs text-muted/40">
·
</span>
<label className="inline-flex items-center gap-1.5 text-xs text-muted cursor-pointer select-none">
<input
type="checkbox"
checked={groupBy}
onChange={(e) => setGroupBy(e.target.checked)}
className="accent-accent"
/>
{t("playlists.groupByChannel")}
</label>
{viewActive && (
<button
onClick={clearView}
className="text-xs text-muted hover:text-accent underline underline-offset-2 transition"
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2 min-w-0">
{renaming ? (
<input
autoFocus
value={renameValue}
onChange={(e) => setRenameValue(e.target.value)}
onBlur={saveRename}
onKeyDown={(e) => {
if (e.key === "Enter") saveRename();
if (e.key === "Escape") setRenaming(false);
}}
aria-label={t("playlists.rename")}
className="min-w-0 flex-1 text-lg font-semibold bg-surface border border-border rounded-md px-2 py-0.5 focus:outline-hidden focus:ring-2 focus:ring-accent/40"
/>
) : (
<>
<h2 className="text-lg font-semibold truncate">{plName(detail)}</h2>
{editable && (
<button
onClick={() => {
setRenameValue(detail.name);
setRenaming(true);
}}
title={t("playlists.rename")}
aria-label={t("playlists.rename")}
className="shrink-0 text-muted hover:text-fg"
>
<Pencil className="w-3.5 h-3.5" />
</button>
)}
{detail.yt_playlist_id && (
<a
href={`https://www.youtube.com/playlist?list=${detail.yt_playlist_id}`}
target="_blank"
rel="noreferrer noopener"
title={t("playlists.openOnYoutube")}
aria-label={t("playlists.openOnYoutube")}
className="shrink-0 text-muted hover:text-accent"
>
<ExternalLink className="w-3.5 h-3.5" />
</a>
)}
</>
)}
<div className="ml-auto flex gap-1.5 items-center shrink-0">
{linked && detail.dirty && (
<span
title={t("playlists.unsyncedHint")}
className="inline-flex items-center gap-1.5 text-2xs text-accent mr-1"
>
{t("playlists.clearView")}
<span className="w-1.5 h-1.5 rounded-full bg-accent" />
{t("playlists.unsynced")}
</span>
)}
<button
onClick={() => shown.length && openPlayer(shown[0]!.id)}
disabled={!shown.length}
className="inline-flex items-center gap-1.5 text-xs font-semibold px-3 py-1.5 rounded-lg btn-primary disabled:opacity-40 transition"
>
<Play className="w-3.5 h-3.5 fill-current" /> {t("playlists.playAll")}
</button>
{editable && canWrite && (
<button
onClick={pushToYoutube}
disabled={pushing}
title={
linked ? t("playlists.pushToYoutube") : t("playlists.exportToYoutube")
}
aria-label={
linked ? t("playlists.pushToYoutube") : t("playlists.exportToYoutube")
}
className={`inline-flex items-center justify-center p-2 rounded-lg border transition disabled:opacity-40 ${
detail.dirty
? "border-accent text-accent hover:bg-accent/10"
: "border-border text-muted hover:text-fg hover:border-accent"
}`}
>
{pushing ? (
<RefreshCw className="w-3.5 h-3.5 animate-spin" />
) : linked ? (
<Youtube className="w-3.5 h-3.5" />
) : (
<Upload className="w-3.5 h-3.5" />
)}
</button>
)}
{canSaveOrder && (
{linked && detail.dirty && (
<Button
variant="outline-accent"
size="xs"
onClick={saveOrder}
icon={<Check className="w-3.5 h-3.5" />}
variant="outline"
size="icon-sm"
onClick={revertToYoutube}
disabled={reverting}
title={t("playlists.revert")}
aria-label={t("playlists.revert")}
>
{t("playlists.saveOrder")}
<RotateCcw className={`w-3.5 h-3.5 ${reverting ? "animate-spin" : ""}`} />
</Button>
)}
{/* ONE sentence that names the state. Three loose texts used to pile up here
{editable && (
<Button
variant="outline-danger"
size="icon-sm"
onClick={deletePlaylist}
title={t("playlists.delete")}
aria-label={t("playlists.delete")}
icon={<Trash2 className="w-3.5 h-3.5" />}
/>
)}
</div>
</div>
<div className="flex items-center gap-2 mt-1 flex-wrap">
<span className="text-xs text-muted">
{t("playlists.itemCount", { count: items.length })}
{totalSeconds > 0 && ` · ${formatRuntime(totalSeconds)}`}
{filterActive && ` · ${t("playlists.filteredCount", { count: shown.length })}`}
</span>
{(items.length > 1 || viewActive) && (
<>
<span aria-hidden="true" className="text-xs text-muted/40">
·
</span>
<label className="inline-flex items-center gap-1.5 text-xs text-muted cursor-pointer select-none">
<input
type="checkbox"
checked={groupBy}
onChange={(e) => setGroupBy(e.target.checked)}
className="accent-accent"
/>
{t("playlists.groupByChannel")}
</label>
{viewActive && (
<button
onClick={clearView}
className="text-xs text-muted hover:text-accent underline underline-offset-2 transition"
>
{t("playlists.clearView")}
</button>
)}
{canSaveOrder && (
<Button
variant="outline-accent"
size="xs"
onClick={saveOrder}
icon={<Check className="w-3.5 h-3.5" />}
>
{t("playlists.saveOrder")}
</Button>
)}
{/* ONE sentence that names the state. Three loose texts used to pile up here
(reset link + save hint + "reordering is off"), and none of them answered the
question a user actually has — "did I just change my playlist?". Worse,
sorting a list whose stored order ALREADY matches the sort produced a
warning, no Save button and no visible change, which reads as a malfunction.
Now the line says which of the three situations you are in; the disabled grip
still explains itself on hover/focus. */}
{viewActive && <span className="text-2xs text-muted/80">{viewStatus}</span>}
{canEdit && (
<div className="ml-auto">
<UndoToolbar
canUndo={order.canUndo}
canRedo={order.canRedo}
onUndo={order.undo}
onRedo={order.redo}
/>
</div>
)}
</>
)}
{viewActive && <span className="text-2xs text-muted/80">{viewStatus}</span>}
{canEdit && (
<div className="ml-auto">
<UndoToolbar
canUndo={order.canUndo}
canRedo={order.canRedo}
onUndo={order.undo}
onRedo={order.redo}
/>
</div>
)}
</>
)}
</div>
</div>
</div>
</div>
</div>
</PageToolbar>
</PageToolbar>
{items.length === 0 ? (
<div className="text-sm text-muted grid place-items-center text-center py-12">
<div>
<ListPlus className="w-6 h-6 mx-auto mb-2 opacity-60" />
{t("playlists.empty")}
{items.length === 0 ? (
<div className="text-sm text-muted grid place-items-center text-center py-12">
<div>
<ListPlus className="w-6 h-6 mx-auto mb-2 opacity-60" />
{t("playlists.empty")}
</div>
</div>
</div>
) : (
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragEnd={onDragEnd}
// dnd-kit's defaults are hardcoded English. They were harmless while only a pointer could
// drag; now that the keyboard can, they ARE the instructions for the feature.
accessibility={{ screenReaderInstructions, announcements }}
>
{/* Not clipped, so the header's filter popovers can overflow the table box. */}
<div className="max-w-5xl mx-auto">
{/* table-fixed, not auto: every column but the title has a declared width, so the
) : (
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragEnd={onDragEnd}
// dnd-kit's defaults are hardcoded English. They were harmless while only a pointer could
// drag; now that the keyboard can, they ARE the instructions for the feature.
accessibility={{ screenReaderInstructions, announcements }}
>
{/* Not clipped, so the header's filter popovers can overflow the table box. */}
<div className="max-w-5xl mx-auto">
{/* table-fixed, not auto: every column but the title has a declared width, so the
title absorbs the rest and its `truncate` works. Auto layout sizes columns from
min-content, and a nowrap title cell's min-content is the entire title. */}
<table className="w-full table-fixed border-collapse text-sm">
<ColumnHead
columns={columns}
sort={viewSort}
onToggleSort={toggleSort}
filters={filters}
onFilter={applyFilter}
/>
<SortableContext
items={shown.map((v) => v.id)}
strategy={verticalListSortingStrategy}
>
<tbody>
{shown.map((v) => (
<Row
key={v.id}
video={v}
columns={columns}
dragDisabled={!canEdit || viewActive}
dragHint={dragHint}
menuProps={rowMenu.triggerProps(v)}
/>
))}
</tbody>
</SortableContext>
</table>
{shown.length === 0 && (
<div className="text-muted py-8 text-center text-sm">
{t("playlists.noneMatchFilter")}
</div>
)}
</div>
</DndContext>
)}
<table className="w-full table-fixed border-collapse text-sm">
<ColumnHead
columns={columns}
sort={viewSort}
onToggleSort={toggleSort}
filters={filters}
onFilter={applyFilter}
/>
<SortableContext
items={shown.map((v) => v.id)}
strategy={verticalListSortingStrategy}
>
<tbody>
{shown.map((v) => (
<Row
key={v.id}
video={v}
columns={columns}
dragDisabled={!canEdit || viewActive}
dragHint={dragHint}
menuProps={rowMenu.triggerProps(v)}
/>
))}
</tbody>
</SortableContext>
</table>
{shown.length === 0 && (
<div className="text-muted py-8 text-center text-sm">
{t("playlists.noneMatchFilter")}
</div>
)}
</div>
</DndContext>
)}
{/* One surface for the whole table, outside the rows — it anchors to the pointer, not the row. */}
<ContextMenu menu={rowMenu} ariaLabel={t("playlists.rowMenu")} items={ctxItems} />
{playing && (
<Suspense fallback={null}>
<PlayerModal
video={playing.video}
queue={playing.queue}
startIndex={playing.index}
startAt={null}
onClose={closePlayer}
onState={playerState}
onRemoveFromList={canEdit ? removeFromPlayer : undefined}
/>
</Suspense>
)}
</div>
{/* One surface for the whole table, outside the rows — it anchors to the pointer, not the row. */}
<ContextMenu menu={rowMenu} ariaLabel={t("playlists.rowMenu")} items={ctxItems} />
</div>
{player}
</>
);
}
@@ -77,6 +77,9 @@
"movedToast": "“{{title}}” moved to “{{name}}” ✓",
"moveFailed": "Couldn't move the video.",
"removeFailed": "Couldn't remove the video.",
"errors": {
"not_found": "This playlist doesn't exist any more."
},
"moveErrors": {
"not_in_playlist": "That video isn't in this playlist any more — it changed somewhere else.",
"same_playlist": "That video is already in this playlist."
@@ -77,6 +77,9 @@
"movedToast": "„{{title}}” áthelyezve ide: „{{name}}” ✓",
"moveFailed": "Nem sikerült áthelyezni a videót.",
"removeFailed": "Nem sikerült eltávolítani a videót.",
"errors": {
"not_found": "Ez a lejátszási lista már nem létezik."
},
"moveErrors": {
"not_in_playlist": "Ez a videó már nincs ebben a listában — időközben máshol megváltozott.",
"same_playlist": "Ez a videó már ebben a listában van."
+19 -1
View File
@@ -21,7 +21,7 @@ vi.mock("../activeAccount", () => ({
notifyActiveAccountChange: vi.fn(),
}));
import { uploadReq, setUnauthorizedHandler, HttpError } from "./core";
import { req, uploadReq, setUnauthorizedHandler, HttpError } from "./core";
// A fake XHR whose lifecycle the test drives: capture the instance, set status/responseText, then
// invoke onload/onerror/abort by hand.
@@ -117,3 +117,21 @@ describe("uploadReq", () => {
expect(reportError).not.toHaveBeenCalled();
});
});
describe("req — a structured refusal reads in the user's language", () => {
it("names a playlist that is gone instead of passing the server's English through", async () => {
// The 404 every playlist route answers when the list was deleted in another tab. It used to be a
// bare "Unknown playlist" string, shown verbatim under the Hungarian UI.
vi.spyOn(globalThis, "fetch").mockResolvedValue(
new Response(JSON.stringify({ detail: { code: "playlist", reason: "not_found" } }), {
status: 404,
headers: { "content-type": "application/json" },
}),
);
await expect(req("/api/playlists/7/items/v1", { method: "DELETE" })).rejects.toMatchObject({
status: 404,
detail: "playlists.errors.not_found",
});
expect(reportError).not.toHaveBeenCalled(); // a 404 is the caller's to report
});
});
+2
View File
@@ -82,6 +82,8 @@ function localizeDetail(d: unknown): string {
// unknown reason falls back to the plain failure rather than printing a key.
if (o?.code === "playlist_move")
return t(`playlists.moveErrors.${o.reason}`, t("playlists.moveFailed"));
// Any playlist route whose list is gone (deleted in another tab) — or was never the caller's.
if (o?.code === "playlist") return t(`playlists.errors.${o.reason}`, t("errors.generic"));
// SEC-14: a manual sync trigger on cooldown. Not an error the user caused so much as a pace
// limit, and the copy says so.
if (o?.code === "sync_cooldown") return t("errors.syncCooldown");