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