fix(plex): an episode with no number is named by its title on every card
The cards, the up-next card and the mark-next label printed a question mark where Plex has no number; the player's header already showed the title alone. One helper (episodeNumbers/episodeName) for every place.
This commit is contained in:
@@ -81,7 +81,9 @@ describe("UpNextCard", () => {
|
|||||||
expect(onAdvance).not.toHaveBeenCalled();
|
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<typeof vi.fn>).mockResolvedValue({
|
(api.plexItem as ReturnType<typeof vi.fn>).mockResolvedValue({
|
||||||
id: "e7",
|
id: "e7",
|
||||||
kind: "episode",
|
kind: "episode",
|
||||||
@@ -91,6 +93,8 @@ describe("UpNextCard", () => {
|
|||||||
episode_number: null,
|
episode_number: null,
|
||||||
});
|
});
|
||||||
renderCard();
|
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();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { api } from "../lib/api";
|
|||||||
import { plexLang } from "../i18n";
|
import { plexLang } from "../i18n";
|
||||||
import { qk } from "../lib/queryKeys";
|
import { qk } from "../lib/queryKeys";
|
||||||
import { cardImage } from "../lib/plexImage";
|
import { cardImage } from "../lib/plexImage";
|
||||||
|
import { episodeNumbers } from "../lib/plexDetail";
|
||||||
import { Button } from "./ui/Button";
|
import { Button } from "./ui/Button";
|
||||||
import { IconButton } from "./ui/IconButton";
|
import { IconButton } from "./ui/IconButton";
|
||||||
|
|
||||||
@@ -55,10 +56,14 @@ export function UpNextCard({
|
|||||||
const R = 10;
|
const R = 10;
|
||||||
const C = 2 * Math.PI * R;
|
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 =
|
const heading =
|
||||||
next?.kind === "episode" && next.show_title
|
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 ?? "");
|
: (next?.title ?? "");
|
||||||
const sub = next?.kind === "episode" ? next.title : null;
|
const sub = next?.kind === "episode" ? next.title : null;
|
||||||
|
|
||||||
|
|||||||
@@ -11,7 +11,11 @@ vi.mock("react-i18next", async (importOriginal) => {
|
|||||||
const actual = await importOriginal<typeof import("react-i18next")>();
|
const actual = await importOriginal<typeof import("react-i18next")>();
|
||||||
return {
|
return {
|
||||||
...actual,
|
...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(playGlyph(container)).not.toBeNull();
|
||||||
expect(screen.getByText("plex.nextUp")).toBeTruthy();
|
expect(screen.getByText("plex.nextUp:S6·E16")).toBeTruthy();
|
||||||
fireEvent.click(screen.getByRole("button", { name: "plex.markNextWatched" }));
|
fireEvent.click(screen.getByRole("button", { name: "plex.markNextWatched:S6·E16" }));
|
||||||
expect(onToggleWatched).toHaveBeenCalledWith(next);
|
expect(onToggleWatched).toHaveBeenCalledWith(next);
|
||||||
// The episode's own add ("Add episode"), not the show's.
|
// The episode's own add ("Add episode"), not the show's.
|
||||||
fireEvent.click(screen.getByRole("button", { name: "plex.playlist.addEpisode" }));
|
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(<PlexPosterCard card={special} onOpen={vi.fn()} />);
|
||||||
|
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(
|
||||||
|
<PlexPosterCard card={show} next={special} onOpen={vi.fn()} onToggleWatched={vi.fn()} />,
|
||||||
|
);
|
||||||
|
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
|
// 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.
|
// proxy's small cut too and lets the browser pick by its width.
|
||||||
describe("a Plex card's picture", () => {
|
describe("a Plex card's picture", () => {
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { type ReactNode, useRef } from "react";
|
|||||||
import { formatDecimal, formatRuntime } from "../lib/format";
|
import { formatDecimal, formatRuntime } from "../lib/format";
|
||||||
import { Carousel } from "./ui/Carousel";
|
import { Carousel } from "./ui/Carousel";
|
||||||
import type { PlexCard, PlexCastMember, PlexFilters, PlexSeasonDetail } from "../lib/api";
|
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 { navItemAttrs } from "../lib/listNav";
|
||||||
import { endsTextSelection, movedPastSlop } from "../lib/textSelection";
|
import { endsTextSelection, movedPastSlop } from "../lib/textSelection";
|
||||||
import { plexAddLabelKey, plexStarted } from "./usePlexCardActions";
|
import { plexAddLabelKey, plexStarted } from "./usePlexCardActions";
|
||||||
@@ -318,7 +318,8 @@ export function PlexPosterCard({
|
|||||||
const m = more(card, menuButtonProps, menuOpen);
|
const m = more(card, menuButtonProps, menuOpen);
|
||||||
const toggle = isPlayable && onToggleWatched ? () => onToggleWatched(acted) : undefined;
|
const toggle = isPlayable && onToggleWatched ? () => onToggleWatched(acted) : undefined;
|
||||||
const add = isPlayable && onAdd ? () => onAdd(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 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).
|
// a movie from a show (the film/TV glyph it replaces said the same thing a second time).
|
||||||
const corner =
|
const corner =
|
||||||
@@ -361,8 +362,10 @@ export function PlexPosterCard({
|
|||||||
<HoverBar
|
<HoverBar
|
||||||
watched={actedWatched}
|
watched={actedWatched}
|
||||||
watchedLabel={
|
watchedLabel={
|
||||||
epCode
|
nextName
|
||||||
? t(actedWatched ? "plex.markNextUnwatched" : "plex.markNextWatched", epCode)
|
? t(actedWatched ? "plex.markNextUnwatched" : "plex.markNextWatched", {
|
||||||
|
name: nextName,
|
||||||
|
})
|
||||||
: actedWatched
|
: actedWatched
|
||||||
? t("plex.markUnwatched")
|
? t("plex.markUnwatched")
|
||||||
: t("plex.markWatched")
|
: t("plex.markWatched")
|
||||||
@@ -389,14 +392,14 @@ export function PlexPosterCard({
|
|||||||
meta={
|
meta={
|
||||||
asShow ? (
|
asShow ? (
|
||||||
<div className="truncate text-xs leading-6 text-muted">
|
<div className="truncate text-xs leading-6 text-muted">
|
||||||
{t("plex.episodeLine", {
|
{episodeNumbers(card)
|
||||||
season: card.season_number ?? "?",
|
? t("plex.episodeLine", { ...episodeNumbers(card)!, title: card.title })
|
||||||
episode: card.episode_number ?? "?",
|
: card.title}
|
||||||
title: card.title,
|
</div>
|
||||||
})}
|
) : nextName ? (
|
||||||
|
<div className="truncate text-xs leading-6 text-muted">
|
||||||
|
{t("plex.nextUp", { name: nextName })}
|
||||||
</div>
|
</div>
|
||||||
) : epCode ? (
|
|
||||||
<div className="truncate text-xs leading-6 text-muted">{t("plex.nextUp", epCode)}</div>
|
|
||||||
) : (
|
) : (
|
||||||
(!!card.year || card.rating != null) && (
|
(!!card.year || card.rating != null) && (
|
||||||
<div className="flex items-center gap-1.5 text-xs leading-6 text-muted">
|
<div className="flex items-center gap-1.5 text-xs leading-6 text-muted">
|
||||||
@@ -741,8 +744,8 @@ export function EpisodeCard({
|
|||||||
ep.show_title && (
|
ep.show_title && (
|
||||||
<div className="truncate text-xs text-muted">
|
<div className="truncate text-xs text-muted">
|
||||||
{ep.show_title}
|
{ep.show_title}
|
||||||
{ep.season_number != null && ep.episode_number != null
|
{episodeNumbers(ep)
|
||||||
? ` · S${ep.season_number}·E${ep.episode_number}`
|
? ` · S${episodeNumbers(ep)!.season}·E${episodeNumbers(ep)!.episode}`
|
||||||
: ""}
|
: ""}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -121,9 +121,9 @@
|
|||||||
"menu": "Media",
|
"menu": "Media",
|
||||||
"markWatched": "Mark watched",
|
"markWatched": "Mark watched",
|
||||||
"markUnwatched": "Mark unwatched",
|
"markUnwatched": "Mark unwatched",
|
||||||
"markNextWatched": "Mark S{{season}}·E{{episode}} watched",
|
"markNextWatched": "Mark {{name}} watched",
|
||||||
"markNextUnwatched": "Mark S{{season}}·E{{episode}} unwatched",
|
"markNextUnwatched": "Mark {{name}} unwatched",
|
||||||
"nextUp": "Next: S{{season}}·E{{episode}}",
|
"nextUp": "Next: {{name}}",
|
||||||
"episodeLine": "S{{season}}·E{{episode}} · {{title}}",
|
"episodeLine": "S{{season}}·E{{episode}} · {{title}}",
|
||||||
"seasonTitle": "Season {{n}}",
|
"seasonTitle": "Season {{n}}",
|
||||||
"specials": "Specials",
|
"specials": "Specials",
|
||||||
|
|||||||
@@ -121,9 +121,9 @@
|
|||||||
"menu": "Média",
|
"menu": "Média",
|
||||||
"markWatched": "Megnézettnek jelöl",
|
"markWatched": "Megnézettnek jelöl",
|
||||||
"markUnwatched": "Nem-nézettnek jelöl",
|
"markUnwatched": "Nem-nézettnek jelöl",
|
||||||
"markNextWatched": "S{{season}}·E{{episode}} megnézettnek jelöl",
|
"markNextWatched": "{{name}} megnézettnek jelöl",
|
||||||
"markNextUnwatched": "S{{season}}·E{{episode}} nem-nézettnek jelöl",
|
"markNextUnwatched": "{{name}} nem-nézettnek jelöl",
|
||||||
"nextUp": "Következik: S{{season}}·E{{episode}}",
|
"nextUp": "Következik: {{name}}",
|
||||||
"episodeLine": "S{{season}}·E{{episode}} · {{title}}",
|
"episodeLine": "S{{season}}·E{{episode}} · {{title}}",
|
||||||
"seasonTitle": "{{n}}. évad",
|
"seasonTitle": "{{n}}. évad",
|
||||||
"specials": "Különkiadások",
|
"specials": "Különkiadások",
|
||||||
|
|||||||
@@ -205,20 +205,31 @@ export function useCrossfadeBackdrop(
|
|||||||
// Series detail pages (show + season subpage): the S·E label under a resume/next-up episode.
|
// Series detail pages (show + season subpage): the S·E label under a resume/next-up episode.
|
||||||
export function epLabel(ep?: PlexCard | null): string | undefined {
|
export function epLabel(ep?: PlexCard | null): string | undefined {
|
||||||
if (!ep) return undefined;
|
if (!ep) return undefined;
|
||||||
if (ep.season_number != null && ep.episode_number != null)
|
const n = episodeNumbers(ep);
|
||||||
return `S${ep.season_number} · E${ep.episode_number}`;
|
return n ? `S${n.season} · E${n.episode}` : ep.title;
|
||||||
return ep.title;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** An episode's line under its show's name: "S1·E4 — Title" — or the title alone when Plex has no
|
type Numbered = { season_number?: number | null; episode_number?: number | null };
|
||||||
* number for it (a special): a template literal printed the missing one as "Enull", JSX as "E". */
|
|
||||||
export function episodeLine(ep: {
|
/** An episode's season and episode number, or null when Plex has no number for one of them (a
|
||||||
season_number?: number | null;
|
* special). Every place that names an episode asks this, so a numberless one is named by its title
|
||||||
episode_number?: number | null;
|
* alone everywhere: a template literal printed the missing number as "Enull", JSX as "E", and a
|
||||||
title: string;
|
* `?? "?"` as "E?", which told the viewer nothing. */
|
||||||
}): string {
|
export function episodeNumbers(ep: Numbered): { season: number; episode: number } | null {
|
||||||
if (ep.season_number == null || ep.episode_number == null) return ep.title;
|
if (ep.season_number == null || ep.episode_number == null) return null;
|
||||||
return `S${ep.season_number}·E${ep.episode_number} — ${ep.title}`;
|
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
|
// Plex names seasons in the library's language ("Season 1", "Specials"), so a Hungarian reader got
|
||||||
|
|||||||
Reference in New Issue
Block a user