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:
@@ -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)}
|
||||
|
||||
Reference in New Issue
Block a user