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.
This commit is contained in:
2026-11-04 19:27:46 +01:00
parent 6e6e47652a
commit bf5cf011ce
14 changed files with 178 additions and 7 deletions
+1
View File
@@ -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,
+2
View File
@@ -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/"
+1
View File
@@ -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)
+7 -2
View File
@@ -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);
@@ -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(
<TagFilterChips
chips={[{ ...chip("alpha"), count: 42 }, { ...chip("beta"), count: 0 }, chip("gamma")]}
/>,
);
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");
});
});
@@ -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 ? <ChipLabelPending value={chip.label} /> : <>{chip.label}</>;
if (chip.pending) return <ChipLabelPending value={chip.label} />;
return (
<>
{chip.label}
{chip.count != null && <span className="ml-1 opacity-70 tabular-nums">{chip.count}</span>}
</>
);
}
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<number>({
@@ -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();
});
});
@@ -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),
})),
+10 -1
View File
@@ -579,7 +579,16 @@ function PlexBrowseView({ sub }: { sub: ReturnType<typeof useHistorySubview<Sub>
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" });
}}
/>
</div>
);
@@ -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}
/>
</QueryClientProvider>,
);
@@ -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(
<PlexDiscover
scope="both"
onOpenCard={noop}
onRestartCard={() => {}}
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}
/>
</QueryClientProvider>
);
@@ -297,6 +326,7 @@ describe("PlexDiscover", () => {
onInfoCard={noop}
onGenre={noop}
onSeeAllRecent={noop}
onSeeAllContinue={noop}
/>
</QueryClientProvider>,
);
@@ -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");
@@ -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({
<CardRail
title={t("plex.discover.continueWatching")}
ariaLabel={t("plex.discover.continueWatching")}
onSeeAll={onSeeAllContinue}
cards={d.continue_watching}
{...railProps}
/>
@@ -426,3 +426,17 @@ describe("a collection strip's card", () => {
await vi.waitFor(() => expect(onOpenInfo).toHaveBeenCalledWith("m2"));
});
});
describe("PlexInfo size", () => {
const show = (over: Partial<PlexItemDetail>) =>
wrap(<PlexInfo onPlayItem={noop} detail={{ ...detail, ...over }} variant="page" />);
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();
});
});
+4 -1
View File
@@ -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 ? (
<span className={mutedCls}>{formatRuntime(detail.duration_seconds, lang)}</span>
) : null}
{detail.size_bytes ? (
<span className={mutedCls}>{formatBytes(detail.size_bytes, lang)}</span>
) : null}
{detail.studio && (
<Filterable
tag
@@ -1121,6 +1121,57 @@ describe("PlexPlayer — clip mode", () => {
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 = {