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:
@@ -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,
|
||||
|
||||
@@ -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/"
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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),
|
||||
})),
|
||||
|
||||
@@ -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,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 = {
|
||||
|
||||
Reference in New Issue
Block a user