From bf5cf011ce0f8fdc6ab4c198722990fa9bc32ec2 Mon Sep 17 00:00:00 2001 From: npeter83 Date: Wed, 4 Nov 2026 19:27:46 +0100 Subject: [PATCH] feat: tag chips count their channels, Plex shows a file's size, a skip waits, Continue watching opens whole The channel manager's tag chips carry a muted count. A Plex item's Info page names the file's size. An auto-skip's countdown starts 3 s into the marker, with its button there from the start and a delay of 0 still immediate. Continue watching has a See all, opening Browse on in progress, last watched first; Recently added's sets the watch state back so it opens what it says. --- backend/app/routes/plex/detail.py | 1 + backend/tests/test_plex_detail.py | 2 + frontend/src/lib/api/plex.ts | 1 + frontend/src/lib/plexPlayerHooks.ts | 9 +++- .../channels/TagFilterChips.dom.test.tsx | 15 ++++++ .../src/modules/channels/TagFilterChips.tsx | 12 ++++- .../channels/channelColumns.dom.test.tsx | 25 +++++++++ .../src/modules/channels/channelColumns.tsx | 1 + frontend/src/modules/plex/PlexBrowse.tsx | 11 +++- .../modules/plex/PlexDiscover.dom.test.tsx | 35 ++++++++++++- frontend/src/modules/plex/PlexDiscover.tsx | 3 ++ .../src/modules/plex/PlexInfo.dom.test.tsx | 14 +++++ frontend/src/modules/plex/PlexInfo.tsx | 5 +- .../plex/PlexPlayer.controls.dom.test.tsx | 51 +++++++++++++++++++ 14 files changed, 178 insertions(+), 7 deletions(-) diff --git a/backend/app/routes/plex/detail.py b/backend/app/routes/plex/detail.py index ac981005..76aa0b02 100644 --- a/backend/app/routes/plex/detail.py +++ b/backend/app/routes/plex/detail.py @@ -432,6 +432,7 @@ def item_detail( "summary": (it.summary_hu or it.summary) if hu else it.summary, "year": it.year, "duration_seconds": it.duration_s, + "size_bytes": it.size_bytes, "playable": it.playable, # Playback-class detail: HW/SW transcode split + the codec/bit-depth the info-page chip shows. "transcode_hw": it.transcode_hw, diff --git a/backend/tests/test_plex_detail.py b/backend/tests/test_plex_detail.py index b8e2ec3d..19772e9f 100644 --- a/backend/tests/test_plex_detail.py +++ b/backend/tests/test_plex_detail.py @@ -219,6 +219,7 @@ def test_item_detail_movie_serves_from_row_despite_live_outage(db, monkeypatch, library_id=lib.id, kind="movie", title="The Godfather", + size_bytes=15_000_000_000, genres=["Crime", "Drama"], directors=["Francis Ford Coppola"], studio="Paramount", @@ -238,6 +239,7 @@ def test_item_detail_movie_serves_from_row_despite_live_outage(db, monkeypatch, resp = _get(db, user, monkeypatch, override_dep, "/api/plex/item/m1") assert resp.status_code == 200, resp.text body = resp.json() + assert body["size_bytes"] == 15_000_000_000 assert body["imdb_rating"] == 8.7 assert body["imdb_id"] == "tt0068646" assert body["imdb_url"] == "https://www.imdb.com/title/tt0068646/" diff --git a/frontend/src/lib/api/plex.ts b/frontend/src/lib/api/plex.ts index 5ca6e261..7524ccd0 100644 --- a/frontend/src/lib/api/plex.ts +++ b/frontend/src/lib/api/plex.ts @@ -154,6 +154,7 @@ export interface PlexItemDetail { summary?: string | null; year?: number | null; duration_seconds: number | null; + size_bytes?: number | null; // the file's, null while unknown playable: string; // direct | remux | transcode transcode_hw?: boolean | null; // transcode items only: true = HW-capable, false = SW-only, null = n/a codec_video?: string | null; // e.g. "hevc" / "h264" (info-chip subtitle) diff --git a/frontend/src/lib/plexPlayerHooks.ts b/frontend/src/lib/plexPlayerHooks.ts index 13a5a166..5f7b10cb 100644 --- a/frontend/src/lib/plexPlayerHooks.ts +++ b/frontend/src/lib/plexPlayerHooks.ts @@ -434,6 +434,9 @@ export function usePlayerVolume(opts: { return { setVol, nudgeVolume, toggleMute, onWheel }; } +// How long a marker plays before an auto-skip's countdown starts (seconds). +const AUTO_SKIP_WAIT_S = 3; + // Auto-skip intro/credits. Entering a marker whose auto-skip is on runs a countdown (skipProgress // 0→1 over autoSkipDelay s, shown on the Skip button); on completion it skips — to the marker end, or // for the credits that end the item to the next item (binge). Which credits those are, and which the @@ -538,14 +541,16 @@ export function useAutoSkip(opts: { const enabled = (m.type === "intro" ? autoSkipIntro : autoSkipCredits) && activeAuto; if (!enabled || suspended || cancelledMarkerRef.current === key) return setSkipProgress(null); if (autoSkipDelay <= 0) return doSkip(m); // immediate - let elapsed = 0; + // Below zero is the wait: time to see where the marker has landed and press the button, which + // shows from the start, before anything counts down to leaving. A delay of 0 asked for none. + let elapsed = -AUTO_SKIP_WAIT_S; setSkipProgress(0); const iv = window.setInterval(() => { // Freeze the countdown while the video is paused — otherwise it would fire and auto-seek a // deliberately-paused video. It resumes from where it stopped when playback resumes. if (videoRef.current?.paused) return; elapsed += 0.1; - const p = Math.min(1, elapsed / autoSkipDelay); + const p = Math.max(0, Math.min(1, elapsed / autoSkipDelay)); setSkipProgress(p); if (p >= 1) { window.clearInterval(iv); diff --git a/frontend/src/modules/channels/TagFilterChips.dom.test.tsx b/frontend/src/modules/channels/TagFilterChips.dom.test.tsx index 198fe12c..b8e5b9ea 100644 --- a/frontend/src/modules/channels/TagFilterChips.dom.test.tsx +++ b/frontend/src/modules/channels/TagFilterChips.dom.test.tsx @@ -100,3 +100,18 @@ describe("TagFilterChips — the overflow menu", () => { expect(menu()).toBeNull(); }); }); + +describe("TagFilterChips counts", () => { + it("draws a chip's count after its name, and none when it has none", () => { + fit.visible = 2; + render( + , + ); + const name = (label: string) => + screen.getAllByRole("button").find((b) => b.textContent?.startsWith(label)); + expect(name("alpha")?.textContent).toBe("alpha42"); + expect(name("beta")?.textContent).toBe("beta0"); + }); +}); diff --git a/frontend/src/modules/channels/TagFilterChips.tsx b/frontend/src/modules/channels/TagFilterChips.tsx index 9eb2ea4a..243883a0 100644 --- a/frontend/src/modules/channels/TagFilterChips.tsx +++ b/frontend/src/modules/channels/TagFilterChips.tsx @@ -19,6 +19,8 @@ import { ChipLabelPending } from "../../components/ui/Skeleton"; export interface FilterChip { key: string; label: string; + /** How many it holds, drawn muted after the name. */ + count?: number; /** The name is still on its way: the chip draws a placeholder, not `label` (a raw id). */ pending?: boolean; active: boolean; @@ -28,7 +30,13 @@ export interface FilterChip { // A chip's text — the visible chip and the hidden measuring mirror both draw it, so the mirror // measures what shows (ActiveFilterChips' ChipText, for this row's chips). function ChipText({ chip }: { chip: FilterChip }) { - return chip.pending ? : <>{chip.label}; + if (chip.pending) return ; + return ( + <> + {chip.label} + {chip.count != null && {chip.count}} + + ); } function TagChip({ chip }: { chip: FilterChip }) { @@ -53,7 +61,7 @@ export default function TagFilterChips({ // Re-fit when the set, the labels, OR the pressed state change (the pressed paint can change a chip's // width via the `.pill` recipe, so an aria-pressed flip must re-measure too). const key = chips - .map((c) => `${c.key}:${c.label}:${c.active ? 1 : 0}${c.pending ? "…" : ""}`) + .map((c) => `${c.key}:${c.label}:${c.count ?? ""}:${c.active ? 1 : 0}${c.pending ? "…" : ""}`) .join("|"); const visible = useChipOverflowFit({ diff --git a/frontend/src/modules/channels/channelColumns.dom.test.tsx b/frontend/src/modules/channels/channelColumns.dom.test.tsx index 977f10b5..7149d1e0 100644 --- a/frontend/src/modules/channels/channelColumns.dom.test.tsx +++ b/frontend/src/modules/channels/channelColumns.dom.test.tsx @@ -186,3 +186,28 @@ describe("buildChannelColumns — Added", () => { expect(screen.getByText(formatDate("2026-10-12T08:00:00+00:00", "en"))).toBeTruthy(); }); }); + +describe("tagFilterChips", () => { + it("counts a tag's channels, and only a tag's", () => { + const tag = { + id: 3, + name: "Tech", + color: null, + category: "topic", + system: false, + channel_count: 17, + }; + const chips = tagFilterChips({ + userTags: [tag], + tags: { isPending: false } as never, + picked: ["99"], + noTagOn: false, + toggle: () => {}, + toggleNoTag: () => {}, + t: ((k: string) => k) as never, + }); + expect(chips.find((c) => c.label === "Tech")?.count).toBe(17); + expect(chips.find((c) => c.key === "t99")?.count).toBeUndefined(); + expect(chips[chips.length - 1]?.count).toBeUndefined(); + }); +}); diff --git a/frontend/src/modules/channels/channelColumns.tsx b/frontend/src/modules/channels/channelColumns.tsx index 1c8899c0..736c0b51 100644 --- a/frontend/src/modules/channels/channelColumns.tsx +++ b/frontend/src/modules/channels/channelColumns.tsx @@ -78,6 +78,7 @@ export function tagFilterChips(o: { ...o.userTags.map((tg) => ({ key: `t${tg.id}`, label: tg.name, + count: tg.channel_count, active: o.picked.includes(String(tg.id)), onToggle: () => o.toggle(tg.id), })), diff --git a/frontend/src/modules/plex/PlexBrowse.tsx b/frontend/src/modules/plex/PlexBrowse.tsx index 86f31603..8cbbe24e 100644 --- a/frontend/src/modules/plex/PlexBrowse.tsx +++ b/frontend/src/modules/plex/PlexBrowse.tsx @@ -579,7 +579,16 @@ function PlexBrowseView({ sub }: { sub: ReturnType onRestartCard={onRestartCard} onInfoCard={onInfo} onGenre={(g) => applyFilter({ genres: [g] })} - onSeeAllRecent={() => sub.open({ kind: "grid" })} + // Each opens the list its rail is the start of: the watch state goes with it, and + // entering in-progress brings the last-watched sort (the effect above). + onSeeAllRecent={() => { + setShow("all"); + sub.open({ kind: "grid" }); + }} + onSeeAllContinue={() => { + setShow("in_progress"); + sub.open({ kind: "grid" }); + }} /> ); diff --git a/frontend/src/modules/plex/PlexDiscover.dom.test.tsx b/frontend/src/modules/plex/PlexDiscover.dom.test.tsx index fdc257cc..b173b5f0 100644 --- a/frontend/src/modules/plex/PlexDiscover.dom.test.tsx +++ b/frontend/src/modules/plex/PlexDiscover.dom.test.tsx @@ -105,6 +105,7 @@ describe("PlexDiscover", () => { onInfoCard={noop} onGenre={noop} onSeeAllRecent={noop} + onSeeAllContinue={noop} />, ); await screen.findByText("plex.discover.continueWatching"); @@ -144,6 +145,7 @@ describe("PlexDiscover", () => { onInfoCard={noop} onGenre={noop} onSeeAllRecent={noop} + onSeeAllContinue={noop} /> , ); @@ -179,12 +181,34 @@ describe("PlexDiscover", () => { onInfoCard={noop} onGenre={onGenre} onSeeAllRecent={noop} + onSeeAllContinue={noop} />, ); fireEvent.click(await screen.findByText("Drama")); expect(onGenre).toHaveBeenCalledWith("Drama"); }); + it("fires onSeeAllContinue from the Continue watching rail's see-all", async () => { + const onSeeAllContinue = vi.fn(); + const onSeeAllRecent = vi.fn(); + plexDiscover.mockResolvedValue({ ...fullPayload, recently_added: [] }); + plexSurprise.mockResolvedValue({ scope: "both", items: [] }); + wrap( + {}} + onInfoCard={noop} + onGenre={noop} + onSeeAllRecent={onSeeAllRecent} + onSeeAllContinue={onSeeAllContinue} + />, + ); + fireEvent.click(await screen.findByRole("button", { name: "common.seeAll" })); + expect(onSeeAllContinue).toHaveBeenCalledOnce(); + expect(onSeeAllRecent).not.toHaveBeenCalled(); + }); + it("fires onSeeAllRecent from the recently-added rail's see-all", async () => { plexDiscover.mockResolvedValue(fullPayload); plexSurprise.mockResolvedValue({ scope: "both", items: [] }); @@ -197,9 +221,12 @@ describe("PlexDiscover", () => { onInfoCard={noop} onGenre={noop} onSeeAllRecent={onSeeAllRecent} + onSeeAllContinue={noop} />, ); - fireEvent.click(await screen.findByRole("button", { name: "common.seeAll" })); + // The last of them: Continue watching, above it, has its own. + const seeAll = await screen.findAllByRole("button", { name: "common.seeAll" }); + fireEvent.click(seeAll[seeAll.length - 1]!); expect(onSeeAllRecent).toHaveBeenCalledOnce(); }); @@ -214,6 +241,7 @@ describe("PlexDiscover", () => { onInfoCard={noop} onGenre={noop} onSeeAllRecent={noop} + onSeeAllContinue={noop} />, ); await screen.findByText("Random Pick"); @@ -250,6 +278,7 @@ describe("PlexDiscover", () => { onInfoCard={noop} onGenre={noop} onSeeAllRecent={noop} + onSeeAllContinue={noop} /> ); @@ -297,6 +326,7 @@ describe("PlexDiscover", () => { onInfoCard={noop} onGenre={noop} onSeeAllRecent={noop} + onSeeAllContinue={noop} /> , ); @@ -338,6 +368,7 @@ describe("PlexDiscover", () => { onInfoCard={noop} onGenre={noop} onSeeAllRecent={noop} + onSeeAllContinue={noop} />, ); await screen.findByText("plex.discover.empty"); @@ -363,6 +394,7 @@ describe("PlexDiscover", () => { onInfoCard={noop} onGenre={noop} onSeeAllRecent={noop} + onSeeAllContinue={noop} />, ); fireEvent.click(await screen.findByLabelText("plex.markWatched")); @@ -386,6 +418,7 @@ describe("PlexDiscover · keyboard", () => { onInfoCard={noop} onGenre={noop} onSeeAllRecent={noop} + onSeeAllContinue={noop} />, ); await screen.findAllByText("Random Pick"); diff --git a/frontend/src/modules/plex/PlexDiscover.tsx b/frontend/src/modules/plex/PlexDiscover.tsx index 19a8d3d1..0908dfd6 100644 --- a/frontend/src/modules/plex/PlexDiscover.tsx +++ b/frontend/src/modules/plex/PlexDiscover.tsx @@ -101,6 +101,7 @@ export default function PlexDiscover({ onInfoCard, onGenre, onSeeAllRecent, + onSeeAllContinue, }: { scope: string; onOpenCard: (c: PlexCard) => void; @@ -108,6 +109,7 @@ export default function PlexDiscover({ onInfoCard: (c: PlexCard) => void; onGenre: (genre: string) => void; onSeeAllRecent: () => void; + onSeeAllContinue: () => void; }) { const { t, i18n } = useTranslation(); const qc = useQueryClient(); @@ -233,6 +235,7 @@ export default function PlexDiscover({ diff --git a/frontend/src/modules/plex/PlexInfo.dom.test.tsx b/frontend/src/modules/plex/PlexInfo.dom.test.tsx index eb2a9f0e..88a2f5b1 100644 --- a/frontend/src/modules/plex/PlexInfo.dom.test.tsx +++ b/frontend/src/modules/plex/PlexInfo.dom.test.tsx @@ -426,3 +426,17 @@ describe("a collection strip's card", () => { await vi.waitFor(() => expect(onOpenInfo).toHaveBeenCalledWith("m2")); }); }); + +describe("PlexInfo size", () => { + const show = (over: Partial) => + wrap(); + it("names the file's size beside the runtime", () => { + show({ size_bytes: 15_000_000_000 }); + expect(screen.getByText(/^1[45](\.\d)? GB$/)).toBeTruthy(); + }); + it("names none when it is not known", () => { + show({ size_bytes: null }); + expect(screen.getByText("Test Movie")).toBeTruthy(); + expect(screen.queryByText(/ GB$/)).toBeNull(); + }); +}); diff --git a/frontend/src/modules/plex/PlexInfo.tsx b/frontend/src/modules/plex/PlexInfo.tsx index ad2f46e1..c1f99b96 100644 --- a/frontend/src/modules/plex/PlexInfo.tsx +++ b/frontend/src/modules/plex/PlexInfo.tsx @@ -4,7 +4,7 @@ import { useTranslation } from "react-i18next"; import { useQueryClient } from "@tanstack/react-query"; import { Check, ExternalLink, FolderPlus, ListPlus, Play, RotateCcw, Star, X } from "lucide-react"; import { api, type PlexCard, type PlexFilters, type PlexItemDetail } from "../../lib/api"; -import { formatCompact, formatDecimal, formatRuntime } from "../../lib/format"; +import { formatBytes, formatCompact, formatDecimal, formatRuntime } from "../../lib/format"; import { notifyActionError } from "../../lib/actionErrors"; import { DetailCustomizeMenu, Filterable, PrefToggle } from "./plexDetailUi"; import { DetailAside, DetailAsideToggles, DetailKeywords } from "./plexDetailAside"; @@ -452,6 +452,9 @@ export default function PlexInfo({ {detail.duration_seconds ? ( {formatRuntime(detail.duration_seconds, lang)} ) : null} + {detail.size_bytes ? ( + {formatBytes(detail.size_bytes, lang)} + ) : null} {detail.studio && ( { expect(screen.getByTestId("clip-panel")).toBeTruthy(); }); + describe("the wait before an auto-skip's countdown", () => { + const withCredits = { + ...item("a", 0, "b"), + markers: [{ type: "credits", start_s: 2900, end_s: 2995, final: true }], + } as unknown as PlexItemDetail; + const advance = (ms: number) => + act(() => { + vi.advanceTimersByTime(ms); + }); + + // Into the marker with the clock already faked, so the countdown's interval is the fake one's. + const enterMarker = (paused: boolean) => { + vi.useFakeTimers(); + const v = video(); + v._paused = paused; + v._t = 2910; + act(() => void v.dispatchEvent(new Event("timeupdate"))); + }; + + it("holds the countdown for 3 s after the marker starts, then runs it", async () => { + prefs({ autoSkipCredits: true, autoSkipDelay: 1 }); + // Leaving a final marker writes "watched" as it skips, in the same tick: the next item's + // session comes only after promises this clock does not run. + const left = vi.spyOn(api, "plexSetState").mockResolvedValue(undefined as never); + mount({ details: { a: withCredits, b: item("b") }, queue: ["a", "b"] }); + await playingAt("/media/a", 2800); + enterMarker(false); + advance(3500); // past what the delay alone would have waited + expect(left).not.toHaveBeenCalled(); + advance(1000); + expect(left).toHaveBeenCalledWith("a", "watched"); + }); + + it("a delay of 0 is still immediate", async () => { + prefs({ autoSkipCredits: true, autoSkipDelay: 0 }); + const { session } = mount({ details: { a: withCredits, b: item("b") }, queue: ["a", "b"] }); + await playingAt("/media/a", 2910); + await waitFor(() => expect(lastStart(session)?.[0]).toBe("b")); + }); + + it("waits out the pause: a paused video does not use up the 3 s", async () => { + prefs({ autoSkipCredits: true, autoSkipDelay: 1 }); + const left = vi.spyOn(api, "plexSetState").mockResolvedValue(undefined as never); + mount({ details: { a: withCredits, b: item("b") }, queue: ["a", "b"] }); + await playingAt("/media/a", 2800); + enterMarker(true); + advance(10_000); + expect(left).not.toHaveBeenCalled(); + }); + }); + it("does not auto-skip while a clip is marked: the skip would carry the player off it", async () => { prefs({ autoSkipCredits: true, autoSkipDelay: 0 }); const withCredits = {