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:
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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">
|
||||||
|
|||||||
@@ -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)}
|
||||||
|
|||||||
@@ -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>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -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.",
|
||||||
|
|||||||
@@ -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];
|
||||||
|
}
|
||||||
|
|||||||
@@ -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));
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -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");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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 };
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user