feat(plex): S6 frontend — language threading, rating badge, i18n parity
E5 S6 frontend half. Language threading (?lang=hu serves the HU overlay; en is the default): - api/plex.ts: `lang` on plexLibrary / plexFacets / plexItem / plexShow (only sent for hu, so EN URLs stay byte-stable); PlexItemDetail gains `rating`. - queryKeys: plexItem / plexShow take an optional trailing lang so a switch caches HU/EN separately; the ...rest keys (library/facets) already carry it. Prefix invalidations (qk.plexShow(id) / qk.plexItem(id)) still match either. - i18n: plexLang() collapses the UI language to en|hu for non-component code. - PlexBrowse / PlexSidebar: thread lang into the grid, facets, item and show queries (useTranslation re-renders on switch → the key changes → refetch). - plexPlayerHooks: the optimistic resume setQueryData now keys by plexLang() so it still lands on the language-keyed detail query. - Clear the genre-filter selection on a language switch — genre chips are language-specific strings, so a stale one silently empties the grid. IMDb-only rating badge (folded-in S5b review #5): - PlexInfo.tsx shows the IMDb badge + imdb.com link only when imdb_rating is present; otherwise it renders the Plex audience `rating` (no IMDb link/branding), so a non-IMDb score is never mislabelled. ratingMin filter keys off whichever score is shown. Tests: i18n parity.test.ts (en/hu key sets identical per namespace — currently green, no drift). FE gate green: tsc / knip / lint clean, 213 vitest tests pass.
This commit is contained in:
@@ -15,6 +15,7 @@ import SortControl from "./SortControl";
|
|||||||
import { createPortal } from "react-dom";
|
import { createPortal } from "react-dom";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import type { TFunction } from "i18next";
|
import type { TFunction } from "i18next";
|
||||||
|
import { plexLang } from "../i18n";
|
||||||
import {
|
import {
|
||||||
useInfiniteQuery,
|
useInfiniteQuery,
|
||||||
useQuery,
|
useQuery,
|
||||||
@@ -117,6 +118,9 @@ export default function PlexBrowse() {
|
|||||||
const { q, scope, show, sort, filters, field, playlistOpen: openPlaylist, jumpTo } = usePlex();
|
const { q, scope, show, sort, filters, field, playlistOpen: openPlaylist, jumpTo } = usePlex();
|
||||||
const { setScope, setSort, setFilters, setPlaylistOpen, setJumpTo } = usePlexActions();
|
const { setScope, setSort, setFilters, setPlaylistOpen, setJumpTo } = usePlexActions();
|
||||||
const dq = useDebounced(q.trim(), 350);
|
const dq = useDebounced(q.trim(), 350);
|
||||||
|
// Active language for the HU overlay: "hu" serves Hungarian metadata, else EN. In the query keys so
|
||||||
|
// a switch refetches the grid/facets/detail; useTranslation re-renders this component on switch.
|
||||||
|
const lang = plexLang();
|
||||||
// Plex's active-filter chip row (above the grid), the shared feed idiom: one removable chip per
|
// Plex's active-filter chip row (above the grid), the shared feed idiom: one removable chip per
|
||||||
// narrowing, plus a Clear-all (shared with the sidebar) that resets filters + watch-state + sort.
|
// narrowing, plus a Clear-all (shared with the sidebar) that resets filters + watch-state + sort.
|
||||||
const plexChips = usePlexActiveFilters();
|
const plexChips = usePlexActiveFilters();
|
||||||
@@ -191,7 +195,7 @@ export default function PlexBrowse() {
|
|||||||
}, [sub]);
|
}, [sub]);
|
||||||
|
|
||||||
const browseQ = useInfiniteQuery({
|
const browseQ = useInfiniteQuery({
|
||||||
queryKey: qk.plexLibrary(scope, dq, sortState, show, filters, fieldState),
|
queryKey: qk.plexLibrary(scope, dq, sortState, show, filters, fieldState, lang),
|
||||||
enabled: !!scope && sub.view.kind === "grid",
|
enabled: !!scope && sub.view.kind === "grid",
|
||||||
initialPageParam: 0,
|
initialPageParam: 0,
|
||||||
queryFn: ({ pageParam }) =>
|
queryFn: ({ pageParam }) =>
|
||||||
@@ -201,6 +205,7 @@ export default function PlexBrowse() {
|
|||||||
field: fieldState,
|
field: fieldState,
|
||||||
sort: sortState,
|
sort: sortState,
|
||||||
show,
|
show,
|
||||||
|
lang,
|
||||||
filters,
|
filters,
|
||||||
offset: pageParam as number,
|
offset: pageParam as number,
|
||||||
limit: PAGE,
|
limit: PAGE,
|
||||||
@@ -211,6 +216,17 @@ export default function PlexBrowse() {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Genre chips are language-specific strings (the HU column stores "Dráma", the EN one "Drama"), so a
|
||||||
|
// selection made in one language matches nothing against the other's column and would silently empty
|
||||||
|
// the grid. Drop the genre selection when the language flips (only on an actual change, not mount).
|
||||||
|
const prevLangRef = useRef(lang);
|
||||||
|
useEffect(() => {
|
||||||
|
if (prevLangRef.current !== lang) {
|
||||||
|
prevLangRef.current = lang;
|
||||||
|
if (filters.genres.length) setFilters({ ...filters, genres: [] });
|
||||||
|
}
|
||||||
|
}, [lang, filters, setFilters]);
|
||||||
|
|
||||||
const items = browseQ.data?.pages.flatMap((p) => p.items) ?? [];
|
const items = browseQ.data?.pages.flatMap((p) => p.items) ?? [];
|
||||||
const total = browseQ.data?.pages[0]?.total ?? 0;
|
const total = browseQ.data?.pages[0]?.total ?? 0;
|
||||||
// Cards for the actors currently in the filter (name + headshot + in-scope count) — shown above the
|
// Cards for the actors currently in the filter (name + headshot + in-scope count) — shown above the
|
||||||
@@ -690,7 +706,8 @@ function PlexInfoView({
|
|||||||
onOpenPerson: (name: string) => void;
|
onOpenPerson: (name: string) => void;
|
||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const q = useQuery({ queryKey: qk.plexItem(id), queryFn: () => api.plexItem(id) });
|
const lang = plexLang();
|
||||||
|
const q = useQuery({ queryKey: qk.plexItem(id, lang), queryFn: () => api.plexItem(id, lang) });
|
||||||
return (
|
return (
|
||||||
<div className="w-[90%] max-w-[1600px] mx-auto">
|
<div className="w-[90%] max-w-[1600px] mx-auto">
|
||||||
<div className="p-4 pb-0">
|
<div className="p-4 pb-0">
|
||||||
@@ -1302,7 +1319,8 @@ function PlexShowView({
|
|||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const qc = useQueryClient();
|
const qc = useQueryClient();
|
||||||
const isAdmin = qc.getQueryData<{ role?: string }>(qk.me())?.role === "admin";
|
const isAdmin = qc.getQueryData<{ role?: string }>(qk.me())?.role === "admin";
|
||||||
const q = useQuery({ queryKey: qk.plexShow(showId), queryFn: () => api.plexShow(showId) });
|
const lang = plexLang();
|
||||||
|
const q = useQuery({ queryKey: qk.plexShow(showId, lang), queryFn: () => api.plexShow(showId, lang) });
|
||||||
const d = q.data;
|
const d = q.data;
|
||||||
const [addTarget, setAddTarget] = useState<PlexAddTarget | null>(null);
|
const [addTarget, setAddTarget] = useState<PlexAddTarget | null>(null);
|
||||||
const [collOpen, setCollOpen] = useState(false);
|
const [collOpen, setCollOpen] = useState(false);
|
||||||
@@ -1533,7 +1551,8 @@ function PlexSeasonView({
|
|||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const qc = useQueryClient();
|
const qc = useQueryClient();
|
||||||
const q = useQuery({ queryKey: qk.plexShow(showId), queryFn: () => api.plexShow(showId) });
|
const lang = plexLang();
|
||||||
|
const q = useQuery({ queryKey: qk.plexShow(showId, lang), queryFn: () => api.plexShow(showId, lang) });
|
||||||
const d = q.data;
|
const d = q.data;
|
||||||
const se = d?.seasons.find((s) => s.id === seasonId);
|
const se = d?.seasons.find((s) => s.id === seasonId);
|
||||||
const [addTarget, setAddTarget] = useState<PlexAddTarget | null>(null);
|
const [addTarget, setAddTarget] = useState<PlexAddTarget | null>(null);
|
||||||
|
|||||||
@@ -195,37 +195,42 @@ export default function PlexInfo({
|
|||||||
{detail.studio}
|
{detail.studio}
|
||||||
</Filterable>
|
</Filterable>
|
||||||
)}
|
)}
|
||||||
{detail.imdb_rating != null && (
|
{(() => {
|
||||||
// decorative rating gold — intentionally NOT a status token
|
// IMDb score when we have one (with the IMDb link); otherwise the Plex audience
|
||||||
<span className="inline-flex items-center gap-1 rounded-md bg-amber-400/15 px-1.5 py-0.5 font-semibold text-amber-500">
|
// score (no IMDb link/branding, so a non-IMDb score is never mislabelled as IMDb).
|
||||||
<button
|
const score = detail.imdb_rating ?? detail.rating;
|
||||||
onClick={
|
if (score == null) return null;
|
||||||
onFilter
|
const isImdb = detail.imdb_rating != null;
|
||||||
? () => onFilter({ ratingMin: Math.floor(detail.imdb_rating!) })
|
return (
|
||||||
: undefined
|
// decorative rating gold — intentionally NOT a status token
|
||||||
}
|
<span className="inline-flex items-center gap-1 rounded-md bg-amber-400/15 px-1.5 py-0.5 font-semibold text-amber-500">
|
||||||
className={`inline-flex items-center gap-1 ${onFilter ? "cursor-pointer hover:opacity-80" : "cursor-default"}`}
|
<button
|
||||||
title={
|
onClick={
|
||||||
onFilter
|
onFilter ? () => onFilter({ ratingMin: Math.floor(score) }) : undefined
|
||||||
? t("plex.info.filterRating", { n: Math.floor(detail.imdb_rating!) })
|
}
|
||||||
: undefined
|
className={`inline-flex items-center gap-1 ${onFilter ? "cursor-pointer hover:opacity-80" : "cursor-default"}`}
|
||||||
}
|
title={
|
||||||
>
|
onFilter
|
||||||
<Star className="w-3.5 h-3.5 fill-current" />
|
? t("plex.info.filterRating", { n: Math.floor(score) })
|
||||||
{detail.imdb_rating.toFixed(1)}
|
: undefined
|
||||||
</button>
|
}
|
||||||
{detail.imdb_url && (
|
|
||||||
<a
|
|
||||||
href={detail.imdb_url}
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
title={t("plex.info.openImdb")}
|
|
||||||
>
|
>
|
||||||
<ExternalLink className="w-3 h-3 opacity-70 hover:opacity-100" />
|
<Star className="w-3.5 h-3.5 fill-current" />
|
||||||
</a>
|
{score.toFixed(1)}
|
||||||
)}
|
</button>
|
||||||
</span>
|
{isImdb && detail.imdb_url && (
|
||||||
)}
|
<a
|
||||||
|
href={detail.imdb_url}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
title={t("plex.info.openImdb")}
|
||||||
|
>
|
||||||
|
<ExternalLink className="w-3 h-3 opacity-70 hover:opacity-100" />
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{detail.tagline && (
|
{detail.tagline && (
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { qk } from "../lib/queryKeys";
|
import { qk } from "../lib/queryKeys";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
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";
|
||||||
@@ -80,9 +81,10 @@ export default function PlexSidebar({ layout, setLayout, collapsed, onToggleColl
|
|||||||
onOpenPlaylist(pl.id);
|
onOpenPlaylist(pl.id);
|
||||||
}
|
}
|
||||||
const { collectionTitle: _ct, ...facetKey } = filters;
|
const { collectionTitle: _ct, ...facetKey } = filters;
|
||||||
|
const lang = plexLang(); // HU/EN genre vocabulary — refetch facets on a language switch
|
||||||
const facetsQ = useQuery({
|
const facetsQ = useQuery({
|
||||||
queryKey: qk.plexFacets(scope, show, facetKey),
|
queryKey: qk.plexFacets(scope, show, facetKey, lang),
|
||||||
queryFn: () => api.plexFacets(scope, filters, show),
|
queryFn: () => api.plexFacets(scope, filters, show, lang),
|
||||||
enabled: !!scope,
|
enabled: !!scope,
|
||||||
placeholderData: (prev) => prev,
|
placeholderData: (prev) => prev,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -56,4 +56,11 @@ export function setLanguage(code: LangCode): void {
|
|||||||
document.documentElement.lang = code;
|
document.documentElement.lang = code;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The Plex overlay serves only EN or HU metadata (E5 S6). Collapse the active UI language to one of
|
||||||
|
// those for the `?lang=` param / query keys. Components read `i18n.language` off useTranslation (which
|
||||||
|
// re-renders on switch); this singleton reader is for non-component code (e.g. plexPlayerHooks).
|
||||||
|
export function plexLang(): "en" | "hu" {
|
||||||
|
return i18n.language?.startsWith("hu") ? "hu" : "en";
|
||||||
|
}
|
||||||
|
|
||||||
export default i18n;
|
export default i18n;
|
||||||
|
|||||||
@@ -0,0 +1,52 @@
|
|||||||
|
// E5 S6: every locale namespace must carry the SAME key set in English and Hungarian. A key present
|
||||||
|
// in one language but not the other renders the raw key string (missing translation) or is dead
|
||||||
|
// weight (orphaned translation); this test fails loudly on either, per namespace.
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
const mods = import.meta.glob("./locales/*/*.json", { eager: true }) as Record<
|
||||||
|
string,
|
||||||
|
{ default: Record<string, unknown> }
|
||||||
|
>;
|
||||||
|
|
||||||
|
function flatten(obj: Record<string, unknown>, prefix = ""): string[] {
|
||||||
|
const out: string[] = [];
|
||||||
|
for (const [k, v] of Object.entries(obj)) {
|
||||||
|
const key = prefix ? `${prefix}.${k}` : k;
|
||||||
|
if (v && typeof v === "object" && !Array.isArray(v)) {
|
||||||
|
out.push(...flatten(v as Record<string, unknown>, key));
|
||||||
|
} else {
|
||||||
|
out.push(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out.sort();
|
||||||
|
}
|
||||||
|
|
||||||
|
// { namespace: { en: {...}, hu: {...} } }
|
||||||
|
const byNs: Record<string, Record<string, Record<string, unknown>>> = {};
|
||||||
|
for (const [path, mod] of Object.entries(mods)) {
|
||||||
|
const m = path.match(/\/locales\/([^/]+)\/([^/]+)\.json$/);
|
||||||
|
const lang = m?.[1];
|
||||||
|
const ns = m?.[2];
|
||||||
|
if (!lang || !ns) continue;
|
||||||
|
(byNs[ns] ??= {})[lang] = mod.default;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("i18n en/hu key parity", () => {
|
||||||
|
it("loaded both languages for every namespace", () => {
|
||||||
|
expect(Object.keys(byNs).length).toBeGreaterThan(0);
|
||||||
|
for (const [ns, langs] of Object.entries(byNs)) {
|
||||||
|
expect({ ns, langs: Object.keys(langs).sort() }).toEqual({ ns, langs: ["en", "hu"] });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const [ns, langs] of Object.entries(byNs)) {
|
||||||
|
it(`${ns}: en and hu declare identical keys`, () => {
|
||||||
|
const en = flatten(langs.en ?? {});
|
||||||
|
const hu = flatten(langs.hu ?? {});
|
||||||
|
expect({
|
||||||
|
missingInHu: en.filter((k) => !hu.includes(k)),
|
||||||
|
missingInEn: hu.filter((k) => !en.includes(k)),
|
||||||
|
}).toEqual({ missingInHu: [], missingInEn: [] });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -121,6 +121,7 @@ export interface PlexItemDetail {
|
|||||||
imdb_rating?: number | null;
|
imdb_rating?: number | null;
|
||||||
imdb_id?: string | null;
|
imdb_id?: string | null;
|
||||||
imdb_url?: string | null;
|
imdb_url?: string | null;
|
||||||
|
rating?: number | null; // Plex audience score — shown when there's no IMDb score
|
||||||
content_rating?: string | null;
|
content_rating?: string | null;
|
||||||
genres: string[];
|
genres: string[];
|
||||||
directors: string[];
|
directors: string[];
|
||||||
@@ -285,6 +286,7 @@ export const plexApi = {
|
|||||||
field?: PlexSearchField; // search scope-pill (all | title | people | summary | episode)
|
field?: PlexSearchField; // search scope-pill (all | title | people | summary | episode)
|
||||||
sort?: SortState;
|
sort?: SortState;
|
||||||
show?: string;
|
show?: string;
|
||||||
|
lang?: string; // active UI language: "hu" serves the HU overlay twin, else EN (default)
|
||||||
offset?: number;
|
offset?: number;
|
||||||
limit?: number;
|
limit?: number;
|
||||||
filters?: PlexFilters;
|
filters?: PlexFilters;
|
||||||
@@ -297,16 +299,18 @@ export const plexApi = {
|
|||||||
u.set("sort_dir", p.sort.dir);
|
u.set("sort_dir", p.sort.dir);
|
||||||
}
|
}
|
||||||
if (p.show && p.show !== "all") u.set("show", p.show);
|
if (p.show && p.show !== "all") u.set("show", p.show);
|
||||||
|
if (p.lang === "hu") u.set("lang", "hu"); // en is the server default; omit to keep URLs stable
|
||||||
if (p.offset) u.set("offset", String(p.offset));
|
if (p.offset) u.set("offset", String(p.offset));
|
||||||
if (p.limit) u.set("limit", String(p.limit));
|
if (p.limit) u.set("limit", String(p.limit));
|
||||||
if (p.filters) appendPlexFilters(u, p.filters);
|
if (p.filters) appendPlexFilters(u, p.filters);
|
||||||
return req(`/api/plex/library?${u.toString()}`);
|
return req(`/api/plex/library?${u.toString()}`);
|
||||||
},
|
},
|
||||||
// Facets ADAPT to the active filters + watch-state (faceted search) — pass them so each group
|
// Facets ADAPT to the active filters + watch-state (faceted search) — pass them so each group
|
||||||
// narrows the others and the bounds match the visible result set.
|
// narrows the others and the bounds match the visible result set. `lang` picks the genre vocabulary.
|
||||||
plexFacets: (scope: string, f?: PlexFilters, show?: string): Promise<PlexFacets> => {
|
plexFacets: (scope: string, f?: PlexFilters, show?: string, lang?: string): Promise<PlexFacets> => {
|
||||||
const u = new URLSearchParams({ scope });
|
const u = new URLSearchParams({ scope });
|
||||||
if (show && show !== "all") u.set("show", show);
|
if (show && show !== "all") u.set("show", show);
|
||||||
|
if (lang === "hu") u.set("lang", "hu");
|
||||||
if (f) appendPlexFilters(u, f);
|
if (f) appendPlexFilters(u, f);
|
||||||
return req(`/api/plex/facets?${u.toString()}`);
|
return req(`/api/plex/facets?${u.toString()}`);
|
||||||
},
|
},
|
||||||
@@ -400,8 +404,8 @@ export const plexApi = {
|
|||||||
method: "PUT",
|
method: "PUT",
|
||||||
body: JSON.stringify({ item_rating_keys: itemRks }),
|
body: JSON.stringify({ item_rating_keys: itemRks }),
|
||||||
}),
|
}),
|
||||||
plexShow: (id: string): Promise<PlexShowDetail> =>
|
plexShow: (id: string, lang?: string): Promise<PlexShowDetail> =>
|
||||||
req(`/api/plex/show/${encodeURIComponent(id)}`),
|
req(`/api/plex/show/${encodeURIComponent(id)}${lang === "hu" ? "?lang=hu" : ""}`),
|
||||||
// Mark a whole show / season watched or unwatched (all its episodes) for the current user.
|
// Mark a whole show / season watched or unwatched (all its episodes) for the current user.
|
||||||
plexShowState: (rk: string, watched: boolean): Promise<{ changed: number; watched: boolean }> =>
|
plexShowState: (rk: string, watched: boolean): Promise<{ changed: number; watched: boolean }> =>
|
||||||
req(`/api/plex/show/${encodeURIComponent(rk)}/state`, {
|
req(`/api/plex/show/${encodeURIComponent(rk)}/state`, {
|
||||||
@@ -413,8 +417,8 @@ export const plexApi = {
|
|||||||
method: "POST",
|
method: "POST",
|
||||||
body: JSON.stringify({ watched }),
|
body: JSON.stringify({ watched }),
|
||||||
}),
|
}),
|
||||||
plexItem: (id: string): Promise<PlexItemDetail> =>
|
plexItem: (id: string, lang?: string): Promise<PlexItemDetail> =>
|
||||||
req(`/api/plex/item/${encodeURIComponent(id)}`),
|
req(`/api/plex/item/${encodeURIComponent(id)}${lang === "hu" ? "?lang=hu" : ""}`),
|
||||||
// Cards for the actor filter-chips currently applied: name + cached headshot + in-scope film/show count.
|
// Cards for the actor filter-chips currently applied: name + cached headshot + in-scope film/show count.
|
||||||
plexPeopleCards: (names: string[], scope: string): Promise<{ people: PlexPersonCard[] }> =>
|
plexPeopleCards: (names: string[], scope: string): Promise<{ people: PlexPersonCard[] }> =>
|
||||||
req(
|
req(
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import {
|
|||||||
} from "./fullscreen";
|
} from "./fullscreen";
|
||||||
import { api, type PlexItemDetail, type PlexMarker } from "./api";
|
import { api, type PlexItemDetail, type PlexMarker } from "./api";
|
||||||
import { qk } from "./queryKeys";
|
import { qk } from "./queryKeys";
|
||||||
|
import { plexLang } from "../i18n";
|
||||||
import { type PlexPlayerPrefs } from "./plexPlayerPrefs";
|
import { type PlexPlayerPrefs } from "./plexPlayerPrefs";
|
||||||
import { PLEX_SCALE, normalizeVolumeIn, stepVolumeIn } from "./playerVolume";
|
import { PLEX_SCALE, normalizeVolumeIn, stepVolumeIn } from "./playerVolume";
|
||||||
|
|
||||||
@@ -126,7 +127,10 @@ export function usePlexProgress(opts: {
|
|||||||
/* eslint-disable react-hooks/exhaustive-deps */
|
/* eslint-disable react-hooks/exhaustive-deps */
|
||||||
if (absRef.current > 0 && durationRef.current > 0) {
|
if (absRef.current > 0 && durationRef.current > 0) {
|
||||||
const pos = Math.floor(absRef.current);
|
const pos = Math.floor(absRef.current);
|
||||||
qc.setQueryData(qk.plexItem(id), (old) => (old ? { ...old, position_seconds: pos } : old));
|
// Match the language-keyed detail query (E5 S6) so the optimistic resume patch lands.
|
||||||
|
qc.setQueryData(qk.plexItem(id, plexLang()), (old) =>
|
||||||
|
old ? { ...old, position_seconds: pos } : old,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
/* eslint-enable react-hooks/exhaustive-deps */
|
/* eslint-enable react-hooks/exhaustive-deps */
|
||||||
qc.invalidateQueries({ queryKey: qk.plexLibrary() });
|
qc.invalidateQueries({ queryKey: qk.plexLibrary() });
|
||||||
|
|||||||
@@ -92,9 +92,13 @@ export const qk = {
|
|||||||
plexWatchLink: () => ["plex-watch-link"] as const,
|
plexWatchLink: () => ["plex-watch-link"] as const,
|
||||||
plexLibrary: (...rest: unknown[]) => ["plex-library", ...rest],
|
plexLibrary: (...rest: unknown[]) => ["plex-library", ...rest],
|
||||||
plexSuggest: (...rest: unknown[]) => ["plex-suggest", ...rest],
|
plexSuggest: (...rest: unknown[]) => ["plex-suggest", ...rest],
|
||||||
plexShow: (showId?: string) =>
|
// Optional trailing `lang` (E5 S6): a language switch remounts the detail with a new key so HU/EN
|
||||||
showId !== undefined ? (["plex-show", showId] as const) : (["plex-show"] as const),
|
// metadata is cached separately. Omitting it (e.g. for a prefix invalidation) still matches both.
|
||||||
plexItem: (id: string) => ["plex-item", id] as const,
|
plexShow: (showId?: string, lang?: string) =>
|
||||||
|
showId !== undefined
|
||||||
|
? (["plex-show", showId, ...(lang ? [lang] : [])] as const)
|
||||||
|
: (["plex-show"] as const),
|
||||||
|
plexItem: (id: string, lang?: string) => ["plex-item", id, ...(lang ? [lang] : [])] as const,
|
||||||
plexPlaylists: (...rest: unknown[]) => ["plex-playlists", ...rest],
|
plexPlaylists: (...rest: unknown[]) => ["plex-playlists", ...rest],
|
||||||
plexPlaylist: (playlistId: number) => ["plex-playlist", playlistId] as const,
|
plexPlaylist: (playlistId: number) => ["plex-playlist", playlistId] as const,
|
||||||
plexCollections: (...rest: unknown[]) => ["plex-collections", ...rest],
|
plexCollections: (...rest: unknown[]) => ["plex-collections", ...rest],
|
||||||
|
|||||||
Reference in New Issue
Block a user