fix(nav): a linked player, file and search survive what the app does by itself (review round 2)

- Playlists: a link over a playlist the account does not hold plays alone; the rail's own
  fallback no longer counts as a pick that gives the player up.
- The linked opener opens or clears nothing once the address names another player, and stops
  looking (and fetching its row) once it settled.
- Files: a list the server's cap cut — the sorted or searched one too — keeps the link, with a
  notice of its own (EN/HU).
- History: an in-place view rewrite (search refine, count, channel to channel, sub-view)
  drops a reloaded entry's overlay marks (replaceView); a landing on a channel opened from a
  search keeps the search; the Feed press on a search opens the plain feed.
- Files/Playlists: an emptied search is written at once.
- Client log: plf/cf (column text filters) are typed keys; the shell has none of its own.
- Contract: writers outside lib/ take a literal or a lib/ function's result; every URL key is
  sorted typed or not; the writer regexes are held by a fixture; sub-views named; the bookmark
  net reads page tags.
- Feed defaults in one constant; one live-search queue helper; Scheduler writes through the
  Activity codec; notRun copy (EN/HU) names Forward and a plain "Search YouTube" button.
This commit is contained in:
2026-10-27 16:08:52 +01:00
parent 0d5c6273f1
commit 6d57d62ac8
38 changed files with 717 additions and 187 deletions
+1 -1
View File
@@ -20,7 +20,7 @@ import { setupApi } from "./api/setup";
export type * from "./api/setup";
import { feedApi } from "./api/feed";
export type * from "./api/feed";
export { TAKEOUT_JOB } from "./api/feed";
export { FEED_DEFAULT_SHOW, FEED_DEFAULT_SORT, TAKEOUT_JOB } from "./api/feed";
import { discoverApi } from "./api/discover";
export type * from "./api/discover";
export { DISCOVER_BUDGETS, HERO_TIERS, discoverCardKey } from "./api/discover";
+5
View File
@@ -81,6 +81,11 @@ export interface FeedResponse {
// or optional (dead surface). Re-add if a consumer ever needs it.
}
/** The feed's view and order with nothing chosen: what a bookmark of the plain feed names, and what
* the active-filter chips read "nothing applied" against. */
export const FEED_DEFAULT_SHOW = "unwatched";
export const FEED_DEFAULT_SORT = "newest";
export interface FeedFilters {
tags: number[];
/** Per-category tag match (language / topic / other each independently any/all). */
+3 -2
View File
@@ -3,8 +3,9 @@ import { type FilterMap } from "./tableColumns";
// A table's column-filter map as one address-bar value (the channel manager's `cf`, a playlist's `plf`).
// The map mixes scalar column filters (select, text, the sync chip) with array ones (multi), so it's
// carried as a compact JSON blob rather than a nested query string — JSON round-trips both shapes
// exactly (and future multi columns for free), and the values are opaque enum/id tokens where a
// readable querystring buys little. Empty entries are dropped so a cleared funnel emits nothing.
// exactly (and future multi columns for free) where a readable querystring buys little. A text
// column's entry is what the user typed, so a map with one is a typed key (ModuleDef.typedUrlKeys).
// Empty entries are dropped so a cleared funnel emits nothing.
export function encodeFilterMap(map: FilterMap): string | null {
const clean: FilterMap = {};
for (const [k, v] of Object.entries(map)) {
@@ -0,0 +1,37 @@
// @vitest-environment jsdom
//
// A reload under two overlays: the player reopens on the upper one's entry, the lower one's is dead
// under it. A browser Back closes the player — and steps over the dead entry too, onto the page, as
// the player's own close does. Its own file: the entry is read as the module loads, so it is set up
// before the import.
import { describe, expect, it } from "vitest";
import { act, renderHook } from "@testing-library/react";
const landing = () =>
new Promise((res) => window.addEventListener("popstate", res, { once: true }));
describe("a browser Back over an overlay reopened on the entry a reload under two started from", () => {
it("lands on the page under both", async () => {
window.history.replaceState({ sfPage: "feed" }, "", "/?page=feed");
window.history.pushState({ sfPage: "feed", _ov: 1, _ovd: 1 }, "", "/?page=feed&watch=v1");
window.history.pushState({ sfPage: "feed", _ov: 2, _ovd: 2 }, "", "/?page=feed&watch=v1");
const { useBackToClose } = await import("./history");
let closed = false;
renderHook(() => useBackToClose(() => (closed = true), { watch: "v1" }));
await act(async () => {
await Promise.resolve();
});
await act(async () => {
const first = landing();
window.history.back();
await first;
await landing(); // the step over the dead entry
await Promise.resolve();
});
expect(closed).toBe(true);
expect(window.history.state?._ov).toBeUndefined();
expect(window.location.search).toBe("?page=feed");
});
});
@@ -0,0 +1,43 @@
// @vitest-environment jsdom
//
// A reload under two overlays, in a tab whose entries below the dead one are not this document's
// (Chrome dropped them, see backStaysInDocument): closing the reopened player cannot step over both,
// so it steps back one — onto the dead entry, still in the app — rather than leaving it. Its own file:
// the entry is read as the module loads, so it is set up before the import.
import { afterEach, describe, expect, it, vi } from "vitest";
import { act, renderHook } from "@testing-library/react";
afterEach(() => {
Reflect.deleteProperty(window, "navigation");
});
describe("an overlay reopened over a dead entry with nothing of this document below it", () => {
it("closes one step back, staying in the document", async () => {
window.history.replaceState({ sfPage: "feed", _ov: 1, _ovd: 1 }, "", "/?page=feed&watch=v1");
window.history.pushState({ sfPage: "feed", _ov: 2, _ovd: 2 }, "", "/?page=feed&watch=v1");
const { useBackToClose } = await import("./history");
// Two steps back would leave the document; one stays in it.
Object.defineProperty(window, "navigation", {
configurable: true,
value: {
currentEntry: { index: 2 },
entries: () => [{ sameDocument: false }, { sameDocument: true }, { sameDocument: true }],
},
});
const go = vi.spyOn(window.history, "go");
const back = vi.spyOn(window.history, "back");
const r = renderHook(() => useBackToClose(() => {}, { watch: "v1" }));
await act(async () => {
await Promise.resolve();
});
await act(async () => {
const landed = new Promise((res) => window.addEventListener("popstate", res, { once: true }));
r.unmount();
await landed;
});
expect(go).not.toHaveBeenCalled();
expect(back).toHaveBeenCalledTimes(1);
expect(window.history.state?._ov).toBe(1);
});
});
+16 -1
View File
@@ -253,7 +253,7 @@ export function useHistorySubview<T>(
function writeSubEntry<T>(next: T, url: SubviewUrl<T> | undefined): void {
const kept = url?.entry ? url.entry(next) : next;
const href = url ? urlWithParams({ [url.key]: url.encode(next) }) : "";
window.history.replaceState({ ...window.history.state, _sub: kept }, "", href);
replaceView({ ...window.history.state, _sub: kept }, href);
}
// --- Overlays / modals (one history entry each, nesting-safe) -------------------------------
@@ -304,6 +304,21 @@ let historyDepth = 0;
let reloadedOverlayEntry: unknown = window.history.state?._ov ?? null;
const reloadedDepth: unknown = window.history.state?._ovd;
let deadBelow = 0;
/** Rewrite the current entry in place for another view (a refined search, another channel, a
* sub-view followed): `state` and `href` as replaceState takes them. With no overlay open, the
* entry's overlay marks go — the ones a reload under an overlay left. They named the view it was
* left from, and the first overlay over the new one would take this entry over: closed, it stepped
* back onto the entry below and the screen went back to the view before the rewrite. */
export function replaceView(state: Record<string, unknown>, href?: string): void {
if (historyDepth > 0) {
window.history.replaceState(state, "", href);
return;
}
reloadedOverlayEntry = null;
const { _ov: _overlay, _ovd: _depth, ...view } = state;
window.history.replaceState(view, "", href);
}
// Programmatic history steps back still awaiting their popstate — each must be ignored — and how
// many entries they step over in all (a step over dead entries is one traversal of several).
let pendingProgrammatic = 0;
+2 -2
View File
@@ -10,8 +10,8 @@ import { installClientLog } from "./logHooks";
const appCopy = (text: string | null) =>
["Refresh", "Frissítés"].includes(text?.trim() ?? "") ? "Refresh" : null;
// The pages' search boxes' keys, as main.tsx hands them over from the module definitions.
const TYPED = ["fq", "yt"];
// The pages' typed keys, as main.tsx hands them over from the module definitions.
const TYPED = ["q", "fq", "yt"];
beforeAll(() => {
installClientLog(appCopy, TYPED);
+4 -4
View File
@@ -13,10 +13,10 @@ import { FRONTEND_SHA, FRONTEND_VERSION } from "./version";
import { enabled, errorAttrs, log } from "./log";
import { pathTemplate } from "./http";
// URL parameters that carry what the user TYPED — search text. Logged only at TRACE ("Teljes").
// Every other parameter is filter/sort/view state: its NAME is kept at INFO, its value at DEBUG. The
// shell's own here; each page's search boxes join them at install (ModuleDef.typedUrlKeys).
const TYPED_PARAMS = new Set(["q", "keywords", "search"]);
// URL parameters that carry what the user TYPED — a search, a column's text filter. Logged only at
// TRACE ("Teljes"). Every other parameter is filter/sort/view state: its NAME is kept at INFO, its
// value at DEBUG. The shell has none; each page's join at install (ModuleDef.typedUrlKeys).
const TYPED_PARAMS = new Set<string>();
type Via = "start" | "push" | "replace" | "pop";
+3 -3
View File
@@ -50,9 +50,9 @@ export type ModuleDef = {
// along onto another's URL. Empty for a page with nothing of its own in the URL. Each key belongs to
// one module and none is a shell key (moduleUrl.contract.test).
urlKeys: readonly string[];
// Those of them that carry what the user TYPED (a search box's text): the client log keeps their
// values out below its most detailed level, as it does the shell's own (lib/logHooks). A new search
// box's key is declared here with it (moduleUrl.contract.test holds them to urlKeys).
// Those of them that carry what the user TYPED (a search box's text, a column filter map holding a
// text filter): the client log keeps their values out below its most detailed level (lib/logHooks).
// moduleUrl.contract.test sorts every key of urlKeys as typed or not, so a new one is decided.
typedUrlKeys?: readonly string[];
// Its page plays YouTube videos in the player and reopens it from an address that names one (a
// reload, a bookmark — lib/playerUrlState), with its own list as the queue. A page without it gets
+7 -7
View File
@@ -4,7 +4,7 @@
// bookmarkable/shareable — the old "Share view" workaround (and the whole-query stripUrlParams) is
// retired. Only non-default values are emitted so URLs stay clean. NOTE: the browser TAB title stays
// the short module/channel name (NavigationProvider owns document.title) — the query never leaks into it.
import { TAG_CATEGORIES, type FeedFilters } from "./api";
import { FEED_DEFAULT_SHOW, FEED_DEFAULT_SORT, TAG_CATEGORIES, type FeedFilters } from "./api";
import { CHANNEL_PAGE_KEYS } from "./channelPageUrlState";
import { PLAYER_KEYS } from "./playerUrlState";
@@ -55,8 +55,8 @@ export const SHELL_KEYS = [
export function filtersToParams(f: FeedFilters): URLSearchParams {
const p = new URLSearchParams();
if (f.q) p.set("q", f.q);
if (f.show && f.show !== "unwatched") p.set("show", f.show);
if (f.sort && f.sort !== "newest") p.set("sort", f.sort);
if (f.show && f.show !== FEED_DEFAULT_SHOW) p.set("show", f.show);
if (f.sort && f.sort !== FEED_DEFAULT_SORT) p.set("sort", f.sort);
if (f.scope === "all") p.set("scope", "all");
// Library provenance filter (only meaningful in "all" scope); "organic" is the default.
if (f.scope === "all" && f.librarySource && f.librarySource !== "organic")
@@ -90,8 +90,8 @@ export function filterParamUpdates(f: FeedFilters | null): Record<string, string
const out: Record<string, string | null> = {};
for (const k of KEYS) out[k] = p.get(k);
if (f) {
out.show = f.show || "unwatched";
out.sort = f.sort || "newest";
out.show = f.show || FEED_DEFAULT_SHOW;
out.sort = f.sort || FEED_DEFAULT_SORT;
}
return out;
}
@@ -106,8 +106,8 @@ export function paramsToFilters(params: URLSearchParams, base: FeedFilters): Fee
};
if (params.has("q")) f.q = params.get("q") ?? "";
if (params.has("show")) f.show = params.get("show") || "unwatched";
if (params.has("sort")) f.sort = params.get("sort") || "newest";
if (params.has("show")) f.show = params.get("show") || FEED_DEFAULT_SHOW;
if (params.has("sort")) f.sort = params.get("sort") || FEED_DEFAULT_SORT;
if (params.has("scope")) f.scope = params.get("scope") === "all" ? "all" : "my";
if (params.has("source")) {
const s = params.get("source");
+7 -7
View File
@@ -1,5 +1,5 @@
import { useTranslation } from "react-i18next";
import { DEFAULT_TAG_MODES, type FeedFilters } from "./api";
import { DEFAULT_TAG_MODES, FEED_DEFAULT_SHOW, FEED_DEFAULT_SORT, type FeedFilters } from "./api";
import { tagPickLabel } from "./tagPicks";
import { useChannelNames } from "./useChannelNames";
import { parseSort } from "./feedSort";
@@ -20,11 +20,11 @@ const DEFAULT_SIDEBAR_FILTERS: Omit<FeedFilters, "q" | "scope"> = {
tagModes: DEFAULT_TAG_MODES,
untagged: false,
channelIds: [],
sort: "newest",
sort: FEED_DEFAULT_SORT,
includeNormal: true,
includeShorts: false,
includeLive: false,
show: "unwatched",
show: FEED_DEFAULT_SHOW,
};
/**
@@ -74,19 +74,19 @@ export function useActiveFilters(
});
}
if (filters.show !== "unwatched") {
if (filters.show !== FEED_DEFAULT_SHOW) {
out.push({
key: "show",
label: t("sidebar.show." + filters.show),
remove: () => setFilters({ ...filters, show: "unwatched" }),
remove: () => setFilters({ ...filters, show: FEED_DEFAULT_SHOW }),
});
}
if (filters.sort !== "newest" && filters.sort !== "relevance") {
if (filters.sort !== FEED_DEFAULT_SORT && filters.sort !== "relevance") {
out.push({
key: "sort",
label: t("feed.sortKey." + parseSort(filters.sort).key),
remove: () => setFilters({ ...filters, sort: "newest", seed: undefined }),
remove: () => setFilters({ ...filters, sort: FEED_DEFAULT_SORT, seed: undefined }),
});
}