feat(plex): P1.5 — Plex as a nav module + integrated search + filter sidebar
Per user feedback: promote Plex from a hidden feed-Source option to a first-class left-nav module, with its own filter sidebar and the shared top search box. - Plex is now a nav-rail module (page='plex', gated on me.plex_enabled) — its own page → correct browser Back/Forward; removed the Feed Source-dropdown 'plex' hack. - PlexSidebar.tsx: left filter column (library scope, watch-state for movies: all/unwatched/in_progress/watched, sort) — per-account persisted (App state). - Header search box now also serves the Plex page (integrated search); the live YouTube-search escalation stays feed-only. - PlexBrowse.tsx reworked: infinite scroll (IntersectionObserver, no 'Load more'), content-visibility for smoother long-list scroll, show drill-down rides history (useHistorySubview) so Back returns to the grid; dropped the stray 'YouTube feed' button on the show page. - backend /browse gains a per-user watch-state filter (show=, movies only). - plex i18n (navLabel + filter.*) en/hu/de. Note: episode-title 'Episode N' on some shows (e.g. Westworld) is Plex-side (unmatched show) — we mirror what Plex has; Match/Refresh in Plex + re-sync fixes it.
This commit is contained in:
parent
62636319b1
commit
219a935121
14 changed files with 290 additions and 133 deletions
|
|
@ -1,54 +1,40 @@
|
|||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useEffect, useRef, type CSSProperties } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useInfiniteQuery, useQuery } from "@tanstack/react-query";
|
||||
import { ArrowLeft, Film, Search, Tv2 } from "lucide-react";
|
||||
import { api, type FeedFilters, type PlexCard } from "../lib/api";
|
||||
import { ArrowLeft } from "lucide-react";
|
||||
import { api, type PlexCard } from "../lib/api";
|
||||
import { notify } from "../lib/notifications";
|
||||
import { useDebounced } from "../lib/useDebounced";
|
||||
import { useHistorySubview } from "../lib/history";
|
||||
|
||||
// Plex mode of the feed: browse/search the mirrored Plex library as feed-style cards, drill from a
|
||||
// show into seasons/episodes. Rendered by App in place of <Feed> when filters.librarySource==="plex"
|
||||
// (so the video-feed hooks never run in Plex mode). Playback lands in P2 — for now a card just
|
||||
// announces it; shows drill down. Self-contained: its own library scope + search + sort + paging.
|
||||
// The Plex module's content area: browse/search the mirrored library as poster cards, drill from a
|
||||
// show into seasons/episodes. Library scope / watch-state / sort live in the left PlexSidebar; the
|
||||
// search term comes from the shared top Header search box (q). A show drill-down rides history
|
||||
// (useHistorySubview) so browser Back returns to the grid. Playback lands in P2 — a card announces
|
||||
// for now. Rendered by App for page==="plex" (its own nav module).
|
||||
|
||||
type Props = {
|
||||
filters: FeedFilters;
|
||||
setFilters: (f: FeedFilters) => void;
|
||||
};
|
||||
type Props = { q: string; library: string; show: string; sort: string };
|
||||
|
||||
const PAGE = 40;
|
||||
|
||||
function secs(n?: number | null): string {
|
||||
function dur(n?: number | null): string {
|
||||
if (!n) return "";
|
||||
const h = Math.floor(n / 3600);
|
||||
const m = Math.floor((n % 3600) / 60);
|
||||
return h ? `${h}h ${m}m` : `${m}m`;
|
||||
}
|
||||
|
||||
export default function PlexBrowse({ filters, setFilters }: Props) {
|
||||
export default function PlexBrowse({ q, library, show, sort }: Props) {
|
||||
const { t } = useTranslation();
|
||||
const libsQ = useQuery({ queryKey: ["plex-libraries"], queryFn: api.plexLibraries });
|
||||
const libs = useMemo(() => libsQ.data?.libraries ?? [], [libsQ.data]);
|
||||
|
||||
const [library, setLibrary] = useState<string | null>(null);
|
||||
const [q, setQ] = useState("");
|
||||
const [sort, setSort] = useState("added");
|
||||
const [openShow, setOpenShow] = useState<string | null>(null);
|
||||
const dq = useDebounced(q.trim(), 350);
|
||||
|
||||
// Default to the first library once loaded.
|
||||
useEffect(() => {
|
||||
if (library === null && libs.length) setLibrary(libs[0].key);
|
||||
}, [libs, library]);
|
||||
|
||||
const activeLib = libs.find((l) => l.key === library);
|
||||
const sub = useHistorySubview<string | null>(null); // open show ratingKey (null = grid)
|
||||
|
||||
const browseQ = useInfiniteQuery({
|
||||
queryKey: ["plex-browse", library, dq, sort],
|
||||
enabled: !!library && !openShow,
|
||||
queryKey: ["plex-browse", library, dq, sort, show],
|
||||
enabled: !!library && sub.view === null,
|
||||
initialPageParam: 0,
|
||||
queryFn: ({ pageParam }) =>
|
||||
api.plexBrowse({ library: library!, q: dq || undefined, sort, offset: pageParam as number, limit: PAGE }),
|
||||
api.plexBrowse({ library, q: dq || undefined, sort, show, offset: pageParam as number, limit: PAGE }),
|
||||
getNextPageParam: (last) => {
|
||||
const seen = last.offset + last.items.length;
|
||||
return seen < last.total ? seen : undefined;
|
||||
|
|
@ -58,77 +44,44 @@ export default function PlexBrowse({ filters, setFilters }: Props) {
|
|||
const items = browseQ.data?.pages.flatMap((p) => p.items) ?? [];
|
||||
const total = browseQ.data?.pages[0]?.total ?? 0;
|
||||
|
||||
function exitPlex() {
|
||||
setFilters({ ...filters, librarySource: "organic", q: "" });
|
||||
}
|
||||
// Infinite scroll: auto-load the next page when the sentinel scrolls into view.
|
||||
const sentinel = useRef<HTMLDivElement>(null);
|
||||
const { hasNextPage, isFetchingNextPage, fetchNextPage } = browseQ;
|
||||
useEffect(() => {
|
||||
const el = sentinel.current;
|
||||
if (!el) return;
|
||||
const io = new IntersectionObserver(
|
||||
(entries) => {
|
||||
if (entries[0].isIntersecting && hasNextPage && !isFetchingNextPage) fetchNextPage();
|
||||
},
|
||||
{ rootMargin: "600px" },
|
||||
);
|
||||
io.observe(el);
|
||||
return () => io.disconnect();
|
||||
}, [hasNextPage, isFetchingNextPage, fetchNextPage]);
|
||||
|
||||
function onPlay(card: PlexCard) {
|
||||
// P2 wires the real player; announce for now so the flow is visible in P1.
|
||||
notify({ level: "info", message: t("plex.playerSoon", { title: card.title }) });
|
||||
}
|
||||
function onCard(card: PlexCard) {
|
||||
if (card.type === "show") setOpenShow(card.id);
|
||||
if (card.type === "show") sub.open(card.id);
|
||||
else onPlay(card);
|
||||
}
|
||||
|
||||
if (openShow) {
|
||||
return <PlexShowView showId={openShow} onBack={() => setOpenShow(null)} onPlay={onPlay} onExit={exitPlex} />;
|
||||
if (sub.view) {
|
||||
return <PlexShowView showId={sub.view} onBack={sub.back} onPlay={onPlay} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-4 max-w-[1600px] mx-auto">
|
||||
{/* Toolbar: back-to-YouTube, library scope, search, sort */}
|
||||
<div className="flex flex-wrap items-center gap-2 mb-4">
|
||||
<button
|
||||
onClick={exitPlex}
|
||||
className="glass-card glass-hover inline-flex items-center gap-1.5 px-2.5 py-1.5 rounded-lg text-xs"
|
||||
title={t("plex.backToFeed")}
|
||||
>
|
||||
<ArrowLeft className="w-4 h-4" />
|
||||
{t("plex.backToFeed")}
|
||||
</button>
|
||||
<span className="mx-1 h-5 w-px bg-border" aria-hidden="true" />
|
||||
<div className="inline-flex items-center gap-1">
|
||||
{libs.map((l) => (
|
||||
<button
|
||||
key={l.key}
|
||||
onClick={() => setLibrary(l.key)}
|
||||
className={`inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs transition ${
|
||||
l.key === library ? "bg-accent text-white" : "glass-card glass-hover"
|
||||
}`}
|
||||
>
|
||||
{l.kind === "movie" ? <Film className="w-3.5 h-3.5" /> : <Tv2 className="w-3.5 h-3.5" />}
|
||||
{l.title}
|
||||
<span className="opacity-60">{l.count.toLocaleString()}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="relative ml-auto">
|
||||
<Search className="w-4 h-4 absolute left-2.5 top-1/2 -translate-y-1/2 text-muted" />
|
||||
<input
|
||||
value={q}
|
||||
onChange={(e) => setQ(e.target.value)}
|
||||
placeholder={t("plex.searchPlaceholder")}
|
||||
className="bg-card border border-border rounded-lg pl-8 pr-3 py-1.5 text-sm w-56 outline-none focus:border-accent"
|
||||
/>
|
||||
</div>
|
||||
<select
|
||||
value={sort}
|
||||
onChange={(e) => setSort(e.target.value)}
|
||||
className="bg-card border border-border rounded-lg px-2 py-1.5 text-xs outline-none focus:border-accent"
|
||||
>
|
||||
<option value="added">{t("plex.sort.added")}</option>
|
||||
<option value="title">{t("plex.sort.title")}</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<p className="text-xs text-muted mb-3">
|
||||
{dq ? t("plex.searchCount", { count: total }) : t("plex.count", { count: total })}
|
||||
{browseQ.isLoading ? " " : dq ? t("plex.searchCount", { count: total }) : t("plex.count", { count: total })}
|
||||
</p>
|
||||
|
||||
{browseQ.isLoading ? (
|
||||
<p className="text-muted text-sm">{t("plex.loading")}</p>
|
||||
) : items.length === 0 ? (
|
||||
<p className="text-muted text-sm">{t("plex.empty")}</p>
|
||||
<p className="text-muted text-sm">{dq ? t("plex.noMatches") : t("plex.empty")}</p>
|
||||
) : (
|
||||
<div className="grid grid-cols-[repeat(auto-fill,minmax(150px,1fr))] gap-3">
|
||||
{items.map((c) => (
|
||||
|
|
@ -137,17 +90,8 @@ export default function PlexBrowse({ filters, setFilters }: Props) {
|
|||
</div>
|
||||
)}
|
||||
|
||||
{browseQ.hasNextPage && (
|
||||
<div className="flex justify-center mt-5">
|
||||
<button
|
||||
onClick={() => browseQ.fetchNextPage()}
|
||||
disabled={browseQ.isFetchingNextPage}
|
||||
className="glass-card glass-hover px-4 py-2 rounded-xl text-sm disabled:opacity-50"
|
||||
>
|
||||
{browseQ.isFetchingNextPage ? t("plex.loading") : t("plex.loadMore")}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<div ref={sentinel} className="h-8" />
|
||||
{isFetchingNextPage && <p className="text-center text-xs text-muted pb-4">{t("plex.loading")}</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -166,12 +110,18 @@ function PlexPosterCard({ card, onClick }: { card: PlexCard; onClick: () => void
|
|||
? Math.min(100, Math.round(((card.position_seconds ?? 0) / card.duration_seconds) * 100))
|
||||
: 0;
|
||||
return (
|
||||
<button onClick={onClick} className="group text-left">
|
||||
<button
|
||||
onClick={onClick}
|
||||
className="group text-left"
|
||||
// content-visibility keeps off-screen posters cheap to render (smoother long-list scroll).
|
||||
style={{ contentVisibility: "auto", containIntrinsicSize: "auto 300px" } as CSSProperties}
|
||||
>
|
||||
<div className="relative aspect-[2/3] rounded-xl overflow-hidden bg-card border border-border">
|
||||
<img
|
||||
src={card.thumb}
|
||||
alt=""
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
className="w-full h-full object-cover group-hover:scale-105 transition-transform duration-200"
|
||||
/>
|
||||
{card.status === "watched" && (
|
||||
|
|
@ -197,9 +147,7 @@ function PlexPosterCard({ card, onClick }: { card: PlexCard; onClick: () => void
|
|||
)}
|
||||
</div>
|
||||
<div className="mt-1.5 text-sm font-medium line-clamp-2 leading-tight">{card.title}</div>
|
||||
<div className="text-xs text-muted">
|
||||
{[card.year, secs(card.duration_seconds)].filter(Boolean).join(" · ")}
|
||||
</div>
|
||||
<div className="text-xs text-muted">{[card.year, dur(card.duration_seconds)].filter(Boolean).join(" · ")}</div>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
|
@ -208,12 +156,10 @@ function PlexShowView({
|
|||
showId,
|
||||
onBack,
|
||||
onPlay,
|
||||
onExit,
|
||||
}: {
|
||||
showId: string;
|
||||
onBack: () => void;
|
||||
onPlay: (c: PlexCard) => void;
|
||||
onExit: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const q = useQuery({ queryKey: ["plex-show", showId], queryFn: () => api.plexShow(showId) });
|
||||
|
|
@ -221,18 +167,13 @@ function PlexShowView({
|
|||
|
||||
return (
|
||||
<div className="p-4 max-w-[1200px] mx-auto">
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<button
|
||||
onClick={onBack}
|
||||
className="glass-card glass-hover inline-flex items-center gap-1.5 px-2.5 py-1.5 rounded-lg text-xs"
|
||||
>
|
||||
<ArrowLeft className="w-4 h-4" />
|
||||
{t("plex.backToLibrary")}
|
||||
</button>
|
||||
<button onClick={onExit} className="text-xs text-muted hover:text-fg ml-1">
|
||||
{t("plex.backToFeed")}
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
onClick={onBack}
|
||||
className="glass-card glass-hover inline-flex items-center gap-1.5 px-2.5 py-1.5 rounded-lg text-xs mb-4"
|
||||
>
|
||||
<ArrowLeft className="w-4 h-4" />
|
||||
{t("plex.backToLibrary")}
|
||||
</button>
|
||||
|
||||
{q.isLoading || !d ? (
|
||||
<p className="text-muted text-sm">{t("plex.loading")}</p>
|
||||
|
|
@ -282,7 +223,7 @@ function PlexShowView({
|
|||
{ep.summary && (
|
||||
<div className="text-xs text-muted line-clamp-2 mt-0.5">{ep.summary}</div>
|
||||
)}
|
||||
<div className="text-[11px] text-muted mt-0.5">{secs(ep.duration_seconds)}</div>
|
||||
<div className="text-[11px] text-muted mt-0.5">{dur(ep.duration_seconds)}</div>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue