feat(design): players on touch and the C5 control set, restart on the card (E28 S3f2)
Plex player: a finger's tap on the picture only shows or hides the controls, the hide timer starts with playback, a double-tap on a side third skips 10 s, the seek bar is a 2.75rem touch-none hit area with its head shown on touch. The row is play/pause, Back by position, Next only with a next item, mute, time, tracks, a fixed ⋯ (settings, media info, stats, shortcuts, download) and fullscreen; Stop, Restart and the ±10 s buttons are gone. A started card shows the video card's Continue + Restart pair (one shared MediaResumeButtons, beside the picture's button), and its menu lists the restart; the player opens that item at 0 (fromStart). YouTube modal: the description opens from an ⓘ, the queue arrows sit inside the screen.
This commit is contained in:
@@ -712,6 +712,36 @@ describe("Home", () => {
|
||||
expect(setPage).toHaveBeenCalledWith("plex");
|
||||
});
|
||||
|
||||
// C5: what a tile's Play would continue, its menu can start over — the one way to on touch.
|
||||
it("a started film's menu starts it over: the player opens from the start", async () => {
|
||||
const movie = {
|
||||
...ytCard("m1", "A Film"),
|
||||
source: "plex",
|
||||
kind: "movie",
|
||||
aspect: "poster",
|
||||
position_seconds: 600,
|
||||
video: null,
|
||||
plex: { id: "m1", type: "movie", title: "A Film", thumb: "", position_seconds: 600 },
|
||||
};
|
||||
discover.mockResolvedValue({
|
||||
budget: "any",
|
||||
hero: null,
|
||||
continue_watching: [movie],
|
||||
comfort: [],
|
||||
});
|
||||
mount();
|
||||
fireEvent.click((await screen.findAllByRole("button", { name: "card.menu.more" }))[0]!);
|
||||
const restart = screen
|
||||
.getAllByRole("menuitem", { hidden: true })
|
||||
.find((b) => b.textContent === "card.restartTitle");
|
||||
fireEvent.click(restart!);
|
||||
await waitFor(() =>
|
||||
expect(setJumpTo).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ id: "m1", type: "play", fromStart: true }),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
it("rotates the tier on each press so the button offers a different KIND of answer", async () => {
|
||||
// Measured 2026-09-06: exclusion alone left it walking the resume rail and cycling nine cards,
|
||||
// never once reaching the shelf. The press count travels to the server as the rotation.
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
PinOff,
|
||||
Play,
|
||||
RotateCcw,
|
||||
SkipBack,
|
||||
Star,
|
||||
} from "lucide-react";
|
||||
import { qk } from "../lib/queryKeys";
|
||||
@@ -51,7 +52,8 @@ import { SURFACE } from "./ui/surfaces";
|
||||
import clsx from "clsx";
|
||||
import { IconButton } from "./ui/IconButton";
|
||||
import { MoreButton } from "./ui/MoreButton";
|
||||
import { HOVER_YIELD } from "./ui/reveal";
|
||||
import { HOVER_REVEAL, HOVER_YIELD } from "./ui/reveal";
|
||||
import { MediaResumeButtons } from "./MediaResumeButtons";
|
||||
import { MediaCard, MediaPlayGlyph } from "./ui/MediaCard";
|
||||
import {
|
||||
MEDIA_BAR,
|
||||
@@ -171,6 +173,17 @@ function useHomeCardMenu({
|
||||
disabled: !(target.next ?? target).video && !(target.next ?? target).plex,
|
||||
onSelect: () => onPlay(target),
|
||||
},
|
||||
// What the Play row would continue can also start over (C5), with the card menus' words.
|
||||
...((target.next ?? target).position_seconds > 0
|
||||
? [
|
||||
{
|
||||
id: "restart",
|
||||
label: t("card.restartTitle"),
|
||||
icon: <SkipBack className="w-4 h-4" />,
|
||||
onSelect: () => onPlay(target, 0),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...(target.video
|
||||
? videoActions
|
||||
.items(target.video, menu.anchor, { pin: false })
|
||||
@@ -344,6 +357,7 @@ function Rail({
|
||||
shelf
|
||||
navIndex={i}
|
||||
onOpen={() => onPlay(c)}
|
||||
onRestart={() => onPlay(c, 0)}
|
||||
onToggleWatched={onToggleWatched}
|
||||
onAdd={plexAdd}
|
||||
next={c.next?.plex}
|
||||
@@ -569,10 +583,21 @@ function Hero({
|
||||
/>
|
||||
</span>
|
||||
) : null}
|
||||
<div className={MEDIA_HOVER_LAYER}>
|
||||
<MediaPlayGlyph />
|
||||
</div>
|
||||
<div className={MEDIA_HOVER_LAYER}>{!started && <MediaPlayGlyph />}</div>
|
||||
</Pressable>
|
||||
{/* A started hero continues on a click, and offers the start as a card does (C5): the pair
|
||||
is a sibling over the picture, never inside its button. */}
|
||||
{started && (
|
||||
<div
|
||||
className={clsx(
|
||||
HOVER_REVEAL,
|
||||
"pointer-events-none absolute inset-0 z-base flex items-center justify-center gap-2 *:pointer-events-auto",
|
||||
card.aspect === "poster" && "flex-col",
|
||||
)}
|
||||
>
|
||||
<MediaResumeButtons onContinue={() => onPlay(card)} onRestart={() => onPlay(card, 0)} />
|
||||
</div>
|
||||
)}
|
||||
{/* The card's own hover row, as on every card: a sibling of the picture's button. */}
|
||||
{card.video ? (
|
||||
<div className={MEDIA_BAR}>
|
||||
@@ -851,6 +876,7 @@ export default function Home() {
|
||||
setJumpTo({
|
||||
id: target.plex.id,
|
||||
type: target.plex.type === "show" ? "show" : "play",
|
||||
fromStart: startAt === 0,
|
||||
replace: true,
|
||||
});
|
||||
setPage("plex");
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
import type { MouseEvent } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Play, RotateCcw } from "lucide-react";
|
||||
import { Button } from "./ui/Button";
|
||||
import { IconButton } from "./ui/IconButton";
|
||||
|
||||
// The started card's hover pair (DESIGN "Players", C5: restarting lives on the card): Continue in the
|
||||
// accent, Restart dark translucent. ONE component for the YouTube card and the Plex cards, so the one
|
||||
// media card says the same two words in the same two looks. A small picture (a shelf's landscape, the
|
||||
// dense video card) takes the icon-only pair, which fits beside the hover row. Touch has no hover: a
|
||||
// tap on the picture continues, and the card's "…" carries the restart.
|
||||
export function MediaResumeButtons({
|
||||
small = false,
|
||||
onContinue,
|
||||
onRestart,
|
||||
}: {
|
||||
small?: boolean;
|
||||
onContinue: (e: MouseEvent) => void;
|
||||
onRestart: (e: MouseEvent) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
return small ? (
|
||||
<>
|
||||
<IconButton
|
||||
variant="primary"
|
||||
size="md"
|
||||
round
|
||||
onClick={onContinue}
|
||||
label={t("card.continue")}
|
||||
title={t("card.continueTitle")}
|
||||
>
|
||||
<Play className="fill-current" />
|
||||
</IconButton>
|
||||
<IconButton
|
||||
variant="media"
|
||||
size="md"
|
||||
onClick={onRestart}
|
||||
label={t("card.restart")}
|
||||
title={t("card.restartTitle")}
|
||||
>
|
||||
<RotateCcw />
|
||||
</IconButton>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Button
|
||||
size="sm"
|
||||
round
|
||||
onClick={onContinue}
|
||||
title={t("card.continueTitle")}
|
||||
icon={<Play className="fill-current" />}
|
||||
>
|
||||
{t("card.continue")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="media"
|
||||
size="sm"
|
||||
onClick={onRestart}
|
||||
title={t("card.restartTitle")}
|
||||
icon={<RotateCcw />}
|
||||
>
|
||||
{t("card.restart")}
|
||||
</Button>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -401,3 +401,45 @@ describe("PlayerModal — the watch toggle after a queue advance", () => {
|
||||
expect(screen.queryAllByTitle("player.watchedUnmark").length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
// C3: the description used to open on hovering the title, which a finger never does. It opens from
|
||||
// an ⓘ beside the title now, by click or tap, and a hover over the title opens nothing.
|
||||
describe("PlayerModal — the description", () => {
|
||||
it("opens from the ⓘ, not from hovering the title", async () => {
|
||||
vi.spyOn(api, "videoDetail").mockResolvedValue({
|
||||
description: "A long description",
|
||||
} as Awaited<ReturnType<typeof api.videoDetail>>);
|
||||
const A = vid("A", 0);
|
||||
renderModal(
|
||||
<PlayerModal video={A} queue={[A]} startIndex={0} onClose={() => {}} onState={() => {}} />,
|
||||
);
|
||||
fireEvent.mouseEnter(screen.getByRole("heading", { name: "Video A" }));
|
||||
fireEvent.pointerEnter(screen.getByRole("heading", { name: "Video A" }));
|
||||
await new Promise((r) => setTimeout(r, 500));
|
||||
expect(screen.queryByText("A long description")).toBeNull();
|
||||
const info = screen.getByRole("button", { name: "player.description" });
|
||||
fireEvent.click(info);
|
||||
expect(await screen.findByText("A long description")).toBeTruthy();
|
||||
expect(info.getAttribute("aria-expanded")).toBe("true");
|
||||
});
|
||||
});
|
||||
|
||||
// C3: the queue's side arrows sit in a gutter of the card's row, not outside it, so no window cuts
|
||||
// them off (outside the card, a 1024 window cut them and a portrait tablet lost them).
|
||||
describe("PlayerModal — the side arrows", () => {
|
||||
it("stand inside the row's gutter", () => {
|
||||
vi.spyOn(api, "videoDetail").mockReturnValue(new Promise(() => {}));
|
||||
const A = vid("A", 0);
|
||||
const B = vid("B", 0);
|
||||
renderModal(
|
||||
<PlayerModal video={A} queue={[A, B]} startIndex={0} onClose={() => {}} onState={() => {}} />,
|
||||
);
|
||||
const arrow = [...document.querySelectorAll<HTMLElement>("[aria-label='player.next']")].find(
|
||||
(b) => b.className.includes("w-12"),
|
||||
)!;
|
||||
expect(arrow).toBeTruthy();
|
||||
expect(arrow.className).toMatch(/\bright-0\b/);
|
||||
expect(arrow.className).not.toMatch(/left-full/);
|
||||
expect(arrow.parentElement!.className).toMatch(/sm:px-14/);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
ExternalLink,
|
||||
Info,
|
||||
Maximize,
|
||||
Minimize,
|
||||
Minimize2,
|
||||
@@ -403,25 +404,23 @@ export default function PlayerModal({
|
||||
}
|
||||
};
|
||||
|
||||
// Lazy description (fetched only when the title is hovered). Anchored ABOVE the title so it grows
|
||||
// upward over the player rather than downward off-screen / behind the OS taskbar — `flip` still
|
||||
// rescues it when there is no room up there either, which the old fixed `bottom` could not. The
|
||||
// hover-intent delay (only pop it if the pointer lingers, so a quick pass doesn't flash the card)
|
||||
// and the travel grace are now the platform's, along with the safePolygon bridge that replaces the
|
||||
// pair of hand-managed timers.
|
||||
// Lazy description (fetched only when it is opened). It opens from an ⓘ beside the title, by click
|
||||
// or tap (C3): it used to open on hovering the title, which a finger never does — measured live,
|
||||
// a tablet could not read a description at all. Anchored ABOVE, so it grows upward over the player
|
||||
// rather than off-screen; `flip` still rescues it when there is no room up there.
|
||||
const [showDesc, setShowDesc] = useState(false);
|
||||
const descPop = useAnchoredPopover({
|
||||
open: showDesc,
|
||||
onOpenChange: setShowDesc,
|
||||
mode: "hover",
|
||||
mode: "dialog",
|
||||
role: "dialog",
|
||||
placement: "top-start",
|
||||
offset: 8,
|
||||
hoverDelay: { open: 400, close: 150 },
|
||||
});
|
||||
const detail = useQuery({
|
||||
queryKey: qk.videoDetail(currentVideoId),
|
||||
queryFn: () => api.videoDetail(currentVideoId),
|
||||
// On hover (for the description) and eagerly when navigated, so we can link the
|
||||
// On opening the description, and eagerly when navigated, so we can link the
|
||||
// linked video's channel. Both share the one cached result.
|
||||
enabled: showDesc || navigated,
|
||||
staleTime: 5 * 60_000,
|
||||
@@ -574,10 +573,13 @@ export default function PlayerModal({
|
||||
aria-labelledby={titleId}
|
||||
>
|
||||
{/* The card, flanked by full-height glassy strips that step through the feed's order (or a
|
||||
playlist). Each strip sits just outside the card, spans its full height, and fades out at
|
||||
the ends. stopPropagation so a click steps instead of closing. Hidden on narrow screens
|
||||
where there's no room beside the card. */}
|
||||
<div className="relative flex w-full max-w-4xl max-h-full">
|
||||
playlist). Each strip sits in a gutter of the card's row, spans its full height, and fades
|
||||
out at the ends; the gutter keeps it on the screen (C3: outside the card, a 1024 window cut
|
||||
it and a portrait tablet lost it). stopPropagation so a click steps instead of closing.
|
||||
Hidden on narrow screens where there's no room beside the card. */}
|
||||
<div
|
||||
className={`relative flex w-full max-h-full ${hasQueue ? "max-w-[63rem] sm:px-14" : "max-w-4xl"}`}
|
||||
>
|
||||
{hasQueue && (
|
||||
<Pressable
|
||||
onClick={(e) => {
|
||||
@@ -587,7 +589,7 @@ export default function PlayerModal({
|
||||
disabled={index === 0}
|
||||
aria-label={t("player.previous")}
|
||||
title={`${t("player.previous")} · Shift+←`}
|
||||
className="absolute right-full top-0 bottom-0 mr-2 hidden w-12 place-items-center rounded-2xl bg-white/5 text-white/40 backdrop-blur-xs transition hover:bg-white/15 hover:text-white disabled:pointer-events-none disabled:opacity-0 sm:grid"
|
||||
className="absolute left-0 top-0 bottom-0 hidden w-12 place-items-center rounded-2xl bg-white/5 text-white/40 backdrop-blur-xs transition hover:bg-white/15 hover:text-white disabled:pointer-events-none disabled:opacity-0 sm:grid"
|
||||
>
|
||||
<ChevronLeft className="h-8 w-8" />
|
||||
</Pressable>
|
||||
@@ -778,18 +780,20 @@ export default function PlayerModal({
|
||||
)}
|
||||
|
||||
<div className="p-4 sm:p-5">
|
||||
{/* Title row — title (with hover description) on the left, Close on the right. */}
|
||||
{/* Title row — the title and its ⓘ (the description) on the left, Close on the right. */}
|
||||
<div className="flex items-start gap-3">
|
||||
<h2 id={titleId} className="min-w-0 flex-1 text-lg font-semibold leading-snug">
|
||||
{/* Hover target is the text itself (inline), not the whole row. */}
|
||||
<span
|
||||
ref={descPop.refs.setReference}
|
||||
{...descPop.getReferenceProps()}
|
||||
className="cursor-default"
|
||||
>
|
||||
{navigated ? (liveData?.title ?? t("player.loading")) : active.title}
|
||||
</span>
|
||||
{navigated ? (liveData?.title ?? t("player.loading")) : active.title}
|
||||
</h2>
|
||||
<IconButton
|
||||
ref={descPop.refs.setReference}
|
||||
{...descPop.getReferenceProps()}
|
||||
variant="ghost"
|
||||
aria-expanded={showDesc}
|
||||
label={t("player.description")}
|
||||
>
|
||||
<Info />
|
||||
</IconButton>
|
||||
{navigated && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
@@ -999,7 +1003,7 @@ export default function PlayerModal({
|
||||
disabled={index >= queue!.length - 1}
|
||||
aria-label={t("player.next")}
|
||||
title={`${t("player.next")} · Shift+→`}
|
||||
className="absolute left-full top-0 bottom-0 ml-2 hidden w-12 place-items-center rounded-2xl bg-white/5 text-white/40 backdrop-blur-xs transition hover:bg-white/15 hover:text-white disabled:pointer-events-none disabled:opacity-0 sm:grid"
|
||||
className="absolute right-0 top-0 bottom-0 hidden w-12 place-items-center rounded-2xl bg-white/5 text-white/40 backdrop-blur-xs transition hover:bg-white/15 hover:text-white disabled:pointer-events-none disabled:opacity-0 sm:grid"
|
||||
>
|
||||
<ChevronRight className="h-8 w-8" />
|
||||
</Pressable>
|
||||
|
||||
@@ -152,7 +152,7 @@ export default function PlexBrowse() {
|
||||
jumpTo.type === "show"
|
||||
? { kind: "show", id: jumpTo.id }
|
||||
: jumpTo.type === "play"
|
||||
? { kind: "player", id: jumpTo.id }
|
||||
? { kind: "player", id: jumpTo.id, fromStart: jumpTo.fromStart }
|
||||
: { kind: "info", id: jumpTo.id },
|
||||
// A cross-module hand-off continues the navigation that brought us here rather than adding
|
||||
// to it. It also makes this effect idempotent, which matters more than it looks: under
|
||||
@@ -334,6 +334,11 @@ export default function PlexBrowse() {
|
||||
if (card.type === "show") sub.open({ kind: "show", 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.
|
||||
function onRestartCard(card: PlexCard) {
|
||||
scrollRef.current = scroller()?.scrollTop ?? 0;
|
||||
sub.open({ kind: "player", id: card.id, fromStart: true });
|
||||
}
|
||||
function onInfo(card: PlexCard) {
|
||||
scrollRef.current = scroller()?.scrollTop ?? 0;
|
||||
rememberInfoScroll(0); // fresh info page starts at the top
|
||||
@@ -370,11 +375,11 @@ export default function PlexBrowse() {
|
||||
resync: () => qc.invalidateQueries({ queryKey: qk.plexLibrary() }),
|
||||
});
|
||||
const actions = usePlexCardActions(
|
||||
{ onOpen: onCard, onInfo, onToggleWatched: toggleWatched },
|
||||
{ onOpen: onCard, onRestart: onRestartCard, onInfo, onToggleWatched: toggleWatched },
|
||||
menu.open,
|
||||
);
|
||||
const epActions = usePlexCardActions(
|
||||
{ onOpen: onCard, onToggleWatched: toggleWatched },
|
||||
{ onOpen: onCard, onRestart: onRestartCard, onToggleWatched: toggleWatched },
|
||||
epMenu.open,
|
||||
);
|
||||
// Apply a metadata filter (clicked on an info page / show hero / cast) then show the unified grid.
|
||||
@@ -408,7 +413,12 @@ export default function PlexBrowse() {
|
||||
<Suspense
|
||||
fallback={createPortal(<div className="fixed inset-0 z-overlay bg-black" />, document.body)}
|
||||
>
|
||||
<PlexPlayer itemId={sub.view.id} queue={sub.view.queue} onClose={sub.back} />
|
||||
<PlexPlayer
|
||||
itemId={sub.view.id}
|
||||
queue={sub.view.queue}
|
||||
fromStart={sub.view.fromStart}
|
||||
onClose={sub.back}
|
||||
/>
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
@@ -419,7 +429,9 @@ export default function PlexBrowse() {
|
||||
<PlexPlaylistView
|
||||
playlistId={pid}
|
||||
onBack={sub.back}
|
||||
onPlay={(itemRk, queue) => sub.open({ kind: "player", id: itemRk, queue })}
|
||||
onPlay={(itemRk, queue, fromStart) =>
|
||||
sub.open({ kind: "player", id: itemRk, queue, fromStart })
|
||||
}
|
||||
onInfoCard={onInfo}
|
||||
/>
|
||||
</Suspense>
|
||||
@@ -434,6 +446,7 @@ export default function PlexBrowse() {
|
||||
onBack={sub.back}
|
||||
onPlay={(rk, queue) => sub.open({ kind: "player", id: rk, queue })}
|
||||
onOpenCard={onCard}
|
||||
onRestartCard={onRestartCard}
|
||||
onInfoCard={onInfo}
|
||||
/>
|
||||
</Suspense>
|
||||
@@ -446,7 +459,7 @@ export default function PlexBrowse() {
|
||||
id={infoId}
|
||||
onBack={sub.back}
|
||||
onPlay={() => sub.open({ kind: "player", id: infoId })}
|
||||
onPlayItem={(id) => sub.open({ kind: "player", id })}
|
||||
onPlayItem={(id, fromStart) => sub.open({ kind: "player", id, fromStart })}
|
||||
onFilter={applyFilter}
|
||||
onOpenPerson={onOpenPerson}
|
||||
/>
|
||||
@@ -459,6 +472,7 @@ export default function PlexBrowse() {
|
||||
name={personName}
|
||||
onBack={sub.back}
|
||||
onOpenCard={onCard}
|
||||
onRestartCard={onRestartCard}
|
||||
onInfoCard={onInfo}
|
||||
onFilter={applyFilter}
|
||||
/>
|
||||
@@ -485,7 +499,9 @@ export default function PlexBrowse() {
|
||||
showId={showId}
|
||||
seasonId={seasonId}
|
||||
onBack={sub.back}
|
||||
onPlay={(epRk, queue) => sub.open({ kind: "player", id: epRk, queue })}
|
||||
onPlay={(epRk, queue, fromStart) =>
|
||||
sub.open({ kind: "player", id: epRk, queue, fromStart })
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -510,6 +526,7 @@ export default function PlexBrowse() {
|
||||
<PlexDiscover
|
||||
scope={scope}
|
||||
onOpenCard={onCard}
|
||||
onRestartCard={onRestartCard}
|
||||
onInfoCard={onInfo}
|
||||
onGenre={(g) => applyFilter({ genres: [g] })}
|
||||
onSeeAllRecent={() => sub.open({ kind: "grid" })}
|
||||
@@ -685,6 +702,7 @@ export default function PlexBrowse() {
|
||||
navIndex={i}
|
||||
card={c}
|
||||
onOpen={() => onCard(c)}
|
||||
onRestart={() => onRestartCard(c)}
|
||||
onToggleWatched={toggleWatched}
|
||||
onAdd={actions.add}
|
||||
menuProps={menu.triggerProps}
|
||||
@@ -707,6 +725,7 @@ export default function PlexBrowse() {
|
||||
ep={ep}
|
||||
withShowTitle
|
||||
onPlay={() => onCard(ep)}
|
||||
onRestart={() => onRestartCard(ep)}
|
||||
onToggleWatched={() => toggleWatched(ep)}
|
||||
onAdd={() => epActions.add(ep)}
|
||||
menuProps={epMenu.triggerProps}
|
||||
|
||||
@@ -95,6 +95,7 @@ function mount(items: PlexCard[], onPlay = vi.fn()) {
|
||||
onBack={() => {}}
|
||||
onPlay={onPlay}
|
||||
onOpenCard={() => {}}
|
||||
onRestartCard={() => {}}
|
||||
onInfoCard={() => {}}
|
||||
/>
|
||||
</QueryClientProvider>,
|
||||
@@ -192,6 +193,7 @@ describe("PlexCollectionView — after the sort joined the query key", () => {
|
||||
onBack={() => {}}
|
||||
onPlay={() => {}}
|
||||
onOpenCard={() => {}}
|
||||
onRestartCard={() => {}}
|
||||
onInfoCard={() => {}}
|
||||
/>
|
||||
</QueryClientProvider>
|
||||
|
||||
@@ -56,12 +56,14 @@ export default function PlexCollectionView({
|
||||
onBack,
|
||||
onPlay,
|
||||
onOpenCard,
|
||||
onRestartCard,
|
||||
onInfoCard,
|
||||
}: {
|
||||
collectionId: string;
|
||||
onBack: () => void;
|
||||
onPlay: (rk: string, queue: string[]) => void;
|
||||
onOpenCard: (card: PlexCard) => void;
|
||||
onRestartCard: (card: PlexCard) => void;
|
||||
onInfoCard: (card: PlexCard) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
@@ -185,7 +187,12 @@ export default function PlexCollectionView({
|
||||
},
|
||||
});
|
||||
const actions = usePlexCardActions(
|
||||
{ onOpen: onOpenCard, onInfo: onInfoCard, onToggleWatched: toggleWatched },
|
||||
{
|
||||
onOpen: onOpenCard,
|
||||
onRestart: onRestartCard,
|
||||
onInfo: onInfoCard,
|
||||
onToggleWatched: toggleWatched,
|
||||
},
|
||||
menu.open,
|
||||
);
|
||||
|
||||
@@ -430,6 +437,7 @@ export default function PlexCollectionView({
|
||||
card={c}
|
||||
navIndex={i}
|
||||
onOpen={() => onOpenCard(c)}
|
||||
onRestart={() => onRestartCard(c)}
|
||||
onToggleWatched={toggleWatched}
|
||||
onAdd={actions.add}
|
||||
menuProps={menu.triggerProps}
|
||||
|
||||
@@ -98,6 +98,7 @@ describe("PlexDiscover", () => {
|
||||
<PlexDiscover
|
||||
scope="both"
|
||||
onOpenCard={noop}
|
||||
onRestartCard={() => {}}
|
||||
onInfoCard={noop}
|
||||
onGenre={noop}
|
||||
onSeeAllRecent={noop}
|
||||
@@ -136,6 +137,7 @@ describe("PlexDiscover", () => {
|
||||
<PlexDiscover
|
||||
scope="both"
|
||||
onOpenCard={noop}
|
||||
onRestartCard={() => {}}
|
||||
onInfoCard={noop}
|
||||
onGenre={noop}
|
||||
onSeeAllRecent={noop}
|
||||
@@ -169,6 +171,7 @@ describe("PlexDiscover", () => {
|
||||
<PlexDiscover
|
||||
scope="both"
|
||||
onOpenCard={noop}
|
||||
onRestartCard={() => {}}
|
||||
onInfoCard={noop}
|
||||
onGenre={onGenre}
|
||||
onSeeAllRecent={noop}
|
||||
@@ -186,6 +189,7 @@ describe("PlexDiscover", () => {
|
||||
<PlexDiscover
|
||||
scope="both"
|
||||
onOpenCard={noop}
|
||||
onRestartCard={() => {}}
|
||||
onInfoCard={noop}
|
||||
onGenre={noop}
|
||||
onSeeAllRecent={onSeeAllRecent}
|
||||
@@ -202,6 +206,7 @@ describe("PlexDiscover", () => {
|
||||
<PlexDiscover
|
||||
scope="both"
|
||||
onOpenCard={noop}
|
||||
onRestartCard={() => {}}
|
||||
onInfoCard={noop}
|
||||
onGenre={noop}
|
||||
onSeeAllRecent={noop}
|
||||
@@ -228,6 +233,7 @@ describe("PlexDiscover", () => {
|
||||
<PlexDiscover
|
||||
scope="both"
|
||||
onOpenCard={noop}
|
||||
onRestartCard={() => {}}
|
||||
onInfoCard={noop}
|
||||
onGenre={noop}
|
||||
onSeeAllRecent={noop}
|
||||
@@ -252,6 +258,7 @@ describe("PlexDiscover", () => {
|
||||
<PlexDiscover
|
||||
scope="both"
|
||||
onOpenCard={noop}
|
||||
onRestartCard={() => {}}
|
||||
onInfoCard={noop}
|
||||
onGenre={noop}
|
||||
onSeeAllRecent={noop}
|
||||
@@ -274,6 +281,7 @@ describe("PlexDiscover · keyboard", () => {
|
||||
<PlexDiscover
|
||||
scope="both"
|
||||
onOpenCard={noop}
|
||||
onRestartCard={() => {}}
|
||||
onInfoCard={noop}
|
||||
onGenre={noop}
|
||||
onSeeAllRecent={noop}
|
||||
|
||||
@@ -31,6 +31,7 @@ function CardRail({
|
||||
onSeeAll,
|
||||
cards,
|
||||
onOpenCard,
|
||||
onRestartCard,
|
||||
onInfoCard,
|
||||
onToggleWatched,
|
||||
action,
|
||||
@@ -41,6 +42,7 @@ function CardRail({
|
||||
onSeeAll?: () => void;
|
||||
cards: PlexCard[];
|
||||
onOpenCard: (c: PlexCard) => void;
|
||||
onRestartCard: (c: PlexCard) => void;
|
||||
onInfoCard: (c: PlexCard) => void;
|
||||
onToggleWatched: (c: PlexCard) => void;
|
||||
action?: ReactNode; // optional header control (e.g. the Surprise-me reshuffle button)
|
||||
@@ -48,7 +50,7 @@ function CardRail({
|
||||
// One menu per rail (see ui/ContextMenu), not one per poster.
|
||||
const menu = useContextMenu<PlexCard>();
|
||||
const actions = usePlexCardActions(
|
||||
{ onOpen: onOpenCard, onInfo: onInfoCard, onToggleWatched },
|
||||
{ onOpen: onOpenCard, onRestart: onRestartCard, onInfo: onInfoCard, onToggleWatched },
|
||||
menu.open,
|
||||
);
|
||||
const heading =
|
||||
@@ -76,6 +78,7 @@ function CardRail({
|
||||
card={c}
|
||||
navIndex={i}
|
||||
onOpen={() => onOpenCard(c)}
|
||||
onRestart={() => onRestartCard(c)}
|
||||
onToggleWatched={onToggleWatched}
|
||||
onAdd={actions.add}
|
||||
menuProps={menu.triggerProps}
|
||||
@@ -93,12 +96,14 @@ function CardRail({
|
||||
export default function PlexDiscover({
|
||||
scope,
|
||||
onOpenCard,
|
||||
onRestartCard,
|
||||
onInfoCard,
|
||||
onGenre,
|
||||
onSeeAllRecent,
|
||||
}: {
|
||||
scope: string;
|
||||
onOpenCard: (c: PlexCard) => void;
|
||||
onRestartCard: (c: PlexCard) => void;
|
||||
onInfoCard: (c: PlexCard) => void;
|
||||
onGenre: (genre: string) => void;
|
||||
onSeeAllRecent: () => void;
|
||||
@@ -184,7 +189,7 @@ export default function PlexDiscover({
|
||||
return <p className="text-muted text-sm">{t("plex.discover.empty")}</p>;
|
||||
}
|
||||
|
||||
const railProps = { onOpenCard, onInfoCard, onToggleWatched: toggleWatched };
|
||||
const railProps = { onOpenCard, onRestartCard, onInfoCard, onToggleWatched: toggleWatched };
|
||||
|
||||
return (
|
||||
<div>
|
||||
|
||||
@@ -347,7 +347,7 @@ type Props = {
|
||||
// When provided, a cast member's photo/name opens their filmography page instead of filtering.
|
||||
onOpenPerson?: (name: string) => void;
|
||||
// Play a sibling title from a collection strip (opens its player).
|
||||
onPlayItem?: (id: string) => void;
|
||||
onPlayItem?: (id: string, fromStart?: boolean) => void;
|
||||
// The item's library key — enables the admin "Manage collections" editor (page variant, movies).
|
||||
library?: string;
|
||||
};
|
||||
@@ -720,6 +720,7 @@ export default function PlexInfo({
|
||||
shelf
|
||||
navIndex={i}
|
||||
onOpen={() => onPlayItem?.(m.id)}
|
||||
onRestart={onPlayItem && (() => onPlayItem(m.id, true))}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
|
||||
@@ -102,7 +102,12 @@ function Grid({
|
||||
}
|
||||
|
||||
function setup(c: PlexCard, opts: Omit<Parameters<typeof Grid>[0], "c" | "handlers"> = {}) {
|
||||
const handlers = { onOpen: vi.fn(), onInfo: vi.fn(), onToggleWatched: vi.fn() };
|
||||
const handlers = {
|
||||
onOpen: vi.fn(),
|
||||
onInfo: vi.fn(),
|
||||
onToggleWatched: vi.fn(),
|
||||
onRestart: vi.fn(),
|
||||
};
|
||||
render(
|
||||
<QueryClientProvider client={new QueryClient()}>
|
||||
<FloatingTree>
|
||||
@@ -131,6 +136,24 @@ describe("a Plex title's menu", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
// C5: on touch the card's hover pair is gone, so the menu is where a started title starts over.
|
||||
it("a started title continues from its Play row and starts over from the next one", async () => {
|
||||
const { handlers } = setup(card({ status: "in_progress", position_seconds: 600 }));
|
||||
rightClick();
|
||||
expect(labels().slice(0, 2)).toEqual(["card.continue", "card.restartTitle"]);
|
||||
fireEvent.click(item("card.restartTitle"));
|
||||
// A row acts a task after the menu closes (closeThenAct).
|
||||
await vi.waitFor(() =>
|
||||
expect(handlers.onRestart).toHaveBeenCalledWith(expect.objectContaining({ id: "m1" })),
|
||||
);
|
||||
});
|
||||
|
||||
it("a watched title has no restart row: it plays from the start anyway", () => {
|
||||
setup(card({ status: "watched", position_seconds: 600 }));
|
||||
rightClick();
|
||||
expect(labels()).not.toContain("card.restartTitle");
|
||||
});
|
||||
|
||||
it("a show opens its page and has no watched, add or details rows", () => {
|
||||
setup(card({ type: "show" }));
|
||||
rightClick();
|
||||
|
||||
@@ -100,6 +100,7 @@ describe("PlexPersonView", () => {
|
||||
name="Jane Doe"
|
||||
onBack={() => {}}
|
||||
onOpenCard={() => {}}
|
||||
onRestartCard={() => {}}
|
||||
onInfoCard={() => {}}
|
||||
onFilter={onFilter}
|
||||
/>,
|
||||
@@ -139,6 +140,7 @@ describe("PlexPersonView", () => {
|
||||
name="Nobody"
|
||||
onBack={() => {}}
|
||||
onOpenCard={() => {}}
|
||||
onRestartCard={() => {}}
|
||||
onInfoCard={() => {}}
|
||||
onFilter={() => {}}
|
||||
/>,
|
||||
|
||||
@@ -0,0 +1,367 @@
|
||||
// @vitest-environment jsdom
|
||||
//
|
||||
// The Plex player's controls on touch and the C5 control set (DESIGN "Players", E28 S3f2): a tap on
|
||||
// the picture only shows or hides the controls, a double-tap on a side third skips 10 s, Back goes by
|
||||
// position, Next exists only with a next item, the rare tools sit behind a fixed ⋯, and the card's
|
||||
// Restart opens the player at 0. jsdom has no media pipeline: the <video> is stubbed as in
|
||||
// PlexPlayer.session.dom.test.tsx, and where the player asks a session to start is the observable.
|
||||
import { describe, it, expect, vi, beforeAll, beforeEach, afterEach } from "vitest";
|
||||
import { render, cleanup, waitFor, screen, fireEvent, act } from "@testing-library/react";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
|
||||
vi.mock("react-i18next", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("react-i18next")>();
|
||||
return {
|
||||
...actual,
|
||||
useTranslation: () => ({
|
||||
t: (key: string, o?: { n?: number }) => (o?.n != null ? `${key}:${o.n}` : key),
|
||||
i18n: { language: "en" },
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
import PlexPlayer from "./PlexPlayer";
|
||||
import { api, type PlexItemDetail } from "../lib/api";
|
||||
import { ACTIVE_ACCOUNT_KEY } from "../lib/activeAccount";
|
||||
|
||||
type Media = HTMLMediaElement & { _t?: number; _paused?: boolean };
|
||||
|
||||
beforeAll(() => {
|
||||
class Stub {
|
||||
observe() {}
|
||||
unobserve() {}
|
||||
disconnect() {}
|
||||
takeRecords() {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
globalThis.ResizeObserver ??= Stub as unknown as typeof ResizeObserver;
|
||||
globalThis.IntersectionObserver ??= Stub as unknown as typeof IntersectionObserver;
|
||||
// jsdom has no PointerEvent, and the player tells a finger from a mouse by `pointerType`.
|
||||
class PointerEventStub extends MouseEvent {
|
||||
pointerType: string;
|
||||
constructor(type: string, init: PointerEventInit = {}) {
|
||||
super(type, init);
|
||||
this.pointerType = init.pointerType ?? "mouse";
|
||||
}
|
||||
}
|
||||
globalThis.PointerEvent ??= PointerEventStub as unknown as typeof PointerEvent;
|
||||
Object.defineProperty(HTMLMediaElement.prototype, "currentTime", {
|
||||
configurable: true,
|
||||
get(this: Media) {
|
||||
return this._t ?? 0;
|
||||
},
|
||||
set(this: Media, v: number) {
|
||||
this._t = v;
|
||||
},
|
||||
});
|
||||
Object.defineProperty(HTMLMediaElement.prototype, "paused", {
|
||||
configurable: true,
|
||||
get(this: Media) {
|
||||
return this._paused ?? true;
|
||||
},
|
||||
});
|
||||
Object.defineProperty(HTMLMediaElement.prototype, "load", {
|
||||
configurable: true,
|
||||
value(this: Media) {
|
||||
this._t = 0;
|
||||
},
|
||||
});
|
||||
Object.defineProperty(HTMLMediaElement.prototype, "seekable", {
|
||||
configurable: true,
|
||||
get: () => ({ length: 0, end: () => 0 }),
|
||||
});
|
||||
Object.defineProperty(HTMLMediaElement.prototype, "textTracks", {
|
||||
configurable: true,
|
||||
get: () => Object.assign([], { addEventListener() {}, removeEventListener() {} }),
|
||||
});
|
||||
});
|
||||
|
||||
let play: ReturnType<typeof vi.fn>;
|
||||
let pause: ReturnType<typeof vi.fn>;
|
||||
beforeEach(() => {
|
||||
sessionStorage.setItem(ACTIVE_ACCOUNT_KEY, "7");
|
||||
play = vi.fn(function (this: Media) {
|
||||
this._paused = false;
|
||||
this.dispatchEvent(new Event("play"));
|
||||
return Promise.resolve();
|
||||
});
|
||||
pause = vi.fn(function (this: Media) {
|
||||
this._paused = true;
|
||||
this.dispatchEvent(new Event("pause"));
|
||||
});
|
||||
Object.defineProperty(HTMLMediaElement.prototype, "play", { configurable: true, value: play });
|
||||
Object.defineProperty(HTMLMediaElement.prototype, "pause", { configurable: true, value: pause });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.restoreAllMocks();
|
||||
vi.useRealTimers();
|
||||
sessionStorage.clear();
|
||||
localStorage.clear();
|
||||
});
|
||||
|
||||
const item = (id: string, position = 0, next: string | null = null): PlexItemDetail =>
|
||||
({
|
||||
id,
|
||||
title: `Title ${id}`,
|
||||
type: "movie",
|
||||
duration_seconds: 3000,
|
||||
status: "new",
|
||||
position_seconds: position,
|
||||
next_id: next,
|
||||
prev_id: null,
|
||||
audio_streams: [{ ord: 0, label: "English", language: "en", default: true }],
|
||||
subtitle_streams: [],
|
||||
markers: [],
|
||||
}) as unknown as PlexItemDetail;
|
||||
|
||||
const direct = (id: string) =>
|
||||
({ mode: "direct", url: `/media/${id}`, start_s: 0 }) as Awaited<
|
||||
ReturnType<typeof api.plexSession>
|
||||
>;
|
||||
|
||||
function mount(opts: {
|
||||
details: Record<string, PlexItemDetail>;
|
||||
queue?: string[];
|
||||
first?: string;
|
||||
fromStart?: boolean;
|
||||
}) {
|
||||
vi.spyOn(api, "plexItem").mockImplementation((id: string) => Promise.resolve(opts.details[id]!));
|
||||
const session = vi
|
||||
.spyOn(api, "plexSession")
|
||||
.mockImplementation((id: string) => Promise.resolve(direct(id)));
|
||||
vi.spyOn(api, "plexProgress").mockResolvedValue(undefined as never);
|
||||
vi.spyOn(api, "plexSubtitleStatus").mockResolvedValue({ state: "preparing", code: null, gen: 0 });
|
||||
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
const first = opts.first ?? opts.queue?.[0] ?? Object.keys(opts.details)[0]!;
|
||||
render(
|
||||
<QueryClientProvider client={qc}>
|
||||
<PlexPlayer itemId={first} onClose={() => {}} queue={opts.queue} fromStart={opts.fromStart} />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
return { session };
|
||||
}
|
||||
|
||||
const video = () => document.querySelector("video") as Media;
|
||||
|
||||
/** Which item the player last asked a session for, and where in it. */
|
||||
const lastStart = (session: { mock: { calls: unknown[][] } }) =>
|
||||
session.mock.calls[session.mock.calls.length - 1]?.slice(0, 2);
|
||||
|
||||
async function playingAt(src: string, t: number) {
|
||||
await waitFor(() => expect(video()?.getAttribute("src")).toBe(src));
|
||||
const v = video();
|
||||
v._t = t;
|
||||
act(() => void v.dispatchEvent(new Event("timeupdate")));
|
||||
}
|
||||
|
||||
/** The bottom band (the seek bar and the row): hidden = faded AND out of the pointer's way. */
|
||||
const bandHidden = () =>
|
||||
screen
|
||||
.getByLabelText("plex.player.playPause")
|
||||
.closest("[class*='bottom-0']")!
|
||||
.className.includes("opacity-0");
|
||||
|
||||
/** A press on the picture, from a finger or a mouse, at `x` of a 900px-wide picture. */
|
||||
function pressPicture(pointerType: "touch" | "mouse", x = 450) {
|
||||
const v = video();
|
||||
vi.spyOn(v, "getBoundingClientRect").mockReturnValue({
|
||||
left: 0,
|
||||
top: 0,
|
||||
width: 900,
|
||||
height: 500,
|
||||
right: 900,
|
||||
bottom: 500,
|
||||
x: 0,
|
||||
y: 0,
|
||||
toJSON: () => ({}),
|
||||
});
|
||||
fireEvent.pointerDown(v, { pointerType });
|
||||
fireEvent.click(v, { clientX: x, clientY: 250 });
|
||||
}
|
||||
|
||||
describe("a tap on the picture (C3)", () => {
|
||||
it("a mouse click plays or pauses; a finger's tap only shows or hides the controls", async () => {
|
||||
mount({ details: { A: item("A") } });
|
||||
await playingAt("/media/A", 100);
|
||||
const plays = play.mock.calls.length;
|
||||
const pauses = pause.mock.calls.length;
|
||||
|
||||
pressPicture("touch");
|
||||
expect(bandHidden()).toBe(true);
|
||||
pressPicture("touch");
|
||||
expect(bandHidden()).toBe(false);
|
||||
expect(play.mock.calls.length).toBe(plays);
|
||||
expect(pause.mock.calls.length).toBe(pauses);
|
||||
|
||||
pressPicture("mouse");
|
||||
expect(play.mock.calls.length + pause.mock.calls.length).toBe(plays + pauses + 1);
|
||||
});
|
||||
|
||||
it("the hide timer starts with playback, not with the first touch", async () => {
|
||||
mount({ details: { A: item("A") } });
|
||||
await playingAt("/media/A", 100);
|
||||
vi.useFakeTimers();
|
||||
video()._paused = true;
|
||||
act(() => void video().play());
|
||||
expect(bandHidden()).toBe(false);
|
||||
act(() => vi.advanceTimersByTime(3000));
|
||||
expect(bandHidden()).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("a double-tap on a side third skips 10 s (C5)", () => {
|
||||
it("skips from where it plays, again for each further tap, and takes back the first tap's toggle", async () => {
|
||||
const { session } = mount({ details: { A: item("A") } });
|
||||
await playingAt("/media/A", 1500);
|
||||
expect(bandHidden()).toBe(false);
|
||||
|
||||
pressPicture("touch", 800);
|
||||
expect(bandHidden()).toBe(true); // the first tap alone is a toggle…
|
||||
pressPicture("touch", 800);
|
||||
expect(bandHidden()).toBe(false); // …which the second takes back
|
||||
await waitFor(() => expect(lastStart(session)).toEqual(["A", 1510]));
|
||||
expect(screen.getByText("plex.player.tapForward:10")).toBeTruthy();
|
||||
|
||||
pressPicture("touch", 800);
|
||||
await waitFor(() => expect(lastStart(session)).toEqual(["A", 1520]));
|
||||
expect(screen.getByText("plex.player.tapForward:20")).toBeTruthy();
|
||||
|
||||
pressPicture("touch", 50); // the other side, a fresh first tap: a toggle, no skip
|
||||
expect(lastStart(session)).toEqual(["A", 1520]);
|
||||
});
|
||||
|
||||
it("the middle third never skips: a quick second tap there just toggles back", async () => {
|
||||
const { session } = mount({ details: { A: item("A") } });
|
||||
await playingAt("/media/A", 1500);
|
||||
const calls = session.mock.calls.length;
|
||||
pressPicture("touch", 450);
|
||||
pressPicture("touch", 450);
|
||||
expect(bandHidden()).toBe(false);
|
||||
expect(session.mock.calls.length).toBe(calls);
|
||||
});
|
||||
|
||||
it("the left third skips back", async () => {
|
||||
const { session } = mount({ details: { A: item("A") } });
|
||||
await playingAt("/media/A", 1500);
|
||||
pressPicture("touch", 100);
|
||||
pressPicture("touch", 100);
|
||||
await waitFor(() => expect(lastStart(session)).toEqual(["A", 1490]));
|
||||
expect(screen.getByText("plex.player.tapBack:10")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe("the C5 row", () => {
|
||||
it("has no Stop, Restart or ±10 s buttons, and keeps the rare tools behind the ⋯", async () => {
|
||||
mount({ details: { A: item("A") } });
|
||||
await playingAt("/media/A", 100);
|
||||
for (const gone of [
|
||||
"plex.player.stop",
|
||||
"plex.player.rewind10",
|
||||
"plex.player.forward10",
|
||||
"plex.player.stats.title",
|
||||
"plex.player.infoBtn",
|
||||
"plex.player.help",
|
||||
"plex.player.download",
|
||||
"plex.player.settings",
|
||||
])
|
||||
expect(screen.queryByLabelText(gone), gone).toBeNull();
|
||||
fireEvent.click(screen.getByLabelText("plex.player.more"));
|
||||
const rows = [
|
||||
"plex.player.settings",
|
||||
"plex.player.infoBtn",
|
||||
"plex.player.stats.title",
|
||||
"plex.player.help",
|
||||
"plex.player.download",
|
||||
];
|
||||
for (const r of rows) expect(screen.getByRole("button", { name: r }), r).toBeTruthy();
|
||||
// A row closes the menu and opens its tool: Settings → the tuning panel.
|
||||
fireEvent.click(screen.getByRole("button", { name: "plex.player.settings" }));
|
||||
expect(screen.queryByRole("button", { name: "plex.player.stats.title" })).toBeNull();
|
||||
expect(screen.getByLabelText("plex.player.more").getAttribute("aria-expanded")).toBe("true");
|
||||
});
|
||||
|
||||
it("Next shows only when there is a next item", async () => {
|
||||
mount({ details: { A: item("A") } });
|
||||
await playingAt("/media/A", 100);
|
||||
expect(screen.queryByLabelText("plex.player.next")).toBeNull();
|
||||
cleanup();
|
||||
mount({ details: { A: item("A"), B: item("B") }, queue: ["A", "B"] });
|
||||
await playingAt("/media/A", 100);
|
||||
expect(screen.getByLabelText("plex.player.next")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Back goes by position (C5)", () => {
|
||||
it("more than 3 s in: back to the start of this item", async () => {
|
||||
// B opened at its resume point, so a session asked at 0 can only be the restart.
|
||||
const { session } = mount({
|
||||
details: { A: item("A"), B: item("B", 900) },
|
||||
queue: ["A", "B"],
|
||||
first: "B",
|
||||
});
|
||||
await playingAt("/media/B", 40);
|
||||
fireEvent.click(screen.getByLabelText("plex.player.restart"));
|
||||
// Inside the loaded media the restart is a native seek: the clock goes to 0, on B.
|
||||
await waitFor(() => expect(video()._t).toBe(0));
|
||||
expect(lastStart(session)?.[0]).toBe("B");
|
||||
});
|
||||
|
||||
it("within the first 3 s: the previous item, at its resume point", async () => {
|
||||
const { session } = mount({
|
||||
details: { A: item("A", 700), B: item("B") },
|
||||
queue: ["A", "B"],
|
||||
first: "B",
|
||||
});
|
||||
await playingAt("/media/B", 2);
|
||||
fireEvent.click(screen.getByLabelText("plex.player.prev"));
|
||||
await waitFor(() => expect(lastStart(session)).toEqual(["A", 700]));
|
||||
});
|
||||
|
||||
it("the first item has only the restart", async () => {
|
||||
const { session } = mount({ details: { A: item("A", 900), B: item("B") }, queue: ["A", "B"] });
|
||||
await playingAt("/media/A", 2);
|
||||
expect(screen.queryByLabelText("plex.player.prev")).toBeNull();
|
||||
fireEvent.click(screen.getByLabelText("plex.player.restart"));
|
||||
await waitFor(() => expect(video()._t).toBe(0));
|
||||
expect(lastStart(session)?.[0]).toBe("A");
|
||||
});
|
||||
});
|
||||
|
||||
describe("the card's Restart (C5)", () => {
|
||||
it("opens the item at 0 instead of its resume point", async () => {
|
||||
const { session } = mount({ details: { A: item("A", 900) }, fromStart: true });
|
||||
await waitFor(() => expect(session).toHaveBeenCalled());
|
||||
expect(session.mock.calls[0]![1]).toBe(0);
|
||||
});
|
||||
|
||||
it("without it, the item resumes", async () => {
|
||||
const { session } = mount({ details: { A: item("A", 900) } });
|
||||
await waitFor(() => expect(session).toHaveBeenCalled());
|
||||
expect(session.mock.calls[0]![1]).toBe(900);
|
||||
});
|
||||
|
||||
it("only for the item it opened with: the next one resumes", async () => {
|
||||
const { session } = mount({
|
||||
details: { A: item("A", 900), B: item("B", 300) },
|
||||
queue: ["A", "B"],
|
||||
fromStart: true,
|
||||
});
|
||||
await playingAt("/media/A", 100);
|
||||
fireEvent.click(screen.getByLabelText("plex.player.next"));
|
||||
await waitFor(() => expect(lastStart(session)).toEqual(["B", 300]));
|
||||
});
|
||||
});
|
||||
|
||||
describe("the seek bar (C3)", () => {
|
||||
it("is a 2.75rem hit area a finger drags without panning, with the head shown on touch", async () => {
|
||||
mount({ details: { A: item("A") } });
|
||||
await playingAt("/media/A", 100);
|
||||
const hit = document.querySelector<HTMLElement>(".touch-none.h-11");
|
||||
expect(hit).not.toBeNull();
|
||||
const head = hit!.querySelector("[class*='rounded-full'][class*='w-3']");
|
||||
expect(head?.className).toContain("[@media(hover:none)]:opacity-100");
|
||||
});
|
||||
});
|
||||
@@ -180,7 +180,7 @@ it("a seek that restarts the session is saved at its target while the new sessio
|
||||
});
|
||||
const beacon = vi.spyOn(api, "plexProgressBeacon").mockImplementation(() => {});
|
||||
await playingAt("/media/A", 1500);
|
||||
fireEvent.click(screen.getByLabelText("plex.player.forward10"));
|
||||
fireEvent.keyDown(document.body, { key: "ArrowRight" }); // the 10 s step
|
||||
await waitFor(() => expect(api.plexSession).toHaveBeenCalledTimes(2)); // a restart, not a native seek
|
||||
await tick();
|
||||
act(() => void window.dispatchEvent(new Event("pagehide")));
|
||||
@@ -526,8 +526,8 @@ it("a seek made while a restart is pending wins over it", async () => {
|
||||
await waitFor(() => expect(video()?.getAttribute("src")).toBe("/media/A"));
|
||||
Object.defineProperty(video(), "seekable", { get: () => ({ length: 1, end: () => 100 }) });
|
||||
await playingAt("/media/A", 95);
|
||||
fireEvent.click(screen.getByLabelText("plex.player.forward10")); // 105: past the buffer → restart
|
||||
fireEvent.click(screen.getByLabelText("plex.player.rewind10")); // 95: inside the OLD buffer
|
||||
fireEvent.keyDown(document.body, { key: "ArrowRight" }); // the 10 s step // 105: past the buffer → restart
|
||||
fireEvent.keyDown(document.body, { key: "ArrowLeft" }); // 95: inside the OLD buffer
|
||||
await waitFor(() => expect(startsAsked("A")).toEqual([0, 105, 95]));
|
||||
await act(async () => pending.resolve({ mode: "direct", url: "/media/A2", start_s: 0 } as never));
|
||||
await tick();
|
||||
@@ -594,7 +594,7 @@ describe("a session request that fails", () => {
|
||||
},
|
||||
});
|
||||
await playingAt("/media/A", 1500);
|
||||
fireEvent.click(screen.getByLabelText("plex.player.forward10"));
|
||||
fireEvent.keyDown(document.body, { key: "ArrowRight" }); // the 10 s step
|
||||
await screen.findByText("plex.player.errGeneric");
|
||||
await playingAt("/media/A", 1600); // the old stream plays on
|
||||
unmount();
|
||||
|
||||
@@ -154,14 +154,18 @@ describe("PlexPlayer · Tab stays inside the player", () => {
|
||||
// DOES trap, so the player stood its own trap down for it and Tab walked out past the last
|
||||
// control into the nav rail. Measured in a real browser: 30 Tabs, 9 escapes. The menus render
|
||||
// inside the player, so the player's trap is the one that must stay on.
|
||||
const gear = [...player().querySelectorAll<HTMLElement>("button")].find(
|
||||
(b) => b.getAttribute("aria-label") === "plex.player.settings",
|
||||
);
|
||||
expect(gear).toBeDefined();
|
||||
act(() => gear!.click());
|
||||
const items = [...player().querySelectorAll<HTMLElement>("button:not([disabled])")];
|
||||
// The menu really opened — the trap now has to span its controls too.
|
||||
expect(items.length).toBeGreaterThan(12);
|
||||
const buttons = () => [...player().querySelectorAll<HTMLElement>("button:not([disabled])")];
|
||||
const before = buttons().length;
|
||||
// The tuning panel opens from the ⋯ (C5): ⋯, then its Settings row.
|
||||
const more = buttons().find((b) => b.getAttribute("aria-label") === "plex.player.more");
|
||||
expect(more).toBeDefined();
|
||||
act(() => more!.click());
|
||||
const settings = buttons().find((b) => b.textContent === "plex.player.settings");
|
||||
expect(settings).toBeDefined();
|
||||
act(() => settings!.click());
|
||||
const items = buttons();
|
||||
// The panel really opened — the trap now has to span its controls too.
|
||||
expect(items.length).toBeGreaterThan(before + 3);
|
||||
const last = items[items.length - 1]!;
|
||||
last.focus();
|
||||
withLayout(() => press("Tab"));
|
||||
|
||||
@@ -17,13 +17,12 @@ import {
|
||||
Loader2,
|
||||
Maximize,
|
||||
Minimize,
|
||||
MoreHorizontal,
|
||||
Pause,
|
||||
Play,
|
||||
RotateCcw,
|
||||
Settings,
|
||||
SkipBack,
|
||||
SkipForward,
|
||||
Square,
|
||||
Volume2,
|
||||
VolumeX,
|
||||
} from "lucide-react";
|
||||
@@ -39,7 +38,7 @@ import {
|
||||
subShadowCss,
|
||||
type PlexPlayerPrefs,
|
||||
} from "../lib/plexPlayerPrefs";
|
||||
import { Ctrl, PanelHead, Skip10 } from "./plexPlayerControls";
|
||||
import { Ctrl, PanelHead } from "./plexPlayerControls";
|
||||
import { PlexPlayerSettings } from "./PlexPlayerSettings";
|
||||
import { PlexStatsOverlay } from "./PlexStatsOverlay";
|
||||
import { createFragMeter } from "../lib/plexStats";
|
||||
@@ -100,6 +99,13 @@ const MAX_SESSION_RECLAIMS = 2;
|
||||
// Two losses inside two minutes is a session that keeps being lost; one an hour apart is not.
|
||||
const RECLAIM_WINDOW_MS = 120_000;
|
||||
|
||||
// C5: Back restarts the item after this many seconds in, and steps to the previous one before it.
|
||||
const BACK_TO_PREV_S = 3;
|
||||
// C5: a touch double-tap on the picture's left or right third skips this far; each further tap
|
||||
// inside the window skips again (the keyboard keeps ← / → at the viewer's own step).
|
||||
const TAP_SKIP_S = 10;
|
||||
const DOUBLE_TAP_MS = 300;
|
||||
|
||||
// The Plex module's rich, full-page player. Plays the LOCAL file: direct-playable files stream raw
|
||||
// (native <video>), remux files stream via the seek-restart HLS session (hls.js). The visible
|
||||
// timeline is ABSOLUTE (0…duration); the HLS session is relative to its start offset, so we map
|
||||
@@ -108,9 +114,11 @@ const RECLAIM_WINDOW_MS = 120_000;
|
||||
|
||||
// `queue` (ordered rating_keys) drives play-through for a playlist: prev/next + auto-advance follow
|
||||
// the queue instead of the item's own episode neighbours.
|
||||
type Props = { itemId: string; onClose: () => void; queue?: string[] };
|
||||
// `fromStart` (the card's Restart, C5) applies to the item the player opened with; every item it
|
||||
// moves on to starts at its own resume point.
|
||||
type Props = { itemId: string; onClose: () => void; queue?: string[]; fromStart?: boolean };
|
||||
|
||||
export default function PlexPlayer({ itemId, onClose, queue }: Props) {
|
||||
export default function PlexPlayer({ itemId, onClose, queue, fromStart = false }: Props) {
|
||||
const { t } = useTranslation();
|
||||
// `t` changes identity on a language switch. loadSession only uses it for error strings, so read it
|
||||
// through a ref — keeping it OUT of loadSession's deps. Otherwise a mid-playback language change
|
||||
@@ -224,7 +232,10 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
|
||||
fineSeekStepRef.current = prefs.fineSeekStep;
|
||||
const aoffRef = useRef(prefs.audioOffset);
|
||||
aoffRef.current = prefs.audioOffset;
|
||||
const [menuOpen, setMenuOpen] = useState(false); // gear = tuning (tabbed)
|
||||
const [menuOpen, setMenuOpen] = useState(false); // the tuning panel (tabbed), from ⋯ → Settings
|
||||
// The fixed ⋯ (C5): the rare tools — settings, media info, stats, shortcuts, the original file.
|
||||
const [moreOpen, setMoreOpen] = useState(false);
|
||||
const moreRef = useRef<HTMLDivElement>(null);
|
||||
const [tracksOpen, setTracksOpen] = useState(false); // separate quick menu = audio/subtitle tracks
|
||||
const [settingsTab, setSettingsTab] = useState<"sync" | "playback" | "subtitle" | "clock">(
|
||||
"sync",
|
||||
@@ -235,9 +246,15 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
|
||||
const menuTriggerRef = useRef<HTMLButtonElement>(null);
|
||||
const tracksRef = useRef<HTMLDivElement | null>(null);
|
||||
const tracksTriggerRef = useRef<HTMLButtonElement>(null);
|
||||
menuOpenRef.current = menuOpen || tracksOpen; // keep the control bar up while either menu is open
|
||||
menuOpenRef.current = menuOpen || tracksOpen || moreOpen; // keep the control bar up while one is open
|
||||
// Auto-hiding control bar (reveal on mouse move / any key; hide after 3s while playing).
|
||||
const { uiVisible, wake, holdOnHover } = useControlsVisibility({
|
||||
const {
|
||||
uiVisible,
|
||||
wake,
|
||||
holdOnHover,
|
||||
toggle: toggleControls,
|
||||
show: showControls,
|
||||
} = useControlsVisibility({
|
||||
barAutoHideRef,
|
||||
menuOpenRef,
|
||||
videoRef,
|
||||
@@ -250,6 +267,7 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
|
||||
// Each menu stays open until a click/Escape OUTSIDE it (so sliders can be dragged freely).
|
||||
useDismiss(menuOpen, () => setMenuOpen(false), [menuRef, menuTriggerRef]);
|
||||
useDismiss(tracksOpen, () => setTracksOpen(false), [tracksRef, tracksTriggerRef]);
|
||||
useDismiss(moreOpen, () => setMoreOpen(false), [moreRef, menuTriggerRef]);
|
||||
// The tracks menu is both dismiss-tracked and edge-faded, so its two refs are merged here.
|
||||
// Must be stable: an inline arrow would detach (null) and re-attach the node on every render,
|
||||
// and the fade's node-state ref would re-render in response — a loop. `fade.ref` is a setState.
|
||||
@@ -572,6 +590,10 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
|
||||
// the cache when the player opened is not corrected by the refetch that follows; the player's own
|
||||
// leave-patch (usePlexProgress) keeps the cache honest for everything played here.
|
||||
const startedForRef = useRef<string | null>(null);
|
||||
// Raised by the first `go()`: `fromStart` belongs to the item the player opened on, and coming BACK
|
||||
// to that item (Back within 3 s of the next) resumes it like any other. A flag, not a consumed
|
||||
// prop: StrictMode replays this effect, and a consumed one resumed on the replay.
|
||||
const movedOnRef = useRef(false);
|
||||
useEffect(() => {
|
||||
if (!detail || detail.id !== id || startedForRef.current === id) return;
|
||||
startedForRef.current = id;
|
||||
@@ -585,9 +607,12 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
|
||||
audioRef.current = aOrd;
|
||||
setAudioOrd(aOrd);
|
||||
setSubOrd(subOrdForLang(detail.subtitle_streams ?? [], subLang));
|
||||
const resume = detail.status !== "watched" ? detail.position_seconds || 0 : 0;
|
||||
const resume =
|
||||
detail.status !== "watched" && !(fromStart && !movedOnRef.current)
|
||||
? detail.position_seconds || 0
|
||||
: 0;
|
||||
void loadSession(resume);
|
||||
}, [detail, id, loadSession, audioRef, setAudioOrd, setSubOrd]);
|
||||
}, [detail, id, fromStart, loadSession, audioRef, setAudioOrd, setSubOrd]);
|
||||
// Leaving the item: a session still in flight for it must not land after it, and its stream goes.
|
||||
useEffect(
|
||||
() => () => {
|
||||
@@ -785,6 +810,7 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
|
||||
// reset below and would otherwise hand the old position to the new item.
|
||||
settleProgress();
|
||||
clockLiveRef.current = false;
|
||||
movedOnRef.current = true;
|
||||
setAbs(0);
|
||||
setId(otherId);
|
||||
}
|
||||
@@ -801,6 +827,55 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
|
||||
: null
|
||||
: detail?.next_id;
|
||||
|
||||
// Back (C5): past the first BACK_TO_PREV_S seconds it restarts the item; inside them it steps to the
|
||||
// previous one (at its resume point, as `go` opens every item) — or restarts again on the first.
|
||||
const backToPrev = abs <= BACK_TO_PREV_S && !!prevId;
|
||||
const backOrPrev = useCallback(() => {
|
||||
if (playhead(absRef, loadStartRef) <= BACK_TO_PREV_S && prevId) go(prevId);
|
||||
else seekTo(0);
|
||||
}, [prevId, go, seekTo]);
|
||||
|
||||
// What the last press came from. A touch tap on the picture only shows or hides the controls (C3:
|
||||
// measured live, a tap used to reveal the bar AND pause); a mouse click there still plays/pauses.
|
||||
// Kept from `pointerdown`, because the `click` that follows is not a PointerEvent everywhere.
|
||||
const pointerRef = useRef("mouse");
|
||||
// The picture's taps: one shows/hides the controls; a second within DOUBLE_TAP_MS on the left or
|
||||
// right third skips ∓TAP_SKIP_S, as does each further one (C5), and takes back the first tap's
|
||||
// show/hide. The middle third has no skip, so a quick second tap there just toggles again.
|
||||
const tapRef = useRef<{ at: number; zone: number; wasShown: boolean; skips: number } | null>(
|
||||
null,
|
||||
);
|
||||
const onPictureClick = useCallback(
|
||||
(e: React.MouseEvent<HTMLVideoElement>) => {
|
||||
if (pointerRef.current !== "touch") {
|
||||
togglePlay();
|
||||
return;
|
||||
}
|
||||
// The wrapper's own click wakes the controls, which would undo a hide.
|
||||
e.stopPropagation();
|
||||
const r = e.currentTarget.getBoundingClientRect();
|
||||
const x = r.width > 0 ? (e.clientX - r.left) / r.width : 0.5;
|
||||
const zone = x < 1 / 3 ? -1 : x > 2 / 3 ? 1 : 0;
|
||||
const now = performance.now();
|
||||
const prev = tapRef.current;
|
||||
if (zone !== 0 && prev && prev.zone === zone && now - prev.at < DOUBLE_TAP_MS) {
|
||||
if (prev.skips === 0) showControls(prev.wasShown);
|
||||
const skips = prev.skips + 1;
|
||||
tapRef.current = { ...prev, at: now, skips };
|
||||
seekTo(playhead(absRef, loadStartRef) + zone * TAP_SKIP_S);
|
||||
flashOsd(
|
||||
t(zone < 0 ? "plex.player.tapBack" : "plex.player.tapForward", {
|
||||
n: skips * TAP_SKIP_S,
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
tapRef.current = { at: now, zone, wasShown: uiVisible, skips: 0 };
|
||||
toggleControls();
|
||||
},
|
||||
[togglePlay, showControls, toggleControls, seekTo, flashOsd, t, uiVisible],
|
||||
);
|
||||
|
||||
// Auto-skip intro/credits (countdown → jump to marker end / next item). The refs bridge to the
|
||||
// earlier keyboard/back handlers that cancel a running countdown.
|
||||
const { activeMarker, skipProgress, skipProgressRef, cancelAutoSkipRef, doSkip } = useAutoSkip({
|
||||
@@ -837,6 +912,45 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
|
||||
];
|
||||
const activeTab = settingsTabs.some((x) => x.key === settingsTab) ? settingsTab : "sync";
|
||||
|
||||
// The ⋯ menu's rows (C5), in the lab's order. Settings and the media info need the item's detail.
|
||||
const ico = "h-4 w-4 shrink-0";
|
||||
const moreItems = [
|
||||
...(detail
|
||||
? [
|
||||
{
|
||||
id: "settings",
|
||||
label: t("plex.player.settings"),
|
||||
icon: <Settings className={ico} />,
|
||||
run: () => setMenuOpen(true),
|
||||
},
|
||||
{
|
||||
id: "info",
|
||||
label: t("plex.player.infoBtn"),
|
||||
icon: <Info className={ico} />,
|
||||
run: () => setInfoOpen((v) => !v),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
{
|
||||
id: "stats",
|
||||
label: t("plex.player.stats.title"),
|
||||
icon: <Gauge className={ico} />,
|
||||
run: () => setStatsOpen((v) => !v),
|
||||
},
|
||||
{
|
||||
id: "help",
|
||||
label: t("plex.player.help"),
|
||||
icon: <Keyboard className={ico} />,
|
||||
run: toggleKeyboardHelp,
|
||||
},
|
||||
{
|
||||
id: "download",
|
||||
label: t("plex.player.download"),
|
||||
icon: <Download className={ico} />,
|
||||
run: download,
|
||||
},
|
||||
];
|
||||
|
||||
// Auto-advance to the next episode when one finishes.
|
||||
useEffect(() => {
|
||||
const video = videoRef.current;
|
||||
@@ -887,6 +1001,7 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
|
||||
if (menuOpenRef.current) {
|
||||
setMenuOpen(false);
|
||||
setTracksOpen(false);
|
||||
setMoreOpen(false);
|
||||
} else if (infoOpenRef.current) setInfoOpen(false);
|
||||
else if (statsOpenRef.current) setStatsOpen(false);
|
||||
else if (upNextOpenRef.current) setUpNextOpen(false);
|
||||
@@ -1030,6 +1145,7 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
|
||||
else if (menuOpenRef.current) {
|
||||
setMenuOpen(false);
|
||||
setTracksOpen(false);
|
||||
setMoreOpen(false);
|
||||
} else if (infoOpenRef.current) setInfoOpen(false);
|
||||
else if (statsOpenRef.current) setStatsOpen(false);
|
||||
else if (upNextOpenRef.current) setUpNextOpen(false);
|
||||
@@ -1086,7 +1202,14 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
|
||||
<div
|
||||
ref={wrapRef}
|
||||
className="fixed left-0 top-0 z-overlay bg-black flex items-center justify-center select-none"
|
||||
onMouseMove={wake}
|
||||
onPointerDown={(e) => {
|
||||
pointerRef.current = e.pointerType;
|
||||
}}
|
||||
// A pointer MOVE wakes the bar; a touch has none of its own, and the mouse events a browser
|
||||
// fakes after a tap would show the bar the tap just hid.
|
||||
onPointerMove={(e) => {
|
||||
if (e.pointerType !== "touch") wake();
|
||||
}}
|
||||
onClick={wake}
|
||||
onWheel={onWheel}
|
||||
// Windowed (pseudo-fullscreen): everything is 25% larger for lean-back / HTPC legibility. Laid
|
||||
@@ -1129,8 +1252,9 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
|
||||
// Fill the wrapper (which is sized to the viewport / screen) and letterbox by aspect via
|
||||
// object-contain. NOT `w-auto h-auto max-*`: that caps the video at its intrinsic resolution
|
||||
// (a 720p stream → 1280×720) and never scales it UP, so in fullscreen it sat tiny and centred.
|
||||
className="w-full h-full object-contain"
|
||||
onClick={togglePlay}
|
||||
// `touch-manipulation`: a double-tap is the skip, never the browser's zoom.
|
||||
className="w-full h-full object-contain touch-manipulation"
|
||||
onClick={onPictureClick}
|
||||
playsInline
|
||||
>
|
||||
{/* Text subtitles as native tracks (same-origin → sent with the session cookie). The effect
|
||||
@@ -1234,7 +1358,7 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
|
||||
<div
|
||||
{...holdWhileHovered}
|
||||
className={`absolute top-0 inset-x-0 p-4 bg-linear-to-b from-black/70 to-transparent transition-opacity ${
|
||||
uiVisible ? "opacity-100" : "opacity-0"
|
||||
uiVisible ? "opacity-100" : "opacity-0 pointer-events-none"
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-3 text-white">
|
||||
@@ -1311,14 +1435,16 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
|
||||
{/* Bottom controls */}
|
||||
<div
|
||||
{...holdWhileHovered}
|
||||
className={`absolute bottom-0 inset-x-0 px-4 pb-3 pt-8 bg-linear-to-t from-black/80 to-transparent transition-opacity ${
|
||||
className={`absolute bottom-0 inset-x-0 px-4 pb-3 pt-3 bg-linear-to-t from-black/80 to-transparent transition-opacity ${
|
||||
uiVisible ? "opacity-100" : "opacity-0 pointer-events-none"
|
||||
}`}
|
||||
>
|
||||
{/* Seek bar — click to jump, or drag the head (pointer capture) to scrub; the seek commits on
|
||||
release so a long drag doesn't restart the stream on every step. */}
|
||||
release so a long drag doesn't restart the stream on every step. The track stays thin
|
||||
inside a 2.75rem hit area (C3): a finger could not find the 6px bar, and `touch-none`
|
||||
makes a drag scrub instead of panning the page. */}
|
||||
<div
|
||||
className="relative h-1.5 rounded-full bg-white/25 cursor-pointer group mb-2"
|
||||
className="group relative flex h-11 cursor-pointer touch-none items-center"
|
||||
onPointerDown={(e) => {
|
||||
const el = e.currentTarget as HTMLElement;
|
||||
try {
|
||||
@@ -1337,11 +1463,17 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
|
||||
setHover({ frac, t: tt });
|
||||
if (e.buttons & 1) setScrub(tt); // dragging with the primary button held
|
||||
}}
|
||||
onPointerUp={() => {
|
||||
onPointerUp={(e) => {
|
||||
if (scrub != null) {
|
||||
seekTo(scrub);
|
||||
setScrub(null);
|
||||
}
|
||||
// A finger never "leaves": its time tip goes with it.
|
||||
if (e.pointerType === "touch") setHover(null);
|
||||
}}
|
||||
onPointerCancel={() => {
|
||||
setScrub(null);
|
||||
setHover(null);
|
||||
}}
|
||||
onMouseLeave={() => setHover(null)}
|
||||
>
|
||||
@@ -1354,65 +1486,56 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
|
||||
{fmt(hover.t)}
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
className="absolute inset-y-0 left-0 bg-white/30 rounded-full"
|
||||
style={{ width: `${bufPct}%` }}
|
||||
/>
|
||||
{/* intro/credit marker regions */}
|
||||
{detail?.markers.map((m, i) => (
|
||||
<div className="relative h-1.5 w-full rounded-full bg-white/25">
|
||||
<div
|
||||
key={i}
|
||||
className="absolute inset-y-0 bg-warn/50"
|
||||
style={{
|
||||
left: `${(m.start_s / duration) * 100}%`,
|
||||
width: `${((m.end_s - m.start_s) / duration) * 100}%`,
|
||||
}}
|
||||
className="absolute inset-y-0 left-0 bg-white/30 rounded-full"
|
||||
style={{ width: `${bufPct}%` }}
|
||||
/>
|
||||
))}
|
||||
<div
|
||||
className="absolute inset-y-0 left-0 bg-accent rounded-full"
|
||||
style={{ width: `${headPct}%` }}
|
||||
/>
|
||||
<div
|
||||
className={`absolute top-1/2 -translate-y-1/2 -translate-x-1/2 w-3 h-3 rounded-full bg-accent transition-opacity ${
|
||||
scrub != null ? "opacity-100" : "opacity-0 group-hover:opacity-100"
|
||||
}`}
|
||||
style={{ left: `${headPct}%` }}
|
||||
/>
|
||||
{/* intro/credit marker regions */}
|
||||
{detail?.markers.map((m, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="absolute inset-y-0 bg-warn/50"
|
||||
style={{
|
||||
left: `${(m.start_s / duration) * 100}%`,
|
||||
width: `${((m.end_s - m.start_s) / duration) * 100}%`,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
<div
|
||||
className="absolute inset-y-0 left-0 bg-accent rounded-full"
|
||||
style={{ width: `${headPct}%` }}
|
||||
/>
|
||||
{/* The head: on hover for a mouse, always on touch (C3), where nothing hovers. */}
|
||||
<div
|
||||
className={`absolute top-1/2 -translate-y-1/2 -translate-x-1/2 w-3 h-3 rounded-full bg-accent transition-opacity ${
|
||||
scrub != null
|
||||
? "opacity-100"
|
||||
: "opacity-0 group-hover:opacity-100 [@media(hover:none)]:opacity-100"
|
||||
}`}
|
||||
style={{ left: `${headPct}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* The C5 row, one line on a portrait tablet: play/pause, Back, Next (only with a next
|
||||
item), mute, the time, then audio & subtitles, the fixed ⋯ and fullscreen. No Stop
|
||||
(closing is stopping), no Restart (Back is, and the card's), no ±10 s (← / → and the
|
||||
touch double-tap are). */}
|
||||
<div className="flex items-center gap-3 text-white">
|
||||
<Ctrl
|
||||
label={t("plex.player.rewind10")}
|
||||
onClick={() => seekTo(playhead(absRef, loadStartRef) - 10)}
|
||||
align="start"
|
||||
>
|
||||
<Skip10 dir="back" />
|
||||
</Ctrl>
|
||||
<Ctrl label={t("plex.player.playPause")} onClick={togglePlay}>
|
||||
<Ctrl label={t("plex.player.playPause")} onClick={togglePlay} align="start">
|
||||
{playing ? <Pause className="w-6 h-6" /> : <Play className="w-6 h-6" />}
|
||||
</Ctrl>
|
||||
<Ctrl
|
||||
label={t("plex.player.forward10")}
|
||||
onClick={() => seekTo(playhead(absRef, loadStartRef) + 10)}
|
||||
label={backToPrev ? t("plex.player.prev") : t("plex.player.restart")}
|
||||
onClick={backOrPrev}
|
||||
>
|
||||
<Skip10 dir="forward" />
|
||||
<SkipBack className="w-5 h-5" />
|
||||
</Ctrl>
|
||||
<Ctrl label={t("plex.player.restart")} onClick={() => seekTo(0)}>
|
||||
<RotateCcw className="w-5 h-5" />
|
||||
</Ctrl>
|
||||
<Ctrl label={t("plex.player.stop")} onClick={onClose}>
|
||||
<Square className="w-5 h-5" />
|
||||
</Ctrl>
|
||||
{(detail?.kind === "episode" || queue) && (
|
||||
<>
|
||||
<Ctrl label={t("plex.player.prev")} onClick={() => go(prevId)} disabled={!prevId}>
|
||||
<SkipBack className="w-5 h-5" />
|
||||
</Ctrl>
|
||||
<Ctrl label={t("plex.player.next")} onClick={() => go(nextId)} disabled={!nextId}>
|
||||
<SkipForward className="w-5 h-5" />
|
||||
</Ctrl>
|
||||
</>
|
||||
{nextId && (
|
||||
<Ctrl label={t("plex.player.next")} onClick={() => go(nextId)}>
|
||||
<SkipForward className="w-5 h-5" />
|
||||
</Ctrl>
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
@@ -1427,8 +1550,10 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
|
||||
keyboard and screen-reader support come with it — the hand-drawn bar it replaces had
|
||||
none of them. The level shows above it while hovered. A pointer drag hands the focus
|
||||
back so Space and the arrows keep driving the player, not the slider. */}
|
||||
{/* Not on touch: a tablet's volume is its buttons (iPadOS ignores a page's), and the
|
||||
row has to fit a portrait screen. */}
|
||||
<div
|
||||
className="relative w-24"
|
||||
className="relative w-24 [@media(hover:none)]:hidden"
|
||||
onPointerEnter={() => setVolHover(true)}
|
||||
onPointerLeave={() => setVolHover(false)}
|
||||
>
|
||||
@@ -1449,7 +1574,7 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="text-xs tabular-nums text-white/90">
|
||||
<div className="min-w-0 truncate text-xs tabular-nums text-white/90">
|
||||
{fmt(abs)} / {fmt(duration)}
|
||||
<span className="ml-1.5 text-white/55">-{fmt(Math.max(0, duration - abs))}</span>
|
||||
{/* Projected finish time (wall clock now + time remaining), in the clock's own format —
|
||||
@@ -1537,46 +1662,57 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Gear = tuning, split into tabs (Sync / Playback / Subtitle). */}
|
||||
{detail && (
|
||||
<div className="relative">
|
||||
<IconButton
|
||||
variant="overlay"
|
||||
size="player"
|
||||
round
|
||||
ref={menuTriggerRef}
|
||||
onClick={() => setMenuOpen((o) => !o)}
|
||||
aria-expanded={menuOpen}
|
||||
label={t("plex.player.settings")}
|
||||
{/* The fixed ⋯ (C5): the rare tools. Settings opens the tuning panel from the same spot,
|
||||
so the ⋯ also closes it. */}
|
||||
<div className="relative">
|
||||
<IconButton
|
||||
variant="overlay"
|
||||
size="player"
|
||||
round
|
||||
ref={menuTriggerRef}
|
||||
onClick={() => (menuOpen ? setMenuOpen(false) : setMoreOpen((o) => !o))}
|
||||
aria-expanded={moreOpen || menuOpen}
|
||||
label={t("plex.player.more")}
|
||||
>
|
||||
<MoreHorizontal />
|
||||
</IconButton>
|
||||
{moreOpen && (
|
||||
<div
|
||||
ref={moreRef}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="border border-white/15 bg-media-chrome/95 absolute bottom-full right-0 z-menu mb-2 flex w-60 flex-col rounded-xl p-1.5 text-sm text-white shadow-2xl"
|
||||
>
|
||||
<Settings />
|
||||
</IconButton>
|
||||
{menuOpen && (
|
||||
<PlexPlayerSettings
|
||||
menuRef={menuRef}
|
||||
settingsTabs={settingsTabs}
|
||||
activeTab={activeTab}
|
||||
setSettingsTab={setSettingsTab}
|
||||
prefs={prefs}
|
||||
patchPrefs={patchPrefs}
|
||||
changeAudioOffset={changeAudioOffset}
|
||||
now={now}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<Ctrl label={t("plex.player.stats.title")} onClick={() => setStatsOpen((v) => !v)}>
|
||||
<Gauge className="w-5 h-5" />
|
||||
</Ctrl>
|
||||
<Ctrl label={t("plex.player.infoBtn")} onClick={() => setInfoOpen((v) => !v)}>
|
||||
<Info className="w-5 h-5" />
|
||||
</Ctrl>
|
||||
<Ctrl label={t("plex.player.help")} onClick={toggleKeyboardHelp}>
|
||||
<Keyboard className="w-5 h-5" />
|
||||
</Ctrl>
|
||||
<Ctrl label={t("plex.player.download")} onClick={download}>
|
||||
<Download className="w-5 h-5" />
|
||||
</Ctrl>
|
||||
{moreItems.map((it) => (
|
||||
<Pressable
|
||||
key={it.id}
|
||||
onClick={() => {
|
||||
setMoreOpen(false);
|
||||
// Back on the ⋯ before the tool opens: its row is about to unmount, and a
|
||||
// dialog opened from here returns the focus to where it found it.
|
||||
menuTriggerRef.current?.focus();
|
||||
it.run();
|
||||
}}
|
||||
className="touch-target flex min-h-9 w-full items-center gap-2.5 rounded-lg px-2.5 text-left hover:bg-white/10"
|
||||
>
|
||||
{it.icon}
|
||||
{it.label}
|
||||
</Pressable>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{menuOpen && (
|
||||
<PlexPlayerSettings
|
||||
menuRef={menuRef}
|
||||
settingsTabs={settingsTabs}
|
||||
activeTab={activeTab}
|
||||
setSettingsTab={setSettingsTab}
|
||||
prefs={prefs}
|
||||
patchPrefs={patchPrefs}
|
||||
changeAudioOffset={changeAudioOffset}
|
||||
now={now}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<Ctrl label={t("plex.player.fullscreen")} onClick={toggleFs} align="end">
|
||||
{fs ? <Minimize className="w-5 h-5" /> : <Maximize className="w-5 h-5" />}
|
||||
</Ctrl>
|
||||
|
||||
@@ -405,7 +405,7 @@ export default function PlexPlaylistView({
|
||||
}: {
|
||||
playlistId: number;
|
||||
onBack: () => void;
|
||||
onPlay: (itemRk: string, queue: string[]) => void;
|
||||
onPlay: (itemRk: string, queue: string[], fromStart?: boolean) => void;
|
||||
/** A title's media-info page, from its menu's "Details". */
|
||||
onInfoCard?: (card: PlexCard) => void;
|
||||
}) {
|
||||
@@ -558,6 +558,7 @@ export default function PlexPlaylistView({
|
||||
const titleActions = usePlexCardActions(
|
||||
{
|
||||
onOpen: (c) => onPlay(c.id, order),
|
||||
onRestart: (c) => onPlay(c.id, order, true),
|
||||
onInfo: onInfoCard,
|
||||
onToggleWatched: (c) =>
|
||||
void toggleWatchState(c, t, {
|
||||
|
||||
@@ -8,7 +8,6 @@ import {
|
||||
Clock,
|
||||
Play,
|
||||
Radio,
|
||||
RotateCcw,
|
||||
// Aliased: `Video` is this app's own domain type (lib/api), and it wins the name.
|
||||
Video as VideoIcon,
|
||||
} from "lucide-react";
|
||||
@@ -23,7 +22,6 @@ import { isPlainLeftClick } from "../lib/nav";
|
||||
import { endsTextSelection } from "../lib/textSelection";
|
||||
import ChannelNameLink from "./ChannelNameLink";
|
||||
import { IconButton } from "./ui/IconButton";
|
||||
import { Button } from "./ui/Button";
|
||||
import { SURFACE } from "./ui/surfaces";
|
||||
import { MoreButton } from "./ui/MoreButton";
|
||||
import { HOVER_REVEAL, HOVER_YIELD } from "./ui/reveal";
|
||||
@@ -36,6 +34,7 @@ import {
|
||||
mediaTitleClass,
|
||||
} from "./ui/mediaCardStyles";
|
||||
import { MediaCard, MediaProgress } from "./ui/MediaCard";
|
||||
import { MediaResumeButtons } from "./MediaResumeButtons";
|
||||
|
||||
/** The "…" that opens the video's action list, and whether its menu is the open one. */
|
||||
type More = { props: Record<string, unknown>; open: boolean };
|
||||
@@ -294,51 +293,7 @@ function Thumb({
|
||||
{onOpen && (
|
||||
<div className={MEDIA_HOVER_LAYER}>
|
||||
{inProgress ? (
|
||||
<>
|
||||
{small ? (
|
||||
<IconButton
|
||||
variant="primary"
|
||||
size="md"
|
||||
round
|
||||
onClick={open(null)}
|
||||
label={t("card.continue")}
|
||||
title={t("card.continueTitle")}
|
||||
>
|
||||
<Play className="fill-current" />
|
||||
</IconButton>
|
||||
) : (
|
||||
<Button
|
||||
size="sm"
|
||||
round
|
||||
onClick={open(null)}
|
||||
title={t("card.continueTitle")}
|
||||
icon={<Play className="fill-current" />}
|
||||
>
|
||||
{t("card.continue")}
|
||||
</Button>
|
||||
)}
|
||||
{small ? (
|
||||
<IconButton
|
||||
variant="media"
|
||||
size="md"
|
||||
onClick={open(0)}
|
||||
label={t("card.restart")}
|
||||
title={t("card.restartTitle")}
|
||||
>
|
||||
<RotateCcw />
|
||||
</IconButton>
|
||||
) : (
|
||||
<Button
|
||||
variant="media"
|
||||
size="sm"
|
||||
onClick={open(0)}
|
||||
title={t("card.restartTitle")}
|
||||
icon={<RotateCcw />}
|
||||
>
|
||||
{t("card.restart")}
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
<MediaResumeButtons small={small} onContinue={open(null)} onRestart={open(0)} />
|
||||
) : (
|
||||
<IconButton
|
||||
variant="media"
|
||||
|
||||
@@ -166,6 +166,57 @@ describe("PlexPosterCard with a next episode", () => {
|
||||
});
|
||||
});
|
||||
|
||||
// C5: a started item's picture offers Continue and Restart (the video card's pair) instead of a Play;
|
||||
// they sit beside the picture's own button, never inside it.
|
||||
describe("a started Plex card", () => {
|
||||
const started: PlexCard = {
|
||||
...movie("in_progress"),
|
||||
position_seconds: 600,
|
||||
duration_seconds: 6000,
|
||||
};
|
||||
|
||||
it("shows Continue + Restart, not the Play, and each does its own thing", () => {
|
||||
const onOpen = vi.fn();
|
||||
const onRestart = vi.fn();
|
||||
const { container } = render(
|
||||
<PlexPosterCard card={started} onOpen={onOpen} onRestart={onRestart} />,
|
||||
);
|
||||
const picture = screen.getByRole("button", { name: "M" });
|
||||
expect(picture.querySelector(".reveal-hover.inset-0 svg")).toBeNull();
|
||||
const cont = screen.getByRole("button", { name: "card.continue" });
|
||||
const restart = screen.getByRole("button", { name: "card.restart" });
|
||||
expect(picture.contains(cont) || picture.contains(restart)).toBe(false);
|
||||
fireEvent.click(restart);
|
||||
expect(onRestart).toHaveBeenCalledTimes(1);
|
||||
expect(onOpen).not.toHaveBeenCalled();
|
||||
fireEvent.click(cont);
|
||||
expect(onOpen).toHaveBeenCalledTimes(1);
|
||||
// Mouse only: on touch the tap continues and the "…" has the restart.
|
||||
expect(cont.closest(".reveal-hover")).not.toBeNull();
|
||||
expect(container.querySelector(".flex-col.reveal-hover")).not.toBeNull(); // a poster stacks
|
||||
});
|
||||
|
||||
it("an episode's landscape picture keeps the pair side by side", () => {
|
||||
const { container } = render(
|
||||
<EpisodeCard
|
||||
ep={{ ...episode("in_progress"), position_seconds: 60, duration_seconds: 1200 }}
|
||||
onPlay={vi.fn()}
|
||||
onRestart={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByRole("button", { name: "card.restart" })).toBeTruthy();
|
||||
expect(container.querySelector(".flex-col.reveal-hover")).toBeNull();
|
||||
});
|
||||
|
||||
it("a not-started or watched card keeps the one Play", () => {
|
||||
render(<PlexPosterCard card={movie("watched")} onOpen={vi.fn()} onRestart={vi.fn()} />);
|
||||
expect(screen.queryByRole("button", { name: "card.restart" })).toBeNull();
|
||||
expect(
|
||||
screen.getByRole("button", { name: "M" }).querySelector(".reveal-hover.inset-0 svg"),
|
||||
).not.toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("SeasonCard a11y", () => {
|
||||
it("names the watched-toggle and the add-season button", () => {
|
||||
render(<SeasonCard se={season()} onOpen={vi.fn()} onToggleWatched={vi.fn()} onAdd={vi.fn()} />);
|
||||
|
||||
@@ -11,7 +11,8 @@ import { plexAddLabelKey } from "./usePlexCardActions";
|
||||
import { IconButton } from "./ui/IconButton";
|
||||
import { MoreButton } from "./ui/MoreButton";
|
||||
import { Pressable } from "./ui/Pressable";
|
||||
import { HOVER_YIELD } from "./ui/reveal";
|
||||
import { HOVER_REVEAL, HOVER_YIELD } from "./ui/reveal";
|
||||
import { MediaResumeButtons } from "./MediaResumeButtons";
|
||||
import {
|
||||
MEDIA_BADGE,
|
||||
MEDIA_BAR,
|
||||
@@ -173,12 +174,15 @@ function CardTitle({
|
||||
}
|
||||
|
||||
/** A Plex card's picture: the whole box is the card's open/play button (Enter opens it from the
|
||||
* keyboard walk), with the hover row docked beside it rather than inside it. */
|
||||
* keyboard walk), with the hover row docked beside it rather than inside it. A started item shows the
|
||||
* video card's Continue + Restart pair instead of the Play (C5); like the hover row, the pair is a
|
||||
* sibling laid over the picture, never inside its button. */
|
||||
function PlexPicture({
|
||||
aspect,
|
||||
thumb,
|
||||
label,
|
||||
onOpen,
|
||||
onRestart,
|
||||
plays,
|
||||
small = false,
|
||||
pct = 0,
|
||||
@@ -190,6 +194,8 @@ function PlexPicture({
|
||||
/** The button's name: the picture has no text of its own. */
|
||||
label: string;
|
||||
onOpen: () => void;
|
||||
/** Play from the start, where the surface can (the player's `fromStart`). */
|
||||
onRestart?: () => void;
|
||||
/** The click plays (a movie, an episode) — only then does the hover draw a Play. */
|
||||
plays: boolean;
|
||||
/** A shelf's short landscape picture: the smaller Play, clear of the hover row under it. */
|
||||
@@ -199,6 +205,8 @@ function PlexPicture({
|
||||
/** The badges printed on the picture. */
|
||||
children?: ReactNode;
|
||||
}) {
|
||||
// The pct is the resume point's: a watched or untouched item has none, and plays from its start.
|
||||
const started = plays && pct > 0 && !!onRestart;
|
||||
return (
|
||||
<div className={mediaPictureBox(aspect)}>
|
||||
<div
|
||||
@@ -217,9 +225,23 @@ function PlexPicture({
|
||||
>
|
||||
<img src={thumb} alt="" loading="lazy" decoding="async" className={MEDIA_IMG} />
|
||||
{children}
|
||||
<div className={MEDIA_HOVER_LAYER}>{plays && <MediaPlayGlyph small={small} />}</div>
|
||||
<div className={MEDIA_HOVER_LAYER}>
|
||||
{plays && !started && <MediaPlayGlyph small={small} />}
|
||||
</div>
|
||||
<MediaProgress pct={pct} />
|
||||
</div>
|
||||
{started && (
|
||||
<div
|
||||
className={clsx(
|
||||
HOVER_REVEAL,
|
||||
"pointer-events-none absolute inset-0 z-base flex items-center justify-center gap-2 *:pointer-events-auto",
|
||||
// A poster is too narrow for the pair side by side (150px); it stacks, as in the lab.
|
||||
aspect === "poster" && "flex-col",
|
||||
)}
|
||||
>
|
||||
<MediaResumeButtons small={small} onContinue={onOpen} onRestart={onRestart} />
|
||||
</div>
|
||||
)}
|
||||
{bar}
|
||||
</div>
|
||||
);
|
||||
@@ -243,9 +265,12 @@ export function PlexPosterCard({
|
||||
shelf = false,
|
||||
next,
|
||||
onInfo,
|
||||
onRestart,
|
||||
}: {
|
||||
card: PlexCard;
|
||||
onOpen: () => void;
|
||||
/** Play the item (or the `next` episode) from its start, not its resume point (C5). */
|
||||
onRestart?: () => void;
|
||||
onToggleWatched?: (c: PlexCard) => void;
|
||||
/** Add a movie or an episode to a playlist (the list's `usePlexCardActions().add`). */
|
||||
onAdd?: (c: PlexCard) => void;
|
||||
@@ -316,6 +341,7 @@ export function PlexPosterCard({
|
||||
thumb={(wide && card.type !== "episode" && card.art) || card.thumb}
|
||||
label={card.title}
|
||||
onOpen={onOpen}
|
||||
onRestart={onRestart}
|
||||
plays={isPlayable}
|
||||
small={shelf && landscape}
|
||||
pct={pct}
|
||||
@@ -591,6 +617,7 @@ export function RelatedStrip({
|
||||
export function EpisodeCard({
|
||||
ep,
|
||||
onPlay,
|
||||
onRestart,
|
||||
onAdd,
|
||||
onToggleWatched,
|
||||
withShowTitle,
|
||||
@@ -601,6 +628,8 @@ export function EpisodeCard({
|
||||
}: {
|
||||
ep: PlexCard;
|
||||
onPlay: () => void;
|
||||
/** Play the episode from its start, not its resume point (C5). */
|
||||
onRestart?: () => void;
|
||||
onAdd?: () => void;
|
||||
onToggleWatched?: () => void;
|
||||
withShowTitle?: boolean;
|
||||
@@ -628,6 +657,7 @@ export function EpisodeCard({
|
||||
thumb={ep.thumb}
|
||||
label={ep.title}
|
||||
onOpen={onPlay}
|
||||
onRestart={onRestart}
|
||||
plays
|
||||
pct={pct}
|
||||
bar={
|
||||
|
||||
@@ -22,6 +22,8 @@ export type PlexJumpTarget = {
|
||||
* from INSIDE the Plex module (the header typeahead), where the entry being replaced would be a
|
||||
* sub-view the user really was looking at. */
|
||||
replace?: boolean;
|
||||
/** `play` only: from the start, not the resume point (the card's Restart, C5). */
|
||||
fromStart?: boolean;
|
||||
};
|
||||
|
||||
export type PlexState = {
|
||||
|
||||
@@ -4,49 +4,6 @@ import { RotateCcw } from "lucide-react";
|
||||
import { IconButton } from "./ui/IconButton";
|
||||
import { Slider as Range, Checkbox } from "./ui/NativeControls";
|
||||
|
||||
// ±10s skip glyph: an outline circular arrow with a "10" in the centre — the YouTube/Plex-native
|
||||
// muscle-memory shape. Deliberately distinct from the episode-level SkipBack/SkipForward (double
|
||||
// triangles) and from Restart's plain RotateCcw. The arrowhead is an L-corner at the arc's open end,
|
||||
// the same construction lucide uses for RotateCcw/RotateCw, so it sits consistently in the bar.
|
||||
export function Skip10({ dir }: { dir: "back" | "forward" }) {
|
||||
const back = dir === "back";
|
||||
return (
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
className="w-6 h-6"
|
||||
aria-hidden="true"
|
||||
>
|
||||
{back ? (
|
||||
<>
|
||||
<path d="M4 8.5 A8 8 0 1 1 4 12" />
|
||||
<path d="M4 8.5 L4 4 M4 8.5 L8.5 8.5" />
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<path d="M20 8.5 A8 8 0 1 0 20 12" />
|
||||
<path d="M20 8.5 L20 4 M20 8.5 L15.5 8.5" />
|
||||
</>
|
||||
)}
|
||||
<text
|
||||
x="12"
|
||||
y="14.5"
|
||||
textAnchor="middle"
|
||||
fontSize="8"
|
||||
fontWeight={700}
|
||||
fill="currentColor"
|
||||
stroke="none"
|
||||
>
|
||||
10
|
||||
</text>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
// --- settings-panel building blocks ------------------------------------------------------------
|
||||
export function PanelHead({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
|
||||
@@ -11,7 +11,8 @@ export type Sub =
|
||||
| { kind: "collections" } // browse-all-collections surface (header pill "Collection" mode)
|
||||
| { kind: "show"; id: string }
|
||||
| { kind: "season"; showId: string; seasonId: string }
|
||||
| { kind: "player"; id: string; queue?: string[] }
|
||||
// `fromStart`: the card's Restart (C5) — the first item plays from 0, not its resume point.
|
||||
| { kind: "player"; id: string; queue?: string[]; fromStart?: boolean }
|
||||
| { kind: "info"; id: string }
|
||||
| { kind: "person"; name: string }
|
||||
| { kind: "playlist"; id: number }
|
||||
|
||||
@@ -71,7 +71,7 @@ export function PlexInfoView({
|
||||
id: string;
|
||||
onBack: () => void;
|
||||
onPlay: () => void;
|
||||
onPlayItem: (id: string) => void;
|
||||
onPlayItem: (id: string, fromStart?: boolean) => void;
|
||||
onFilter: (patch: Partial<PlexFilters>) => void;
|
||||
onOpenPerson: (name: string) => void;
|
||||
}) {
|
||||
@@ -119,12 +119,14 @@ export function PlexPersonView({
|
||||
name,
|
||||
onBack,
|
||||
onOpenCard,
|
||||
onRestartCard,
|
||||
onInfoCard,
|
||||
onFilter,
|
||||
}: {
|
||||
name: string;
|
||||
onBack: () => void;
|
||||
onOpenCard: (card: PlexCard) => void;
|
||||
onRestartCard: (card: PlexCard) => void;
|
||||
onInfoCard: (card: PlexCard) => void;
|
||||
onFilter: (patch: Partial<PlexFilters>) => void;
|
||||
}) {
|
||||
@@ -170,10 +172,11 @@ export function PlexPersonView({
|
||||
});
|
||||
|
||||
const actions = usePlexCardActions(
|
||||
{ onOpen: onOpenCard, onInfo: onInfoCard, onToggleWatched: toggle },
|
||||
{ onOpen: onOpenCard, onRestart: onRestartCard, onInfo: onInfoCard, onToggleWatched: toggle },
|
||||
menu.open,
|
||||
);
|
||||
const cardMenu = (c: PlexCard) => ({
|
||||
onRestart: () => onRestartCard(c),
|
||||
onAdd: actions.add,
|
||||
menuProps: menu.triggerProps,
|
||||
menuButtonProps: menu.buttonProps,
|
||||
@@ -654,7 +657,7 @@ export function PlexSeasonView({
|
||||
showId: string;
|
||||
seasonId: string;
|
||||
onBack: () => void;
|
||||
onPlay: (epRk: string, queue: string[]) => void;
|
||||
onPlay: (epRk: string, queue: string[], fromStart?: boolean) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const qc = useQueryClient();
|
||||
@@ -706,6 +709,7 @@ export function PlexSeasonView({
|
||||
const episodes = usePlexCardActions(
|
||||
{
|
||||
onOpen: (ep) => onPlay(ep.id, allKeys),
|
||||
onRestart: (ep) => onPlay(ep.id, allKeys, true),
|
||||
onToggleWatched: (ep) => void markEpisode(ep),
|
||||
},
|
||||
epMenu.open,
|
||||
@@ -796,6 +800,7 @@ export function PlexSeasonView({
|
||||
navIndex={i}
|
||||
ep={ep}
|
||||
onPlay={() => onPlay(ep.id, allKeys)}
|
||||
onRestart={() => onPlay(ep.id, allKeys, true)}
|
||||
onAdd={() => episodes.add(ep)}
|
||||
onToggleWatched={() => markEpisode(ep)}
|
||||
menuProps={epMenu.triggerProps}
|
||||
|
||||
@@ -27,7 +27,7 @@ const PLAIN: Record<string, [number, string]> = {
|
||||
"PlaylistsRail.tsx": [1, "the load-error retry"],
|
||||
"PlexCollectionEditor.tsx": [1, "a checkbox row in a picker"],
|
||||
"PlexInfo.tsx": [1, "the rating filter (a number)"],
|
||||
"PlexPlayer.tsx": [3, "track picker rows"],
|
||||
"PlexPlayer.tsx": [4, "track picker rows and the ⋯ menu's rows"],
|
||||
"PlexPlayerSettings.tsx": [2, "setting options"],
|
||||
"PlexPlaylistAdd.tsx": [1, "a checkbox row in a picker"],
|
||||
"PlexPlaylistView.tsx": [1, "a show's poster stack (aria-hidden)"],
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Check, Info, ListPlus, Play, Tv2, Undo2 } from "lucide-react";
|
||||
import { Check, Info, ListPlus, Play, SkipBack, Tv2, Undo2 } from "lucide-react";
|
||||
|
||||
import { type PlexCard } from "../lib/api";
|
||||
import { pinPlex, useComfortPin } from "./comfortPin";
|
||||
@@ -18,6 +18,9 @@ export interface PlexActionHandlers {
|
||||
/** The media-info page. Omitted where a surface has none (the episode lists). */
|
||||
onInfo?: (c: PlexCard) => void;
|
||||
onToggleWatched?: (c: PlexCard) => void;
|
||||
/** Play from the start instead of the resume point (C5). Where given, a started title lists it
|
||||
* under Play — on touch, the only way to it (the card's hover pair is mouse-only). */
|
||||
onRestart?: (c: PlexCard) => void;
|
||||
}
|
||||
|
||||
const icon = "w-4 h-4";
|
||||
@@ -29,6 +32,11 @@ export const plexAddLabelKey = (c: PlexCard): string =>
|
||||
/** A show opens its drill-down; a movie or an episode plays. */
|
||||
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
|
||||
* stored at 0, so it never counts. */
|
||||
export const plexStarted = (c: PlexCard): boolean =>
|
||||
isPlexPlayable(c) && c.status !== "watched" && (c.position_seconds ?? 0) > 0;
|
||||
|
||||
/** `open` is the host menu's own flag (the shelf is fetched only once a menu opened). Returns the
|
||||
* item builder, `add` for the card's own hover button, and the dialog to render beside the menu
|
||||
* (one per list, so a card does not mount a modal of its own). */
|
||||
@@ -48,18 +56,30 @@ export function usePlexCardActions(handlers: PlexActionHandlers, open: boolean)
|
||||
c: PlexCard,
|
||||
{ play = true, pin: withPin = true }: { play?: boolean; pin?: boolean } = {},
|
||||
): ActionMenuItem[] => {
|
||||
const { onOpen, onInfo, onToggleWatched } = handlers;
|
||||
const { onOpen, onInfo, onToggleWatched, onRestart } = handlers;
|
||||
const playable = isPlexPlayable(c);
|
||||
const watched = c.status === "watched";
|
||||
const started = plexStarted(c);
|
||||
return [
|
||||
...(play
|
||||
? [
|
||||
{
|
||||
id: "play",
|
||||
label: playable ? t("plex.play") : t("plex.openShow"),
|
||||
label: !playable ? t("plex.openShow") : started ? t("card.continue") : t("plex.play"),
|
||||
icon: playable ? <Play className={icon} /> : <Tv2 className={icon} />,
|
||||
onSelect: () => onOpen(c),
|
||||
},
|
||||
// The same words and glyph as the video card's menu (useVideoActions).
|
||||
...(started && onRestart
|
||||
? [
|
||||
{
|
||||
id: "restart",
|
||||
label: t("card.restartTitle"),
|
||||
icon: <SkipBack className={icon} />,
|
||||
onSelect: () => onRestart(c),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
]
|
||||
: []),
|
||||
...(playable && onToggleWatched
|
||||
|
||||
@@ -313,10 +313,10 @@ function MediaCard({ state }: { state: "new" | "started" | "touch" }) {
|
||||
{state === "started" && (
|
||||
<span className="absolute inset-0 flex flex-col items-center justify-center gap-2 bg-black/30">
|
||||
<span className={clsx(pill, "bg-accent text-accent-fg")}>
|
||||
<Play className="h-4 w-4" /> Resume 14:02
|
||||
<Play className="h-4 w-4" /> Continue
|
||||
</span>
|
||||
<span className={clsx(pill, "bg-black/60 text-white")}>
|
||||
<RotateCcw className="h-3.5 w-3.5" /> From the start
|
||||
<RotateCcw className="h-3.5 w-3.5" /> Restart
|
||||
</span>
|
||||
</span>
|
||||
)}
|
||||
@@ -352,7 +352,7 @@ function Cards() {
|
||||
<Sample label="Started · hover">
|
||||
<MediaCard state="started" />
|
||||
</Sample>
|
||||
<Sample label="Started · touch: a tap resumes, the '…' has From the start">
|
||||
<Sample label="Started · touch: a tap continues, the '…' has the restart">
|
||||
<MediaCard state="touch" />
|
||||
</Sample>
|
||||
</div>
|
||||
@@ -365,7 +365,7 @@ function C5() {
|
||||
id="c5"
|
||||
code="C5"
|
||||
title="The Plex player's controls"
|
||||
rule="The owner's model. No Stop (closing is stopping), no Restart and no ±10 s buttons. Back: more than 3 s in → restart; within the first 3 s → the previous item, at its resume point if it has one. Next shows only when there is a next item (an episode, a playlist), starts it at its resume point, and leaves one behind (not watched, unless already in the credits). On touch a double-tap on the left / right third skips ∓10 s; the keyboard keeps ← / →. The rare tools sit in a fixed '⋯'. Restarting lives on the card: not started → Play; started → Resume + From the start (touch: a tap resumes, the '…' has From the start)."
|
||||
rule="The owner's model. No Stop (closing is stopping), no Restart and no ±10 s buttons. Back: more than 3 s in → restart; within the first 3 s → the previous item, at its resume point if it has one. Next shows only when there is a next item (an episode, a playlist), starts it at its resume point, and leaves one behind (not watched, unless already in the credits). On touch a double-tap on the left / right third skips ∓10 s; the keyboard keeps ← / →. The rare tools sit in a fixed '⋯'. Restarting lives on the card: not started → Play; started → Continue + Restart, the video card's pair (touch: a tap continues, the '…' has the restart)."
|
||||
>
|
||||
<div className="flex flex-wrap items-start gap-4">
|
||||
<div className="w-full max-w-xl">
|
||||
|
||||
@@ -171,10 +171,10 @@
|
||||
"skipCredits": "Skip credits",
|
||||
"playPause": "Play / pause (space)",
|
||||
"restart": "Restart from beginning",
|
||||
"rewind10": "Back 10 seconds",
|
||||
"forward10": "Forward 10 seconds",
|
||||
"prev": "Previous episode",
|
||||
"next": "Next episode",
|
||||
"tapBack": "−{{n}} s",
|
||||
"tapForward": "+{{n}} s",
|
||||
"mute": "Mute (m)",
|
||||
"volume": "Volume",
|
||||
"fullscreen": "Fullscreen (f)",
|
||||
@@ -186,9 +186,9 @@
|
||||
"errNotFound": "This file can't be played — the media isn't reachable on the server (the Plex media mount is missing or the path map is wrong). Ask the admin to check the Plex configuration.",
|
||||
"errTranscode": "This file's format needs transcoding, which isn't supported yet.",
|
||||
"errGeneric": "Playback couldn't start. Please try again.",
|
||||
"stop": "Stop",
|
||||
"help": "Keyboard shortcuts",
|
||||
"infoBtn": "Media info",
|
||||
"more": "More",
|
||||
"upNext": {
|
||||
"title": "Up next",
|
||||
"playNow": "Play now",
|
||||
|
||||
@@ -171,10 +171,10 @@
|
||||
"skipCredits": "Stáblista átugrása",
|
||||
"playPause": "Lejátszás / szünet (szóköz)",
|
||||
"restart": "Újra az elejéről",
|
||||
"rewind10": "10 másodperc vissza",
|
||||
"forward10": "10 másodperc előre",
|
||||
"prev": "Előző rész",
|
||||
"next": "Következő rész",
|
||||
"tapBack": "−{{n}} mp",
|
||||
"tapForward": "+{{n}} mp",
|
||||
"mute": "Némítás (m)",
|
||||
"volume": "Hangerő",
|
||||
"fullscreen": "Teljes képernyő (f)",
|
||||
@@ -186,9 +186,9 @@
|
||||
"errNotFound": "Ez a fájl nem játszható le — a média nem érhető el a szerveren (hiányzik a Plex media-mount, vagy hibás az útvonal-leképezés). Kérd meg az adminisztrátort, hogy ellenőrizze a Plex beállításait.",
|
||||
"errTranscode": "Ennek a fájlnak a formátuma transzkódolást igényel, ami még nem támogatott.",
|
||||
"errGeneric": "A lejátszást nem sikerült elindítani. Próbáld újra.",
|
||||
"stop": "Leállítás",
|
||||
"help": "Billentyűparancsok",
|
||||
"infoBtn": "Média infó",
|
||||
"more": "Továbbiak",
|
||||
"upNext": {
|
||||
"title": "Következik",
|
||||
"playNow": "Lejátszás most",
|
||||
|
||||
@@ -7,12 +7,13 @@ import { describe, it, expect, vi, afterEach, beforeEach } from "vitest";
|
||||
import { act, renderHook } from "@testing-library/react";
|
||||
import { useControlsVisibility } from "./plexPlayerHooks";
|
||||
|
||||
const setup = () => {
|
||||
const video = { paused: false } as HTMLVideoElement;
|
||||
const setup = (menuOpen = false) => {
|
||||
const video = document.createElement("video");
|
||||
Object.defineProperty(video, "paused", { configurable: true, value: false });
|
||||
return renderHook(() =>
|
||||
useControlsVisibility({
|
||||
barAutoHideRef: { current: true },
|
||||
menuOpenRef: { current: false },
|
||||
menuOpenRef: { current: menuOpen },
|
||||
videoRef: { current: video },
|
||||
}),
|
||||
);
|
||||
@@ -44,4 +45,19 @@ describe("useControlsVisibility", () => {
|
||||
act(() => vi.advanceTimersByTime(100));
|
||||
expect(result.current.uiVisible).toBe(false);
|
||||
});
|
||||
|
||||
// C3: on touch nothing moves a mouse, so a tap toggles the bar and playback arms the timer.
|
||||
it("toggle hides a shown bar at once and shows a hidden one", () => {
|
||||
const { result } = setup();
|
||||
act(() => result.current.toggle());
|
||||
expect(result.current.uiVisible).toBe(false);
|
||||
act(() => result.current.toggle());
|
||||
expect(result.current.uiVisible).toBe(true);
|
||||
});
|
||||
|
||||
it("a tap does not hide the bar under an open menu", () => {
|
||||
const { result } = setup(true);
|
||||
act(() => result.current.toggle());
|
||||
expect(result.current.uiVisible).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -73,25 +73,50 @@ export function useFullscreen(wrapRef: RefObject<HTMLElement | null>): {
|
||||
// `holdOnHover` is the pointer resting on the chrome: the bar stays while it is there (as on YouTube
|
||||
// and Plex Web — E28 F22, it used to fade out from under a mouse parked on Next), and leaving it
|
||||
// starts a fresh 3 s.
|
||||
//
|
||||
// On touch (C3) nothing moves a mouse, so the timer is also armed by PLAYBACK starting — before, the
|
||||
// bar stayed up until the first tap however long the video played — and a tap on the picture only
|
||||
// shows or hides the bar (`toggle`), never pauses. `show(false)` hides at once: a double-tap skip
|
||||
// takes back the show/hide its first tap did.
|
||||
export function useControlsVisibility(opts: {
|
||||
barAutoHideRef: RefObject<boolean>;
|
||||
menuOpenRef: RefObject<boolean>;
|
||||
videoRef: RefObject<HTMLVideoElement | null>;
|
||||
}): { uiVisible: boolean; wake: () => void; holdOnHover: (over: boolean) => void } {
|
||||
}): {
|
||||
uiVisible: boolean;
|
||||
wake: () => void;
|
||||
holdOnHover: (over: boolean) => void;
|
||||
toggle: () => void;
|
||||
show: (visible: boolean) => void;
|
||||
} {
|
||||
const { barAutoHideRef, menuOpenRef, videoRef } = opts;
|
||||
const [uiVisible, setUiVisible] = useState(true);
|
||||
const visibleRef = useRef(true);
|
||||
const hideTimer = useRef<number | null>(null);
|
||||
const hovered = useRef(false);
|
||||
const setVisible = useCallback((v: boolean) => {
|
||||
visibleRef.current = v;
|
||||
setUiVisible(v);
|
||||
}, []);
|
||||
const wake = useCallback(() => {
|
||||
setUiVisible(true);
|
||||
setVisible(true);
|
||||
if (hideTimer.current) window.clearTimeout(hideTimer.current);
|
||||
if (!barAutoHideRef.current) return; // per-user setting: never auto-hide the control bar
|
||||
hideTimer.current = window.setTimeout(() => {
|
||||
// Keep the controls up while the audio/subtitle menu is open, else it would vanish mid-choice.
|
||||
if (!videoRef.current?.paused && !menuOpenRef.current && !hovered.current)
|
||||
setUiVisible(false);
|
||||
if (!videoRef.current?.paused && !menuOpenRef.current && !hovered.current) setVisible(false);
|
||||
}, 3000);
|
||||
}, [barAutoHideRef, menuOpenRef, videoRef]);
|
||||
}, [barAutoHideRef, menuOpenRef, videoRef, setVisible]);
|
||||
const show = useCallback(
|
||||
(visible: boolean) => {
|
||||
if (visible) return wake();
|
||||
if (hideTimer.current) window.clearTimeout(hideTimer.current);
|
||||
// A tap on a picture with a menu open closes nothing and must not strand that menu unseen.
|
||||
if (!menuOpenRef.current) setVisible(false);
|
||||
},
|
||||
[wake, menuOpenRef, setVisible],
|
||||
);
|
||||
const toggle = useCallback(() => show(!visibleRef.current), [show]);
|
||||
const holdOnHover = useCallback(
|
||||
(over: boolean) => {
|
||||
hovered.current = over;
|
||||
@@ -99,7 +124,19 @@ export function useControlsVisibility(opts: {
|
||||
},
|
||||
[wake],
|
||||
);
|
||||
return { uiVisible, wake, holdOnHover };
|
||||
useEffect(() => {
|
||||
const video = videoRef.current;
|
||||
if (!video) return;
|
||||
video.addEventListener("play", wake);
|
||||
return () => video.removeEventListener("play", wake);
|
||||
}, [videoRef, wake]);
|
||||
useEffect(
|
||||
() => () => {
|
||||
if (hideTimer.current) window.clearTimeout(hideTimer.current);
|
||||
},
|
||||
[],
|
||||
);
|
||||
return { uiVisible, wake, holdOnHover, toggle, show };
|
||||
}
|
||||
|
||||
// Periodic + on-unmount progress checkpoint. Reads live position/duration through refs (owned by the
|
||||
|
||||
@@ -46,7 +46,7 @@ describe("the Plex player's layers", () => {
|
||||
const popovers = classes(
|
||||
/\bbottom-full\b.*\bbg-media-chrome\b|\bbg-media-chrome\b.*\bbottom-full\b/,
|
||||
);
|
||||
expect(popovers).toHaveLength(2);
|
||||
expect(popovers).toHaveLength(3); // tracks, the ⋯ menu (C5), the settings panel
|
||||
for (const c of popovers) expect(c).toMatch(/\bz-menu\b/);
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user