feat(plex): every show card plays the episode it would put on
A show card's picture opened the show page everywhere but Home, whose shelves carried the
resolved `next`. Now every Plex picture plays: a show asks `GET /api/plex/show/{id}/next`
(the in-progress episode, else the first unwatched, else S1·E1 — the rule Home and the show
page's Resume follow) on the click, fresh each time. A started show shows Continue + Restart;
its title, an ⓘ and the menu's "Open show" row open its page; one with no episodes opens there.
The question is asked per show, never carried on every card: three probes a show would cost a
grid page up to 180 queries. The hover preview asks it once it opens and names the episode
("Next: S6·E16 — Title"). Related shows on a show page play the same way.
This commit is contained in:
@@ -14,6 +14,7 @@ from app.collation import collated, sorted_words
|
|||||||
from app import logs
|
from app import logs
|
||||||
from app.auth import current_user
|
from app.auth import current_user
|
||||||
from app.db import get_db
|
from app.db import get_db
|
||||||
|
from app.discover import queries
|
||||||
from app.models import (
|
from app.models import (
|
||||||
PlexCollection,
|
PlexCollection,
|
||||||
PlexItem,
|
PlexItem,
|
||||||
@@ -221,6 +222,24 @@ def show_detail(
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/show/{rating_key}/next")
|
||||||
|
def show_next(
|
||||||
|
rating_key: str,
|
||||||
|
lang: str = "en",
|
||||||
|
user: User = Depends(current_user),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
) -> dict:
|
||||||
|
"""The episode a show's card plays: the one in progress, else the first unwatched, else the first
|
||||||
|
(`discover.queries.next_episode`, the rule Home's shelves and the show page's Resume follow).
|
||||||
|
Asked one show at a time, when a card is hovered or played, rather than carried on every card of a
|
||||||
|
grid: three probes a show would cost a 60-card page 180 queries for the few cards anyone plays.
|
||||||
|
`next` is null for a show with no episodes."""
|
||||||
|
sh = by_rating_key(db, PlexShow, rating_key)
|
||||||
|
if sh is None:
|
||||||
|
raise not_found("show")
|
||||||
|
return {"next": queries.next_episode(db, user.id, sh.id, lang=_norm_lang(lang))}
|
||||||
|
|
||||||
|
|
||||||
def _season_block(se: PlexSeason, cards: list[dict]) -> dict:
|
def _season_block(se: PlexSeason, cards: list[dict]) -> dict:
|
||||||
"""A season's card for the show page (with its aggregate rollup) plus its episodes (used by the
|
"""A season's card for the show page (with its aggregate rollup) plus its episodes (used by the
|
||||||
season subpage, read from the same cached show-detail payload)."""
|
season subpage, read from the same cached show-detail payload)."""
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ import app.state as state
|
|||||||
from app.auth import current_user
|
from app.auth import current_user
|
||||||
from app.db import get_db
|
from app.db import get_db
|
||||||
from app.main import app
|
from app.main import app
|
||||||
from app.models import PlexItem, PlexLibrary, PlexSeason, PlexShow, User
|
from app.models import PlexItem, PlexLibrary, PlexSeason, PlexShow, PlexState, User
|
||||||
from app.plex import detail
|
from app.plex import detail
|
||||||
from app.plex.sync import _apply_facet_fields, _enrich_people_batch
|
from app.plex.sync import _apply_facet_fields, _enrich_people_batch
|
||||||
|
|
||||||
@@ -446,3 +446,63 @@ def test_item_detail_markers_carry_plex_final_flag(db, monkeypatch, override_dep
|
|||||||
{"type": "credits", "start_s": 6811.0, "end_s": 6831.0, "final": False},
|
{"type": "credits", "start_s": 6811.0, "end_s": 6831.0, "final": False},
|
||||||
{"type": "credits", "start_s": 7549.0, "end_s": 7559.0, "final": True},
|
{"type": "credits", "start_s": 7549.0, "end_s": 7559.0, "final": True},
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _show_with_episodes(db, n=3):
|
||||||
|
lib = PlexLibrary(plex_key="3", title="Shows", kind="show", enabled=True)
|
||||||
|
db.add(lib)
|
||||||
|
db.flush()
|
||||||
|
show = PlexShow(rating_key="sn1", library_id=lib.id, title="Next Show")
|
||||||
|
db.add(show)
|
||||||
|
db.flush()
|
||||||
|
eps = [
|
||||||
|
PlexItem(
|
||||||
|
rating_key=f"sn1e{i}",
|
||||||
|
library_id=lib.id,
|
||||||
|
kind="episode",
|
||||||
|
title=f"Episode {i}",
|
||||||
|
show_id=show.id,
|
||||||
|
season_number=1,
|
||||||
|
episode_number=i,
|
||||||
|
)
|
||||||
|
for i in range(1, n + 1)
|
||||||
|
]
|
||||||
|
db.add_all(eps)
|
||||||
|
db.flush()
|
||||||
|
return eps
|
||||||
|
|
||||||
|
|
||||||
|
def test_show_next_plays_the_first_episode_of_an_untouched_show(db, monkeypatch, override_dep):
|
||||||
|
"""A show card's picture plays: never started → its first episode."""
|
||||||
|
_show_with_episodes(db)
|
||||||
|
user = _user(db, "next1@example.com")
|
||||||
|
db.commit()
|
||||||
|
resp = _get(db, user, monkeypatch, override_dep, "/api/plex/show/sn1/next")
|
||||||
|
assert resp.status_code == 200, resp.text
|
||||||
|
nxt = resp.json()["next"]
|
||||||
|
assert nxt["id"] == "sn1e1"
|
||||||
|
assert nxt["show_title"] == "Next Show"
|
||||||
|
|
||||||
|
|
||||||
|
def test_show_next_continues_the_episode_in_progress(db, monkeypatch, override_dep):
|
||||||
|
"""Started → the episode in progress (the show page's Resume), not the first unwatched."""
|
||||||
|
eps = _show_with_episodes(db)
|
||||||
|
user = _user(db, "next2@example.com")
|
||||||
|
db.add(PlexState(user_id=user.id, item_id=eps[0].id, status="watched"))
|
||||||
|
db.add(PlexState(user_id=user.id, item_id=eps[2].id, status="new", position_seconds=120))
|
||||||
|
db.commit()
|
||||||
|
resp = _get(db, user, monkeypatch, override_dep, "/api/plex/show/sn1/next")
|
||||||
|
assert resp.json()["next"]["id"] == "sn1e3"
|
||||||
|
assert resp.json()["next"]["position_seconds"] == 120
|
||||||
|
|
||||||
|
|
||||||
|
def test_show_next_is_null_for_an_empty_show_and_404_for_an_unknown_one(
|
||||||
|
db, monkeypatch, override_dep
|
||||||
|
):
|
||||||
|
_show_with_episodes(db, n=0)
|
||||||
|
user = _user(db, "next3@example.com")
|
||||||
|
db.commit()
|
||||||
|
assert _get(db, user, monkeypatch, override_dep, "/api/plex/show/sn1/next").json() == {
|
||||||
|
"next": None
|
||||||
|
}
|
||||||
|
assert _get(db, user, monkeypatch, override_dep, "/api/plex/show/nope/next").status_code == 404
|
||||||
|
|||||||
+6
-2
@@ -400,7 +400,9 @@ pick a density step; it never changes the anatomy or loses an action. [Nielsen #
|
|||||||
`mediaTitleClass`, `mediaPictureBox`).
|
`mediaTitleClass`, `mediaPictureBox`).
|
||||||
- **Play only where the click plays.** The hover layer dims the picture and shows `MediaPlayGlyph`, the
|
- **Play only where the click plays.** The hover layer dims the picture and shows `MediaPlayGlyph`, the
|
||||||
one round media Play (the button family's `media` variant), only on a card whose click starts
|
one round media Play (the button family's `media` variant), only on a card whose click starts
|
||||||
playback; a show without a next episode opens instead and shows none. The hover row (watched, save or
|
playback. Every Plex picture plays, a show too: the episode in progress (Continue + Restart), else the
|
||||||
|
first unwatched, else the first, which the click asks the server for (`/show/{id}/next`); its title
|
||||||
|
and an ⓘ open the show's page, and one with no episodes opens there. The hover row (watched, save or
|
||||||
add, "…") docks at the picture's bottom-right; the length badge steps aside while it shows.
|
add, "…") docks at the picture's bottom-right; the length badge steps aside while it shows.
|
||||||
- **Density.** A grid card: a two-line title and the full meta (Plex: year · ★ score; a video: the
|
- **Density.** A grid card: a two-line title and the full meta (Plex: year · ★ score; a video: the
|
||||||
channel, then views · age). On a shelf (any horizontal strip) the title is one line, cut with an
|
channel, then views · age). On a shelf (any horizontal strip) the title is one line, cut with an
|
||||||
@@ -413,7 +415,8 @@ pick a density step; it never changes the anatomy or loses an action. [Nielsen #
|
|||||||
had become the strip's.
|
had become the strip's.
|
||||||
- **The preview (the owner's pick C).** Resting the mouse on a Plex card for ~0.9 s opens a panel beside
|
- **The preview (the owner's pick C).** Resting the mouse on a Plex card for ~0.9 s opens a panel beside
|
||||||
it: the whole title, year · length · ★, and the synopsis in short lines (`ui/MediaPreview` +
|
it: the whole title, year · length · ★, and the synopsis in short lines (`ui/MediaPreview` +
|
||||||
`useMediaPreview`). Mouse only (a keyboard walk or a tap never opens it; "…" → Media info has the same),
|
`useMediaPreview`); a show's adds the episode it would play ("Next: S6·E16 — Title"), asked as the
|
||||||
|
panel opens. Mouse only (a keyboard walk or a tap never opens it; "…" → Media info has the same),
|
||||||
passive (it never takes a click), and any press on the card closes it. Long text never goes in a
|
passive (it never takes a click), and any press on the card closes it. Long text never goes in a
|
||||||
native `title` tooltip.
|
native `title` tooltip.
|
||||||
- **Home's hero is a card** (the owner's pick A): the same hover row and Play on its picture, the same
|
- **Home's hero is a card** (the owner's pick A): the same hover row and Play on its picture, the same
|
||||||
@@ -718,3 +721,4 @@ of a full-width panel and nothing on the right.
|
|||||||
| 2026-10-08 | Plex subtitles and the control bar | The player draws the cues itself; while the bar shows they rise above it (200 ms), then settle back | The browser's own cues, moved by `line` | On an iPad the bar covered the text; a native cue cannot be animated or measured, and our layer behaves the same on every engine |
|
| 2026-10-08 | Plex subtitles and the control bar | The player draws the cues itself; while the bar shows they rise above it (200 ms), then settle back | The browser's own cues, moved by `line` | On an iPad the bar covered the text; a native cue cannot be animated or measured, and our layer behaves the same on every engine |
|
||||||
| 2026-10-08 | Hover on a button over a picture | The `media` variant's hover is a 2px accent ring over a darker body (Plex + YT cards, rail arrows; option B) | An accent fill (reads as "selected"); a white wash (weak on a bright poster) | The 60 → 75% black step was invisible, and over a card a rail arrow could not be told from the card's own Play under it |
|
| 2026-10-08 | Hover on a button over a picture | The `media` variant's hover is a 2px accent ring over a darker body (Plex + YT cards, rail arrows; option B) | An accent fill (reads as "selected"); a white wash (weak on a bright poster) | The 60 → 75% black step was invisible, and over a card a rail arrow could not be told from the card's own Play under it |
|
||||||
| 2026-10-08 | Plex "Add to collection" | A movie or show card's menu row opens a panel beside the card: its library's manual collections tick in place, a name + Enter creates one with the title in it (admin; option A) | An Add submenu with a "New…" dialog; the per-title editor modal from the menu | A collection took a trip to the Collections page, a name, a title search, then a second search per title; the panel is the video card's "Add to playlist" |
|
| 2026-10-08 | Plex "Add to collection" | A movie or show card's menu row opens a panel beside the card: its library's manual collections tick in place, a name + Enter creates one with the title in it (admin; option A) | An Add submenu with a "New…" dialog; the per-title editor modal from the menu | A collection took a trip to the Collections page, a name, a title search, then a second search per title; the panel is the video card's "Add to playlist" |
|
||||||
|
| 2026-10-09 | A Plex show card plays | Its picture plays the show's next episode everywhere (in progress → Continue + Restart, else the first unwatched, else S1·E1), resolved on the click; the preview names it once up; title and ⓘ open the show (option A + hover ask) | The picture opens the show page (as before); every card carrying `next` | A show card on Discover had no Play while Home's had one; carrying `next` costs a grid page up to 180 queries for the few cards anyone plays |
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ import { useDebounced } from "../lib/useDebounced";
|
|||||||
import { useHistorySubview } from "../lib/history";
|
import { useHistorySubview } from "../lib/history";
|
||||||
import { PLEX_SUB_URL, type Sub } from "./plexSubUrl";
|
import { PLEX_SUB_URL, type Sub } from "./plexSubUrl";
|
||||||
import { toggleWatchState } from "../lib/plexWatch";
|
import { toggleWatchState } from "../lib/plexWatch";
|
||||||
|
import { usePlayShow } from "./usePlayShow";
|
||||||
import SortControl from "./ui/SortControl";
|
import SortControl from "./ui/SortControl";
|
||||||
import FilterSelect from "./ui/FilterSelect";
|
import FilterSelect from "./ui/FilterSelect";
|
||||||
import PlexDiscover from "./PlexDiscover";
|
import PlexDiscover from "./PlexDiscover";
|
||||||
@@ -328,13 +329,23 @@ export default function PlexBrowse() {
|
|||||||
return () => io.disconnect();
|
return () => io.disconnect();
|
||||||
}, [sentinelEl, hasNextPage, isFetching, fetchNextPage]);
|
}, [sentinelEl, hasNextPage, isFetching, fetchNextPage]);
|
||||||
|
|
||||||
|
const resolveShow = usePlayShow(
|
||||||
|
(id, fromStart) => sub.open({ kind: "player", id, fromStart }),
|
||||||
|
(id) => sub.open({ kind: "show", id }),
|
||||||
|
);
|
||||||
|
// A show's card plays the episode it resolves to (usePlayShow); the grid's place is kept first.
|
||||||
|
function playShow(card: PlexCard, fromStart: boolean) {
|
||||||
|
scrollRef.current = scroller()?.scrollTop ?? 0;
|
||||||
|
void resolveShow(card, fromStart);
|
||||||
|
}
|
||||||
function onCard(card: PlexCard) {
|
function onCard(card: PlexCard) {
|
||||||
|
if (card.type === "show") return playShow(card, false);
|
||||||
scrollRef.current = scroller()?.scrollTop ?? 0; // remember grid position for the trip back
|
scrollRef.current = scroller()?.scrollTop ?? 0; // remember grid position for the trip back
|
||||||
if (card.type === "show") sub.open({ kind: "show", id: card.id });
|
sub.open({ kind: "player", id: card.id });
|
||||||
else sub.open({ kind: "player", id: card.id });
|
|
||||||
}
|
}
|
||||||
// The started card's Restart (C5): the same trip, from 0 instead of the resume point.
|
// The started card's Restart (C5): the same trip, from 0 instead of the resume point.
|
||||||
function onRestartCard(card: PlexCard) {
|
function onRestartCard(card: PlexCard) {
|
||||||
|
if (card.type === "show") return playShow(card, true);
|
||||||
scrollRef.current = scroller()?.scrollTop ?? 0;
|
scrollRef.current = scroller()?.scrollTop ?? 0;
|
||||||
sub.open({ kind: "player", id: card.id, fromStart: true });
|
sub.open({ kind: "player", id: card.id, fromStart: true });
|
||||||
}
|
}
|
||||||
@@ -509,6 +520,7 @@ export default function PlexBrowse() {
|
|||||||
}
|
}
|
||||||
onOpenSeason={(seasonId) => sub.open({ kind: "season", showId, seasonId })}
|
onOpenSeason={(seasonId) => sub.open({ kind: "season", showId, seasonId })}
|
||||||
onOpenShow={(id) => sub.open({ kind: "show", id })}
|
onOpenShow={(id) => sub.open({ kind: "show", id })}
|
||||||
|
onPlayShow={playShow}
|
||||||
onFilter={applyFilter}
|
onFilter={applyFilter}
|
||||||
onOpenPerson={onOpenPerson}
|
onOpenPerson={onOpenPerson}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -161,10 +161,27 @@ describe("a Plex title's menu", () => {
|
|||||||
expect(labels()).not.toContain("card.restartTitle");
|
expect(labels()).not.toContain("card.restartTitle");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("a show opens its page and has no watched, add or details rows", () => {
|
// A show plays like any title (the surface resolves the episode); its page is a row of its own.
|
||||||
setup(card({ type: "show" }));
|
// The watched toggle and the add belong to an episode, so a show has none.
|
||||||
|
it("a show plays, opens its page from a row of its own, and has no watched or add rows", async () => {
|
||||||
|
const { handlers } = setup(card({ type: "show" }));
|
||||||
rightClick();
|
rightClick();
|
||||||
expect(labels()).toEqual(["plex.openShow", "common.copy.title", "home.pin.add"]);
|
expect(labels()).toEqual(["plex.play", "plex.openShow", "common.copy.title", "home.pin.add"]);
|
||||||
|
fireEvent.click(item("plex.play"));
|
||||||
|
await vi.waitFor(() =>
|
||||||
|
expect(handlers.onOpen).toHaveBeenCalledWith(expect.objectContaining({ type: "show" })),
|
||||||
|
);
|
||||||
|
rightClick();
|
||||||
|
fireEvent.click(item("plex.openShow"));
|
||||||
|
await vi.waitFor(() =>
|
||||||
|
expect(handlers.onInfo).toHaveBeenCalledWith(expect.objectContaining({ type: "show" })),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("a started show continues, and starts its episode over from the next row", () => {
|
||||||
|
setup(card({ type: "show", status: "in_progress" }));
|
||||||
|
rightClick();
|
||||||
|
expect(labels().slice(0, 2)).toEqual(["card.continue", "card.restartTitle"]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("the card's '…' opens the same menu, and says so", () => {
|
it("the card's '…' opens the same menu, and says so", () => {
|
||||||
|
|||||||
@@ -5,7 +5,9 @@
|
|||||||
// aria-label — a dropped label silently reintroduces an unnamed button for screen-reader users. i18n is
|
// aria-label — a dropped label silently reintroduces an unnamed button for screen-reader users. i18n is
|
||||||
// key-passthrough, so the asserted names are the i18n keys.
|
// key-passthrough, so the asserted names are the i18n keys.
|
||||||
import { describe, it, expect, afterEach, vi } from "vitest";
|
import { describe, it, expect, afterEach, vi } from "vitest";
|
||||||
import { render, screen, cleanup, fireEvent, act } from "@testing-library/react";
|
import { render as rtlRender, screen, cleanup, fireEvent, act } from "@testing-library/react";
|
||||||
|
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||||
|
import type { ReactElement } from "react";
|
||||||
|
|
||||||
vi.mock("react-i18next", async (importOriginal) => {
|
vi.mock("react-i18next", async (importOriginal) => {
|
||||||
const actual = await importOriginal<typeof import("react-i18next")>();
|
const actual = await importOriginal<typeof import("react-i18next")>();
|
||||||
@@ -21,7 +23,15 @@ vi.mock("react-i18next", async (importOriginal) => {
|
|||||||
|
|
||||||
import { PlexPosterCard, SeasonCard, EpisodeCard } from "./plexCards";
|
import { PlexPosterCard, SeasonCard, EpisodeCard } from "./plexCards";
|
||||||
import { iconButtonClass } from "./ui/buttonStyles";
|
import { iconButtonClass } from "./ui/buttonStyles";
|
||||||
import type { PlexCard, PlexSeasonDetail } from "../lib/api";
|
import { api, type PlexCard, type PlexSeasonDetail } from "../lib/api";
|
||||||
|
|
||||||
|
// A show card asks the server which episode it would play once its preview is up: a query client.
|
||||||
|
const render = (ui: ReactElement) =>
|
||||||
|
rtlRender(ui, {
|
||||||
|
wrapper: ({ children }) => (
|
||||||
|
<QueryClientProvider client={new QueryClient()}>{children}</QueryClientProvider>
|
||||||
|
),
|
||||||
|
});
|
||||||
|
|
||||||
const movie = (status?: string): PlexCard => ({
|
const movie = (status?: string): PlexCard => ({
|
||||||
id: "1",
|
id: "1",
|
||||||
@@ -169,14 +179,57 @@ describe("PlexPosterCard with a next episode", () => {
|
|||||||
expect(onInfo).toHaveBeenCalled();
|
expect(onInfo).toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("stays a plain show card without one: no Play, no toggles, no ⓘ", () => {
|
// Every show card plays (the owner, 2026-10-09): without its episode in hand the surface's onOpen
|
||||||
|
// asks which one. The toggles act on an episode, so they wait for one; the ⓘ is the show's page.
|
||||||
|
it("plays without one too, opens its page from the ⓘ, and offers no toggles", () => {
|
||||||
|
const onOpen = vi.fn();
|
||||||
|
const onPage = vi.fn();
|
||||||
const { container } = render(
|
const { container } = render(
|
||||||
<PlexPosterCard card={show} onOpen={vi.fn()} onToggleWatched={vi.fn()} onInfo={vi.fn()} />,
|
<PlexPosterCard card={show} onOpen={onOpen} onPage={onPage} onToggleWatched={vi.fn()} />,
|
||||||
);
|
);
|
||||||
expect(playGlyph(container)).toBeNull();
|
expect(playGlyph(container)).not.toBeNull();
|
||||||
expect(screen.queryByRole("button", { name: "plex.openShow" })).toBeNull();
|
fireEvent.click(screen.getByRole("button", { name: "Show" }));
|
||||||
|
expect(onOpen).toHaveBeenCalledTimes(1);
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "plex.openShow" }));
|
||||||
|
expect(onPage).toHaveBeenCalledWith(show);
|
||||||
expect(screen.queryByRole("button", { name: "plex.markWatched" })).toBeNull();
|
expect(screen.queryByRole("button", { name: "plex.markWatched" })).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("continues a started show and starts its episode over, as a started movie does", () => {
|
||||||
|
const onOpen = vi.fn();
|
||||||
|
const onRestart = vi.fn();
|
||||||
|
render(
|
||||||
|
<PlexPosterCard
|
||||||
|
card={{ ...show, status: "in_progress" }}
|
||||||
|
onOpen={onOpen}
|
||||||
|
onRestart={onRestart}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "card.restart" }));
|
||||||
|
expect(onRestart).toHaveBeenCalledTimes(1);
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "card.continue" }));
|
||||||
|
expect(onOpen).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("names the episode it would play once its preview is up, and not asked before", async () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
const ask = vi
|
||||||
|
.spyOn(api, "plexShowNext")
|
||||||
|
.mockResolvedValue({ next: { ...next, title: "Pilot" } });
|
||||||
|
render(<PlexPosterCard card={show} onOpen={vi.fn()} />);
|
||||||
|
fireEvent.mouseEnter(screen.getByRole("button", { name: "Show" }).closest(".cv-poster")!);
|
||||||
|
act(() => {
|
||||||
|
vi.advanceTimersByTime(600);
|
||||||
|
});
|
||||||
|
expect(ask).not.toHaveBeenCalled(); // a sweep across a grid asks nothing
|
||||||
|
await act(async () => {
|
||||||
|
vi.advanceTimersByTime(400);
|
||||||
|
});
|
||||||
|
vi.useRealTimers();
|
||||||
|
expect(ask).toHaveBeenCalledWith("sh", "en");
|
||||||
|
expect(await screen.findByText("plex.nextUp:S6·E16 — Pilot")).toBeTruthy();
|
||||||
|
ask.mockRestore();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// C5: a started item's picture offers Continue and Restart (the video card's pair) instead of a Play;
|
// C5: a started item's picture offers Continue and Restart (the video card's pair) instead of a Play;
|
||||||
|
|||||||
@@ -5,7 +5,9 @@
|
|||||||
// ("Season 1", "Specials") is named from its number instead. i18n here echoes the key AND its values,
|
// ("Season 1", "Specials") is named from its number instead. i18n here echoes the key AND its values,
|
||||||
// so an assertion can see which words went into the line.
|
// so an assertion can see which words went into the line.
|
||||||
import { describe, it, expect, afterEach, vi } from "vitest";
|
import { describe, it, expect, afterEach, vi } from "vitest";
|
||||||
import { render, screen, cleanup } from "@testing-library/react";
|
import { render as rtlRender, screen, cleanup } from "@testing-library/react";
|
||||||
|
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||||
|
import type { ReactElement } from "react";
|
||||||
|
|
||||||
vi.mock("react-i18next", async (importOriginal) => {
|
vi.mock("react-i18next", async (importOriginal) => {
|
||||||
const actual = await importOriginal<typeof import("react-i18next")>();
|
const actual = await importOriginal<typeof import("react-i18next")>();
|
||||||
@@ -17,6 +19,14 @@ vi.mock("react-i18next", async (importOriginal) => {
|
|||||||
import { PlexPosterCard, SeasonCard } from "./plexCards";
|
import { PlexPosterCard, SeasonCard } from "./plexCards";
|
||||||
import type { PlexCard, PlexSeasonDetail } from "../lib/api";
|
import type { PlexCard, PlexSeasonDetail } from "../lib/api";
|
||||||
|
|
||||||
|
// A show card asks the server which episode it would play once its preview is up: a query client.
|
||||||
|
const render = (ui: ReactElement) =>
|
||||||
|
rtlRender(ui, {
|
||||||
|
wrapper: ({ children }) => (
|
||||||
|
<QueryClientProvider client={new QueryClient()}>{children}</QueryClientProvider>
|
||||||
|
),
|
||||||
|
});
|
||||||
|
|
||||||
afterEach(cleanup);
|
afterEach(cleanup);
|
||||||
|
|
||||||
const watchedEpisode: PlexCard = {
|
const watchedEpisode: PlexCard = {
|
||||||
|
|||||||
@@ -4,11 +4,21 @@
|
|||||||
// React Compiler memoizes a call on its ARGUMENTS — `formatRuntime(card.duration_seconds)` stayed
|
// React Compiler memoizes a call on its ARGUMENTS — `formatRuntime(card.duration_seconds)` stayed
|
||||||
// "2 hr 4 min" after switching to Hungarian while the hero beside it said "2 ó 16 p". Real catalog.
|
// "2 hr 4 min" after switching to Hungarian while the hero beside it said "2 ó 16 p". Real catalog.
|
||||||
import { describe, it, expect, afterEach, vi } from "vitest";
|
import { describe, it, expect, afterEach, vi } from "vitest";
|
||||||
import { render, screen, cleanup, act } from "@testing-library/react";
|
import { render as rtlRender, screen, cleanup, act } from "@testing-library/react";
|
||||||
|
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||||
|
import type { ReactElement } from "react";
|
||||||
import { setLanguage } from "../i18n";
|
import { setLanguage } from "../i18n";
|
||||||
import { PlexPosterCard } from "./plexCards";
|
import { PlexPosterCard } from "./plexCards";
|
||||||
import type { PlexCard } from "../lib/api";
|
import type { PlexCard } from "../lib/api";
|
||||||
|
|
||||||
|
// A show card asks the server which episode it would play once its preview is up: a query client.
|
||||||
|
const render = (ui: ReactElement) =>
|
||||||
|
rtlRender(ui, {
|
||||||
|
wrapper: ({ children }) => (
|
||||||
|
<QueryClientProvider client={new QueryClient()}>{children}</QueryClientProvider>
|
||||||
|
),
|
||||||
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
cleanup();
|
cleanup();
|
||||||
setLanguage("en");
|
setLanguage("en");
|
||||||
|
|||||||
@@ -2,10 +2,14 @@ import { useTranslation } from "react-i18next";
|
|||||||
import { CheckCircle2, Circle, Info, ListPlus, Star } from "lucide-react";
|
import { CheckCircle2, Circle, Info, ListPlus, Star } from "lucide-react";
|
||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
import { type ReactNode, useRef } from "react";
|
import { type ReactNode, useRef } from "react";
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import { api } from "../lib/api";
|
||||||
|
import { qk } from "../lib/queryKeys";
|
||||||
|
import { plexLang } from "../i18n";
|
||||||
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 { episodeName, episodeNumbers, seasonTitle } from "../lib/plexDetail";
|
import { episodeLine, 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";
|
||||||
@@ -30,8 +34,9 @@ import { useMediaPreview } from "./ui/useMediaPreview";
|
|||||||
import { SURFACE } from "./ui/surfaces";
|
import { SURFACE } from "./ui/surfaces";
|
||||||
import { cardImage } from "../lib/plexImage";
|
import { cardImage } from "../lib/plexImage";
|
||||||
|
|
||||||
// Presentational Plex cards + strips, split out of PlexBrowse (all purely prop-driven, no shared
|
// Presentational Plex cards + strips, split out of PlexBrowse (prop-driven, no shared state; the one
|
||||||
// state). Rendered by the browse grid, the person page, and the series show/season views.
|
// read is a show card asking which episode it would play, once its preview is up). Rendered by the
|
||||||
|
// browse grid, the person page, and the series show/season views.
|
||||||
//
|
//
|
||||||
// docs/DESIGN.md C1 ("Action placement"): a card shows its state (watched, progress) at rest;
|
// docs/DESIGN.md C1 ("Action placement"): a card shows its state (watched, progress) at rest;
|
||||||
// on hover its picture carries the everyday toggles (watched, add to playlist) and the "…" that
|
// on hover its picture carries the everyday toggles (watched, add to playlist) and the "…" that
|
||||||
@@ -74,7 +79,7 @@ function HoverBar({
|
|||||||
onToggleWatched?: () => void;
|
onToggleWatched?: () => void;
|
||||||
addLabel: string;
|
addLabel: string;
|
||||||
onAdd?: () => void;
|
onAdd?: () => void;
|
||||||
/** The item's own page, when the click does something else (a show on Home plays its next episode). */
|
/** A show's own page: its picture plays an episode, so the page has a button of its own. */
|
||||||
onInfo?: () => void;
|
onInfo?: () => void;
|
||||||
more: More;
|
more: More;
|
||||||
}) {
|
}) {
|
||||||
@@ -276,7 +281,7 @@ export function PlexPosterCard({
|
|||||||
/** Open a card's page (a show's, or a movie's or an episode's info page): the title's link. It is
|
/** Open a card's page (a show's, or a movie's or an episode's info page): the title's link. It is
|
||||||
* handed the card the title NAMES — the show, for an episode standing as its show. */
|
* handed the card the title NAMES — the show, for an episode standing as its show. */
|
||||||
onPage?: (target: PlexCard) => void;
|
onPage?: (target: PlexCard) => void;
|
||||||
/** Play the item (or the `next` episode) from its start, not its resume point (C5). */
|
/** Play the item (or the episode a show plays) from its start, not its resume point (C5). */
|
||||||
onRestart?: () => void;
|
onRestart?: () => void;
|
||||||
onToggleWatched?: (c: PlexCard) => void;
|
onToggleWatched?: (c: PlexCard) => void;
|
||||||
/** Add a movie or an episode to a playlist (the list's `usePlexCardActions().add`). */
|
/** Add a movie or an episode to a playlist (the list's `usePlexCardActions().add`). */
|
||||||
@@ -296,11 +301,12 @@ export function PlexPosterCard({
|
|||||||
wide?: boolean;
|
wide?: boolean;
|
||||||
/** On a shelf (a horizontal strip): a one-line title (ui/mediaCardStyles). */
|
/** On a shelf (a horizontal strip): a one-line title (ui/mediaCardStyles). */
|
||||||
shelf?: boolean;
|
shelf?: boolean;
|
||||||
/** A SHOW whose click plays this episode (Home's shelves: a pinned or started show plays on). The
|
/** A SHOW's episode, resolved by the surface (Home's shelves carry it): the watched toggle and the
|
||||||
* picture then draws its Play, the watched toggle and the add act on the episode, the meta line
|
* add act on it and the meta line names it. Without it a show still plays — the surface's `onOpen`
|
||||||
* names it, and an ⓘ (`onInfo`) opens the show. */
|
* asks the server which episode (`/show/{id}/next`) — and the preview names that episode once the
|
||||||
|
* pointer has rested on the card. */
|
||||||
next?: PlexCard | null;
|
next?: PlexCard | null;
|
||||||
/** The show's page, beside a `next` episode. */
|
/** The show's page, on the ⓘ (`onPage` where a surface has no other). */
|
||||||
onInfo?: () => void;
|
onInfo?: () => void;
|
||||||
}) {
|
}) {
|
||||||
const { t, i18n } = useTranslation();
|
const { t, i18n } = useTranslation();
|
||||||
@@ -315,9 +321,21 @@ export function PlexPosterCard({
|
|||||||
const titled: PlexCard = asShow
|
const titled: PlexCard = asShow
|
||||||
? { ...card, type: "show", id: card.show_id!, title: card.show_title! }
|
? { ...card, type: "show", id: card.show_id!, title: card.show_title! }
|
||||||
: card;
|
: card;
|
||||||
const ep = card.type === "show" ? (next ?? null) : null;
|
const isShow = card.type === "show";
|
||||||
// movies/episodes play; a show opens its drill-down — unless it plays on (`next`)
|
const ep = isShow ? (next ?? null) : null;
|
||||||
const isPlayable = card.type !== "show" || !!ep;
|
// Every picture plays: a show its next episode. The hover toggles act on a leaf, so a show offers
|
||||||
|
// them only with its episode in hand.
|
||||||
|
const isPlayable = !isShow || !!ep;
|
||||||
|
const plang = plexLang(lang);
|
||||||
|
// Which episode a show would play, for the preview: asked once the pointer rests on the card, and
|
||||||
|
// fresh every time (staleTime 0), since a watch anywhere moves it.
|
||||||
|
const upNextQ = useQuery({
|
||||||
|
queryKey: qk.plexShowNext(card.id, plang),
|
||||||
|
queryFn: () => api.plexShowNext(card.id, plang),
|
||||||
|
enabled: isShow && !ep && preview.open,
|
||||||
|
staleTime: 0,
|
||||||
|
});
|
||||||
|
const upNext = ep ?? upNextQ.data?.next ?? null;
|
||||||
// What the hover row acts on: the episode a show would play, else the card itself.
|
// What the hover row acts on: the episode a show would play, else the card itself.
|
||||||
const acted = ep ?? card;
|
const acted = ep ?? card;
|
||||||
const watched = card.status === "watched";
|
const watched = card.status === "watched";
|
||||||
@@ -329,6 +347,9 @@ export function PlexPosterCard({
|
|||||||
const add = isPlayable && onAdd ? () => onAdd(acted) : undefined;
|
const add = isPlayable && onAdd ? () => onAdd(acted) : undefined;
|
||||||
// The next episode by name: its numbers, or its title when Plex has none for it.
|
// The next episode by name: its numbers, or its title when Plex has none for it.
|
||||||
const nextName = ep && episodeName(ep);
|
const nextName = ep && episodeName(ep);
|
||||||
|
// A show without its episode in hand continues where a started one stands (its rollup status).
|
||||||
|
const started = ep ? plexStarted(ep) : isShow ? card.status === "in_progress" : plexStarted(card);
|
||||||
|
const showPage = ep ? onInfo : isShow && onPage ? () => onPage(card) : undefined;
|
||||||
// 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 =
|
||||||
@@ -363,10 +384,10 @@ export function PlexPosterCard({
|
|||||||
label={name}
|
label={name}
|
||||||
onOpen={onOpen}
|
onOpen={onOpen}
|
||||||
onRestart={onRestart}
|
onRestart={onRestart}
|
||||||
plays={isPlayable}
|
plays
|
||||||
small={shelf && landscape}
|
small={shelf && landscape}
|
||||||
pct={pct}
|
pct={pct}
|
||||||
started={plexStarted(acted)}
|
started={started}
|
||||||
bar={
|
bar={
|
||||||
<HoverBar
|
<HoverBar
|
||||||
watched={actedWatched}
|
watched={actedWatched}
|
||||||
@@ -382,7 +403,7 @@ export function PlexPosterCard({
|
|||||||
onToggleWatched={toggle}
|
onToggleWatched={toggle}
|
||||||
addLabel={t(plexAddLabelKey(acted))}
|
addLabel={t(plexAddLabelKey(acted))}
|
||||||
onAdd={add}
|
onAdd={add}
|
||||||
onInfo={ep ? onInfo : undefined}
|
onInfo={showPage}
|
||||||
more={m}
|
more={m}
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
@@ -432,6 +453,7 @@ export function PlexPosterCard({
|
|||||||
{rating}
|
{rating}
|
||||||
</span>
|
</span>
|
||||||
}
|
}
|
||||||
|
note={upNext && t("plex.nextUp", { name: episodeLine(upNext) })}
|
||||||
summary={card.summary}
|
summary={card.summary}
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
@@ -637,12 +659,18 @@ export function CastRow({
|
|||||||
export function RelatedStrip({
|
export function RelatedStrip({
|
||||||
related,
|
related,
|
||||||
onOpen,
|
onOpen,
|
||||||
|
onPlay,
|
||||||
|
onRestart,
|
||||||
menuProps,
|
menuProps,
|
||||||
menuButtonProps,
|
menuButtonProps,
|
||||||
openId = null,
|
openId = null,
|
||||||
}: {
|
}: {
|
||||||
related: PlexCard[];
|
related: PlexCard[];
|
||||||
|
/** A related show's page (its title and its ⓘ). */
|
||||||
onOpen: (c: PlexCard) => void;
|
onOpen: (c: PlexCard) => void;
|
||||||
|
/** Its picture: the episode it would play, as on every show card. */
|
||||||
|
onPlay: (c: PlexCard) => void;
|
||||||
|
onRestart: (c: PlexCard) => void;
|
||||||
/** The page's Plex menu (a related show is a Plex card like any other: DESIGN.md C1). */
|
/** The page's Plex menu (a related show is a Plex card like any other: DESIGN.md C1). */
|
||||||
menuProps?: (c: PlexCard) => Record<string, unknown>;
|
menuProps?: (c: PlexCard) => Record<string, unknown>;
|
||||||
menuButtonProps?: (c: PlexCard) => Record<string, unknown>;
|
menuButtonProps?: (c: PlexCard) => Record<string, unknown>;
|
||||||
@@ -663,8 +691,8 @@ export function RelatedStrip({
|
|||||||
card={r}
|
card={r}
|
||||||
shelf
|
shelf
|
||||||
navIndex={i}
|
navIndex={i}
|
||||||
onOpen={() => onOpen(r)}
|
onOpen={() => onPlay(r)}
|
||||||
// A related title is a show: its page is what both its picture and its title open.
|
onRestart={() => onRestart(r)}
|
||||||
onPage={onOpen}
|
onPage={onOpen}
|
||||||
menuProps={menuProps}
|
menuProps={menuProps}
|
||||||
menuButtonProps={menuButtonProps}
|
menuButtonProps={menuButtonProps}
|
||||||
|
|||||||
@@ -78,6 +78,7 @@ function renderShow(onFilter = vi.fn(), onOpenPerson = vi.fn()) {
|
|||||||
onPlay={() => {}}
|
onPlay={() => {}}
|
||||||
onOpenSeason={() => {}}
|
onOpenSeason={() => {}}
|
||||||
onOpenShow={() => {}}
|
onOpenShow={() => {}}
|
||||||
|
onPlayShow={() => {}}
|
||||||
onFilter={onFilter}
|
onFilter={onFilter}
|
||||||
onOpenPerson={onOpenPerson}
|
onOpenPerson={onOpenPerson}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -132,6 +132,7 @@ describe("the restart button on a show and a season", () => {
|
|||||||
onPlay={onPlay}
|
onPlay={onPlay}
|
||||||
onOpenSeason={() => {}}
|
onOpenSeason={() => {}}
|
||||||
onOpenShow={() => {}}
|
onOpenShow={() => {}}
|
||||||
|
onPlayShow={() => {}}
|
||||||
/>,
|
/>,
|
||||||
);
|
);
|
||||||
const btn = await screen.findByRole("button", { name: /plex\.series\.showFromStart/ });
|
const btn = await screen.findByRole("button", { name: /plex\.series\.showFromStart/ });
|
||||||
@@ -154,6 +155,7 @@ describe("the show page's summary", () => {
|
|||||||
onPlay={() => {}}
|
onPlay={() => {}}
|
||||||
onOpenSeason={() => {}}
|
onOpenSeason={() => {}}
|
||||||
onOpenShow={() => {}}
|
onOpenShow={() => {}}
|
||||||
|
onPlayShow={() => {}}
|
||||||
/>,
|
/>,
|
||||||
);
|
);
|
||||||
fireEvent.click(await screen.findByText("plex.textMore"));
|
fireEvent.click(await screen.findByText("plex.textMore"));
|
||||||
|
|||||||
@@ -422,6 +422,7 @@ export function PlexShowView({
|
|||||||
onPlay,
|
onPlay,
|
||||||
onOpenSeason,
|
onOpenSeason,
|
||||||
onOpenShow,
|
onOpenShow,
|
||||||
|
onPlayShow,
|
||||||
onFilter,
|
onFilter,
|
||||||
onOpenPerson,
|
onOpenPerson,
|
||||||
}: {
|
}: {
|
||||||
@@ -430,6 +431,8 @@ export function PlexShowView({
|
|||||||
onPlay: (epRk: string, queue: string[], fromStart?: boolean) => void;
|
onPlay: (epRk: string, queue: string[], fromStart?: boolean) => void;
|
||||||
onOpenSeason: (seasonId: string) => void;
|
onOpenSeason: (seasonId: string) => void;
|
||||||
onOpenShow: (id: string) => void;
|
onOpenShow: (id: string) => void;
|
||||||
|
/** A related show's picture: the episode it would play, as on every show card. */
|
||||||
|
onPlayShow: (show: PlexCard, fromStart: boolean) => void;
|
||||||
onFilter?: (patch: Partial<PlexFilters>) => void;
|
onFilter?: (patch: Partial<PlexFilters>) => void;
|
||||||
onOpenPerson?: (name: string) => void;
|
onOpenPerson?: (name: string) => void;
|
||||||
}) {
|
}) {
|
||||||
@@ -446,7 +449,14 @@ export function PlexShowView({
|
|||||||
const [collOpen, setCollOpen] = useState(false);
|
const [collOpen, setCollOpen] = useState(false);
|
||||||
const [busy, setBusy] = useState(false);
|
const [busy, setBusy] = useState(false);
|
||||||
const relatedMenu = useContextMenu<PlexCard>();
|
const relatedMenu = useContextMenu<PlexCard>();
|
||||||
const related = usePlexCardActions({ onOpen: (c) => onOpenShow(c.id) }, relatedMenu.open);
|
const related = usePlexCardActions(
|
||||||
|
{
|
||||||
|
onOpen: (c) => onPlayShow(c, false),
|
||||||
|
onRestart: (c) => onPlayShow(c, true),
|
||||||
|
onInfo: (c) => onOpenShow(c.id),
|
||||||
|
},
|
||||||
|
relatedMenu.open,
|
||||||
|
);
|
||||||
const seasonMenu = useContextMenu<PlexSeasonDetail>();
|
const seasonMenu = useContextMenu<PlexSeasonDetail>();
|
||||||
const copy = useCopyItems();
|
const copy = useCopyItems();
|
||||||
|
|
||||||
@@ -743,6 +753,8 @@ export function PlexShowView({
|
|||||||
<RelatedStrip
|
<RelatedStrip
|
||||||
related={d.related}
|
related={d.related}
|
||||||
onOpen={(c) => onOpenShow(c.id)}
|
onOpen={(c) => onOpenShow(c.id)}
|
||||||
|
onPlay={(c) => onPlayShow(c, false)}
|
||||||
|
onRestart={(c) => onPlayShow(c, true)}
|
||||||
menuProps={relatedMenu.triggerProps}
|
menuProps={relatedMenu.triggerProps}
|
||||||
menuButtonProps={relatedMenu.buttonProps}
|
menuButtonProps={relatedMenu.buttonProps}
|
||||||
openId={relatedMenu.open ? (relatedMenu.target?.id ?? null) : null}
|
openId={relatedMenu.open ? (relatedMenu.target?.id ?? null) : null}
|
||||||
|
|||||||
@@ -18,12 +18,15 @@ export function MediaPreview({
|
|||||||
preview,
|
preview,
|
||||||
title,
|
title,
|
||||||
facts,
|
facts,
|
||||||
|
note,
|
||||||
summary,
|
summary,
|
||||||
}: {
|
}: {
|
||||||
preview: ReturnType<typeof useMediaPreview>;
|
preview: ReturnType<typeof useMediaPreview>;
|
||||||
title: string;
|
title: string;
|
||||||
/** One line under the title: year, length, score. */
|
/** One line under the title: year, length, score. */
|
||||||
facts?: ReactNode;
|
facts?: ReactNode;
|
||||||
|
/** A line of its own under the facts (a show's "Next: S6·E16 — Title"). */
|
||||||
|
note?: ReactNode;
|
||||||
summary?: string | null;
|
summary?: string | null;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
@@ -36,6 +39,7 @@ export function MediaPreview({
|
|||||||
>
|
>
|
||||||
<p className="text-sm font-semibold leading-snug text-fg">{title}</p>
|
<p className="text-sm font-semibold leading-snug text-fg">{title}</p>
|
||||||
{facts && <div className="mt-0.5 text-xs text-muted">{facts}</div>}
|
{facts && <div className="mt-0.5 text-xs text-muted">{facts}</div>}
|
||||||
|
{note && <p className="mt-1 text-xs font-medium text-fg">{note}</p>}
|
||||||
{summary && (
|
{summary && (
|
||||||
<p className="mt-2 line-clamp-8 whitespace-pre-line text-xs leading-relaxed text-fg">
|
<p className="mt-2 line-clamp-8 whitespace-pre-line text-xs leading-relaxed text-fg">
|
||||||
{summary}
|
{summary}
|
||||||
|
|||||||
@@ -19,6 +19,8 @@ export function useMediaPreview(enabled = true) {
|
|||||||
});
|
});
|
||||||
return {
|
return {
|
||||||
popover: pop,
|
popover: pop,
|
||||||
|
/** The preview is up: the pointer has rested on the card (what a card may fetch for it waits on). */
|
||||||
|
open: open && enabled,
|
||||||
/** The card root's props: the anchor, the hover wiring, and a close on any press — merged with
|
/** The card root's props: the anchor, the hover wiring, and a close on any press — merged with
|
||||||
* the root's own (its menu's right-click and long-press). */
|
* the root's own (its menu's right-click and long-press). */
|
||||||
rootProps: (own: HTMLAttributes<HTMLDivElement> & Record<string, unknown> = {}) => ({
|
rootProps: (own: HTMLAttributes<HTMLDivElement> & Record<string, unknown> = {}) => ({
|
||||||
|
|||||||
@@ -0,0 +1,76 @@
|
|||||||
|
// @vitest-environment jsdom
|
||||||
|
//
|
||||||
|
// A show's card plays (the owner, 2026-10-09): the click asks the server which episode, every time —
|
||||||
|
// a preview answer from before a watch elsewhere must not decide it.
|
||||||
|
import { describe, it, expect, vi, afterEach } from "vitest";
|
||||||
|
import { renderHook, act } from "@testing-library/react";
|
||||||
|
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||||
|
import type { ReactNode } from "react";
|
||||||
|
|
||||||
|
const notified = vi.hoisted(() => vi.fn());
|
||||||
|
vi.mock("../lib/actionErrors", () => ({ notifyActionError: notified }));
|
||||||
|
vi.mock("react-i18next", async (importOriginal) => ({
|
||||||
|
...(await importOriginal<typeof import("react-i18next")>()),
|
||||||
|
useTranslation: () => ({ t: (k: string) => k, i18n: { language: "hu" } }),
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { api, type PlexCard } from "../lib/api";
|
||||||
|
import { qk } from "../lib/queryKeys";
|
||||||
|
import { usePlayShow } from "./usePlayShow";
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
notified.mockReset();
|
||||||
|
});
|
||||||
|
|
||||||
|
const show: PlexCard = { id: "sh", type: "show", title: "Show", thumb: "" };
|
||||||
|
const ep = (id: string): PlexCard => ({ id, type: "episode", title: id, thumb: "" });
|
||||||
|
|
||||||
|
function mount() {
|
||||||
|
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||||
|
const play = vi.fn();
|
||||||
|
const openShow = vi.fn();
|
||||||
|
const { result } = renderHook(() => usePlayShow(play, openShow), {
|
||||||
|
wrapper: ({ children }: { children: ReactNode }) => (
|
||||||
|
<QueryClientProvider client={qc}>{children}</QueryClientProvider>
|
||||||
|
),
|
||||||
|
});
|
||||||
|
return { qc, play, openShow, playShow: result.current };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("usePlayShow", () => {
|
||||||
|
it("plays the episode the server names, from the start when asked to", async () => {
|
||||||
|
const ask = vi.spyOn(api, "plexShowNext").mockResolvedValue({ next: ep("e7") });
|
||||||
|
const { play, openShow, playShow } = mount();
|
||||||
|
await act(() => playShow(show, true));
|
||||||
|
expect(ask).toHaveBeenCalledWith("sh", "hu");
|
||||||
|
expect(play).toHaveBeenCalledWith("e7", true);
|
||||||
|
expect(openShow).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("asks again rather than play what the preview cached before a watch", async () => {
|
||||||
|
const ask = vi.spyOn(api, "plexShowNext").mockResolvedValue({ next: ep("e8") });
|
||||||
|
const { qc, play, playShow } = mount();
|
||||||
|
qc.setQueryData(qk.plexShowNext("sh", "hu"), { next: ep("e7") });
|
||||||
|
await act(() => playShow(show, false));
|
||||||
|
expect(ask).toHaveBeenCalledTimes(1);
|
||||||
|
expect(play).toHaveBeenCalledWith("e8", false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("opens the page of a show with nothing to play", async () => {
|
||||||
|
vi.spyOn(api, "plexShowNext").mockResolvedValue({ next: null });
|
||||||
|
const { play, openShow, playShow } = mount();
|
||||||
|
await act(() => playShow(show, false));
|
||||||
|
expect(openShow).toHaveBeenCalledWith("sh");
|
||||||
|
expect(play).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("says so when the question fails, and opens nothing", async () => {
|
||||||
|
vi.spyOn(api, "plexShowNext").mockRejectedValue(new Error("down"));
|
||||||
|
const { play, openShow, playShow } = mount();
|
||||||
|
await act(() => playShow(show, false));
|
||||||
|
expect(notified).toHaveBeenCalledWith(expect.any(Error), "plex.playShowFailed");
|
||||||
|
expect(play).not.toHaveBeenCalled();
|
||||||
|
expect(openShow).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
import { useQueryClient } from "@tanstack/react-query";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { api, type PlexCard } from "../lib/api";
|
||||||
|
import { qk } from "../lib/queryKeys";
|
||||||
|
import { notifyActionError } from "../lib/actionErrors";
|
||||||
|
import { plexLang } from "../i18n";
|
||||||
|
|
||||||
|
/** A show's card plays like any card: the episode in progress, else the first unwatched, else the
|
||||||
|
* first (`/show/{id}/next`). Asked fresh on the click, since the card's preview may hold an answer
|
||||||
|
* from before a watch somewhere else. A show with no episodes has nothing to play, so its page opens
|
||||||
|
* (`openShow`) and says so; a failed question is a toast, and nothing opens. */
|
||||||
|
export function usePlayShow(
|
||||||
|
play: (episodeId: string, fromStart: boolean) => void,
|
||||||
|
openShow: (showId: string) => void,
|
||||||
|
) {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
const { t, i18n } = useTranslation();
|
||||||
|
const lang = plexLang(i18n.language);
|
||||||
|
// `undefined` when the question failed (said in the toast).
|
||||||
|
const next = (id: string) =>
|
||||||
|
qc
|
||||||
|
.fetchQuery({
|
||||||
|
queryKey: qk.plexShowNext(id, lang),
|
||||||
|
queryFn: () => api.plexShowNext(id, lang),
|
||||||
|
staleTime: 0,
|
||||||
|
})
|
||||||
|
.then(
|
||||||
|
(r) => r.next,
|
||||||
|
(e: unknown) => void notifyActionError(e, t("plex.playShowFailed")),
|
||||||
|
);
|
||||||
|
return async (show: PlexCard, fromStart: boolean) => {
|
||||||
|
const ep = await next(show.id);
|
||||||
|
if (ep === undefined) return;
|
||||||
|
if (ep) play(ep.id, fromStart);
|
||||||
|
else openShow(show.id);
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -17,8 +17,10 @@ import { type ActionMenuItem } from "./ui/ActionMenu";
|
|||||||
// the everyday toggles (watched, add to playlist) on hover; details, copy and the pin live here.
|
// the everyday toggles (watched, add to playlist) on hover; details, copy and the pin live here.
|
||||||
|
|
||||||
export interface PlexActionHandlers {
|
export interface PlexActionHandlers {
|
||||||
|
/** Play it: a show, the episode it would play (the surface resolves it). */
|
||||||
onOpen: (c: PlexCard) => void;
|
onOpen: (c: PlexCard) => void;
|
||||||
/** The media-info page. Omitted where a surface has none (the episode lists). */
|
/** The media-info page, or a show's own page. Omitted where a surface has none (the episode
|
||||||
|
* lists). */
|
||||||
onInfo?: (c: PlexCard) => void;
|
onInfo?: (c: PlexCard) => void;
|
||||||
onToggleWatched?: (c: PlexCard) => void;
|
onToggleWatched?: (c: PlexCard) => void;
|
||||||
/** Play from the start instead of the resume point (C5). Where given, a started title lists it
|
/** Play from the start instead of the resume point (C5). Where given, a started title lists it
|
||||||
@@ -32,7 +34,8 @@ const icon = "w-4 h-4";
|
|||||||
export const plexAddLabelKey = (c: PlexCard): string =>
|
export const plexAddLabelKey = (c: PlexCard): string =>
|
||||||
c.type === "episode" ? "plex.playlist.addEpisode" : "plex.playlistAdd.manage";
|
c.type === "episode" ? "plex.playlist.addEpisode" : "plex.playlistAdd.manage";
|
||||||
|
|
||||||
/** A show opens its drill-down; a movie or an episode plays. */
|
/** A leaf: a movie or an episode, what the watched toggle, the playlist add and a resume point
|
||||||
|
* belong to. A show plays too, through the episode it resolves to. */
|
||||||
const isPlexPlayable = (c: PlexCard): boolean => c.type !== "show";
|
const isPlexPlayable = (c: PlexCard): boolean => c.type !== "show";
|
||||||
|
|
||||||
/** Has a resume point: the click continues it, and "from the start" is a choice. A watched item is
|
/** Has a resume point: the click continues it, and "from the start" is a choice. A watched item is
|
||||||
@@ -73,15 +76,17 @@ export function usePlexCardActions(handlers: PlexActionHandlers, open: boolean)
|
|||||||
): ActionMenuItem[] => {
|
): ActionMenuItem[] => {
|
||||||
const { onOpen, onInfo, onToggleWatched, onRestart } = handlers;
|
const { onOpen, onInfo, onToggleWatched, onRestart } = handlers;
|
||||||
const playable = isPlexPlayable(c);
|
const playable = isPlexPlayable(c);
|
||||||
|
const isShow = c.type === "show";
|
||||||
const watched = c.status === "watched";
|
const watched = c.status === "watched";
|
||||||
const started = plexStarted(c);
|
// A show continues where its rollup says it stands, as its card's Continue + Restart do.
|
||||||
|
const started = isShow ? c.status === "in_progress" : plexStarted(c);
|
||||||
return [
|
return [
|
||||||
...(play
|
...(play
|
||||||
? [
|
? [
|
||||||
{
|
{
|
||||||
id: "play",
|
id: "play",
|
||||||
label: !playable ? t("plex.openShow") : started ? t("card.continue") : t("plex.play"),
|
label: started ? t("card.continue") : t("plex.play"),
|
||||||
icon: playable ? <Play className={icon} /> : <Tv2 className={icon} />,
|
icon: <Play className={icon} />,
|
||||||
onSelect: () => onOpen(c),
|
onSelect: () => onOpen(c),
|
||||||
},
|
},
|
||||||
// The same words and glyph as the video card's menu (useVideoActions).
|
// The same words and glyph as the video card's menu (useVideoActions).
|
||||||
@@ -131,18 +136,18 @@ export function usePlexCardActions(handlers: PlexActionHandlers, open: boolean)
|
|||||||
},
|
},
|
||||||
]
|
]
|
||||||
: []),
|
: []),
|
||||||
...(playable && onInfo
|
...(onInfo
|
||||||
? [
|
? [
|
||||||
{
|
{
|
||||||
id: "details",
|
id: "details",
|
||||||
label: t("plex.info.title"),
|
label: isShow ? t("plex.openShow") : t("plex.info.title"),
|
||||||
icon: <Info className={icon} />,
|
icon: isShow ? <Tv2 className={icon} /> : <Info className={icon} />,
|
||||||
separatorBefore: true,
|
separatorBefore: true,
|
||||||
onSelect: () => onInfo(c),
|
onSelect: () => onInfo(c),
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
: []),
|
: []),
|
||||||
...copy(c.title, null).map((it) => ({ ...it, separatorBefore: !(playable && onInfo) })),
|
...copy(c.title, null).map((it) => ({ ...it, separatorBefore: !onInfo })),
|
||||||
...(withPin ? pin.item(pinPlex(c), c.title) : []),
|
...(withPin ? pin.item(pinPlex(c), c.title) : []),
|
||||||
];
|
];
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -127,6 +127,7 @@
|
|||||||
"episodeLine": "S{{season}}·E{{episode}} · {{title}}",
|
"episodeLine": "S{{season}}·E{{episode}} · {{title}}",
|
||||||
"seasonTitle": "Season {{n}}",
|
"seasonTitle": "Season {{n}}",
|
||||||
"specials": "Specials",
|
"specials": "Specials",
|
||||||
|
"playShowFailed": "Couldn't find the episode to play — please try again",
|
||||||
"watchedFailed": "Couldn't update the watched state — please try again",
|
"watchedFailed": "Couldn't update the watched state — please try again",
|
||||||
"seasons_one": "{{count}} season",
|
"seasons_one": "{{count}} season",
|
||||||
"seasons_other": "{{count}} seasons",
|
"seasons_other": "{{count}} seasons",
|
||||||
|
|||||||
@@ -127,6 +127,7 @@
|
|||||||
"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",
|
||||||
|
"playShowFailed": "Nem sikerült megtalálni a lejátszandó epizódot — próbáld újra",
|
||||||
"watchedFailed": "Nem sikerült frissíteni a megnézett állapotot — próbáld újra",
|
"watchedFailed": "Nem sikerült frissíteni a megnézett állapotot — próbáld újra",
|
||||||
"seasons_one": "{{count}} évad",
|
"seasons_one": "{{count}} évad",
|
||||||
"seasons_other": "{{count}} évad",
|
"seasons_other": "{{count}} évad",
|
||||||
|
|||||||
@@ -644,6 +644,10 @@ export const plexApi = {
|
|||||||
},
|
},
|
||||||
plexShow: (id: string, lang?: string): Promise<PlexShowDetail> =>
|
plexShow: (id: string, lang?: string): Promise<PlexShowDetail> =>
|
||||||
req(`/api/plex/show/${encodeURIComponent(id)}${lang === "hu" ? "?lang=hu" : ""}`),
|
req(`/api/plex/show/${encodeURIComponent(id)}${lang === "hu" ? "?lang=hu" : ""}`),
|
||||||
|
// The episode a show's card plays: in progress, else the first unwatched, else the first (null:
|
||||||
|
// no episodes). Asked per show, as a card is hovered or played (the route says why).
|
||||||
|
plexShowNext: (id: string, lang?: string): Promise<{ next: PlexCard | null }> =>
|
||||||
|
req(`/api/plex/show/${encodeURIComponent(id)}/next${lang === "hu" ? "?lang=hu" : ""}`),
|
||||||
// Mark a whole show / season watched or unwatched (all its episodes) for the current user.
|
// Mark a whole show / season watched or unwatched (all its episodes) for the current user.
|
||||||
plexShowState: (rk: string, watched: boolean): Promise<{ changed: number; watched: boolean }> =>
|
plexShowState: (rk: string, watched: boolean): Promise<{ changed: number; watched: boolean }> =>
|
||||||
req(`/api/plex/show/${encodeURIComponent(rk)}/state`, {
|
req(`/api/plex/show/${encodeURIComponent(rk)}/state`, {
|
||||||
|
|||||||
@@ -134,6 +134,10 @@ export const qk = {
|
|||||||
showId !== undefined
|
showId !== undefined
|
||||||
? (["plex-show", showId, ...(lang ? [lang] : [])] as const)
|
? (["plex-show", showId, ...(lang ? [lang] : [])] as const)
|
||||||
: (["plex-show"] as const),
|
: (["plex-show"] as const),
|
||||||
|
// A show card's next episode. Its own root, not under `plex-show`: a watch write anywhere moves
|
||||||
|
// it, and the card reads it fresh (staleTime 0) instead of trusting every write site to invalidate.
|
||||||
|
plexShowNext: (showId: string, lang?: string) =>
|
||||||
|
["plex-show-next", showId, ...(lang ? [lang] : [])] as const,
|
||||||
plexItem: (id: string, lang?: string) => ["plex-item", id, ...(lang ? [lang] : [])] as const,
|
plexItem: (id: string, lang?: string) => ["plex-item", id, ...(lang ? [lang] : [])] as const,
|
||||||
// Extraction state of one subtitle track. Not keyed by the playhead: the position is an argument
|
// Extraction state of one subtitle track. Not keyed by the playhead: the position is an argument
|
||||||
// that steers the background extraction, not part of the track's identity.
|
// that steers the background extraction, not part of the track's identity.
|
||||||
|
|||||||
Reference in New Issue
Block a user