Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
00bf9a39f7 | ||
|
|
640a8efeed | ||
|
|
23ac884b42 | ||
|
|
1579a81395 | ||
|
|
4fe6f38f1b | ||
|
|
94a608053a | ||
|
|
aea99cc71a | ||
|
|
180ab56379 | ||
|
|
0b592345e6 | ||
|
|
4800df4208 | ||
|
|
8bfc01684f | ||
|
|
48aa1a25f5 |
+28
-51
@@ -4,7 +4,7 @@ import { useTranslation } from "react-i18next";
|
|||||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import { api, getActiveAccount, setActiveAccount, setUnauthorizedHandler } from "./lib/api";
|
import { api, getActiveAccount, setActiveAccount, setUnauthorizedHandler } from "./lib/api";
|
||||||
import { setLanguage, isSupported, type LangCode } from "./i18n";
|
import { setLanguage, isSupported, type LangCode } from "./i18n";
|
||||||
import { hasFilterParams, stripUrlParams } from "./lib/urlState";
|
import { clearParams } from "./lib/urlState";
|
||||||
import type { Page } from "./lib/urlState";
|
import type { Page } from "./lib/urlState";
|
||||||
import {
|
import {
|
||||||
loadLayout,
|
loadLayout,
|
||||||
@@ -15,7 +15,8 @@ import {
|
|||||||
type PanelLayout,
|
type PanelLayout,
|
||||||
} from "./lib/panelLayout";
|
} from "./lib/panelLayout";
|
||||||
import { notify, removeByMetaKind } from "./lib/notifications";
|
import { notify, removeByMetaKind } from "./lib/notifications";
|
||||||
import { LS, readAccount, setAccountRaw, writeAccount } from "./lib/storage";
|
import { LS, setAccountRaw } from "./lib/storage";
|
||||||
|
import { usePref } from "./lib/usePref";
|
||||||
import { useNavigation, useNavigationActions } from "./components/NavigationProvider";
|
import { useNavigation, useNavigationActions } from "./components/NavigationProvider";
|
||||||
import { useFeedFilters, useFeedFiltersActions } from "./components/FeedFiltersProvider";
|
import { useFeedFilters, useFeedFiltersActions } from "./components/FeedFiltersProvider";
|
||||||
import { useChannelLayout } from "./components/ChannelsProvider";
|
import { useChannelLayout } from "./components/ChannelsProvider";
|
||||||
@@ -90,37 +91,20 @@ export default function App() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Collapse state for the two full-height panels (left nav + filter sidebar). Persisted to the
|
// Collapse state for the two full-height panels (left nav + filter sidebar). Persisted to the
|
||||||
// user's preferences so it follows the account across devices; a localStorage cache seeds the
|
// user's preferences (via usePref) so it follows the account across devices; the localStorage mirror
|
||||||
// initial render synchronously so nothing flashes open before the server prefs arrive.
|
// seeds the initial render synchronously so nothing flashes open before the server prefs arrive.
|
||||||
// Default unpinned (tab + hover-expand overlay); accounts that explicitly pin keep their choice.
|
// Default unpinned (tab + hover-expand overlay); accounts that explicitly pin keep their choice.
|
||||||
const [filterCollapsed, setFilterCollapsedState] = useState<boolean>(() =>
|
const [filterCollapsed, setFilterCollapsed] = usePref<boolean>("filterCollapsed", true, {
|
||||||
Boolean(readAccount(LS.filterCollapsed, true)),
|
localKey: LS.filterCollapsed,
|
||||||
);
|
parse: Boolean,
|
||||||
|
});
|
||||||
// The Playlists rail has its own collapse flag (it's a list/navigator, not filters).
|
// The Playlists rail has its own collapse flag (it's a list/navigator, not filters).
|
||||||
const [playlistsCollapsed, setPlaylistsCollapsedState] = useState<boolean>(() =>
|
const [playlistsCollapsed, setPlaylistsCollapsed] = usePref<boolean>("playlistsCollapsed", true, {
|
||||||
Boolean(readAccount(LS.playlistsCollapsed, true)),
|
localKey: LS.playlistsCollapsed,
|
||||||
);
|
parse: Boolean,
|
||||||
function setFilterCollapsed(next: boolean) {
|
});
|
||||||
setFilterCollapsedState(next);
|
// (The feed filters + page/channel now LIVE in the address bar — FeedFiltersProvider and
|
||||||
writeAccount(LS.filterCollapsed, next);
|
// NavigationProvider read them on load and keep them mirrored — so there's no query to strip here.)
|
||||||
api.savePrefs({ filterCollapsed: next }).catch(() => {});
|
|
||||||
}
|
|
||||||
function setPlaylistsCollapsed(next: boolean) {
|
|
||||||
setPlaylistsCollapsedState(next);
|
|
||||||
writeAccount(LS.playlistsCollapsed, next);
|
|
||||||
api.savePrefs({ playlistsCollapsed: next }).catch(() => {});
|
|
||||||
}
|
|
||||||
// Filters live in localStorage, not the address bar. If we arrived via a "Share view"
|
|
||||||
// link, its params have already hydrated the initial state — persist them and strip the
|
|
||||||
// query so the URL stays clean from here on.
|
|
||||||
useEffect(() => {
|
|
||||||
const params = new URLSearchParams(window.location.search);
|
|
||||||
if (hasFilterParams(params) || params.has("page")) {
|
|
||||||
// A share link's filters are held in state; they're persisted to the account's key by the
|
|
||||||
// post-login effect (which knows the account id). Here we only clean the address bar.
|
|
||||||
stripUrlParams();
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
// First-run install gate: a fresh instance reports configured=false and runs in setup mode.
|
// First-run install gate: a fresh instance reports configured=false and runs in setup mode.
|
||||||
@@ -152,11 +136,11 @@ export default function App() {
|
|||||||
if (meQuery.data && getActiveAccount() == null) setActiveAccount(meQuery.data.id);
|
if (meQuery.data && getActiveAccount() == null) setActiveAccount(meQuery.data.id);
|
||||||
}, [meQuery.data?.id]);
|
}, [meQuery.data?.id]);
|
||||||
|
|
||||||
// Once signed in, drop any leftover query string from the pre-login flow (e.g.
|
// Once signed in, drop any leftover ONE-TIME query params from the pre-login flow (?access, ?verify,
|
||||||
// ?access=requested, ?verify, ?reset). The logged-in app reads nothing from the URL, so the
|
// ?reset) — but NOT the nav/filter params, which the logged-in app now reads from and keeps in the
|
||||||
// address bar stays clean. Gated on auth so the logged-out Welcome page still reads its params.
|
// URL. Gated on auth so the logged-out Welcome page still reads its params first.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (meQuery.data) stripUrlParams();
|
if (meQuery.data) clearParams("access", "verify", "reset");
|
||||||
}, [meQuery.data?.id]); // eslint-disable-line react-hooks/exhaustive-deps
|
}, [meQuery.data?.id]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||||
|
|
||||||
// Any request that 401s means the session ended server-side. If we were signed in (cached
|
// Any request that 401s means the session ended server-side. If we were signed in (cached
|
||||||
@@ -188,7 +172,7 @@ export default function App() {
|
|||||||
const key = result === "conflict" || result === "mismatch" ? result : "error";
|
const key = result === "conflict" || result === "mismatch" ? result : "error";
|
||||||
notify({ level: "error", message: t(`settings.account.googleLink.${key}`) });
|
notify({ level: "error", message: t(`settings.account.googleLink.${key}`) });
|
||||||
}
|
}
|
||||||
stripUrlParams();
|
clearParams("link");
|
||||||
}, []); // eslint-disable-line react-hooks/exhaustive-deps
|
}, []); // eslint-disable-line react-hooks/exhaustive-deps
|
||||||
|
|
||||||
// A Google OAuth flow started while already signed in (add-another-account, link, or upgrade) was
|
// A Google OAuth flow started while already signed in (add-another-account, link, or upgrade) was
|
||||||
@@ -197,12 +181,12 @@ export default function App() {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (new URLSearchParams(window.location.search).get("oauth") !== "cancelled") return;
|
if (new URLSearchParams(window.location.search).get("oauth") !== "cancelled") return;
|
||||||
notify({ level: "info", message: t("settings.account.googleCancelled") });
|
notify({ level: "info", message: t("settings.account.googleCancelled") });
|
||||||
stripUrlParams();
|
clearParams("oauth");
|
||||||
}, []); // eslint-disable-line react-hooks/exhaustive-deps
|
}, []); // eslint-disable-line react-hooks/exhaustive-deps
|
||||||
|
|
||||||
// Deep-link from the "new access request" admin email (?admin=access-requests) → jump straight to
|
// Deep-link from the "new access request" admin email (?admin=access-requests) → jump straight to
|
||||||
// the admin Users → Access requests view. Snapshot the param so the pre-login URL strip doesn't
|
// the admin Users → Access requests view. Snapshot the param up front (read once at mount); act once
|
||||||
// drop it; act once `me` is known, and only for an admin (others just land on their default page).
|
// `me` is known, and only for an admin (others just land on their default page).
|
||||||
const [adminDeepLink] = useState(() => new URLSearchParams(window.location.search).get("admin"));
|
const [adminDeepLink] = useState(() => new URLSearchParams(window.location.search).get("admin"));
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (adminDeepLink !== "access-requests" || !meQuery.data) return;
|
if (adminDeepLink !== "access-requests" || !meQuery.data) return;
|
||||||
@@ -210,12 +194,13 @@ export default function App() {
|
|||||||
focusAccessRequestsTab();
|
focusAccessRequestsTab();
|
||||||
setPage("users");
|
setPage("users");
|
||||||
}
|
}
|
||||||
stripUrlParams();
|
clearParams("admin");
|
||||||
}, [adminDeepLink, meQuery.data?.id]); // eslint-disable-line react-hooks/exhaustive-deps
|
}, [adminDeepLink, meQuery.data?.id]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||||
|
|
||||||
// On login, adopt the server-stored SHELL preferences (panel layouts, rail collapse flags,
|
// On login, adopt the server-stored SHELL preferences (panel layouts + language). The rail-collapse
|
||||||
// language). The Settings-page prefs (theme/perf/hints/notifications) are adopted by PrefsProvider
|
// flags (filterCollapsed / playlistsCollapsed) are adopted by their own usePref hooks, the
|
||||||
// and the feed view by FeedViewProvider — each via its own passive qk.me() observer.
|
// Settings-page prefs (theme/perf/hints/notifications) by PrefsProvider, and the feed view by
|
||||||
|
// FeedViewProvider — each via its own passive qk.me() observer.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const prefs = meQuery.data?.preferences;
|
const prefs = meQuery.data?.preferences;
|
||||||
if (!prefs) return;
|
if (!prefs) return;
|
||||||
@@ -227,14 +212,6 @@ export default function App() {
|
|||||||
saveLayoutLocal(p, l);
|
saveLayoutLocal(p, l);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
if (typeof prefs.filterCollapsed === "boolean") {
|
|
||||||
setFilterCollapsedState(prefs.filterCollapsed);
|
|
||||||
writeAccount(LS.filterCollapsed, prefs.filterCollapsed);
|
|
||||||
}
|
|
||||||
if (typeof prefs.playlistsCollapsed === "boolean") {
|
|
||||||
setPlaylistsCollapsedState(prefs.playlistsCollapsed);
|
|
||||||
writeAccount(LS.playlistsCollapsed, prefs.playlistsCollapsed);
|
|
||||||
}
|
|
||||||
const lang = prefs.language;
|
const lang = prefs.language;
|
||||||
if (typeof lang === "string" && isSupported(lang)) setLanguage(lang);
|
if (typeof lang === "string" && isSupported(lang)) setLanguage(lang);
|
||||||
// (Per-account filters — incl. the demo account's whole-library default — are loaded by the
|
// (Per-account filters — incl. the demo account's whole-library default — are loaded by the
|
||||||
|
|||||||
@@ -1,14 +1,11 @@
|
|||||||
import { lazy, Suspense, useEffect, useMemo, useState } from "react";
|
import { lazy, Suspense, useEffect, useState } from "react";
|
||||||
import { qk } from "../lib/queryKeys";
|
import { qk } from "../lib/queryKeys";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import {
|
import {
|
||||||
Ban,
|
Ban,
|
||||||
CalendarClock,
|
|
||||||
ClipboardPaste,
|
ClipboardPaste,
|
||||||
Copy,
|
|
||||||
Download,
|
Download,
|
||||||
Link2,
|
|
||||||
Pause,
|
Pause,
|
||||||
Play,
|
Play,
|
||||||
Plus,
|
Plus,
|
||||||
@@ -19,11 +16,9 @@ import {
|
|||||||
Pencil,
|
Pencil,
|
||||||
Trash2,
|
Trash2,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import clsx from "clsx";
|
|
||||||
import Tabs from "./Tabs";
|
import Tabs from "./Tabs";
|
||||||
import { PageToolbar } from "./PageShell";
|
import { PageToolbar } from "./PageShell";
|
||||||
import { LoadingState, StateMessage } from "./QueryState";
|
import { LoadingState, StateMessage } from "./QueryState";
|
||||||
import Modal from "./Modal";
|
|
||||||
// Lazy: these dialogs/editors open on demand, so they stay out of the Downloads page chunk until
|
// Lazy: these dialogs/editors open on demand, so they stay out of the Downloads page chunk until
|
||||||
// used (the video editor in particular is heavy — filmstrip + scrubber).
|
// used (the video editor in particular is heavy — filmstrip + scrubber).
|
||||||
const DownloadDialog = lazy(() => import("./DownloadDialog"));
|
const DownloadDialog = lazy(() => import("./DownloadDialog"));
|
||||||
@@ -35,734 +30,10 @@ import { useLiveQuery } from "../lib/useLiveQuery";
|
|||||||
import { useMe } from "../lib/useMe";
|
import { useMe } from "../lib/useMe";
|
||||||
import { api, type DownloadJob } from "../lib/api";
|
import { api, type DownloadJob } from "../lib/api";
|
||||||
import { invalidateDownloads } from "../lib/downloads";
|
import { invalidateDownloads } from "../lib/downloads";
|
||||||
import { notify } from "../lib/notifications";
|
|
||||||
import { copyText } from "../lib/clipboard";
|
|
||||||
import { formatBytes, formatDuration, formatEta, formatSpeed, relativeTime } from "../lib/format";
|
|
||||||
import { inputCls } from "./ui/form";
|
import { inputCls } from "./ui/form";
|
||||||
|
import { DownloadRow, IconBtn, UsageBar } from "./downloadRow";
|
||||||
const GiB = 1024 ** 3;
|
import { MetaEditModal, ErrorModal } from "./downloadModals";
|
||||||
|
import { AdminSystem } from "./downloadAdmin";
|
||||||
const STATUS_CLS: Record<string, string> = {
|
|
||||||
queued: "text-muted",
|
|
||||||
running: "text-accent",
|
|
||||||
paused: "text-warn",
|
|
||||||
done: "text-success",
|
|
||||||
error: "text-danger",
|
|
||||||
canceled: "text-muted",
|
|
||||||
};
|
|
||||||
|
|
||||||
// Byte-progress phases (show a % bar) vs ffmpeg post-steps (no %, indeterminate pulse).
|
|
||||||
// "editing" (the phase-2 editor) reports a real % from ffmpeg's out_time, so it gets a bar.
|
|
||||||
const DOWNLOAD_PHASES = ["video", "audio", "editing"];
|
|
||||||
const PHASE_KEYS = [
|
|
||||||
"video",
|
|
||||||
"audio",
|
|
||||||
"editing",
|
|
||||||
"merging",
|
|
||||||
"audio_extract",
|
|
||||||
"thumbnail",
|
|
||||||
"sponsorblock",
|
|
||||||
"metadata",
|
|
||||||
"processing",
|
|
||||||
];
|
|
||||||
|
|
||||||
function StatusPill({ job }: { job: DownloadJob }) {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
// While downloading, show the concrete phase (video / audio / merging) so the user isn't
|
|
||||||
// confused by the bar resetting between the separate video and audio streams.
|
|
||||||
if (job.status === "running" && job.phase && PHASE_KEYS.includes(job.phase)) {
|
|
||||||
return (
|
|
||||||
<span className="text-xs font-medium text-accent">{t(`downloads.phase.${job.phase}`)}</span>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
const key = job.expired ? "expired" : job.status;
|
|
||||||
return (
|
|
||||||
<span className={clsx("text-xs font-medium", STATUS_CLS[job.status] ?? "text-muted")}>
|
|
||||||
{t(`downloads.status.${key}`)}
|
|
||||||
</span>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function Thumb({ job }: { job: DownloadJob }) {
|
|
||||||
// Prefer OUR self-hosted poster: it's always present on disk, never expires, and is same-origin, so
|
|
||||||
// a source-CDN signature expiry (Facebook's fbcdn URLs expire within days) or an ad-blocker/Shields
|
|
||||||
// rule on the source domain can't break it. The source's remote thumbnail_url is only a fallback for
|
|
||||||
// the rare poster-less asset (a thumbnail-less source whose ffmpeg frame-cut also failed).
|
|
||||||
const remote = job.asset?.thumbnail_url ?? null;
|
|
||||||
// `job.poster_url` only SIGNALS a poster exists; the actual <img> src must carry ?account= (see
|
|
||||||
// api.posterImageUrl). A plain <img> can't send the X-Siftlode-Account header, so without it the
|
|
||||||
// poster resolves to the session-default account and 404s for a per-tab account's download — which,
|
|
||||||
// now that the poster is the primary image, reads as a blank Library thumbnail.
|
|
||||||
const poster = job.poster_url ? api.posterImageUrl(job.id) : null;
|
|
||||||
const primary = poster || remote;
|
|
||||||
// On load failure (a genuinely missing poster file) fall back to the remote source thumbnail.
|
|
||||||
const [src, setSrc] = useState(primary);
|
|
||||||
useEffect(() => setSrc(primary), [primary]);
|
|
||||||
return (
|
|
||||||
<div className="w-28 aspect-video shrink-0 rounded-lg overflow-hidden bg-surface border border-border">
|
|
||||||
{src ? (
|
|
||||||
<img
|
|
||||||
src={src}
|
|
||||||
alt=""
|
|
||||||
loading="lazy"
|
|
||||||
className="w-full h-full object-cover"
|
|
||||||
onError={() => {
|
|
||||||
if (remote && src !== remote) setSrc(remote);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function jobTitle(job: DownloadJob): string {
|
|
||||||
return job.display_name || job.asset?.title || job.source_ref;
|
|
||||||
}
|
|
||||||
|
|
||||||
// The channel to show under the title: a user override wins over the auto-extracted asset value.
|
|
||||||
// `url` is null for sources without a channel link (e.g. reddit) — then it renders as plain text.
|
|
||||||
function jobChannel(job: DownloadJob): { name: string; url: string | null } {
|
|
||||||
return {
|
|
||||||
name: job.display_uploader || job.asset?.uploader || job.source_ref,
|
|
||||||
url: job.display_uploader_url || job.asset?.uploader_url || null,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function IconBtn({
|
|
||||||
onClick,
|
|
||||||
title,
|
|
||||||
danger,
|
|
||||||
children,
|
|
||||||
}: {
|
|
||||||
onClick: () => void;
|
|
||||||
title: string;
|
|
||||||
danger?: boolean;
|
|
||||||
children: React.ReactNode;
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
onClick={onClick}
|
|
||||||
title={title}
|
|
||||||
className={clsx(
|
|
||||||
"p-1.5 rounded-md hover:bg-surface text-muted transition",
|
|
||||||
danger ? "hover:text-danger" : "hover:text-fg",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{children}
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Compact display of a URL: drop the protocol + trailing slash so "youtube.com/watch?v=…" fits.
|
|
||||||
function displayUrl(url: string): string {
|
|
||||||
try {
|
|
||||||
const u = new URL(url);
|
|
||||||
return (u.host + u.pathname + u.search).replace(/\/$/, "");
|
|
||||||
} catch {
|
|
||||||
return url;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// "Downloaded from" reference: opens the original page in a new tab, or copies the link.
|
|
||||||
function SourceRef({ url }: { url: string }) {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const copy = async () => {
|
|
||||||
if (await copyText(url)) {
|
|
||||||
notify({ level: "success", message: t("downloads.source.copied") });
|
|
||||||
} else {
|
|
||||||
notify({ level: "error", message: t("downloads.source.copyFailed") });
|
|
||||||
}
|
|
||||||
};
|
|
||||||
return (
|
|
||||||
<div className="flex items-center gap-1 mt-1 text-xs text-muted min-w-0">
|
|
||||||
<Link2 className="w-3 h-3 shrink-0" />
|
|
||||||
<a
|
|
||||||
href={url}
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
title={`${t("downloads.source.open")} — ${url}`}
|
|
||||||
className="truncate hover:text-accent hover:underline"
|
|
||||||
>
|
|
||||||
{displayUrl(url)}
|
|
||||||
</a>
|
|
||||||
<button
|
|
||||||
onClick={copy}
|
|
||||||
aria-label={t("downloads.source.copy")}
|
|
||||||
title={t("downloads.source.copy")}
|
|
||||||
className="shrink-0 p-0.5 rounded-sm hover:bg-surface hover:text-fg transition"
|
|
||||||
>
|
|
||||||
<Copy className="w-3 h-3" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Channel under the title — a clickable link when a channel URL is known, plain text otherwise.
|
|
||||||
function ChannelLine({ job }: { job: DownloadJob }) {
|
|
||||||
const { name, url } = jobChannel(job);
|
|
||||||
if (url) {
|
|
||||||
return (
|
|
||||||
<div className="text-xs mt-0.5 truncate">
|
|
||||||
<a
|
|
||||||
href={url}
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
title={url}
|
|
||||||
className="text-muted hover:text-accent hover:underline"
|
|
||||||
>
|
|
||||||
{name}
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return <div className="text-xs text-muted truncate mt-0.5">{name}</div>;
|
|
||||||
}
|
|
||||||
|
|
||||||
function DownloadRow({
|
|
||||||
job,
|
|
||||||
onShowError,
|
|
||||||
children,
|
|
||||||
}: {
|
|
||||||
job: DownloadJob;
|
|
||||||
onShowError?: (job: DownloadJob) => void;
|
|
||||||
children?: React.ReactNode;
|
|
||||||
}) {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const running = job.status === "running";
|
|
||||||
return (
|
|
||||||
<div className="flex gap-3 p-2.5 rounded-xl glass-card glass-hover transition">
|
|
||||||
<Thumb job={job} />
|
|
||||||
<div className="min-w-0 flex-1">
|
|
||||||
<div className="font-medium leading-snug line-clamp-1 flex items-center gap-1.5">
|
|
||||||
{job.job_kind === "edit" && (
|
|
||||||
<span className="inline-flex items-center gap-1 shrink-0 px-1.5 py-0.5 rounded-sm text-3xs font-semibold uppercase tracking-wide bg-accent/15 text-accent">
|
|
||||||
<Scissors className="w-3 h-3" /> {t("downloads.clipBadge")}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
<span className="truncate">{jobTitle(job)}</span>
|
|
||||||
</div>
|
|
||||||
<ChannelLine job={job} />
|
|
||||||
<div className="flex items-center gap-2 mt-1 text-xs">
|
|
||||||
<StatusPill job={job} />
|
|
||||||
{job.asset?.size_bytes ? (
|
|
||||||
<span className="text-muted">· {formatBytes(job.asset.size_bytes)}</span>
|
|
||||||
) : null}
|
|
||||||
{job.asset?.duration_s ? (
|
|
||||||
<span className="text-muted">· {formatDuration(job.asset.duration_s)}</span>
|
|
||||||
) : null}
|
|
||||||
{job.status === "done" && job.created_at ? (
|
|
||||||
<span
|
|
||||||
className="text-muted inline-flex items-center gap-1"
|
|
||||||
title={new Date(job.created_at).toLocaleString()}
|
|
||||||
>
|
|
||||||
· <CalendarClock className="w-3 h-3" /> {relativeTime(job.created_at)}
|
|
||||||
</span>
|
|
||||||
) : null}
|
|
||||||
{job.status === "error" ? (
|
|
||||||
<button
|
|
||||||
onClick={() => onShowError?.(job)}
|
|
||||||
className="text-danger hover:opacity-80 inline-flex items-center gap-1 truncate"
|
|
||||||
title={t("downloads.errors.fail.title")}
|
|
||||||
>
|
|
||||||
· {t(`downloads.errors.fail.cat.${job.error_code || "unknown"}.short`)}
|
|
||||||
<span className="text-muted underline">{t("downloads.errors.fail.detailsLink")}</span>
|
|
||||||
</button>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
{job.source_url && <SourceRef url={job.source_url} />}
|
|
||||||
{job.extra_links?.map((url) => (
|
|
||||||
<SourceRef key={url} url={url} />
|
|
||||||
))}
|
|
||||||
{running && (
|
|
||||||
<div className="mt-1.5">
|
|
||||||
{job.phase && !DOWNLOAD_PHASES.includes(job.phase) ? (
|
|
||||||
// ffmpeg post-steps aren't byte-progress — show an indeterminate pulse, no %.
|
|
||||||
<div className="h-1.5 rounded-full bg-surface overflow-hidden">
|
|
||||||
<div className="h-full w-full bg-accent/60 animate-pulse" />
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<div className="h-1.5 rounded-full bg-surface overflow-hidden">
|
|
||||||
<div
|
|
||||||
className="h-full bg-accent transition-all"
|
|
||||||
style={{ width: `${job.progress}%` }}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="text-2xs text-muted mt-0.5 flex gap-2">
|
|
||||||
<span>{job.progress}%</span>
|
|
||||||
{job.speed_bps ? <span>{formatSpeed(job.speed_bps)}</span> : null}
|
|
||||||
{job.eta_s ? <span>{formatEta(job.eta_s)}</span> : null}
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div className="flex items-start gap-0.5 shrink-0">{children}</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Rename + Share small modals ------------------------------------------------------------
|
|
||||||
|
|
||||||
// Edit a download's display metadata: title, channel (name + link), and extra reference links.
|
|
||||||
// All are display-only overrides — the on-disk file is never renamed.
|
|
||||||
function MetaEditModal({ job, onClose }: { job: DownloadJob; onClose: () => void }) {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const qc = useQueryClient();
|
|
||||||
const [name, setName] = useState(job.display_name || job.asset?.title || "");
|
|
||||||
const [channel, setChannel] = useState(job.display_uploader || job.asset?.uploader || "");
|
|
||||||
const [channelUrl, setChannelUrl] = useState(
|
|
||||||
job.display_uploader_url || job.asset?.uploader_url || "",
|
|
||||||
);
|
|
||||||
// Editable extra-link rows; keep one blank row so there's always somewhere to type.
|
|
||||||
const [links, setLinks] = useState<string[]>(() => [...(job.extra_links ?? []), ""]);
|
|
||||||
|
|
||||||
const setLink = (i: number, v: string) => setLinks((ls) => ls.map((l, j) => (j === i ? v : l)));
|
|
||||||
const addLink = () => setLinks((ls) => [...ls, ""]);
|
|
||||||
const removeLink = (i: number) => setLinks((ls) => ls.filter((_, j) => j !== i));
|
|
||||||
|
|
||||||
const save = useMutation({
|
|
||||||
mutationFn: () =>
|
|
||||||
api.updateDownloadMeta(job.id, {
|
|
||||||
display_name: name.trim(),
|
|
||||||
display_uploader: channel.trim(),
|
|
||||||
display_uploader_url: channelUrl.trim(),
|
|
||||||
extra_links: links.map((l) => l.trim()).filter(Boolean),
|
|
||||||
}),
|
|
||||||
onSuccess: () => {
|
|
||||||
qc.invalidateQueries({ queryKey: qk.downloads() });
|
|
||||||
onClose();
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Modal title={t("downloads.edit.title")} onClose={onClose}>
|
|
||||||
<div className="space-y-3">
|
|
||||||
<label className="block">
|
|
||||||
<span className="block text-sm font-medium mb-1">{t("downloads.edit.name")}</span>
|
|
||||||
<input
|
|
||||||
value={name}
|
|
||||||
onChange={(e) => setName(e.target.value)}
|
|
||||||
className={inputCls}
|
|
||||||
autoFocus
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
<label className="block">
|
|
||||||
<span className="block text-sm font-medium mb-1">{t("downloads.edit.channel")}</span>
|
|
||||||
<input
|
|
||||||
value={channel}
|
|
||||||
onChange={(e) => setChannel(e.target.value)}
|
|
||||||
className={inputCls}
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
<label className="block">
|
|
||||||
<span className="block text-sm font-medium mb-1">{t("downloads.edit.channelUrl")}</span>
|
|
||||||
<input
|
|
||||||
value={channelUrl}
|
|
||||||
onChange={(e) => setChannelUrl(e.target.value)}
|
|
||||||
placeholder="https://…"
|
|
||||||
className={inputCls}
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
<div>
|
|
||||||
<span className="block text-sm font-medium mb-1">{t("downloads.edit.extraLinks")}</span>
|
|
||||||
<div className="space-y-2">
|
|
||||||
{links.map((l, i) => (
|
|
||||||
<div key={i} className="flex gap-2">
|
|
||||||
<input
|
|
||||||
value={l}
|
|
||||||
onChange={(e) => setLink(i, e.target.value)}
|
|
||||||
placeholder="https://…"
|
|
||||||
className={inputCls}
|
|
||||||
/>
|
|
||||||
<IconBtn
|
|
||||||
onClick={() => removeLink(i)}
|
|
||||||
title={t("downloads.edit.removeLink")}
|
|
||||||
danger
|
|
||||||
>
|
|
||||||
<Trash2 className="w-4 h-4" />
|
|
||||||
</IconBtn>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
onClick={addLink}
|
|
||||||
className="mt-2 inline-flex items-center gap-1.5 text-sm text-muted hover:text-fg transition"
|
|
||||||
>
|
|
||||||
<Plus className="w-4 h-4" /> {t("downloads.edit.addLink")}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<p className="text-xs text-muted">{t("downloads.edit.hint")}</p>
|
|
||||||
<div className="flex justify-end">
|
|
||||||
<button
|
|
||||||
onClick={() => save.mutate()}
|
|
||||||
disabled={!name.trim() || save.isPending}
|
|
||||||
className="px-3 py-1.5 rounded-lg text-sm font-medium btn-primary disabled:opacity-50 transition"
|
|
||||||
>
|
|
||||||
{t("downloads.edit.save")}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Modal>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Error detail modal ---------------------------------------------------------------------
|
|
||||||
|
|
||||||
// Transient failures worth an in-modal Retry button; the rest are permanent (private/geo/DRM/…),
|
|
||||||
// where a retry can't help — the queue row still keeps its own retry control regardless.
|
|
||||||
const RETRYABLE_ERRORS = ["postprocess", "network", "unknown"];
|
|
||||||
|
|
||||||
// A readable "why did this fail" window for a failed job: a category headline + plain explanation,
|
|
||||||
// the raw yt-dlp/ffmpeg text behind a disclosure, and a Retry when the failure may be transient.
|
|
||||||
function ErrorModal({
|
|
||||||
job,
|
|
||||||
onRetry,
|
|
||||||
onClose,
|
|
||||||
}: {
|
|
||||||
job: DownloadJob;
|
|
||||||
onRetry: () => void;
|
|
||||||
onClose: () => void;
|
|
||||||
}) {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const code = job.error_code || "unknown";
|
|
||||||
const [showRaw, setShowRaw] = useState(false);
|
|
||||||
const retryable = RETRYABLE_ERRORS.includes(code);
|
|
||||||
return (
|
|
||||||
<Modal title={t("downloads.errors.fail.title")} onClose={onClose}>
|
|
||||||
<div className="space-y-3">
|
|
||||||
<div className="font-medium">{t(`downloads.errors.fail.cat.${code}.short`)}</div>
|
|
||||||
<p className="text-sm text-muted">{t(`downloads.errors.fail.cat.${code}.body`)}</p>
|
|
||||||
{job.error && (
|
|
||||||
<div>
|
|
||||||
<button
|
|
||||||
onClick={() => setShowRaw((v) => !v)}
|
|
||||||
className="text-xs text-muted hover:text-fg transition"
|
|
||||||
>
|
|
||||||
{t("downloads.errors.fail.detailsToggle")}
|
|
||||||
</button>
|
|
||||||
{showRaw && (
|
|
||||||
<pre className="mt-1.5 text-xs bg-surface rounded-lg p-2.5 overflow-x-auto whitespace-pre-wrap wrap-break-word max-h-40 text-muted">
|
|
||||||
{job.error}
|
|
||||||
</pre>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<div className="flex justify-end gap-2 pt-1">
|
|
||||||
{retryable && (
|
|
||||||
<button
|
|
||||||
onClick={() => {
|
|
||||||
onRetry();
|
|
||||||
onClose();
|
|
||||||
}}
|
|
||||||
className="px-3 py-1.5 rounded-lg text-sm font-medium btn-primary transition"
|
|
||||||
>
|
|
||||||
{t("downloads.actions.retry")}
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
<button
|
|
||||||
onClick={onClose}
|
|
||||||
className="px-3 py-1.5 rounded-lg text-sm text-muted hover:text-fg hover:bg-surface transition"
|
|
||||||
>
|
|
||||||
{t("common.close")}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Modal>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Usage bar ------------------------------------------------------------------------------
|
|
||||||
|
|
||||||
function UsageBar() {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
// Poll live (like the library list) so the footprint updates the moment the worker finishes an
|
|
||||||
// edit/download — enqueue only creates a queued job (0 bytes), the size lands asynchronously, so
|
|
||||||
// a one-shot fetch at enqueue time would show a stale 0 B until a manual reload.
|
|
||||||
const usage = useLiveQuery(qk.downloadUsage(), api.downloadUsage, { intervalMs: 2000 });
|
|
||||||
const u = usage.data;
|
|
||||||
if (!u) return null;
|
|
||||||
const pct =
|
|
||||||
u.unlimited || !u.max_bytes ? 0 : Math.min(100, (u.footprint_bytes / u.max_bytes) * 100);
|
|
||||||
return (
|
|
||||||
<div className="rounded-xl glass-card p-3 mb-4">
|
|
||||||
<div className="flex justify-between text-sm mb-1.5">
|
|
||||||
<span className="font-medium">{t("downloads.usage.title")}</span>
|
|
||||||
<span className="text-muted">
|
|
||||||
{formatBytes(u.footprint_bytes)}
|
|
||||||
{u.unlimited ? "" : ` / ${formatBytes(u.max_bytes)}`} ·{" "}
|
|
||||||
{u.unlimited
|
|
||||||
? t("downloads.usage.unlimited")
|
|
||||||
: t("downloads.usage.items", { used: u.active_jobs, max: u.max_jobs })}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
{!u.unlimited && (
|
|
||||||
<div className="h-2 rounded-full bg-surface overflow-hidden">
|
|
||||||
<div
|
|
||||||
className={clsx("h-full transition-all", pct > 90 ? "bg-danger" : "bg-accent")}
|
|
||||||
style={{ width: `${pct}%` }}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Admin system tab -----------------------------------------------------------------------
|
|
||||||
|
|
||||||
function QuotaModal({
|
|
||||||
userId,
|
|
||||||
email,
|
|
||||||
onClose,
|
|
||||||
}: {
|
|
||||||
userId: number;
|
|
||||||
email: string;
|
|
||||||
onClose: () => void;
|
|
||||||
}) {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const qc = useQueryClient();
|
|
||||||
const q = useQuery({
|
|
||||||
queryKey: qk.dlQuota(userId),
|
|
||||||
queryFn: () => api.adminGetDownloadQuota(userId),
|
|
||||||
});
|
|
||||||
const [form, setForm] = useState<{
|
|
||||||
gb: number;
|
|
||||||
jobs: number;
|
|
||||||
conc: number;
|
|
||||||
unlimited: boolean;
|
|
||||||
} | null>(null);
|
|
||||||
const cur = q.data;
|
|
||||||
const state =
|
|
||||||
form ??
|
|
||||||
(cur
|
|
||||||
? {
|
|
||||||
gb: Math.round((cur.max_bytes / GiB) * 10) / 10,
|
|
||||||
jobs: cur.max_jobs,
|
|
||||||
conc: cur.max_concurrent,
|
|
||||||
unlimited: cur.unlimited,
|
|
||||||
}
|
|
||||||
: null);
|
|
||||||
const done = () => {
|
|
||||||
qc.invalidateQueries({ queryKey: qk.dlQuota(userId) });
|
|
||||||
qc.invalidateQueries({ queryKey: qk.adminStorage() });
|
|
||||||
onClose();
|
|
||||||
};
|
|
||||||
const save = useMutation({
|
|
||||||
mutationFn: () =>
|
|
||||||
api.adminSetDownloadQuota(userId, {
|
|
||||||
max_bytes: Math.round(state!.gb * GiB),
|
|
||||||
max_jobs: state!.jobs,
|
|
||||||
max_concurrent: state!.conc,
|
|
||||||
unlimited: state!.unlimited,
|
|
||||||
}),
|
|
||||||
onSuccess: done,
|
|
||||||
});
|
|
||||||
const reset = useMutation({
|
|
||||||
mutationFn: () => api.adminResetDownloadQuota(userId),
|
|
||||||
onSuccess: done,
|
|
||||||
});
|
|
||||||
if (!state) return null;
|
|
||||||
const upd = (p: Partial<typeof state>) => setForm({ ...state, ...p });
|
|
||||||
return (
|
|
||||||
<Modal title={t("downloads.admin.quotaTitle", { email })} onClose={onClose}>
|
|
||||||
<div className="space-y-3">
|
|
||||||
<div className="grid grid-cols-3 gap-3">
|
|
||||||
<label className="text-sm">
|
|
||||||
{t("downloads.admin.maxBytes")}
|
|
||||||
<input
|
|
||||||
type="number"
|
|
||||||
value={state.gb}
|
|
||||||
onChange={(e) => upd({ gb: Number(e.target.value) })}
|
|
||||||
className={inputCls}
|
|
||||||
disabled={state.unlimited}
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
<label className="text-sm">
|
|
||||||
{t("downloads.admin.maxJobs")}
|
|
||||||
<input
|
|
||||||
type="number"
|
|
||||||
value={state.jobs}
|
|
||||||
onChange={(e) => upd({ jobs: Number(e.target.value) })}
|
|
||||||
className={inputCls}
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
<label className="text-sm">
|
|
||||||
{t("downloads.admin.maxConcurrent")}
|
|
||||||
<input
|
|
||||||
type="number"
|
|
||||||
value={state.conc}
|
|
||||||
onChange={(e) => upd({ conc: Number(e.target.value) })}
|
|
||||||
className={inputCls}
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={state.unlimited}
|
|
||||||
onChange={(e) => upd({ unlimited: e.target.checked })}
|
|
||||||
/>
|
|
||||||
{t("downloads.admin.unlimited")}
|
|
||||||
</label>
|
|
||||||
<div className="flex justify-between pt-1">
|
|
||||||
<button
|
|
||||||
onClick={() => reset.mutate()}
|
|
||||||
className="px-3 py-1.5 rounded-lg text-sm text-muted hover:text-fg hover:bg-surface transition"
|
|
||||||
>
|
|
||||||
{t("downloads.admin.reset")}
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() => save.mutate()}
|
|
||||||
disabled={save.isPending}
|
|
||||||
className="px-3 py-1.5 rounded-lg text-sm font-medium btn-primary disabled:opacity-50 transition"
|
|
||||||
>
|
|
||||||
{t("downloads.admin.save")}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Modal>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Pick ANY user to set a download quota for — even one who hasn't downloaded anything yet (the
|
|
||||||
// footprint list below only shows users who already have files).
|
|
||||||
function QuotaUserPicker({ onPick }: { onPick: (u: { id: number; email: string }) => void }) {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const [q, setQ] = useState("");
|
|
||||||
const [open, setOpen] = useState(false);
|
|
||||||
const users = useQuery({ queryKey: qk.adminUsers(), queryFn: api.adminUsers });
|
|
||||||
const list = (users.data ?? []).filter((u) => !u.is_demo);
|
|
||||||
const filtered = useMemo(() => {
|
|
||||||
const s = q.trim().toLowerCase();
|
|
||||||
const base = s
|
|
||||||
? list.filter(
|
|
||||||
(u) =>
|
|
||||||
u.email.toLowerCase().includes(s) || (u.display_name ?? "").toLowerCase().includes(s),
|
|
||||||
)
|
|
||||||
: list;
|
|
||||||
return base.slice(0, 8);
|
|
||||||
}, [q, list]);
|
|
||||||
return (
|
|
||||||
<div className="relative w-full sm:w-72">
|
|
||||||
<input
|
|
||||||
value={q}
|
|
||||||
onChange={(e) => {
|
|
||||||
setQ(e.target.value);
|
|
||||||
setOpen(true);
|
|
||||||
}}
|
|
||||||
onFocus={() => setOpen(true)}
|
|
||||||
onBlur={() => setTimeout(() => setOpen(false), 150)}
|
|
||||||
placeholder={t("downloads.admin.quotaPickPlaceholder")}
|
|
||||||
className={inputCls}
|
|
||||||
/>
|
|
||||||
{open && filtered.length > 0 && (
|
|
||||||
<div className="absolute z-base mt-1 w-full rounded-lg glass-menu overflow-hidden">
|
|
||||||
{filtered.map((u) => (
|
|
||||||
<button
|
|
||||||
key={u.id}
|
|
||||||
onClick={() => {
|
|
||||||
onPick({ id: u.id, email: u.email });
|
|
||||||
setQ("");
|
|
||||||
setOpen(false);
|
|
||||||
}}
|
|
||||||
className="w-full text-left px-3 py-2 text-sm hover:bg-surface transition flex flex-col"
|
|
||||||
>
|
|
||||||
<span className="truncate">{u.display_name || u.email}</span>
|
|
||||||
{u.display_name && <span className="text-xs text-muted truncate">{u.email}</span>}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function AdminSystem() {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const storage = useLiveQuery(qk.adminStorage(), api.adminDownloadStorage, { intervalMs: 5000 });
|
|
||||||
const jobs = useLiveQuery(qk.adminDownloads(), api.adminDownloads, { intervalMs: 5000 });
|
|
||||||
const [quotaFor, setQuotaFor] = useState<{ id: number; email: string } | null>(null);
|
|
||||||
const s = storage.data;
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
{s && (
|
|
||||||
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3 mb-5">
|
|
||||||
{[
|
|
||||||
[t("downloads.admin.readyFiles"), String(s.ready_files)],
|
|
||||||
[t("downloads.admin.totalSize"), formatBytes(s.total_bytes)],
|
|
||||||
[
|
|
||||||
t("downloads.admin.cap"),
|
|
||||||
s.total_cap_bytes ? formatBytes(s.total_cap_bytes) : t("downloads.admin.noCap"),
|
|
||||||
],
|
|
||||||
].map(([label, val]) => (
|
|
||||||
<div key={label} className="rounded-xl glass-card p-3">
|
|
||||||
<div className="text-xs text-muted">{label}</div>
|
|
||||||
<div className="text-lg font-semibold mt-0.5">{val}</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{s && (
|
|
||||||
<div className="mb-6">
|
|
||||||
<div className="flex items-center justify-between gap-3 mb-2 flex-wrap">
|
|
||||||
<div className="text-sm font-medium">{t("downloads.admin.perUser")}</div>
|
|
||||||
{/* Set a quota for any user, regardless of whether they've downloaded anything. */}
|
|
||||||
<QuotaUserPicker onPick={setQuotaFor} />
|
|
||||||
</div>
|
|
||||||
<div className="space-y-1">
|
|
||||||
{s.per_user.map((u) => (
|
|
||||||
<div
|
|
||||||
key={u.user_id}
|
|
||||||
className="flex items-center gap-2 text-sm px-3 py-1.5 rounded-lg bg-card/40"
|
|
||||||
>
|
|
||||||
<span className="flex-1 truncate">{u.email}</span>
|
|
||||||
<span className="text-muted">{formatBytes(u.footprint_bytes)}</span>
|
|
||||||
<button
|
|
||||||
onClick={() => setQuotaFor({ id: u.user_id, email: u.email ?? "" })}
|
|
||||||
className="p-1 rounded-sm hover:bg-surface text-muted hover:text-fg"
|
|
||||||
title={t("downloads.admin.editQuota")}
|
|
||||||
>
|
|
||||||
<Settings2 className="w-4 h-4" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
{s.per_user.length === 0 && (
|
|
||||||
<div className="text-xs text-muted px-3 py-2">{t("downloads.admin.noFootprint")}</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="text-sm font-medium mb-2">{t("downloads.tabs.system")}</div>
|
|
||||||
<div className="space-y-1.5">
|
|
||||||
{(jobs.data ?? []).map((j) => (
|
|
||||||
<div
|
|
||||||
key={j.id}
|
|
||||||
className="flex items-center gap-3 text-sm px-3 py-1.5 rounded-lg bg-card/40"
|
|
||||||
>
|
|
||||||
<span className="flex-1 truncate">{jobTitle(j)}</span>
|
|
||||||
<span className="text-muted truncate w-40">{j.user_email}</span>
|
|
||||||
<StatusPill job={j} />
|
|
||||||
<span className="text-muted w-16 text-right">
|
|
||||||
{j.asset?.size_bytes ? formatBytes(j.asset.size_bytes) : ""}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
{jobs.data && jobs.data.length === 0 && (
|
|
||||||
<div className="text-sm text-muted py-6 text-center">{t("downloads.empty.admin")}</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{quotaFor && (
|
|
||||||
<QuotaModal userId={quotaFor.id} email={quotaFor.email} onClose={() => setQuotaFor(null)} />
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Main -----------------------------------------------------------------------------------
|
|
||||||
|
|
||||||
export default function DownloadCenter() {
|
export default function DownloadCenter() {
|
||||||
const me = useMe();
|
const me = useMe();
|
||||||
|
|||||||
@@ -10,8 +10,10 @@ import {
|
|||||||
import { qk } from "../lib/queryKeys";
|
import { qk } from "../lib/queryKeys";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { api, getActiveAccount, type FeedFilters } from "../lib/api";
|
import { api, getActiveAccount, type FeedFilters } from "../lib/api";
|
||||||
import { hasFilterParams, paramsToFilters } from "../lib/urlState";
|
import { filterParamUpdates, hasFilterParams, paramsToFilters } from "../lib/urlState";
|
||||||
|
import { useUrlMirror } from "../lib/useUrlMirror";
|
||||||
import { accountKey, LS, readJSON, readMerged, writeJSON } from "../lib/storage";
|
import { accountKey, LS, readJSON, readMerged, writeJSON } from "../lib/storage";
|
||||||
|
import { useNavigation } from "./NavigationProvider";
|
||||||
|
|
||||||
// Owns the GLOBAL feed-filter state (the feed's scope/tags/content/sort/show), lifted out of the App
|
// Owns the GLOBAL feed-filter state (the feed's scope/tags/content/sort/show), lifted out of the App
|
||||||
// god component so consumers read it via a hook instead of prop-drilling `filters`/`setFilters`
|
// god component so consumers read it via a hook instead of prop-drilling `filters`/`setFilters`
|
||||||
@@ -90,8 +92,8 @@ export function useFeedFiltersActions(): FeedFiltersActions {
|
|||||||
|
|
||||||
export function FeedFiltersProvider({ children }: { children: ReactNode }) {
|
export function FeedFiltersProvider({ children }: { children: ReactNode }) {
|
||||||
const [filters, setFiltersState] = useState<FeedFilters>(loadInitialFilters);
|
const [filters, setFiltersState] = useState<FeedFilters>(loadInitialFilters);
|
||||||
// Whether this load arrived via a "Share view" link (captured once, before the URL is stripped)
|
// Whether this load arrived with feed-filter params in the URL (a bookmark/shared link). Captured
|
||||||
// — such filters win over the account's stored view and are persisted to the account instead.
|
// once at mount — such filters win over the account's stored view and are persisted to the account.
|
||||||
const [initialHadUrlFilters] = useState(() =>
|
const [initialHadUrlFilters] = useState(() =>
|
||||||
hasFilterParams(new URLSearchParams(window.location.search)),
|
hasFilterParams(new URLSearchParams(window.location.search)),
|
||||||
);
|
);
|
||||||
@@ -108,6 +110,14 @@ export function FeedFiltersProvider({ children }: { children: ReactNode }) {
|
|||||||
if (key) writeJSON(key, next);
|
if (key) writeJSON(key, next);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
// Mirror the feed filters into the address bar so every feed view is bookmarkable. Gated on
|
||||||
|
// page===feed: on other pages the filter keys are cleared from the URL (localStorage still holds
|
||||||
|
// them; they re-mirror on return to the feed). The channel detail page keeps its own isolated
|
||||||
|
// filters, so those never reach here. useUrlMirror preserves the nav params (?page/?chan/?v/?pl) the
|
||||||
|
// other providers own.
|
||||||
|
const { page } = useNavigation();
|
||||||
|
useUrlMirror(filterParamUpdates(page === "feed" ? filters : null));
|
||||||
|
|
||||||
// Load THIS account's own filters when the account first resolves or changes (login / switch /
|
// Load THIS account's own filters when the account first resolves or changes (login / switch /
|
||||||
// add). Filters are per-account localStorage state, so another account's last view (incl. its
|
// add). Filters are per-account localStorage state, so another account's last view (incl. its
|
||||||
// default saved view) must never carry over. A share link's filters (captured at mount) win and
|
// default saved view) must never carry over. A share link's filters (captured at mount) win and
|
||||||
|
|||||||
@@ -1,26 +1,15 @@
|
|||||||
import {
|
import { createContext, useContext, useMemo, type ReactNode } from "react";
|
||||||
createContext,
|
|
||||||
useCallback,
|
|
||||||
useContext,
|
|
||||||
useEffect,
|
|
||||||
useMemo,
|
|
||||||
useState,
|
|
||||||
type ReactNode,
|
|
||||||
} from "react";
|
|
||||||
import { qk } from "../lib/queryKeys";
|
|
||||||
import { useQuery } from "@tanstack/react-query";
|
|
||||||
import { api } from "../lib/api";
|
|
||||||
import { parseFeedView, type FeedView } from "../lib/feedView";
|
import { parseFeedView, type FeedView } from "../lib/feedView";
|
||||||
import { LS, readAccount, writeAccount } from "../lib/storage";
|
import { LS } from "../lib/storage";
|
||||||
|
import { usePref } from "../lib/usePref";
|
||||||
|
|
||||||
// Owns the feed's view mode (the card/row/tile density switcher), lifted out of App so both the feed
|
// Owns the feed's view mode (the card/row/tile density switcher), lifted out of App so both the feed
|
||||||
// page and the channel page read it via hooks instead of prop-drilling `view`/`setView` through App.
|
// page and the channel page read it via hooks instead of prop-drilling `view`/`setView` through App.
|
||||||
// Split State + Actions contexts, mirroring the other providers; composed in main.tsx around <App/>.
|
// Split State + Actions contexts, mirroring the other providers; composed in main.tsx around <App/>.
|
||||||
//
|
//
|
||||||
// The view mode is an account PREFERENCE, not a filter: it persists to the server on pick (savePrefs)
|
// The view mode is an account PREFERENCE, not a filter: it persists to the server on pick and is
|
||||||
// and is adopted from server prefs on login — so unlike the ephemeral list filters, this provider
|
// adopted from server prefs on login. usePref carries that server-sync (plus the localStorage mirror
|
||||||
// carries the same server-sync the App god component used to. A localStorage mirror seeds the initial
|
// that seeds the first render synchronously) so this provider is just the two contexts over it.
|
||||||
// render synchronously; the passive qk.me() observer adopts the server value once the account resolves.
|
|
||||||
|
|
||||||
interface FeedViewActions {
|
interface FeedViewActions {
|
||||||
setView: (v: FeedView) => void;
|
setView: (v: FeedView) => void;
|
||||||
@@ -37,30 +26,11 @@ export function useFeedViewActions(): FeedViewActions {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function FeedViewProvider({ children }: { children: ReactNode }) {
|
export function FeedViewProvider({ children }: { children: ReactNode }) {
|
||||||
const [view, setViewState] = useState<FeedView>(() =>
|
// parseFeedView also maps the pre-0.45 "grid"/"list" values forward (idempotent for current values).
|
||||||
parseFeedView(readAccount(LS.feedView, null)),
|
const [view, setView] = usePref<FeedView>("view", "cards", {
|
||||||
);
|
localKey: LS.feedView,
|
||||||
// Passive observer of the account App fetches (enabled:false → reads cache, re-renders on update).
|
parse: parseFeedView,
|
||||||
const { data: me } = useQuery({ queryKey: qk.me(), queryFn: api.me, enabled: false });
|
});
|
||||||
|
|
||||||
// A user pick persists both to the localStorage mirror (instant reload) and to the server.
|
|
||||||
const setView = useCallback((next: FeedView) => {
|
|
||||||
setViewState(next);
|
|
||||||
writeAccount(LS.feedView, next);
|
|
||||||
api.savePrefs({ view: next }).catch(() => {});
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// Adopt the server-stored view on login / account switch. Writes the mirror but does NOT savePrefs
|
|
||||||
// (that would echo the just-adopted value straight back). parseFeedView also maps the pre-0.45
|
|
||||||
// "grid" / "list" values forward.
|
|
||||||
useEffect(() => {
|
|
||||||
const prefs = me?.preferences;
|
|
||||||
if (!prefs) return;
|
|
||||||
const adopted = parseFeedView(prefs.view);
|
|
||||||
setViewState(adopted);
|
|
||||||
writeAccount(LS.feedView, adopted);
|
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
||||||
}, [me?.id]);
|
|
||||||
|
|
||||||
const actions = useMemo<FeedViewActions>(() => ({ setView }), [setView]);
|
const actions = useMemo<FeedViewActions>(() => ({ setView }), [setView]);
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,62 @@
|
|||||||
|
import { useState, type ReactNode } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { defaultLayout, type PanelId, type PanelLayout } from "../lib/panelLayout";
|
||||||
|
import SidePanel from "./SidePanel";
|
||||||
|
import PanelGroups, { type PanelItem } from "./PanelGroups";
|
||||||
|
|
||||||
|
// The shared filter-rail shell, extracted from the three rails that all render the same SidePanel
|
||||||
|
// chrome around a PanelGroups body: the feed Sidebar, the PlexSidebar, and the PlaylistsRail. It owns
|
||||||
|
// the customize (reorder/hide) `editing` state and the fixed title-bar wiring (edit toggle, reset to
|
||||||
|
// the layout default, edit hint); each rail supplies only what actually differs — its identity
|
||||||
|
// (title/icon), the active-filter count + Clear-all action, its `items`, and any content above the
|
||||||
|
// groups (`top`, e.g. the feed's scope toggle) or below (`children`, e.g. the feed's tag manager).
|
||||||
|
// `kind` is the PanelId (also the layout key), so `top`/`children` default to nothing for the rails
|
||||||
|
// that need neither.
|
||||||
|
export default function FilterRail({
|
||||||
|
kind,
|
||||||
|
title,
|
||||||
|
icon,
|
||||||
|
count,
|
||||||
|
actions,
|
||||||
|
collapsed,
|
||||||
|
onToggleCollapse,
|
||||||
|
layout,
|
||||||
|
setLayout,
|
||||||
|
items,
|
||||||
|
top,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
kind: PanelId;
|
||||||
|
title: string;
|
||||||
|
icon: ReactNode;
|
||||||
|
count?: number;
|
||||||
|
actions?: ReactNode;
|
||||||
|
collapsed: boolean;
|
||||||
|
onToggleCollapse: () => void;
|
||||||
|
layout: PanelLayout;
|
||||||
|
setLayout: (l: PanelLayout) => void;
|
||||||
|
items: PanelItem[];
|
||||||
|
top?: ReactNode;
|
||||||
|
children?: ReactNode;
|
||||||
|
}) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const [editing, setEditing] = useState(false);
|
||||||
|
return (
|
||||||
|
<SidePanel
|
||||||
|
title={title}
|
||||||
|
icon={icon}
|
||||||
|
count={count}
|
||||||
|
collapsed={collapsed}
|
||||||
|
onToggleCollapse={onToggleCollapse}
|
||||||
|
editing={editing}
|
||||||
|
onToggleEditing={() => setEditing((e) => !e)}
|
||||||
|
onReset={() => setLayout(defaultLayout(kind))}
|
||||||
|
editHint={t("sidebar.editHint")}
|
||||||
|
actions={actions}
|
||||||
|
>
|
||||||
|
{top}
|
||||||
|
<PanelGroups items={items} layout={layout} setLayout={setLayout} editing={editing} />
|
||||||
|
{children}
|
||||||
|
</SidePanel>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -189,7 +189,7 @@ function ConversationList({
|
|||||||
onClick={() => openChat(c.partner)}
|
onClick={() => openChat(c.partner)}
|
||||||
title={t("messages.popOut")}
|
title={t("messages.popOut")}
|
||||||
aria-label={t("messages.popOut")}
|
aria-label={t("messages.popOut")}
|
||||||
className="shrink-0 mr-1.5 p-1.5 rounded-lg text-muted opacity-0 group-hover:opacity-100 hover:text-accent hover:bg-bg transition"
|
className="shrink-0 mr-1.5 p-1.5 rounded-lg text-muted reveal hover:text-accent hover:bg-bg transition"
|
||||||
>
|
>
|
||||||
<ExternalLink className="w-4 h-4" />
|
<ExternalLink className="w-4 h-4" />
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useEffect, useRef, type ReactNode } from "react";
|
import { useEffect, useId, useRef, useState, type ReactNode } from "react";
|
||||||
import { createPortal } from "react-dom";
|
import { createPortal } from "react-dom";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { X } from "lucide-react";
|
import { X } from "lucide-react";
|
||||||
@@ -6,6 +6,14 @@ import { useBackToClose } from "../lib/history";
|
|||||||
import { pushLayer, removeLayer, isTopmost } from "../lib/layerStack";
|
import { pushLayer, removeLayer, isTopmost } from "../lib/layerStack";
|
||||||
import { useFullscreenElement } from "../lib/fullscreen";
|
import { useFullscreenElement } from "../lib/fullscreen";
|
||||||
|
|
||||||
|
// Tabbable elements inside the dialog, in DOM order. `:not([tabindex="-1"])` drops programmatically
|
||||||
|
// focusable-only nodes; the visibility filter (offsetParent) drops display:none ones (e.g. a
|
||||||
|
// collapsed disclosure) so the trap never lands focus on something the user can't see.
|
||||||
|
const FOCUSABLE =
|
||||||
|
'a[href], button:not([disabled]), textarea:not([disabled]), input:not([disabled]), select:not([disabled]), [tabindex]:not([tabindex="-1"])';
|
||||||
|
const focusablesIn = (root: HTMLElement): HTMLElement[] =>
|
||||||
|
[...root.querySelectorAll<HTMLElement>(FOCUSABLE)].filter((el) => el.offsetParent !== null);
|
||||||
|
|
||||||
// Small centered modal shell: backdrop + ESC + scroll-lock close. Each open modal is a layer in the
|
// Small centered modal shell: backdrop + ESC + scroll-lock close. Each open modal is a layer in the
|
||||||
// shared `layerStack` so ESC only closes the topmost one — e.g. an error dialog over the tag editor
|
// shared `layerStack` so ESC only closes the topmost one — e.g. an error dialog over the tag editor
|
||||||
// dismisses just the error and returns to the editor underneath, and a player below defers to it
|
// dismisses just the error and returns to the editor underneath, and a player below defers to it
|
||||||
@@ -27,6 +35,13 @@ export default function Modal({
|
|||||||
// Browser/mouse Back closes the topmost modal instead of navigating away.
|
// Browser/mouse Back closes the topmost modal instead of navigating away.
|
||||||
useBackToClose(onClose);
|
useBackToClose(onClose);
|
||||||
const fsRoot = useFullscreenElement();
|
const fsRoot = useFullscreenElement();
|
||||||
|
const panelRef = useRef<HTMLDivElement>(null);
|
||||||
|
const titleId = useId();
|
||||||
|
// Capture the trigger to restore focus to, during THIS render — i.e. before commit runs the
|
||||||
|
// dialog's autoFocus (or our own initial-focus) and moves activeElement into the dialog. Reading
|
||||||
|
// it later (in the effect) would capture a now-inside element that unmounts on close, stranding
|
||||||
|
// focus on <body>. useState initializer runs exactly once, on first render.
|
||||||
|
const [restoreTo] = useState(() => document.activeElement as HTMLElement | null);
|
||||||
|
|
||||||
// Backdrop-click close must only fire when the press STARTED on the backdrop too. Otherwise a
|
// Backdrop-click close must only fire when the press STARTED on the backdrop too. Otherwise a
|
||||||
// drag that begins inside the dialog (e.g. selecting text in an input) and is released out on
|
// drag that begins inside the dialog (e.g. selecting text in an input) and is released out on
|
||||||
@@ -39,20 +54,57 @@ export default function Modal({
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const id = pushLayer();
|
const id = pushLayer();
|
||||||
const onKey = (e: KeyboardEvent) => {
|
const onKey = (e: KeyboardEvent) => {
|
||||||
if (e.key === "Escape" && isTopmost(id)) {
|
if (!isTopmost(id)) return;
|
||||||
|
if (e.key === "Escape") {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
onCloseRef.current();
|
onCloseRef.current();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Focus trap: keep Tab / Shift+Tab cycling inside the dialog instead of reaching the inert
|
||||||
|
// page behind the backdrop. Only the topmost modal traps (a nested one defers to its child).
|
||||||
|
if (e.key === "Tab") {
|
||||||
|
const panel = panelRef.current;
|
||||||
|
if (!panel) return;
|
||||||
|
const items = focusablesIn(panel);
|
||||||
|
const first = items[0];
|
||||||
|
const last = items[items.length - 1];
|
||||||
|
if (!first || !last) {
|
||||||
|
e.preventDefault();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const active = document.activeElement;
|
||||||
|
if (!panel.contains(active) || active === panel) {
|
||||||
|
// Focus escaped the dialog, OR sits on the panel container itself (tabIndex=-1, reached by
|
||||||
|
// clicking dialog dead space) — Node.contains(self) is true, so `active === panel` must be
|
||||||
|
// checked explicitly or Shift+Tab from the panel would leak to the background. Pull it back.
|
||||||
|
e.preventDefault();
|
||||||
|
(e.shiftKey ? last : first).focus();
|
||||||
|
} else if (e.shiftKey && active === first) {
|
||||||
|
e.preventDefault();
|
||||||
|
last.focus();
|
||||||
|
} else if (!e.shiftKey && active === last) {
|
||||||
|
e.preventDefault();
|
||||||
|
first.focus();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
window.addEventListener("keydown", onKey);
|
window.addEventListener("keydown", onKey);
|
||||||
const prev = document.body.style.overflow;
|
const prev = document.body.style.overflow;
|
||||||
document.body.style.overflow = "hidden";
|
document.body.style.overflow = "hidden";
|
||||||
|
// Initial focus: unless an autoFocus child already placed it inside (e.g. a rename input), move
|
||||||
|
// focus to the first control so the dialog opens focused, not stranded on the background.
|
||||||
|
const panel = panelRef.current;
|
||||||
|
if (panel && !panel.contains(document.activeElement)) {
|
||||||
|
(focusablesIn(panel)[0] ?? panel).focus();
|
||||||
|
}
|
||||||
return () => {
|
return () => {
|
||||||
window.removeEventListener("keydown", onKey);
|
window.removeEventListener("keydown", onKey);
|
||||||
removeLayer(id);
|
removeLayer(id);
|
||||||
document.body.style.overflow = prev;
|
document.body.style.overflow = prev;
|
||||||
|
// Restore focus to the trigger, if it's still in the document and focusable.
|
||||||
|
if (restoreTo && restoreTo.isConnected) restoreTo.focus();
|
||||||
};
|
};
|
||||||
}, []);
|
}, [restoreTo]);
|
||||||
|
|
||||||
return createPortal(
|
return createPortal(
|
||||||
<div
|
<div
|
||||||
@@ -65,10 +117,17 @@ export default function Modal({
|
|||||||
}}
|
}}
|
||||||
role="dialog"
|
role="dialog"
|
||||||
aria-modal="true"
|
aria-modal="true"
|
||||||
|
aria-labelledby={titleId}
|
||||||
>
|
>
|
||||||
<div className={`glass relative w-full ${maxWidth} max-h-full overflow-y-auto rounded-2xl`}>
|
<div
|
||||||
|
ref={panelRef}
|
||||||
|
tabIndex={-1}
|
||||||
|
className={`glass relative w-full ${maxWidth} max-h-full overflow-y-auto rounded-2xl outline-none`}
|
||||||
|
>
|
||||||
<div className="flex items-start justify-between gap-3 p-5 pb-3">
|
<div className="flex items-start justify-between gap-3 p-5 pb-3">
|
||||||
<h2 className="text-lg font-semibold leading-snug">{title}</h2>
|
<h2 id={titleId} className="text-lg font-semibold leading-snug">
|
||||||
|
{title}
|
||||||
|
</h2>
|
||||||
<button
|
<button
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
title={t("common.close")}
|
title={t("common.close")}
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import {
|
|||||||
type ReactNode,
|
type ReactNode,
|
||||||
} from "react";
|
} from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { isPage, readPage, type Page } from "../lib/urlState";
|
import { hasFilterParams, isPage, readPage, urlWithParams, type Page } from "../lib/urlState";
|
||||||
import { pageTitleKey } from "../lib/pageMeta";
|
import { pageTitleKey } from "../lib/pageMeta";
|
||||||
import { getAccountRaw, LS, setAccountRaw } from "../lib/storage";
|
import { getAccountRaw, LS, setAccountRaw } from "../lib/storage";
|
||||||
import { useAccountRehydrate } from "../lib/useAccountRehydrate";
|
import { useAccountRehydrate } from "../lib/useAccountRehydrate";
|
||||||
@@ -52,6 +52,12 @@ export interface NavigationActions {
|
|||||||
function loadInitialPage(): Page {
|
function loadInitialPage(): Page {
|
||||||
const params = new URLSearchParams(window.location.search);
|
const params = new URLSearchParams(window.location.search);
|
||||||
if (params.has("page")) return readPage();
|
if (params.has("page")) return readPage();
|
||||||
|
// A view-specific param implies its page, so a bookmarked deep link lands on the right module even
|
||||||
|
// without an explicit ?page. Feed filters (or a channel overlay) imply the feed.
|
||||||
|
if (params.has("v")) return "plex";
|
||||||
|
if (params.has("pl")) return "playlists";
|
||||||
|
if (hasFilterParams(params) || params.has("chan")) return "feed";
|
||||||
|
// A bare URL → the account's stored landing page.
|
||||||
const stored = getAccountRaw(PAGE_KEY);
|
const stored = getAccountRaw(PAGE_KEY);
|
||||||
return isPage(stored) ? stored : "feed";
|
return isPage(stored) ? stored : "feed";
|
||||||
}
|
}
|
||||||
@@ -88,10 +94,16 @@ export function NavigationProvider({ children }: { children: ReactNode }) {
|
|||||||
return st?._yt && st?._ytScrape ? (st._yt as string) : null;
|
return st?._yt && st?._ytScrape ? (st._yt as string) : null;
|
||||||
});
|
});
|
||||||
// The open channel page rides in history.state (_chan = id, _chanName = a name to show before the
|
// The open channel page rides in history.state (_chan = id, _chanName = a name to show before the
|
||||||
// detail loads), so Back closes it and a reload restores it (unlike ytSearch, which is dropped).
|
// detail loads) AND is mirrored to the address bar as ?chan=<id>, so Back closes it, a reload restores
|
||||||
|
// it, and a bookmarked ?chan link opens it (unlike ytSearch, which is dropped). Reload of an in-app
|
||||||
|
// entry reads history.state._chan; a fresh bookmark has only the ?chan param.
|
||||||
const [channelView, setChannelView] = useState<ChannelView | null>(() => {
|
const [channelView, setChannelView] = useState<ChannelView | null>(() => {
|
||||||
const c = window.history.state?._chan as string | undefined;
|
const st = window.history.state;
|
||||||
return c ? { id: c, name: (window.history.state?._chanName as string) ?? undefined } : null;
|
const id =
|
||||||
|
(st?._chan as string | undefined) ??
|
||||||
|
new URLSearchParams(window.location.search).get("chan") ??
|
||||||
|
undefined;
|
||||||
|
return id ? { id, name: (st?._chanName as string) ?? undefined } : null;
|
||||||
});
|
});
|
||||||
|
|
||||||
// Refs so the STABLE action callbacks can read the latest state without depending on it — a dep
|
// Refs so the STABLE action callbacks can read the latest state without depending on it — a dep
|
||||||
@@ -116,8 +128,13 @@ export function NavigationProvider({ children }: { children: ReactNode }) {
|
|||||||
setAccountRaw(PAGE_KEY, stored); // no-op re-write, keeps storage authoritative
|
setAccountRaw(PAGE_KEY, stored); // no-op re-write, keeps storage authoritative
|
||||||
// Correct the CURRENT entry (replace, not push) and PRESERVE any markers already on it — a channel
|
// Correct the CURRENT entry (replace, not push) and PRESERVE any markers already on it — a channel
|
||||||
// (_chan) or YT search (_yt) opened in the pre-pin window survives on top of the restored page,
|
// (_chan) or YT search (_yt) opened in the pre-pin window survives on top of the restored page,
|
||||||
// rather than being orphaned from history so Back can't close it.
|
// rather than being orphaned from history so Back can't close it. Mirror the corrected page to the
|
||||||
window.history.replaceState({ ...(window.history.state || {}), sfPage: stored }, "");
|
// address bar too (urlWithParams keeps any ?chan/filter params already there).
|
||||||
|
window.history.replaceState(
|
||||||
|
{ ...(window.history.state || {}), sfPage: stored },
|
||||||
|
"",
|
||||||
|
urlWithParams({ page: stored === "feed" ? null : stored }),
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
const setPage = useCallback(
|
const setPage = useCallback(
|
||||||
@@ -130,9 +147,15 @@ export function NavigationProvider({ children }: { children: ReactNode }) {
|
|||||||
setPageState(next);
|
setPageState(next);
|
||||||
setAccountRaw(PAGE_KEY, next);
|
setAccountRaw(PAGE_KEY, next);
|
||||||
// Push an in-app history entry so browser/mouse Back steps through pages instead of leaving the
|
// Push an in-app history entry so browser/mouse Back steps through pages instead of leaving the
|
||||||
// app. The URL stays clean — the page rides in history.state. A fresh { sfPage } (no carried-over
|
// app. A fresh { sfPage } (no carried-over _yt/_chan markers) so each page starts at its root, and
|
||||||
// _yt/_chan markers) so each page starts at its root.
|
// the address bar reflects the page (feed is implicit) while clearing the view-specific params
|
||||||
window.history.pushState({ sfPage: next }, "");
|
// (chan/v/pl) — a page switch resets to that module's root. Feed filters (owned by
|
||||||
|
// FeedFiltersProvider) are left intact.
|
||||||
|
window.history.pushState(
|
||||||
|
{ sfPage: next },
|
||||||
|
"",
|
||||||
|
urlWithParams({ page: next === "feed" ? null : next, chan: null, v: null, pl: null }),
|
||||||
|
);
|
||||||
},
|
},
|
||||||
[markDirty],
|
[markDirty],
|
||||||
);
|
);
|
||||||
@@ -143,13 +166,20 @@ export function NavigationProvider({ children }: { children: ReactNode }) {
|
|||||||
const openChannel = useCallback((id: string, name?: string) => {
|
const openChannel = useCallback((id: string, name?: string) => {
|
||||||
setChannelView({ id, name });
|
setChannelView({ id, name });
|
||||||
const st = window.history.state || {};
|
const st = window.history.state || {};
|
||||||
if (st._chan) window.history.replaceState({ ...st, _chan: id, _chanName: name ?? null }, "");
|
const url = urlWithParams({ chan: id });
|
||||||
else window.history.pushState({ ...st, _chan: id, _chanName: name ?? null }, "");
|
if (st._chan)
|
||||||
|
window.history.replaceState({ ...st, _chan: id, _chanName: name ?? null }, "", url);
|
||||||
|
else window.history.pushState({ ...st, _chan: id, _chanName: name ?? null }, "", url);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const closeChannel = useCallback(() => {
|
const closeChannel = useCallback(() => {
|
||||||
if (window.history.state?._chan) window.history.back();
|
if (window.history.state?._chan) window.history.back();
|
||||||
else setChannelView(null);
|
else {
|
||||||
|
// No owned history entry (e.g. opened straight from a ?chan bookmark): just clear the state and
|
||||||
|
// drop the ?chan param from the address bar.
|
||||||
|
setChannelView(null);
|
||||||
|
window.history.replaceState(window.history.state, "", urlWithParams({ chan: null }));
|
||||||
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const enterYtSearch = useCallback((q: string) => {
|
const enterYtSearch = useCallback((q: string) => {
|
||||||
@@ -189,19 +219,23 @@ export function NavigationProvider({ children }: { children: ReactNode }) {
|
|||||||
document.title = label ? `${label} · ${brand}` : brand;
|
document.title = label ? `${label} · ${brand}` : brand;
|
||||||
}, [page, channelView, i18n.language, t]);
|
}, [page, channelView, i18n.language, t]);
|
||||||
|
|
||||||
// In-app Back/Forward: stamp the initial entry with the current page, then sync state from
|
// In-app Back/Forward: stamp the initial entry with the current page (mirrored to the address bar),
|
||||||
// history.state on popstate. (stripUrlParams preserves history.state, so this stamp survives a
|
// then sync state from history.state on popstate — the browser restores each entry's URL for us, so
|
||||||
// later query-string strip in App.)
|
// the popstate handler only needs to read history.state.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// A reload drops a stale _yt EXCEPT when scrape-sourced (zero quota): then keep _yt + _ytScrape
|
// A reload drops a stale _yt EXCEPT when scrape-sourced (zero quota): then keep _yt + _ytScrape
|
||||||
// so it restores and re-fetches free (ytSearch above already initialised from it). _chan/_chanName
|
// so it restores and re-fetches free (ytSearch above already initialised from it). _chan/_chanName
|
||||||
// are always kept so a channel page survives F5.
|
// are always kept so a channel page survives F5.
|
||||||
const st = window.history.state || {};
|
const st = window.history.state || {};
|
||||||
|
// Mirror the resolved initial page to the address bar (feed implicit) so a page reached from the
|
||||||
|
// stored landing pref — not just from the URL — is itself bookmarkable. urlWithParams preserves any
|
||||||
|
// ?chan / ?v / ?pl / filter params the loaders already read.
|
||||||
|
const url = urlWithParams({ page: page === "feed" ? null : page });
|
||||||
if (st._yt && st._ytScrape) {
|
if (st._yt && st._ytScrape) {
|
||||||
window.history.replaceState({ ...st, sfPage: page }, "");
|
window.history.replaceState({ ...st, sfPage: page }, "", url);
|
||||||
} else {
|
} else {
|
||||||
const { _yt: _staleYt, _ytScrape: _staleScrape, ...rest } = st;
|
const { _yt: _staleYt, _ytScrape: _staleScrape, ...rest } = st;
|
||||||
window.history.replaceState({ ...rest, sfPage: page }, "");
|
window.history.replaceState({ ...rest, sfPage: page }, "", url);
|
||||||
}
|
}
|
||||||
function onPop(e: PopStateEvent) {
|
function onPop(e: PopStateEvent) {
|
||||||
const p = (e.state?.sfPage as Page) ?? "feed";
|
const p = (e.state?.sfPage as Page) ?? "feed";
|
||||||
@@ -210,7 +244,14 @@ export function NavigationProvider({ children }: { children: ReactNode }) {
|
|||||||
// The YouTube-search sub-view and the channel page ride in history.state, so Back/Forward
|
// The YouTube-search sub-view and the channel page ride in history.state, so Back/Forward
|
||||||
// restores or clears them to match the entry we landed on.
|
// restores or clears them to match the entry we landed on.
|
||||||
setYtSearch((e.state?._yt as string) ?? null);
|
setYtSearch((e.state?._yt as string) ?? null);
|
||||||
const chan = e.state?._chan as string | undefined;
|
// Fall back to the ?chan URL param (the browser restored the landed entry's URL) when the entry
|
||||||
|
// has no _chan — a fresh ?chan bookmark's entry is URL-only, and reading e.state alone would
|
||||||
|
// wrongly close the channel when a popstate lands here (e.g. after an overlay's programmatic
|
||||||
|
// history.back()). Mirrors the channelView initializer's URL fallback.
|
||||||
|
const chan =
|
||||||
|
(e.state?._chan as string | undefined) ??
|
||||||
|
new URLSearchParams(window.location.search).get("chan") ??
|
||||||
|
undefined;
|
||||||
setChannelView(chan ? { id: chan, name: (e.state?._chanName as string) ?? undefined } : null);
|
setChannelView(chan ? { id: chan, name: (e.state?._chanName as string) ?? undefined } : null);
|
||||||
}
|
}
|
||||||
window.addEventListener("popstate", onPop);
|
window.addEventListener("popstate", onPop);
|
||||||
|
|||||||
@@ -1,9 +1,17 @@
|
|||||||
import { useEffect, useMemo, useRef, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { createPortal } from "react-dom";
|
import { createPortal } from "react-dom";
|
||||||
import { useNavigationActions } from "./NavigationProvider";
|
import { useNavigationActions } from "./NavigationProvider";
|
||||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import { qk } from "../lib/queryKeys";
|
import { qk } from "../lib/queryKeys";
|
||||||
|
import { usePref } from "../lib/usePref";
|
||||||
|
import {
|
||||||
|
AUTO_MODES,
|
||||||
|
LOOP_MODES,
|
||||||
|
PlayerTransportControls,
|
||||||
|
type AutoMode,
|
||||||
|
type LoopMode,
|
||||||
|
} from "./playerModalControls";
|
||||||
import {
|
import {
|
||||||
AlertTriangle,
|
AlertTriangle,
|
||||||
ArrowLeft,
|
ArrowLeft,
|
||||||
@@ -15,12 +23,7 @@ import {
|
|||||||
Maximize,
|
Maximize,
|
||||||
Minimize,
|
Minimize,
|
||||||
Minimize2,
|
Minimize2,
|
||||||
Repeat,
|
|
||||||
Repeat1,
|
|
||||||
Settings,
|
Settings,
|
||||||
Shuffle,
|
|
||||||
SkipBack,
|
|
||||||
SkipForward,
|
|
||||||
Volume2,
|
Volume2,
|
||||||
VolumeX,
|
VolumeX,
|
||||||
X,
|
X,
|
||||||
@@ -31,7 +34,7 @@ import { PopoverSurface, useAnchoredPopover } from "./AnchoredPopover";
|
|||||||
import { pushLayer, removeLayer, anyLayerAbove } from "../lib/layerStack";
|
import { pushLayer, removeLayer, anyLayerAbove } from "../lib/layerStack";
|
||||||
import DownloadButton from "./DownloadButton";
|
import DownloadButton from "./DownloadButton";
|
||||||
import { api, type Video, type VideoStatus } from "../lib/api";
|
import { api, type Video, type VideoStatus } from "../lib/api";
|
||||||
import type { YTNamespace, YTPlayer, YTPlayerEvent } from "../lib/youtube";
|
import type { YTPlayer } from "../lib/youtube";
|
||||||
import {
|
import {
|
||||||
channelYouTubeUrl,
|
channelYouTubeUrl,
|
||||||
formatDate,
|
formatDate,
|
||||||
@@ -40,15 +43,14 @@ import {
|
|||||||
relativeTime,
|
relativeTime,
|
||||||
} from "../lib/format";
|
} from "../lib/format";
|
||||||
import { renderDescription } from "../lib/descriptionLinks";
|
import { renderDescription } from "../lib/descriptionLinks";
|
||||||
|
import { fullscreenElement } from "../lib/fullscreen";
|
||||||
|
import { useBackToClose } from "../lib/history";
|
||||||
import {
|
import {
|
||||||
exitFullscreen,
|
useNativeControlsYield,
|
||||||
fullscreenElement,
|
useYtBigView,
|
||||||
onFullscreenChange,
|
useYtProgress,
|
||||||
requestFullscreen,
|
useYtVolume,
|
||||||
} from "../lib/fullscreen";
|
} from "../lib/playerModalHooks";
|
||||||
import { useBackToClose, useBackToExit } from "../lib/history";
|
|
||||||
import { DEFAULT_VOLUME, normalizeVolume, stepVolume } from "../lib/playerVolume";
|
|
||||||
import { LS, readAccountMerged, writeAccount } from "../lib/storage";
|
|
||||||
import { useScrollFade } from "../lib/useScrollFade";
|
import { useScrollFade } from "../lib/useScrollFade";
|
||||||
|
|
||||||
// Experiment (branch experiment/inline-player): play the video in-app via the
|
// Experiment (branch experiment/inline-player): play the video in-app via the
|
||||||
@@ -57,119 +59,6 @@ import { useScrollFade } from "../lib/useScrollFade";
|
|||||||
// button, the backdrop, or ESC (ESC only while focus is on our page, not inside
|
// button, the backdrop, or ESC (ESC only while focus is on our page, not inside
|
||||||
// the cross-origin player iframe — a browser security boundary we can't cross).
|
// the cross-origin player iframe — a browser security boundary we can't cross).
|
||||||
|
|
||||||
// How close to the end (seconds) counts as "finished" → auto-mark watched.
|
|
||||||
const FINISH_MARGIN = 10;
|
|
||||||
|
|
||||||
// Persistent playback settings (stored in users.preferences). Auto-advance = what plays when a
|
|
||||||
// video ends; loop = whether it repeats the current video ("one"), wraps the list at its ends
|
|
||||||
// ("all"), or neither ("off"). Both apply to any queued player (feed or playlist).
|
|
||||||
type AutoMode = "off" | "next" | "prev" | "random";
|
|
||||||
type LoopMode = "off" | "one" | "all";
|
|
||||||
const AUTO_MODES: AutoMode[] = ["off", "next", "prev", "random"];
|
|
||||||
const LOOP_MODES: LoopMode[] = ["off", "one", "all"];
|
|
||||||
|
|
||||||
/** The queue transport cluster — prev / counter / next, then the auto-advance and loop mode toggles.
|
|
||||||
* Rendered in two places with identical logic: the modal's queue bar (`variant:"modal"`, normal glass
|
|
||||||
* theme, labels always shown) and the big-view docked control bar (`variant:"docked"`, white-on-dark,
|
|
||||||
* labels hidden until a breakpoint). Only the theme + label-visibility differ; the behaviour is one,
|
|
||||||
* so a mode/icon change lands once. The wrapper (fragment vs. bordered row) stays at the call site. */
|
|
||||||
function PlayerTransportControls({
|
|
||||||
variant,
|
|
||||||
index,
|
|
||||||
queueLength,
|
|
||||||
autoMode,
|
|
||||||
loopMode,
|
|
||||||
onPrev,
|
|
||||||
onNext,
|
|
||||||
onCycleAuto,
|
|
||||||
onCycleLoop,
|
|
||||||
}: {
|
|
||||||
variant: "docked" | "modal";
|
|
||||||
index: number;
|
|
||||||
queueLength: number;
|
|
||||||
autoMode: AutoMode;
|
|
||||||
loopMode: LoopMode;
|
|
||||||
onPrev: () => void;
|
|
||||||
onNext: () => void;
|
|
||||||
onCycleAuto: () => void;
|
|
||||||
onCycleLoop: () => void;
|
|
||||||
}) {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const docked = variant === "docked";
|
|
||||||
const navBtn = docked
|
|
||||||
? "inline-flex items-center gap-1 text-white/80 enabled:hover:text-white disabled:opacity-30 transition"
|
|
||||||
: "inline-flex items-center gap-1.5 text-muted enabled:hover:text-fg disabled:opacity-40 transition";
|
|
||||||
const navLabel = docked ? "hidden sm:inline" : "";
|
|
||||||
const counter = docked ? "tabular-nums text-white/70" : "text-muted tabular-nums";
|
|
||||||
const divider = docked ? "h-4 w-px bg-white/20" : "w-px h-4 bg-border";
|
|
||||||
const toggle = (on: boolean) =>
|
|
||||||
docked
|
|
||||||
? `inline-flex items-center gap-1 rounded-lg px-2 py-1 transition ${
|
|
||||||
on ? "bg-white/20 text-white" : "text-white/70 hover:bg-white/10 hover:text-white"
|
|
||||||
}`
|
|
||||||
: `inline-flex items-center gap-1.5 rounded-lg px-2.5 py-1 transition ${
|
|
||||||
on ? "bg-accent/15 text-accent" : "text-muted hover:bg-surface hover:text-fg"
|
|
||||||
}`;
|
|
||||||
const toggleLabel = docked ? "hidden md:inline" : "";
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<button
|
|
||||||
onClick={onPrev}
|
|
||||||
disabled={index === 0}
|
|
||||||
title={`${t("player.previous")} · Shift+←`}
|
|
||||||
className={navBtn}
|
|
||||||
>
|
|
||||||
<SkipBack className="h-4 w-4" />
|
|
||||||
<span className={navLabel}>{t("player.previous")}</span>
|
|
||||||
</button>
|
|
||||||
<span className={counter}>
|
|
||||||
{index + 1} / {queueLength}
|
|
||||||
</span>
|
|
||||||
<button
|
|
||||||
onClick={onNext}
|
|
||||||
disabled={index === queueLength - 1}
|
|
||||||
title={`${t("player.next")} · Shift+→`}
|
|
||||||
className={navBtn}
|
|
||||||
>
|
|
||||||
<span className={navLabel}>{t("player.next")}</span>
|
|
||||||
<SkipForward className="h-4 w-4" />
|
|
||||||
</button>
|
|
||||||
<span className={divider} />
|
|
||||||
{/* Persistent playback settings — cycle on click, saved to your account. */}
|
|
||||||
<button
|
|
||||||
onClick={onCycleAuto}
|
|
||||||
title={t("player.autoAdvance.hint")}
|
|
||||||
className={toggle(autoMode !== "off")}
|
|
||||||
>
|
|
||||||
{autoMode === "prev" ? (
|
|
||||||
<SkipBack className="h-3.5 w-3.5" />
|
|
||||||
) : autoMode === "random" ? (
|
|
||||||
<Shuffle className="h-3.5 w-3.5" />
|
|
||||||
) : (
|
|
||||||
<SkipForward className="h-3.5 w-3.5" />
|
|
||||||
)}
|
|
||||||
<span className={toggleLabel}>
|
|
||||||
{t("player.autoAdvance.label")}: {t(`player.autoAdvance.${autoMode}`)}
|
|
||||||
</span>
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={onCycleLoop}
|
|
||||||
title={t("player.loop.hint")}
|
|
||||||
className={toggle(loopMode !== "off")}
|
|
||||||
>
|
|
||||||
{loopMode === "one" ? (
|
|
||||||
<Repeat1 className="h-3.5 w-3.5" />
|
|
||||||
) : (
|
|
||||||
<Repeat className="h-3.5 w-3.5" />
|
|
||||||
)}
|
|
||||||
<span className={toggleLabel}>
|
|
||||||
{t("player.loop.label")}: {t(`player.loop.${loopMode}`)}
|
|
||||||
</span>
|
|
||||||
</button>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Can `el` (or an ancestor inside the modal) still scroll vertically in `dir` (-1 up, 1 down)?
|
/** Can `el` (or an ancestor inside the modal) still scroll vertically in `dir` (-1 up, 1 down)?
|
||||||
* Used to leave ArrowUp/ArrowDown alone while there is content to scroll — the volume shortcut
|
* Used to leave ArrowUp/ArrowDown alone while there is content to scroll — the volume shortcut
|
||||||
* must not eat the only keyboard way to read a long description. Stops at `<body>`: the page
|
* must not eat the only keyboard way to read a long description. Stops at `<body>`: the page
|
||||||
@@ -185,26 +74,6 @@ function canScrollBy(el: HTMLElement | null, dir: -1 | 1): boolean {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- IFrame Player API loader (singleton) ---
|
|
||||||
let apiPromise: Promise<YTNamespace> | null = null;
|
|
||||||
function loadYouTubeApi(): Promise<YTNamespace> {
|
|
||||||
if (apiPromise) return apiPromise;
|
|
||||||
apiPromise = new Promise<YTNamespace>((resolve) => {
|
|
||||||
const w = window;
|
|
||||||
if (w.YT && w.YT.Player) return resolve(w.YT);
|
|
||||||
const prev = w.onYouTubeIframeAPIReady;
|
|
||||||
w.onYouTubeIframeAPIReady = () => {
|
|
||||||
if (typeof prev === "function") prev();
|
|
||||||
// The API-ready callback only fires once the script has installed window.YT.
|
|
||||||
resolve(w.YT!);
|
|
||||||
};
|
|
||||||
const tag = document.createElement("script");
|
|
||||||
tag.src = "https://www.youtube.com/iframe_api";
|
|
||||||
document.head.appendChild(tag);
|
|
||||||
});
|
|
||||||
return apiPromise;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function PlayerModal({
|
export default function PlayerModal({
|
||||||
video,
|
video,
|
||||||
startAt,
|
startAt,
|
||||||
@@ -264,11 +133,6 @@ export default function PlayerModal({
|
|||||||
startAt != null && active.id === video.id ? startAt : active.position_seconds || 0;
|
startAt != null && active.id === video.id ? startAt : active.position_seconds || 0;
|
||||||
const mountRef = useRef<HTMLDivElement | null>(null);
|
const mountRef = useRef<HTMLDivElement | null>(null);
|
||||||
const playerRef = useRef<YTPlayer | null>(null);
|
const playerRef = useRef<YTPlayer | null>(null);
|
||||||
const autoMarkedRef = useRef(false);
|
|
||||||
// Bound consecutive unplayable items so an auto-advancing queue (esp. loop=all) can't spin over a
|
|
||||||
// run of dead videos — after the cap we stop on the error overlay instead of skipping forever.
|
|
||||||
const MAX_CONSECUTIVE_ERRORS = 3;
|
|
||||||
const errorStreakRef = useRef(0);
|
|
||||||
// Keyboard/wheel controls. We keep focus on the modal card (not the cross-origin player
|
// Keyboard/wheel controls. We keep focus on the modal card (not the cross-origin player
|
||||||
// iframe) so F / Space / Esc keep working until the user clicks into YouTube's native
|
// iframe) so F / Space / Esc keep working until the user clicks into YouTube's native
|
||||||
// controls; the interaction overlay below also stops the iframe stealing focus on a video
|
// controls; the interaction overlay below also stops the iframe stealing focus on a video
|
||||||
@@ -278,52 +142,6 @@ export default function PlayerModal({
|
|||||||
// The whole modal (backdrop + card) — the wheel-to-volume target, so scrolling anywhere over the
|
// The whole modal (backdrop + card) — the wheel-to-volume target, so scrolling anywhere over the
|
||||||
// modal adjusts volume (not just the small player area).
|
// modal adjusts volume (not just the small player area).
|
||||||
const dialogRef = useRef<HTMLDivElement | null>(null);
|
const dialogRef = useRef<HTMLDivElement | null>(null);
|
||||||
const volTimerRef = useRef<number | undefined>(undefined);
|
|
||||||
// Volume level to flash in the on-player overlay (null = hidden). Auto-fades after a moment.
|
|
||||||
const [volumeUi, setVolumeUi] = useState<number | null>(null);
|
|
||||||
// When the user interacts with YouTube's own controls (gear/seek/CC), focus moves into the
|
|
||||||
// cross-origin player iframe. We detect that and drop the interaction overlay's pointer-events
|
|
||||||
// so YouTube's settings menu — which expands DOWN over the video, past our overlay — is fully
|
|
||||||
// navigable (previously the overlay ate clicks below the top items). Moving the pointer off the
|
|
||||||
// video, or the window regaining focus, re-arms our click/scroll/keyboard shortcuts.
|
|
||||||
const [nativeControls, setNativeControls] = useState(false);
|
|
||||||
// True while OUR stage element is the fullscreen element. In fullscreen the interaction overlay
|
|
||||||
// yields entirely (see the overlay's pointer-events below): YouTube's control bar only appears
|
|
||||||
// while the iframe itself sees mouse movement, so an armed overlay kept the bar — and with it
|
|
||||||
// the native fullscreen button — permanently hidden after pressing F.
|
|
||||||
const [isFullscreen, setIsFullscreen] = useState(false);
|
|
||||||
// Our own "fills the viewport" mode (the F key). Not the Fullscreen API — see toggleFullscreen.
|
|
||||||
const [maximized, setMaximized] = useState(false);
|
|
||||||
const maximizedRef = useRef(false);
|
|
||||||
maximizedRef.current = maximized;
|
|
||||||
// Mirrored into a ref because the keydown handler is bound once (deps: [onClose]) and would
|
|
||||||
// otherwise read `false` forever — the house pattern here (see navRef/volumeRef).
|
|
||||||
const isFullscreenRef = useRef(false);
|
|
||||||
// Nested Back: peel fullscreen → maximise → close, one browser/mouse Back at a time (each is a
|
|
||||||
// history layer only while active). Mirrors the Esc ladder in the keydown handler below.
|
|
||||||
useBackToExit(isFullscreen, exitFullscreen);
|
|
||||||
useBackToExit(maximized, () => setMaximized(false));
|
|
||||||
// Volume survives the modal, the next video and a reload: a YT.Player is created per video and
|
|
||||||
// always starts at full volume. Per-account, like the Plex player's prefs. Level 0 IS the muted
|
|
||||||
// state (that's how YouTube's own control behaves), so there's no separate `muted` flag to
|
|
||||||
// contradict it.
|
|
||||||
//
|
|
||||||
// Ref + explicit read/write rather than `useAccountPersistedObject`: nothing RENDERS the level
|
|
||||||
// (the on-player flash has its own `volumeUi` state), the once-bound wheel/key listeners need a
|
|
||||||
// ref anyway, and the hook's write lives inside a `setState` updater — which is both a second
|
|
||||||
// write on every settle and not something React must run while the component is unmounting.
|
|
||||||
// Sanitised HERE, at the boundary where the untrusted value enters (`normalizeVolume`, see
|
|
||||||
// lib/playerVolume): a stored `null` / `"80"` / missing key becomes the default once, instead of
|
|
||||||
// travelling into the arithmetic. Read via a lazy `useState` initializer so it happens once, not
|
|
||||||
// on every render (the house pattern — see the frozen queue above).
|
|
||||||
const [storedVolume] = useState(() =>
|
|
||||||
normalizeVolume(readAccountMerged(LS.ytPlayerPrefs, { volume: DEFAULT_VOLUME }).volume),
|
|
||||||
);
|
|
||||||
const volumeRef = useRef(storedVolume);
|
|
||||||
const volSaveTimerRef = useRef<number | undefined>(undefined);
|
|
||||||
// When the last volume gesture happened — see nudgeVolume (a spin must not re-read the player).
|
|
||||||
const lastNudgeAtRef = useRef(0);
|
|
||||||
|
|
||||||
const focusModal = () => cardRef.current?.focus();
|
const focusModal = () => cardRef.current?.focus();
|
||||||
const togglePlay = () => {
|
const togglePlay = () => {
|
||||||
const p = playerRef.current;
|
const p = playerRef.current;
|
||||||
@@ -332,84 +150,28 @@ export default function PlayerModal({
|
|||||||
p.pauseVideo?.(); // 1 === playing
|
p.pauseVideo?.(); // 1 === playing
|
||||||
else p.playVideo?.();
|
else p.playVideo?.();
|
||||||
};
|
};
|
||||||
/** F = maximise, deliberately WITHOUT the Fullscreen API.
|
// Per-account volume (wheel over the whole modal + ↑/↓): the level lives in the hook's ref and is
|
||||||
*
|
// re-applied to each freshly-created player via reapplyStored (onReady).
|
||||||
* The measured reason (Chrome/Brave/Firefox alike, so it is spec behaviour, not a browser bug):
|
const { volumeUi, reapplyStored, nudgeVolume } = useYtVolume({
|
||||||
* a cross-origin embed reads its fullscreen state from ITS OWN document. While WE hold the
|
playerRef,
|
||||||
* browser's fullscreen lock — on our wrapper or even on the iframe element — the YouTube player
|
dialogRef,
|
||||||
* never learns it is fullscreen, so its own fullscreen button stays in the "enter" state and a
|
focusModal,
|
||||||
* click on it changes nothing visible. That is the reported bug: every other YouTube control
|
});
|
||||||
* (gear, CC, volume) works, only its fullscreen button looks dead.
|
const { nativeControls, setNativeControls } = useNativeControlsYield({
|
||||||
*
|
playerRef,
|
||||||
* So we don't take the lock at all: `maximized` is a CSS layer that fills the viewport. The page
|
stageRef,
|
||||||
* stays non-fullscreen, which means the embed's own fullscreen button works exactly as it does
|
focusModal,
|
||||||
* anywhere else — and OUR shortcuts and overlays (wheel volume, the level flash, the queue bar)
|
});
|
||||||
* keep working, because our DOM is what's on screen. The one thing we give up is hiding the
|
const {
|
||||||
* browser's own chrome; YouTube's button provides that, correctly, when the user wants it.
|
isFullscreen,
|
||||||
*/
|
maximized,
|
||||||
const toggleFullscreen = () => setMaximized((m) => !m);
|
maximizedRef,
|
||||||
// True browser fullscreen that WE own (the stage element). Unlike YouTube's own fullscreen it keeps
|
isFullscreenRef,
|
||||||
// OUR overlay + control bar on screen, so wheel/arrow volume and our controls work inside it too.
|
toggleFullscreen,
|
||||||
// We never surface YouTube's own fullscreen button, so the "a cross-origin embed's own button looks
|
toggleTrueFullscreen,
|
||||||
// dead while we hold the lock" problem that made F avoid the Fullscreen API does not arise.
|
exitBigView,
|
||||||
const toggleTrueFullscreen = () => {
|
setMaximized,
|
||||||
if (fullscreenElement()) exitFullscreen();
|
} = useYtBigView({ stageRef, playerRef, focusModal });
|
||||||
else if (stageRef.current) requestFullscreen(stageRef.current);
|
|
||||||
};
|
|
||||||
// Leave the big view back to the modal card: drop fullscreen first if held, then un-maximise.
|
|
||||||
const exitBigView = () => {
|
|
||||||
if (fullscreenElement()) exitFullscreen();
|
|
||||||
setMaximized(false);
|
|
||||||
};
|
|
||||||
const flashVolume = (level: number) => {
|
|
||||||
setVolumeUi(level);
|
|
||||||
window.clearTimeout(volTimerRef.current);
|
|
||||||
volTimerRef.current = window.setTimeout(() => setVolumeUi(null), 1200);
|
|
||||||
};
|
|
||||||
/** Write the current level out now, cancelling any pending debounce. */
|
|
||||||
const persistVolume = () => {
|
|
||||||
window.clearTimeout(volSaveTimerRef.current);
|
|
||||||
volSaveTimerRef.current = undefined;
|
|
||||||
writeAccount(LS.ytPlayerPrefs, { volume: volumeRef.current });
|
|
||||||
};
|
|
||||||
/** Push a level (0–100) to the player and remember it; 0 also mutes (a player left at zero
|
|
||||||
* volume but unmuted shows YouTube's speaker icon as ON, which reads as broken). */
|
|
||||||
const applyVolume = (level: number, flash = true) => {
|
|
||||||
// Callers hand over an already-normalised level (stepVolume / the stored value), but the
|
|
||||||
// player can still answer NaN, so re-normalise here against the LAST GOOD level — never the
|
|
||||||
// default: jumping a quiet session to full blast is the surprise this feature exists to avoid.
|
|
||||||
const next = normalizeVolume(level, volumeRef.current);
|
|
||||||
const p = playerRef.current;
|
|
||||||
if (p && typeof p.setVolume === "function") {
|
|
||||||
if (next > 0) p.unMute?.();
|
|
||||||
p.setVolume(next);
|
|
||||||
if (next === 0) p.mute?.();
|
|
||||||
}
|
|
||||||
volumeRef.current = next;
|
|
||||||
// Debounced: one write when the spin settles, not one per notch. The unmount cleanup flushes
|
|
||||||
// a pending write, so the last change is never lost.
|
|
||||||
window.clearTimeout(volSaveTimerRef.current);
|
|
||||||
volSaveTimerRef.current = window.setTimeout(persistVolume, 400);
|
|
||||||
if (flash) flashVolume(next);
|
|
||||||
};
|
|
||||||
const nudgeVolume = (delta: number) => {
|
|
||||||
// No early return when the player isn't ready yet: the level lives in `volumeRef` and onReady
|
|
||||||
// applies it, so a wheel spin in the first second after opening (the natural reaction to a
|
|
||||||
// video that starts loud) now lands instead of being silently dropped.
|
|
||||||
const p = playerRef.current;
|
|
||||||
// Whose level do we step from? Our own ref, EXCEPT when the gesture starts fresh — then read
|
|
||||||
// the player, because the user may have moved YouTube's own slider in the meantime.
|
|
||||||
// `getVolume()` is answered across the iframe boundary and lags our `setVolume` by a beat, so
|
|
||||||
// re-reading it mid-spin resurrects a stale level and silently drops notches (measured: five
|
|
||||||
// wheel notches moved 100 → 90 instead of 75).
|
|
||||||
const now = Date.now();
|
|
||||||
const continuing = now - lastNudgeAtRef.current < 1000;
|
|
||||||
lastNudgeAtRef.current = now;
|
|
||||||
const live =
|
|
||||||
p && typeof p.getVolume === "function" ? (p.isMuted?.() ? 0 : p.getVolume()) : null;
|
|
||||||
const cur = continuing || live === null ? volumeRef.current : live;
|
|
||||||
applyVolume(stepVolume(cur, { delta, fallback: volumeRef.current }));
|
|
||||||
};
|
|
||||||
|
|
||||||
// The player can navigate to other videos (YouTube links in a description). The
|
// The player can navigate to other videos (YouTube links in a description). The
|
||||||
// currently-playing id may differ from the active item.
|
// currently-playing id may differ from the active item.
|
||||||
@@ -481,36 +243,18 @@ export default function PlayerModal({
|
|||||||
p.seekTo(Math.max(0, (p.getCurrentTime() || 0) + delta), true);
|
p.seekTo(Math.max(0, (p.getCurrentTime() || 0) + delta), true);
|
||||||
};
|
};
|
||||||
|
|
||||||
// Auto-advance + loop are persistent per-user settings. Read the current values from the cached
|
// Auto-advance + loop are persistent per-user settings (usePref: server + `me` cache, adopted on
|
||||||
// `me`, mirror them in local state for instant UI, and write both to the server + cache on change
|
// login) so they apply to every player and survive reloads. The player mounts after `me` is cached,
|
||||||
// so they apply to every player and survive reloads. A ref feeds the (once-bound) end handler.
|
// so it seeds from the cache — no localStorage mirror. A ref feeds the (once-bound) end handler.
|
||||||
const savedPrefs = useMemo(
|
const [autoMode, setAutoMode] = usePref<AutoMode>("playerAutoAdvance", "off");
|
||||||
() =>
|
const [loopMode, setLoopMode] = usePref<LoopMode>("playerLoop", "off");
|
||||||
(qc.getQueryData<{ preferences?: Record<string, unknown> }>(qk.me())?.preferences ?? {}) as {
|
|
||||||
playerAutoAdvance?: AutoMode;
|
|
||||||
playerLoop?: LoopMode;
|
|
||||||
},
|
|
||||||
[qc],
|
|
||||||
);
|
|
||||||
const [autoMode, setAutoMode] = useState<AutoMode>(savedPrefs.playerAutoAdvance ?? "off");
|
|
||||||
const [loopMode, setLoopMode] = useState<LoopMode>(savedPrefs.playerLoop ?? "off");
|
|
||||||
const modeRef = useRef({ autoMode, loopMode });
|
const modeRef = useRef({ autoMode, loopMode });
|
||||||
modeRef.current = { autoMode, loopMode };
|
modeRef.current = { autoMode, loopMode };
|
||||||
const persistPref = (patch: Record<string, string>) => {
|
|
||||||
api.savePrefs(patch).catch(() => {});
|
|
||||||
qc.setQueryData<{ preferences?: Record<string, unknown> } | undefined>(qk.me(), (m) =>
|
|
||||||
m ? { ...m, preferences: { ...(m.preferences || {}), ...patch } } : m,
|
|
||||||
);
|
|
||||||
};
|
|
||||||
const cycleAuto = () => {
|
const cycleAuto = () => {
|
||||||
const next = AUTO_MODES[(AUTO_MODES.indexOf(autoMode) + 1) % AUTO_MODES.length]!;
|
setAutoMode(AUTO_MODES[(AUTO_MODES.indexOf(autoMode) + 1) % AUTO_MODES.length]!);
|
||||||
setAutoMode(next);
|
|
||||||
persistPref({ playerAutoAdvance: next });
|
|
||||||
};
|
};
|
||||||
const cycleLoop = () => {
|
const cycleLoop = () => {
|
||||||
const next = LOOP_MODES[(LOOP_MODES.indexOf(loopMode) + 1) % LOOP_MODES.length]!;
|
setLoopMode(LOOP_MODES[(LOOP_MODES.indexOf(loopMode) + 1) % LOOP_MODES.length]!);
|
||||||
setLoopMode(next);
|
|
||||||
persistPref({ playerLoop: next });
|
|
||||||
};
|
};
|
||||||
const replayCurrent = () => {
|
const replayCurrent = () => {
|
||||||
const p = playerRef.current;
|
const p = playerRef.current;
|
||||||
@@ -656,232 +400,29 @@ export default function PlayerModal({
|
|||||||
return () => {
|
return () => {
|
||||||
window.removeEventListener("keydown", onKey);
|
window.removeEventListener("keydown", onKey);
|
||||||
document.body.style.overflow = prevOverflow;
|
document.body.style.overflow = prevOverflow;
|
||||||
window.clearTimeout(volTimerRef.current);
|
// (The debounced volume write is flushed by useYtVolume's own unmount cleanup.)
|
||||||
// Flush a debounced volume write — closing the player right after a wheel spin (the normal
|
|
||||||
// way to leave) must not drop the level the user just set.
|
|
||||||
if (volSaveTimerRef.current != null) persistVolume();
|
|
||||||
};
|
};
|
||||||
}, [onClose]);
|
}, [onClose]);
|
||||||
|
|
||||||
// Mouse-wheel volume over the WHOLE modal (backdrop + card), not just the small player area.
|
// Create the player, resume from the saved position, checkpoint progress, and auto-mark watched at
|
||||||
// Non-passive so preventDefault stops the modal/page scrolling. Note: the cross-origin iframe
|
// the end — recreated per active item. The once-bound event closures read fresh queue/mode/volume via
|
||||||
// swallows wheel events over its own native-control strips, but the central interaction overlay
|
// the refs passed in. See lib/playerModalHooks.
|
||||||
// is our element (wheel there bubbles here), and everything outside the video works directly.
|
useYtProgress({
|
||||||
useEffect(() => {
|
activeId: active.id,
|
||||||
const el = dialogRef.current;
|
resumeAt,
|
||||||
if (!el) return;
|
mountRef,
|
||||||
const onWheel = (e: WheelEvent) => {
|
playerRef,
|
||||||
// Ctrl/⌘+wheel is the browser's zoom gesture — never swallow it. Volume is the PLAIN wheel
|
currentIdRef,
|
||||||
// (and ↑/↓); taking the modifier too left zoom unreachable while the player was open.
|
navRef,
|
||||||
if (e.ctrlKey || e.metaKey) return;
|
modeRef,
|
||||||
e.preventDefault();
|
setWatched,
|
||||||
focusModal(); // re-grab focus if it had slipped into the native controls
|
setPlayerError,
|
||||||
nudgeVolume(e.deltaY < 0 ? 5 : -5);
|
setLiveData,
|
||||||
};
|
reapplyStored,
|
||||||
el.addEventListener("wheel", onWheel, { passive: false });
|
focusModal,
|
||||||
return () => el.removeEventListener("wheel", onWheel);
|
advanceOnEnd,
|
||||||
}, []);
|
qc,
|
||||||
|
});
|
||||||
// Track the browser's fullscreen state. WE never request it any more (F maximises via CSS), so
|
|
||||||
// this is YouTube's own fullscreen — entered from its control bar, left by its button or Esc.
|
|
||||||
useEffect(() => {
|
|
||||||
const onChange = () => {
|
|
||||||
const fsEl = fullscreenElement();
|
|
||||||
// Refs only — this listener is bound once, so it must not close over render values.
|
|
||||||
const p = playerRef.current;
|
|
||||||
const frame = p && typeof p.getIframe === "function" ? p.getIframe() : null;
|
|
||||||
const ours = !!fsEl && (fsEl === stageRef.current || fsEl === frame);
|
|
||||||
isFullscreenRef.current = ours; // read by the once-bound keydown handler
|
|
||||||
setIsFullscreen(ours); // drives the overlay's pointer-events
|
|
||||||
// LEAVING fullscreen is where the user got stranded: to reach YouTube's fullscreen button
|
|
||||||
// they had to click INTO the player, which moves keyboard focus into the cross-origin iframe
|
|
||||||
// — and it stays there afterwards. Our window-level shortcuts then never fire (F went to
|
|
||||||
// YouTube instead, giving a second, control-less fullscreen), so the maximised view had no
|
|
||||||
// way out. Taking focus back here restores F/Space/arrows the moment fullscreen ends.
|
|
||||||
if (!fsEl) focusModal();
|
|
||||||
};
|
|
||||||
return onFullscreenChange(onChange); // both spellings — see lib/fullscreen
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// Yield the interaction overlay to YouTube's native UI. Clicking a native control (gear / seek
|
|
||||||
// bar / CC) moves focus into the player iframe — the only cross-origin signal we get. While the
|
|
||||||
// iframe holds focus we drop the overlay's pointer-events so the (arbitrarily tall) settings
|
|
||||||
// menu is clickable end to end. Re-arm — restoring click-to-play, wheel volume and our keyboard
|
|
||||||
// shortcuts — when the pointer leaves the video or the window regains focus (a click back on our
|
|
||||||
// card blurs the iframe → the parent window fires 'focus').
|
|
||||||
useEffect(() => {
|
|
||||||
const iframeFocused = () => {
|
|
||||||
const p = playerRef.current;
|
|
||||||
const f = p && typeof p.getIframe === "function" ? p.getIframe() : null;
|
|
||||||
return !!f && document.activeElement === f;
|
|
||||||
};
|
|
||||||
// activeElement settles on the tick after blur, so defer the check.
|
|
||||||
const onBlur = () => window.setTimeout(() => iframeFocused() && setNativeControls(true), 0);
|
|
||||||
const rearm = () => {
|
|
||||||
setNativeControls(false);
|
|
||||||
focusModal();
|
|
||||||
};
|
|
||||||
const stage = stageRef.current;
|
|
||||||
window.addEventListener("blur", onBlur);
|
|
||||||
window.addEventListener("focus", rearm);
|
|
||||||
stage?.addEventListener("mouseleave", rearm);
|
|
||||||
return () => {
|
|
||||||
window.removeEventListener("blur-sm", onBlur);
|
|
||||||
window.removeEventListener("focus", rearm);
|
|
||||||
stage?.removeEventListener("mouseleave", rearm);
|
|
||||||
};
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// Create the player, resume from the saved position, persist progress, and
|
|
||||||
// auto-mark watched once playback reaches the end.
|
|
||||||
useEffect(() => {
|
|
||||||
let cancelled = false;
|
|
||||||
const id = active.id;
|
|
||||||
autoMarkedRef.current = false;
|
|
||||||
setPlayerError(null);
|
|
||||||
|
|
||||||
// Auto-watch only applies to the active item — not to other videos the player
|
|
||||||
// navigated to via description links.
|
|
||||||
const maybeAutoWatch = (current: number, duration: number): boolean => {
|
|
||||||
if (autoMarkedRef.current || duration <= 0 || currentIdRef.current !== id) return false;
|
|
||||||
// Cap the finish margin at half the clip, so a video shorter than FINISH_MARGIN isn't
|
|
||||||
// auto-marked watched almost immediately (its threshold would otherwise go negative).
|
|
||||||
const margin = Math.min(FINISH_MARGIN, duration / 2);
|
|
||||||
if (current > duration - margin) {
|
|
||||||
autoMarkedRef.current = true;
|
|
||||||
setWatched(true);
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
};
|
|
||||||
const persist = (): Promise<unknown> | void => {
|
|
||||||
const p = playerRef.current;
|
|
||||||
if (!p || typeof p.getCurrentTime !== "function") return;
|
|
||||||
try {
|
|
||||||
const cur = p.getCurrentTime();
|
|
||||||
const dur = typeof p.getDuration === "function" ? p.getDuration() : 0;
|
|
||||||
// If we just auto-marked watched, skip the near-end progress checkpoint: it would
|
|
||||||
// only clear the resume position, and firing both at once needlessly races the
|
|
||||||
// watched write against the progress row it deletes.
|
|
||||||
if (maybeAutoWatch(cur, dur)) return;
|
|
||||||
// Only checkpoint the feed video we opened with — a navigated-to video may not
|
|
||||||
// be in this user's library, so the server would 404 on it.
|
|
||||||
if (currentIdRef.current === id) {
|
|
||||||
return api.saveProgress(id, cur, dur).catch(() => {});
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
/* player may be tearing down */
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
loadYouTubeApi().then((YT) => {
|
|
||||||
if (cancelled || !mountRef.current) return;
|
|
||||||
playerRef.current = new YT.Player(mountRef.current, {
|
|
||||||
width: "100%",
|
|
||||||
height: "100%",
|
|
||||||
videoId: id,
|
|
||||||
playerVars: {
|
|
||||||
autoplay: 1,
|
|
||||||
start: resumeAt || undefined,
|
|
||||||
rel: 0, // limit "related" to the same channel (full removal is no longer possible)
|
|
||||||
enablejsapi: 1,
|
|
||||||
origin: window.location.origin,
|
|
||||||
playsinline: 1,
|
|
||||||
// Best-effort HD hint. The IFrame API's setPlaybackQuality/suggestedQuality are hard no-ops
|
|
||||||
// now (YouTube removed them), and the undocumented `vq` URL param is only occasionally
|
|
||||||
// honoured — kept because it's harmless. NOTE: YouTube hard-caps an embed's max quality by
|
|
||||||
// the player's on-screen size; a windowed embed is stuck ~360p and only true fullscreen
|
|
||||||
// lifts the cap (after which the higher pick persists for the session). We can't beat that
|
|
||||||
// programmatically — a CSS transform-scale trick was tried and does NOT fool the cap.
|
|
||||||
vq: "hd1080",
|
|
||||||
},
|
|
||||||
events: {
|
|
||||||
// Keep keyboard focus on the modal, not the iframe, so shortcuts work right away, and
|
|
||||||
// restore the remembered volume — a fresh player always starts at 100.
|
|
||||||
onReady: () => {
|
|
||||||
focusModal();
|
|
||||||
applyVolume(volumeRef.current, false);
|
|
||||||
},
|
|
||||||
onStateChange: (e: YTPlayerEvent) => {
|
|
||||||
// 1 === playing → sync the navigated-to video's title/author for display.
|
|
||||||
if (e?.data === 1) {
|
|
||||||
errorStreakRef.current = 0; // a successful play breaks any unplayable run
|
|
||||||
const p = playerRef.current;
|
|
||||||
const d = p && typeof p.getVideoData === "function" ? p.getVideoData() : null;
|
|
||||||
if (d) setLiveData({ title: d.title, author: d.author });
|
|
||||||
}
|
|
||||||
// 0 === ended → mark watched, then advance to the next queue item if any.
|
|
||||||
if (e?.data === 0 && currentIdRef.current === id) {
|
|
||||||
if (!autoMarkedRef.current) {
|
|
||||||
autoMarkedRef.current = true;
|
|
||||||
setWatched(true);
|
|
||||||
}
|
|
||||||
advanceOnEnd();
|
|
||||||
}
|
|
||||||
},
|
|
||||||
// The embed couldn't play this video (embedding disabled, removed, private…).
|
|
||||||
// Surface our own message + an "Open on YouTube" escape hatch.
|
|
||||||
onError: (e: YTPlayerEvent) => {
|
|
||||||
setPlayerError(typeof e?.data === "number" ? e.data : -1);
|
|
||||||
// The IFrame API fires onError (not 'ended') for an unplayable item, so advanceOnEnd()
|
|
||||||
// never runs and an auto-advancing queue would freeze here. Skip forward instead — but
|
|
||||||
// bounded, so a run of dead items (or loop=all over a broken queue) can't spin. We do
|
|
||||||
// NOT mark the broken video watched (only the ended-handler does that).
|
|
||||||
if (currentIdRef.current !== id) return; // a navigated description-link error: leave the queue alone
|
|
||||||
const { autoMode: a } = modeRef.current;
|
|
||||||
const { queue: q } = navRef.current;
|
|
||||||
if (a === "off" || !q || q.length <= 1) return;
|
|
||||||
errorStreakRef.current += 1;
|
|
||||||
if (errorStreakRef.current >= MAX_CONSECUTIVE_ERRORS) return; // give up; leave the overlay
|
|
||||||
advanceOnEnd();
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// Periodic checkpoint so progress (and auto-watch) survive a crash/refresh.
|
|
||||||
const timer = window.setInterval(persist, 5000);
|
|
||||||
// Save on page unload too (F5/close/navigate): effect cleanup doesn't run on a full reload, so
|
|
||||||
// without this the resume point lags up to 5s (a seek right before F5 would be lost). A keepalive
|
|
||||||
// beacon so the POST survives the unload. Only for the library item we opened with — a navigated
|
|
||||||
// description-link video may not be in this user's library (the server would 404).
|
|
||||||
const onHide = () => {
|
|
||||||
const p = playerRef.current;
|
|
||||||
if (!p || typeof p.getCurrentTime !== "function" || currentIdRef.current !== id) return;
|
|
||||||
try {
|
|
||||||
const cur = p.getCurrentTime();
|
|
||||||
const dur = typeof p.getDuration === "function" ? p.getDuration() : 0;
|
|
||||||
if (cur > 0 && dur > 0) api.saveProgressBeacon(id, cur, dur);
|
|
||||||
} catch {
|
|
||||||
/* player tearing down */
|
|
||||||
}
|
|
||||||
};
|
|
||||||
window.addEventListener("pagehide", onHide);
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
cancelled = true;
|
|
||||||
window.clearInterval(timer);
|
|
||||||
window.removeEventListener("pagehide", onHide);
|
|
||||||
// Final flush, then mark the feed + playlists stale so resume bars reflect this session on the
|
|
||||||
// next natural refetch — WITHOUT refetching every loaded feed page on each player close (PERF-2:
|
|
||||||
// opening/closing the player through a watch session otherwise re-runs the whole infinite query
|
|
||||||
// each time). Progress is already persisted server-side; only the on-screen resume bar waits.
|
|
||||||
Promise.resolve(persist()).finally(() => {
|
|
||||||
qc.invalidateQueries({ queryKey: qk.feed(), refetchType: "none" });
|
|
||||||
qc.invalidateQueries({ queryKey: qk.playlist(), refetchType: "none" });
|
|
||||||
});
|
|
||||||
const p = playerRef.current;
|
|
||||||
if (p && typeof p.destroy === "function") {
|
|
||||||
try {
|
|
||||||
p.destroy();
|
|
||||||
} catch {
|
|
||||||
/* ignore */
|
|
||||||
}
|
|
||||||
}
|
|
||||||
playerRef.current = null;
|
|
||||||
};
|
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
||||||
}, [active.id]);
|
|
||||||
|
|
||||||
// Portaled to <body>, like every other full-screen overlay here. A fixed, full-viewport modal
|
// Portaled to <body>, like every other full-screen overlay here. A fixed, full-viewport modal
|
||||||
// must not render inside the page scroller: once that scroller is a stacking context (its
|
// must not render inside the page scroller: once that scroller is a stacking context (its
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import { createContext, useCallback, useContext, useMemo, useState, type ReactNode } from "react";
|
import { createContext, useCallback, useContext, useMemo, useState, type ReactNode } from "react";
|
||||||
import { getAccountRaw, LS, setAccountRaw } from "../lib/storage";
|
import { getAccountRaw, LS, setAccountRaw } from "../lib/storage";
|
||||||
import { useAccountRehydrate } from "../lib/useAccountRehydrate";
|
import { useAccountRehydrate } from "../lib/useAccountRehydrate";
|
||||||
|
import { useUrlMirror } from "../lib/useUrlMirror";
|
||||||
|
import { useNavigation } from "./NavigationProvider";
|
||||||
|
|
||||||
// Owns the Playlists module's shared local state — the rail's name filter and the selected playlist
|
// Owns the Playlists module's shared local state — the rail's name filter and the selected playlist
|
||||||
// — lifted out of the App god component so the header search box, the PlaylistsRail, and the
|
// — lifted out of the App god component so the header search box, the PlaylistsRail, and the
|
||||||
@@ -37,6 +39,12 @@ export function usePlaylistsActions(): PlaylistsActions {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function readSelectedId(): number | null {
|
function readSelectedId(): number | null {
|
||||||
|
// A ?pl bookmark wins over the stored selection so a pasted link opens that playlist. Accept only a
|
||||||
|
// positive integer id — a bare "?pl=" gives Number("")===0 (finite!) and "?pl=1.5" a fractional id,
|
||||||
|
// both of which would select a non-existent playlist.
|
||||||
|
const fromUrl = new URLSearchParams(window.location.search).get("pl");
|
||||||
|
const n = fromUrl ? Number(fromUrl) : NaN;
|
||||||
|
if (Number.isInteger(n) && n > 0) return n;
|
||||||
const s = getAccountRaw(LS.playlist);
|
const s = getAccountRaw(LS.playlist);
|
||||||
return s ? Number(s) : null;
|
return s ? Number(s) : null;
|
||||||
}
|
}
|
||||||
@@ -59,6 +67,12 @@ export function PlaylistsProvider({ children }: { children: ReactNode }) {
|
|||||||
[markDirty],
|
[markDirty],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Mirror the selected playlist to ?pl on the Playlists page so it's bookmarkable; clear it elsewhere
|
||||||
|
// (the localStorage copy still restores it on return). useUrlMirror keeps ?page and the other nav
|
||||||
|
// params intact.
|
||||||
|
const { page } = useNavigation();
|
||||||
|
useUrlMirror({ pl: page === "playlists" && selectedId != null ? String(selectedId) : null });
|
||||||
|
|
||||||
const state = useMemo<PlaylistsState>(() => ({ search, selectedId }), [search, selectedId]);
|
const state = useMemo<PlaylistsState>(() => ({ search, selectedId }), [search, selectedId]);
|
||||||
const actions = useMemo<PlaylistsActions>(
|
const actions = useMemo<PlaylistsActions>(
|
||||||
() => ({ setSearch, setSelectedId }),
|
() => ({ setSearch, setSelectedId }),
|
||||||
|
|||||||
@@ -8,9 +8,9 @@ import { formatDuration } from "../lib/format";
|
|||||||
import { notify } from "../lib/notifications";
|
import { notify } from "../lib/notifications";
|
||||||
import { notifyYouTubeActionError } from "../lib/youtubeErrors";
|
import { notifyYouTubeActionError } from "../lib/youtubeErrors";
|
||||||
import { LS, readAccountMerged, writeAccount } from "../lib/storage";
|
import { LS, readAccountMerged, writeAccount } from "../lib/storage";
|
||||||
import { defaultLayout, type PanelLayout } from "../lib/panelLayout";
|
import { type PanelLayout } from "../lib/panelLayout";
|
||||||
import SidePanel from "./SidePanel";
|
import FilterRail from "./FilterRail";
|
||||||
import PanelGroups, { type PanelItem } from "./PanelGroups";
|
import { type PanelItem } from "./PanelGroups";
|
||||||
import SortControl from "./SortControl";
|
import SortControl from "./SortControl";
|
||||||
import { type SortOption } from "../lib/columnSort";
|
import { type SortOption } from "../lib/columnSort";
|
||||||
import { usePlaylists, usePlaylistsActions } from "./PlaylistsProvider";
|
import { usePlaylists, usePlaylistsActions } from "./PlaylistsProvider";
|
||||||
@@ -58,7 +58,6 @@ export default function PlaylistsRail({
|
|||||||
}, [plSort]);
|
}, [plSort]);
|
||||||
const [newName, setNewName] = useState("");
|
const [newName, setNewName] = useState("");
|
||||||
const [syncing, setSyncing] = useState(false);
|
const [syncing, setSyncing] = useState(false);
|
||||||
const [editing, setEditing] = useState(false);
|
|
||||||
const selectedRef = useRef<HTMLButtonElement | null>(null);
|
const selectedRef = useRef<HTMLButtonElement | null>(null);
|
||||||
const scrolledRef = useRef(false);
|
const scrolledRef = useRef(false);
|
||||||
|
|
||||||
@@ -260,17 +259,15 @@ export default function PlaylistsRail({
|
|||||||
];
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SidePanel
|
<FilterRail
|
||||||
|
kind="playlists"
|
||||||
title={t("playlists.title")}
|
title={t("playlists.title")}
|
||||||
icon={<ListVideo className="w-4 h-4" />}
|
icon={<ListVideo className="w-4 h-4" />}
|
||||||
collapsed={collapsed}
|
collapsed={collapsed}
|
||||||
onToggleCollapse={onToggleCollapse}
|
onToggleCollapse={onToggleCollapse}
|
||||||
editing={editing}
|
layout={layout}
|
||||||
onToggleEditing={() => setEditing((e) => !e)}
|
setLayout={setLayout}
|
||||||
onReset={() => setLayout(defaultLayout("playlists"))}
|
items={items}
|
||||||
editHint={t("sidebar.editHint")}
|
/>
|
||||||
>
|
|
||||||
<PanelGroups items={items} layout={layout} setLayout={setLayout} editing={editing} />
|
|
||||||
</SidePanel>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -25,7 +25,8 @@ vi.mock("../lib/api", () => ({
|
|||||||
plexFilterCount: () => 0,
|
plexFilterCount: () => 0,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
import { CastStrip, PlexPersonView } from "./PlexBrowse";
|
import { CastStrip } from "./plexCards";
|
||||||
|
import { PlexPersonView } from "./plexViews";
|
||||||
|
|
||||||
afterEach(cleanup);
|
afterEach(cleanup);
|
||||||
|
|
||||||
|
|||||||
@@ -5,11 +5,12 @@ import { plexLang } from "../i18n";
|
|||||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import { Layers, ListMusic, Plus, SlidersHorizontal, X } from "lucide-react";
|
import { Layers, ListMusic, Plus, SlidersHorizontal, X } from "lucide-react";
|
||||||
import { api, plexFilterCount, type PlexFilters } from "../lib/api";
|
import { api, plexFilterCount, type PlexFilters } from "../lib/api";
|
||||||
import { defaultLayout, type PanelLayout } from "../lib/panelLayout";
|
import { type PanelLayout } from "../lib/panelLayout";
|
||||||
|
import { type MediaSourceKind } from "../lib/mediaSource";
|
||||||
import { useDebounced } from "../lib/useDebounced";
|
import { useDebounced } from "../lib/useDebounced";
|
||||||
import { useScrollFade } from "../lib/useScrollFade";
|
import { useScrollFade } from "../lib/useScrollFade";
|
||||||
import SidePanel from "./SidePanel";
|
import FilterRail from "./FilterRail";
|
||||||
import PanelGroups, { type PanelItem } from "./PanelGroups";
|
import { type PanelItem } from "./PanelGroups";
|
||||||
import SegmentedToggle from "./SegmentedToggle";
|
import SegmentedToggle from "./SegmentedToggle";
|
||||||
import FilterChipGroup, { filterChipClass } from "./FilterChipGroup";
|
import FilterChipGroup, { filterChipClass } from "./FilterChipGroup";
|
||||||
import { usePlexClearFilters } from "./usePlexActiveFilters";
|
import { usePlexClearFilters } from "./usePlexActiveFilters";
|
||||||
@@ -70,7 +71,9 @@ export default function PlexSidebar({ layout, setLayout, collapsed, onToggleColl
|
|||||||
const collFade = useScrollFade();
|
const collFade = useScrollFade();
|
||||||
const playlistsQ = useQuery({ queryKey: qk.plexPlaylists(), queryFn: () => api.plexPlaylists() });
|
const playlistsQ = useQuery({ queryKey: qk.plexPlaylists(), queryFn: () => api.plexPlaylists() });
|
||||||
const [newPlaylist, setNewPlaylist] = useState("");
|
const [newPlaylist, setNewPlaylist] = useState("");
|
||||||
const [editing, setEditing] = useState(false);
|
// This rail's media source (the Plex library) — names the abstraction the feed and Plex rails
|
||||||
|
// share; drives the panel layout key. `FilterRail` owns the customize `editing` state now.
|
||||||
|
const source: MediaSourceKind = "plex";
|
||||||
const qc = useQueryClient();
|
const qc = useQueryClient();
|
||||||
async function createPlaylist() {
|
async function createPlaylist() {
|
||||||
const title = newPlaylist.trim();
|
const title = newPlaylist.trim();
|
||||||
@@ -402,16 +405,16 @@ export default function PlexSidebar({ layout, setLayout, collapsed, onToggleColl
|
|||||||
];
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SidePanel
|
<FilterRail
|
||||||
|
kind={source}
|
||||||
title={t("sidebar.filters")}
|
title={t("sidebar.filters")}
|
||||||
icon={<SlidersHorizontal className="w-4 h-4" />}
|
icon={<SlidersHorizontal className="w-4 h-4" />}
|
||||||
count={activeCount}
|
count={activeCount}
|
||||||
collapsed={collapsed}
|
collapsed={collapsed}
|
||||||
onToggleCollapse={onToggleCollapse}
|
onToggleCollapse={onToggleCollapse}
|
||||||
editing={editing}
|
layout={layout}
|
||||||
onToggleEditing={() => setEditing((e) => !e)}
|
setLayout={setLayout}
|
||||||
onReset={() => setLayout(defaultLayout("plex"))}
|
items={items}
|
||||||
editHint={t("sidebar.editHint")}
|
|
||||||
actions={
|
actions={
|
||||||
anyActive ? (
|
anyActive ? (
|
||||||
<button
|
<button
|
||||||
@@ -423,9 +426,7 @@ export default function PlexSidebar({ layout, setLayout, collapsed, onToggleColl
|
|||||||
</button>
|
</button>
|
||||||
) : undefined
|
) : undefined
|
||||||
}
|
}
|
||||||
>
|
/>
|
||||||
<PanelGroups items={items} layout={layout} setLayout={setLayout} editing={editing} />
|
|
||||||
</SidePanel>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { useEffect, useState } from "react";
|
|||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import { qk } from "../lib/queryKeys";
|
import { qk } from "../lib/queryKeys";
|
||||||
import { BookmarkPlus, Check, GripVertical, Pencil, Share2, Star, Trash2, X } from "lucide-react";
|
import { BookmarkPlus, Check, GripVertical, Pencil, Star, Trash2, X } from "lucide-react";
|
||||||
import {
|
import {
|
||||||
closestCenter,
|
closestCenter,
|
||||||
DndContext,
|
DndContext,
|
||||||
@@ -19,10 +19,8 @@ import {
|
|||||||
} from "@dnd-kit/sortable";
|
} from "@dnd-kit/sortable";
|
||||||
import { CSS } from "@dnd-kit/utilities";
|
import { CSS } from "@dnd-kit/utilities";
|
||||||
import { api, getActiveAccount, type FeedFilters, type SavedView } from "../lib/api";
|
import { api, getActiveAccount, type FeedFilters, type SavedView } from "../lib/api";
|
||||||
import { filtersToParams, shareUrl } from "../lib/urlState";
|
import { filtersToParams } from "../lib/urlState";
|
||||||
import { notify } from "../lib/notifications";
|
|
||||||
import { notifyActionError } from "../lib/actionErrors";
|
import { notifyActionError } from "../lib/actionErrors";
|
||||||
import { copyText } from "../lib/clipboard";
|
|
||||||
import { accountKey, LS, writeJSON } from "../lib/storage";
|
import { accountKey, LS, writeJSON } from "../lib/storage";
|
||||||
import { useConfirm } from "./ConfirmProvider";
|
import { useConfirm } from "./ConfirmProvider";
|
||||||
|
|
||||||
@@ -131,14 +129,6 @@ export default function SavedViewsWidget({
|
|||||||
await runViewMutation(() => api.deleteView(v.id), t("views.actionFailed"));
|
await runViewMutation(() => api.deleteView(v.id), t("views.actionFailed"));
|
||||||
}
|
}
|
||||||
|
|
||||||
async function share(v: SavedView) {
|
|
||||||
if (await copyText(shareUrl(asFilters(v.filters)))) {
|
|
||||||
notify({ level: "success", message: t("views.shareCopied") });
|
|
||||||
} else {
|
|
||||||
notify({ level: "warning", message: t("sidebar.shareFailed") });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function onDragEnd(e: DragEndEvent) {
|
async function onDragEnd(e: DragEndEvent) {
|
||||||
const { active, over } = e;
|
const { active, over } = e;
|
||||||
if (!over || active.id === over.id) return;
|
if (!over || active.id === over.id) return;
|
||||||
@@ -177,7 +167,6 @@ export default function SavedViewsWidget({
|
|||||||
onRename={(name) => rename(v.id, name)}
|
onRename={(name) => rename(v.id, name)}
|
||||||
onCancelRename={() => setRenameId(null)}
|
onCancelRename={() => setRenameId(null)}
|
||||||
onToggleDefault={() => toggleDefault(v)}
|
onToggleDefault={() => toggleDefault(v)}
|
||||||
onShare={() => share(v)}
|
|
||||||
onDelete={() => remove(v)}
|
onDelete={() => remove(v)}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
@@ -239,7 +228,6 @@ function ViewRow({
|
|||||||
onRename,
|
onRename,
|
||||||
onCancelRename,
|
onCancelRename,
|
||||||
onToggleDefault,
|
onToggleDefault,
|
||||||
onShare,
|
|
||||||
onDelete,
|
onDelete,
|
||||||
}: {
|
}: {
|
||||||
view: SavedView;
|
view: SavedView;
|
||||||
@@ -250,7 +238,6 @@ function ViewRow({
|
|||||||
onRename: (name: string) => void;
|
onRename: (name: string) => void;
|
||||||
onCancelRename: () => void;
|
onCancelRename: () => void;
|
||||||
onToggleDefault: () => void;
|
onToggleDefault: () => void;
|
||||||
onShare: () => void;
|
|
||||||
onDelete: () => void;
|
onDelete: () => void;
|
||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
@@ -334,29 +321,22 @@ function ViewRow({
|
|||||||
className={`p-1 rounded transition ${
|
className={`p-1 rounded transition ${
|
||||||
view.is_default
|
view.is_default
|
||||||
? "text-accent"
|
? "text-accent"
|
||||||
: "text-muted opacity-0 group-hover:opacity-100 group-focus-within:opacity-100 hover:text-accent"
|
: "text-muted reveal hover:text-accent"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<Star className={`w-3.5 h-3.5 ${view.is_default ? "fill-current" : ""}`} />
|
<Star className={`w-3.5 h-3.5 ${view.is_default ? "fill-current" : ""}`} />
|
||||||
</button>
|
</button>
|
||||||
<button
|
|
||||||
onClick={onShare}
|
|
||||||
title={t("views.share")}
|
|
||||||
className="p-1 rounded-sm text-muted opacity-0 group-hover:opacity-100 group-focus-within:opacity-100 hover:text-fg transition"
|
|
||||||
>
|
|
||||||
<Share2 className="w-3.5 h-3.5" />
|
|
||||||
</button>
|
|
||||||
<button
|
<button
|
||||||
onClick={onStartRename}
|
onClick={onStartRename}
|
||||||
title={t("views.rename")}
|
title={t("views.rename")}
|
||||||
className="p-1 rounded-sm text-muted opacity-0 group-hover:opacity-100 group-focus-within:opacity-100 hover:text-fg transition"
|
className="p-1 rounded-sm text-muted reveal hover:text-fg transition"
|
||||||
>
|
>
|
||||||
<Pencil className="w-3.5 h-3.5" />
|
<Pencil className="w-3.5 h-3.5" />
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={onDelete}
|
onClick={onDelete}
|
||||||
title={t("views.delete")}
|
title={t("views.delete")}
|
||||||
className="p-1 rounded-sm text-muted opacity-0 group-hover:opacity-100 group-focus-within:opacity-100 hover:text-danger transition"
|
className="p-1 rounded-sm text-muted reveal hover:text-danger transition"
|
||||||
>
|
>
|
||||||
<Trash2 className="w-3.5 h-3.5" />
|
<Trash2 className="w-3.5 h-3.5" />
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -198,7 +198,7 @@ function JobRow({
|
|||||||
className="group text-muted font-normal hover:text-accent inline-flex items-center gap-1 transition"
|
className="group text-muted font-normal hover:text-accent inline-flex items-center gap-1 transition"
|
||||||
>
|
>
|
||||||
· {t("scheduler.everyMin", { count: job.interval_minutes })}
|
· {t("scheduler.everyMin", { count: job.interval_minutes })}
|
||||||
<Pencil className="w-3 h-3 opacity-0 group-hover:opacity-100 transition" />
|
<Pencil className="w-3 h-3 reveal transition" />
|
||||||
</button>
|
</button>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -2,19 +2,17 @@ import { type ReactNode, useState } from "react";
|
|||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { keepPreviousData, useQuery } from "@tanstack/react-query";
|
import { keepPreviousData, useQuery } from "@tanstack/react-query";
|
||||||
import { qk } from "../lib/queryKeys";
|
import { qk } from "../lib/queryKeys";
|
||||||
import { Library, Pencil, Share2, SlidersHorizontal, User } from "lucide-react";
|
import { Library, Pencil, SlidersHorizontal, User } from "lucide-react";
|
||||||
import { api, type FeedFilters, type Tag } from "../lib/api";
|
import { api, type FeedFilters, type Tag } from "../lib/api";
|
||||||
import { defaultLayout, type PanelLayout } from "../lib/panelLayout";
|
import { defaultLayout, type PanelLayout } from "../lib/panelLayout";
|
||||||
import { shareUrl } from "../lib/urlState";
|
|
||||||
import { copyText } from "../lib/clipboard";
|
|
||||||
import { notify } from "../lib/notifications";
|
|
||||||
import { useDebounced } from "../lib/useDebounced";
|
import { useDebounced } from "../lib/useDebounced";
|
||||||
import { clearedFilters, useActiveFilters } from "../lib/useActiveFilters";
|
import { clearedFilters, useActiveFilters } from "../lib/useActiveFilters";
|
||||||
import { applyDateChip, deriveDateKey } from "../lib/dateFilter";
|
import { applyDateChip, deriveDateKey } from "../lib/dateFilter";
|
||||||
import TagManager from "./TagManager";
|
import TagManager from "./TagManager";
|
||||||
import SavedViewsWidget from "./SavedViewsWidget";
|
import SavedViewsWidget from "./SavedViewsWidget";
|
||||||
import SidePanel from "./SidePanel";
|
import FilterRail from "./FilterRail";
|
||||||
import PanelGroups, { type PanelItem } from "./PanelGroups";
|
import { type PanelItem } from "./PanelGroups";
|
||||||
|
import { type MediaSourceKind } from "../lib/mediaSource";
|
||||||
import ChannelPicker from "./ChannelPicker";
|
import ChannelPicker from "./ChannelPicker";
|
||||||
import SegmentedToggle from "./SegmentedToggle";
|
import SegmentedToggle from "./SegmentedToggle";
|
||||||
import FilterChipGroup, { filterChipClass } from "./FilterChipGroup";
|
import FilterChipGroup, { filterChipClass } from "./FilterChipGroup";
|
||||||
@@ -111,7 +109,9 @@ export default function Sidebar({
|
|||||||
const userTags = tags.filter((t) => !t.system);
|
const userTags = tags.filter((t) => !t.system);
|
||||||
const [customDates, setCustomDates] = useState(false);
|
const [customDates, setCustomDates] = useState(false);
|
||||||
const [tagManagerOpen, setTagManagerOpen] = useState(false);
|
const [tagManagerOpen, setTagManagerOpen] = useState(false);
|
||||||
const [editing, setEditing] = useState(false);
|
// This rail's media source (the feed). Names the abstraction the feed and Plex rails share; drives
|
||||||
|
// the panel layout key. `FilterRail` owns the customize `editing` state now.
|
||||||
|
const source: MediaSourceKind = "feed";
|
||||||
|
|
||||||
function toggleTag(id: number) {
|
function toggleTag(id: number) {
|
||||||
const has = filters.tags.includes(id);
|
const has = filters.tags.includes(id);
|
||||||
@@ -126,14 +126,6 @@ export default function Sidebar({
|
|||||||
const activeCount = useActiveFilters(filters, setFilters).length;
|
const activeCount = useActiveFilters(filters, setFilters).length;
|
||||||
const active = activeCount > 0;
|
const active = activeCount > 0;
|
||||||
|
|
||||||
async function shareView() {
|
|
||||||
if (await copyText(shareUrl(filters))) {
|
|
||||||
notify({ level: "success", message: t("sidebar.shareCopied") });
|
|
||||||
} else {
|
|
||||||
notify({ level: "warning", message: t("sidebar.shareFailed") });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function clearAll() {
|
function clearAll() {
|
||||||
setCustomDates(false);
|
setCustomDates(false);
|
||||||
setFilters(clearedFilters(filters));
|
setFilters(clearedFilters(filters));
|
||||||
@@ -308,7 +300,7 @@ export default function Sidebar({
|
|||||||
|
|
||||||
// Group ids come from the shared default order (panelLayout) so there's one source of truth;
|
// Group ids come from the shared default order (panelLayout) so there's one source of truth;
|
||||||
// the actual render order is the user's saved layout (PanelGroups orders by it).
|
// the actual render order is the user's saved layout (PanelGroups orders by it).
|
||||||
const items: PanelItem[] = defaultLayout("feed")
|
const items: PanelItem[] = defaultLayout(source)
|
||||||
.order.filter((id) => available[id])
|
.order.filter((id) => available[id])
|
||||||
.map((id) => ({ id, title: t("sidebar.widget." + id), body: widgetBody(id) }));
|
.map((id) => ({ id, title: t("sidebar.widget." + id), body: widgetBody(id) }));
|
||||||
|
|
||||||
@@ -321,58 +313,49 @@ export default function Sidebar({
|
|||||||
>
|
>
|
||||||
{t("sidebar.clearAll")}
|
{t("sidebar.clearAll")}
|
||||||
</button>
|
</button>
|
||||||
<button
|
|
||||||
onClick={shareView}
|
|
||||||
title={t("sidebar.shareView")}
|
|
||||||
aria-label={t("sidebar.shareView")}
|
|
||||||
className="p-1.5 rounded-lg text-muted hover:text-fg hover:bg-card transition"
|
|
||||||
>
|
|
||||||
<Share2 className="w-4 h-4" />
|
|
||||||
</button>
|
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SidePanel
|
<FilterRail
|
||||||
|
kind={source}
|
||||||
title={t("sidebar.filters")}
|
title={t("sidebar.filters")}
|
||||||
icon={<SlidersHorizontal className="w-4 h-4" />}
|
icon={<SlidersHorizontal className="w-4 h-4" />}
|
||||||
count={activeCount}
|
count={activeCount}
|
||||||
collapsed={collapsed}
|
collapsed={collapsed}
|
||||||
onToggleCollapse={onToggleCollapse}
|
onToggleCollapse={onToggleCollapse}
|
||||||
editing={editing}
|
layout={layout}
|
||||||
onToggleEditing={() => setEditing((e) => !e)}
|
setLayout={setLayout}
|
||||||
onReset={() => setLayout(defaultLayout("feed"))}
|
items={items}
|
||||||
editHint={t("sidebar.editHint")}
|
|
||||||
actions={actions}
|
actions={actions}
|
||||||
|
top={
|
||||||
|
/* Scope: your own subscriptions (Mine) vs the shared Library. */
|
||||||
|
<SegmentedToggle
|
||||||
|
ariaLabel={t("header.scope.label")}
|
||||||
|
fullWidth
|
||||||
|
size="md"
|
||||||
|
options={[
|
||||||
|
{
|
||||||
|
value: "my",
|
||||||
|
label: t("header.scope.my"),
|
||||||
|
icon: <User className="w-3.5 h-3.5" />,
|
||||||
|
title: t("header.scope.myTip"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "all",
|
||||||
|
label: t("header.scope.all"),
|
||||||
|
icon: <Library className="w-3.5 h-3.5" />,
|
||||||
|
title: t("header.scope.allTip"),
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
value={filters.scope}
|
||||||
|
onChange={(scope) => setFilters({ ...filters, scope })}
|
||||||
|
/>
|
||||||
|
}
|
||||||
>
|
>
|
||||||
{/* Scope: your own subscriptions (Mine) vs the shared Library. */}
|
|
||||||
<SegmentedToggle
|
|
||||||
ariaLabel={t("header.scope.label")}
|
|
||||||
fullWidth
|
|
||||||
size="md"
|
|
||||||
options={[
|
|
||||||
{
|
|
||||||
value: "my",
|
|
||||||
label: t("header.scope.my"),
|
|
||||||
icon: <User className="w-3.5 h-3.5" />,
|
|
||||||
title: t("header.scope.myTip"),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
value: "all",
|
|
||||||
label: t("header.scope.all"),
|
|
||||||
icon: <Library className="w-3.5 h-3.5" />,
|
|
||||||
title: t("header.scope.allTip"),
|
|
||||||
},
|
|
||||||
]}
|
|
||||||
value={filters.scope}
|
|
||||||
onChange={(scope) => setFilters({ ...filters, scope })}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<PanelGroups items={items} layout={layout} setLayout={setLayout} editing={editing} />
|
|
||||||
|
|
||||||
{tagManagerOpen && (
|
{tagManagerOpen && (
|
||||||
<TagManager onClose={() => setTagManagerOpen(false)} onFocusChannel={onFocusChannel} />
|
<TagManager onClose={() => setTagManagerOpen(false)} onFocusChannel={onFocusChannel} />
|
||||||
)}
|
)}
|
||||||
</SidePanel>
|
</FilterRail>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -62,7 +62,7 @@ function Actions({
|
|||||||
// rather than silently rendering a half-clipped control outside the card.
|
// rather than silently rendering a half-clipped control outside the card.
|
||||||
<div
|
<div
|
||||||
className={clsx(
|
className={clsx(
|
||||||
"flex flex-wrap opacity-0 group-hover:opacity-100 transition",
|
"flex flex-wrap reveal transition",
|
||||||
dense ? "gap-0.5" : "gap-1",
|
dense ? "gap-0.5" : "gap-1",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -0,0 +1,267 @@
|
|||||||
|
import { useMemo, useState } from "react";
|
||||||
|
import { qk } from "../lib/queryKeys";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import { Settings2 } from "lucide-react";
|
||||||
|
import Modal from "./Modal";
|
||||||
|
import { useLiveQuery } from "../lib/useLiveQuery";
|
||||||
|
import { api } from "../lib/api";
|
||||||
|
import { formatBytes } from "../lib/format";
|
||||||
|
import { inputCls } from "./ui/form";
|
||||||
|
import { StatusPill, jobTitle } from "./downloadRow";
|
||||||
|
|
||||||
|
// The admin "System" tab — storage overview, per-user footprint, quota editor, and the global job
|
||||||
|
// list. Split out of DownloadCenter (self-contained, admin-only surface).
|
||||||
|
|
||||||
|
const GiB = 1024 ** 3;
|
||||||
|
|
||||||
|
function QuotaModal({
|
||||||
|
userId,
|
||||||
|
email,
|
||||||
|
onClose,
|
||||||
|
}: {
|
||||||
|
userId: number;
|
||||||
|
email: string;
|
||||||
|
onClose: () => void;
|
||||||
|
}) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const qc = useQueryClient();
|
||||||
|
const q = useQuery({
|
||||||
|
queryKey: qk.dlQuota(userId),
|
||||||
|
queryFn: () => api.adminGetDownloadQuota(userId),
|
||||||
|
});
|
||||||
|
const [form, setForm] = useState<{
|
||||||
|
gb: number;
|
||||||
|
jobs: number;
|
||||||
|
conc: number;
|
||||||
|
unlimited: boolean;
|
||||||
|
} | null>(null);
|
||||||
|
const cur = q.data;
|
||||||
|
const state =
|
||||||
|
form ??
|
||||||
|
(cur
|
||||||
|
? {
|
||||||
|
gb: Math.round((cur.max_bytes / GiB) * 10) / 10,
|
||||||
|
jobs: cur.max_jobs,
|
||||||
|
conc: cur.max_concurrent,
|
||||||
|
unlimited: cur.unlimited,
|
||||||
|
}
|
||||||
|
: null);
|
||||||
|
const done = () => {
|
||||||
|
qc.invalidateQueries({ queryKey: qk.dlQuota(userId) });
|
||||||
|
qc.invalidateQueries({ queryKey: qk.adminStorage() });
|
||||||
|
onClose();
|
||||||
|
};
|
||||||
|
const save = useMutation({
|
||||||
|
mutationFn: () =>
|
||||||
|
api.adminSetDownloadQuota(userId, {
|
||||||
|
max_bytes: Math.round(state!.gb * GiB),
|
||||||
|
max_jobs: state!.jobs,
|
||||||
|
max_concurrent: state!.conc,
|
||||||
|
unlimited: state!.unlimited,
|
||||||
|
}),
|
||||||
|
onSuccess: done,
|
||||||
|
});
|
||||||
|
const reset = useMutation({
|
||||||
|
mutationFn: () => api.adminResetDownloadQuota(userId),
|
||||||
|
onSuccess: done,
|
||||||
|
});
|
||||||
|
if (!state) return null;
|
||||||
|
const upd = (p: Partial<typeof state>) => setForm({ ...state, ...p });
|
||||||
|
return (
|
||||||
|
<Modal title={t("downloads.admin.quotaTitle", { email })} onClose={onClose}>
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="grid grid-cols-3 gap-3">
|
||||||
|
<label className="text-sm">
|
||||||
|
{t("downloads.admin.maxBytes")}
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={state.gb}
|
||||||
|
onChange={(e) => upd({ gb: Number(e.target.value) })}
|
||||||
|
className={inputCls}
|
||||||
|
disabled={state.unlimited}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="text-sm">
|
||||||
|
{t("downloads.admin.maxJobs")}
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={state.jobs}
|
||||||
|
onChange={(e) => upd({ jobs: Number(e.target.value) })}
|
||||||
|
className={inputCls}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="text-sm">
|
||||||
|
{t("downloads.admin.maxConcurrent")}
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={state.conc}
|
||||||
|
onChange={(e) => upd({ conc: Number(e.target.value) })}
|
||||||
|
className={inputCls}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={state.unlimited}
|
||||||
|
onChange={(e) => upd({ unlimited: e.target.checked })}
|
||||||
|
/>
|
||||||
|
{t("downloads.admin.unlimited")}
|
||||||
|
</label>
|
||||||
|
<div className="flex justify-between pt-1">
|
||||||
|
<button
|
||||||
|
onClick={() => reset.mutate()}
|
||||||
|
className="px-3 py-1.5 rounded-lg text-sm text-muted hover:text-fg hover:bg-surface transition"
|
||||||
|
>
|
||||||
|
{t("downloads.admin.reset")}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => save.mutate()}
|
||||||
|
disabled={save.isPending}
|
||||||
|
className="px-3 py-1.5 rounded-lg text-sm font-medium btn-primary disabled:opacity-50 transition"
|
||||||
|
>
|
||||||
|
{t("downloads.admin.save")}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pick ANY user to set a download quota for — even one who hasn't downloaded anything yet (the
|
||||||
|
// footprint list below only shows users who already have files).
|
||||||
|
function QuotaUserPicker({ onPick }: { onPick: (u: { id: number; email: string }) => void }) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const [q, setQ] = useState("");
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const users = useQuery({ queryKey: qk.adminUsers(), queryFn: api.adminUsers });
|
||||||
|
const list = (users.data ?? []).filter((u) => !u.is_demo);
|
||||||
|
const filtered = useMemo(() => {
|
||||||
|
const s = q.trim().toLowerCase();
|
||||||
|
const base = s
|
||||||
|
? list.filter(
|
||||||
|
(u) =>
|
||||||
|
u.email.toLowerCase().includes(s) || (u.display_name ?? "").toLowerCase().includes(s),
|
||||||
|
)
|
||||||
|
: list;
|
||||||
|
return base.slice(0, 8);
|
||||||
|
}, [q, list]);
|
||||||
|
return (
|
||||||
|
<div className="relative w-full sm:w-72">
|
||||||
|
<input
|
||||||
|
value={q}
|
||||||
|
onChange={(e) => {
|
||||||
|
setQ(e.target.value);
|
||||||
|
setOpen(true);
|
||||||
|
}}
|
||||||
|
onFocus={() => setOpen(true)}
|
||||||
|
onBlur={() => setTimeout(() => setOpen(false), 150)}
|
||||||
|
placeholder={t("downloads.admin.quotaPickPlaceholder")}
|
||||||
|
className={inputCls}
|
||||||
|
/>
|
||||||
|
{open && filtered.length > 0 && (
|
||||||
|
<div className="absolute z-base mt-1 w-full rounded-lg glass-menu overflow-hidden">
|
||||||
|
{filtered.map((u) => (
|
||||||
|
<button
|
||||||
|
key={u.id}
|
||||||
|
onClick={() => {
|
||||||
|
onPick({ id: u.id, email: u.email });
|
||||||
|
setQ("");
|
||||||
|
setOpen(false);
|
||||||
|
}}
|
||||||
|
className="w-full text-left px-3 py-2 text-sm hover:bg-surface transition flex flex-col"
|
||||||
|
>
|
||||||
|
<span className="truncate">{u.display_name || u.email}</span>
|
||||||
|
{u.display_name && <span className="text-xs text-muted truncate">{u.email}</span>}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AdminSystem() {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const storage = useLiveQuery(qk.adminStorage(), api.adminDownloadStorage, { intervalMs: 5000 });
|
||||||
|
const jobs = useLiveQuery(qk.adminDownloads(), api.adminDownloads, { intervalMs: 5000 });
|
||||||
|
const [quotaFor, setQuotaFor] = useState<{ id: number; email: string } | null>(null);
|
||||||
|
const s = storage.data;
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
{s && (
|
||||||
|
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3 mb-5">
|
||||||
|
{[
|
||||||
|
[t("downloads.admin.readyFiles"), String(s.ready_files)],
|
||||||
|
[t("downloads.admin.totalSize"), formatBytes(s.total_bytes)],
|
||||||
|
[
|
||||||
|
t("downloads.admin.cap"),
|
||||||
|
s.total_cap_bytes ? formatBytes(s.total_cap_bytes) : t("downloads.admin.noCap"),
|
||||||
|
],
|
||||||
|
].map(([label, val]) => (
|
||||||
|
<div key={label} className="rounded-xl glass-card p-3">
|
||||||
|
<div className="text-xs text-muted">{label}</div>
|
||||||
|
<div className="text-lg font-semibold mt-0.5">{val}</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{s && (
|
||||||
|
<div className="mb-6">
|
||||||
|
<div className="flex items-center justify-between gap-3 mb-2 flex-wrap">
|
||||||
|
<div className="text-sm font-medium">{t("downloads.admin.perUser")}</div>
|
||||||
|
{/* Set a quota for any user, regardless of whether they've downloaded anything. */}
|
||||||
|
<QuotaUserPicker onPick={setQuotaFor} />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
{s.per_user.map((u) => (
|
||||||
|
<div
|
||||||
|
key={u.user_id}
|
||||||
|
className="flex items-center gap-2 text-sm px-3 py-1.5 rounded-lg bg-card/40"
|
||||||
|
>
|
||||||
|
<span className="flex-1 truncate">{u.email}</span>
|
||||||
|
<span className="text-muted">{formatBytes(u.footprint_bytes)}</span>
|
||||||
|
<button
|
||||||
|
onClick={() => setQuotaFor({ id: u.user_id, email: u.email ?? "" })}
|
||||||
|
className="p-1 rounded-sm hover:bg-surface text-muted hover:text-fg"
|
||||||
|
title={t("downloads.admin.editQuota")}
|
||||||
|
>
|
||||||
|
<Settings2 className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{s.per_user.length === 0 && (
|
||||||
|
<div className="text-xs text-muted px-3 py-2">{t("downloads.admin.noFootprint")}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="text-sm font-medium mb-2">{t("downloads.tabs.system")}</div>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
{(jobs.data ?? []).map((j) => (
|
||||||
|
<div
|
||||||
|
key={j.id}
|
||||||
|
className="flex items-center gap-3 text-sm px-3 py-1.5 rounded-lg bg-card/40"
|
||||||
|
>
|
||||||
|
<span className="flex-1 truncate">{jobTitle(j)}</span>
|
||||||
|
<span className="text-muted truncate w-40">{j.user_email}</span>
|
||||||
|
<StatusPill job={j} />
|
||||||
|
<span className="text-muted w-16 text-right">
|
||||||
|
{j.asset?.size_bytes ? formatBytes(j.asset.size_bytes) : ""}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{jobs.data && jobs.data.length === 0 && (
|
||||||
|
<div className="text-sm text-muted py-6 text-center">{t("downloads.empty.admin")}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{quotaFor && (
|
||||||
|
<QuotaModal userId={quotaFor.id} email={quotaFor.email} onClose={() => setQuotaFor(null)} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,178 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import { qk } from "../lib/queryKeys";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import { Plus, Trash2 } from "lucide-react";
|
||||||
|
import Modal from "./Modal";
|
||||||
|
import { api, type DownloadJob } from "../lib/api";
|
||||||
|
import { inputCls } from "./ui/form";
|
||||||
|
import { IconBtn } from "./downloadRow";
|
||||||
|
|
||||||
|
// Per-item user modals split out of DownloadCenter (self-contained, prop-driven): rename/metadata
|
||||||
|
// editing and the failure-detail window.
|
||||||
|
|
||||||
|
// Edit a download's display metadata: title, channel (name + link), and extra reference links.
|
||||||
|
// All are display-only overrides — the on-disk file is never renamed.
|
||||||
|
export function MetaEditModal({ job, onClose }: { job: DownloadJob; onClose: () => void }) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const qc = useQueryClient();
|
||||||
|
const [name, setName] = useState(job.display_name || job.asset?.title || "");
|
||||||
|
const [channel, setChannel] = useState(job.display_uploader || job.asset?.uploader || "");
|
||||||
|
const [channelUrl, setChannelUrl] = useState(
|
||||||
|
job.display_uploader_url || job.asset?.uploader_url || "",
|
||||||
|
);
|
||||||
|
// Editable extra-link rows; keep one blank row so there's always somewhere to type.
|
||||||
|
const [links, setLinks] = useState<string[]>(() => [...(job.extra_links ?? []), ""]);
|
||||||
|
|
||||||
|
const setLink = (i: number, v: string) => setLinks((ls) => ls.map((l, j) => (j === i ? v : l)));
|
||||||
|
const addLink = () => setLinks((ls) => [...ls, ""]);
|
||||||
|
const removeLink = (i: number) => setLinks((ls) => ls.filter((_, j) => j !== i));
|
||||||
|
|
||||||
|
const save = useMutation({
|
||||||
|
mutationFn: () =>
|
||||||
|
api.updateDownloadMeta(job.id, {
|
||||||
|
display_name: name.trim(),
|
||||||
|
display_uploader: channel.trim(),
|
||||||
|
display_uploader_url: channelUrl.trim(),
|
||||||
|
extra_links: links.map((l) => l.trim()).filter(Boolean),
|
||||||
|
}),
|
||||||
|
onSuccess: () => {
|
||||||
|
qc.invalidateQueries({ queryKey: qk.downloads() });
|
||||||
|
onClose();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal title={t("downloads.edit.title")} onClose={onClose}>
|
||||||
|
<div className="space-y-3">
|
||||||
|
<label className="block">
|
||||||
|
<span className="block text-sm font-medium mb-1">{t("downloads.edit.name")}</span>
|
||||||
|
<input
|
||||||
|
value={name}
|
||||||
|
onChange={(e) => setName(e.target.value)}
|
||||||
|
className={inputCls}
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="block">
|
||||||
|
<span className="block text-sm font-medium mb-1">{t("downloads.edit.channel")}</span>
|
||||||
|
<input
|
||||||
|
value={channel}
|
||||||
|
onChange={(e) => setChannel(e.target.value)}
|
||||||
|
className={inputCls}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="block">
|
||||||
|
<span className="block text-sm font-medium mb-1">{t("downloads.edit.channelUrl")}</span>
|
||||||
|
<input
|
||||||
|
value={channelUrl}
|
||||||
|
onChange={(e) => setChannelUrl(e.target.value)}
|
||||||
|
placeholder="https://…"
|
||||||
|
className={inputCls}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<div>
|
||||||
|
<span className="block text-sm font-medium mb-1">{t("downloads.edit.extraLinks")}</span>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{links.map((l, i) => (
|
||||||
|
<div key={i} className="flex gap-2">
|
||||||
|
<input
|
||||||
|
value={l}
|
||||||
|
onChange={(e) => setLink(i, e.target.value)}
|
||||||
|
placeholder="https://…"
|
||||||
|
className={inputCls}
|
||||||
|
/>
|
||||||
|
<IconBtn
|
||||||
|
onClick={() => removeLink(i)}
|
||||||
|
title={t("downloads.edit.removeLink")}
|
||||||
|
danger
|
||||||
|
>
|
||||||
|
<Trash2 className="w-4 h-4" />
|
||||||
|
</IconBtn>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={addLink}
|
||||||
|
className="mt-2 inline-flex items-center gap-1.5 text-sm text-muted hover:text-fg transition"
|
||||||
|
>
|
||||||
|
<Plus className="w-4 h-4" /> {t("downloads.edit.addLink")}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-muted">{t("downloads.edit.hint")}</p>
|
||||||
|
<div className="flex justify-end">
|
||||||
|
<button
|
||||||
|
onClick={() => save.mutate()}
|
||||||
|
disabled={!name.trim() || save.isPending}
|
||||||
|
className="px-3 py-1.5 rounded-lg text-sm font-medium btn-primary disabled:opacity-50 transition"
|
||||||
|
>
|
||||||
|
{t("downloads.edit.save")}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Transient failures worth an in-modal Retry button; the rest are permanent (private/geo/DRM/…),
|
||||||
|
// where a retry can't help — the queue row still keeps its own retry control regardless.
|
||||||
|
const RETRYABLE_ERRORS = ["postprocess", "network", "unknown"];
|
||||||
|
|
||||||
|
// A readable "why did this fail" window for a failed job: a category headline + plain explanation,
|
||||||
|
// the raw yt-dlp/ffmpeg text behind a disclosure, and a Retry when the failure may be transient.
|
||||||
|
export function ErrorModal({
|
||||||
|
job,
|
||||||
|
onRetry,
|
||||||
|
onClose,
|
||||||
|
}: {
|
||||||
|
job: DownloadJob;
|
||||||
|
onRetry: () => void;
|
||||||
|
onClose: () => void;
|
||||||
|
}) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const code = job.error_code || "unknown";
|
||||||
|
const [showRaw, setShowRaw] = useState(false);
|
||||||
|
const retryable = RETRYABLE_ERRORS.includes(code);
|
||||||
|
return (
|
||||||
|
<Modal title={t("downloads.errors.fail.title")} onClose={onClose}>
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="font-medium">{t(`downloads.errors.fail.cat.${code}.short`)}</div>
|
||||||
|
<p className="text-sm text-muted">{t(`downloads.errors.fail.cat.${code}.body`)}</p>
|
||||||
|
{job.error && (
|
||||||
|
<div>
|
||||||
|
<button
|
||||||
|
onClick={() => setShowRaw((v) => !v)}
|
||||||
|
className="text-xs text-muted hover:text-fg transition"
|
||||||
|
>
|
||||||
|
{t("downloads.errors.fail.detailsToggle")}
|
||||||
|
</button>
|
||||||
|
{showRaw && (
|
||||||
|
<pre className="mt-1.5 text-xs bg-surface rounded-lg p-2.5 overflow-x-auto whitespace-pre-wrap wrap-break-word max-h-40 text-muted">
|
||||||
|
{job.error}
|
||||||
|
</pre>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="flex justify-end gap-2 pt-1">
|
||||||
|
{retryable && (
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
onRetry();
|
||||||
|
onClose();
|
||||||
|
}}
|
||||||
|
className="px-3 py-1.5 rounded-lg text-sm font-medium btn-primary transition"
|
||||||
|
>
|
||||||
|
{t("downloads.actions.retry")}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="px-3 py-1.5 rounded-lg text-sm text-muted hover:text-fg hover:bg-surface transition"
|
||||||
|
>
|
||||||
|
{t("common.close")}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,309 @@
|
|||||||
|
import { useEffect, useState, type ReactNode } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { CalendarClock, Copy, Link2, Scissors } from "lucide-react";
|
||||||
|
import clsx from "clsx";
|
||||||
|
import { qk } from "../lib/queryKeys";
|
||||||
|
import { useLiveQuery } from "../lib/useLiveQuery";
|
||||||
|
import { api, type DownloadJob } from "../lib/api";
|
||||||
|
import { notify } from "../lib/notifications";
|
||||||
|
import { copyText } from "../lib/clipboard";
|
||||||
|
import { formatBytes, formatDuration, formatEta, formatSpeed, relativeTime } from "../lib/format";
|
||||||
|
|
||||||
|
// Download-row presentation + the small shared primitives (status pill, icon button, usage bar),
|
||||||
|
// split out of DownloadCenter (all purely prop-driven, no shared state). Rendered by the queue,
|
||||||
|
// library, shared, and admin tabs.
|
||||||
|
|
||||||
|
const STATUS_CLS: Record<string, string> = {
|
||||||
|
queued: "text-muted",
|
||||||
|
running: "text-accent",
|
||||||
|
paused: "text-warn",
|
||||||
|
done: "text-success",
|
||||||
|
error: "text-danger",
|
||||||
|
canceled: "text-muted",
|
||||||
|
};
|
||||||
|
|
||||||
|
// Byte-progress phases (show a % bar) vs ffmpeg post-steps (no %, indeterminate pulse).
|
||||||
|
// "editing" (the phase-2 editor) reports a real % from ffmpeg's out_time, so it gets a bar.
|
||||||
|
const DOWNLOAD_PHASES = ["video", "audio", "editing"];
|
||||||
|
const PHASE_KEYS = [
|
||||||
|
"video",
|
||||||
|
"audio",
|
||||||
|
"editing",
|
||||||
|
"merging",
|
||||||
|
"audio_extract",
|
||||||
|
"thumbnail",
|
||||||
|
"sponsorblock",
|
||||||
|
"metadata",
|
||||||
|
"processing",
|
||||||
|
];
|
||||||
|
|
||||||
|
export function StatusPill({ job }: { job: DownloadJob }) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
// While downloading, show the concrete phase (video / audio / merging) so the user isn't
|
||||||
|
// confused by the bar resetting between the separate video and audio streams.
|
||||||
|
if (job.status === "running" && job.phase && PHASE_KEYS.includes(job.phase)) {
|
||||||
|
return (
|
||||||
|
<span className="text-xs font-medium text-accent">{t(`downloads.phase.${job.phase}`)}</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const key = job.expired ? "expired" : job.status;
|
||||||
|
return (
|
||||||
|
<span className={clsx("text-xs font-medium", STATUS_CLS[job.status] ?? "text-muted")}>
|
||||||
|
{t(`downloads.status.${key}`)}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Thumb({ job }: { job: DownloadJob }) {
|
||||||
|
// Prefer OUR self-hosted poster: it's always present on disk, never expires, and is same-origin, so
|
||||||
|
// a source-CDN signature expiry (Facebook's fbcdn URLs expire within days) or an ad-blocker/Shields
|
||||||
|
// rule on the source domain can't break it. The source's remote thumbnail_url is only a fallback for
|
||||||
|
// the rare poster-less asset (a thumbnail-less source whose ffmpeg frame-cut also failed).
|
||||||
|
const remote = job.asset?.thumbnail_url ?? null;
|
||||||
|
// `job.poster_url` only SIGNALS a poster exists; the actual <img> src must carry ?account= (see
|
||||||
|
// api.posterImageUrl). A plain <img> can't send the X-Siftlode-Account header, so without it the
|
||||||
|
// poster resolves to the session-default account and 404s for a per-tab account's download — which,
|
||||||
|
// now that the poster is the primary image, reads as a blank Library thumbnail.
|
||||||
|
const poster = job.poster_url ? api.posterImageUrl(job.id) : null;
|
||||||
|
const primary = poster || remote;
|
||||||
|
// On load failure (a genuinely missing poster file) fall back to the remote source thumbnail.
|
||||||
|
const [src, setSrc] = useState(primary);
|
||||||
|
useEffect(() => setSrc(primary), [primary]);
|
||||||
|
return (
|
||||||
|
<div className="w-28 aspect-video shrink-0 rounded-lg overflow-hidden bg-surface border border-border">
|
||||||
|
{src ? (
|
||||||
|
<img
|
||||||
|
src={src}
|
||||||
|
alt=""
|
||||||
|
loading="lazy"
|
||||||
|
className="w-full h-full object-cover"
|
||||||
|
onError={() => {
|
||||||
|
if (remote && src !== remote) setSrc(remote);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function jobTitle(job: DownloadJob): string {
|
||||||
|
return job.display_name || job.asset?.title || job.source_ref;
|
||||||
|
}
|
||||||
|
|
||||||
|
// The channel to show under the title: a user override wins over the auto-extracted asset value.
|
||||||
|
// `url` is null for sources without a channel link (e.g. reddit) — then it renders as plain text.
|
||||||
|
function jobChannel(job: DownloadJob): { name: string; url: string | null } {
|
||||||
|
return {
|
||||||
|
name: job.display_uploader || job.asset?.uploader || job.source_ref,
|
||||||
|
url: job.display_uploader_url || job.asset?.uploader_url || null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function IconBtn({
|
||||||
|
onClick,
|
||||||
|
title,
|
||||||
|
danger,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
onClick: () => void;
|
||||||
|
title: string;
|
||||||
|
danger?: boolean;
|
||||||
|
children: ReactNode;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
onClick={onClick}
|
||||||
|
title={title}
|
||||||
|
className={clsx(
|
||||||
|
"p-1.5 rounded-md hover:bg-surface text-muted transition",
|
||||||
|
danger ? "hover:text-danger" : "hover:text-fg",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Compact display of a URL: drop the protocol + trailing slash so "youtube.com/watch?v=…" fits.
|
||||||
|
function displayUrl(url: string): string {
|
||||||
|
try {
|
||||||
|
const u = new URL(url);
|
||||||
|
return (u.host + u.pathname + u.search).replace(/\/$/, "");
|
||||||
|
} catch {
|
||||||
|
return url;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// "Downloaded from" reference: opens the original page in a new tab, or copies the link.
|
||||||
|
function SourceRef({ url }: { url: string }) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const copy = async () => {
|
||||||
|
if (await copyText(url)) {
|
||||||
|
notify({ level: "success", message: t("downloads.source.copied") });
|
||||||
|
} else {
|
||||||
|
notify({ level: "error", message: t("downloads.source.copyFailed") });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-1 mt-1 text-xs text-muted min-w-0">
|
||||||
|
<Link2 className="w-3 h-3 shrink-0" />
|
||||||
|
<a
|
||||||
|
href={url}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
title={`${t("downloads.source.open")} — ${url}`}
|
||||||
|
className="truncate hover:text-accent hover:underline"
|
||||||
|
>
|
||||||
|
{displayUrl(url)}
|
||||||
|
</a>
|
||||||
|
<button
|
||||||
|
onClick={copy}
|
||||||
|
aria-label={t("downloads.source.copy")}
|
||||||
|
title={t("downloads.source.copy")}
|
||||||
|
className="shrink-0 p-0.5 rounded-sm hover:bg-surface hover:text-fg transition"
|
||||||
|
>
|
||||||
|
<Copy className="w-3 h-3" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Channel under the title — a clickable link when a channel URL is known, plain text otherwise.
|
||||||
|
function ChannelLine({ job }: { job: DownloadJob }) {
|
||||||
|
const { name, url } = jobChannel(job);
|
||||||
|
if (url) {
|
||||||
|
return (
|
||||||
|
<div className="text-xs mt-0.5 truncate">
|
||||||
|
<a
|
||||||
|
href={url}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
title={url}
|
||||||
|
className="text-muted hover:text-accent hover:underline"
|
||||||
|
>
|
||||||
|
{name}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return <div className="text-xs text-muted truncate mt-0.5">{name}</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DownloadRow({
|
||||||
|
job,
|
||||||
|
onShowError,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
job: DownloadJob;
|
||||||
|
onShowError?: (job: DownloadJob) => void;
|
||||||
|
children?: ReactNode;
|
||||||
|
}) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const running = job.status === "running";
|
||||||
|
return (
|
||||||
|
<div className="flex gap-3 p-2.5 rounded-xl glass-card glass-hover transition">
|
||||||
|
<Thumb job={job} />
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className="font-medium leading-snug line-clamp-1 flex items-center gap-1.5">
|
||||||
|
{job.job_kind === "edit" && (
|
||||||
|
<span className="inline-flex items-center gap-1 shrink-0 px-1.5 py-0.5 rounded-sm text-3xs font-semibold uppercase tracking-wide bg-accent/15 text-accent">
|
||||||
|
<Scissors className="w-3 h-3" /> {t("downloads.clipBadge")}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<span className="truncate">{jobTitle(job)}</span>
|
||||||
|
</div>
|
||||||
|
<ChannelLine job={job} />
|
||||||
|
<div className="flex items-center gap-2 mt-1 text-xs">
|
||||||
|
<StatusPill job={job} />
|
||||||
|
{job.asset?.size_bytes ? (
|
||||||
|
<span className="text-muted">· {formatBytes(job.asset.size_bytes)}</span>
|
||||||
|
) : null}
|
||||||
|
{job.asset?.duration_s ? (
|
||||||
|
<span className="text-muted">· {formatDuration(job.asset.duration_s)}</span>
|
||||||
|
) : null}
|
||||||
|
{job.status === "done" && job.created_at ? (
|
||||||
|
<span
|
||||||
|
className="text-muted inline-flex items-center gap-1"
|
||||||
|
title={new Date(job.created_at).toLocaleString()}
|
||||||
|
>
|
||||||
|
· <CalendarClock className="w-3 h-3" /> {relativeTime(job.created_at)}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
{job.status === "error" ? (
|
||||||
|
<button
|
||||||
|
onClick={() => onShowError?.(job)}
|
||||||
|
className="text-danger hover:opacity-80 inline-flex items-center gap-1 truncate"
|
||||||
|
title={t("downloads.errors.fail.title")}
|
||||||
|
>
|
||||||
|
· {t(`downloads.errors.fail.cat.${job.error_code || "unknown"}.short`)}
|
||||||
|
<span className="text-muted underline">{t("downloads.errors.fail.detailsLink")}</span>
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
{job.source_url && <SourceRef url={job.source_url} />}
|
||||||
|
{job.extra_links?.map((url) => (
|
||||||
|
<SourceRef key={url} url={url} />
|
||||||
|
))}
|
||||||
|
{running && (
|
||||||
|
<div className="mt-1.5">
|
||||||
|
{job.phase && !DOWNLOAD_PHASES.includes(job.phase) ? (
|
||||||
|
// ffmpeg post-steps aren't byte-progress — show an indeterminate pulse, no %.
|
||||||
|
<div className="h-1.5 rounded-full bg-surface overflow-hidden">
|
||||||
|
<div className="h-full w-full bg-accent/60 animate-pulse" />
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="h-1.5 rounded-full bg-surface overflow-hidden">
|
||||||
|
<div
|
||||||
|
className="h-full bg-accent transition-all"
|
||||||
|
style={{ width: `${job.progress}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="text-2xs text-muted mt-0.5 flex gap-2">
|
||||||
|
<span>{job.progress}%</span>
|
||||||
|
{job.speed_bps ? <span>{formatSpeed(job.speed_bps)}</span> : null}
|
||||||
|
{job.eta_s ? <span>{formatEta(job.eta_s)}</span> : null}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-start gap-0.5 shrink-0">{children}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function UsageBar() {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
// Poll live (like the library list) so the footprint updates the moment the worker finishes an
|
||||||
|
// edit/download — enqueue only creates a queued job (0 bytes), the size lands asynchronously, so
|
||||||
|
// a one-shot fetch at enqueue time would show a stale 0 B until a manual reload.
|
||||||
|
const usage = useLiveQuery(qk.downloadUsage(), api.downloadUsage, { intervalMs: 2000 });
|
||||||
|
const u = usage.data;
|
||||||
|
if (!u) return null;
|
||||||
|
const pct =
|
||||||
|
u.unlimited || !u.max_bytes ? 0 : Math.min(100, (u.footprint_bytes / u.max_bytes) * 100);
|
||||||
|
return (
|
||||||
|
<div className="rounded-xl glass-card p-3 mb-4">
|
||||||
|
<div className="flex justify-between text-sm mb-1.5">
|
||||||
|
<span className="font-medium">{t("downloads.usage.title")}</span>
|
||||||
|
<span className="text-muted">
|
||||||
|
{formatBytes(u.footprint_bytes)}
|
||||||
|
{u.unlimited ? "" : ` / ${formatBytes(u.max_bytes)}`} ·{" "}
|
||||||
|
{u.unlimited
|
||||||
|
? t("downloads.usage.unlimited")
|
||||||
|
: t("downloads.usage.items", { used: u.active_jobs, max: u.max_jobs })}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{!u.unlimited && (
|
||||||
|
<div className="h-2 rounded-full bg-surface overflow-hidden">
|
||||||
|
<div
|
||||||
|
className={clsx("h-full transition-all", pct > 90 ? "bg-danger" : "bg-accent")}
|
||||||
|
style={{ width: `${pct}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,115 @@
|
|||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { Repeat, Repeat1, Shuffle, SkipBack, SkipForward } from "lucide-react";
|
||||||
|
|
||||||
|
// Presentational controls for the YouTube modal player (PlayerModal), split out of the god component —
|
||||||
|
// the sibling of plexPlayerControls.tsx. No state or effects here; all behaviour comes in via props.
|
||||||
|
|
||||||
|
// Persistent playback settings (stored in users.preferences). Auto-advance = what plays when a video
|
||||||
|
// ends; loop = whether it repeats the current video ("one"), wraps the list at its ends ("all"), or
|
||||||
|
// neither ("off"). Both apply to any queued player (feed or playlist).
|
||||||
|
export type AutoMode = "off" | "next" | "prev" | "random";
|
||||||
|
export type LoopMode = "off" | "one" | "all";
|
||||||
|
export const AUTO_MODES: AutoMode[] = ["off", "next", "prev", "random"];
|
||||||
|
export const LOOP_MODES: LoopMode[] = ["off", "one", "all"];
|
||||||
|
|
||||||
|
/** The queue transport cluster — prev / counter / next, then the auto-advance and loop mode toggles.
|
||||||
|
* Rendered in two places with identical logic: the modal's queue bar (`variant:"modal"`, normal glass
|
||||||
|
* theme, labels always shown) and the big-view docked control bar (`variant:"docked"`, white-on-dark,
|
||||||
|
* labels hidden until a breakpoint). Only the theme + label-visibility differ; the behaviour is one,
|
||||||
|
* so a mode/icon change lands once. The wrapper (fragment vs. bordered row) stays at the call site. */
|
||||||
|
export function PlayerTransportControls({
|
||||||
|
variant,
|
||||||
|
index,
|
||||||
|
queueLength,
|
||||||
|
autoMode,
|
||||||
|
loopMode,
|
||||||
|
onPrev,
|
||||||
|
onNext,
|
||||||
|
onCycleAuto,
|
||||||
|
onCycleLoop,
|
||||||
|
}: {
|
||||||
|
variant: "docked" | "modal";
|
||||||
|
index: number;
|
||||||
|
queueLength: number;
|
||||||
|
autoMode: AutoMode;
|
||||||
|
loopMode: LoopMode;
|
||||||
|
onPrev: () => void;
|
||||||
|
onNext: () => void;
|
||||||
|
onCycleAuto: () => void;
|
||||||
|
onCycleLoop: () => void;
|
||||||
|
}) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const docked = variant === "docked";
|
||||||
|
const navBtn = docked
|
||||||
|
? "inline-flex items-center gap-1 text-white/80 enabled:hover:text-white disabled:opacity-30 transition"
|
||||||
|
: "inline-flex items-center gap-1.5 text-muted enabled:hover:text-fg disabled:opacity-40 transition";
|
||||||
|
const navLabel = docked ? "hidden sm:inline" : "";
|
||||||
|
const counter = docked ? "tabular-nums text-white/70" : "text-muted tabular-nums";
|
||||||
|
const divider = docked ? "h-4 w-px bg-white/20" : "w-px h-4 bg-border";
|
||||||
|
const toggle = (on: boolean) =>
|
||||||
|
docked
|
||||||
|
? `inline-flex items-center gap-1 rounded-lg px-2 py-1 transition ${
|
||||||
|
on ? "bg-white/20 text-white" : "text-white/70 hover:bg-white/10 hover:text-white"
|
||||||
|
}`
|
||||||
|
: `inline-flex items-center gap-1.5 rounded-lg px-2.5 py-1 transition ${
|
||||||
|
on ? "bg-accent/15 text-accent" : "text-muted hover:bg-surface hover:text-fg"
|
||||||
|
}`;
|
||||||
|
const toggleLabel = docked ? "hidden md:inline" : "";
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
onClick={onPrev}
|
||||||
|
disabled={index === 0}
|
||||||
|
title={`${t("player.previous")} · Shift+←`}
|
||||||
|
className={navBtn}
|
||||||
|
>
|
||||||
|
<SkipBack className="h-4 w-4" />
|
||||||
|
<span className={navLabel}>{t("player.previous")}</span>
|
||||||
|
</button>
|
||||||
|
<span className={counter}>
|
||||||
|
{index + 1} / {queueLength}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
onClick={onNext}
|
||||||
|
disabled={index === queueLength - 1}
|
||||||
|
title={`${t("player.next")} · Shift+→`}
|
||||||
|
className={navBtn}
|
||||||
|
>
|
||||||
|
<span className={navLabel}>{t("player.next")}</span>
|
||||||
|
<SkipForward className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
<span className={divider} />
|
||||||
|
{/* Persistent playback settings — cycle on click, saved to your account. */}
|
||||||
|
<button
|
||||||
|
onClick={onCycleAuto}
|
||||||
|
title={t("player.autoAdvance.hint")}
|
||||||
|
className={toggle(autoMode !== "off")}
|
||||||
|
>
|
||||||
|
{autoMode === "prev" ? (
|
||||||
|
<SkipBack className="h-3.5 w-3.5" />
|
||||||
|
) : autoMode === "random" ? (
|
||||||
|
<Shuffle className="h-3.5 w-3.5" />
|
||||||
|
) : (
|
||||||
|
<SkipForward className="h-3.5 w-3.5" />
|
||||||
|
)}
|
||||||
|
<span className={toggleLabel}>
|
||||||
|
{t("player.autoAdvance.label")}: {t(`player.autoAdvance.${autoMode}`)}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={onCycleLoop}
|
||||||
|
title={t("player.loop.hint")}
|
||||||
|
className={toggle(loopMode !== "off")}
|
||||||
|
>
|
||||||
|
{loopMode === "one" ? (
|
||||||
|
<Repeat1 className="h-3.5 w-3.5" />
|
||||||
|
) : (
|
||||||
|
<Repeat className="h-3.5 w-3.5" />
|
||||||
|
)}
|
||||||
|
<span className={toggleLabel}>
|
||||||
|
{t("player.loop.label")}: {t(`player.loop.${loopMode}`)}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,493 @@
|
|||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { CheckCircle2, Circle, Film, Info, ListPlus, Play, Tv2 } from "lucide-react";
|
||||||
|
import { type CSSProperties } from "react";
|
||||||
|
import { formatRuntime } from "../lib/format";
|
||||||
|
import type { PlexCard, PlexCastMember, PlexFilters, PlexSeasonDetail } from "../lib/api";
|
||||||
|
|
||||||
|
// Presentational Plex cards + strips, split out of PlexBrowse (all purely prop-driven, no shared
|
||||||
|
// state). Rendered by the browse grid, the person page, and the series show/season views.
|
||||||
|
|
||||||
|
// Playback cost, escalating good→heavy: direct (green) → remux, a light repackage
|
||||||
|
// (amber) → transcode, a full re-encode (red). Reuses the status trio as a gradient;
|
||||||
|
// all three stay visually distinct.
|
||||||
|
const PLAYABLE_TINT: Record<string, string> = {
|
||||||
|
direct: "bg-success",
|
||||||
|
remux: "bg-warn",
|
||||||
|
transcode: "bg-danger",
|
||||||
|
};
|
||||||
|
|
||||||
|
// The reveal class for a status pill (watched / in-progress) that shares a corner with a hover-reveal
|
||||||
|
// control (the watched-toggle). When that control is present it takes over on touch, so the pill yields
|
||||||
|
// via `.reveal-idle` (hidden on touch, faded on hover); when there's no such control the pill is the
|
||||||
|
// sole indicator and just fades on hover. (The single-episode card, where the pill is itself the
|
||||||
|
// full-size watched marker with no fade, keeps its own inline rule.)
|
||||||
|
const swapBadgeReveal = (hasControl: boolean): string =>
|
||||||
|
hasControl ? "reveal-idle" : "group-hover:opacity-0";
|
||||||
|
|
||||||
|
// Watched-toggle icon. Distinguishes state by SHAPE (a filled check vs an empty circle), not color
|
||||||
|
// alone — on touch the toggle is the only surviving watched indicator (the text pill is hidden), so a
|
||||||
|
// green-vs-white CheckCircle2 would be a use-of-color (WCAG 1.4.1) gap. Watched = CheckCircle2 (green),
|
||||||
|
// not-watched = Circle (empty, white).
|
||||||
|
function WatchedIcon({ watched }: { watched: boolean }) {
|
||||||
|
return watched ? (
|
||||||
|
<CheckCircle2 className="w-4 h-4 text-success" />
|
||||||
|
) : (
|
||||||
|
<Circle className="w-4 h-4 text-white" />
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PlexPosterCard({
|
||||||
|
card,
|
||||||
|
onOpen,
|
||||||
|
onInfo,
|
||||||
|
onToggleWatched,
|
||||||
|
}: {
|
||||||
|
card: PlexCard;
|
||||||
|
onOpen: () => void;
|
||||||
|
onInfo: () => void;
|
||||||
|
onToggleWatched: (c: PlexCard) => void;
|
||||||
|
}) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const inProgress = (card.position_seconds ?? 0) > 0 && card.status !== "watched";
|
||||||
|
const isPlayable = card.type !== "show"; // movies/episodes play; shows open the drill-down
|
||||||
|
const pct =
|
||||||
|
inProgress && card.duration_seconds
|
||||||
|
? Math.min(100, Math.round(((card.position_seconds ?? 0) / card.duration_seconds) * 100))
|
||||||
|
: 0;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
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
|
||||||
|
onClick={onOpen}
|
||||||
|
role="button"
|
||||||
|
tabIndex={0}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Enter" || e.key === " ") {
|
||||||
|
e.preventDefault();
|
||||||
|
onOpen();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className="glass-card glass-hover relative aspect-2/3 rounded-xl overflow-hidden transition-all duration-200 cursor-pointer"
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
src={card.thumb}
|
||||||
|
alt=""
|
||||||
|
loading="lazy"
|
||||||
|
decoding="async"
|
||||||
|
className="w-full h-full object-cover group-hover:scale-105 transition-transform duration-200"
|
||||||
|
/>
|
||||||
|
{/* Hover affordance: a play overlay on every card (movies/episodes play; a show opens). */}
|
||||||
|
<div className="absolute inset-0 bg-black/45 opacity-0 group-hover:opacity-100 transition grid place-items-center">
|
||||||
|
<div className="flex flex-col items-center gap-1 text-white">
|
||||||
|
<Play className="w-10 h-10" fill="currentColor" />
|
||||||
|
<span className="text-xs font-medium">
|
||||||
|
{card.type === "show"
|
||||||
|
? t("plex.openShow")
|
||||||
|
: inProgress
|
||||||
|
? t("plex.resume")
|
||||||
|
: t("plex.play")}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/* Quick watched toggle (movies/episodes), on hover. */}
|
||||||
|
{isPlayable && (
|
||||||
|
<button
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onToggleWatched(card);
|
||||||
|
}}
|
||||||
|
title={card.status === "watched" ? t("plex.markUnwatched") : t("plex.markWatched")}
|
||||||
|
className="absolute top-1.5 right-1.5 p-1 rounded-full bg-black/60 reveal hover:bg-black/80 transition"
|
||||||
|
>
|
||||||
|
<WatchedIcon watched={card.status === "watched"} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{/* Media info page (movies/episodes). */}
|
||||||
|
{isPlayable && (
|
||||||
|
<button
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onInfo();
|
||||||
|
}}
|
||||||
|
title={t("plex.info.title")}
|
||||||
|
className="absolute bottom-1.5 left-1.5 p-1 rounded-full bg-black/60 reveal hover:bg-black/80 transition"
|
||||||
|
>
|
||||||
|
<Info className="w-4 h-4 text-white" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{/* Watched badge when not hovering (the toggle replaces it on hover). Only a playable card
|
||||||
|
has that toggle, so only there does the badge yield to it (reveal-idle: gone on touch,
|
||||||
|
where the toggle is always shown); a watched show keeps the badge as its sole marker. */}
|
||||||
|
{card.status === "watched" && (
|
||||||
|
<span
|
||||||
|
className={`pointer-events-none absolute top-1.5 right-1.5 text-3xs bg-black/70 text-white px-1.5 py-0.5 rounded-sm ${swapBadgeReveal(
|
||||||
|
isPlayable,
|
||||||
|
)}`}
|
||||||
|
>
|
||||||
|
{t("plex.watched")}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{card.type === "show" && card.season_count != null && (
|
||||||
|
<span className="absolute bottom-1.5 left-1.5 text-3xs bg-black/70 text-white px-1.5 py-0.5 rounded-sm">
|
||||||
|
{t("plex.seasons", { count: card.season_count })}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{/* Aggregate in-progress badge for a partially-watched show. */}
|
||||||
|
{card.type === "show" && card.status === "in_progress" && (
|
||||||
|
<span className="pointer-events-none absolute top-1.5 right-1.5 text-3xs bg-black/75 text-accent px-1.5 py-0.5 rounded-sm group-hover:opacity-0">
|
||||||
|
{t("plex.inProgress")}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{card.playable && (
|
||||||
|
<span
|
||||||
|
className={`absolute top-1.5 left-1.5 w-2 h-2 rounded-full ${PLAYABLE_TINT[card.playable] ?? "bg-gray-400"}`}
|
||||||
|
title={t(`plex.playable.${card.playable}`)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{/* Type tag — tell a movie card from a show card at a glance (unified feed). */}
|
||||||
|
<span
|
||||||
|
className="absolute bottom-1.5 right-1.5 rounded-sm bg-black/65 p-0.5 text-white/90"
|
||||||
|
title={t(
|
||||||
|
card.type === "show" ? "plex.filter.scopeOpt.show" : "plex.filter.scopeOpt.movie",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{card.type === "show" ? <Tv2 className="w-3 h-3" /> : <Film className="w-3 h-3" />}
|
||||||
|
</span>
|
||||||
|
{pct > 0 && (
|
||||||
|
<div className="absolute bottom-0 inset-x-0 h-1 bg-black/40">
|
||||||
|
<div className="h-full bg-accent" style={{ width: `${pct}%` }} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
onClick={onOpen}
|
||||||
|
className="mt-1.5 rounded-lg px-2 py-1.5 cursor-pointer"
|
||||||
|
style={{ background: "color-mix(in srgb, var(--card) 60%, transparent)" }}
|
||||||
|
>
|
||||||
|
<div className="text-sm font-medium line-clamp-2 leading-tight min-h-[2lh] hover:text-accent">
|
||||||
|
{card.title}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-muted">
|
||||||
|
{[card.year, formatRuntime(card.duration_seconds)].filter(Boolean).join(" · ")}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SeasonCard({
|
||||||
|
se,
|
||||||
|
onOpen,
|
||||||
|
onToggleWatched,
|
||||||
|
onAdd,
|
||||||
|
}: {
|
||||||
|
se: PlexSeasonDetail;
|
||||||
|
onOpen: () => void;
|
||||||
|
onToggleWatched?: () => void;
|
||||||
|
onAdd?: () => void;
|
||||||
|
}) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const watched = se.status === "watched";
|
||||||
|
return (
|
||||||
|
<div className="group text-left">
|
||||||
|
<div
|
||||||
|
onClick={onOpen}
|
||||||
|
role="button"
|
||||||
|
tabIndex={0}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Enter" || e.key === " ") {
|
||||||
|
e.preventDefault();
|
||||||
|
onOpen();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className="glass-card glass-hover relative aspect-2/3 rounded-xl overflow-hidden transition-all duration-200 cursor-pointer"
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
src={se.thumb}
|
||||||
|
alt=""
|
||||||
|
loading="lazy"
|
||||||
|
className="w-full h-full object-cover group-hover:scale-105 transition-transform duration-200"
|
||||||
|
/>
|
||||||
|
<div className="absolute inset-0 bg-black/45 opacity-0 group-hover:opacity-100 transition grid place-items-center">
|
||||||
|
<div className="flex flex-col items-center gap-1 text-white">
|
||||||
|
<Play className="w-9 h-9" fill="currentColor" />
|
||||||
|
<span className="text-xs font-medium">{t("plex.openShow")}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/* Quick watched toggle (whole season), on hover. */}
|
||||||
|
{onToggleWatched && (
|
||||||
|
<button
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onToggleWatched();
|
||||||
|
}}
|
||||||
|
title={
|
||||||
|
watched ? t("plex.series.markSeasonUnwatched") : t("plex.series.markSeasonWatched")
|
||||||
|
}
|
||||||
|
className="absolute top-1.5 right-1.5 p-1 rounded-full bg-black/60 reveal hover:bg-black/80 transition"
|
||||||
|
>
|
||||||
|
<WatchedIcon watched={watched} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{/* Add whole season to a playlist, on hover. */}
|
||||||
|
{onAdd && (
|
||||||
|
<button
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onAdd();
|
||||||
|
}}
|
||||||
|
title={t("plex.playlist.addSeason")}
|
||||||
|
className="absolute bottom-1.5 right-1.5 p-1 rounded-full bg-black/60 reveal hover:bg-black/80 transition"
|
||||||
|
>
|
||||||
|
<ListPlus className="w-4 h-4 text-white" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{watched && (
|
||||||
|
<span
|
||||||
|
className={`pointer-events-none absolute top-1.5 right-1.5 text-3xs bg-black/70 text-white px-1.5 py-0.5 rounded-sm ${swapBadgeReveal(
|
||||||
|
!!onToggleWatched,
|
||||||
|
)}`}
|
||||||
|
>
|
||||||
|
{t("plex.watched")}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{/* In-progress lives at the top-LEFT (the watched-toggle owns the top-right) and stays visible
|
||||||
|
on touch: unlike movie/episode cards, a season has no progress bar, and the always-shown
|
||||||
|
toggle reads as unwatched for a partial season — so this badge is the sole in-progress cue
|
||||||
|
and must not be a reveal-idle swap-badge (which display:none-s on touch). */}
|
||||||
|
{se.status === "in_progress" && (
|
||||||
|
<span className="pointer-events-none absolute top-1.5 left-1.5 text-3xs bg-black/75 text-accent px-1.5 py-0.5 rounded-sm">
|
||||||
|
{t("plex.inProgress")}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<span className="absolute bottom-1.5 left-1.5 text-3xs bg-black/70 text-white px-1.5 py-0.5 rounded-sm">
|
||||||
|
{t("plex.series.episodeCount", { count: se.episode_count })}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
onClick={onOpen}
|
||||||
|
className="mt-1.5 rounded-lg px-2 py-1.5 text-sm font-medium line-clamp-2 leading-tight cursor-pointer hover:text-accent"
|
||||||
|
style={{ background: "color-mix(in srgb, var(--card) 60%, transparent)" }}
|
||||||
|
>
|
||||||
|
{se.title}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CastStrip({
|
||||||
|
cast,
|
||||||
|
onFilter,
|
||||||
|
onOpenPerson,
|
||||||
|
}: {
|
||||||
|
cast: PlexCastMember[];
|
||||||
|
onFilter?: (patch: Partial<PlexFilters>) => void;
|
||||||
|
onOpenPerson?: (name: string) => void;
|
||||||
|
}) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const clickable = !!(onOpenPerson || onFilter);
|
||||||
|
return (
|
||||||
|
<section
|
||||||
|
className="glass-card rounded-2xl p-4 sm:p-5 mb-6"
|
||||||
|
style={{ background: "color-mix(in srgb, var(--card) 55%, transparent)" }}
|
||||||
|
>
|
||||||
|
<h2 className="text-sm font-semibold mb-3">{t("plex.info.cast")}</h2>
|
||||||
|
<div className="flex gap-4 overflow-x-auto pb-2">
|
||||||
|
{cast.map((c, i) => {
|
||||||
|
const inner = (
|
||||||
|
<>
|
||||||
|
<div
|
||||||
|
className={`mx-auto h-20 w-20 overflow-hidden rounded-full border bg-surface ${clickable ? "border-border group-hover/cast:border-accent" : "border-border"}`}
|
||||||
|
>
|
||||||
|
{c.thumb ? (
|
||||||
|
<img src={c.thumb} alt="" loading="lazy" className="h-full w-full object-cover" />
|
||||||
|
) : (
|
||||||
|
<div className="grid h-full w-full place-items-center opacity-40">
|
||||||
|
{c.name.charAt(0)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className={`mt-1.5 text-xs font-medium line-clamp-2 leading-tight ${clickable ? "group-hover/cast:text-accent" : ""}`}
|
||||||
|
>
|
||||||
|
{c.name}
|
||||||
|
</div>
|
||||||
|
{c.role && <div className="text-2xs text-muted line-clamp-1">{c.role}</div>}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
return clickable ? (
|
||||||
|
<button
|
||||||
|
key={i}
|
||||||
|
onClick={() =>
|
||||||
|
onOpenPerson ? onOpenPerson(c.name) : onFilter?.({ actors: [c.name] })
|
||||||
|
}
|
||||||
|
title={
|
||||||
|
onOpenPerson
|
||||||
|
? t("plex.info.viewPerson", { name: c.name })
|
||||||
|
: t("plex.info.filterActor", { name: c.name })
|
||||||
|
}
|
||||||
|
className="group/cast w-20 shrink-0 text-center"
|
||||||
|
>
|
||||||
|
{inner}
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<div key={i} className="w-20 shrink-0 text-center">
|
||||||
|
{inner}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function RelatedStrip({
|
||||||
|
related,
|
||||||
|
onOpen,
|
||||||
|
}: {
|
||||||
|
related: PlexCard[];
|
||||||
|
onOpen: (id: string) => void;
|
||||||
|
}) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
return (
|
||||||
|
<section
|
||||||
|
className="glass-card rounded-2xl p-4 sm:p-5 mb-6"
|
||||||
|
style={{ background: "color-mix(in srgb, var(--card) 55%, transparent)" }}
|
||||||
|
>
|
||||||
|
<h2 className="text-sm font-semibold mb-3">{t("plex.series.related")}</h2>
|
||||||
|
<div className="flex gap-3 overflow-x-auto pb-2">
|
||||||
|
{related.map((r) => (
|
||||||
|
<button
|
||||||
|
key={r.id}
|
||||||
|
onClick={() => onOpen(r.id)}
|
||||||
|
className="group w-[130px] shrink-0 text-left"
|
||||||
|
>
|
||||||
|
<div className="glass-card glass-hover relative aspect-2/3 rounded-xl overflow-hidden transition-all duration-200">
|
||||||
|
<img
|
||||||
|
src={r.thumb}
|
||||||
|
alt=""
|
||||||
|
loading="lazy"
|
||||||
|
className="w-full h-full object-cover group-hover:scale-105 transition-transform duration-200"
|
||||||
|
/>
|
||||||
|
{r.status === "watched" && (
|
||||||
|
<span className="absolute top-1.5 right-1.5 text-3xs bg-black/70 text-white px-1.5 py-0.5 rounded-sm">
|
||||||
|
{t("plex.watched")}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="mt-1.5 text-sm font-medium line-clamp-2 leading-tight min-h-[2lh]">
|
||||||
|
{r.title}
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function EpisodeCard({
|
||||||
|
ep,
|
||||||
|
onPlay,
|
||||||
|
onAdd,
|
||||||
|
onToggleWatched,
|
||||||
|
withShowTitle,
|
||||||
|
}: {
|
||||||
|
ep: PlexCard;
|
||||||
|
onPlay: () => void;
|
||||||
|
onAdd?: () => void;
|
||||||
|
onToggleWatched?: () => void;
|
||||||
|
withShowTitle?: boolean;
|
||||||
|
}) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const inProgress = (ep.position_seconds ?? 0) > 0 && ep.status !== "watched";
|
||||||
|
const pct =
|
||||||
|
inProgress && ep.duration_seconds
|
||||||
|
? Math.min(100, Math.round(((ep.position_seconds ?? 0) / ep.duration_seconds) * 100))
|
||||||
|
: 0;
|
||||||
|
return (
|
||||||
|
<div className="group">
|
||||||
|
<div
|
||||||
|
onClick={onPlay}
|
||||||
|
role="button"
|
||||||
|
tabIndex={0}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Enter" || e.key === " ") {
|
||||||
|
e.preventDefault();
|
||||||
|
onPlay();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className="relative block w-full aspect-video rounded-xl overflow-hidden bg-card border border-border text-left cursor-pointer"
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
src={ep.thumb}
|
||||||
|
alt=""
|
||||||
|
loading="lazy"
|
||||||
|
className="w-full h-full object-cover group-hover:scale-105 transition-transform duration-200"
|
||||||
|
/>
|
||||||
|
<div className="absolute inset-0 bg-black/40 opacity-0 group-hover:opacity-100 transition grid place-items-center">
|
||||||
|
<Play className="w-9 h-9 text-white" fill="currentColor" />
|
||||||
|
</div>
|
||||||
|
{/* Quick watched toggle (single episode), on hover. */}
|
||||||
|
{onToggleWatched && (
|
||||||
|
<button
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onToggleWatched();
|
||||||
|
}}
|
||||||
|
title={ep.status === "watched" ? t("plex.markUnwatched") : t("plex.markWatched")}
|
||||||
|
className="absolute top-1.5 right-1.5 p-1 rounded-full bg-black/60 reveal hover:bg-black/80 transition"
|
||||||
|
>
|
||||||
|
<WatchedIcon watched={ep.status === "watched"} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{ep.status === "watched" && (
|
||||||
|
<span
|
||||||
|
className={`pointer-events-none absolute top-1.5 right-1.5 p-1 rounded-full bg-black/60 ${
|
||||||
|
onToggleWatched ? "reveal-idle" : ""
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<CheckCircle2 className="w-4 h-4 text-success" />
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{pct > 0 && (
|
||||||
|
<div className="absolute bottom-0 inset-x-0 h-1 bg-black/40">
|
||||||
|
<div className="h-full bg-accent" style={{ width: `${pct}%` }} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className="mt-1.5 flex items-start gap-1.5 rounded-lg px-2 py-1.5"
|
||||||
|
style={{ background: "color-mix(in srgb, var(--card) 60%, transparent)" }}
|
||||||
|
>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
{withShowTitle && ep.show_title && (
|
||||||
|
<div className="text-2xs text-muted truncate">
|
||||||
|
{ep.show_title}
|
||||||
|
{ep.season_number != null && ep.episode_number != null
|
||||||
|
? ` · S${ep.season_number}·E${ep.episode_number}`
|
||||||
|
: ""}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="text-sm font-medium leading-tight">
|
||||||
|
{!withShowTitle && <span className="text-muted mr-1">{ep.episode_number}.</span>}
|
||||||
|
{ep.title}
|
||||||
|
</div>
|
||||||
|
<div className="text-2xs text-muted mt-0.5">{formatRuntime(ep.duration_seconds)}</div>
|
||||||
|
</div>
|
||||||
|
{onAdd && (
|
||||||
|
<button
|
||||||
|
onClick={onAdd}
|
||||||
|
title={t("plex.playlist.addEpisode")}
|
||||||
|
aria-label={t("plex.playlist.addEpisode")}
|
||||||
|
className="shrink-0 rounded-sm p-1 text-muted transition hover:text-accent"
|
||||||
|
>
|
||||||
|
<ListPlus className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,615 @@
|
|||||||
|
import { lazy, Suspense, useState } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import { ArrowLeft, Layers, ListPlus, Play, RotateCcw, Star, User } from "lucide-react";
|
||||||
|
import { qk } from "../lib/queryKeys";
|
||||||
|
import { api, type PlexCard, type PlexFilters, type PlexPersonDetail } from "../lib/api";
|
||||||
|
import { plexLang } from "../i18n";
|
||||||
|
import { pickRatingBadge } from "../lib/plexRating";
|
||||||
|
import { notifyActionError } from "../lib/actionErrors";
|
||||||
|
import { toggleWatchState } from "../lib/plexWatch";
|
||||||
|
import {
|
||||||
|
ActionBtn,
|
||||||
|
BackBtn,
|
||||||
|
DetailCustomizeMenu,
|
||||||
|
DetailHero,
|
||||||
|
epLabel,
|
||||||
|
Filterable,
|
||||||
|
useArtBackdrop,
|
||||||
|
useDetailPrefs,
|
||||||
|
WatchedToggle,
|
||||||
|
} from "../lib/plexDetailUi";
|
||||||
|
import { CastStrip, EpisodeCard, PlexPosterCard, RelatedStrip, SeasonCard } from "./plexCards";
|
||||||
|
import PlexPlaylistAdd, { type PlexAddTarget } from "./PlexPlaylistAdd";
|
||||||
|
import PlexCollectionEditor from "./PlexCollectionEditor";
|
||||||
|
import { LoadingState, StateMessage } from "./QueryState";
|
||||||
|
|
||||||
|
// The Plex sub-router's view branches, split out of PlexBrowse. Each is invoked by PlexBrowse with
|
||||||
|
// shell callbacks as props (onBack/onPlay/onFilter/…) — no view reaches back into the shell's state,
|
||||||
|
// which is what made this a pure code-motion split. Cards/strips come from plexCards, the shared
|
||||||
|
// detail-page UI from lib/plexDetailUi.
|
||||||
|
|
||||||
|
// Lazy: the rich movie info panel loads only when an info page is opened.
|
||||||
|
const PlexInfo = lazy(() => import("./PlexInfo"));
|
||||||
|
|
||||||
|
export function PlexInfoView({
|
||||||
|
id,
|
||||||
|
onBack,
|
||||||
|
onPlay,
|
||||||
|
onPlayItem,
|
||||||
|
onFilter,
|
||||||
|
onOpenPerson,
|
||||||
|
}: {
|
||||||
|
id: string;
|
||||||
|
onBack: () => void;
|
||||||
|
onPlay: () => void;
|
||||||
|
onPlayItem: (id: string) => void;
|
||||||
|
onFilter: (patch: Partial<PlexFilters>) => void;
|
||||||
|
onOpenPerson: (name: string) => void;
|
||||||
|
}) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const lang = plexLang();
|
||||||
|
const q = useQuery({ queryKey: qk.plexItem(id, lang), queryFn: () => api.plexItem(id, lang) });
|
||||||
|
return (
|
||||||
|
<div className="w-[90%] max-w-[1600px] mx-auto">
|
||||||
|
<div className="p-4 pb-0">
|
||||||
|
<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>
|
||||||
|
</div>
|
||||||
|
{q.isError && !q.data ? (
|
||||||
|
<StateMessage tone="error" onRetry={() => q.refetch()}>
|
||||||
|
{t("common.loadError")}
|
||||||
|
</StateMessage>
|
||||||
|
) : q.isLoading || !q.data ? (
|
||||||
|
<LoadingState label={t("plex.loading")} />
|
||||||
|
) : (
|
||||||
|
<Suspense fallback={<p className="p-8 text-muted text-sm">{t("plex.loading")}</p>}>
|
||||||
|
<PlexInfo
|
||||||
|
detail={q.data}
|
||||||
|
variant="page"
|
||||||
|
library={q.data.library ?? undefined}
|
||||||
|
onPlay={onPlay}
|
||||||
|
onPlayItem={onPlayItem}
|
||||||
|
onFilter={onFilter}
|
||||||
|
onOpenPerson={onOpenPerson}
|
||||||
|
// No onStateChange: PlexInfo.setState already invalidates qk.plexItem(id) (this very
|
||||||
|
// query), so q refetches automatically — a manual refetch here just double-fetched.
|
||||||
|
/>
|
||||||
|
</Suspense>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Actor detail: a cast/crew member's filmography (movies + shows in one place) ---------------
|
||||||
|
// The inverse of the cast-name → library-filter link: opened from a cast row (info/show page), it
|
||||||
|
// shows the person's headshot + name, a "filter the library by them" action, and their titles as two
|
||||||
|
// poster-card sections (Movies / Shows). Mirrors PlexInfoView's fetch-wrapper + glass-hero shape.
|
||||||
|
export function PlexPersonView({
|
||||||
|
name,
|
||||||
|
onBack,
|
||||||
|
onOpenCard,
|
||||||
|
onInfoCard,
|
||||||
|
onFilter,
|
||||||
|
}: {
|
||||||
|
name: string;
|
||||||
|
onBack: () => void;
|
||||||
|
onOpenCard: (card: PlexCard) => void;
|
||||||
|
onInfoCard: (card: PlexCard) => void;
|
||||||
|
onFilter: (patch: Partial<PlexFilters>) => void;
|
||||||
|
}) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const qc = useQueryClient();
|
||||||
|
const q = useQuery({ queryKey: qk.plexPerson(name), queryFn: () => api.plexPerson(name) });
|
||||||
|
|
||||||
|
// Quick watch-toggle from a filmography card: optimistically flip in THIS person's cached payload
|
||||||
|
// (both the movies and shows arrays — an id matches only one), then persist + resync via the shared
|
||||||
|
// helper; also invalidate the library so its grid agrees.
|
||||||
|
const toggle = (card: PlexCard) =>
|
||||||
|
toggleWatchState(card, t, {
|
||||||
|
optimistic: (next) =>
|
||||||
|
qc.setQueryData<PlexPersonDetail>(qk.plexPerson(name), (old) =>
|
||||||
|
old
|
||||||
|
? {
|
||||||
|
...old,
|
||||||
|
movies: old.movies.map((m) => (m.id === card.id ? { ...m, status: next } : m)),
|
||||||
|
shows: old.shows.map((s) => (s.id === card.id ? { ...s, status: next } : s)),
|
||||||
|
}
|
||||||
|
: old,
|
||||||
|
),
|
||||||
|
resync: () => {
|
||||||
|
qc.invalidateQueries({ queryKey: qk.plexPerson(name) });
|
||||||
|
qc.invalidateQueries({ queryKey: qk.plexLibrary() });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const section = (titleKey: string, cards: PlexCard[]) =>
|
||||||
|
cards.length > 0 ? (
|
||||||
|
<section className="mb-8">
|
||||||
|
<h2 className="text-sm font-semibold mb-3">{t(titleKey, { count: cards.length })}</h2>
|
||||||
|
<div className="grid grid-cols-[repeat(auto-fill,minmax(150px,1fr))] gap-3">
|
||||||
|
{cards.map((c) => (
|
||||||
|
<PlexPosterCard
|
||||||
|
key={c.id}
|
||||||
|
card={c}
|
||||||
|
onOpen={() => onOpenCard(c)}
|
||||||
|
onInfo={() => onInfoCard(c)}
|
||||||
|
onToggleWatched={toggle}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
) : null;
|
||||||
|
|
||||||
|
const d = q.data;
|
||||||
|
const empty = !!d && d.movies.length === 0 && d.shows.length === 0;
|
||||||
|
return (
|
||||||
|
<div className="w-[90%] max-w-[1600px] mx-auto">
|
||||||
|
<div className="p-4 pb-0">
|
||||||
|
<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>
|
||||||
|
</div>
|
||||||
|
{q.isError && !d ? (
|
||||||
|
<StateMessage tone="error" onRetry={() => q.refetch()}>
|
||||||
|
{t("common.loadError")}
|
||||||
|
</StateMessage>
|
||||||
|
) : q.isLoading || !d ? (
|
||||||
|
<LoadingState label={t("plex.loading")} />
|
||||||
|
) : (
|
||||||
|
<div className="p-4">
|
||||||
|
{/* Hero: headshot + name + a "filter the library by this person" action. */}
|
||||||
|
<section className="glass-card rounded-2xl p-4 sm:p-5 mb-6 flex items-center gap-4">
|
||||||
|
<div className="h-24 w-24 shrink-0 overflow-hidden rounded-full border border-border bg-surface">
|
||||||
|
{d.photo ? (
|
||||||
|
<img src={d.photo} alt="" className="h-full w-full object-cover" />
|
||||||
|
) : (
|
||||||
|
<div className="grid h-full w-full place-items-center opacity-40">
|
||||||
|
<User className="h-8 w-8" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<h1 className="truncate text-xl font-semibold">{d.name}</h1>
|
||||||
|
<p className="mb-2 text-xs text-muted">
|
||||||
|
{t("plex.person.credits", {
|
||||||
|
movies: d.movies.length,
|
||||||
|
shows: d.shows.length,
|
||||||
|
})}
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
onClick={() => onFilter({ actors: [d.name] })}
|
||||||
|
className="rounded-full border border-border bg-card px-3 py-1 text-xs font-medium transition hover:border-accent hover:text-accent"
|
||||||
|
>
|
||||||
|
{t("plex.person.filterBy", { name: d.name })}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
{empty ? (
|
||||||
|
<p className="text-sm text-muted">{t("plex.person.empty")}</p>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{section("plex.person.movies", d.movies)}
|
||||||
|
{section("plex.person.shows", d.shows)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Series drill-down: show detail page → season subpage → player -------------------------------
|
||||||
|
// Both the show page and the season page read the SAME cached qk.plexShow(showId) payload (the
|
||||||
|
// season page just picks its season out of it), so opening a season is instant and PlexPlaylistAdd's
|
||||||
|
// whole-show/season gather keeps working. Actions: Resume (on-deck episode), Play (from the start),
|
||||||
|
// Mark whole show/season watched/unwatched, Add to playlist (show/season/episode), and — admin only —
|
||||||
|
// Add the whole show to a collection.
|
||||||
|
|
||||||
|
export function PlexShowView({
|
||||||
|
showId,
|
||||||
|
onBack,
|
||||||
|
onPlay,
|
||||||
|
onOpenSeason,
|
||||||
|
onOpenShow,
|
||||||
|
onFilter,
|
||||||
|
onOpenPerson,
|
||||||
|
}: {
|
||||||
|
showId: string;
|
||||||
|
onBack: () => void;
|
||||||
|
onPlay: (epRk: string, queue: string[]) => void;
|
||||||
|
onOpenSeason: (seasonId: string) => void;
|
||||||
|
onOpenShow: (id: string) => void;
|
||||||
|
onFilter?: (patch: Partial<PlexFilters>) => void;
|
||||||
|
onOpenPerson?: (name: string) => void;
|
||||||
|
}) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const qc = useQueryClient();
|
||||||
|
const isAdmin = qc.getQueryData<{ role?: string }>(qk.me())?.role === "admin";
|
||||||
|
const lang = plexLang();
|
||||||
|
const q = useQuery({
|
||||||
|
queryKey: qk.plexShow(showId, lang),
|
||||||
|
queryFn: () => api.plexShow(showId, lang),
|
||||||
|
});
|
||||||
|
const d = q.data;
|
||||||
|
const [addTarget, setAddTarget] = useState<PlexAddTarget | null>(null);
|
||||||
|
const [collOpen, setCollOpen] = useState(false);
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
|
||||||
|
const show = d?.show;
|
||||||
|
const showLib = show?.library ?? undefined;
|
||||||
|
const allKeys = (d?.seasons ?? []).flatMap((se) => se.episodes.map((e) => e.id));
|
||||||
|
const watched = show?.status === "watched";
|
||||||
|
const { artBg, showCast, showRelated, toggleArtBg, toggleCast, toggleRelated } = useDetailPrefs();
|
||||||
|
useArtBackdrop(show?.art, artBg);
|
||||||
|
async function markAll(w: boolean) {
|
||||||
|
setBusy(true);
|
||||||
|
try {
|
||||||
|
await api.plexShowState(showId, w);
|
||||||
|
} catch (e) {
|
||||||
|
notifyActionError(e, t("plex.watchedFailed"));
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
qc.invalidateQueries({ queryKey: qk.plexShow(showId) });
|
||||||
|
qc.invalidateQueries({ queryKey: qk.plexLibrary() });
|
||||||
|
}
|
||||||
|
async function markSeason(seasonRk: string, w: boolean) {
|
||||||
|
try {
|
||||||
|
await api.plexSeasonState(seasonRk, w);
|
||||||
|
} catch (e) {
|
||||||
|
notifyActionError(e, t("plex.watchedFailed"));
|
||||||
|
}
|
||||||
|
qc.invalidateQueries({ queryKey: qk.plexShow(showId) });
|
||||||
|
qc.invalidateQueries({ queryKey: qk.plexLibrary() });
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="p-4 max-w-[1200px] mx-auto glass-media">
|
||||||
|
<BackBtn onBack={onBack} label={t("plex.backToLibrary")} />
|
||||||
|
|
||||||
|
{q.isError && !q.data ? (
|
||||||
|
<StateMessage tone="error" onRetry={() => q.refetch()}>
|
||||||
|
{t("common.loadError")}
|
||||||
|
</StateMessage>
|
||||||
|
) : q.isLoading || !d || !show ? (
|
||||||
|
<LoadingState label={t("plex.loading")} />
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{/* Hero */}
|
||||||
|
<DetailHero
|
||||||
|
thumb={show.thumb}
|
||||||
|
posterTitle={show.status === "new" ? t("plex.play") : t("plex.resume")}
|
||||||
|
posterDisabled={!show.resume}
|
||||||
|
showPlayOverlay={!!show.resume}
|
||||||
|
onPosterClick={() => show.resume && onPlay(show.resume.id, allKeys)}
|
||||||
|
customizeMenu={
|
||||||
|
<DetailCustomizeMenu
|
||||||
|
artBg={artBg}
|
||||||
|
onToggleArtBg={toggleArtBg}
|
||||||
|
showCast={showCast}
|
||||||
|
onToggleCast={toggleCast}
|
||||||
|
hasCast={show.cast.length > 0}
|
||||||
|
showRelated={showRelated}
|
||||||
|
onToggleRelated={toggleRelated}
|
||||||
|
hasRelated={d.related.length > 0}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<h1 className="text-2xl font-semibold leading-tight">{show.title}</h1>
|
||||||
|
<div className="mt-1 flex flex-wrap items-center gap-x-2 gap-y-0.5 text-sm text-muted">
|
||||||
|
{show.year != null && (
|
||||||
|
<Filterable
|
||||||
|
onClick={
|
||||||
|
onFilter
|
||||||
|
? () => onFilter({ yearMin: show.year, yearMax: show.year })
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{show.year}
|
||||||
|
</Filterable>
|
||||||
|
)}
|
||||||
|
{show.content_rating && (
|
||||||
|
<Filterable
|
||||||
|
onClick={
|
||||||
|
onFilter
|
||||||
|
? () => onFilter({ contentRatings: [show.content_rating!] })
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
>
|
||||||
|
· {show.content_rating}
|
||||||
|
</Filterable>
|
||||||
|
)}
|
||||||
|
{show.season_count != null && (
|
||||||
|
<span>· {t("plex.seasons", { count: show.season_count })}</span>
|
||||||
|
)}
|
||||||
|
{(() => {
|
||||||
|
// IMDb score when present, else the Plex audience score — mirrors PlexInfo so a show
|
||||||
|
// with no imdb:// entry (imdb_rating NULL after the S6 IMDb-only change) still shows a
|
||||||
|
// rating instead of dropping the badge entirely.
|
||||||
|
const badge = pickRatingBadge(show.imdb_rating, show.rating);
|
||||||
|
if (!badge) return null;
|
||||||
|
const { score } = badge;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
onClick={
|
||||||
|
onFilter ? () => onFilter({ ratingMin: Math.floor(score) }) : undefined
|
||||||
|
}
|
||||||
|
className={`inline-flex items-center gap-1 ${onFilter ? "cursor-pointer hover:text-accent" : "cursor-default"}`}
|
||||||
|
>
|
||||||
|
{/* decorative rating gold — intentionally NOT a status token */}
|
||||||
|
· <Star className="w-3.5 h-3.5 text-amber-400" fill="currentColor" />
|
||||||
|
{score.toFixed(1)}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
|
</div>
|
||||||
|
{show.genres.length > 0 && (
|
||||||
|
<div className="mt-2 flex flex-wrap gap-1.5">
|
||||||
|
{show.genres.map((g) => (
|
||||||
|
<Filterable
|
||||||
|
key={g}
|
||||||
|
className="text-2xs rounded-full bg-surface px-2 py-0.5 text-muted"
|
||||||
|
onClick={onFilter ? () => onFilter({ genres: [g] }) : undefined}
|
||||||
|
>
|
||||||
|
{g}
|
||||||
|
</Filterable>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{show.summary && (
|
||||||
|
<p className="text-sm text-muted mt-3 leading-relaxed line-clamp-5">{show.summary}</p>
|
||||||
|
)}
|
||||||
|
<div className="mt-4 flex flex-wrap gap-2">
|
||||||
|
{show.resume && (
|
||||||
|
<ActionBtn
|
||||||
|
primary
|
||||||
|
onClick={() => onPlay(show.resume!.id, allKeys)}
|
||||||
|
icon={Play}
|
||||||
|
label={show.status === "new" ? t("plex.play") : t("plex.resume")}
|
||||||
|
sub={epLabel(show.resume)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{show.first && show.status !== "new" && (
|
||||||
|
<ActionBtn
|
||||||
|
onClick={() => onPlay(show.first!.id, allKeys)}
|
||||||
|
icon={RotateCcw}
|
||||||
|
label={t("plex.series.playFromStart")}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<WatchedToggle
|
||||||
|
watched={watched}
|
||||||
|
busy={busy}
|
||||||
|
onToggle={() => markAll(!watched)}
|
||||||
|
watchedLabel={t("plex.series.markShowUnwatched")}
|
||||||
|
unwatchedLabel={t("plex.series.markShowWatched")}
|
||||||
|
/>
|
||||||
|
{allKeys.length > 0 && (
|
||||||
|
<ActionBtn
|
||||||
|
onClick={() =>
|
||||||
|
setAddTarget({ kind: "group", ratingKeys: allKeys, title: show.title })
|
||||||
|
}
|
||||||
|
icon={ListPlus}
|
||||||
|
label={t("plex.playlist.addShow")}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{isAdmin && showLib && (
|
||||||
|
<ActionBtn
|
||||||
|
onClick={() => setCollOpen(true)}
|
||||||
|
icon={Layers}
|
||||||
|
label={t("plex.series.addShowCollection")}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</DetailHero>
|
||||||
|
|
||||||
|
{/* Seasons — in a glassy panel like the movie info-page strips. */}
|
||||||
|
<section
|
||||||
|
className="glass-card rounded-2xl p-4 sm:p-5 mb-6"
|
||||||
|
style={{ background: "color-mix(in srgb, var(--card) 55%, transparent)" }}
|
||||||
|
>
|
||||||
|
<h2 className="text-sm font-semibold mb-3">{t("plex.series.seasons")}</h2>
|
||||||
|
<div className="grid grid-cols-[repeat(auto-fill,minmax(140px,1fr))] gap-3">
|
||||||
|
{d.seasons.map((se) => (
|
||||||
|
<SeasonCard
|
||||||
|
key={se.id}
|
||||||
|
se={se}
|
||||||
|
onOpen={() => onOpenSeason(se.id)}
|
||||||
|
onToggleWatched={() => markSeason(se.id, se.status !== "watched")}
|
||||||
|
onAdd={
|
||||||
|
se.episodes.length > 0
|
||||||
|
? () =>
|
||||||
|
setAddTarget({
|
||||||
|
kind: "group",
|
||||||
|
ratingKeys: se.episodes.map((e) => e.id),
|
||||||
|
title: `${show.title} — ${se.title}`,
|
||||||
|
})
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{show.cast.length > 0 && showCast && (
|
||||||
|
<CastStrip cast={show.cast} onFilter={onFilter} onOpenPerson={onOpenPerson} />
|
||||||
|
)}
|
||||||
|
{d.related.length > 0 && showRelated && (
|
||||||
|
<RelatedStrip related={d.related} onOpen={onOpenShow} />
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{addTarget && <PlexPlaylistAdd target={addTarget} onClose={() => setAddTarget(null)} />}
|
||||||
|
{collOpen && show && showLib && (
|
||||||
|
<PlexCollectionEditor
|
||||||
|
item={{ id: show.id, title: show.title }}
|
||||||
|
library={showLib}
|
||||||
|
memberOf={show.collection_keys}
|
||||||
|
onClose={() => setCollOpen(false)}
|
||||||
|
onChanged={() => qc.invalidateQueries({ queryKey: qk.plexShow(showId) })}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PlexSeasonView({
|
||||||
|
showId,
|
||||||
|
seasonId,
|
||||||
|
onBack,
|
||||||
|
onPlay,
|
||||||
|
}: {
|
||||||
|
showId: string;
|
||||||
|
seasonId: string;
|
||||||
|
onBack: () => void;
|
||||||
|
onPlay: (epRk: string, queue: string[]) => void;
|
||||||
|
}) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const qc = useQueryClient();
|
||||||
|
const lang = plexLang();
|
||||||
|
const q = useQuery({
|
||||||
|
queryKey: qk.plexShow(showId, lang),
|
||||||
|
queryFn: () => api.plexShow(showId, lang),
|
||||||
|
});
|
||||||
|
const d = q.data;
|
||||||
|
const se = d?.seasons.find((s) => s.id === seasonId);
|
||||||
|
const [addTarget, setAddTarget] = useState<PlexAddTarget | null>(null);
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
|
||||||
|
const seasonKeys = se?.episodes.map((e) => e.id) ?? [];
|
||||||
|
// Play-through queue spans the WHOLE show (all seasons, in order) so a season's last episode rolls
|
||||||
|
// straight into the next season's first — the same cross-season order the show page already uses.
|
||||||
|
// seasonKeys stays only for the season-scoped "add to playlist" action.
|
||||||
|
const allKeys = (d?.seasons ?? []).flatMap((s) => s.episodes.map((e) => e.id));
|
||||||
|
const watched = se?.status === "watched";
|
||||||
|
const { artBg, showCast, toggleArtBg, toggleCast } = useDetailPrefs();
|
||||||
|
useArtBackdrop(d?.show.art, artBg);
|
||||||
|
async function markAll(w: boolean) {
|
||||||
|
setBusy(true);
|
||||||
|
try {
|
||||||
|
await api.plexSeasonState(seasonId, w);
|
||||||
|
} catch (e) {
|
||||||
|
notifyActionError(e, t("plex.watchedFailed"));
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
qc.invalidateQueries({ queryKey: qk.plexShow(showId) });
|
||||||
|
qc.invalidateQueries({ queryKey: qk.plexLibrary() });
|
||||||
|
}
|
||||||
|
async function markEpisode(ep: PlexCard) {
|
||||||
|
try {
|
||||||
|
await api.plexSetState(ep.id, ep.status === "watched" ? "new" : "watched");
|
||||||
|
} catch (e) {
|
||||||
|
notifyActionError(e, t("plex.watchedFailed"));
|
||||||
|
}
|
||||||
|
qc.invalidateQueries({ queryKey: qk.plexShow(showId) });
|
||||||
|
qc.invalidateQueries({ queryKey: qk.plexLibrary() });
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="p-4 max-w-[1200px] mx-auto glass-media">
|
||||||
|
<BackBtn onBack={onBack} label={t("plex.series.backToShow")} />
|
||||||
|
|
||||||
|
{q.isError && !q.data ? (
|
||||||
|
<StateMessage tone="error" onRetry={() => q.refetch()}>
|
||||||
|
{t("common.loadError")}
|
||||||
|
</StateMessage>
|
||||||
|
) : q.isLoading || !d || !se ? (
|
||||||
|
<LoadingState label={t("plex.loading")} />
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<DetailHero
|
||||||
|
thumb={se.thumb}
|
||||||
|
posterTitle={se.status === "new" ? t("plex.play") : t("plex.resume")}
|
||||||
|
posterDisabled={!se.resume}
|
||||||
|
showPlayOverlay={!!se.resume}
|
||||||
|
onPosterClick={() => se.resume && onPlay(se.resume.id, allKeys)}
|
||||||
|
posterSizeClass="w-28 sm:w-40"
|
||||||
|
customizeMenu={
|
||||||
|
<DetailCustomizeMenu
|
||||||
|
artBg={artBg}
|
||||||
|
onToggleArtBg={toggleArtBg}
|
||||||
|
showCast={showCast}
|
||||||
|
onToggleCast={toggleCast}
|
||||||
|
hasCast={false}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
onClick={onBack}
|
||||||
|
className="text-sm text-muted hover:text-accent transition text-left"
|
||||||
|
>
|
||||||
|
{d.show.title}
|
||||||
|
</button>
|
||||||
|
<h1 className="text-2xl font-semibold leading-tight">{se.title}</h1>
|
||||||
|
<p className="text-sm text-muted mt-1">
|
||||||
|
{t("plex.series.episodeCount", { count: se.episode_count })}
|
||||||
|
</p>
|
||||||
|
<div className="mt-4 flex flex-wrap gap-2">
|
||||||
|
{se.resume && (
|
||||||
|
<ActionBtn
|
||||||
|
primary
|
||||||
|
onClick={() => onPlay(se.resume!.id, allKeys)}
|
||||||
|
icon={Play}
|
||||||
|
label={se.status === "new" ? t("plex.play") : t("plex.resume")}
|
||||||
|
sub={epLabel(se.resume)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{se.first && se.status !== "new" && (
|
||||||
|
<ActionBtn
|
||||||
|
onClick={() => onPlay(se.first!.id, allKeys)}
|
||||||
|
icon={RotateCcw}
|
||||||
|
label={t("plex.series.playFromStart")}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<WatchedToggle
|
||||||
|
watched={watched}
|
||||||
|
busy={busy}
|
||||||
|
onToggle={() => markAll(!watched)}
|
||||||
|
watchedLabel={t("plex.series.markSeasonUnwatched")}
|
||||||
|
unwatchedLabel={t("plex.series.markSeasonWatched")}
|
||||||
|
/>
|
||||||
|
{seasonKeys.length > 0 && (
|
||||||
|
<ActionBtn
|
||||||
|
onClick={() =>
|
||||||
|
setAddTarget({
|
||||||
|
kind: "group",
|
||||||
|
ratingKeys: seasonKeys,
|
||||||
|
title: `${d.show.title} — ${se.title}`,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
icon={ListPlus}
|
||||||
|
label={t("plex.playlist.addSeason")}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</DetailHero>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-[repeat(auto-fill,minmax(230px,1fr))] gap-x-3 gap-y-4">
|
||||||
|
{se.episodes.map((ep) => (
|
||||||
|
<EpisodeCard
|
||||||
|
key={ep.id}
|
||||||
|
ep={ep}
|
||||||
|
onPlay={() => onPlay(ep.id, allKeys)}
|
||||||
|
onAdd={() => setAddTarget({ kind: "single", ratingKey: ep.id, title: ep.title })}
|
||||||
|
onToggleWatched={() => markEpisode(ep)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{addTarget && <PlexPlaylistAdd target={addTarget} onClose={() => setAddTarget(null)} />}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -28,9 +28,6 @@
|
|||||||
"reshuffle": "Reshuffle",
|
"reshuffle": "Reshuffle",
|
||||||
"noMatchingTags": "No matching tags here",
|
"noMatchingTags": "No matching tags here",
|
||||||
"manageTags": "Manage",
|
"manageTags": "Manage",
|
||||||
"shareView": "Copy a link to this view",
|
|
||||||
"shareCopied": "View link copied to clipboard",
|
|
||||||
"shareFailed": "Couldn't copy the link",
|
|
||||||
"widget": {
|
"widget": {
|
||||||
"savedviews": "Saved views",
|
"savedviews": "Saved views",
|
||||||
"show": "Show",
|
"show": "Show",
|
||||||
|
|||||||
@@ -7,8 +7,6 @@
|
|||||||
"apply": "Apply this view",
|
"apply": "Apply this view",
|
||||||
"makeDefault": "Set as default (loads on start)",
|
"makeDefault": "Set as default (loads on start)",
|
||||||
"isDefault": "Default view — loads on start",
|
"isDefault": "Default view — loads on start",
|
||||||
"share": "Copy a link to this view",
|
|
||||||
"shareCopied": "View link copied",
|
|
||||||
"rename": "Rename",
|
"rename": "Rename",
|
||||||
"delete": "Delete",
|
"delete": "Delete",
|
||||||
"deleteConfirm": "Delete the saved view “{{name}}”?",
|
"deleteConfirm": "Delete the saved view “{{name}}”?",
|
||||||
|
|||||||
@@ -28,9 +28,6 @@
|
|||||||
"reshuffle": "Újrakeverés",
|
"reshuffle": "Újrakeverés",
|
||||||
"noMatchingTags": "Nincs ide illő címke",
|
"noMatchingTags": "Nincs ide illő címke",
|
||||||
"manageTags": "Kezelés",
|
"manageTags": "Kezelés",
|
||||||
"shareView": "Hivatkozás másolása erre a nézetre",
|
|
||||||
"shareCopied": "Nézet-hivatkozás a vágólapra másolva",
|
|
||||||
"shareFailed": "Nem sikerült a hivatkozás másolása",
|
|
||||||
"widget": {
|
"widget": {
|
||||||
"savedviews": "Mentett nézetek",
|
"savedviews": "Mentett nézetek",
|
||||||
"show": "Megjelenítés",
|
"show": "Megjelenítés",
|
||||||
|
|||||||
@@ -7,8 +7,6 @@
|
|||||||
"apply": "Nézet alkalmazása",
|
"apply": "Nézet alkalmazása",
|
||||||
"makeDefault": "Beállítás alapértelmezettként (indításkor betölt)",
|
"makeDefault": "Beállítás alapértelmezettként (indításkor betölt)",
|
||||||
"isDefault": "Alapértelmezett nézet — indításkor betölt",
|
"isDefault": "Alapértelmezett nézet — indításkor betölt",
|
||||||
"share": "Hivatkozás másolása a nézethez",
|
|
||||||
"shareCopied": "Nézet hivatkozása a vágólapra másolva",
|
|
||||||
"rename": "Átnevezés",
|
"rename": "Átnevezés",
|
||||||
"delete": "Törlés",
|
"delete": "Törlés",
|
||||||
"deleteConfirm": "Törlöd a(z) „{{name}}” mentett nézetet?",
|
"deleteConfirm": "Törlöd a(z) „{{name}}” mentett nézetet?",
|
||||||
|
|||||||
@@ -536,6 +536,42 @@ body {
|
|||||||
0 14px 30px -14px rgba(0, 0, 0, 0.5);
|
0 14px 30px -14px rgba(0, 0, 0, 0.5);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* UX-7 — touch-visible reveal for hover-only card controls. A control tagged `.reveal` used to be
|
||||||
|
`opacity-0 group-hover:opacity-100`: fine on a mouse (the enclosing `.group` reveals it on hover),
|
||||||
|
but on a touch device — which never hovers — it is invisible AND still tappable. So the reveal is
|
||||||
|
gated on `@media (hover: hover)`: only a hover-capable pointer hides it until the group is hovered
|
||||||
|
or focused (keyboard too, via `:focus-within`); a touch device keeps the default opacity:1 and the
|
||||||
|
control is always shown. `.reveal-idle` is the counterpart badge a control replaces (e.g. the
|
||||||
|
"watched" pill under the watched-toggle): visible at rest, faded when the group is hovered, and
|
||||||
|
dropped entirely on touch — where the toggle is always present, so the two would otherwise collide
|
||||||
|
at the same corner. Requires a `.group` ancestor (Tailwind's group marker). */
|
||||||
|
.reveal-idle {
|
||||||
|
transition: opacity 150ms ease;
|
||||||
|
}
|
||||||
|
@media (hover: hover) {
|
||||||
|
.reveal {
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 150ms ease;
|
||||||
|
}
|
||||||
|
.group:hover .reveal,
|
||||||
|
.group:focus-within .reveal,
|
||||||
|
.reveal:focus-visible {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
/* Fade the idle badge whenever its companion control is revealed — on hover AND on keyboard
|
||||||
|
focus-within (the `.reveal` trigger set above lists both), else the two overlap at the same
|
||||||
|
corner for a keyboard user. */
|
||||||
|
.group:hover .reveal-idle,
|
||||||
|
.group:focus-within .reveal-idle {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@media (hover: none) {
|
||||||
|
.reveal-idle {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/* Dark mode: the ambient accent pools are faint over a near-black bg, so the docked frosted
|
/* Dark mode: the ambient accent pools are faint over a near-black bg, so the docked frosted
|
||||||
chrome (nav/header) barely reads — there's no content behind it to refract, unlike dialogs
|
chrome (nav/header) barely reads — there's no content behind it to refract, unlike dialogs
|
||||||
which sit over the feed. Strengthen the pools and let a touch more bleed through the glass.
|
which sit over the feed. Strengthen the pools and let a touch more bleed through the glass.
|
||||||
|
|||||||
@@ -10,25 +10,58 @@
|
|||||||
//
|
//
|
||||||
// setPage() pushes a clean { sfPage } entry (dropping _sub/_ov), so each page starts at its root.
|
// setPage() pushes a clean { sfPage } entry (dropping _sub/_ov), so each page starts at its root.
|
||||||
import { useEffect, useRef, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
import { urlWithParams } from "./urlState";
|
||||||
|
|
||||||
// --- Sub-views (value carried in history.state._sub) ---------------------------------------
|
// --- Sub-views (value carried in history.state._sub) ---------------------------------------
|
||||||
export function useHistorySubview<T>(root: T): {
|
/** Optional address-bar mirroring for a sub-view, so a deep sub-view (e.g. a Plex show page) is
|
||||||
|
* bookmarkable. history.state._sub stays the source of truth for Back/Forward; this only adds the URL. */
|
||||||
|
export interface SubviewUrl<T> {
|
||||||
|
/** The query key this sub-view mirrors to (e.g. "v" for the Plex sub-view). */
|
||||||
|
key: string;
|
||||||
|
/** Serialize a sub-view to the param value, or null for views not mirrored (root / ephemeral ones). */
|
||||||
|
encode: (view: T) => string | null;
|
||||||
|
/** Parse a param value back to a sub-view, or null if it isn't a valid one. */
|
||||||
|
decode: (raw: string) => T | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useHistorySubview<T>(
|
||||||
|
root: T,
|
||||||
|
url?: SubviewUrl<T>,
|
||||||
|
): {
|
||||||
view: T;
|
view: T;
|
||||||
open: (next: T) => void;
|
open: (next: T) => void;
|
||||||
back: () => void;
|
back: () => void;
|
||||||
} {
|
} {
|
||||||
const [view, setView] = useState<T>(() => (window.history.state?._sub as T) ?? root);
|
// Resolve the current entry's sub-view: history.state._sub for an in-app entry, else decode the
|
||||||
|
// ?<key> URL param for a fresh bookmark (whose entry carries the param but no _sub), else root.
|
||||||
|
// ⚠️ The seed AND the popstate handler must use THIS same fallback: a popstate can land on a
|
||||||
|
// bookmark entry (URL-only, no _sub) — e.g. after an overlay's programmatic history.back() — and
|
||||||
|
// reading history.state alone would wrongly collapse the bookmarked sub-view to root.
|
||||||
|
const readView = (): T => {
|
||||||
|
const fromState = window.history.state?._sub as T | undefined;
|
||||||
|
if (fromState != null) return fromState;
|
||||||
|
if (url) {
|
||||||
|
const raw = new URLSearchParams(window.location.search).get(url.key);
|
||||||
|
const decoded = raw != null ? url.decode(raw) : null;
|
||||||
|
if (decoded != null) return decoded;
|
||||||
|
}
|
||||||
|
return root;
|
||||||
|
};
|
||||||
|
const readViewRef = useRef(readView);
|
||||||
|
readViewRef.current = readView;
|
||||||
|
const [view, setView] = useState<T>(readView);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const onPop = () => setView((window.history.state?._sub as T) ?? root);
|
// Back/Forward restores the sub-view from the landed entry (state OR its restored ?<key> URL).
|
||||||
|
const onPop = () => setView(readViewRef.current());
|
||||||
window.addEventListener("popstate", onPop);
|
window.addEventListener("popstate", onPop);
|
||||||
return () => window.removeEventListener("popstate", onPop);
|
return () => window.removeEventListener("popstate", onPop);
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
||||||
}, []);
|
}, []);
|
||||||
return {
|
return {
|
||||||
view,
|
view,
|
||||||
open: (next: T) => {
|
open: (next: T) => {
|
||||||
setView(next);
|
setView(next);
|
||||||
window.history.pushState({ ...window.history.state, _sub: next }, "");
|
const href = url ? urlWithParams({ [url.key]: url.encode(next) }) : "";
|
||||||
|
window.history.pushState({ ...window.history.state, _sub: next }, "", href);
|
||||||
},
|
},
|
||||||
back: () => window.history.back(),
|
back: () => window.history.back(),
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import type { PanelId } from "./panelLayout";
|
||||||
|
|
||||||
|
// The two browsable media sources the app filters over: the YouTube feed and the Plex library.
|
||||||
|
// (A `PanelId` minus "playlists" — a rail, but not a filterable source.) The pair is already unified
|
||||||
|
// in practice — one shared SortControl, the source-agnostic `ActiveFilter[]` chip row, PanelId-keyed
|
||||||
|
// sidebar layouts, and the shared `FilterRail` shell. This names that abstraction as one narrow
|
||||||
|
// contract, so a future third source (or a feature built across sources) targets `MediaSourceKind`
|
||||||
|
// instead of re-deriving it from the YT/Plex twins. Deliberately thin — just the kind; widen it only
|
||||||
|
// when a concrete cross-source feature needs more than the name.
|
||||||
|
export type MediaSourceKind = Extract<PanelId, "feed" | "plex">;
|
||||||
@@ -0,0 +1,463 @@
|
|||||||
|
import {
|
||||||
|
useEffect,
|
||||||
|
useRef,
|
||||||
|
useState,
|
||||||
|
type Dispatch,
|
||||||
|
type MutableRefObject,
|
||||||
|
type RefObject,
|
||||||
|
type SetStateAction,
|
||||||
|
} from "react";
|
||||||
|
import type { QueryClient } from "@tanstack/react-query";
|
||||||
|
import { DEFAULT_VOLUME, normalizeVolume, stepVolume } from "./playerVolume";
|
||||||
|
import { LS, readAccountMerged, writeAccount } from "./storage";
|
||||||
|
import {
|
||||||
|
exitFullscreen,
|
||||||
|
fullscreenElement,
|
||||||
|
onFullscreenChange,
|
||||||
|
requestFullscreen,
|
||||||
|
} from "./fullscreen";
|
||||||
|
import { useBackToExit } from "./history";
|
||||||
|
import { api, type Video } from "./api";
|
||||||
|
import { qk } from "./queryKeys";
|
||||||
|
import type { YTNamespace, YTPlayer, YTPlayerEvent } from "./youtube";
|
||||||
|
import type { AutoMode, LoopMode } from "../components/playerModalControls";
|
||||||
|
|
||||||
|
// How close to the end (seconds) counts as "finished" → auto-mark watched.
|
||||||
|
const FINISH_MARGIN = 10;
|
||||||
|
// Bound consecutive unplayable items so an auto-advancing queue (esp. loop=all) can't spin over a run
|
||||||
|
// of dead videos — after the cap we stop on the error overlay instead of skipping forever.
|
||||||
|
const MAX_CONSECUTIVE_ERRORS = 3;
|
||||||
|
|
||||||
|
// --- IFrame Player API loader (singleton) ---
|
||||||
|
let apiPromise: Promise<YTNamespace> | null = null;
|
||||||
|
function loadYouTubeApi(): Promise<YTNamespace> {
|
||||||
|
if (apiPromise) return apiPromise;
|
||||||
|
apiPromise = new Promise<YTNamespace>((resolve) => {
|
||||||
|
const w = window;
|
||||||
|
if (w.YT && w.YT.Player) return resolve(w.YT);
|
||||||
|
const prev = w.onYouTubeIframeAPIReady;
|
||||||
|
w.onYouTubeIframeAPIReady = () => {
|
||||||
|
if (typeof prev === "function") prev();
|
||||||
|
// The API-ready callback only fires once the script has installed window.YT.
|
||||||
|
resolve(w.YT!);
|
||||||
|
};
|
||||||
|
const tag = document.createElement("script");
|
||||||
|
tag.src = "https://www.youtube.com/iframe_api";
|
||||||
|
document.head.appendChild(tag);
|
||||||
|
});
|
||||||
|
return apiPromise;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extracted hooks for the YouTube modal player (PlayerModal), the sibling of plexPlayerHooks.ts. The
|
||||||
|
// component owns the shared media refs (playerRef, stageRef, dialogRef) and passes them in via an opts
|
||||||
|
// object; each hook owns its own private state/timers and returns handlers + reactive values. The
|
||||||
|
// once-bound keyboard handler and layer-stack registration stay in the component shell (they orchestrate
|
||||||
|
// every hook through refs), exactly as PlexPlayer keeps its keydown handler while consuming these hooks.
|
||||||
|
|
||||||
|
/** Per-account volume for the YouTube player, mirroring Plex's usePlayerVolume. A YT.Player is created
|
||||||
|
* per video and always starts at 100, so the level lives in a ref and onReady re-applies it. Level 0 IS
|
||||||
|
* the muted state (that's how YouTube's own control behaves), so there's no separate `muted` flag.
|
||||||
|
* Mouse-wheel over the WHOLE modal (backdrop + card) adjusts volume, not just the small player area. */
|
||||||
|
export function useYtVolume({
|
||||||
|
playerRef,
|
||||||
|
dialogRef,
|
||||||
|
focusModal,
|
||||||
|
}: {
|
||||||
|
playerRef: MutableRefObject<YTPlayer | null>;
|
||||||
|
dialogRef: RefObject<HTMLDivElement | null>;
|
||||||
|
focusModal: () => void;
|
||||||
|
}): {
|
||||||
|
/** Level (0–100) to flash in the on-player overlay, or null when hidden. */
|
||||||
|
volumeUi: number | null;
|
||||||
|
/** Re-apply the remembered level to a freshly-created player (onReady), without flashing. */
|
||||||
|
reapplyStored: () => void;
|
||||||
|
/** Step the level by `delta` from the freshest good source (own ref, or the player on a fresh gesture). */
|
||||||
|
nudgeVolume: (delta: number) => void;
|
||||||
|
} {
|
||||||
|
const volTimerRef = useRef<number | undefined>(undefined);
|
||||||
|
// Sanitised HERE, at the boundary where the untrusted stored value enters (normalizeVolume): a `null`
|
||||||
|
// / `"80"` / missing key becomes the default ONCE, via a lazy useState so it happens once per mount.
|
||||||
|
const [volumeUi, setVolumeUi] = useState<number | null>(null);
|
||||||
|
const [storedVolume] = useState(() =>
|
||||||
|
normalizeVolume(readAccountMerged(LS.ytPlayerPrefs, { volume: DEFAULT_VOLUME }).volume),
|
||||||
|
);
|
||||||
|
const volumeRef = useRef(storedVolume);
|
||||||
|
const volSaveTimerRef = useRef<number | undefined>(undefined);
|
||||||
|
// When the last volume gesture happened — see nudgeVolume (a spin must not re-read the player).
|
||||||
|
const lastNudgeAtRef = useRef(0);
|
||||||
|
|
||||||
|
const flashVolume = (level: number) => {
|
||||||
|
setVolumeUi(level);
|
||||||
|
window.clearTimeout(volTimerRef.current);
|
||||||
|
volTimerRef.current = window.setTimeout(() => setVolumeUi(null), 1200);
|
||||||
|
};
|
||||||
|
const persistVolume = () => {
|
||||||
|
window.clearTimeout(volSaveTimerRef.current);
|
||||||
|
volSaveTimerRef.current = undefined;
|
||||||
|
writeAccount(LS.ytPlayerPrefs, { volume: volumeRef.current });
|
||||||
|
};
|
||||||
|
const applyVolume = (level: number, flash = true) => {
|
||||||
|
// Re-normalise against the LAST GOOD level (never the default) — the player can answer NaN, and
|
||||||
|
// jumping a quiet session to full blast is the surprise this feature exists to avoid.
|
||||||
|
const next = normalizeVolume(level, volumeRef.current);
|
||||||
|
const p = playerRef.current;
|
||||||
|
if (p && typeof p.setVolume === "function") {
|
||||||
|
if (next > 0) p.unMute?.();
|
||||||
|
p.setVolume(next);
|
||||||
|
if (next === 0) p.mute?.();
|
||||||
|
}
|
||||||
|
volumeRef.current = next;
|
||||||
|
// Debounced: one write when the spin settles; the unmount cleanup flushes a pending write.
|
||||||
|
window.clearTimeout(volSaveTimerRef.current);
|
||||||
|
volSaveTimerRef.current = window.setTimeout(persistVolume, 400);
|
||||||
|
if (flash) flashVolume(next);
|
||||||
|
};
|
||||||
|
const nudgeVolume = (delta: number) => {
|
||||||
|
// No early return when the player isn't ready: the level lives in volumeRef and onReady applies it,
|
||||||
|
// so a wheel spin in the first second after opening still lands.
|
||||||
|
const p = playerRef.current;
|
||||||
|
// Step from our own ref, EXCEPT when the gesture starts fresh — then read the player (the user may
|
||||||
|
// have moved YouTube's own slider). getVolume() lags setVolume across the iframe boundary, so
|
||||||
|
// re-reading it mid-spin resurrects a stale level and silently drops notches.
|
||||||
|
const now = Date.now();
|
||||||
|
const continuing = now - lastNudgeAtRef.current < 1000;
|
||||||
|
lastNudgeAtRef.current = now;
|
||||||
|
const live =
|
||||||
|
p && typeof p.getVolume === "function" ? (p.isMuted?.() ? 0 : p.getVolume()) : null;
|
||||||
|
const cur = continuing || live === null ? volumeRef.current : live;
|
||||||
|
applyVolume(stepVolume(cur, { delta, fallback: volumeRef.current }));
|
||||||
|
};
|
||||||
|
|
||||||
|
// Mouse-wheel volume over the WHOLE modal. Non-passive so preventDefault stops the modal/page
|
||||||
|
// scrolling. The cross-origin iframe swallows wheel over its native strips, but the central
|
||||||
|
// interaction overlay is our element (wheel there bubbles here), and everything outside works.
|
||||||
|
useEffect(() => {
|
||||||
|
const el = dialogRef.current;
|
||||||
|
if (!el) return;
|
||||||
|
const onWheel = (e: WheelEvent) => {
|
||||||
|
// Ctrl/⌘+wheel is the browser's zoom gesture — never swallow it.
|
||||||
|
if (e.ctrlKey || e.metaKey) return;
|
||||||
|
e.preventDefault();
|
||||||
|
focusModal(); // re-grab focus if it had slipped into the native controls
|
||||||
|
nudgeVolume(e.deltaY < 0 ? 5 : -5);
|
||||||
|
};
|
||||||
|
el.addEventListener("wheel", onWheel, { passive: false });
|
||||||
|
return () => el.removeEventListener("wheel", onWheel);
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// On unmount, clear the flash timer and flush any pending debounced write — closing the player right
|
||||||
|
// after a wheel spin (the normal way to leave) must not drop the level the user just set.
|
||||||
|
useEffect(
|
||||||
|
() => () => {
|
||||||
|
window.clearTimeout(volTimerRef.current);
|
||||||
|
if (volSaveTimerRef.current != null) persistVolume();
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
volumeUi,
|
||||||
|
reapplyStored: () => applyVolume(volumeRef.current, false),
|
||||||
|
nudgeVolume,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Yield the interaction overlay to YouTube's native UI. Clicking a native control (gear / seek bar /
|
||||||
|
* CC) moves focus into the cross-origin player iframe — the only signal we get. While the iframe holds
|
||||||
|
* focus we drop the overlay's pointer-events so the (arbitrarily tall) settings menu is clickable end to
|
||||||
|
* end. Re-arm — restoring click-to-play, wheel volume and our keyboard shortcuts — when the pointer
|
||||||
|
* leaves the video or the window regains focus. */
|
||||||
|
export function useNativeControlsYield({
|
||||||
|
playerRef,
|
||||||
|
stageRef,
|
||||||
|
focusModal,
|
||||||
|
}: {
|
||||||
|
playerRef: MutableRefObject<YTPlayer | null>;
|
||||||
|
stageRef: RefObject<HTMLDivElement | null>;
|
||||||
|
focusModal: () => void;
|
||||||
|
}): {
|
||||||
|
nativeControls: boolean;
|
||||||
|
setNativeControls: (on: boolean) => void;
|
||||||
|
} {
|
||||||
|
const [nativeControls, setNativeControls] = useState(false);
|
||||||
|
useEffect(() => {
|
||||||
|
const iframeFocused = () => {
|
||||||
|
const p = playerRef.current;
|
||||||
|
const f = p && typeof p.getIframe === "function" ? p.getIframe() : null;
|
||||||
|
return !!f && document.activeElement === f;
|
||||||
|
};
|
||||||
|
// activeElement settles on the tick after blur, so defer the check.
|
||||||
|
const onBlur = () => window.setTimeout(() => iframeFocused() && setNativeControls(true), 0);
|
||||||
|
const rearm = () => {
|
||||||
|
setNativeControls(false);
|
||||||
|
focusModal();
|
||||||
|
};
|
||||||
|
const stage = stageRef.current;
|
||||||
|
window.addEventListener("blur", onBlur);
|
||||||
|
window.addEventListener("focus", rearm);
|
||||||
|
stage?.addEventListener("mouseleave", rearm);
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener("blur", onBlur);
|
||||||
|
window.removeEventListener("focus", rearm);
|
||||||
|
stage?.removeEventListener("mouseleave", rearm);
|
||||||
|
};
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, []);
|
||||||
|
return { nativeControls, setNativeControls };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The YouTube player's big views: `maximized` (the F key — a CSS layer that fills the viewport,
|
||||||
|
* deliberately NOT the Fullscreen API, so the cross-origin embed's OWN fullscreen button keeps working)
|
||||||
|
* and true browser fullscreen that WE own on the stage element (keeps our overlay + control bar on
|
||||||
|
* screen). Both back out one step at a time via the history layers (fullscreen → maximise → close). This
|
||||||
|
* is deliberately YT-specific and does NOT reuse Plex's useFullscreen. The refs are the bridge the
|
||||||
|
* once-bound keyboard handler reads. */
|
||||||
|
export function useYtBigView({
|
||||||
|
stageRef,
|
||||||
|
playerRef,
|
||||||
|
focusModal,
|
||||||
|
}: {
|
||||||
|
stageRef: RefObject<HTMLDivElement | null>;
|
||||||
|
playerRef: MutableRefObject<YTPlayer | null>;
|
||||||
|
focusModal: () => void;
|
||||||
|
}): {
|
||||||
|
isFullscreen: boolean;
|
||||||
|
maximized: boolean;
|
||||||
|
maximizedRef: MutableRefObject<boolean>;
|
||||||
|
isFullscreenRef: MutableRefObject<boolean>;
|
||||||
|
toggleFullscreen: () => void;
|
||||||
|
toggleTrueFullscreen: () => void;
|
||||||
|
exitBigView: () => void;
|
||||||
|
setMaximized: Dispatch<SetStateAction<boolean>>;
|
||||||
|
} {
|
||||||
|
// True while OUR stage is the fullscreen element (drives the overlay's pointer-events).
|
||||||
|
const [isFullscreen, setIsFullscreen] = useState(false);
|
||||||
|
// Our own "fills the viewport" mode (the F key). Not the Fullscreen API — see toggleFullscreen.
|
||||||
|
const [maximized, setMaximized] = useState(false);
|
||||||
|
const maximizedRef = useRef(false);
|
||||||
|
maximizedRef.current = maximized;
|
||||||
|
// Mirrored into a ref because the keydown handler is bound once and would otherwise read false forever.
|
||||||
|
const isFullscreenRef = useRef(false);
|
||||||
|
// Nested Back: peel fullscreen → maximise → close, one Back at a time (each a history layer only while
|
||||||
|
// active). Mirrors the Esc ladder in the component's keydown handler.
|
||||||
|
useBackToExit(isFullscreen, exitFullscreen);
|
||||||
|
useBackToExit(maximized, () => setMaximized(false));
|
||||||
|
|
||||||
|
const toggleFullscreen = () => setMaximized((m) => !m);
|
||||||
|
// True browser fullscreen WE own (the stage element) — keeps our overlay + controls on screen. We
|
||||||
|
// never surface YouTube's own fullscreen button, so the "cross-origin button looks dead while we hold
|
||||||
|
// the lock" problem that made F avoid the Fullscreen API doesn't arise here.
|
||||||
|
const toggleTrueFullscreen = () => {
|
||||||
|
if (fullscreenElement()) exitFullscreen();
|
||||||
|
else if (stageRef.current) requestFullscreen(stageRef.current);
|
||||||
|
};
|
||||||
|
// Leave the big view back to the modal card: drop fullscreen first if held, then un-maximise.
|
||||||
|
const exitBigView = () => {
|
||||||
|
if (fullscreenElement()) exitFullscreen();
|
||||||
|
setMaximized(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Track the browser's fullscreen state. WE never request it via F (that maximises via CSS), so this is
|
||||||
|
// YouTube's own fullscreen — entered from its control bar, left by its button or Esc. On LEAVING,
|
||||||
|
// focus is stuck in the iframe (the user had to click into the player to reach YT's button), so our
|
||||||
|
// window-level shortcuts never fire — take focus back so F/Space/arrows work the moment fullscreen ends.
|
||||||
|
useEffect(() => {
|
||||||
|
const onChange = () => {
|
||||||
|
const fsEl = fullscreenElement();
|
||||||
|
// Refs only — this listener is bound once, so it must not close over render values.
|
||||||
|
const p = playerRef.current;
|
||||||
|
const frame = p && typeof p.getIframe === "function" ? p.getIframe() : null;
|
||||||
|
const ours = !!fsEl && (fsEl === stageRef.current || fsEl === frame);
|
||||||
|
isFullscreenRef.current = ours; // read by the once-bound keydown handler
|
||||||
|
setIsFullscreen(ours); // drives the overlay's pointer-events
|
||||||
|
if (!fsEl) focusModal();
|
||||||
|
};
|
||||||
|
return onFullscreenChange(onChange); // both spellings — see lib/fullscreen
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return {
|
||||||
|
isFullscreen,
|
||||||
|
maximized,
|
||||||
|
maximizedRef,
|
||||||
|
isFullscreenRef,
|
||||||
|
toggleFullscreen,
|
||||||
|
toggleTrueFullscreen,
|
||||||
|
exitBigView,
|
||||||
|
setMaximized,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Create the YouTube IFrame player for the active item, resume from the saved position, checkpoint
|
||||||
|
* progress every 5 s (+ a pagehide beacon), and auto-mark watched once playback reaches the end. Keyed
|
||||||
|
* on activeId (a queue advance recreates the player to reuse this single-video logic). The once-bound
|
||||||
|
* event closures read fresh queue/mode/volume via the refs passed in. Mirrors Plex's usePlexProgress —
|
||||||
|
* side-effect only, returns nothing. */
|
||||||
|
export function useYtProgress({
|
||||||
|
activeId,
|
||||||
|
resumeAt,
|
||||||
|
mountRef,
|
||||||
|
playerRef,
|
||||||
|
currentIdRef,
|
||||||
|
navRef,
|
||||||
|
modeRef,
|
||||||
|
setWatched,
|
||||||
|
setPlayerError,
|
||||||
|
setLiveData,
|
||||||
|
reapplyStored,
|
||||||
|
focusModal,
|
||||||
|
advanceOnEnd,
|
||||||
|
qc,
|
||||||
|
}: {
|
||||||
|
activeId: string;
|
||||||
|
resumeAt: number;
|
||||||
|
mountRef: RefObject<HTMLDivElement | null>;
|
||||||
|
playerRef: MutableRefObject<YTPlayer | null>;
|
||||||
|
currentIdRef: MutableRefObject<string>;
|
||||||
|
navRef: MutableRefObject<{ queue: Video[] | undefined; index: number }>;
|
||||||
|
modeRef: MutableRefObject<{ autoMode: AutoMode; loopMode: LoopMode }>;
|
||||||
|
setWatched: (on: boolean) => void;
|
||||||
|
setPlayerError: (code: number | null) => void;
|
||||||
|
setLiveData: (d: { title?: string; author?: string } | null) => void;
|
||||||
|
reapplyStored: () => void;
|
||||||
|
focusModal: () => void;
|
||||||
|
advanceOnEnd: () => void;
|
||||||
|
qc: QueryClient;
|
||||||
|
}): void {
|
||||||
|
const autoMarkedRef = useRef(false);
|
||||||
|
const errorStreakRef = useRef(0);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
const id = activeId;
|
||||||
|
autoMarkedRef.current = false;
|
||||||
|
setPlayerError(null);
|
||||||
|
|
||||||
|
// Auto-watch only applies to the active item — not to other videos the player navigated to via
|
||||||
|
// description links. Cap the finish margin at half the clip so a very short video isn't auto-marked
|
||||||
|
// almost immediately.
|
||||||
|
const maybeAutoWatch = (current: number, duration: number): boolean => {
|
||||||
|
if (autoMarkedRef.current || duration <= 0 || currentIdRef.current !== id) return false;
|
||||||
|
const margin = Math.min(FINISH_MARGIN, duration / 2);
|
||||||
|
if (current > duration - margin) {
|
||||||
|
autoMarkedRef.current = true;
|
||||||
|
setWatched(true);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
const persist = (): Promise<unknown> | void => {
|
||||||
|
const p = playerRef.current;
|
||||||
|
if (!p || typeof p.getCurrentTime !== "function") return;
|
||||||
|
try {
|
||||||
|
const cur = p.getCurrentTime();
|
||||||
|
const dur = typeof p.getDuration === "function" ? p.getDuration() : 0;
|
||||||
|
// If we just auto-marked watched, skip the near-end checkpoint (it would only clear the resume
|
||||||
|
// position, racing the watched write against the progress row it deletes).
|
||||||
|
if (maybeAutoWatch(cur, dur)) return;
|
||||||
|
// Only checkpoint the library item we opened with — a navigated-to video may not be in this
|
||||||
|
// user's library, so the server would 404 on it.
|
||||||
|
if (currentIdRef.current === id) {
|
||||||
|
return api.saveProgress(id, cur, dur).catch(() => {});
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
/* player may be tearing down */
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
loadYouTubeApi().then((YT) => {
|
||||||
|
if (cancelled || !mountRef.current) return;
|
||||||
|
playerRef.current = new YT.Player(mountRef.current, {
|
||||||
|
width: "100%",
|
||||||
|
height: "100%",
|
||||||
|
videoId: id,
|
||||||
|
playerVars: {
|
||||||
|
autoplay: 1,
|
||||||
|
start: resumeAt || undefined,
|
||||||
|
rel: 0, // limit "related" to the same channel (full removal is no longer possible)
|
||||||
|
enablejsapi: 1,
|
||||||
|
origin: window.location.origin,
|
||||||
|
playsinline: 1,
|
||||||
|
// Best-effort HD hint (mostly a no-op now; YouTube hard-caps embed quality by on-screen size).
|
||||||
|
vq: "hd1080",
|
||||||
|
},
|
||||||
|
events: {
|
||||||
|
onReady: () => {
|
||||||
|
focusModal();
|
||||||
|
reapplyStored();
|
||||||
|
},
|
||||||
|
onStateChange: (e: YTPlayerEvent) => {
|
||||||
|
// 1 === playing → sync the navigated-to video's title/author for display.
|
||||||
|
if (e?.data === 1) {
|
||||||
|
errorStreakRef.current = 0; // a successful play breaks any unplayable run
|
||||||
|
const p = playerRef.current;
|
||||||
|
const d = p && typeof p.getVideoData === "function" ? p.getVideoData() : null;
|
||||||
|
if (d) setLiveData({ title: d.title, author: d.author });
|
||||||
|
}
|
||||||
|
// 0 === ended → mark watched, then advance to the next queue item if any.
|
||||||
|
if (e?.data === 0 && currentIdRef.current === id) {
|
||||||
|
if (!autoMarkedRef.current) {
|
||||||
|
autoMarkedRef.current = true;
|
||||||
|
setWatched(true);
|
||||||
|
}
|
||||||
|
advanceOnEnd();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onError: (e: YTPlayerEvent) => {
|
||||||
|
setPlayerError(typeof e?.data === "number" ? e.data : -1);
|
||||||
|
// onError (not 'ended') fires for an unplayable item, so advanceOnEnd() never runs and an
|
||||||
|
// auto-advancing queue would freeze — skip forward instead, but bounded so a run of dead
|
||||||
|
// items can't spin. We do NOT mark the broken video watched.
|
||||||
|
if (currentIdRef.current !== id) return; // a navigated description-link error: leave the queue alone
|
||||||
|
const { autoMode: a } = modeRef.current;
|
||||||
|
const { queue: q } = navRef.current;
|
||||||
|
if (a === "off" || !q || q.length <= 1) return;
|
||||||
|
errorStreakRef.current += 1;
|
||||||
|
if (errorStreakRef.current >= MAX_CONSECUTIVE_ERRORS) return; // give up; leave the overlay
|
||||||
|
advanceOnEnd();
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Periodic checkpoint so progress (and auto-watch) survive a crash/refresh.
|
||||||
|
const timer = window.setInterval(persist, 5000);
|
||||||
|
// Save on page unload too (F5/close/navigate): effect cleanup doesn't run on a full reload. A
|
||||||
|
// keepalive beacon so the POST survives the unload. Only for the library item we opened with.
|
||||||
|
const onHide = () => {
|
||||||
|
const p = playerRef.current;
|
||||||
|
if (!p || typeof p.getCurrentTime !== "function" || currentIdRef.current !== id) return;
|
||||||
|
try {
|
||||||
|
const cur = p.getCurrentTime();
|
||||||
|
const dur = typeof p.getDuration === "function" ? p.getDuration() : 0;
|
||||||
|
if (cur > 0 && dur > 0) api.saveProgressBeacon(id, cur, dur);
|
||||||
|
} catch {
|
||||||
|
/* player tearing down */
|
||||||
|
}
|
||||||
|
};
|
||||||
|
window.addEventListener("pagehide", onHide);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
window.clearInterval(timer);
|
||||||
|
window.removeEventListener("pagehide", onHide);
|
||||||
|
// Final flush, then mark the feed + playlists stale so resume bars reflect this session on the next
|
||||||
|
// natural refetch — WITHOUT refetching every loaded feed page on each player close (PERF-2).
|
||||||
|
Promise.resolve(persist()).finally(() => {
|
||||||
|
qc.invalidateQueries({ queryKey: qk.feed(), refetchType: "none" });
|
||||||
|
qc.invalidateQueries({ queryKey: qk.playlist(), refetchType: "none" });
|
||||||
|
});
|
||||||
|
const p = playerRef.current;
|
||||||
|
if (p && typeof p.destroy === "function") {
|
||||||
|
try {
|
||||||
|
p.destroy();
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
playerRef.current = null;
|
||||||
|
};
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [activeId]);
|
||||||
|
}
|
||||||
@@ -1,10 +1,16 @@
|
|||||||
import { useLayoutEffect, useRef, useState, type ReactNode } from "react";
|
import { useLayoutEffect, useRef, useState, type ReactNode } from "react";
|
||||||
import { qk } from "./queryKeys";
|
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { useQueryClient } from "@tanstack/react-query";
|
import {
|
||||||
import { SlidersHorizontal } from "lucide-react";
|
ArrowLeft,
|
||||||
|
Check,
|
||||||
|
CheckCheck,
|
||||||
|
Play,
|
||||||
|
SlidersHorizontal,
|
||||||
|
type LucideIcon,
|
||||||
|
} from "lucide-react";
|
||||||
import { useDismiss } from "./useDismiss";
|
import { useDismiss } from "./useDismiss";
|
||||||
import { api } from "./api";
|
import { usePref, useSavePref } from "./usePref";
|
||||||
|
import type { PlexCard } from "./api";
|
||||||
|
|
||||||
// Shared "detail page" UI reused by the movie info page (PlexInfo) and the series show/season pages,
|
// Shared "detail page" UI reused by the movie info page (PlexInfo) and the series show/season pages,
|
||||||
// so they look consistent (the standing glassy/HTPC brief): the faint fixed art backdrop, the two
|
// so they look consistent (the standing glassy/HTPC brief): the faint fixed art backdrop, the two
|
||||||
@@ -12,42 +18,21 @@ import { api } from "./api";
|
|||||||
// customize menu that toggles them.
|
// customize menu that toggles them.
|
||||||
|
|
||||||
export function useDetailPrefs() {
|
export function useDetailPrefs() {
|
||||||
const qc = useQueryClient();
|
// Three per-user detail-page toggles, each a server pref adopted on login (usePref). Default ON:
|
||||||
const prefs = (qc.getQueryData<{ preferences?: Record<string, unknown> }>(qk.me())?.preferences ??
|
// undefined → true, only an explicit false hides. The detail pages mount after `me` is cached, so
|
||||||
{}) as {
|
// usePref seeds from the cache (no localStorage mirror).
|
||||||
plexInfoArtBg?: boolean;
|
const [artBg, setArtBg] = usePref<boolean>("plexInfoArtBg", true);
|
||||||
plexInfoCast?: boolean;
|
const [showCast, setShowCast] = usePref<boolean>("plexInfoCast", true);
|
||||||
plexInfoRelated?: boolean;
|
const [showRelated, setShowRelated] = usePref<boolean>("plexInfoRelated", true);
|
||||||
};
|
const savePref = useSavePref();
|
||||||
const [artBg, setArtBg] = useState(prefs.plexInfoArtBg !== false);
|
|
||||||
const [showCast, setShowCast] = useState(prefs.plexInfoCast !== false);
|
|
||||||
const [showRelated, setShowRelated] = useState(prefs.plexInfoRelated !== false);
|
|
||||||
const save = (patch: Record<string, unknown>) => {
|
|
||||||
api.savePrefs(patch).catch(() => {});
|
|
||||||
qc.setQueryData<{ preferences?: Record<string, unknown> } | undefined>(qk.me(), (m) =>
|
|
||||||
m ? { ...m, preferences: { ...(m.preferences || {}), ...patch } } : m,
|
|
||||||
);
|
|
||||||
};
|
|
||||||
return {
|
return {
|
||||||
artBg,
|
artBg,
|
||||||
showCast,
|
showCast,
|
||||||
showRelated,
|
showRelated,
|
||||||
savePref: save, // expose the raw persister for callers with extra per-user prefs (e.g. strip sources)
|
savePref, // the raw persister for callers with extra per-user prefs (e.g. strip sources)
|
||||||
toggleArtBg: () => {
|
toggleArtBg: () => setArtBg(!artBg),
|
||||||
const next = !artBg;
|
toggleCast: () => setShowCast(!showCast),
|
||||||
setArtBg(next);
|
toggleRelated: () => setShowRelated(!showRelated),
|
||||||
save({ plexInfoArtBg: next });
|
|
||||||
},
|
|
||||||
toggleCast: () => {
|
|
||||||
const next = !showCast;
|
|
||||||
setShowCast(next);
|
|
||||||
save({ plexInfoCast: next });
|
|
||||||
},
|
|
||||||
toggleRelated: () => {
|
|
||||||
const next = !showRelated;
|
|
||||||
setShowRelated(next);
|
|
||||||
save({ plexInfoRelated: next });
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -207,3 +192,130 @@ export function PrefToggle({
|
|||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Series detail-page shared UI (the show page + the season subpage share these) -------------------
|
||||||
|
// Moved out of PlexBrowse — small building blocks the show/season detail views render verbatim.
|
||||||
|
|
||||||
|
export function epLabel(ep?: PlexCard | null): string | undefined {
|
||||||
|
if (!ep) return undefined;
|
||||||
|
if (ep.season_number != null && ep.episode_number != null)
|
||||||
|
return `S${ep.season_number} · E${ep.episode_number}`;
|
||||||
|
return ep.title;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function BackBtn({ onBack, label }: { onBack: () => void; label: string }) {
|
||||||
|
return (
|
||||||
|
<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" />
|
||||||
|
{label}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ActionBtn({
|
||||||
|
onClick,
|
||||||
|
icon: Icon,
|
||||||
|
label,
|
||||||
|
sub,
|
||||||
|
primary,
|
||||||
|
disabled,
|
||||||
|
}: {
|
||||||
|
onClick: () => void;
|
||||||
|
icon: LucideIcon;
|
||||||
|
label: string;
|
||||||
|
sub?: string;
|
||||||
|
primary?: boolean;
|
||||||
|
disabled?: boolean;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
onClick={onClick}
|
||||||
|
disabled={disabled}
|
||||||
|
className={`inline-flex items-center gap-2 rounded-xl px-3 py-2 text-sm font-medium transition disabled:opacity-50 ${
|
||||||
|
primary ? "btn-primary shadow-xs" : "glass-card glass-hover"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Icon className="w-4 h-4 shrink-0" />
|
||||||
|
<span className="flex flex-col items-start leading-tight">
|
||||||
|
<span>{label}</span>
|
||||||
|
{sub && <span className="text-2xs font-normal opacity-70">{sub}</span>}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The shared detail-page hero shell used by BOTH the show page and the season subpage (they were
|
||||||
|
* copy-paste siblings). Owns the glass frame, the play/resume poster button, the customize-menu slot,
|
||||||
|
* and the flex info column; the caller passes the customize menu + the title/metadata/actions as
|
||||||
|
* `children`. Poster is slightly larger on the show page (default) than the season page. */
|
||||||
|
export function DetailHero({
|
||||||
|
thumb,
|
||||||
|
posterTitle,
|
||||||
|
posterDisabled,
|
||||||
|
showPlayOverlay,
|
||||||
|
onPosterClick,
|
||||||
|
posterSizeClass = "w-32 sm:w-44",
|
||||||
|
customizeMenu,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
thumb: string;
|
||||||
|
posterTitle: string;
|
||||||
|
posterDisabled: boolean;
|
||||||
|
showPlayOverlay: boolean;
|
||||||
|
onPosterClick: () => void;
|
||||||
|
posterSizeClass?: string;
|
||||||
|
customizeMenu: ReactNode;
|
||||||
|
children: ReactNode;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="glass relative rounded-2xl p-4 sm:p-5 mb-8 flex gap-4 sm:gap-5">
|
||||||
|
<div className="absolute right-3 top-3 z-base">{customizeMenu}</div>
|
||||||
|
<button
|
||||||
|
onClick={onPosterClick}
|
||||||
|
disabled={posterDisabled}
|
||||||
|
className={`group/poster relative ${posterSizeClass} shrink-0 self-start`}
|
||||||
|
title={posterTitle}
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
src={thumb}
|
||||||
|
alt=""
|
||||||
|
className="w-full aspect-2/3 object-cover rounded-xl border border-border"
|
||||||
|
/>
|
||||||
|
{showPlayOverlay && (
|
||||||
|
<div className="absolute inset-0 rounded-xl bg-black/40 opacity-0 group-hover/poster:opacity-100 transition grid place-items-center">
|
||||||
|
<Play className="w-10 h-10 text-white" fill="currentColor" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
<div className="min-w-0 flex-1">{children}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The "mark whole show/season watched" hero action (icon + label flip on state). Verbatim in both
|
||||||
|
* the show and season heroes; the labels differ, so the caller passes both. */
|
||||||
|
export function WatchedToggle({
|
||||||
|
watched,
|
||||||
|
busy,
|
||||||
|
onToggle,
|
||||||
|
watchedLabel,
|
||||||
|
unwatchedLabel,
|
||||||
|
}: {
|
||||||
|
watched: boolean;
|
||||||
|
busy?: boolean;
|
||||||
|
onToggle: () => void;
|
||||||
|
watchedLabel: string;
|
||||||
|
unwatchedLabel: string;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<ActionBtn
|
||||||
|
onClick={onToggle}
|
||||||
|
disabled={busy}
|
||||||
|
icon={watched ? CheckCheck : Check}
|
||||||
|
label={watched ? watchedLabel : unwatchedLabel}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import type { TFunction } from "i18next";
|
||||||
|
import { api, type PlexCard } from "./api";
|
||||||
|
import { notifyActionError } from "./actionErrors";
|
||||||
|
|
||||||
|
// Persist a watch-state toggle from any Plex surface: flip via the caller's `optimistic` cache
|
||||||
|
// updater, call the API, surface a failure (the click used to look dead on 403/404/429), then
|
||||||
|
// `resync` (invalidate) — which corrects the optimistic flip to truth either way. Shared by the
|
||||||
|
// browse grid and the person page, each of which owns a different cache shape.
|
||||||
|
export async function toggleWatchState(
|
||||||
|
card: PlexCard,
|
||||||
|
t: TFunction,
|
||||||
|
handlers: { optimistic: (next: string) => void; resync: () => void },
|
||||||
|
) {
|
||||||
|
const next = card.status === "watched" ? "new" : "watched";
|
||||||
|
handlers.optimistic(next);
|
||||||
|
try {
|
||||||
|
await api.plexSetState(card.id, next);
|
||||||
|
} catch (e) {
|
||||||
|
notifyActionError(e, t("plex.watchedFailed"));
|
||||||
|
}
|
||||||
|
handlers.resync();
|
||||||
|
}
|
||||||
@@ -14,6 +14,22 @@ export interface ReleaseEntry {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const RELEASE_NOTES: ReleaseEntry[] = [
|
export const RELEASE_NOTES: ReleaseEntry[] = [
|
||||||
|
{
|
||||||
|
version: "0.70.0",
|
||||||
|
date: "2026-08-20",
|
||||||
|
summary:
|
||||||
|
"The address bar now shows a real link for what you're viewing, so you can bookmark or share a channel, a Plex title, a playlist, or a filtered feed — and card buttons work on touch screens.",
|
||||||
|
features: [
|
||||||
|
"You can now bookmark or share the exact view you're on. The address bar reflects the open channel page, a Plex movie/show, a selected playlist, or the feed with its filters applied, so copying the link reproduces that view for you or anyone you send it to. This replaces the old “Share view” button, which is no longer needed. (The browser tab keeps showing the short page name.)",
|
||||||
|
],
|
||||||
|
fixes: [
|
||||||
|
"On touch screens the buttons on a video or Plex card — mark watched, save, add to playlist, info — are now always visible and tappable, instead of only appearing on mouse hover. Watched items also show a filled check, not colour alone, so the state is clear without relying on colour.",
|
||||||
|
"Opening and closing a dialog no longer closes a channel or Plex page you reached from a bookmarked or shared link.",
|
||||||
|
],
|
||||||
|
chores: [
|
||||||
|
"A large internal front-end refactor with no behaviour change: several oversized screens (the Plex browser, the video player, the downloads page) were split into focused files; account preferences and the address-bar handling were folded into single reusable hooks; the three filter sidebars now share one shell; and the pop-up dialogs gained proper keyboard focus handling. Covered by the existing tests plus two review passes — a standard review and a multi-agent deep review.",
|
||||||
|
],
|
||||||
|
},
|
||||||
{
|
{
|
||||||
version: "0.69.2",
|
version: "0.69.2",
|
||||||
date: "2026-08-19",
|
date: "2026-08-19",
|
||||||
|
|||||||
@@ -0,0 +1,124 @@
|
|||||||
|
// @vitest-environment jsdom
|
||||||
|
//
|
||||||
|
// The address-bar helpers that back the URL model: urlWithParams (read-modify-write on the live query so
|
||||||
|
// each provider owns its own key without clobbering the others), clearParams (strip only transient
|
||||||
|
// auth/one-time keys, never the nav/filter ones), and filterParamUpdates (feed filters → set/delete
|
||||||
|
// delta, or clear-all). The round-trip through filtersToParams/paramsToFilters is exercised too.
|
||||||
|
import { describe, it, expect, beforeEach } from "vitest";
|
||||||
|
import {
|
||||||
|
urlWithParams,
|
||||||
|
clearParams,
|
||||||
|
filterParamUpdates,
|
||||||
|
filtersToParams,
|
||||||
|
paramsToFilters,
|
||||||
|
} from "./urlState";
|
||||||
|
import type { FeedFilters } from "./api";
|
||||||
|
|
||||||
|
function setLocation(search: string) {
|
||||||
|
window.history.replaceState(null, "", `/app${search}`);
|
||||||
|
}
|
||||||
|
function queryOf(url: string): URLSearchParams {
|
||||||
|
return new URLSearchParams(url.includes("?") ? url.slice(url.indexOf("?") + 1) : "");
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEFAULTS: FeedFilters = {
|
||||||
|
tags: [],
|
||||||
|
tagMode: "or",
|
||||||
|
channelIds: [],
|
||||||
|
q: "",
|
||||||
|
sort: "newest",
|
||||||
|
scope: "my",
|
||||||
|
includeNormal: true,
|
||||||
|
includeShorts: false,
|
||||||
|
includeLive: false,
|
||||||
|
show: "unwatched",
|
||||||
|
};
|
||||||
|
|
||||||
|
describe("urlWithParams", () => {
|
||||||
|
beforeEach(() => setLocation(""));
|
||||||
|
|
||||||
|
it("sets a key while preserving the pathname and existing params", () => {
|
||||||
|
setLocation("?page=plex");
|
||||||
|
const url = urlWithParams({ v: "show:1" });
|
||||||
|
expect(url.startsWith("/app?")).toBe(true);
|
||||||
|
const q = queryOf(url);
|
||||||
|
expect(q.get("v")).toBe("show:1");
|
||||||
|
expect(q.get("page")).toBe("plex"); // untouched
|
||||||
|
});
|
||||||
|
|
||||||
|
it("deletes a key on a null value and leaves the others", () => {
|
||||||
|
setLocation("?page=plex&v=show:1&q=react");
|
||||||
|
const q = queryOf(urlWithParams({ v: null }));
|
||||||
|
expect(q.has("v")).toBe(false);
|
||||||
|
expect(q.get("page")).toBe("plex");
|
||||||
|
expect(q.get("q")).toBe("react");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("applies a mix of sets and deletes in one call", () => {
|
||||||
|
setLocation("?page=plex&v=show:1&chan=UC1");
|
||||||
|
const q = queryOf(urlWithParams({ page: null, chan: null, v: null, pl: null }));
|
||||||
|
expect([...q.keys()]).toEqual([]); // page switch resets the view-specific keys
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns a bare path when the query empties out", () => {
|
||||||
|
setLocation("?page=channels");
|
||||||
|
expect(urlWithParams({ page: null })).toBe("/app");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("clearParams", () => {
|
||||||
|
beforeEach(() => setLocation(""));
|
||||||
|
|
||||||
|
it("removes only the named transient keys, keeping nav/filter params", () => {
|
||||||
|
setLocation("?link=ok&page=plex&v=show:1&q=react");
|
||||||
|
clearParams("link", "oauth", "admin", "access", "verify", "reset");
|
||||||
|
const q = new URLSearchParams(window.location.search);
|
||||||
|
expect(q.has("link")).toBe(false);
|
||||||
|
expect(q.get("page")).toBe("plex");
|
||||||
|
expect(q.get("v")).toBe("show:1");
|
||||||
|
expect(q.get("q")).toBe("react");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("is a no-op (no history write) when none of the keys are present", () => {
|
||||||
|
setLocation("?page=plex");
|
||||||
|
const before = window.location.search;
|
||||||
|
clearParams("link", "oauth");
|
||||||
|
expect(window.location.search).toBe(before);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("filterParamUpdates", () => {
|
||||||
|
it("maps non-default filters to values and defaults to null (delete)", () => {
|
||||||
|
const f: FeedFilters = { ...DEFAULTS, q: "react", tags: [3, 7], show: "watched" };
|
||||||
|
const upd = filterParamUpdates(f);
|
||||||
|
expect(upd.q).toBe("react");
|
||||||
|
expect(upd.tags).toBe("3,7");
|
||||||
|
expect(upd.show).toBe("watched");
|
||||||
|
expect(upd.sort).toBeNull(); // default "newest" is not emitted → cleared
|
||||||
|
expect(upd.scope).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("clears ALL filter keys when passed null (leaving the feed)", () => {
|
||||||
|
const upd = filterParamUpdates(null);
|
||||||
|
for (const v of Object.values(upd)) expect(v).toBeNull();
|
||||||
|
// includes every recognized filter key
|
||||||
|
expect(Object.keys(upd)).toEqual(
|
||||||
|
expect.arrayContaining(["q", "tags", "show", "sort", "scope"]),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("round-trips through the URL (filters → params → filters)", () => {
|
||||||
|
const f: FeedFilters = {
|
||||||
|
...DEFAULTS,
|
||||||
|
q: "hi",
|
||||||
|
tags: [1, 2],
|
||||||
|
scope: "all",
|
||||||
|
includeShorts: true,
|
||||||
|
};
|
||||||
|
const back = paramsToFilters(filtersToParams(f), DEFAULTS);
|
||||||
|
expect(back.q).toBe("hi");
|
||||||
|
expect(back.tags).toEqual([1, 2]);
|
||||||
|
expect(back.scope).toBe("all");
|
||||||
|
expect(back.includeShorts).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,7 +1,9 @@
|
|||||||
// Serialize feed filters to/from a compact, human-readable URL query string. Filters are
|
// Serialize feed filters to/from a compact, human-readable URL query string, and build/patch the
|
||||||
// NOT kept in the address bar during normal use (localStorage is the single source of
|
// address-bar URL for the whole app's navigation. The query string IS the source of truth for the
|
||||||
// truth); this serializer powers the explicit "Share view" link and the one-time hydration
|
// current view (page / channel / plex sub-view / playlist) and the feed filters, so every URL is
|
||||||
// when someone opens such a link. Only non-default values are emitted to keep URLs clean.
|
// bookmarkable/shareable — the old "Share view" workaround (and the whole-query stripUrlParams) is
|
||||||
|
// retired. Only non-default values are emitted so URLs stay clean. NOTE: the browser TAB title stays
|
||||||
|
// the short module/channel name (NavigationProvider owns document.title) — the query never leaks into it.
|
||||||
import type { FeedFilters } from "./api";
|
import type { FeedFilters } from "./api";
|
||||||
|
|
||||||
const KEYS = [
|
const KEYS = [
|
||||||
@@ -46,6 +48,17 @@ export function filtersToParams(f: FeedFilters): URLSearchParams {
|
|||||||
return p;
|
return p;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Build the address-bar delta for the feed filters: each recognized filter key mapped to its emitted
|
||||||
|
* value, or null to delete it (so switching a filter back to its default clears the key). Pass `null`
|
||||||
|
* to clear ALL filter keys — used when leaving the feed, where the localStorage copy still holds them.
|
||||||
|
* Feed the result into urlWithParams(). */
|
||||||
|
export function filterParamUpdates(f: FeedFilters | null): Record<string, string | null> {
|
||||||
|
const p = f ? filtersToParams(f) : new URLSearchParams();
|
||||||
|
const out: Record<string, string | null> = {};
|
||||||
|
for (const k of KEYS) out[k] = p.get(k);
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
/** Merge URL params onto a base filter set (typically the defaults). */
|
/** Merge URL params onto a base filter set (typically the defaults). */
|
||||||
export function paramsToFilters(params: URLSearchParams, base: FeedFilters): FeedFilters {
|
export function paramsToFilters(params: URLSearchParams, base: FeedFilters): FeedFilters {
|
||||||
const f: FeedFilters = { ...base };
|
const f: FeedFilters = { ...base };
|
||||||
@@ -117,20 +130,38 @@ export function readPage(): Page {
|
|||||||
return isPage(p) ? p : "feed";
|
return isPage(p) ? p : "feed";
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Build a shareable absolute URL that reproduces the current filters (+ page). Used by the
|
/** Build a URL (path + query) from the CURRENT location's query with `updates` applied — a null value
|
||||||
* opt-in "Share view" action — filters are not otherwise written to the address bar. */
|
* deletes that key, any other string sets it. Lets each provider own its own nav key(s) — page / chan /
|
||||||
export function shareUrl(f: FeedFilters, page: Page = "feed"): string {
|
* v / pl / the feed-filter keys — and update the address bar without clobbering the others (read-modify-
|
||||||
const params = filtersToParams(f);
|
* write on the live query). The caller decides pushState vs replaceState with the returned string. */
|
||||||
if (page !== "feed") params.set("page", page);
|
export function urlWithParams(updates: Record<string, string | null>): string {
|
||||||
|
const params = new URLSearchParams(window.location.search);
|
||||||
|
for (const [k, v] of Object.entries(updates)) {
|
||||||
|
if (v == null) params.delete(k);
|
||||||
|
else params.set(k, v);
|
||||||
|
}
|
||||||
const qs = params.toString();
|
const qs = params.toString();
|
||||||
return `${window.location.origin}${window.location.pathname}${qs ? `?${qs}` : ""}`;
|
return `${window.location.pathname}${qs ? `?${qs}` : ""}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Strip the query string from the address bar (after hydrating from a share/auth link),
|
/** Remove specific TRANSIENT params (one-time auth/admin links: ?link, ?oauth, ?admin, ?access, ?verify,
|
||||||
* leaving a clean URL. Preserves the current history.state (e.g. the in-app `sfPage` stamp)
|
* ?reset) from the address bar once they've been read, WITHOUT touching the nav/filter params that now
|
||||||
* so it's safe to call at any time, not just before that stamp is written. */
|
* live there. Preserves history.state. Replaces the old whole-query stripUrlParams, which is unsafe now
|
||||||
export function stripUrlParams(): void {
|
* that the query is the source of truth for navigation. No-op when none of the keys are present. */
|
||||||
if (window.location.search) {
|
export function clearParams(...keys: string[]): void {
|
||||||
window.history.replaceState(window.history.state, "", window.location.pathname);
|
const params = new URLSearchParams(window.location.search);
|
||||||
|
let changed = false;
|
||||||
|
for (const k of keys) {
|
||||||
|
if (params.has(k)) {
|
||||||
|
params.delete(k);
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
if (!changed) return;
|
||||||
|
const qs = params.toString();
|
||||||
|
window.history.replaceState(
|
||||||
|
window.history.state,
|
||||||
|
"",
|
||||||
|
`${window.location.pathname}${qs ? `?${qs}` : ""}`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,174 @@
|
|||||||
|
// @vitest-environment jsdom
|
||||||
|
//
|
||||||
|
// usePref: the one per-account + server preference hook. Covers the three seed sources (server cache /
|
||||||
|
// localStorage mirror / default), the persist path (server PUT + optimistic `me` cache merge + mirror),
|
||||||
|
// debounced writes, adopt-on-login, parse, and the raw useSavePref persister. The `me` cache is the
|
||||||
|
// server source of truth here; storage is an in-memory account-keyed map; api.savePrefs is a spy.
|
||||||
|
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
||||||
|
import { renderHook, act, waitFor } from "@testing-library/react";
|
||||||
|
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||||
|
import { createElement, type ReactNode } from "react";
|
||||||
|
|
||||||
|
const { store, savePrefs } = vi.hoisted(() => ({
|
||||||
|
store: new Map<string, string>(),
|
||||||
|
savePrefs: vi.fn(() => Promise.resolve({})),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("./storage", () => ({
|
||||||
|
readAccount: (base: string, fallback: unknown) => {
|
||||||
|
const raw = store.get(base);
|
||||||
|
return raw == null ? fallback : JSON.parse(raw);
|
||||||
|
},
|
||||||
|
writeAccount: (base: string, value: unknown) => {
|
||||||
|
store.set(base, JSON.stringify(value));
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
vi.mock("./api", () => ({ api: { savePrefs, me: vi.fn() } }));
|
||||||
|
|
||||||
|
import { usePref, useSavePref } from "./usePref";
|
||||||
|
import { qk } from "./queryKeys";
|
||||||
|
|
||||||
|
function makeClient() {
|
||||||
|
return new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||||
|
}
|
||||||
|
function seedMe(qc: QueryClient, preferences: Record<string, unknown>, id = 1) {
|
||||||
|
qc.setQueryData(qk.me(), { id, preferences });
|
||||||
|
}
|
||||||
|
function wrapperFor(qc: QueryClient) {
|
||||||
|
return ({ children }: { children: ReactNode }) =>
|
||||||
|
createElement(QueryClientProvider, { client: qc }, children);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("usePref", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
store.clear();
|
||||||
|
savePrefs.mockClear();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("seeds from the default when neither server nor mirror has the key", () => {
|
||||||
|
const qc = makeClient();
|
||||||
|
const { result } = renderHook(() => usePref<string>("view", "cards"), {
|
||||||
|
wrapper: wrapperFor(qc),
|
||||||
|
});
|
||||||
|
expect(result.current[0]).toBe("cards");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("seeds from the server (me cache) when present", () => {
|
||||||
|
const qc = makeClient();
|
||||||
|
seedMe(qc, { view: "rows" });
|
||||||
|
const { result } = renderHook(() => usePref<string>("view", "cards"), {
|
||||||
|
wrapper: wrapperFor(qc),
|
||||||
|
});
|
||||||
|
expect(result.current[0]).toBe("rows");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("seeds from the localStorage mirror when there is no server value", () => {
|
||||||
|
const qc = makeClient();
|
||||||
|
store.set("siftlode.feedView", JSON.stringify("tiles"));
|
||||||
|
const { result } = renderHook(
|
||||||
|
() => usePref<string>("view", "cards", { localKey: "siftlode.feedView" }),
|
||||||
|
{ wrapper: wrapperFor(qc) },
|
||||||
|
);
|
||||||
|
expect(result.current[0]).toBe("tiles");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("prefers the server value over the mirror", () => {
|
||||||
|
const qc = makeClient();
|
||||||
|
seedMe(qc, { view: "rows" });
|
||||||
|
store.set("siftlode.feedView", JSON.stringify("tiles"));
|
||||||
|
const { result } = renderHook(
|
||||||
|
() => usePref<string>("view", "cards", { localKey: "siftlode.feedView" }),
|
||||||
|
{ wrapper: wrapperFor(qc) },
|
||||||
|
);
|
||||||
|
expect(result.current[0]).toBe("rows");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("applies parse to a raw stored value", () => {
|
||||||
|
const qc = makeClient();
|
||||||
|
seedMe(qc, { view: "GRID" });
|
||||||
|
const { result } = renderHook(
|
||||||
|
() => usePref<string>("view", "cards", { parse: (r) => String(r).toLowerCase() }),
|
||||||
|
{ wrapper: wrapperFor(qc) },
|
||||||
|
);
|
||||||
|
expect(result.current[0]).toBe("grid");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("on set: updates state, writes the server, merges the me cache, and the mirror", () => {
|
||||||
|
const qc = makeClient();
|
||||||
|
seedMe(qc, {});
|
||||||
|
const { result } = renderHook(
|
||||||
|
() => usePref<string>("view", "cards", { localKey: "siftlode.feedView" }),
|
||||||
|
{ wrapper: wrapperFor(qc) },
|
||||||
|
);
|
||||||
|
act(() => result.current[1]("rows"));
|
||||||
|
expect(result.current[0]).toBe("rows");
|
||||||
|
expect(savePrefs).toHaveBeenCalledWith({ view: "rows" });
|
||||||
|
expect(
|
||||||
|
(qc.getQueryData(qk.me()) as { preferences: Record<string, unknown> }).preferences.view,
|
||||||
|
).toBe("rows");
|
||||||
|
expect(store.get("siftlode.feedView")).toBe(JSON.stringify("rows"));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not write a localStorage mirror when no localKey is given", () => {
|
||||||
|
const qc = makeClient();
|
||||||
|
seedMe(qc, {});
|
||||||
|
const { result } = renderHook(() => usePref<string>("playerLoop", "off"), {
|
||||||
|
wrapper: wrapperFor(qc),
|
||||||
|
});
|
||||||
|
act(() => result.current[1]("all"));
|
||||||
|
expect(store.size).toBe(0);
|
||||||
|
expect(savePrefs).toHaveBeenCalledWith({ playerLoop: "all" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("adopts the server value when the account id changes (login / switch)", async () => {
|
||||||
|
const qc = makeClient();
|
||||||
|
seedMe(qc, { view: "cards" }, 1);
|
||||||
|
const { result } = renderHook(() => usePref<string>("view", "cards"), {
|
||||||
|
wrapper: wrapperFor(qc),
|
||||||
|
});
|
||||||
|
expect(result.current[0]).toBe("cards");
|
||||||
|
// A different account resolves. The passive me observer notifies asynchronously (react-query's
|
||||||
|
// notifyManager batches), so wait for the re-render — the same path the real App drives on login.
|
||||||
|
act(() => seedMe(qc, { view: "tiles" }, 2));
|
||||||
|
await waitFor(() => expect(result.current[0]).toBe("tiles"));
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("debounceMs", () => {
|
||||||
|
beforeEach(() => vi.useFakeTimers());
|
||||||
|
afterEach(() => vi.useRealTimers());
|
||||||
|
|
||||||
|
it("coalesces rapid sets into one PUT after the delay", () => {
|
||||||
|
const qc = makeClient();
|
||||||
|
seedMe(qc, {});
|
||||||
|
const { result } = renderHook(() => usePref<number>("vol", 0, { debounceMs: 300 }), {
|
||||||
|
wrapper: wrapperFor(qc),
|
||||||
|
});
|
||||||
|
act(() => {
|
||||||
|
result.current[1](10);
|
||||||
|
result.current[1](20);
|
||||||
|
result.current[1](30);
|
||||||
|
});
|
||||||
|
// Local state is instant; the server write is deferred.
|
||||||
|
expect(result.current[0]).toBe(30);
|
||||||
|
expect(savePrefs).not.toHaveBeenCalled();
|
||||||
|
act(() => vi.advanceTimersByTime(300));
|
||||||
|
expect(savePrefs).toHaveBeenCalledTimes(1);
|
||||||
|
expect(savePrefs).toHaveBeenCalledWith({ vol: 30 });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("useSavePref", () => {
|
||||||
|
beforeEach(() => savePrefs.mockClear());
|
||||||
|
|
||||||
|
it("persists an arbitrary patch and merges it into the me cache", () => {
|
||||||
|
const qc = makeClient();
|
||||||
|
seedMe(qc, { a: 1 });
|
||||||
|
const { result } = renderHook(() => useSavePref(), { wrapper: wrapperFor(qc) });
|
||||||
|
act(() => result.current({ plexHiddenStripSources: ["x"] }));
|
||||||
|
expect(savePrefs).toHaveBeenCalledWith({ plexHiddenStripSources: ["x"] });
|
||||||
|
const prefs = (qc.getQueryData(qk.me()) as { preferences: Record<string, unknown> })
|
||||||
|
.preferences;
|
||||||
|
expect(prefs).toEqual({ a: 1, plexHiddenStripSources: ["x"] });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,152 @@
|
|||||||
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
|
import { useQuery, useQueryClient, type QueryClient } from "@tanstack/react-query";
|
||||||
|
import { qk } from "./queryKeys";
|
||||||
|
import { api } from "./api";
|
||||||
|
import type { Me } from "./api/account";
|
||||||
|
import { readAccount, writeAccount } from "./storage";
|
||||||
|
|
||||||
|
// The ONE per-account + server preference hook. A preference (unlike an ephemeral filter) persists to
|
||||||
|
// the account on the server (PUT /api/me/preferences, which MERGES the partial payload) AND is adopted
|
||||||
|
// back on login / account switch. Before this, every such pref hand-rolled the same three pieces at its
|
||||||
|
// call site: (1) seed local state, (2) on change write the server + a local mirror + the `me` cache,
|
||||||
|
// (3) a `useEffect([me?.id])` block to adopt the server value when the account resolves. usePref folds
|
||||||
|
// all three into one hook so a new server-synced pref is a one-liner.
|
||||||
|
//
|
||||||
|
// Two mirror strategies, unified here (pick via `localKey`):
|
||||||
|
// • Providers mounted ABOVE <App> (feed view, panel layouts, rail-collapse flags) render before
|
||||||
|
// /api/me pins the account, so they need a synchronous localStorage seed to avoid a flash on
|
||||||
|
// reload — pass `localKey`.
|
||||||
|
// • Late-mounting consumers (the player, the Plex detail pages) mount after `me` is cached, so they
|
||||||
|
// seed straight from the react-query cache and need no localStorage mirror — omit `localKey`.
|
||||||
|
// Either way the source of truth is the server; the `me` cache is kept in sync optimistically so other
|
||||||
|
// consumers reading the same key (e.g. a second player instance) see the change at once.
|
||||||
|
|
||||||
|
export interface UsePrefOptions<T> {
|
||||||
|
/** Also mirror to per-account localStorage under this base key, and seed synchronously from it on
|
||||||
|
* first paint. Use for prefs whose owner mounts before the account is pinned; omit otherwise. */
|
||||||
|
localKey?: string;
|
||||||
|
/** Debounce the server PUT by N ms so a slider drag / rapid toggles coalesce into one request. The
|
||||||
|
* local state + cache still update instantly; only the network write is deferred. */
|
||||||
|
debounceMs?: number;
|
||||||
|
/** Normalize a raw stored value (from the server payload or the localStorage mirror) into T — e.g.
|
||||||
|
* mapping legacy values forward, or clamping. Defaults to identity. Must be idempotent. */
|
||||||
|
parse?: (raw: unknown) => T;
|
||||||
|
}
|
||||||
|
|
||||||
|
function readServerPref(me: Me | null | undefined, key: string): unknown {
|
||||||
|
return me?.preferences?.[key];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Optimistically merge a preferences patch into the `me` cache so readers see it without a refetch —
|
||||||
|
// the shape mirrors the server's PUT /api/me/preferences merge. Shared by useSavePref and usePref.set
|
||||||
|
// (their sole difference is WHEN the server write fires — immediately vs debounced), so the cache-merge
|
||||||
|
// shape lives in ONE place.
|
||||||
|
function mergePrefsCache(qc: QueryClient, patch: Record<string, unknown>): void {
|
||||||
|
qc.setQueryData<Me | null>(qk.me(), (m) =>
|
||||||
|
m ? { ...m, preferences: { ...m.preferences, ...patch } } : m,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The raw preference persister: save an arbitrary partial preferences patch to the server and merge
|
||||||
|
* it into the `me` cache optimistically (so readers see it without a refetch). For dynamic or
|
||||||
|
* multi-key prefs that don't map to a single stateful usePref call — e.g. a variable set of toggles
|
||||||
|
* (PlexInfo's per-collection strip-source hiding). Prefer usePref for a fixed, stateful key. */
|
||||||
|
export function useSavePref(): (patch: Record<string, unknown>) => void {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
return useCallback(
|
||||||
|
(patch: Record<string, unknown>) => {
|
||||||
|
api.savePrefs(patch).catch(() => {});
|
||||||
|
mergePrefsCache(qc, patch);
|
||||||
|
},
|
||||||
|
[qc],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A per-account preference persisted to the server (and optionally mirrored to localStorage), with
|
||||||
|
* automatic adopt on login / account switch. Returns `[value, setValue]`; the setter is stable. */
|
||||||
|
export function usePref<T>(
|
||||||
|
serverKey: string,
|
||||||
|
defaultValue: T,
|
||||||
|
options: UsePrefOptions<T> = {},
|
||||||
|
): [T, (next: T) => void] {
|
||||||
|
const { localKey, debounceMs, parse } = options;
|
||||||
|
const qc = useQueryClient();
|
||||||
|
// Passive observer of the account App fetches (enabled:false → reads cache, re-renders on update).
|
||||||
|
const { data: me } = useQuery({ queryKey: qk.me(), queryFn: api.me, enabled: false });
|
||||||
|
|
||||||
|
// Latest parse in a ref so the seed/adopt/setter don't depend on a fresh closure each render (which
|
||||||
|
// would churn the memoized setter and the adopt effect). parse must be idempotent anyway.
|
||||||
|
const parseRef = useRef(parse);
|
||||||
|
parseRef.current = parse;
|
||||||
|
const norm = useCallback((raw: unknown): T => {
|
||||||
|
const p = parseRef.current;
|
||||||
|
return p ? p(raw) : (raw as T);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const seed = useCallback((): T => {
|
||||||
|
const server = readServerPref(qc.getQueryData<Me | null>(qk.me()), serverKey);
|
||||||
|
if (server !== undefined) return norm(server);
|
||||||
|
if (localKey) return norm(readAccount(localKey, defaultValue));
|
||||||
|
return defaultValue;
|
||||||
|
// defaultValue / serverKey / localKey are stable for a given call site.
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [qc, serverKey, localKey, norm]);
|
||||||
|
|
||||||
|
const [value, setValue] = useState<T>(seed);
|
||||||
|
|
||||||
|
const saveTimer = useRef<number | undefined>(undefined);
|
||||||
|
// The pending debounced server write, if any — flushed (NOT dropped) on unmount so the last change
|
||||||
|
// isn't lost. Dropping it would leave the server stale while local/cache/localStorage hold the new
|
||||||
|
// value, and a later adopt could then overwrite the newer local value with the stale server one.
|
||||||
|
const pendingSave = useRef<(() => void) | null>(null);
|
||||||
|
useEffect(
|
||||||
|
() => () => {
|
||||||
|
window.clearTimeout(saveTimer.current);
|
||||||
|
pendingSave.current?.();
|
||||||
|
pendingSave.current = null;
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
const set = useCallback(
|
||||||
|
(next: T) => {
|
||||||
|
setValue(next);
|
||||||
|
if (localKey) writeAccount(localKey, next);
|
||||||
|
// Keep the `me` cache in step so other readers of this key (and this hook's own adopt effect)
|
||||||
|
// see the new value without a refetch. Merges into preferences, mirroring the server PUT.
|
||||||
|
const payload = { [serverKey]: next };
|
||||||
|
mergePrefsCache(qc, payload);
|
||||||
|
// savePrefs is fire-and-forget: api.req() already surfaces a failure toast, and the local state +
|
||||||
|
// cache already reflect the change (idempotent PUT retries a transient blip).
|
||||||
|
const flush = () => api.savePrefs(payload).catch(() => {});
|
||||||
|
if (debounceMs) {
|
||||||
|
window.clearTimeout(saveTimer.current);
|
||||||
|
pendingSave.current = flush;
|
||||||
|
saveTimer.current = window.setTimeout(() => {
|
||||||
|
pendingSave.current = null;
|
||||||
|
flush();
|
||||||
|
}, debounceMs);
|
||||||
|
} else {
|
||||||
|
flush();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[qc, serverKey, localKey, debounceMs],
|
||||||
|
);
|
||||||
|
|
||||||
|
// Adopt the server value when the account becomes known / changes (login, account switch). Guarded on
|
||||||
|
// the account id so a `me` cache update from our OWN setter (same id) can't re-fire it. Missing key →
|
||||||
|
// keep the current (seeded / user-set) value rather than clobbering it with the default.
|
||||||
|
const prevId = useRef<number | null | undefined>(me?.id);
|
||||||
|
useEffect(() => {
|
||||||
|
if (me?.id === prevId.current) return;
|
||||||
|
prevId.current = me?.id;
|
||||||
|
const server = readServerPref(me, serverKey);
|
||||||
|
if (server === undefined) return;
|
||||||
|
const val = norm(server);
|
||||||
|
setValue(val);
|
||||||
|
if (localKey) writeAccount(localKey, val);
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [me?.id]);
|
||||||
|
|
||||||
|
return [value, set];
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import { useEffect } from "react";
|
||||||
|
import { urlWithParams } from "./urlState";
|
||||||
|
|
||||||
|
// Mirror a set of nav params onto the CURRENT history entry (replaceState — mirroring is not a new
|
||||||
|
// entry) whenever they change. Each provider owns its own key(s) and passes the delta it wants; a null
|
||||||
|
// value clears that key, and urlWithParams preserves every other param (so providers don't clobber each
|
||||||
|
// other). Extracted from the identical replaceState/urlWithParams blocks FeedFiltersProvider and
|
||||||
|
// PlaylistsProvider used to hand-roll, so the mirroring contract lives in one place.
|
||||||
|
export function useUrlMirror(updates: Record<string, string | null>): void {
|
||||||
|
// The caller typically passes an inline object literal (new identity every render); key on its
|
||||||
|
// serialized value so the effect only fires when a param actually changes, not every render.
|
||||||
|
const dep = JSON.stringify(updates);
|
||||||
|
useEffect(() => {
|
||||||
|
window.history.replaceState(window.history.state, "", urlWithParams(updates));
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [dep]);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user