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,
|
||||
SORT_DEFAULT_DIR,
|
||||
SORT_KEYS,
|
||||
sortYtResults,
|
||||
YT_SORT_KEYS,
|
||||
type SortKey,
|
||||
} 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 { clearedFilters, useActiveFilters } from "../lib/useActiveFilters";
|
||||
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";
|
||||
// the free scrape source pages through continuations until this many are collected).
|
||||
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
|
||||
// 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) {
|
||||
const ytLoaded: Video[] = (ytQuery.data?.pages ?? []).flatMap((p) => p.items);
|
||||
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 ytError =
|
||||
ytQuery.error instanceof HttpError
|
||||
@@ -430,6 +444,23 @@ export default function Feed({
|
||||
))}
|
||||
</select>
|
||||
</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>
|
||||
{ytItems.length > 0 && (
|
||||
<div className="flex items-center gap-2 flex-wrap mt-2 text-xs text-muted">
|
||||
|
||||
@@ -24,6 +24,7 @@ import {
|
||||
Minimize,
|
||||
Minimize2,
|
||||
Settings,
|
||||
Trash2,
|
||||
Volume2,
|
||||
VolumeX,
|
||||
X,
|
||||
@@ -46,6 +47,8 @@ import { renderDescription } from "../lib/descriptionLinks";
|
||||
import { fullscreenElement } from "../lib/fullscreen";
|
||||
import { dispatch, isActivationTarget, useKeyScope } from "../lib/keymap";
|
||||
import { closeThenNavigate, useBackToClose } from "../lib/history";
|
||||
import ChannelNameLink from "./ChannelNameLink";
|
||||
import { afterRemoval } from "../lib/playerQueue";
|
||||
import {
|
||||
useNativeControlsYield,
|
||||
useYtBigView,
|
||||
@@ -82,6 +85,7 @@ export default function PlayerModal({
|
||||
startIndex,
|
||||
onClose,
|
||||
onState,
|
||||
onRemoveFromList,
|
||||
}: {
|
||||
video: Video;
|
||||
// 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;
|
||||
onClose: () => 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
|
||||
// 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
|
||||
// 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.
|
||||
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").
|
||||
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
|
||||
@@ -245,6 +258,32 @@ export default function PlayerModal({
|
||||
const { queue: q, index: i } = navRef.current;
|
||||
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.
|
||||
const nudgeSeek = (delta: number) => {
|
||||
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">
|
||||
<button
|
||||
onClick={() => {
|
||||
<ChannelNameLink
|
||||
channelId={active.channel_id}
|
||||
title={active.channel_title}
|
||||
onOpenInApp={() => {
|
||||
const cid = active.channel_id;
|
||||
const cname = active.channel_title ?? undefined;
|
||||
// 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));
|
||||
}}
|
||||
className="font-medium hover:text-accent truncate text-left"
|
||||
>
|
||||
{active.channel_title}
|
||||
</button>
|
||||
/>
|
||||
<a
|
||||
href={active.channel_url}
|
||||
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"
|
||||
/>
|
||||
)}
|
||||
{!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 && (
|
||||
<button
|
||||
onClick={() => setWatched(!watched)}
|
||||
|
||||
@@ -28,6 +28,7 @@ import {
|
||||
Check,
|
||||
ExternalLink,
|
||||
FolderInput,
|
||||
History,
|
||||
GripVertical,
|
||||
ListPlus,
|
||||
Pencil,
|
||||
@@ -59,6 +60,7 @@ import { useConfirm } from "./ConfirmProvider";
|
||||
import { usePlaylistActions } from "./usePlaylistActions";
|
||||
import { usePlaylists } from "./PlaylistsProvider";
|
||||
import { useMe } from "../lib/useMe";
|
||||
import { usePref } from "../lib/usePref";
|
||||
import {
|
||||
ColumnHead,
|
||||
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
|
||||
// the actual set of items changes (different playlist, or an add/remove).
|
||||
const lastSetRef = useRef<string>("");
|
||||
// The open player is tracked by video ID, not by a position: `shown` is derived, so a refetch or
|
||||
// a change of view would otherwise slide a different video under the index — or unmount the player
|
||||
// mid-playback. PlayerModal already tracks its own queue by id; this is the parent half of that.
|
||||
const [playingId, setPlayingId] = useState<string | null>(null);
|
||||
// What the player was LAUNCHED with, snapshotted when it opens: the video, the queue and the index
|
||||
// inside it. `shown` is derived (sort / filter / view), and the player freezes its queue and owns
|
||||
// its own cursor from mount — so deriving the mount from `shown` every render meant anything that
|
||||
// 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
|
||||
// belongs to is `rowMenu.target`, and the panel anchors to the pointer.
|
||||
const rowMenu = useContextMenu<Video>();
|
||||
@@ -273,10 +289,23 @@ export default function Playlists() {
|
||||
const [pushing, setPushing] = useState(false);
|
||||
const [reverting, setReverting] = useState(false);
|
||||
|
||||
const onPlay = useCallback((v: Video) => setPlayingId(v.id), []);
|
||||
const onRemove = useCallback((v: Video) => {
|
||||
removeRef.current(v);
|
||||
// Stable (the memoized row callbacks depend on it), so it reads `shown` through the ref rather
|
||||
// than closing over it.
|
||||
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 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
|
||||
}
|
||||
|
||||
async function removeItem(video: Video) {
|
||||
if (selectedId == null) return;
|
||||
/** @returns whether the item was actually removed — the player needs to know, because it must
|
||||
* 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({
|
||||
title: t("playlists.removeItem"),
|
||||
message: t("playlists.confirmRemove", { title: video.title }),
|
||||
confirmLabel: t("playlists.removeItem"),
|
||||
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
|
||||
// drops the item from every snapshot, so the stack stays valid for what's left.
|
||||
const next = items.filter((v) => v.id !== video.id);
|
||||
@@ -495,10 +526,12 @@ export default function Playlists() {
|
||||
try {
|
||||
await api.removeFromPlaylist(selectedId, video.id);
|
||||
refreshAll();
|
||||
return true;
|
||||
} catch {
|
||||
// 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.
|
||||
refreshAll();
|
||||
return false;
|
||||
}
|
||||
}
|
||||
// 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
|
||||
// whenever the second call fails — see the endpoint's docstring.
|
||||
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({
|
||||
level: "success",
|
||||
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
|
||||
// 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.
|
||||
const sortLabel =
|
||||
@@ -601,11 +637,15 @@ export default function Playlists() {
|
||||
: filterActive
|
||||
? t("playlists.dragBlockedFilter")
|
||||
: 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
|
||||
// rows, same paint, same keyboard model) as any ⋯ ActionMenu in the app.
|
||||
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 ctxRow = rowMenu.target;
|
||||
const ctxItems: ActionMenuItem[] = !ctxRow
|
||||
@@ -615,17 +655,29 @@ export default function Playlists() {
|
||||
id: "play",
|
||||
label: t("card.play"),
|
||||
icon: <Play className="w-4 h-4" />,
|
||||
onSelect: () => setPlayingId(ctxRow.id),
|
||||
onSelect: () => openPlayer(ctxRow.id),
|
||||
},
|
||||
...comfortPin.item(pinVideo(ctxRow.id), ctxRow.title ?? ""),
|
||||
...(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
|
||||
? {
|
||||
id: "move",
|
||||
label: t("playlists.moveTo"),
|
||||
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) => ({
|
||||
id: `move-${pl.id}`,
|
||||
label: plName(pl),
|
||||
@@ -647,7 +699,7 @@ export default function Playlists() {
|
||||
id: "move",
|
||||
label: t("playlists.moveTo"),
|
||||
icon: <FolderInput className="w-4 h-4" />,
|
||||
separatorBefore: true,
|
||||
separatorBefore: !recentMoveTarget,
|
||||
disabled: true,
|
||||
title: t("playlists.moveNoTargets"),
|
||||
onSelect: () => {},
|
||||
@@ -780,7 +832,7 @@ export default function Playlists() {
|
||||
</span>
|
||||
)}
|
||||
<button
|
||||
onClick={() => shown.length && setPlayingId(shown[0]!.id)}
|
||||
onClick={() => shown.length && openPlayer(shown[0]!.id)}
|
||||
disabled={!shown.length}
|
||||
className="inline-flex items-center gap-1.5 text-xs font-semibold px-3 py-1.5 rounded-lg btn-primary disabled:opacity-40 transition"
|
||||
>
|
||||
@@ -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. */}
|
||||
<ContextMenu menu={rowMenu} ariaLabel={t("playlists.rowMenu")} items={ctxItems} />
|
||||
|
||||
{playingIndex >= 0 && (
|
||||
{playing && (
|
||||
<Suspense fallback={null}>
|
||||
<PlayerModal
|
||||
video={shown[playingIndex]!}
|
||||
queue={shown}
|
||||
startIndex={playingIndex}
|
||||
video={playing.video}
|
||||
queue={playing.queue}
|
||||
startIndex={playing.index}
|
||||
startAt={null}
|
||||
onClose={closePlayer}
|
||||
onState={playerState}
|
||||
onRemoveFromList={canEdit ? removeFromPlayer : undefined}
|
||||
/>
|
||||
</Suspense>
|
||||
)}
|
||||
|
||||
@@ -22,6 +22,7 @@ import clsx from "clsx";
|
||||
import type { Video, VideoStatus } from "../lib/api";
|
||||
import { FEED_VIEW_SPEC, type FeedView } from "../lib/feedView";
|
||||
import { formatDate, formatDuration, formatViews, relativeTime } from "../lib/format";
|
||||
import { isPlainLeftClick } from "../lib/nav";
|
||||
|
||||
function Actions({
|
||||
video,
|
||||
@@ -124,7 +125,7 @@ function openInApp(
|
||||
video: Video,
|
||||
onOpen?: (v: Video, startAt?: number | null) => void,
|
||||
): void {
|
||||
if (!onOpen || e.metaKey || e.ctrlKey || e.shiftKey || e.altKey || e.button !== 0) return;
|
||||
if (!onOpen || !isPlainLeftClick(e)) return;
|
||||
e.preventDefault();
|
||||
onOpen(video);
|
||||
}
|
||||
|
||||
@@ -72,6 +72,7 @@
|
||||
"colDuration": "Length",
|
||||
"rowMenu": "Video actions",
|
||||
"moveTo": "Move to another list",
|
||||
"moveToRecent": "Move to “{{name}}”",
|
||||
"moveNoTargets": "There is no other playlist to move it to yet.",
|
||||
"movedToast": "“{{title}}” moved to “{{name}}” ✓",
|
||||
"moveFailed": "Couldn't move the video.",
|
||||
|
||||
@@ -72,6 +72,7 @@
|
||||
"colDuration": "Hossz",
|
||||
"rowMenu": "Videó műveletek",
|
||||
"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.",
|
||||
"movedToast": "„{{title}}” áthelyezve ide: „{{name}}” ✓",
|
||||
"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 UI thinks in a key + direction. Kept here rather than inside Feed so the active-filter
|
||||
// chips can label a sort without re-deriving the mapping.
|
||||
import type { Video } from "./api";
|
||||
|
||||
export type SortKey =
|
||||
| "date"
|
||||
@@ -56,3 +57,44 @@ export function buildSort(key: SortKey, dir: "asc" | "desc"): string {
|
||||
if (key === "shuffle" || key === "relevance") return key;
|
||||
return SORT_MAP[key][dir];
|
||||
}
|
||||
|
||||
// --- Live YouTube search results ----------------------------------------------------------------
|
||||
//
|
||||
// The results view had NO sort control at all — the order was the provider's and that was that. It is
|
||||
// sorted HERE, on the page already loaded, rather than by re-asking YouTube with an `order=` param,
|
||||
// for three reasons: the owner's case is "even one page of results", i.e. reorder what is on screen;
|
||||
// a re-query would REPLACE the results, which is not what "sort these" means; and the zero-quota
|
||||
// scrape source has no order parameter at all, so a server-side control would silently do nothing (or
|
||||
// quietly cost 100 quota units) depending on which source happened to serve the page.
|
||||
//
|
||||
// Only the keys a search result actually carries are offered. `subscribers` and `priority` are
|
||||
// properties of a SUBSCRIPTION, which a live search result is not, and `shuffle` on a ranked result
|
||||
// list is noise — an option that cannot mean anything here is worse than no option.
|
||||
export const YT_SORT_KEYS: SortKey[] = ["relevance", "date", "popular", "duration", "title"];
|
||||
|
||||
const YT_SORT_VALUE: Partial<Record<SortKey, (v: Video) => number | string | null | undefined>> = {
|
||||
date: (v) => (v.published_at ? Date.parse(v.published_at) : null),
|
||||
popular: (v) => v.view_count,
|
||||
duration: (v) => v.duration_seconds,
|
||||
title: (v) => v.title,
|
||||
};
|
||||
|
||||
/** Order a loaded page of live-search results. `relevance` is the provider's own ranking, i.e. the
|
||||
* order they arrived in — so it is the identity, not a sort.
|
||||
*
|
||||
* Results missing the value sink to the END in both directions (a video whose duration YouTube did
|
||||
* not report is not "the shortest"), and ties keep the provider's ranking, in both directions —
|
||||
* hence a signed comparator rather than sorting and reversing, which would flip the ties too. */
|
||||
export function sortYtResults(items: Video[], key: SortKey, dir: "asc" | "desc"): Video[] {
|
||||
const value = YT_SORT_VALUE[key];
|
||||
if (!value) return items; // relevance, and anything a stale pref names that we no longer offer
|
||||
const sign = dir === "asc" ? 1 : -1;
|
||||
const have = items.filter((v) => value(v) != null);
|
||||
const missing = items.filter((v) => value(v) == null);
|
||||
have.sort((a, b) => {
|
||||
const x = value(a)!;
|
||||
const y = value(b)!;
|
||||
return sign * (typeof x === "string" ? x.localeCompare(String(y)) : x - Number(y));
|
||||
});
|
||||
return [...have, ...missing];
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
_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