feat(ui): the four small gaps — player delete, repeat-move, a real channel link, YT search sorting

Items 2, 4, 5 and 7 of the bug hunt, one round. They are independent, but they are all the same
kind of thing: an action that exists somewhere else in the app and is missing where you actually
want it.

**5 — the channel name in the YT player is a link again.** It was a `<button>`, so middle-click and
Ctrl/Cmd-click did nothing on a thing that looks exactly like a link (the YouTube icon beside it IS
an anchor and opens a tab). It is now a real `<a>` whose href is the same `?chan=<id>` URL
`openChannel` pushes, with a plain left click still taking the in-app path — that path does more
than navigate, since the player must close and reclaim its own Back-to-close history entry first.
The "is this click ours or the browser's" test is now ONE predicate (`isPlainLeftClick`), which
VideoCard's card link also uses, so a second such link cannot forget the middle-click arm.

**2 — "Remove" from inside the player.** The parent owns the removal (the SAME `removeItem` the row
menu and the inline ✕ use: one confirm, one optimistic update, one undo-stack rebase), and the
player owns what happens next, because it is the only side that knows which item is playing — its
launch queue is frozen at mount. It drops the item, plays what followed, and closes when the list
runs out. That forced a real fix in Playlists: the player's mount was derived from `shown` every
render, so anything that dropped the launch row yanked the player off the screen mid-playback. That
was a latent edge (change the view and it happens) and the normal case for this feature. The parent
now snapshots what it launched with, and only onClose takes the player down.

**4 — "Move to <the last list you moved to>".** The submenu is two levels deep and moving a run of
videos into the same list is the shape this gets used in. The remembered id is a per-account pref;
the row is derived from `moveTargets`, which already excludes the list you are looking at, so it
cannot offer a move to where the video already is. Remembered only on a SUCCESSFUL move — a
shortcut to a move that failed is a shortcut to failing again. Absent, not disabled, when there is
nothing to repeat: there is nothing to explain.

**7 — the live-YouTube results can be sorted.** Client-side, over the page already loaded. Not a
new `order=` param, and this is the decision worth recording: the owner's case is "even one page of
results", i.e. reorder what is on screen; a re-query would REPLACE the results, which is not what
"sort these" means; and the zero-quota scrape source has no order parameter at all, so a
server-side control would silently do nothing (or quietly cost 100 quota units) depending on which
source served the page. Only keys a search hit carries are offered — `subscribers`/`priority`
describe a subscription, which a search hit is not, and `shuffle` on a ranked list is noise. Its
own pref, so sorting a search page never re-sorts the library behind it.

Verified in the browser on localdev against the real account: the channel name renders as
`<a href="…&chan=…">` and a ctrl-click / middle-click is left to the browser while a plain click
still opens the channel in-app; removing from the player stepped 1/3 → 1/2 onto the next video and
closed on the last one; the row menu grew "Move to “…”" only after a move and only on a list that
is not the target; and the results sort reordered by views 41.7M → 29.8M → 24.2M → 15.8M. (The
pane's own ctrl-click arrives without the modifier — a harness limit, so the modifier cases were
driven as real MouseEvents.) Scratch playlists and the touched prefs were cleaned up afterwards.

22 vitest, mutation-checked against 14 mutations (no href at all · href without the rest of the
query · every click hijacked in-app · the middle-click arm dropped from the predicate · plain click
no longer in-app · always jumping to the top of the list · the removed item left in the queue · no
clamp when the last item goes · an empty queue not reported as "nothing left" · sort-then-reverse
flipping ties · unknown values first · titles compared numerically · shuffle offered on search
results · direction ignored) — each red on exactly the test that names it. Item 4 has no test of
its own: its rule is enforced by construction, not by a check.
This commit is contained in:
2026-09-10 22:34:12 +02:00
parent 889d2acf52
commit f76a2afbf1
13 changed files with 531 additions and 29 deletions
+42
View File
@@ -1,6 +1,7 @@
// The feed's sort vocabulary. `FeedFilters.sort` is a single wire string ("newest", "views_asc",
// …); the UI thinks in a key + direction. Kept here rather than inside Feed so the active-filter
// chips can label a sort without re-deriving the mapping.
import type { Video } from "./api";
export type SortKey =
| "date"
@@ -56,3 +57,44 @@ export function buildSort(key: SortKey, dir: "asc" | "desc"): string {
if (key === "shuffle" || key === "relevance") return key;
return SORT_MAP[key][dir];
}
// --- Live YouTube search results ----------------------------------------------------------------
//
// The results view had NO sort control at all — the order was the provider's and that was that. It is
// sorted HERE, on the page already loaded, rather than by re-asking YouTube with an `order=` param,
// for three reasons: the owner's case is "even one page of results", i.e. reorder what is on screen;
// a re-query would REPLACE the results, which is not what "sort these" means; and the zero-quota
// scrape source has no order parameter at all, so a server-side control would silently do nothing (or
// quietly cost 100 quota units) depending on which source happened to serve the page.
//
// Only the keys a search result actually carries are offered. `subscribers` and `priority` are
// properties of a SUBSCRIPTION, which a live search result is not, and `shuffle` on a ranked result
// list is noise — an option that cannot mean anything here is worse than no option.
export const YT_SORT_KEYS: SortKey[] = ["relevance", "date", "popular", "duration", "title"];
const YT_SORT_VALUE: Partial<Record<SortKey, (v: Video) => number | string | null | undefined>> = {
date: (v) => (v.published_at ? Date.parse(v.published_at) : null),
popular: (v) => v.view_count,
duration: (v) => v.duration_seconds,
title: (v) => v.title,
};
/** Order a loaded page of live-search results. `relevance` is the provider's own ranking, i.e. the
* order they arrived in — so it is the identity, not a sort.
*
* Results missing the value sink to the END in both directions (a video whose duration YouTube did
* not report is not "the shortest"), and ties keep the provider's ranking, in both directions —
* hence a signed comparator rather than sorting and reversing, which would flip the ties too. */
export function sortYtResults(items: Video[], key: SortKey, dir: "asc" | "desc"): Video[] {
const value = YT_SORT_VALUE[key];
if (!value) return items; // relevance, and anything a stale pref names that we no longer offer
const sign = dir === "asc" ? 1 : -1;
const have = items.filter((v) => value(v) != null);
const missing = items.filter((v) => value(v) == null);
have.sort((a, b) => {
const x = value(a)!;
const y = value(b)!;
return sign * (typeof x === "string" ? x.localeCompare(String(y)) : x - Number(y));
});
return [...have, ...missing];
}
+113
View File
@@ -0,0 +1,113 @@
import { describe, it, expect } from "vitest";
import { sortYtResults, YT_SORT_KEYS, SORT_KEYS } from "./feedSort";
import type { Video } from "./api";
// The live-YouTube results view shipped with no sort control at all: the provider's order was the
// only order, "már 1 lapnyi találatnál is lenne értelme". These pin what the added control means —
// it reorders the page already on screen, and it only offers keys a search result actually carries.
const v = (over: Partial<Video>): Video => ({ id: over.title ?? "?", ...over }) as Video;
const titles = (list: Video[]) => list.map((x) => x.title);
const RESULTS: Video[] = [
v({
title: "b-old-short",
published_at: "2019-01-01T00:00:00Z",
view_count: 10,
duration_seconds: 60,
}),
v({
title: "a-new-long",
published_at: "2026-01-01T00:00:00Z",
view_count: 5,
duration_seconds: 6000,
}),
v({
title: "c-mid-mid",
published_at: "2022-01-01T00:00:00Z",
view_count: 99,
duration_seconds: 600,
}),
];
describe("YT_SORT_KEYS", () => {
it("offers only keys a live search result can be ordered by", () => {
// `subscribers`/`priority` describe a SUBSCRIPTION, which a search hit is not, and `shuffle` on a
// ranked result list is noise. An option that cannot mean anything here is worse than no option.
expect(YT_SORT_KEYS).not.toContain("subscribers");
expect(YT_SORT_KEYS).not.toContain("priority");
expect(YT_SORT_KEYS).not.toContain("shuffle");
// …and every key it does offer is part of the feed's one vocabulary, not a private spelling.
for (const k of YT_SORT_KEYS) {
if (k === "relevance") continue; // the provider's ranking; not in the wire-sort map
expect(SORT_KEYS).toContain(k);
}
});
});
describe("sortYtResults", () => {
it("leaves the provider's ranking alone on `relevance`", () => {
expect(titles(sortYtResults(RESULTS, "relevance", "desc"))).toEqual([
"b-old-short",
"a-new-long",
"c-mid-mid",
]);
});
it("orders by upload date in both directions", () => {
expect(titles(sortYtResults(RESULTS, "date", "desc"))).toEqual([
"a-new-long",
"c-mid-mid",
"b-old-short",
]);
expect(titles(sortYtResults(RESULTS, "date", "asc"))).toEqual([
"b-old-short",
"c-mid-mid",
"a-new-long",
]);
});
it("orders by views and by length", () => {
expect(titles(sortYtResults(RESULTS, "popular", "desc"))[0]).toBe("c-mid-mid");
expect(titles(sortYtResults(RESULTS, "duration", "desc"))[0]).toBe("a-new-long");
expect(titles(sortYtResults(RESULTS, "duration", "asc"))[0]).toBe("b-old-short");
});
it("orders by title alphabetically, not by code point", () => {
const accented = [v({ title: "Zebra" }), v({ title: "Ábel" }), v({ title: "alma" })];
expect(titles(sortYtResults(accented, "title", "asc"))).toEqual(["Ábel", "alma", "Zebra"]);
});
it("sinks results with no value to the END in both directions", () => {
// YouTube does not always report a duration; "unknown" is not "the shortest".
const mixed = [
v({ title: "unknown", duration_seconds: null }),
v({ title: "long", duration_seconds: 900 }),
v({ title: "short", duration_seconds: 60 }),
];
expect(titles(sortYtResults(mixed, "duration", "desc"))).toEqual(["long", "short", "unknown"]);
expect(titles(sortYtResults(mixed, "duration", "asc"))).toEqual(["short", "long", "unknown"]);
});
it("keeps the provider's ranking inside a tie, in both directions", () => {
// Flipping the direction must reverse the RANKING, not shuffle equally-ranked hits around.
const tied = [
v({ title: "first", view_count: 100 }),
v({ title: "second", view_count: 100 }),
v({ title: "third", view_count: 5 }),
];
expect(titles(sortYtResults(tied, "popular", "desc"))).toEqual(["first", "second", "third"]);
expect(titles(sortYtResults(tied, "popular", "asc"))).toEqual(["third", "first", "second"]);
});
it("does not reorder anything for a key it cannot honour", () => {
// A pref stored before this view offered a control can name `shuffle` or `priority`.
expect(titles(sortYtResults(RESULTS, "priority", "desc"))).toEqual(titles(RESULTS));
expect(titles(sortYtResults(RESULTS, "shuffle", "desc"))).toEqual(titles(RESULTS));
});
it("does not mutate the array it was given", () => {
const input = [...RESULTS];
sortYtResults(input, "date", "asc");
expect(titles(input)).toEqual(titles(RESULTS));
});
});
+17
View File
@@ -12,3 +12,20 @@ export function setNavigator(fn: (p: Page) => void): void {
export function navigateTo(p: Page): void {
_navigate?.(p);
}
/** Should this click be handled IN-APP, or left to the browser?
*
* An anchor that ALSO navigates in-app has to keep the browser's own gestures working — Ctrl/Cmd-click
* and Shift-click open a tab/window, and middle-click arrives as `auxclick` and never reaches React's
* `onClick` at all (the `button !== 0` arm is belt-and-braces for synthetic events). Otherwise the
* thing looks like a link and behaves like a button, which is what the channel name in the player did.
* One predicate, so a second such link cannot forget an arm. */
export function isPlainLeftClick(e: {
metaKey: boolean;
ctrlKey: boolean;
shiftKey: boolean;
altKey: boolean;
button: number;
}): boolean {
return !e.metaKey && !e.ctrlKey && !e.shiftKey && !e.altKey && e.button === 0;
}
+53
View File
@@ -0,0 +1,53 @@
import { describe, it, expect } from "vitest";
import { afterRemoval } from "./playerQueue";
import type { Video } from "./api";
// "Delete" from inside the player (the owner asked for it: today the only way to drop a video from a
// playlist is the Playlists module). The removal itself is the parent's — this is the half the player
// owns, because only the player knows which item is playing: its launch queue is frozen at mount, so
// nothing outside can take the item out for it.
const v = (id: string): Video => ({ id }) as Video;
const ids = (q: Video[]) => q.map((x) => x.id);
describe("afterRemoval", () => {
it("plays the item that followed the one you removed", () => {
const q = [v("a"), v("b"), v("c")];
const next = afterRemoval(q, 1, "b");
expect(next).not.toBeNull();
expect(next!.playingId).toBe("c");
expect(ids(next!.queue)).toEqual(["a", "c"]);
});
it("steps back to the new last item when the last one goes", () => {
// There is no "next" after the end — falling off it would leave the player on nothing.
const q = [v("a"), v("b"), v("c")];
const next = afterRemoval(q, 2, "c");
expect(next!.playingId).toBe("b");
expect(ids(next!.queue)).toEqual(["a", "b"]);
});
it("drops the removed item from the queue, so prev/next cannot step back onto it", () => {
const q = [v("a"), v("b"), v("c")];
expect(ids(afterRemoval(q, 0, "a")!.queue)).not.toContain("a");
});
it("says nothing is left when the list runs out (the player closes)", () => {
expect(afterRemoval([v("only")], 0, "only")).toBeNull();
expect(afterRemoval([], 0, "gone")).toBeNull();
expect(afterRemoval(undefined, 0, "gone")).toBeNull();
});
it("survives an index that no longer points inside the shortened queue", () => {
// The index comes from the frozen queue; nothing guarantees it is still in range afterwards.
const q = [v("a"), v("b")];
const next = afterRemoval(q, 5, "b");
expect(next!.playingId).toBe("a");
});
it("is a no-op-shaped answer when the id is not in the queue at all", () => {
const q = [v("a"), v("b")];
const next = afterRemoval(q, 0, "not-here");
expect(ids(next!.queue)).toEqual(["a", "b"]);
expect(next!.playingId).toBe("a");
});
});
+30
View File
@@ -0,0 +1,30 @@
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;
}
/** `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), clamped to the end when the last item goes. */
export function afterRemoval(
queue: Video[] | undefined,
index: number,
victimId: string,
): AfterRemoval | null {
const rest = (queue ?? []).filter((v) => v.id !== victimId);
if (rest.length === 0) return null;
const next = rest[Math.min(Math.max(index, 0), rest.length - 1)]!;
return { queue: rest, playingId: next.id };
}