diff --git a/frontend/src/components/UpNextCard.dom.test.tsx b/frontend/src/components/UpNextCard.dom.test.tsx index fe4912b1..0de4ed02 100644 --- a/frontend/src/components/UpNextCard.dom.test.tsx +++ b/frontend/src/components/UpNextCard.dom.test.tsx @@ -81,7 +81,9 @@ describe("UpNextCard", () => { expect(onAdvance).not.toHaveBeenCalled(); }); - it("an episode heading falls back to S?·E? when season/episode are null", async () => { + // An episode Plex has no number for (a special) is named by its title alone everywhere — a "?" + // told the viewer nothing, and the player's own header already shows the title alone. + it("an episode heading drops the numbers when Plex has none for it", async () => { (api.plexItem as ReturnType).mockResolvedValue({ id: "e7", kind: "episode", @@ -91,6 +93,8 @@ describe("UpNextCard", () => { episode_number: null, }); renderCard(); - expect(await screen.findByText("Westworld · S?·E?")).toBeTruthy(); + expect(await screen.findByText("Westworld")).toBeTruthy(); + expect(screen.getByText("The Adversary")).toBeTruthy(); + expect(screen.queryByText(/\?/)).toBeNull(); }); }); diff --git a/frontend/src/components/UpNextCard.tsx b/frontend/src/components/UpNextCard.tsx index 72d15ece..9e03daa1 100644 --- a/frontend/src/components/UpNextCard.tsx +++ b/frontend/src/components/UpNextCard.tsx @@ -6,6 +6,7 @@ import { api } from "../lib/api"; import { plexLang } from "../i18n"; import { qk } from "../lib/queryKeys"; import { cardImage } from "../lib/plexImage"; +import { episodeNumbers } from "../lib/plexDetail"; import { Button } from "./ui/Button"; import { IconButton } from "./ui/IconButton"; @@ -55,10 +56,14 @@ export function UpNextCard({ const R = 10; const C = 2 * Math.PI * R; - // Title: "Show · S2·E5" over the episode title for episodes; the plain title for movies/queue items. + // Title: "Show · S2·E5" over the episode title for episodes (the show alone when Plex has no + // number for the episode); the plain title for movies/queue items. + const nums = next ? episodeNumbers(next) : null; const heading = next?.kind === "episode" && next.show_title - ? `${next.show_title} · S${next.season_number ?? "?"}·E${next.episode_number ?? "?"}` + ? nums + ? `${next.show_title} · S${nums.season}·E${nums.episode}` + : next.show_title : (next?.title ?? ""); const sub = next?.kind === "episode" ? next.title : null; diff --git a/frontend/src/components/plexCards.dom.test.tsx b/frontend/src/components/plexCards.dom.test.tsx index 6f508f8c..cd7b6df7 100644 --- a/frontend/src/components/plexCards.dom.test.tsx +++ b/frontend/src/components/plexCards.dom.test.tsx @@ -11,7 +11,11 @@ vi.mock("react-i18next", async (importOriginal) => { const actual = await importOriginal(); return { ...actual, - useTranslation: () => ({ i18n: { language: "en" }, t: (key: string) => key }), + // The key, and the episode a "next" line names after a colon (`{ name }`). + useTranslation: () => ({ + i18n: { language: "en" }, + t: (key: string, o?: { name?: string }) => (o?.name ? `${key}:${o.name}` : key), + }), }; }); @@ -155,8 +159,8 @@ describe("PlexPosterCard with a next episode", () => { />, ); expect(playGlyph(container)).not.toBeNull(); - expect(screen.getByText("plex.nextUp")).toBeTruthy(); - fireEvent.click(screen.getByRole("button", { name: "plex.markNextWatched" })); + expect(screen.getByText("plex.nextUp:S6·E16")).toBeTruthy(); + fireEvent.click(screen.getByRole("button", { name: "plex.markNextWatched:S6·E16" })); expect(onToggleWatched).toHaveBeenCalledWith(next); // The episode's own add ("Add episode"), not the show's. fireEvent.click(screen.getByRole("button", { name: "plex.playlist.addEpisode" })); @@ -370,6 +374,34 @@ describe("PlexPosterCard on a shelf", () => { }); }); +// An episode Plex has no number for (a special) is named by its title alone, as the player's own +// header names it — the cards printed a question mark where the number was missing. +describe("an episode card with no number for the episode", () => { + const special: PlexCard = { + ...episode(), + title: "The Special", + show_id: "sh", + show_title: "Show", + season_number: 0, + episode_number: null, + }; + + it("names a show's episode by its title alone", () => { + render(); + expect(screen.queryByText("plex.episodeLine")).toBeNull(); + expect(screen.getByText("The Special")).toBeTruthy(); + }); + + it("names the next episode by its title alone", () => { + const show: PlexCard = { id: "sh", type: "show", title: "Show", thumb: "", season_count: 6 }; + render( + , + ); + expect(screen.getByText("plex.nextUp:The Special")).toBeTruthy(); + expect(screen.getByRole("button", { name: "plex.markNextWatched:The Special" })).toBeTruthy(); + }); +}); + // A card fetched the detail page's 400px poster for its ~144px picture. It offers the // proxy's small cut too and lets the browser pick by its width. describe("a Plex card's picture", () => { diff --git a/frontend/src/components/plexCards.tsx b/frontend/src/components/plexCards.tsx index 8ab9c281..8fdfaeb5 100644 --- a/frontend/src/components/plexCards.tsx +++ b/frontend/src/components/plexCards.tsx @@ -5,7 +5,7 @@ import { type ReactNode, useRef } from "react"; import { formatDecimal, formatRuntime } from "../lib/format"; import { Carousel } from "./ui/Carousel"; import type { PlexCard, PlexCastMember, PlexFilters, PlexSeasonDetail } from "../lib/api"; -import { seasonTitle } from "../lib/plexDetail"; +import { episodeName, episodeNumbers, seasonTitle } from "../lib/plexDetail"; import { navItemAttrs } from "../lib/listNav"; import { endsTextSelection, movedPastSlop } from "../lib/textSelection"; import { plexAddLabelKey, plexStarted } from "./usePlexCardActions"; @@ -318,7 +318,8 @@ export function PlexPosterCard({ const m = more(card, menuButtonProps, menuOpen); const toggle = isPlayable && onToggleWatched ? () => onToggleWatched(acted) : undefined; const add = isPlayable && onAdd ? () => onAdd(acted) : undefined; - const epCode = ep && { season: ep.season_number ?? "?", episode: ep.episode_number ?? "?" }; + // The next episode by name: its numbers, or its title when Plex has none for it. + const nextName = ep && episodeName(ep); // A show has no length: its corner says how many seasons it has instead, so the corner alone tells // a movie from a show (the film/TV glyph it replaces said the same thing a second time). const corner = @@ -361,8 +362,10 @@ export function PlexPosterCard({ - {t("plex.episodeLine", { - season: card.season_number ?? "?", - episode: card.episode_number ?? "?", - title: card.title, - })} + {episodeNumbers(card) + ? t("plex.episodeLine", { ...episodeNumbers(card)!, title: card.title }) + : card.title} + + ) : nextName ? ( +
+ {t("plex.nextUp", { name: nextName })}
- ) : epCode ? ( -
{t("plex.nextUp", epCode)}
) : ( (!!card.year || card.rating != null) && (
@@ -741,8 +744,8 @@ export function EpisodeCard({ ep.show_title && (
{ep.show_title} - {ep.season_number != null && ep.episode_number != null - ? ` · S${ep.season_number}·E${ep.episode_number}` + {episodeNumbers(ep) + ? ` · S${episodeNumbers(ep)!.season}·E${episodeNumbers(ep)!.episode}` : ""}
) diff --git a/frontend/src/i18n/locales/en/plex.json b/frontend/src/i18n/locales/en/plex.json index 721b4ac9..81a520f1 100644 --- a/frontend/src/i18n/locales/en/plex.json +++ b/frontend/src/i18n/locales/en/plex.json @@ -121,9 +121,9 @@ "menu": "Media", "markWatched": "Mark watched", "markUnwatched": "Mark unwatched", - "markNextWatched": "Mark S{{season}}·E{{episode}} watched", - "markNextUnwatched": "Mark S{{season}}·E{{episode}} unwatched", - "nextUp": "Next: S{{season}}·E{{episode}}", + "markNextWatched": "Mark {{name}} watched", + "markNextUnwatched": "Mark {{name}} unwatched", + "nextUp": "Next: {{name}}", "episodeLine": "S{{season}}·E{{episode}} · {{title}}", "seasonTitle": "Season {{n}}", "specials": "Specials", diff --git a/frontend/src/i18n/locales/hu/plex.json b/frontend/src/i18n/locales/hu/plex.json index eeb79d5f..d6a081e4 100644 --- a/frontend/src/i18n/locales/hu/plex.json +++ b/frontend/src/i18n/locales/hu/plex.json @@ -121,9 +121,9 @@ "menu": "Média", "markWatched": "Megnézettnek jelöl", "markUnwatched": "Nem-nézettnek jelöl", - "markNextWatched": "S{{season}}·E{{episode}} megnézettnek jelöl", - "markNextUnwatched": "S{{season}}·E{{episode}} nem-nézettnek jelöl", - "nextUp": "Következik: S{{season}}·E{{episode}}", + "markNextWatched": "{{name}} megnézettnek jelöl", + "markNextUnwatched": "{{name}} nem-nézettnek jelöl", + "nextUp": "Következik: {{name}}", "episodeLine": "S{{season}}·E{{episode}} · {{title}}", "seasonTitle": "{{n}}. évad", "specials": "Különkiadások", diff --git a/frontend/src/lib/plexDetail.ts b/frontend/src/lib/plexDetail.ts index 002e068a..3e6cac85 100644 --- a/frontend/src/lib/plexDetail.ts +++ b/frontend/src/lib/plexDetail.ts @@ -205,20 +205,31 @@ export function useCrossfadeBackdrop( // Series detail pages (show + season subpage): the S·E label under a resume/next-up episode. export function epLabel(ep?: PlexCard | null): string | undefined { if (!ep) return undefined; - if (ep.season_number != null && ep.episode_number != null) - return `S${ep.season_number} · E${ep.episode_number}`; - return ep.title; + const n = episodeNumbers(ep); + return n ? `S${n.season} · E${n.episode}` : ep.title; } -/** An episode's line under its show's name: "S1·E4 — Title" — or the title alone when Plex has no - * number for it (a special): a template literal printed the missing one as "Enull", JSX as "E". */ -export function episodeLine(ep: { - season_number?: number | null; - episode_number?: number | null; - title: string; -}): string { - if (ep.season_number == null || ep.episode_number == null) return ep.title; - return `S${ep.season_number}·E${ep.episode_number} — ${ep.title}`; +type Numbered = { season_number?: number | null; episode_number?: number | null }; + +/** An episode's season and episode number, or null when Plex has no number for one of them (a + * special). Every place that names an episode asks this, so a numberless one is named by its title + * alone everywhere: a template literal printed the missing number as "Enull", JSX as "E", and a + * `?? "?"` as "E?", which told the viewer nothing. */ +export function episodeNumbers(ep: Numbered): { season: number; episode: number } | null { + if (ep.season_number == null || ep.episode_number == null) return null; + return { season: ep.season_number, episode: ep.episode_number }; +} + +/** An episode named in a line of its own ("Next: …", "Mark … watched"): "S1·E4", or its title. */ +export function episodeName(ep: Numbered & { title: string }): string { + const n = episodeNumbers(ep); + return n ? `S${n.season}·E${n.episode}` : ep.title; +} + +/** An episode's line under its show's name: "S1·E4 — Title", or the title alone (see above). */ +export function episodeLine(ep: Numbered & { title: string }): string { + const n = episodeNumbers(ep); + return n ? `S${n.season}·E${n.episode} — ${ep.title}` : ep.title; } // Plex names seasons in the library's language ("Season 1", "Specials"), so a Hungarian reader got