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
|
|
@ -8,8 +8,8 @@ catalog sync.
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Query, Response
|
from fastapi import APIRouter, Depends, HTTPException, Query, Response
|
||||||
from sqlalchemy import func
|
from sqlalchemy import and_, func, or_
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session, aliased
|
||||||
|
|
||||||
from app import sysconfig
|
from app import sysconfig
|
||||||
from app.auth import current_user
|
from app.auth import current_user
|
||||||
|
|
@ -130,14 +130,15 @@ def browse(
|
||||||
library: str,
|
library: str,
|
||||||
q: str | None = None,
|
q: str | None = None,
|
||||||
sort: str = "added",
|
sort: str = "added",
|
||||||
|
show: str = "all",
|
||||||
offset: int = 0,
|
offset: int = 0,
|
||||||
limit: int = Query(default=40, ge=1, le=100),
|
limit: int = Query(default=40, ge=1, le=100),
|
||||||
user: User = Depends(current_user),
|
user: User = Depends(current_user),
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
) -> dict:
|
) -> dict:
|
||||||
"""List a library's movies (leaves) or shows, with optional FTS search + sorting.
|
"""List a library's movies (leaves) or shows, with optional FTS search + sorting + a per-user
|
||||||
Movie libraries return playable movie cards; show libraries return show cards (drill in via
|
watch-state filter (`show`: all|unwatched|in_progress|watched — movie libraries only). Movie
|
||||||
/show/{id})."""
|
libraries return playable movie cards; show libraries return show cards (drill in via /show)."""
|
||||||
lib = db.query(PlexLibrary).filter_by(plex_key=str(library)).first()
|
lib = db.query(PlexLibrary).filter_by(plex_key=str(library)).first()
|
||||||
if lib is None:
|
if lib is None:
|
||||||
raise HTTPException(status_code=404, detail="Unknown Plex library")
|
raise HTTPException(status_code=404, detail="Unknown Plex library")
|
||||||
|
|
@ -147,6 +148,17 @@ def browse(
|
||||||
query = db.query(model)
|
query = db.query(model)
|
||||||
if lib.kind == "movie":
|
if lib.kind == "movie":
|
||||||
query = query.filter(PlexItem.library_id == lib.id, PlexItem.kind == "movie")
|
query = query.filter(PlexItem.library_id == lib.id, PlexItem.kind == "movie")
|
||||||
|
# Per-user watch-state filter (movies only; a show isn't a single watch unit).
|
||||||
|
st = aliased(PlexState)
|
||||||
|
query = query.outerjoin(st, and_(st.item_id == PlexItem.id, st.user_id == user.id))
|
||||||
|
if show == "watched":
|
||||||
|
query = query.filter(st.status == "watched")
|
||||||
|
elif show == "in_progress":
|
||||||
|
query = query.filter(st.position_seconds > 0, or_(st.status.is_(None), st.status != "watched"))
|
||||||
|
elif show == "unwatched":
|
||||||
|
query = query.filter(or_(st.status.is_(None), st.status.notin_(["watched", "hidden"])))
|
||||||
|
else: # all — hide only the explicitly hidden
|
||||||
|
query = query.filter(or_(st.status.is_(None), st.status != "hidden"))
|
||||||
else:
|
else:
|
||||||
query = query.filter(PlexShow.library_id == lib.id)
|
query = query.filter(PlexShow.library_id == lib.id)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -63,6 +63,7 @@ import { CURRENT_VERSION } from "./lib/releaseNotes";
|
||||||
// never reach non-admins. All are rendered inside a <Suspense> boundary below.
|
// never reach non-admins. All are rendered inside a <Suspense> boundary below.
|
||||||
const Feed = lazy(() => import("./components/Feed"));
|
const Feed = lazy(() => import("./components/Feed"));
|
||||||
const PlexBrowse = lazy(() => import("./components/PlexBrowse"));
|
const PlexBrowse = lazy(() => import("./components/PlexBrowse"));
|
||||||
|
const PlexSidebar = lazy(() => import("./components/PlexSidebar"));
|
||||||
const ChannelPage = lazy(() => import("./components/ChannelPage"));
|
const ChannelPage = lazy(() => import("./components/ChannelPage"));
|
||||||
const Channels = lazy(() => import("./components/Channels"));
|
const Channels = lazy(() => import("./components/Channels"));
|
||||||
const Playlists = lazy(() => import("./components/Playlists"));
|
const Playlists = lazy(() => import("./components/Playlists"));
|
||||||
|
|
@ -242,6 +243,10 @@ export default function App() {
|
||||||
const [channelsViewRaw, setChannelsView] = useAccountPersistedState(LS.channelsView, "subscribed");
|
const [channelsViewRaw, setChannelsView] = useAccountPersistedState(LS.channelsView, "subscribed");
|
||||||
const channelsView: ChannelsView =
|
const channelsView: ChannelsView =
|
||||||
channelsViewRaw === "discovery" ? "discovery" : "subscribed";
|
channelsViewRaw === "discovery" ? "discovery" : "subscribed";
|
||||||
|
// Plex module filters (its own left-sidebar filter section) — per-account persisted.
|
||||||
|
const [plexLib, setPlexLib] = useAccountPersistedState(LS.plexLibrary, "");
|
||||||
|
const [plexShowFilter, setPlexShowFilter] = useAccountPersistedState(LS.plexShow, "all");
|
||||||
|
const [plexSort, setPlexSort] = useAccountPersistedState(LS.plexSort, "added");
|
||||||
// Bumped to tell the channel manager to drop a stale column filter when we send the user
|
// Bumped to tell the channel manager to drop a stale column filter when we send the user
|
||||||
// there to see a specific set (the header's "without full history" link).
|
// there to see a specific set (the header's "without full history" link).
|
||||||
const [channelsFilterReset, setChannelsFilterReset] = useState(0);
|
const [channelsFilterReset, setChannelsFilterReset] = useState(0);
|
||||||
|
|
@ -713,6 +718,20 @@ export default function App() {
|
||||||
onToggleCollapse={() => setFilterCollapsed(!filterCollapsed)}
|
onToggleCollapse={() => setFilterCollapsed(!filterCollapsed)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
{page === "plex" && meQuery.data!.plex_enabled && (
|
||||||
|
<Suspense fallback={null}>
|
||||||
|
<PlexSidebar
|
||||||
|
library={plexLib}
|
||||||
|
setLibrary={setPlexLib}
|
||||||
|
show={plexShowFilter}
|
||||||
|
setShow={setPlexShowFilter}
|
||||||
|
sort={plexSort}
|
||||||
|
setSort={setPlexSort}
|
||||||
|
collapsed={filterCollapsed}
|
||||||
|
onToggleCollapse={() => setFilterCollapsed(!filterCollapsed)}
|
||||||
|
/>
|
||||||
|
</Suspense>
|
||||||
|
)}
|
||||||
<div className="relative flex-1 min-w-0 flex flex-col">
|
<div className="relative flex-1 min-w-0 flex flex-col">
|
||||||
{channelView ? (
|
{channelView ? (
|
||||||
<Suspense fallback={pageFallback}>
|
<Suspense fallback={pageFallback}>
|
||||||
|
|
@ -782,8 +801,8 @@ export default function App() {
|
||||||
prefs={prefsCtl}
|
prefs={prefsCtl}
|
||||||
onOpenWizard={() => setWizardOpen(true)}
|
onOpenWizard={() => setWizardOpen(true)}
|
||||||
/>
|
/>
|
||||||
) : meQuery.data!.plex_enabled && filters.librarySource === "plex" ? (
|
) : page === "plex" && meQuery.data!.plex_enabled ? (
|
||||||
<PlexBrowse filters={filters} setFilters={setFilters} />
|
<PlexBrowse q={filters.q} library={plexLib} show={plexShowFilter} sort={plexSort} />
|
||||||
) : (
|
) : (
|
||||||
<Feed
|
<Feed
|
||||||
filters={filters}
|
filters={filters}
|
||||||
|
|
@ -791,7 +810,6 @@ export default function App() {
|
||||||
view={view}
|
view={view}
|
||||||
canRead={meQuery.data!.can_read}
|
canRead={meQuery.data!.can_read}
|
||||||
isDemo={meQuery.data!.is_demo}
|
isDemo={meQuery.data!.is_demo}
|
||||||
plexEnabled={meQuery.data!.plex_enabled}
|
|
||||||
onOpenWizard={() => setWizardOpen(true)}
|
onOpenWizard={() => setWizardOpen(true)}
|
||||||
ytSearch={ytSearch}
|
ytSearch={ytSearch}
|
||||||
onYtSearch={enterYtSearch}
|
onYtSearch={enterYtSearch}
|
||||||
|
|
|
||||||
|
|
@ -74,7 +74,6 @@ export default function Feed({
|
||||||
view,
|
view,
|
||||||
canRead,
|
canRead,
|
||||||
isDemo = false,
|
isDemo = false,
|
||||||
plexEnabled = false,
|
|
||||||
onOpenWizard,
|
onOpenWizard,
|
||||||
ytSearch,
|
ytSearch,
|
||||||
onYtSearch,
|
onYtSearch,
|
||||||
|
|
@ -87,8 +86,6 @@ export default function Feed({
|
||||||
view: "grid" | "list";
|
view: "grid" | "list";
|
||||||
canRead: boolean;
|
canRead: boolean;
|
||||||
isDemo?: boolean;
|
isDemo?: boolean;
|
||||||
// When the optional Plex module is on, offer "Plex" as a feed source (App swaps to PlexBrowse).
|
|
||||||
plexEnabled?: boolean;
|
|
||||||
onOpenWizard: () => void;
|
onOpenWizard: () => void;
|
||||||
// Live YouTube search: the active search term (null = normal feed). Entering a search and
|
// 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
|
// 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="organic">{t("feed.source.organic")}</option>
|
||||||
<option value="all">{t("feed.source.all")}</option>
|
<option value="all">{t("feed.source.all")}</option>
|
||||||
<option value="search">{t("feed.source.only")}</option>
|
<option value="search">{t("feed.source.only")}</option>
|
||||||
{plexEnabled && <option value="plex">{t("feed.source.plex")}</option>}
|
|
||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
</>
|
</>
|
||||||
|
|
|
||||||
|
|
@ -26,10 +26,14 @@ export default function Header({
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const canSearchYt = !me.is_demo;
|
const canSearchYt = !me.is_demo;
|
||||||
const trimmedQ = filters.q.trim();
|
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 (
|
return (
|
||||||
<header className="glass h-14 shrink-0 border-b border-border flex items-center gap-3 px-4 z-20">
|
<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 max-w-xl mx-auto flex items-center gap-2">
|
||||||
<div className="flex-1 relative">
|
<div className="flex-1 relative">
|
||||||
<Search className="w-4 h-4 absolute left-3 top-1/2 -translate-y-1/2 text-muted" />
|
<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
|
// 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
|
// 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.
|
// "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" } : {}) });
|
setFilters({ ...filters, q, ...(startSearch ? { sort: "relevance" } : {}) });
|
||||||
}}
|
}}
|
||||||
onKeyDown={(e) => {
|
onKeyDown={(e) => {
|
||||||
// Enter runs a live YouTube search for the typed term (the box itself filters
|
// On the feed, Enter escalates the typed term to a live YouTube search (the box
|
||||||
// the local catalog as you type; Enter escalates to the YouTube API).
|
// itself filters the local catalog / Plex library as you type).
|
||||||
if (e.key === "Enter" && trimmedQ && canSearchYt) onYtSearch(trimmedQ);
|
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"
|
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 && (
|
{filters.q && (
|
||||||
|
|
@ -62,7 +67,7 @@ export default function Header({
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{trimmedQ && canSearchYt && (
|
{trimmedQ && isYtCapable && (
|
||||||
<button
|
<button
|
||||||
onClick={() => onYtSearch(trimmedQ)}
|
onClick={() => onYtSearch(trimmedQ)}
|
||||||
title={t("header.searchYoutubeTip")}
|
title={t("header.searchYoutubeTip")}
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@ import {
|
||||||
Bell,
|
Bell,
|
||||||
ChevronLeft,
|
ChevronLeft,
|
||||||
ChevronRight,
|
ChevronRight,
|
||||||
|
Clapperboard,
|
||||||
Download,
|
Download,
|
||||||
Home,
|
Home,
|
||||||
Info,
|
Info,
|
||||||
|
|
@ -162,6 +163,8 @@ export default function NavSidebar({
|
||||||
{ page: "feed", icon: Home, label: t("header.account.feed") },
|
{ page: "feed", icon: Home, label: t("header.account.feed") },
|
||||||
{ page: "channels", icon: Tv, label: t("header.account.channels") },
|
{ page: "channels", icon: Tv, label: t("header.account.channels") },
|
||||||
{ page: "playlists", icon: ListVideo, label: t("header.account.playlists") },
|
{ 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 },
|
{ page: "notifications", icon: Bell, label: t("inbox.navLabel"), badge: unread },
|
||||||
// Direct messaging — its own module; hidden for the shared demo account.
|
// Direct messaging — its own module; hidden for the shared demo account.
|
||||||
...(me.is_demo
|
...(me.is_demo
|
||||||
|
|
|
||||||
|
|
@ -1,54 +1,40 @@
|
||||||
import { useEffect, useMemo, useState } from "react";
|
import { useEffect, useRef, type CSSProperties } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { useInfiniteQuery, useQuery } from "@tanstack/react-query";
|
import { useInfiniteQuery, useQuery } from "@tanstack/react-query";
|
||||||
import { ArrowLeft, Film, Search, Tv2 } from "lucide-react";
|
import { ArrowLeft } from "lucide-react";
|
||||||
import { api, type FeedFilters, type PlexCard } from "../lib/api";
|
import { api, type PlexCard } from "../lib/api";
|
||||||
import { notify } from "../lib/notifications";
|
import { notify } from "../lib/notifications";
|
||||||
import { useDebounced } from "../lib/useDebounced";
|
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
|
// The Plex module's content area: browse/search the mirrored library as poster cards, drill from a
|
||||||
// show into seasons/episodes. Rendered by App in place of <Feed> when filters.librarySource==="plex"
|
// show into seasons/episodes. Library scope / watch-state / sort live in the left PlexSidebar; the
|
||||||
// (so the video-feed hooks never run in Plex mode). Playback lands in P2 — for now a card just
|
// search term comes from the shared top Header search box (q). A show drill-down rides history
|
||||||
// announces it; shows drill down. Self-contained: its own library scope + search + sort + paging.
|
// (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 = {
|
type Props = { q: string; library: string; show: string; sort: string };
|
||||||
filters: FeedFilters;
|
|
||||||
setFilters: (f: FeedFilters) => void;
|
|
||||||
};
|
|
||||||
|
|
||||||
const PAGE = 40;
|
const PAGE = 40;
|
||||||
|
|
||||||
function secs(n?: number | null): string {
|
function dur(n?: number | null): string {
|
||||||
if (!n) return "";
|
if (!n) return "";
|
||||||
const h = Math.floor(n / 3600);
|
const h = Math.floor(n / 3600);
|
||||||
const m = Math.floor((n % 3600) / 60);
|
const m = Math.floor((n % 3600) / 60);
|
||||||
return h ? `${h}h ${m}m` : `${m}m`;
|
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 { 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);
|
const dq = useDebounced(q.trim(), 350);
|
||||||
|
const sub = useHistorySubview<string | null>(null); // open show ratingKey (null = grid)
|
||||||
// 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 browseQ = useInfiniteQuery({
|
const browseQ = useInfiniteQuery({
|
||||||
queryKey: ["plex-browse", library, dq, sort],
|
queryKey: ["plex-browse", library, dq, sort, show],
|
||||||
enabled: !!library && !openShow,
|
enabled: !!library && sub.view === null,
|
||||||
initialPageParam: 0,
|
initialPageParam: 0,
|
||||||
queryFn: ({ pageParam }) =>
|
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) => {
|
getNextPageParam: (last) => {
|
||||||
const seen = last.offset + last.items.length;
|
const seen = last.offset + last.items.length;
|
||||||
return seen < last.total ? seen : undefined;
|
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 items = browseQ.data?.pages.flatMap((p) => p.items) ?? [];
|
||||||
const total = browseQ.data?.pages[0]?.total ?? 0;
|
const total = browseQ.data?.pages[0]?.total ?? 0;
|
||||||
|
|
||||||
function exitPlex() {
|
// Infinite scroll: auto-load the next page when the sentinel scrolls into view.
|
||||||
setFilters({ ...filters, librarySource: "organic", q: "" });
|
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) {
|
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 }) });
|
notify({ level: "info", message: t("plex.playerSoon", { title: card.title }) });
|
||||||
}
|
}
|
||||||
function onCard(card: PlexCard) {
|
function onCard(card: PlexCard) {
|
||||||
if (card.type === "show") setOpenShow(card.id);
|
if (card.type === "show") sub.open(card.id);
|
||||||
else onPlay(card);
|
else onPlay(card);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (openShow) {
|
if (sub.view) {
|
||||||
return <PlexShowView showId={openShow} onBack={() => setOpenShow(null)} onPlay={onPlay} onExit={exitPlex} />;
|
return <PlexShowView showId={sub.view} onBack={sub.back} onPlay={onPlay} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="p-4 max-w-[1600px] mx-auto">
|
<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">
|
<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>
|
</p>
|
||||||
|
|
||||||
{browseQ.isLoading ? (
|
{browseQ.isLoading ? (
|
||||||
<p className="text-muted text-sm">{t("plex.loading")}</p>
|
<p className="text-muted text-sm">{t("plex.loading")}</p>
|
||||||
) : items.length === 0 ? (
|
) : 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">
|
<div className="grid grid-cols-[repeat(auto-fill,minmax(150px,1fr))] gap-3">
|
||||||
{items.map((c) => (
|
{items.map((c) => (
|
||||||
|
|
@ -137,17 +90,8 @@ export default function PlexBrowse({ filters, setFilters }: Props) {
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{browseQ.hasNextPage && (
|
<div ref={sentinel} className="h-8" />
|
||||||
<div className="flex justify-center mt-5">
|
{isFetchingNextPage && <p className="text-center text-xs text-muted pb-4">{t("plex.loading")}</p>}
|
||||||
<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>
|
</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))
|
? Math.min(100, Math.round(((card.position_seconds ?? 0) / card.duration_seconds) * 100))
|
||||||
: 0;
|
: 0;
|
||||||
return (
|
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">
|
<div className="relative aspect-[2/3] rounded-xl overflow-hidden bg-card border border-border">
|
||||||
<img
|
<img
|
||||||
src={card.thumb}
|
src={card.thumb}
|
||||||
alt=""
|
alt=""
|
||||||
loading="lazy"
|
loading="lazy"
|
||||||
|
decoding="async"
|
||||||
className="w-full h-full object-cover group-hover:scale-105 transition-transform duration-200"
|
className="w-full h-full object-cover group-hover:scale-105 transition-transform duration-200"
|
||||||
/>
|
/>
|
||||||
{card.status === "watched" && (
|
{card.status === "watched" && (
|
||||||
|
|
@ -197,9 +147,7 @@ function PlexPosterCard({ card, onClick }: { card: PlexCard; onClick: () => void
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-1.5 text-sm font-medium line-clamp-2 leading-tight">{card.title}</div>
|
<div className="mt-1.5 text-sm font-medium line-clamp-2 leading-tight">{card.title}</div>
|
||||||
<div className="text-xs text-muted">
|
<div className="text-xs text-muted">{[card.year, dur(card.duration_seconds)].filter(Boolean).join(" · ")}</div>
|
||||||
{[card.year, secs(card.duration_seconds)].filter(Boolean).join(" · ")}
|
|
||||||
</div>
|
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -208,12 +156,10 @@ function PlexShowView({
|
||||||
showId,
|
showId,
|
||||||
onBack,
|
onBack,
|
||||||
onPlay,
|
onPlay,
|
||||||
onExit,
|
|
||||||
}: {
|
}: {
|
||||||
showId: string;
|
showId: string;
|
||||||
onBack: () => void;
|
onBack: () => void;
|
||||||
onPlay: (c: PlexCard) => void;
|
onPlay: (c: PlexCard) => void;
|
||||||
onExit: () => void;
|
|
||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const q = useQuery({ queryKey: ["plex-show", showId], queryFn: () => api.plexShow(showId) });
|
const q = useQuery({ queryKey: ["plex-show", showId], queryFn: () => api.plexShow(showId) });
|
||||||
|
|
@ -221,18 +167,13 @@ function PlexShowView({
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="p-4 max-w-[1200px] mx-auto">
|
<div className="p-4 max-w-[1200px] mx-auto">
|
||||||
<div className="flex items-center gap-2 mb-4">
|
<button
|
||||||
<button
|
onClick={onBack}
|
||||||
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"
|
||||||
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" />
|
||||||
<ArrowLeft className="w-4 h-4" />
|
{t("plex.backToLibrary")}
|
||||||
{t("plex.backToLibrary")}
|
</button>
|
||||||
</button>
|
|
||||||
<button onClick={onExit} className="text-xs text-muted hover:text-fg ml-1">
|
|
||||||
{t("plex.backToFeed")}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{q.isLoading || !d ? (
|
{q.isLoading || !d ? (
|
||||||
<p className="text-muted text-sm">{t("plex.loading")}</p>
|
<p className="text-muted text-sm">{t("plex.loading")}</p>
|
||||||
|
|
@ -282,7 +223,7 @@ function PlexShowView({
|
||||||
{ep.summary && (
|
{ep.summary && (
|
||||||
<div className="text-xs text-muted line-clamp-2 mt-0.5">{ep.summary}</div>
|
<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>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
135
frontend/src/components/PlexSidebar.tsx
Normal file
135
frontend/src/components/PlexSidebar.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
{
|
{
|
||||||
|
"navLabel": "Plex",
|
||||||
"backToFeed": "YouTube-Feed",
|
"backToFeed": "YouTube-Feed",
|
||||||
"backToLibrary": "Zurück zur Bibliothek",
|
"backToLibrary": "Zurück zur Bibliothek",
|
||||||
"searchPlaceholder": "Plex durchsuchen…",
|
"searchPlaceholder": "Plex durchsuchen…",
|
||||||
|
|
@ -6,8 +7,20 @@
|
||||||
"added": "Kürzlich hinzugefügt",
|
"added": "Kürzlich hinzugefügt",
|
||||||
"title": "Titel"
|
"title": "Titel"
|
||||||
},
|
},
|
||||||
|
"filter": {
|
||||||
|
"library": "Bibliothek",
|
||||||
|
"show": "Anzeige",
|
||||||
|
"showOpt": {
|
||||||
|
"all": "Alle",
|
||||||
|
"unwatched": "Ungesehen",
|
||||||
|
"in_progress": "Läuft",
|
||||||
|
"watched": "Gesehen"
|
||||||
|
},
|
||||||
|
"sort": "Sortierung"
|
||||||
|
},
|
||||||
"count": "{{count}} Titel",
|
"count": "{{count}} Titel",
|
||||||
"searchCount": "{{count}} Treffer",
|
"searchCount": "{{count}} Treffer",
|
||||||
|
"noMatches": "Keine Treffer.",
|
||||||
"loading": "Wird geladen…",
|
"loading": "Wird geladen…",
|
||||||
"empty": "Noch nichts hier. Starte eine Plex-Synchronisierung auf der Admin-Konfigurationsseite.",
|
"empty": "Noch nichts hier. Starte eine Plex-Synchronisierung auf der Admin-Konfigurationsseite.",
|
||||||
"loadMore": "Mehr laden",
|
"loadMore": "Mehr laden",
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
{
|
{
|
||||||
|
"navLabel": "Plex",
|
||||||
"backToFeed": "YouTube feed",
|
"backToFeed": "YouTube feed",
|
||||||
"backToLibrary": "Back to library",
|
"backToLibrary": "Back to library",
|
||||||
"searchPlaceholder": "Search Plex…",
|
"searchPlaceholder": "Search Plex…",
|
||||||
|
|
@ -6,8 +7,20 @@
|
||||||
"added": "Recently added",
|
"added": "Recently added",
|
||||||
"title": "Title"
|
"title": "Title"
|
||||||
},
|
},
|
||||||
|
"filter": {
|
||||||
|
"library": "Library",
|
||||||
|
"show": "Show",
|
||||||
|
"showOpt": {
|
||||||
|
"all": "All",
|
||||||
|
"unwatched": "Unwatched",
|
||||||
|
"in_progress": "In progress",
|
||||||
|
"watched": "Watched"
|
||||||
|
},
|
||||||
|
"sort": "Sort"
|
||||||
|
},
|
||||||
"count": "{{count}} titles",
|
"count": "{{count}} titles",
|
||||||
"searchCount": "{{count}} matches",
|
"searchCount": "{{count}} matches",
|
||||||
|
"noMatches": "No matches.",
|
||||||
"loading": "Loading…",
|
"loading": "Loading…",
|
||||||
"empty": "Nothing here yet. Run a Plex sync from the admin Config page.",
|
"empty": "Nothing here yet. Run a Plex sync from the admin Config page.",
|
||||||
"loadMore": "Load more",
|
"loadMore": "Load more",
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
{
|
{
|
||||||
|
"navLabel": "Plex",
|
||||||
"backToFeed": "YouTube feed",
|
"backToFeed": "YouTube feed",
|
||||||
"backToLibrary": "Vissza a könyvtárhoz",
|
"backToLibrary": "Vissza a könyvtárhoz",
|
||||||
"searchPlaceholder": "Keresés a Plexben…",
|
"searchPlaceholder": "Keresés a Plexben…",
|
||||||
|
|
@ -6,8 +7,20 @@
|
||||||
"added": "Nemrég hozzáadott",
|
"added": "Nemrég hozzáadott",
|
||||||
"title": "Cím"
|
"title": "Cím"
|
||||||
},
|
},
|
||||||
|
"filter": {
|
||||||
|
"library": "Könyvtár",
|
||||||
|
"show": "Megjelenítés",
|
||||||
|
"showOpt": {
|
||||||
|
"all": "Mind",
|
||||||
|
"unwatched": "Nem nézett",
|
||||||
|
"in_progress": "Folyamatban",
|
||||||
|
"watched": "Megnézett"
|
||||||
|
},
|
||||||
|
"sort": "Rendezés"
|
||||||
|
},
|
||||||
"count": "{{count}} cím",
|
"count": "{{count}} cím",
|
||||||
"searchCount": "{{count}} találat",
|
"searchCount": "{{count}} találat",
|
||||||
|
"noMatches": "Nincs találat.",
|
||||||
"loading": "Betöltés…",
|
"loading": "Betöltés…",
|
||||||
"empty": "Még nincs itt semmi. Futtass egy Plex-szinkront az admin Konfiguráció oldalról.",
|
"empty": "Még nincs itt semmi. Futtass egy Plex-szinkront az admin Konfiguráció oldalról.",
|
||||||
"loadMore": "Több betöltése",
|
"loadMore": "Több betöltése",
|
||||||
|
|
|
||||||
|
|
@ -992,12 +992,14 @@ export const api = {
|
||||||
library: string;
|
library: string;
|
||||||
q?: string;
|
q?: string;
|
||||||
sort?: string;
|
sort?: string;
|
||||||
|
show?: string;
|
||||||
offset?: number;
|
offset?: number;
|
||||||
limit?: number;
|
limit?: number;
|
||||||
}): Promise<PlexBrowseResult> => {
|
}): Promise<PlexBrowseResult> => {
|
||||||
const u = new URLSearchParams({ library: p.library });
|
const u = new URLSearchParams({ library: p.library });
|
||||||
if (p.q) u.set("q", p.q);
|
if (p.q) u.set("q", p.q);
|
||||||
if (p.sort) u.set("sort", p.sort);
|
if (p.sort) u.set("sort", p.sort);
|
||||||
|
if (p.show && p.show !== "all") u.set("show", p.show);
|
||||||
if (p.offset) u.set("offset", String(p.offset));
|
if (p.offset) u.set("offset", String(p.offset));
|
||||||
if (p.limit) u.set("limit", String(p.limit));
|
if (p.limit) u.set("limit", String(p.limit));
|
||||||
return req(`/api/plex/browse?${u.toString()}`);
|
return req(`/api/plex/browse?${u.toString()}`);
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,8 @@ export function pageTitleKey(page: Page): string {
|
||||||
return "downloads.navLabel";
|
return "downloads.navLabel";
|
||||||
case "stats":
|
case "stats":
|
||||||
return "header.usageStats";
|
return "header.usageStats";
|
||||||
|
case "plex":
|
||||||
|
return "plex.navLabel";
|
||||||
case "scheduler":
|
case "scheduler":
|
||||||
return "header.scheduler";
|
return "header.scheduler";
|
||||||
case "config":
|
case "config":
|
||||||
|
|
|
||||||
|
|
@ -23,6 +23,9 @@ export const LS = {
|
||||||
filterCollapsed: "siftlode.filterCollapsed",
|
filterCollapsed: "siftlode.filterCollapsed",
|
||||||
playlist: "siftlode.playlist",
|
playlist: "siftlode.playlist",
|
||||||
plSort: "siftlode.plSort",
|
plSort: "siftlode.plSort",
|
||||||
|
plexLibrary: "siftlode.plexLibrary",
|
||||||
|
plexShow: "siftlode.plexShow",
|
||||||
|
plexSort: "siftlode.plexSort",
|
||||||
notifHistory: "siftlode.notifications",
|
notifHistory: "siftlode.notifications",
|
||||||
notifSettings: "siftlode.notifSettings",
|
notifSettings: "siftlode.notifSettings",
|
||||||
onboardingDismissed: "siftlode.onboarding.dismissed",
|
onboardingDismissed: "siftlode.onboarding.dismissed",
|
||||||
|
|
|
||||||
|
|
@ -101,6 +101,7 @@ export const PAGES = [
|
||||||
"notifications",
|
"notifications",
|
||||||
"messages",
|
"messages",
|
||||||
"downloads",
|
"downloads",
|
||||||
|
"plex",
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
export type Page = (typeof PAGES)[number];
|
export type Page = (typeof PAGES)[number];
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue