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:
npeter83 2026-07-05 03:29:20 +02:00
parent 62636319b1
commit 219a935121
14 changed files with 290 additions and 133 deletions

View file

@ -74,7 +74,6 @@ export default function Feed({
view,
canRead,
isDemo = false,
plexEnabled = false,
onOpenWizard,
ytSearch,
onYtSearch,
@ -87,8 +86,6 @@ export default function Feed({
view: "grid" | "list";
canRead: boolean;
isDemo?: boolean;
// When the optional Plex module is on, offer "Plex" as a feed source (App swaps to PlexBrowse).
plexEnabled?: boolean;
onOpenWizard: () => void;
// Live YouTube search: the active search term (null = normal feed). Entering a search and
// leaving it both step browser history (the search is a feed sub-view with its own history
@ -563,7 +560,6 @@ export default function Feed({
<option value="organic">{t("feed.source.organic")}</option>
<option value="all">{t("feed.source.all")}</option>
<option value="search">{t("feed.source.only")}</option>
{plexEnabled && <option value="plex">{t("feed.source.plex")}</option>}
</select>
</label>
</>

View file

@ -26,10 +26,14 @@ export default function Header({
const { t } = useTranslation();
const canSearchYt = !me.is_demo;
const trimmedQ = filters.q.trim();
// The search box serves both the YouTube feed and the Plex module (integrated search); the live
// YouTube-search escalation (Enter / button) is feed-only.
const isSearchPage = page === "feed" || page === "plex";
const isYtCapable = page === "feed" && canSearchYt;
return (
<header className="glass h-14 shrink-0 border-b border-border flex items-center gap-3 px-4 z-20">
{page === "feed" ? (
{isSearchPage ? (
<div className="flex-1 max-w-xl mx-auto flex items-center gap-2">
<div className="flex-1 relative">
<Search className="w-4 h-4 absolute left-3 top-1/2 -translate-y-1/2 text-muted" />
@ -40,15 +44,16 @@ export default function Header({
// When a search first appears, rank the feed by relevance — set atomically with
// the query (race-free vs. per-keystroke updates). Only overrides the default
// "newest" sort; a custom sort the user chose is left alone. Cleared in Feed.
const startSearch = !filters.q.trim() && !!q.trim() && filters.sort === "newest";
const startSearch =
page === "feed" && !filters.q.trim() && !!q.trim() && filters.sort === "newest";
setFilters({ ...filters, q, ...(startSearch ? { sort: "relevance" } : {}) });
}}
onKeyDown={(e) => {
// Enter runs a live YouTube search for the typed term (the box itself filters
// the local catalog as you type; Enter escalates to the YouTube API).
if (e.key === "Enter" && trimmedQ && canSearchYt) onYtSearch(trimmedQ);
// On the feed, Enter escalates the typed term to a live YouTube search (the box
// itself filters the local catalog / Plex library as you type).
if (e.key === "Enter" && trimmedQ && isYtCapable) onYtSearch(trimmedQ);
}}
placeholder={t("header.searchPlaceholder")}
placeholder={page === "plex" ? t("plex.searchPlaceholder") : t("header.searchPlaceholder")}
className="w-full bg-card border border-border rounded-full pl-9 pr-9 py-2 text-sm outline-none focus:border-accent"
/>
{filters.q && (
@ -62,7 +67,7 @@ export default function Header({
</button>
)}
</div>
{trimmedQ && canSearchYt && (
{trimmedQ && isYtCapable && (
<button
onClick={() => onYtSearch(trimmedQ)}
title={t("header.searchYoutubeTip")}

View file

@ -8,6 +8,7 @@ import {
Bell,
ChevronLeft,
ChevronRight,
Clapperboard,
Download,
Home,
Info,
@ -162,6 +163,8 @@ export default function NavSidebar({
{ page: "feed", icon: Home, label: t("header.account.feed") },
{ page: "channels", icon: Tv, label: t("header.account.channels") },
{ page: "playlists", icon: ListVideo, label: t("header.account.playlists") },
// Optional Plex module — browse/play your Plex library. Shown only when the admin enabled it.
...(me.plex_enabled ? [{ page: "plex" as Page, icon: Clapperboard, label: t("plex.navLabel") }] : []),
{ page: "notifications", icon: Bell, label: t("inbox.navLabel"), badge: unread },
// Direct messaging — its own module; hidden for the shared demo account.
...(me.is_demo

View file

@ -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>
);

View file

@ -0,0 +1,135 @@
import { useEffect } from "react";
import { useTranslation } from "react-i18next";
import { useQuery } from "@tanstack/react-query";
import { ChevronRight, Film, SlidersHorizontal, Tv2 } from "lucide-react";
import { api } from "../lib/api";
// The Plex module's left filter column (mirrors the feed Sidebar's shell): pick a library, filter
// by watch state (movie libraries only), and sort. State is owned by App (per-account persisted).
type Props = {
library: string;
setLibrary: (v: string) => void;
show: string;
setShow: (v: string) => void;
sort: string;
setSort: (v: string) => void;
collapsed: boolean;
onToggleCollapse: () => void;
};
const SHOW_OPTS = ["all", "unwatched", "in_progress", "watched"] as const;
const SORT_OPTS = ["added", "title"] as const;
export default function PlexSidebar({
library,
setLibrary,
show,
setShow,
sort,
setSort,
collapsed,
onToggleCollapse,
}: Props) {
const { t } = useTranslation();
const libsQ = useQuery({ queryKey: ["plex-libraries"], queryFn: api.plexLibraries });
const libs = libsQ.data?.libraries ?? [];
const activeLib = libs.find((l) => l.key === library);
// Default to the first library once loaded (or if the stored one vanished).
useEffect(() => {
if (libs.length && !libs.some((l) => l.key === library)) setLibrary(libs[0].key);
}, [libs, library, setLibrary]);
const isMovieLib = activeLib?.kind === "movie";
const activeCount = (show !== "all" && isMovieLib ? 1 : 0) + (sort !== "added" ? 1 : 0);
if (collapsed) {
return (
<aside className="hidden md:flex w-[46px] shrink-0 flex-col items-center gap-2 border-r border-border bg-surface/40 py-3">
<button
onClick={onToggleCollapse}
title={t("sidebar.expandPanel")}
aria-label={t("sidebar.expandPanel")}
className="p-1 rounded-md text-muted hover:text-fg hover:bg-card transition"
>
<ChevronRight className="w-4 h-4" />
</button>
<button
onClick={onToggleCollapse}
title={t("sidebar.filters")}
className="relative p-2 rounded-lg text-muted hover:text-fg hover:bg-card transition"
>
<SlidersHorizontal className="w-5 h-5" />
{activeCount > 0 && (
<span className="absolute -top-1 -right-1 min-w-[16px] h-4 px-1 rounded-full bg-accent text-accent-fg text-[10px] font-bold leading-none grid place-items-center ring-2 ring-bg">
{activeCount}
</span>
)}
</button>
</aside>
);
}
return (
<aside className="hidden md:block w-64 shrink-0 border-r border-border bg-surface/40 overflow-y-auto p-4 space-y-4">
{/* Library scope */}
<div>
<div className="text-[11px] uppercase tracking-wide text-muted mb-1.5">{t("plex.filter.library")}</div>
<div className="flex flex-col gap-1">
{libs.map((l) => (
<button
key={l.key}
onClick={() => setLibrary(l.key)}
className={`inline-flex items-center gap-2 px-2.5 py-1.5 rounded-lg text-sm transition ${
l.key === library ? "bg-accent text-accent-fg" : "glass-card glass-hover"
}`}
>
{l.kind === "movie" ? <Film className="w-4 h-4" /> : <Tv2 className="w-4 h-4" />}
<span className="flex-1 text-left truncate">{l.title}</span>
<span className="opacity-60 text-xs">{l.count.toLocaleString()}</span>
</button>
))}
</div>
</div>
{/* Watch state (movie libraries only — an episode's state lives in the show drill-down) */}
{isMovieLib && (
<div>
<div className="text-[11px] uppercase tracking-wide text-muted mb-1.5">{t("plex.filter.show")}</div>
<div className="flex flex-wrap gap-1">
{SHOW_OPTS.map((s) => (
<button
key={s}
onClick={() => setShow(s)}
className={`px-2.5 py-1 rounded-full text-xs transition ${
show === s ? "bg-accent text-accent-fg" : "glass-card glass-hover"
}`}
>
{t(`plex.filter.showOpt.${s}`)}
</button>
))}
</div>
</div>
)}
{/* Sort */}
<div>
<div className="text-[11px] uppercase tracking-wide text-muted mb-1.5">{t("plex.filter.sort")}</div>
<div className="flex flex-wrap gap-1">
{SORT_OPTS.map((s) => (
<button
key={s}
onClick={() => setSort(s)}
className={`px-2.5 py-1 rounded-full text-xs transition ${
sort === s ? "bg-accent text-accent-fg" : "glass-card glass-hover"
}`}
>
{t(`plex.sort.${s}`)}
</button>
))}
</div>
</div>
</aside>
);
}