Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 35 additions & 0 deletions src/ui/components/Results.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(
<StoreContext.Provider value={makeTestStore({ query: "linux iso", section: "games", streamResult })}>
<Results reccConfig={{}} />
</StoreContext.Provider>,
);
const u = ui;
await vi.waitFor(() => expect(u.frame()).toContain(`Results (${gamesList.length})`));
u.press("v");
await new Promise<void>((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(
<StoreContext.Provider value={makeTestStore({ query: "linux iso", streamResult })}>
<Results reccConfig={{}} />
</StoreContext.Provider>,
);
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
Expand Down
8 changes: 6 additions & 2 deletions src/ui/components/Results.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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));
Expand Down
44 changes: 30 additions & 14 deletions src/web/static/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,7 @@ import {
parseGrouping,
parseLayout,
parseSort,
playApplies,
previewApplies,
adultPreviewApplies,
reportsHealthLookup,
Expand Down Expand Up @@ -272,6 +273,7 @@ const settingsSourcesBox = el<HTMLDivElement>("settings-sources");
const settingsAccountsBox = el<HTMLDivElement>("settings-accounts");
const settingsAccountsHint = el<HTMLParagraphElement>("settings-accounts-hint");

const logoHome = el<HTMLButtonElement>("logo-home");
const viewsNav = el<HTMLElement>("views");
const viewSearchTab = el<HTMLButtonElement>("view-search");
const viewReccTab = el<HTMLButtonElement>("view-recc");
Expand Down Expand Up @@ -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"));
Expand Down Expand Up @@ -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
Expand All @@ -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"));
Expand Down Expand Up @@ -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"));
Expand Down
26 changes: 16 additions & 10 deletions src/web/static/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -12,16 +12,22 @@
</head>
<body>
<header id="page-header">
<h1 aria-label="torlnk">
<svg class="wordmark-icon" viewBox="0 0 24 24" width="30" height="30" aria-hidden="true" focusable="false">
<path class="magnet-body" d="M7.6 3 V12.5 A4.4 4.4 0 0 0 16.4 12.5 V3" />
<rect class="magnet-tip magnet-tip-a" x="5.6" y="0.5" width="4" height="4.5" rx="0.8" />
<rect class="magnet-tip magnet-tip-b" x="14.4" y="0.5" width="4" height="4.5" rx="0.8" />
</svg>
<!-- aria-label on the heading, not just on the icon: below 34rem the
text hides (CSS only — the DOM node stays, screen readers still
get "torlnk") to give the nav room to sit on the same row. -->
<span class="wordmark-text">torlnk</span>
<h1>
<!-- A real button, not a clickable <h1>: it needs to be reachable by
keyboard and to fire on Enter/Space, and a plain heading gives
neither for free. aria-label carries "torlnk" here rather than on
the heading, for the same reason it used to sit on the heading:
below 34rem the text hides (CSS only — the DOM node stays, screen
readers still get "torlnk") to give the nav room to sit on the
same row. -->
<button type="button" id="logo-home" class="wordmark-button" aria-label="torlnk">
<svg class="wordmark-icon" viewBox="0 0 24 24" width="30" height="30" aria-hidden="true" focusable="false">
<path class="magnet-body" d="M7.6 3 V12.5 A4.4 4.4 0 0 0 16.4 12.5 V3" />
<rect class="magnet-tip magnet-tip-a" x="5.6" y="0.5" width="4" height="4.5" rx="0.8" />
<rect class="magnet-tip magnet-tip-b" x="14.4" y="0.5" width="4" height="4.5" rx="0.8" />
</svg>
<span class="wordmark-text">torlnk</span>
</button>
</h1>
<!-- Search / For You / Saved / Queue. Buttons rather than links: it is one
page with four panes, and there is no route to navigate to.
Expand Down
17 changes: 17 additions & 0 deletions src/web/static/searchModel.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import {
modeForQuery,
parseGrouping,
parseLayout,
playApplies,
previewApplies,
adultPreviewApplies,
progressLabel,
Expand Down Expand Up @@ -641,6 +642,22 @@ describe("previewApplies", () => {
});
});

describe("playApplies", () => {
it("is false for Games and Books, which have no video file to play", () => {
expect(playApplies("Games")).toBe(false);
expect(playApplies("Books")).toBe(false);
});

it("is true everywhere else, including All", () => {
expect(playApplies(ALL_TAB)).toBe(true);
expect(playApplies("Movies")).toBe(true);
expect(playApplies("TV")).toBe(true);
expect(playApplies("Anime")).toBe(true);
expect(playApplies("Music")).toBe(true);
expect(playApplies("Porn")).toBe(true);
});
});

describe("adultPreviewApplies", () => {
it("is true only for the adult group", () => {
expect(adultPreviewApplies("Porn")).toBe(true);
Expand Down
12 changes: 12 additions & 0 deletions src/web/static/searchModel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -645,6 +645,18 @@ export function previewApplies(group: string): boolean {
return group === ALL_TAB || group === "Movies" || group === "TV" || group === "Anime";
}

/**
* Whether "play" is a sensible primary action for this tab.
*
* Games and Books results are installers, archives, and documents — the file
* picker's video-extension heuristic (`streamCandidates`) has nothing to latch
* onto, so play falls back to "every file" and streams whatever's first,
* which is never right. Every other tab, including "All", is mostly video.
*/
export function playApplies(group: string): boolean {
return group !== "Games" && group !== "Books";
}

/**
* Whether a group gets the LOCAL detail pane instead of the OMDb one.
*
Expand Down
31 changes: 26 additions & 5 deletions src/web/static/styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -199,7 +199,7 @@ header {
- The wordmark loses its TEXT, not the magnet icon — the icon alone is
the thing that's on the favicon and the tab title anyway, and losing
"torlnk" recovers ~85px a phone header cannot spare. aria-label on the
<h1> (index.html) keeps a screen reader's announcement unchanged.
button (index.html) keeps a screen reader's announcement unchanged.
- The four nav buttons go icon-only, each with its label text hidden the
same way and an aria-label carrying it instead. Four ~30px icon buttons
fit easily where four text pills could not. */
Expand Down Expand Up @@ -238,14 +238,29 @@ header {
}

h1 {
display: flex;
align-items: center;
margin: 0;
}

/* Resets the <button> back to looking exactly like the plain text/icon row it
replaced — the wordmark is a nav control now (click goes home), not just a
heading, but it must not look like one. */
.wordmark-button {
display: flex;
align-items: center;
gap: 0.4rem;
border: none;
background: none;
padding: 0;
margin: 0;
cursor: pointer;
color: inherit;
font: inherit;
font-family: var(--font-display);
font-weight: 700;
font-size: 1.35rem;
letter-spacing: 0.02em;
margin: 0;
text-transform: uppercase;
}

Expand Down Expand Up @@ -617,15 +632,21 @@ button:hover {
/* Play is the one control in a row that isn't queue housekeeping, so it carries
the accent at rest while pause/remove/delete stay dim until hovered. That is
also the safety argument: the destructive buttons sit millimetres away on a
phone and should not compete for the thumb. */
.row-actions button.play {
phone and should not compete for the thumb.
`.primary` carries the same treatment for whichever button stands in for
play where play doesn't apply — the debrid-add (or plain add) button on the
Games/Books tabs, so those rows still point at one obvious next step
instead of five buttons that all read as equally optional. */
.row-actions button.play,
.row-actions button.primary {
color: var(--sunken);
background: var(--accent);
border-color: var(--accent);
font-weight: 600;
}

.row-actions button.play:hover {
.row-actions button.play:hover,
.row-actions button.primary:hover {
color: var(--sunken);
background: var(--fg);
border-color: var(--fg);
Expand Down
Loading