diff --git a/src/ui/components/Results.test.tsx b/src/ui/components/Results.test.tsx index cc9e2513..255d4643 100644 --- a/src/ui/components/Results.test.tsx +++ b/src/ui/components/Results.test.tsx @@ -521,6 +521,41 @@ const GROUPABLE = [ t("a1", "Ashfall.1999.1080p"), ]; +describe("Results play gating", () => { + // Games and Books releases are installers/archives/documents, not video — + // `v` has nothing sensible to stream, so it's a no-op there. See playSection + // in Results.tsx and playApplies in the web UI's searchModel.ts. + it("does not stream on 'v' for a Games result", async () => { + const streamResult = vi.fn(); + const gamesList: TorrentResult[] = [{ ...LIST[0]!, source: "fitgirl" }]; + searchState.current = settled(gamesList); + ui = renderUI( + + + , + ); + const u = ui; + await vi.waitFor(() => expect(u.frame()).toContain(`Results (${gamesList.length})`)); + u.press("v"); + await new Promise((r) => yieldToLoop(() => r())); + expect(streamResult).not.toHaveBeenCalled(); + }); + + it("still streams on 'v' outside Games/Books", async () => { + const streamResult = vi.fn(); + searchState.current = settled(LIST); + ui = renderUI( + + + , + ); + const u = ui; + await vi.waitFor(() => expect(u.frame()).toContain(`Results (${LIST.length})`)); + u.press("v"); + await vi.waitFor(() => expect(streamResult).toHaveBeenCalled()); + }); +}); + describe("Results grouping", () => { // At a WIDE content width throughout. At 80 columns the list has ~61 and // "Kestrel (2010)" renders as "Kestrel (…", so every assertion here would be diff --git a/src/ui/components/Results.tsx b/src/ui/components/Results.tsx index c5290a54..e0943ed0 100644 --- a/src/ui/components/Results.tsx +++ b/src/ui/components/Results.tsx @@ -540,6 +540,10 @@ export function Results({ reccConfig, fetchImpl }: ResultsProps) { const previewSection = !previewGroup || previewGroup === "Movies" || previewGroup === "TV" || previewGroup === "Anime"; // Anime previews from AniList, so it needs no OMDb key. const sectionIsAnime = section === "anime"; + // Games and Books results are installers, archives, and documents — there is + // no video file for `v` to land on, so it's a no-op there. See the web UI's + // `playApplies` (searchModel.ts) for the same gate on the other front end. + const playSection = previewGroup !== "Games" && previewGroup !== "Books"; // Local pane: the adult group has no OMDb metadata, so it is built from the // release name — no key, no lookup. const adultSection = previewGroup === "Porn"; @@ -769,7 +773,7 @@ export function Results({ reccConfig, fetchImpl }: ResultsProps) { } else if (input === "r") { const r = resultAt(clamped); if (r) openDebrid(r); - } else if (input === "v") { + } else if (input === "v" && playSection) { const row = rows[clamped]; if (row?.kind === "season" || row?.kind === "show") { const plan = seasonPlayPlan( @@ -812,7 +816,7 @@ export function Results({ reccConfig, fetchImpl }: ResultsProps) { } else if (input === "d" && detail) openDownload(detail); else if (input === "D" && detail) openDownloadTo(detail); else if (input === "r" && detail) openDebrid(detail); - else if (input === "v" && detail) openStream(detail); + else if (input === "v" && detail && playSection) openStream(detail); else if (input === "y" && detail) copyResultMagnet(detail); else if (input === "i" && detail) openImdbFor(detail.name); else if (input === "b" && detail && canFavourite(detail)) toggleFavourite(favInput(detail)); diff --git a/src/web/static/app.ts b/src/web/static/app.ts index 667b3a56..5d2444d1 100644 --- a/src/web/static/app.ts +++ b/src/web/static/app.ts @@ -64,6 +64,7 @@ import { parseGrouping, parseLayout, parseSort, + playApplies, previewApplies, adultPreviewApplies, reportsHealthLookup, @@ -272,6 +273,7 @@ const settingsSourcesBox = el("settings-sources"); const settingsAccountsBox = el("settings-accounts"); const settingsAccountsHint = el("settings-accounts-hint"); +const logoHome = el("logo-home"); const viewsNav = el("views"); const viewSearchTab = el("view-search"); const viewReccTab = el("view-recc"); @@ -1644,6 +1646,7 @@ function showView(next: ViewName): void { syncUrl(); } +logoHome.addEventListener("click", () => showView("search")); viewSearchTab.addEventListener("click", () => showView("search")); viewReccTab.addEventListener("click", () => showView("recc")); viewSavedTab.addEventListener("click", () => showView("saved")); @@ -2408,20 +2411,27 @@ function resultActions( const actions = document.createElement("div"); actions.className = "row-actions"; - const playButton = document.createElement("button"); - playButton.type = "button"; - playButton.className = "play"; - playButton.textContent = "play"; - tagControl(playButton, rowKey, "play"); - // `result.infoHash`, NOT `rowKey`: rowKey is the group key (this row may nest - // several releases of one title), while play() is handed rowForPlay(result), - // whose id is the hash. See tagPlayKey for why the two identities are separate. - tagPlayKey(playButton, result.infoHash, "play"); - playButton.addEventListener("click", () => { - if (onPlay) onPlay(); - else void play(rowForPlay(result)); - }); - actions.append(playButton); + // Games and Books results have no video file for `play` to land on — see + // `playApplies`. Add / debrid-add remain as the primary actions on those + // tabs, and one of them takes over play's accent styling below so the row + // still has one obvious next step. + const showPlay = playApplies(searchView.group); + if (showPlay) { + const playButton = document.createElement("button"); + playButton.type = "button"; + playButton.className = "play"; + playButton.textContent = "play"; + tagControl(playButton, rowKey, "play"); + // `result.infoHash`, NOT `rowKey`: rowKey is the group key (this row may nest + // several releases of one title), while play() is handed rowForPlay(result), + // whose id is the hash. See tagPlayKey for why the two identities are separate. + tagPlayKey(playButton, result.infoHash, "play"); + playButton.addEventListener("click", () => { + if (onPlay) onPlay(); + else void play(rowForPlay(result)); + }); + actions.append(playButton); + } // A labelled debrid add button replaces the plain (P2P) "add" whenever a // provider is configured — the server forces debrid in that case (it never @@ -2432,6 +2442,9 @@ function resultActions( if (!debridAddAvailable) { const addButton = document.createElement("button"); addButton.type = "button"; + // Only the primary action where play is not shown at all — with play + // shown, plain P2P add is a secondary option next to it. + if (!showPlay) addButton.className = "primary"; addButton.textContent = "add"; tagControl(addButton, rowKey, "add"); addButton.addEventListener("click", () => void addResult(result, "p2p")); @@ -2463,6 +2476,9 @@ function resultActions( if (debridAddAvailable && sources?.debridProvider) { const debridButton = document.createElement("button"); debridButton.type = "button"; + // Only the primary action where play is not shown at all — with play + // shown, debrid-add is a secondary option next to it. + if (!showPlay) debridButton.className = "primary"; debridButton.textContent = debridAddLabel(sources.debridProvider); tagControl(debridButton, rowKey, "debrid"); debridButton.addEventListener("click", () => void addResult(result, "debrid")); diff --git a/src/web/static/index.html b/src/web/static/index.html index cbfb6262..3963531f 100644 --- a/src/web/static/index.html +++ b/src/web/static/index.html @@ -12,16 +12,22 @@