feat(plex): up-next end-card at episode end (E6 S4)
At episode end, when a next item exists and the pref is on, show an up-next card (poster + title + ~10s countdown + Cancel) instead of the old instant jump, so binge stays in the viewer's control (D6-A). Countdown or Play now advances; Cancel/✕ dismisses and stays on the finished frame. Lives only on the play-to-the-end path — credits-auto-skip advances earlier and never reaches the card, so the two reconcile by construction (no double-advance). New pref 'upNext' (default on) with a Playback-tab toggle; the card closes on item change and joins the Back/Escape cascade. i18n HU/EN.
This commit is contained in:
@@ -42,6 +42,7 @@ import {
|
||||
import { Ctrl, PanelHead, Skip10 } from "./plexPlayerControls";
|
||||
import { PlexPlayerSettings } from "./PlexPlayerSettings";
|
||||
import { PlexStatsOverlay } from "./PlexStatsOverlay";
|
||||
import { UpNextCard } from "./UpNextCard";
|
||||
import {
|
||||
useAutoSkip,
|
||||
useControlsVisibility,
|
||||
@@ -177,6 +178,12 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
|
||||
const [statsOpen, setStatsOpen] = useState(false);
|
||||
const statsOpenRef = useRef(false);
|
||||
statsOpenRef.current = statsOpen;
|
||||
// Up-next end-card: shown at episode end (with a next item) instead of an instant jump (S4).
|
||||
const [upNextOpen, setUpNextOpen] = useState(false);
|
||||
const upNextOpenRef = useRef(false);
|
||||
upNextOpenRef.current = upNextOpen;
|
||||
const upNextPrefRef = useRef(prefs.upNext);
|
||||
upNextPrefRef.current = prefs.upNext;
|
||||
const now = useWallClock();
|
||||
|
||||
durationRef.current = duration;
|
||||
@@ -520,7 +527,11 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
|
||||
// (absRef 0) — its stray 'ended' must not mark anything watched or cascade another advance.
|
||||
if (absRef.current <= 0) return;
|
||||
api.plexSetState(id, "watched").catch(() => {});
|
||||
if (nextId) go(nextId);
|
||||
// With a next item and the up-next pref on, surface the end-card (poster + countdown + Cancel)
|
||||
// and let IT advance — binge stays in the viewer's control (S4/D6-A). The card only appears here,
|
||||
// on play-to-the-end; credits-auto-skip advances earlier and this handler never fires for it.
|
||||
if (nextId && upNextPrefRef.current) setUpNextOpen(true);
|
||||
else if (nextId) go(nextId);
|
||||
// Nothing queued next → dismiss the player back to the previous page (same as Stop/Escape),
|
||||
// instead of stranding a black, ended <video> on screen.
|
||||
else onClose();
|
||||
@@ -529,6 +540,14 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
|
||||
return () => video.removeEventListener("ended", onEnded);
|
||||
}, [id, nextId, go, onClose]);
|
||||
|
||||
// Up-next card actions: advance = go to the next item (it was already marked watched on 'ended');
|
||||
// cancel = dismiss and stay on the finished frame. Loading a new item closes any lingering card.
|
||||
const advanceUpNext = useCallback(() => {
|
||||
setUpNextOpen(false);
|
||||
if (nextId) go(nextId);
|
||||
}, [nextId, go]);
|
||||
useEffect(() => setUpNextOpen(false), [id]);
|
||||
|
||||
// "Back" cascade shared by the on-screen Back arrow AND the keyboard/remote Backspace·Escape: peel
|
||||
// off whatever is layered on top (a tuning/tracks menu, then the shortcuts sheet, then the info
|
||||
// overlay) before leaving the player. The arrow used to call onClose() unconditionally, so Back
|
||||
@@ -540,6 +559,7 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
|
||||
} else if (helpOpenRef.current) setHelpOpen(false);
|
||||
else if (infoOpenRef.current) setInfoOpen(false);
|
||||
else if (statsOpenRef.current) setStatsOpen(false);
|
||||
else if (upNextOpenRef.current) setUpNextOpen(false);
|
||||
else onClose();
|
||||
}, [onClose]);
|
||||
|
||||
@@ -651,6 +671,7 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
|
||||
} else if (helpOpenRef.current) setHelpOpen(false);
|
||||
else if (infoOpenRef.current) setInfoOpen(false);
|
||||
else if (statsOpenRef.current) setStatsOpen(false);
|
||||
else if (upNextOpenRef.current) setUpNextOpen(false);
|
||||
else if (!fullscreenElement()) onClose();
|
||||
break;
|
||||
}
|
||||
@@ -1169,6 +1190,16 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Up-next end-card (episode end, next item queued, pref on). Non-modal — the finished frame
|
||||
stays behind it; the card's own countdown advances unless the viewer cancels. */}
|
||||
{upNextOpen && nextId && (
|
||||
<UpNextCard
|
||||
nextId={nextId}
|
||||
onAdvance={advanceUpNext}
|
||||
onCancel={() => setUpNextOpen(false)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Keyboard-shortcut cheat sheet (toggle with "h" or the keyboard button). */}
|
||||
{helpOpen && (
|
||||
<div
|
||||
|
||||
@@ -113,6 +113,11 @@ export function PlexPlayerSettings(props: {
|
||||
checked={prefs.autoSkipCredits}
|
||||
onChange={(v) => patchPrefs({ autoSkipCredits: v })}
|
||||
/>
|
||||
<Toggle
|
||||
label={t("plex.player.upNext.pref")}
|
||||
checked={prefs.upNext}
|
||||
onChange={(v) => patchPrefs({ upNext: v })}
|
||||
/>
|
||||
{(prefs.autoSkipIntro || prefs.autoSkipCredits) && (
|
||||
<Slider
|
||||
label={t("plex.player.autoSkipDelay")}
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Play, X } from "lucide-react";
|
||||
import { api } from "../lib/api";
|
||||
import { plexLang } from "../i18n";
|
||||
import { qk } from "../lib/queryKeys";
|
||||
|
||||
// Up-next / end-card (E6 S4). Shown at episode end when a next item exists and the pref is on, INSTEAD
|
||||
// of the old instant jump — so binge stays under the viewer's control (D6-A). Counts down ~10s then
|
||||
// advances; "Play now" advances immediately; Cancel/✕ dismisses and stays on the finished frame. It only
|
||||
// lives on the play-to-the-end path; credits-auto-skip advances earlier and never reaches this card.
|
||||
const COUNTDOWN_S = 10;
|
||||
|
||||
export function UpNextCard({
|
||||
nextId,
|
||||
onAdvance,
|
||||
onCancel,
|
||||
}: {
|
||||
nextId: string;
|
||||
onAdvance: () => void;
|
||||
onCancel: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const lang = plexLang();
|
||||
const nextQ = useQuery({
|
||||
queryKey: qk.plexItem(nextId, lang),
|
||||
queryFn: () => api.plexItem(nextId, lang),
|
||||
});
|
||||
const next = nextQ.data;
|
||||
|
||||
// Countdown. `onAdvance` is read through a ref so the 100ms interval never restarts on re-render.
|
||||
const [remaining, setRemaining] = useState(COUNTDOWN_S);
|
||||
const advanceRef = useRef(onAdvance);
|
||||
advanceRef.current = onAdvance;
|
||||
useEffect(() => {
|
||||
const started = performance.now();
|
||||
const iv = window.setInterval(() => {
|
||||
const left = COUNTDOWN_S - (performance.now() - started) / 1000;
|
||||
if (left <= 0) {
|
||||
window.clearInterval(iv);
|
||||
setRemaining(0);
|
||||
advanceRef.current();
|
||||
} else {
|
||||
setRemaining(left);
|
||||
}
|
||||
}, 100);
|
||||
return () => window.clearInterval(iv);
|
||||
}, []);
|
||||
|
||||
const secs = Math.ceil(remaining);
|
||||
const frac = remaining / COUNTDOWN_S; // 1 → 0 as it counts down
|
||||
const R = 10;
|
||||
const C = 2 * Math.PI * R;
|
||||
|
||||
// Title: "Show · S2·E5" over the episode title for episodes; the plain title for movies/queue items.
|
||||
const heading =
|
||||
next?.kind === "episode" && next.show_title
|
||||
? `${next.show_title} · S${next.season_number ?? "?"}·E${next.episode_number ?? "?"}`
|
||||
: (next?.title ?? "");
|
||||
const sub = next?.kind === "episode" ? next.title : null;
|
||||
|
||||
return (
|
||||
<div
|
||||
role="region"
|
||||
aria-label={t("plex.player.upNext.title")}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="absolute bottom-24 right-6 z-menu flex w-[22rem] max-w-[calc(100vw-3rem)] gap-3 rounded-2xl border border-white/15 bg-neutral-900/95 p-3.5 text-white shadow-2xl"
|
||||
>
|
||||
<div className="h-[6.5rem] w-[4.6rem] flex-none overflow-hidden rounded-lg bg-white/10">
|
||||
{next && (
|
||||
<img
|
||||
src={api.plexImageUrl(nextId, "thumb")}
|
||||
alt=""
|
||||
className="h-full w-full object-cover"
|
||||
draggable={false}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex min-w-0 flex-1 flex-col">
|
||||
<div className="mb-0.5 text-2xs font-semibold uppercase tracking-wide text-white/50">
|
||||
{t("plex.player.upNext.title")}
|
||||
</div>
|
||||
<div className="truncate text-sm font-semibold leading-tight">{heading || "…"}</div>
|
||||
{sub && <div className="truncate text-xs text-white/60">{sub}</div>}
|
||||
<div className="mt-auto flex items-center gap-2 pt-2.5">
|
||||
<button
|
||||
onClick={onAdvance}
|
||||
className="flex items-center gap-1.5 rounded-full bg-accent px-3 py-1.5 text-xs font-semibold text-black hover:brightness-110"
|
||||
>
|
||||
<span className="relative grid h-[22px] w-[22px] flex-none place-items-center">
|
||||
<svg viewBox="0 0 24 24" className="absolute inset-0 -rotate-90">
|
||||
<circle cx="12" cy="12" r={R} fill="none" stroke="rgba(0,0,0,0.25)" strokeWidth="2" />
|
||||
<circle
|
||||
cx="12"
|
||||
cy="12"
|
||||
r={R}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeDasharray={C}
|
||||
strokeDashoffset={C * (1 - frac)}
|
||||
/>
|
||||
</svg>
|
||||
<Play className="h-2.5 w-2.5 fill-black" />
|
||||
</span>
|
||||
{t("plex.player.upNext.playNow")}
|
||||
<span className="tabular-nums">{secs}</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={onCancel}
|
||||
className="rounded-full border border-white/25 px-3 py-1.5 text-xs text-white/85 hover:bg-white/10"
|
||||
>
|
||||
{t("plex.player.upNext.cancel")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={onCancel}
|
||||
aria-label={t("plex.player.upNext.cancel")}
|
||||
className="absolute right-2 top-2 rounded p-0.5 text-white/50 hover:bg-white/10 hover:text-white"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -192,6 +192,12 @@
|
||||
"fineSeek": "Fine seek",
|
||||
"stats": "Stats for nerds"
|
||||
},
|
||||
"upNext": {
|
||||
"title": "Up next",
|
||||
"playNow": "Play now",
|
||||
"cancel": "Cancel",
|
||||
"pref": "Up-next card"
|
||||
},
|
||||
"stats": {
|
||||
"title": "Stats for nerds",
|
||||
"mode": "Mode",
|
||||
|
||||
@@ -192,6 +192,12 @@
|
||||
"fineSeek": "Finom léptetés",
|
||||
"stats": "Statisztika (nerdöknek)"
|
||||
},
|
||||
"upNext": {
|
||||
"title": "Következik",
|
||||
"playNow": "Lejátszás most",
|
||||
"cancel": "Mégse",
|
||||
"pref": "Következik-kártya"
|
||||
},
|
||||
"stats": {
|
||||
"title": "Statisztika (nerdöknek)",
|
||||
"mode": "Mód",
|
||||
|
||||
@@ -45,6 +45,7 @@ export type PlexPlayerPrefs = {
|
||||
autoSkipIntro: boolean; // auto-skip the intro marker after a countdown
|
||||
autoSkipCredits: boolean; // auto-skip credits → jump to the next item (binge)
|
||||
autoSkipDelay: number; // countdown before an auto-skip fires (s, 0 = immediate)
|
||||
upNext: boolean; // at episode end (with a next item) show an up-next card + countdown before advancing
|
||||
};
|
||||
export const DEFAULT_PREFS: PlexPlayerPrefs = {
|
||||
volume: 1,
|
||||
@@ -71,6 +72,7 @@ export const DEFAULT_PREFS: PlexPlayerPrefs = {
|
||||
autoSkipIntro: false,
|
||||
autoSkipCredits: false,
|
||||
autoSkipDelay: 5,
|
||||
upNext: true,
|
||||
};
|
||||
export const clamp01 = (n: number) => Math.max(0, Math.min(1, n));
|
||||
|
||||
|
||||
Reference in New Issue
Block a user