1A ChannelPage "Full history" button no longer wraps (whitespace-nowrap). 1B Extract the manager's press-and-hold priority stepper into a shared PriorityStepper and add it to the channel detail header (subscribed, non-demo); backend channel_detail now returns the per-subscription `priority`. 2 Unify YouTube + Plex media cards onto one look/behaviour: drop the whole-card hover LIFT (VideoCard) — which also fixes the clipped-top bug — and instead the inner thumbnail/poster ZOOMS on hover (both); give the flat Plex posters the same glass-card 3D depth + accent hover border as the feed cards (shadow-md is a transparent no-op here, so it's glass-card that provides the depth); clamp Plex card titles to a fixed 2-line height so 1- vs 2-line titles align. 3 Nav rail is now always a slim icon-only column, flush to the left edge; each icon reveals a floating name label on hover (Tooltip gains `always` + `openDelay`, and a right side). Removed the pin/unpin toggle, the expand-on-hover mode and the navCollapsed pref plumbing. 4 Scope the side-panel peek-open HOVER to the tab handle + the card (not the whole full-height slot next to the rail), so approaching the nav no longer pops the filter open; focus stays container-wide. useHoverFocusWithin gains a hover-out grace period + split hover/focus handler groups. 5 Plex art backdrop fills the whole viewport (a fixed inset-0 layer behind the transparent app) instead of only the inset <main> box. 6 Plex season binge continues across seasons: the season view's play queue now spans the whole show (allKeys), so a season's last episode rolls into the next season's first (seasonKeys stays for "add season to playlist"). 7 When a Plex video/episode ends with nothing queued next, the player returns to the previous page (onEnded → onClose) instead of stranding a black screen.
163 lines
5.6 KiB
TypeScript
163 lines
5.6 KiB
TypeScript
import { useCallback, useEffect, useRef, useState } from "react";
|
|
import { ArrowDown, ArrowUp } from "lucide-react";
|
|
import { useTranslation } from "react-i18next";
|
|
import Tooltip from "./Tooltip";
|
|
|
|
function PriorityStepButton({
|
|
delta,
|
|
Icon,
|
|
label,
|
|
onHoldStart,
|
|
onStop,
|
|
onTap,
|
|
}: {
|
|
delta: number;
|
|
Icon: typeof ArrowUp;
|
|
label: string;
|
|
onHoldStart: (delta: number) => void;
|
|
onStop: () => void;
|
|
onTap: (delta: number) => void;
|
|
}) {
|
|
return (
|
|
<button
|
|
onPointerDown={(e) => {
|
|
// Keep receiving pointer events even if the pointer drifts off the small button mid-hold.
|
|
try {
|
|
e.currentTarget.setPointerCapture(e.pointerId);
|
|
} catch {
|
|
/* invalid pointer id (e.g. synthetic events) — capture is a nicety, not required */
|
|
}
|
|
onHoldStart(delta);
|
|
}}
|
|
onPointerUp={onStop}
|
|
onPointerCancel={onStop}
|
|
onClick={() => onTap(delta)}
|
|
className="text-muted hover:text-accent leading-none"
|
|
aria-label={label}
|
|
>
|
|
<Icon className="w-3.5 h-3.5" />
|
|
</button>
|
|
);
|
|
}
|
|
|
|
/**
|
|
* A press-and-hold-repeat priority control with an inline-editable value (negatives allowed).
|
|
* Shared by the channel manager table (a cell) and the channel detail page (a header control), so
|
|
* the two never drift. The caller owns persistence: `onPriority(delta)` nudges by ±1 (with hold
|
|
* auto-repeat), `onSet(value)` writes a typed-in absolute value.
|
|
*/
|
|
export function PriorityStepper({
|
|
priority,
|
|
onPriority,
|
|
onSet,
|
|
}: {
|
|
priority: number;
|
|
onPriority: (delta: number) => void;
|
|
onSet: (value: number) => void;
|
|
}) {
|
|
const { t } = useTranslation();
|
|
const [editing, setEditing] = useState(false);
|
|
const [draft, setDraft] = useState("");
|
|
const holdDelay = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
const holdRepeat = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
const suppressClick = useRef(false);
|
|
|
|
const stop = useCallback(() => {
|
|
if (holdDelay.current) clearTimeout(holdDelay.current);
|
|
if (holdRepeat.current) clearTimeout(holdRepeat.current);
|
|
holdDelay.current = holdRepeat.current = null;
|
|
}, []);
|
|
useEffect(() => stop, [stop]); // clear any running timers on unmount
|
|
|
|
// Hold past a threshold → auto-repeat at an accelerating rate, and suppress the trailing click so
|
|
// a hold doesn't double-count its final notch. A quick tap never crosses the threshold, so it
|
|
// falls through to onClick — which also keeps the keyboard path (Enter/Space) working.
|
|
const start = (delta: number) => {
|
|
suppressClick.current = false;
|
|
holdDelay.current = setTimeout(() => {
|
|
suppressClick.current = true;
|
|
let rate = 140;
|
|
const tick = () => {
|
|
onPriority(delta);
|
|
rate = Math.max(40, rate - 12);
|
|
holdRepeat.current = setTimeout(tick, rate);
|
|
};
|
|
tick();
|
|
}, 350);
|
|
};
|
|
const onTap = (delta: number) => {
|
|
if (suppressClick.current) {
|
|
suppressClick.current = false;
|
|
return;
|
|
}
|
|
onPriority(delta);
|
|
};
|
|
|
|
const commitEdit = () => {
|
|
const parsed = parseInt(draft, 10);
|
|
if (!Number.isNaN(parsed) && parsed !== priority) onSet(parsed);
|
|
setEditing(false);
|
|
};
|
|
const beginEdit = () => {
|
|
setDraft(String(priority));
|
|
setEditing(true);
|
|
};
|
|
|
|
// The value sits to the LEFT of the arrows (not between them) so the cursor — which rests on the
|
|
// arrows while you press-and-hold — never covers the live value you're setting. Clicking the value
|
|
// opens an inline edit to type any integer directly (negatives allowed).
|
|
return (
|
|
<Tooltip hint={t("channels.row.priorityHint")}>
|
|
<div className="inline-flex items-center gap-1.5 cursor-help">
|
|
{/* The number keeps its footprint (w-6) whether displayed or editing; the input is absolutely
|
|
positioned OVER it (out of flow) so opening the editor never changes the row height/width —
|
|
it just overlays, extending left for a longer number. */}
|
|
<span className="relative inline-block w-6 h-5 text-right leading-5">
|
|
<button
|
|
onClick={beginEdit}
|
|
aria-label={t("channels.row.editPriority")}
|
|
title={t("channels.row.editPriority")}
|
|
className={`text-sm font-medium tabular-nums hover:text-accent ${editing ? "invisible" : ""}`}
|
|
>
|
|
{priority}
|
|
</button>
|
|
{editing && (
|
|
<input
|
|
type="number"
|
|
value={draft}
|
|
autoFocus
|
|
onFocus={(e) => e.currentTarget.select()}
|
|
onChange={(e) => setDraft(e.target.value)}
|
|
onBlur={commitEdit}
|
|
onKeyDown={(e) => {
|
|
if (e.key === "Enter") commitEdit();
|
|
else if (e.key === "Escape") setEditing(false);
|
|
}}
|
|
aria-label={t("channels.row.priorityHint")}
|
|
className="absolute top-1/2 right-0 -translate-y-1/2 w-14 h-6 text-sm font-medium tabular-nums text-right bg-card border border-accent rounded-sm px-1 outline-hidden z-popover [appearance:textfield] [&::-webkit-inner-spin-button]:appearance-none"
|
|
/>
|
|
)}
|
|
</span>
|
|
<span className="inline-flex flex-col">
|
|
<PriorityStepButton
|
|
delta={1}
|
|
Icon={ArrowUp}
|
|
label={t("channels.row.raisePriority")}
|
|
onHoldStart={start}
|
|
onStop={stop}
|
|
onTap={onTap}
|
|
/>
|
|
<PriorityStepButton
|
|
delta={-1}
|
|
Icon={ArrowDown}
|
|
label={t("channels.row.lowerPriority")}
|
|
onHoldStart={start}
|
|
onStop={stop}
|
|
onTap={onTap}
|
|
/>
|
|
</span>
|
|
</div>
|
|
</Tooltip>
|
|
);
|
|
}
|