feat(ux): card unification, slim icon nav, filter/plex fixes (7-item batch)
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.
This commit is contained in:
@@ -28,6 +28,7 @@ export interface ChannelDetail {
|
||||
blocked: boolean;
|
||||
stored_videos: number;
|
||||
deep_requested: boolean;
|
||||
priority: number;
|
||||
backfill_done: boolean;
|
||||
from_explore: boolean;
|
||||
details_synced: boolean;
|
||||
|
||||
@@ -77,32 +77,25 @@ export function Filterable({
|
||||
);
|
||||
}
|
||||
|
||||
// Paint the item's art as a faint FIXED backdrop on the page's <main> scroll container (HTPC-style),
|
||||
// so the glass panels float over it. Cleared on unmount / when disabled / when there's no art.
|
||||
// Paint the item's art as a faint FULL-VIEWPORT backdrop (HTPC-style) so the glass chrome floats over
|
||||
// it. A dedicated `position:fixed inset-0` layer at z-index -1 sits BEHIND the (transparent / glassy)
|
||||
// app content — so the art fills the whole page edge-to-edge, under the nav rail and floating header
|
||||
// too, instead of only the <main> box (which is inset by the rail, the header and the scrollbar
|
||||
// gutter). Removed on unmount / when disabled / when there's no art.
|
||||
export function useArtBackdrop(art: string | null | undefined, enabled: boolean) {
|
||||
useLayoutEffect(() => {
|
||||
const main = document.querySelector("main");
|
||||
if (!main) return;
|
||||
const s = main.style;
|
||||
const clear = () => {
|
||||
s.backgroundImage = "";
|
||||
s.backgroundSize = "";
|
||||
s.backgroundPosition = "";
|
||||
s.backgroundRepeat = "";
|
||||
s.backgroundAttachment = "";
|
||||
};
|
||||
if (enabled && art) {
|
||||
s.backgroundImage =
|
||||
`linear-gradient(color-mix(in srgb, var(--bg) 60%, transparent), ` +
|
||||
`color-mix(in srgb, var(--bg) 64%, transparent)), url("${art}")`;
|
||||
s.backgroundSize = "cover";
|
||||
s.backgroundPosition = "center 20%";
|
||||
s.backgroundRepeat = "no-repeat";
|
||||
s.backgroundAttachment = "fixed";
|
||||
} else {
|
||||
clear();
|
||||
}
|
||||
return clear;
|
||||
if (!enabled || !art) return;
|
||||
const el = document.createElement("div");
|
||||
el.setAttribute("data-plex-art-backdrop", "");
|
||||
el.style.cssText =
|
||||
"position:fixed;inset:0;z-index:-1;pointer-events:none;" +
|
||||
"background-size:cover;background-position:center 20%;background-repeat:no-repeat;";
|
||||
// The faint --bg overlay keeps it subtle and text-safe (same tuning the old <main> paint used).
|
||||
el.style.backgroundImage =
|
||||
`linear-gradient(color-mix(in srgb, var(--bg) 60%, transparent), ` +
|
||||
`color-mix(in srgb, var(--bg) 64%, transparent)), url("${art}")`;
|
||||
document.body.appendChild(el);
|
||||
return () => el.remove();
|
||||
}, [art, enabled]);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,29 +1,37 @@
|
||||
import { useEffect, useState, type FocusEvent } from "react";
|
||||
import { useCallback, useEffect, useRef, useState, type FocusEvent } from "react";
|
||||
|
||||
/**
|
||||
* "Peek open on hover or keyboard focus" for a container — the shared brain behind the nav rail
|
||||
* and the floating side panels, which sit slim until you point at them or tab into them.
|
||||
* "Peek open on hover or keyboard focus" for a floating panel — the shared brain behind the side
|
||||
* panels, which sit slim until you point at them or tab into them.
|
||||
*
|
||||
* Spread `handlers` on the container; `active` is true while the pointer is over it (or any
|
||||
* descendant) or focus is inside it.
|
||||
* Returns `active` plus THREE handler groups so a consumer can scope hover and focus independently:
|
||||
* - `hoverHandlers` — pointer enter/leave, with a short GRACE period on leave so the pointer can
|
||||
* cross a gap between two separate peek regions (e.g. the docked tab and the expanded card, which
|
||||
* are separate absolutely-positioned elements) without the panel collapsing mid-move. Put these on
|
||||
* ONLY the visible affordances (the tab handle + the card), so approaching empty gutter next to the
|
||||
* panel does not pop it open.
|
||||
* - `focusHandlers` — focus/blur, tracked through the REACT tree so a portaled child menu still
|
||||
* counts as "inside". Put these on the outer container so keyboard focus anywhere within holds it.
|
||||
* - `handlers` — both groups merged, for the simple single-element case.
|
||||
*
|
||||
* Focus is tracked through the REACT tree, so a portaled menu rendered by a child (the language
|
||||
* switcher, the account popover) still counts as "inside" and holds the container open while you
|
||||
* use it. The one case that misses: such a portal unmounts while focused, and browsers fire NO blur
|
||||
* when a focused node is removed — focus silently falls to the body and `active` would latch on
|
||||
* forever. Hence the document-level safety net: when focus has landed nowhere, clear it.
|
||||
* The one focus case that misses: a portaled menu unmounts while focused — browsers fire NO blur when
|
||||
* a focused node is removed, so focus silently falls to the body and would latch `focused` on forever.
|
||||
* Hence the document-level safety net: when focus has landed nowhere, clear it.
|
||||
*/
|
||||
export function useHoverFocusWithin(): {
|
||||
active: boolean;
|
||||
handlers: {
|
||||
onMouseEnter: () => void;
|
||||
onMouseLeave: () => void;
|
||||
onFocus: () => void;
|
||||
onBlur: (e: FocusEvent) => void;
|
||||
};
|
||||
} {
|
||||
export function useHoverFocusWithin() {
|
||||
const [hovered, setHovered] = useState(false);
|
||||
const [focused, setFocused] = useState(false);
|
||||
const leaveTimer = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
|
||||
|
||||
const enter = useCallback(() => {
|
||||
clearTimeout(leaveTimer.current);
|
||||
setHovered(true);
|
||||
}, []);
|
||||
const leaveSoon = useCallback(() => {
|
||||
clearTimeout(leaveTimer.current);
|
||||
leaveTimer.current = setTimeout(() => setHovered(false), 120);
|
||||
}, []);
|
||||
useEffect(() => () => clearTimeout(leaveTimer.current), []);
|
||||
|
||||
useEffect(() => {
|
||||
let timer: number | undefined;
|
||||
@@ -46,15 +54,18 @@ export function useHoverFocusWithin(): {
|
||||
};
|
||||
}, []);
|
||||
|
||||
return {
|
||||
active: hovered || focused,
|
||||
handlers: {
|
||||
onMouseEnter: () => setHovered(true),
|
||||
onMouseLeave: () => setHovered(false),
|
||||
onFocus: () => setFocused(true),
|
||||
onBlur: (e: FocusEvent) => {
|
||||
if (!e.currentTarget.contains(e.relatedTarget as Node)) setFocused(false);
|
||||
},
|
||||
const hoverHandlers = { onMouseEnter: enter, onMouseLeave: leaveSoon };
|
||||
const focusHandlers = {
|
||||
onFocus: () => setFocused(true),
|
||||
onBlur: (e: FocusEvent) => {
|
||||
if (!e.currentTarget.contains(e.relatedTarget as Node)) setFocused(false);
|
||||
},
|
||||
};
|
||||
|
||||
return {
|
||||
active: hovered || focused,
|
||||
hoverHandlers,
|
||||
focusHandlers,
|
||||
handlers: { ...hoverHandlers, ...focusHandlers },
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user