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
@@ -0,0 +1,73 @@
// @vitest-environment jsdom
//
// Reported from daily use: middle-clicking the channel name in the YT player did nothing. It looked
// like a link — the YouTube icon beside it IS one — but it was a `<button>`, so the browser had no
// href to open a tab with. These pin the two halves the fix has to keep true at once: there is a real
// href pointing where the in-app navigation would go, AND a plain left click still takes the in-app
// path (which does more than navigate — the player has to close and reclaim its history entry first).
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen, cleanup } from "@testing-library/react";
import ChannelNameLink from "./ChannelNameLink";
const CH = "UCsXVk37bltHxD1rDPwtNM8Q";
beforeEach(() => {
// No global auto-cleanup in this project's vitest setup, and several cases render more than once.
cleanup();
window.history.replaceState({}, "", "/?page=feed&show=unwatched");
});
const renderLink = () => {
const onOpenInApp = vi.fn();
render(<ChannelNameLink channelId={CH} title="LEMMiNO" onOpenInApp={onOpenInApp} />);
return { link: screen.getByText("LEMMiNO") as HTMLAnchorElement, onOpenInApp };
};
/** Dispatch a click and report whether the component prevented it (i.e. handled it in-app).
* The document-level guard runs AFTER React's handler and swallows the default, so an
* un-prevented click on a real href does not make jsdom log "Not implemented: navigation". */
const clickWasHandled = (el: HTMLElement, init: MouseEventInit = {}): boolean => {
let prevented = false;
const guard = (e: Event) => {
prevented = e.defaultPrevented;
e.preventDefault();
};
document.addEventListener("click", guard);
el.dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true, ...init }));
document.removeEventListener("click", guard);
return prevented;
};
describe("ChannelNameLink", () => {
it("is an anchor whose href opens this channel on the current page", () => {
const { link } = renderLink();
expect(link.tagName).toBe("A");
// The same URL openChannel pushes: the current page's query plus ?chan=<id>.
expect(link.getAttribute("href")).toBe(`/?page=feed&show=unwatched&chan=${CH}`);
});
it("takes the in-app path on a plain left click, and suppresses the browser navigation", () => {
const { link, onOpenInApp } = renderLink();
expect(clickWasHandled(link, { button: 0 })).toBe(true);
expect(onOpenInApp).toHaveBeenCalledTimes(1);
});
it.each([
["middle-click", { button: 1 }], // the reported gesture
["ctrl-click", { ctrlKey: true }],
["cmd-click", { metaKey: true }],
["shift-click", { shiftKey: true }],
])("leaves %s to the browser", (_name, mod) => {
const { link, onOpenInApp } = renderLink();
// Neither hijacked in-app nor prevented — the href is left to do its job.
expect(clickWasHandled(link, mod)).toBe(false);
expect(onOpenInApp).not.toHaveBeenCalled();
});
it("does not lose the rest of the address bar", () => {
window.history.replaceState({}, "", "/?page=channels&q=abc");
const { link } = renderLink();
expect(link.getAttribute("href")).toContain("page=channels");
expect(link.getAttribute("href")).toContain("q=abc");
});
});
@@ -0,0 +1,37 @@
import { isPlainLeftClick } from "../lib/nav";
import { urlWithParams } from "../lib/urlState";
// A channel name that is a REAL link. The player showed it as a `<button>`, so the browser's own
// gestures did nothing on it: middle-click and Ctrl/Cmd-click opened no tab, on a thing that looks
// exactly like a link (the YouTube icon right next to it IS an anchor and does open one).
//
// The href is the same URL `openChannel` pushes — `?chan=<id>` on the current page — so a new tab
// lands where the in-app navigation would have. A plain left click is still handled in-app, because
// the SPA path does more than navigate (the player has to close first and reclaim its own
// Back-to-close history entry; see closeThenNavigate).
export default function ChannelNameLink({
channelId,
title,
onOpenInApp,
className,
}: {
channelId: string;
title: string | null | undefined;
/** The in-app navigation, run only for a plain left click. */
onOpenInApp: () => void;
className?: string;
}) {
return (
<a
href={urlWithParams({ chan: channelId })}
onClick={(e) => {
if (!isPlainLeftClick(e)) return; // let the browser open its tab/window
e.preventDefault();
onOpenInApp();
}}
className={className}
>
{title}
</a>
);
}
+33 -2
View File
@@ -31,9 +31,12 @@ import {
parseSort, parseSort,
SORT_DEFAULT_DIR, SORT_DEFAULT_DIR,
SORT_KEYS, SORT_KEYS,
sortYtResults,
YT_SORT_KEYS,
type SortKey, type SortKey,
} from "../lib/feedSort"; } from "../lib/feedSort";
import { type SortOption } from "../lib/columnSort"; import { type SortOption, type SortState } from "../lib/columnSort";
import { usePref } from "../lib/usePref";
import { FEED_VIEWS, type FeedView } from "../lib/feedView"; import { FEED_VIEWS, type FeedView } from "../lib/feedView";
import { clearedFilters, useActiveFilters } from "../lib/useActiveFilters"; import { clearedFilters, useActiveFilters } from "../lib/useActiveFilters";
import ActiveFilterChips from "./ActiveFilterChips"; import ActiveFilterChips from "./ActiveFilterChips";
@@ -153,6 +156,13 @@ export default function Feed({
// How many live-search results to gather (the count selector replaces a manual "load more"; // How many live-search results to gather (the count selector replaces a manual "load more";
// the free scrape source pages through continuations until this many are collected). // the free scrape source pages through continuations until this many are collected).
const [ytCount, setYtCount] = useState(20); const [ytCount, setYtCount] = useState(20);
// How to order the loaded results. Its OWN pref, not `filters.sort`: sorting a live-search page
// must not silently re-sort the library you go back to. Default `relevance` — the provider's
// ranking, which is what the view always showed before it had a control.
const [ytSort, setYtSort] = usePref<NonNullable<SortState>>("ytSearchSort", {
key: "relevance",
dir: "desc",
});
// Debounce only the search term feeding the queries: the input still updates instantly (it // Debounce only the search term feeding the queries: the input still updates instantly (it
// owns filters.q), but the feed/count keys settle after a pause, so typing doesn't refetch — // owns filters.q), but the feed/count keys settle after a pause, so typing doesn't refetch —
@@ -378,7 +388,11 @@ export default function Feed({
if (ytActive) { if (ytActive) {
const ytLoaded: Video[] = (ytQuery.data?.pages ?? []).flatMap((p) => p.items); const ytLoaded: Video[] = (ytQuery.data?.pages ?? []).flatMap((p) => p.items);
loadedRef.current = ytLoaded; loadedRef.current = ytLoaded;
const ytItems: Video[] = withOverrides(ytLoaded); const ytItems: Video[] = sortYtResults(
withOverrides(ytLoaded),
ytSort.key as SortKey,
ytSort.dir,
);
const ytPlayerQueue: Video[] = ytItems.filter((v) => v.status !== "hidden"); const ytPlayerQueue: Video[] = ytItems.filter((v) => v.status !== "hidden");
const ytError = const ytError =
ytQuery.error instanceof HttpError ytQuery.error instanceof HttpError
@@ -430,6 +444,23 @@ export default function Feed({
))} ))}
</select> </select>
</label> </label>
{/* Sorts the results already on screen (see sortYtResults) — it never re-queries, so it
costs nothing and works the same on both search sources. */}
<SortControl
options={YT_SORT_KEYS.map((k) => ({
key: k,
label: t("feed.sortKey." + k),
directionless: DIRECTIONLESS.includes(k),
defaultDir: DIRECTIONLESS.includes(k)
? undefined
: SORT_DEFAULT_DIR[k as Exclude<SortKey, "shuffle" | "relevance">],
}))}
sort={ytSort}
onChange={(next) => setYtSort(next ?? { key: "relevance", dir: "desc" })}
label={t("feed.sortLabel")}
ascLabel={t("feed.dirAsc")}
descLabel={t("feed.dirDesc")}
/>
</div> </div>
{ytItems.length > 0 && ( {ytItems.length > 0 && (
<div className="flex items-center gap-2 flex-wrap mt-2 text-xs text-muted"> <div className="flex items-center gap-2 flex-wrap mt-2 text-xs text-muted">
+56 -6
View File
@@ -24,6 +24,7 @@ import {
Minimize, Minimize,
Minimize2, Minimize2,
Settings, Settings,
Trash2,
Volume2, Volume2,
VolumeX, VolumeX,
X, X,
@@ -46,6 +47,8 @@ import { renderDescription } from "../lib/descriptionLinks";
import { fullscreenElement } from "../lib/fullscreen"; import { fullscreenElement } from "../lib/fullscreen";
import { dispatch, isActivationTarget, useKeyScope } from "../lib/keymap"; import { dispatch, isActivationTarget, useKeyScope } from "../lib/keymap";
import { closeThenNavigate, useBackToClose } from "../lib/history"; import { closeThenNavigate, useBackToClose } from "../lib/history";
import ChannelNameLink from "./ChannelNameLink";
import { afterRemoval } from "../lib/playerQueue";
import { import {
useNativeControlsYield, useNativeControlsYield,
useYtBigView, useYtBigView,
@@ -82,6 +85,7 @@ export default function PlayerModal({
startIndex, startIndex,
onClose, onClose,
onState, onState,
onRemoveFromList,
}: { }: {
video: Video; video: Video;
// Where to start the opened video: a number of seconds (0 = Restart), or null/undefined // Where to start the opened video: a number of seconds (0 = Restart), or null/undefined
@@ -94,6 +98,11 @@ export default function PlayerModal({
startIndex?: number; startIndex?: number;
onClose: () => void; onClose: () => void;
onState: (id: string, status: VideoStatus) => void; onState: (id: string, status: VideoStatus) => void;
/** Remove the playing video from the list it was opened from (the Playlists module passes its own
* `removeItem`; the feed passes nothing — there is no list to remove it from). Returns whether it
* actually happened, because the CONSEQUENCE is the player's: its launch queue is frozen, so it
* drops the item itself and steps to the next one, closing when the list runs out. */
onRemoveFromList?: (video: Video) => Promise<boolean>;
// Open the active video's channel page in-app (closes the player first). The small external // Open the active video's channel page in-app (closes the player first). The small external
// icon next to the name keeps the open-on-YouTube behaviour. // icon next to the name keeps the open-on-YouTube behaviour.
}) { }) {
@@ -110,7 +119,11 @@ export default function PlayerModal({
// wrap back to the first item once earlier items dropped out). Snapshot once at mount via // wrap back to the first item once earlier items dropped out). Snapshot once at mount via
// useState's lazy initializer and ignore later prop changes. Items that turn out gone/ // useState's lazy initializer and ignore later prop changes. Items that turn out gone/
// non-embeddable still surface the existing playback-error overlay when reached. // non-embeddable still surface the existing playback-error overlay when reached.
const [queue] = useState(() => queueProp); //
// The setter exists for exactly ONE writer: removing the playing video from its playlist (below).
// That is not the feed changing under us — it is this player's own action — and leaving the item
// in the frozen queue would let prev/next step back onto a video that no longer exists.
const [queue, setQueue] = useState(() => queueProp);
// Precise upload date shown inline after the relative time (e.g. "9 yr ago · 12 Jan 2017"). // Precise upload date shown inline after the relative time (e.g. "9 yr ago · 12 Jan 2017").
const fullDate = (d: string | null | undefined) => formatDate(d, i18n.language); const fullDate = (d: string | null | undefined) => formatDate(d, i18n.language);
// Track the playing item by id (not a frozen index) so it survives the queue changing // Track the playing item by id (not a frozen index) so it survives the queue changing
@@ -245,6 +258,32 @@ export default function PlayerModal({
const { queue: q, index: i } = navRef.current; const { queue: q, index: i } = navRef.current;
if (q && i < q.length - 1) setPlayingId(q[i + 1]!.id); if (q && i < q.length - 1) setPlayingId(q[i + 1]!.id);
}; };
// "Remove from this playlist", offered right where you are watching the thing you want gone. The
// parent owns the removal (confirm + API + caches); the player owns what happens NEXT, because it
// is the only side that knows which item is playing — its queue is frozen at mount, so the parent
// cannot tell. Step to the item that followed (clamped when the last one goes), and close when the
// list runs out. Guarded against a double press while the confirm/request is in flight.
const [removing, setRemoving] = useState(false);
const removeFromList = async () => {
if (!onRemoveFromList || removing) return;
const victim = active;
setRemoving(true);
let done = false;
try {
done = await onRemoveFromList(victim);
} finally {
setRemoving(false);
}
if (!done) return; // cancelled at the confirm, or the request failed — leave playback alone
const next = afterRemoval(queue, index, victim.id);
if (!next) {
onClose(); // that was the last one
return;
}
setQueue(next.queue);
setPlayingId(next.playingId);
};
// Relative seek within the current video (plain Arrow keys), matching YouTube's ±5s. // Relative seek within the current video (plain Arrow keys), matching YouTube's ±5s.
const nudgeSeek = (delta: number) => { const nudgeSeek = (delta: number) => {
const p = playerRef.current; const p = playerRef.current;
@@ -761,8 +800,10 @@ export default function PlayerModal({
) )
) : ( ) : (
<div className="flex items-center gap-1.5 shrink-0 min-w-0"> <div className="flex items-center gap-1.5 shrink-0 min-w-0">
<button <ChannelNameLink
onClick={() => { channelId={active.channel_id}
title={active.channel_title}
onOpenInApp={() => {
const cid = active.channel_id; const cid = active.channel_id;
const cname = active.channel_title ?? undefined; const cname = active.channel_title ?? undefined;
// Close first, open the channel only once this player's own Back-to-close // Close first, open the channel only once this player's own Back-to-close
@@ -772,9 +813,7 @@ export default function PlayerModal({
closeThenNavigate(onClose, () => openChannel(cid, cname)); closeThenNavigate(onClose, () => openChannel(cid, cname));
}} }}
className="font-medium hover:text-accent truncate text-left" className="font-medium hover:text-accent truncate text-left"
> />
{active.channel_title}
</button>
<a <a
href={active.channel_url} href={active.channel_url}
target="_blank" target="_blank"
@@ -863,6 +902,17 @@ export default function PlayerModal({
className="shrink-0 inline-flex items-center justify-center w-9 h-9 rounded-lg text-muted hover:text-fg hover:bg-surface border border-border transition" className="shrink-0 inline-flex items-center justify-center w-9 h-9 rounded-lg text-muted hover:text-fg hover:bg-surface border border-border transition"
/> />
)} )}
{!navigated && onRemoveFromList && (
<button
onClick={() => void removeFromList()}
disabled={removing}
title={t("playlists.removeItem")}
aria-label={t("playlists.removeItem")}
className="shrink-0 inline-flex items-center justify-center w-9 h-9 rounded-lg text-muted hover:text-danger hover:bg-surface border border-border transition disabled:opacity-50"
>
<Trash2 className="w-4 h-4" />
</button>
)}
{!navigated && ( {!navigated && (
<button <button
onClick={() => setWatched(!watched)} onClick={() => setWatched(!watched)}
+73 -20
View File
@@ -28,6 +28,7 @@ import {
Check, Check,
ExternalLink, ExternalLink,
FolderInput, FolderInput,
History,
GripVertical, GripVertical,
ListPlus, ListPlus,
Pencil, Pencil,
@@ -59,6 +60,7 @@ import { useConfirm } from "./ConfirmProvider";
import { usePlaylistActions } from "./usePlaylistActions"; import { usePlaylistActions } from "./usePlaylistActions";
import { usePlaylists } from "./PlaylistsProvider"; import { usePlaylists } from "./PlaylistsProvider";
import { useMe } from "../lib/useMe"; import { useMe } from "../lib/useMe";
import { usePref } from "../lib/usePref";
import { import {
ColumnHead, ColumnHead,
alignClass, alignClass,
@@ -214,10 +216,24 @@ export default function Playlists() {
// re-confirms our own reorder doesn't wipe the undo history — we reset history only when // re-confirms our own reorder doesn't wipe the undo history — we reset history only when
// the actual set of items changes (different playlist, or an add/remove). // the actual set of items changes (different playlist, or an add/remove).
const lastSetRef = useRef<string>(""); const lastSetRef = useRef<string>("");
// The open player is tracked by video ID, not by a position: `shown` is derived, so a refetch or // What the player was LAUNCHED with, snapshotted when it opens: the video, the queue and the index
// a change of view would otherwise slide a different video under the index — or unmount the player // inside it. `shown` is derived (sort / filter / view), and the player freezes its queue and owns
// mid-playback. PlayerModal already tracks its own queue by id; this is the parent half of that. // its own cursor from mount — so deriving the mount from `shown` every render meant anything that
const [playingId, setPlayingId] = useState<string | null>(null); // dropped the launch row yanked the player off the screen mid-playback. That was a latent edge
// (a view change) until "remove this from the playlist" moved INSIDE the player, where it is the
// normal case. Only onClose takes the player down now.
const [playing, setPlaying] = useState<{
video: Video;
queue: Video[];
index: number;
} | 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
// preference like every other remembered choice. Null until the first move of this account's life.
const [lastMoveTargetId, setLastMoveTargetId] = usePref<number | null>(
"lastMovePlaylistId",
null,
);
// ONE context menu for the whole table, not one popover per <tr> (see ui/ContextMenu): the row it // ONE context menu for the whole table, not one popover per <tr> (see ui/ContextMenu): the row it
// belongs to is `rowMenu.target`, and the panel anchors to the pointer. // belongs to is `rowMenu.target`, and the panel anchors to the pointer.
const rowMenu = useContextMenu<Video>(); const rowMenu = useContextMenu<Video>();
@@ -273,10 +289,23 @@ export default function Playlists() {
const [pushing, setPushing] = useState(false); const [pushing, setPushing] = useState(false);
const [reverting, setReverting] = useState(false); const [reverting, setReverting] = useState(false);
const onPlay = useCallback((v: Video) => setPlayingId(v.id), []); // Stable (the memoized row callbacks depend on it), so it reads `shown` through the ref rather
const onRemove = useCallback((v: Video) => { // than closing over it.
removeRef.current(v); const openPlayer = useCallback((id: string) => {
const queue = shownRef.current;
const index = queue.findIndex((v) => v.id === id);
if (index < 0) return;
setPlaying({ video: queue[index]!, queue, index });
}, []); }, []);
const onPlay = useCallback((v: Video) => openPlayer(v.id), [openPlayer]);
const onRemove = useCallback((v: Video) => {
void removeRef.current(v);
}, []);
// The player's "remove from this playlist" goes through the SAME removeItem the row menu and the
// inline ✕ use — one confirm, one optimistic update, one undo-stack rebase, no second copy to
// drift. It answers whether the removal actually happened, because the player has to decide
// afterwards whether to step to the next item or close.
const removeFromPlayer = useCallback((v: Video) => removeRef.current(v), []);
const storedIndex = useMemo(() => { const storedIndex = useMemo(() => {
const m = new Map(items.map((v, i) => [v.id, i])); const m = new Map(items.map((v, i) => [v.id, i]));
@@ -478,15 +507,17 @@ export default function Playlists() {
order.set(arrayMove(items, oldIndex, newIndex)); // onApply persists order.set(arrayMove(items, oldIndex, newIndex)); // onApply persists
} }
async function removeItem(video: Video) { /** @returns whether the item was actually removed — the player needs to know, because it must
if (selectedId == null) return; * step to the next item (or close) only when the removal really happened. */
async function removeItem(video: Video): Promise<boolean> {
if (selectedId == null) return false;
const ok = await confirm({ const ok = await confirm({
title: t("playlists.removeItem"), title: t("playlists.removeItem"),
message: t("playlists.confirmRemove", { title: video.title }), message: t("playlists.confirmRemove", { title: video.title }),
confirmLabel: t("playlists.removeItem"), confirmLabel: t("playlists.removeItem"),
danger: true, danger: true,
}); });
if (!ok) return; if (!ok) return false;
// A removal isn't itself undoable, but it no longer THROWS AWAY the reorder history: rebasing // A removal isn't itself undoable, but it no longer THROWS AWAY the reorder history: rebasing
// drops the item from every snapshot, so the stack stays valid for what's left. // drops the item from every snapshot, so the stack stays valid for what's left.
const next = items.filter((v) => v.id !== video.id); const next = items.filter((v) => v.id !== video.id);
@@ -495,10 +526,12 @@ export default function Playlists() {
try { try {
await api.removeFromPlaylist(selectedId, video.id); await api.removeFromPlaylist(selectedId, video.id);
refreshAll(); refreshAll();
return true;
} catch { } catch {
// The optimistic removal above didn't stick — resync from the server to restore the row. // The optimistic removal above didn't stick — resync from the server to restore the row.
// api.req already surfaced the error to the user via the global error dialog. // api.req already surfaced the error to the user via the global error dialog.
refreshAll(); refreshAll();
return false;
} }
} }
// Kept in a ref so the memoized column callbacks don't rebuild on every state change. // Kept in a ref so the memoized column callbacks don't rebuild on every state change.
@@ -516,6 +549,9 @@ export default function Playlists() {
// ONE request. The two-call version (add there, remove here) leaves the video in BOTH lists // 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. // whenever the second call fails — see the endpoint's docstring.
await api.moveToPlaylist(selectedId, video.id, target.id); await api.moveToPlaylist(selectedId, video.id, target.id);
// Remembered only on SUCCESS: a shortcut that points at a move which failed would be a
// shortcut to failing again.
setLastMoveTargetId(target.id);
notify({ notify({
level: "success", level: "success",
message: t("playlists.movedToast", { title: video.title, name: plName(target) }), message: t("playlists.movedToast", { title: video.title, name: plName(target) }),
@@ -575,7 +611,7 @@ export default function Playlists() {
// Stable identity: PlayerModal's keydown/scroll-lock effect depends on it, so a fresh closure // Stable identity: PlayerModal's keydown/scroll-lock effect depends on it, so a fresh closure
// every render re-subscribed those listeners and re-ran focusModal() under the open player. // every render re-subscribed those listeners and re-ran focusModal() under the open player.
const closePlayer = useCallback(() => setPlayingId(null), []); const closePlayer = useCallback(() => setPlaying(null), []);
// Name the current view in the user's terms: "Title ↑", "grouped by channel", or both. // Name the current view in the user's terms: "Title ↑", "grouped by channel", or both.
const sortLabel = const sortLabel =
@@ -601,11 +637,15 @@ export default function Playlists() {
: filterActive : filterActive
? t("playlists.dragBlockedFilter") ? t("playlists.dragBlockedFilter")
: t("playlists.dragBlockedSort"); : t("playlists.dragBlockedSort");
const playingIndex = playingId ? shown.findIndex((v) => v.id === playingId) : -1;
// The row menu, built from the row the user right-clicked. Same item shape (and therefore same // The row menu, built from the row the user right-clicked. Same item shape (and therefore same
// rows, same paint, same keyboard model) as any ⋯ ActionMenu in the app. // rows, same paint, same keyboard model) as any ⋯ ActionMenu in the app.
const moveTargets = playlists.filter((p) => p.id !== selectedId); const moveTargets = playlists.filter((p) => p.id !== selectedId);
// A one-click repeat of the last move — the submenu is two levels deep, and moving a run of videos
// into the same list is the shape this actually gets used in. Absent (not disabled) when there is
// nothing to repeat: before the first move, or when the remembered list IS the one you are looking
// at, which is not a move at all. Nothing to explain, so nothing to leave behind greyed out.
const recentMoveTarget = moveTargets.find((p) => p.id === lastMoveTargetId);
const storedOrderEditable = canEdit && !viewActive; const storedOrderEditable = canEdit && !viewActive;
const ctxRow = rowMenu.target; const ctxRow = rowMenu.target;
const ctxItems: ActionMenuItem[] = !ctxRow const ctxItems: ActionMenuItem[] = !ctxRow
@@ -615,17 +655,29 @@ export default function Playlists() {
id: "play", id: "play",
label: t("card.play"), label: t("card.play"),
icon: <Play className="w-4 h-4" />, icon: <Play className="w-4 h-4" />,
onSelect: () => setPlayingId(ctxRow.id), onSelect: () => openPlayer(ctxRow.id),
}, },
...comfortPin.item(pinVideo(ctxRow.id), ctxRow.title ?? ""), ...comfortPin.item(pinVideo(ctxRow.id), ctxRow.title ?? ""),
...(canEdit ...(canEdit
? ([ ? ([
...(recentMoveTarget
? [
{
id: "move-recent",
label: t("playlists.moveToRecent", { name: plName(recentMoveTarget) }),
icon: <History className="w-4 h-4" />,
separatorBefore: true,
onSelect: () => void moveItemTo(ctxRow, recentMoveTarget),
},
]
: []),
moveTargets.length moveTargets.length
? { ? {
id: "move", id: "move",
label: t("playlists.moveTo"), label: t("playlists.moveTo"),
icon: <FolderInput className="w-4 h-4" />, icon: <FolderInput className="w-4 h-4" />,
separatorBefore: true, // Only ONE separator opens the move block, wherever it starts.
separatorBefore: !recentMoveTarget,
items: moveTargets.map((pl) => ({ items: moveTargets.map((pl) => ({
id: `move-${pl.id}`, id: `move-${pl.id}`,
label: plName(pl), label: plName(pl),
@@ -647,7 +699,7 @@ export default function Playlists() {
id: "move", id: "move",
label: t("playlists.moveTo"), label: t("playlists.moveTo"),
icon: <FolderInput className="w-4 h-4" />, icon: <FolderInput className="w-4 h-4" />,
separatorBefore: true, separatorBefore: !recentMoveTarget,
disabled: true, disabled: true,
title: t("playlists.moveNoTargets"), title: t("playlists.moveNoTargets"),
onSelect: () => {}, onSelect: () => {},
@@ -780,7 +832,7 @@ export default function Playlists() {
</span> </span>
)} )}
<button <button
onClick={() => shown.length && setPlayingId(shown[0]!.id)} onClick={() => shown.length && openPlayer(shown[0]!.id)}
disabled={!shown.length} 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" 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"
> >
@@ -957,15 +1009,16 @@ export default function Playlists() {
{/* One surface for the whole table, outside the rows — it anchors to the pointer, not the row. */} {/* 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} /> <ContextMenu menu={rowMenu} ariaLabel={t("playlists.rowMenu")} items={ctxItems} />
{playingIndex >= 0 && ( {playing && (
<Suspense fallback={null}> <Suspense fallback={null}>
<PlayerModal <PlayerModal
video={shown[playingIndex]!} video={playing.video}
queue={shown} queue={playing.queue}
startIndex={playingIndex} startIndex={playing.index}
startAt={null} startAt={null}
onClose={closePlayer} onClose={closePlayer}
onState={playerState} onState={playerState}
onRemoveFromList={canEdit ? removeFromPlayer : undefined}
/> />
</Suspense> </Suspense>
)} )}
+2 -1
View File
@@ -22,6 +22,7 @@ import clsx from "clsx";
import type { Video, VideoStatus } from "../lib/api"; import type { Video, VideoStatus } from "../lib/api";
import { FEED_VIEW_SPEC, type FeedView } from "../lib/feedView"; import { FEED_VIEW_SPEC, type FeedView } from "../lib/feedView";
import { formatDate, formatDuration, formatViews, relativeTime } from "../lib/format"; import { formatDate, formatDuration, formatViews, relativeTime } from "../lib/format";
import { isPlainLeftClick } from "../lib/nav";
function Actions({ function Actions({
video, video,
@@ -124,7 +125,7 @@ function openInApp(
video: Video, video: Video,
onOpen?: (v: Video, startAt?: number | null) => void, onOpen?: (v: Video, startAt?: number | null) => void,
): void { ): void {
if (!onOpen || e.metaKey || e.ctrlKey || e.shiftKey || e.altKey || e.button !== 0) return; if (!onOpen || !isPlainLeftClick(e)) return;
e.preventDefault(); e.preventDefault();
onOpen(video); onOpen(video);
} }
@@ -72,6 +72,7 @@
"colDuration": "Length", "colDuration": "Length",
"rowMenu": "Video actions", "rowMenu": "Video actions",
"moveTo": "Move to another list", "moveTo": "Move to another list",
"moveToRecent": "Move to “{{name}}”",
"moveNoTargets": "There is no other playlist to move it to yet.", "moveNoTargets": "There is no other playlist to move it to yet.",
"movedToast": "“{{title}}” moved to “{{name}}” ✓", "movedToast": "“{{title}}” moved to “{{name}}” ✓",
"moveFailed": "Couldn't move the video.", "moveFailed": "Couldn't move the video.",
@@ -72,6 +72,7 @@
"colDuration": "Hossz", "colDuration": "Hossz",
"rowMenu": "Videó műveletek", "rowMenu": "Videó műveletek",
"moveTo": "Áthelyezés másik listába", "moveTo": "Áthelyezés másik listába",
"moveToRecent": "Áthelyezés ide: „{{name}}”",
"moveNoTargets": "Még nincs másik lejátszási lista, ahová áthelyezhetnéd.", "moveNoTargets": "Még nincs másik lejátszási lista, ahová áthelyezhetnéd.",
"movedToast": "„{{title}}” áthelyezve ide: „{{name}}” ✓", "movedToast": "„{{title}}” áthelyezve ide: „{{name}}” ✓",
"moveFailed": "Nem sikerült áthelyezni a videót.", "moveFailed": "Nem sikerült áthelyezni a videót.",
+42
View File
@@ -1,6 +1,7 @@
// The feed's sort vocabulary. `FeedFilters.sort` is a single wire string ("newest", "views_asc", // 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 // …); 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. // chips can label a sort without re-deriving the mapping.
import type { Video } from "./api";
export type SortKey = export type SortKey =
| "date" | "date"
@@ -56,3 +57,44 @@ export function buildSort(key: SortKey, dir: "asc" | "desc"): string {
if (key === "shuffle" || key === "relevance") return key; if (key === "shuffle" || key === "relevance") return key;
return SORT_MAP[key][dir]; 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 { export function navigateTo(p: Page): void {
_navigate?.(p); _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 };
}