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:
@@ -15,5 +15,5 @@ export const channelsModule: ModuleDef = {
|
||||
maxWidth: "96rem",
|
||||
Store: ChannelsProvider,
|
||||
urlKeys: CHANNELS_KEYS,
|
||||
typedUrlKeys: ["cq"],
|
||||
typedUrlKeys: ["cq", "cf"],
|
||||
};
|
||||
|
||||
@@ -269,7 +269,7 @@ export default function Feed({
|
||||
const { setYtCount, askYtSearch } = useNavigationActions();
|
||||
// A search runs by itself only where nobody's reload or link can spend quota with it: the free
|
||||
// page scrape, or a search asked for in this document. An API search reopened by a reload, a link
|
||||
// or Back waits for "Search YouTube again" — unless its results are still cached, which a disabled
|
||||
// or Back waits for "Search YouTube" — unless its results are still cached, which a disabled
|
||||
// query shows all the same. Turned off on this instance it runs too: the server refuses it before
|
||||
// any quota, and its refusal is what says so (no press offered that could only be refused).
|
||||
const ytRuns = ytActive && (me.search_source !== "api" || ytSearchAsked || !searchOn);
|
||||
@@ -506,7 +506,7 @@ export default function Feed({
|
||||
// The live search's cards, in the order picked for them, and the player's queue over them.
|
||||
const ytShown = (rows: Video[]): Video[] =>
|
||||
sortYtResults(withOverrides(rows, true), ytSort.key as SortKey, ytSort.dir, lang);
|
||||
const ytQueue = (rows: Video[]): Video[] => ytShown(rows).filter((v) => v.status !== "hidden");
|
||||
const ytQueueOf = (shown: Video[]): Video[] => shown.filter((v) => v.status !== "hidden");
|
||||
const merged: Video[] = withOverrides(loaded);
|
||||
const items: Video[] = merged.filter((v) => matchesView(v.status, filters.show));
|
||||
// The in-app player's queue (auto-advance / loop / prev-next). It's the filtered feed, but the
|
||||
@@ -520,7 +520,7 @@ export default function Feed({
|
||||
// list it was linked over: leaving the search first gives it up.
|
||||
useOpenLinkedPlayer(
|
||||
ytActive ? ytQuery.data !== undefined || ytQuery.isError || !ytRuns : !query.isLoading,
|
||||
(id) => (ytActive ? ytQueue(ytLoaded) : playerQueue).find((v) => v.id === id),
|
||||
(id) => (ytActive ? ytQueueOf(ytShown(ytLoaded)) : playerQueue).find((v) => v.id === id),
|
||||
(video, inList) => {
|
||||
openVideo(video);
|
||||
// Not in what this list has loaded (further down it, or no longer in it): it plays alone.
|
||||
@@ -541,7 +541,7 @@ export default function Feed({
|
||||
// user explicitly searched, so show everything) and no auto-pagination (each page costs quota).
|
||||
if (ytActive) {
|
||||
const ytItems: Video[] = ytShown(ytLoaded);
|
||||
const ytPlayerQueue: Video[] = ytItems.filter((v) => v.status !== "hidden");
|
||||
const ytPlayerQueue: Video[] = ytQueueOf(ytItems);
|
||||
const ytError =
|
||||
ytQuery.error instanceof HttpError
|
||||
? ytQuery.error.detail || t("feed.yt.error")
|
||||
@@ -649,7 +649,7 @@ export default function Feed({
|
||||
<div className="py-16 text-center text-muted">{t("feed.yt.searching")}</div>
|
||||
) : ytError && !ytItems.length ? (
|
||||
// A first page that failed is said as the error it is, with Retry where trying again can
|
||||
// help (YouTube busy or unreachable): Search YouTube again with the same term reads the
|
||||
// help (YouTube busy or unreachable): Search YouTube with the same term reads the
|
||||
// failure from the cache and sends nothing.
|
||||
<StateMessage
|
||||
tone="error"
|
||||
|
||||
@@ -147,7 +147,7 @@ describe("Feed — a live search's Load more that fails", () => {
|
||||
});
|
||||
|
||||
describe("Feed — a live search whose first page fails", () => {
|
||||
// Search YouTube again with the same term reads the cached failure: Retry is the way to ask again.
|
||||
// Search YouTube with the same term reads the cached failure: Retry is the way to ask again.
|
||||
it("offers Retry where trying again can help, and reads the page on it", async () => {
|
||||
const search = vi
|
||||
.spyOn(api, "searchYoutube")
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
//
|
||||
// A live YouTube search the address reopens (a reload, a link, Back) runs by itself only where it
|
||||
// costs no quota: the free page scrape. An API search spends 100 units, so it waits for "Search
|
||||
// YouTube again" — unless its results are still cached, or it was asked for in this document.
|
||||
// YouTube" — unless its results are still cached, or it was asked for in this document.
|
||||
import { describe, it, expect, vi, beforeAll, afterEach } from "vitest";
|
||||
import { render, screen, fireEvent, waitFor, cleanup } from "@testing-library/react";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
|
||||
@@ -1,4 +1,11 @@
|
||||
import { coerceTagModes, coerceUntagged, DEFAULT_TAG_MODES, type FeedFilters } from "../../lib/api";
|
||||
import {
|
||||
coerceTagModes,
|
||||
coerceUntagged,
|
||||
DEFAULT_TAG_MODES,
|
||||
FEED_DEFAULT_SHOW,
|
||||
FEED_DEFAULT_SORT,
|
||||
type FeedFilters,
|
||||
} from "../../lib/api";
|
||||
import { LS, ownKey, readJSON } from "../../lib/storage";
|
||||
|
||||
// The feed filters' defaults and the per-account localStorage seed, split out of FeedFiltersProvider
|
||||
@@ -10,12 +17,12 @@ export const DEFAULT_FILTERS: FeedFilters = {
|
||||
untagged: false,
|
||||
channelIds: [],
|
||||
q: "",
|
||||
sort: "newest",
|
||||
sort: FEED_DEFAULT_SORT,
|
||||
scope: "my",
|
||||
includeNormal: true,
|
||||
includeShorts: false,
|
||||
includeLive: false,
|
||||
show: "unwatched",
|
||||
show: FEED_DEFAULT_SHOW,
|
||||
};
|
||||
|
||||
/** The watch-state choices, in the order the filter offers them. */
|
||||
|
||||
@@ -170,8 +170,11 @@ const YT = folder(1, { source_key: "youtube" });
|
||||
const OWN = folder(2, { kind: "own", name: "Cimboráknak", retention_days: null });
|
||||
const HUB = folder(4, { kind: "hub", retention_days: null, count: 0 });
|
||||
|
||||
afterEach(() => {
|
||||
afterEach(async () => {
|
||||
cleanup();
|
||||
// A viewer the cleanup closed steps the history back: jsdom lands that traversal a few tasks later,
|
||||
// and on whatever case is current by then. Let it land here, before the address is reset.
|
||||
for (let i = 0; i < 5; i++) await new Promise((r) => setTimeout(r, 0));
|
||||
vi.restoreAllMocks();
|
||||
// The page names its sort in the address: one case's sort must not open the next.
|
||||
window.history.replaceState(null, "", "/");
|
||||
@@ -813,15 +816,30 @@ describe("Files · a file the address names", () => {
|
||||
// A list the cap cut cannot tell a deleted file from one beyond its end: it says the file is not
|
||||
// among the ones listed, and keeps the link.
|
||||
it("past the cap with no search or sort to widen it, says it is not listed and keeps the link", async () => {
|
||||
// The case before closed a viewer: its step back lands a moment later, on whatever is current.
|
||||
await new Promise((r) => setTimeout(r, 30));
|
||||
window.history.replaceState(null, "", "/?page=files&fsort=none&fview=9999");
|
||||
mocks.files = Array.from({ length: FILES_LIST_CAP }, (_, i) => file(i + 1));
|
||||
mocks.folders = [YT];
|
||||
mount();
|
||||
await waitFor(() =>
|
||||
expect(mocks.notify).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ level: "info", message: "files.arrival.notListed" }),
|
||||
expect.objectContaining({ level: "info", message: "files.linkedNotListed" }),
|
||||
),
|
||||
);
|
||||
expect(mocks.notify).not.toHaveBeenCalledWith(
|
||||
expect.objectContaining({ message: "files.linkedGone" }),
|
||||
);
|
||||
expect(here().get("fview")).toBe("9999");
|
||||
}, 30_000); // a full page of FILES_LIST_CAP rows
|
||||
|
||||
it("past the cap, the server's sorted list cut too: not listed, and the link stays", async () => {
|
||||
window.history.replaceState(null, "", "/?page=files&fsort=name:asc&fview=9999");
|
||||
mocks.files = Array.from({ length: FILES_LIST_CAP }, (_, i) => file(i + 1));
|
||||
mocks.searched = Array.from({ length: FILES_LIST_CAP }, (_, i) => file(i + 1));
|
||||
mocks.folders = [YT];
|
||||
mount();
|
||||
await waitFor(() =>
|
||||
expect(mocks.notify).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ level: "info", message: "files.linkedNotListed" }),
|
||||
),
|
||||
);
|
||||
expect(mocks.notify).not.toHaveBeenCalledWith(
|
||||
|
||||
@@ -294,7 +294,9 @@ export default function Files() {
|
||||
const listIn = wide ? wideIn && wideQ.data !== undefined : filesQ.data !== undefined;
|
||||
const linkedRow = linkedFile && listIn ? rows.find((f) => f.id === linkedFile.id) : undefined;
|
||||
const openLinked = useEffectEvent((file: LibraryFile | undefined) => {
|
||||
if (!file && capped && !wide) notify({ level: "info", message: t("files.arrival.notListed") });
|
||||
// Looked for in the list it was shown in: a cut one cannot tell (the newest files, or the first by
|
||||
// its sort or search), as the arrival decides it.
|
||||
if (!file && cut) notify({ level: "info", message: t("files.linkedNotListed") });
|
||||
else if (!file) {
|
||||
notify({ level: "warning", message: t("files.linkedGone") });
|
||||
clearParams(FILES_VIEW_KEY, FILES_PLAY_KEY);
|
||||
|
||||
@@ -102,8 +102,10 @@ export function FilesProvider({ children }: { children: ReactNode }) {
|
||||
|
||||
const shown = urlPage(nav) === "files";
|
||||
// Written once the typing pauses (one address-bar write per keystroke is churn a browser throttles);
|
||||
// an arrival's cleared box at once.
|
||||
const typed = useDebounced(search.trim(), 300, focusRaw?.token);
|
||||
// an emptied box at once — an arrival's, or the page left: back on it within the pause, the address
|
||||
// would name the ended search again.
|
||||
const debounced = useDebounced(search.trim(), 300, focusRaw?.token);
|
||||
const typed = search.trim() ? debounced : "";
|
||||
useUrlMirror(
|
||||
filesListParams(
|
||||
shown ? { search: typed, folder: viewParam(view), item: focus ? focus.id : null } : null,
|
||||
|
||||
@@ -133,13 +133,16 @@ let qc: QueryClient;
|
||||
function mount(
|
||||
lists: Record<number, Video[]>,
|
||||
prefs: Record<string, unknown> = {},
|
||||
{ strictOverCache = false } = {},
|
||||
{
|
||||
strictOverCache = false,
|
||||
detail,
|
||||
}: { strictOverCache?: boolean; detail?: (id: number) => Promise<unknown> } = {},
|
||||
) {
|
||||
vi.spyOn(api, "playlists").mockResolvedValue(
|
||||
Object.entries(lists).map(([id, items]) => list(Number(id), items)) as never,
|
||||
);
|
||||
vi.spyOn(api, "playlist").mockImplementation(
|
||||
async (id: number) => list(id, lists[id] ?? []) as never,
|
||||
(detail ?? (async (id: number) => list(id, lists[id] ?? []))) as never,
|
||||
);
|
||||
qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
qc.setQueryData(qk.me(), { id: 1, can_write: true, prefs: {}, preferences: prefs });
|
||||
@@ -220,6 +223,47 @@ describe("Playlists — a player the address names", () => {
|
||||
expect(h.player!.queue?.map((v) => v.id)).toEqual(["Z"]);
|
||||
});
|
||||
|
||||
it("plays alone over a playlist this account does not hold, as the rail moves on by itself", async () => {
|
||||
window.history.replaceState(null, "", "/?page=playlists&pl=7&watch=Z");
|
||||
h.selectedId = 7;
|
||||
// The catalog answers after the rail moved on, as in the app (the move is in the same tick).
|
||||
let answer: (v: Video) => void = () => {};
|
||||
vi.spyOn(api, "videoRow").mockReturnValue(new Promise((r) => (answer = r)) as never);
|
||||
// Its read never answers: the rail moves on before it could.
|
||||
const { rerender } = mount(
|
||||
{ 1: [vid("A", 0)] },
|
||||
{},
|
||||
{
|
||||
detail: async (id) => (id === 7 ? new Promise<never>(() => {}) : list(id, [vid("A", 0)])),
|
||||
},
|
||||
);
|
||||
// The rail's keep-valid step, once the account's list is in: 7 is not in it.
|
||||
await waitFor(() => expect(qc.getQueryData(qk.playlists())).toBeDefined());
|
||||
h.selectedId = 1;
|
||||
rerender();
|
||||
await screen.findByText("Video A");
|
||||
act(() => answer(vid("Z", 0)));
|
||||
await waitFor(() => expect(h.player?.video.id).toBe("Z"));
|
||||
expect(h.player!.queue?.map((v) => v.id)).toEqual(["Z"]);
|
||||
expect(new URLSearchParams(window.location.search).get("watch")).toBe("Z");
|
||||
});
|
||||
|
||||
it("plays alone when the linked playlist's read is refused", async () => {
|
||||
window.history.replaceState(null, "", "/?page=playlists&pl=1&watch=Z");
|
||||
vi.spyOn(api, "videoRow").mockResolvedValue(vid("Z", 0));
|
||||
mount({ 1: [vid("A", 0)] }, {}, { detail: () => Promise.reject(new Error("refused")) });
|
||||
await waitFor(() => expect(h.player?.video.id).toBe("Z"));
|
||||
expect(h.player!.queue?.map((v) => v.id)).toEqual(["Z"]);
|
||||
});
|
||||
|
||||
it("plays alone with no playlists at all", async () => {
|
||||
window.history.replaceState(null, "", "/?page=playlists&watch=Z");
|
||||
h.selectedId = null;
|
||||
vi.spyOn(api, "videoRow").mockResolvedValue(vid("Z", 0));
|
||||
mount({});
|
||||
await waitFor(() => expect(h.player?.video.id).toBe("Z"));
|
||||
});
|
||||
|
||||
it("is given up when another playlist is picked before it opened, and leaves the address", async () => {
|
||||
window.history.replaceState(null, "", "/?page=playlists&pl=1&watch=Z");
|
||||
const row = vi.spyOn(api, "videoRow").mockResolvedValue(vid("Z", 0));
|
||||
|
||||
@@ -586,18 +586,26 @@ function PlaylistDetail() {
|
||||
// (filtered out, gone from the list, the list emptied or out of reach) plays alone, and nothing
|
||||
// removes it from a list. In: the order seeded from it (an empty one has none to wait for), or its
|
||||
// read refused. Scoped to the playlist it was linked over: another one picked first gives it up.
|
||||
// One the account's list does not hold (deleted since, another account's link) is out of reach
|
||||
// as soon as that list is in: the rail then moves to another one by itself, which is no pick.
|
||||
const [linkedOver] = useState(selectedId);
|
||||
const linkedOutOfReach =
|
||||
listQuery.data != null && !listQuery.data.some((p) => p.id === linkedOver);
|
||||
const linkedListIn =
|
||||
linkedOutOfReach ||
|
||||
(detail != null && (detail.items.length === 0 || items.length > 0)) ||
|
||||
(selectedId != null && detailQuery.isError) ||
|
||||
(selectedId == null && listQuery.data?.length === 0);
|
||||
(selectedId != null && detailQuery.isError);
|
||||
useOpenLinkedPlayer(
|
||||
linkedListIn,
|
||||
(id) => shown.find((v) => v.id === id),
|
||||
(id) => (linkedOutOfReach ? undefined : shown.find((v) => v.id === id)),
|
||||
(video, inList) => {
|
||||
if (inList) openPlayer(video.id);
|
||||
else setPlaying({ video, queue: [video], index: 0, listId: selectedId, editable: false });
|
||||
else {
|
||||
const listId = linkedOutOfReach ? null : selectedId;
|
||||
setPlaying({ video, queue: [video], index: 0, listId, editable: false });
|
||||
}
|
||||
},
|
||||
selectedId,
|
||||
linkedOutOfReach ? linkedOver : selectedId,
|
||||
);
|
||||
|
||||
async function moveItemTo(video: Video, target: Playlist) {
|
||||
|
||||
@@ -46,7 +46,9 @@ export function PlaylistsProvider({ children }: { children: ReactNode }) {
|
||||
const shown = urlPage(nav) === "playlists";
|
||||
// Leaving the page ends the search: adjusted during the render that leaves it.
|
||||
if (nav.page !== "playlists" && search) setSearchState("");
|
||||
const typed = useDebounced(search.trim(), 300);
|
||||
// An emptied box at once: back on the page within the pause, the address would name it again.
|
||||
const debounced = useDebounced(search.trim(), 300);
|
||||
const typed = search.trim() ? debounced : "";
|
||||
useUrlMirror(shown ? playlistsParams(selectedId, typed) : playlistsParams(null, ""));
|
||||
|
||||
const state = useMemo<PlaylistsState>(() => ({ search, selectedId }), [search, selectedId]);
|
||||
|
||||
@@ -19,6 +19,6 @@ export const playlistsModule: ModuleDef = {
|
||||
Store: PlaylistsProvider,
|
||||
// The playlist shown (PlaylistsProvider, playlistsInit).
|
||||
urlKeys: PLAYLISTS_KEYS,
|
||||
typedUrlKeys: ["plq"],
|
||||
typedUrlKeys: ["plq", "plf"],
|
||||
playsVideos: true,
|
||||
};
|
||||
|
||||
@@ -47,6 +47,7 @@ import { jobFailureText, jobResultText } from "../../lib/jobResult";
|
||||
import { StatusDot, type DotKey } from "../activity";
|
||||
import { useNavigationActions } from "../../features/navigation/navigationContext";
|
||||
import { urlWithParams } from "../../lib/urlState";
|
||||
import { activityParamUpdates, DEFAULT_ACTIVITY_STATE } from "../../lib/activityUrlState";
|
||||
|
||||
const POLL_MS = 4000;
|
||||
// While any job is running, poll faster so short scheduled runs aren't missed between ticks.
|
||||
@@ -164,7 +165,10 @@ function HistoryButton({ job }: { job: string }) {
|
||||
window.history.replaceState(
|
||||
window.history.state,
|
||||
"",
|
||||
urlWithParams({ page: "activity", rjob: job }),
|
||||
urlWithParams({
|
||||
page: "activity",
|
||||
...activityParamUpdates({ ...DEFAULT_ACTIVITY_STATE, job }),
|
||||
}),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user