feat(plex): add Playlist/Collection browse modes to the header pill
The header library-scope pill gains two entry points — Playlist and Collection — next to All/Movies/Shows, a second way into each area (the rail filter is the other). Picking Playlist shows a grid of the user's playlists (a tile opens that playlist's view); Collection shows a searchable grid of collections (a tile filters the title grid by that collection). Pure front-end — reuses the existing plexPlaylists / plexCollections(union) list endpoints. Two new top-level sub-views (`playlists` / `collections`, bookmarkable as ?v=playlists / ?v=collections) rendered by PlexBrowse via the new plexBrowseLists surfaces (hue-tiled like the Discover genre tiles). The PlexViewBridge now carries the view `kind`; PlexScopeToggle is a 5-way that derives its active segment from it (browse-mode when in one, else scope) and drives the two views through the bridge — picking a scope from a browse mode returns to the title grid. The Discover/Browse toggle hides in these modes; Backspace + the search→grid flip cover them. Tests: PlexScopeToggle (5-way + browse-mode drive/active), plexSubUrl round-trip for the two new views. Gate: tsc/lint/knip/prettier + 402 vitest.
This commit is contained in:
@@ -27,6 +27,7 @@ import { toggleWatchState } from "../lib/plexWatch";
|
||||
import SortControl from "./SortControl";
|
||||
import FilterSelect from "./FilterSelect";
|
||||
import PlexDiscover from "./PlexDiscover";
|
||||
import { PlexCollectionsBrowse, PlexPlaylistsBrowse } from "./plexBrowseLists";
|
||||
import { PageToolbar } from "./PageShell";
|
||||
import ActiveFilterChips from "./ActiveFilterChips";
|
||||
import ItemCount from "./ItemCount";
|
||||
@@ -87,11 +88,7 @@ export default function PlexBrowse() {
|
||||
// view KIND (open is behaviourally stable — it takes the next view as an arg, closing over nothing).
|
||||
const publishView = usePublishPlexView();
|
||||
useLayoutEffect(() => {
|
||||
publishView({
|
||||
isDiscover: sub.view.kind === "discover",
|
||||
toggleable: sub.view.kind === "discover" || sub.view.kind === "grid",
|
||||
open: sub.open,
|
||||
});
|
||||
publishView({ kind: sub.view.kind, open: sub.open });
|
||||
return () => publishView(null);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [sub.view.kind]);
|
||||
@@ -127,7 +124,10 @@ export default function PlexBrowse() {
|
||||
const browseIntent = !!dq;
|
||||
const prevIntent = useRef(browseIntent);
|
||||
useEffect(() => {
|
||||
if (sub.view.kind === "discover" && browseIntent && !prevIntent.current) {
|
||||
// A search targets titles, so jump to the title grid from any non-title landing (Discover or the
|
||||
// playlists/collections browse surfaces) when a search first appears.
|
||||
const onListing = ["discover", "playlists", "collections"].includes(sub.view.kind);
|
||||
if (onListing && browseIntent && !prevIntent.current) {
|
||||
sub.open({ kind: "grid" });
|
||||
}
|
||||
prevIntent.current = browseIntent;
|
||||
@@ -162,7 +162,18 @@ export default function PlexBrowse() {
|
||||
const tag = (el?.tagName || "").toLowerCase();
|
||||
if (tag === "input" || tag === "textarea" || tag === "select" || el?.isContentEditable)
|
||||
return;
|
||||
if (["grid", "show", "season", "info", "person", "playlist"].includes(sub.view.kind)) {
|
||||
if (
|
||||
[
|
||||
"grid",
|
||||
"playlists",
|
||||
"collections",
|
||||
"show",
|
||||
"season",
|
||||
"info",
|
||||
"person",
|
||||
"playlist",
|
||||
].includes(sub.view.kind)
|
||||
) {
|
||||
e.preventDefault();
|
||||
sub.back();
|
||||
}
|
||||
@@ -407,6 +418,26 @@ export default function PlexBrowse() {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
// The header-pill "Playlist" / "Collection" browse surfaces: a grid of tiles into each area. A
|
||||
// playlist tile opens that playlist; a collection tile filters the title grid by that collection.
|
||||
if (sub.view.kind === "playlists") {
|
||||
return (
|
||||
<div className="p-4 max-w-[1600px] mx-auto">
|
||||
<PlexPlaylistsBrowse onOpen={(id) => sub.open({ kind: "playlist", id })} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (sub.view.kind === "collections") {
|
||||
return (
|
||||
<div className="p-4 max-w-[1600px] mx-auto">
|
||||
<PlexCollectionsBrowse
|
||||
onPick={(id, title) =>
|
||||
applyFilter({ collections: [id], collectionTitles: { [id]: title } })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-4 max-w-[1600px] mx-auto">
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
// @vitest-environment jsdom
|
||||
//
|
||||
// Contract of the header library-scope toggle (S3 relocation): it renders the three scopes, marks
|
||||
// the current one, and writes the picked scope back to the provider. i18n is key-passthrough and the
|
||||
// provider is mocked so the test drives scope directly; SegmentedToggle is a plain button group (no
|
||||
// floating-ui), so no observer stubs are needed.
|
||||
// Contract of the header library-scope pill (S3 relocation + Piece 2 browse modes): it renders the
|
||||
// three library scopes PLUS the Playlist/Collection browse-mode entries, marks the active one, writes a
|
||||
// picked scope to the provider, and drives the playlists/collections views through the PlexViewBridge.
|
||||
// i18n is key-passthrough and both the provider and the bridge are mocked; SegmentedToggle is a plain
|
||||
// button group (no floating-ui), so no observer stubs are needed.
|
||||
import { describe, it, expect, afterEach, beforeEach, vi } from "vitest";
|
||||
import { render, screen, fireEvent, cleanup } from "@testing-library/react";
|
||||
|
||||
@@ -19,32 +20,70 @@ vi.mock("./PlexProvider", () => ({
|
||||
usePlexActions: () => ({ setScope }),
|
||||
}));
|
||||
|
||||
// The bridge: a controllable current view kind + an open spy.
|
||||
const controls = { kind: "discover" as string, open: vi.fn() };
|
||||
vi.mock("./PlexViewBridge", () => ({
|
||||
usePlexViewControls: () => controls,
|
||||
}));
|
||||
|
||||
import PlexScopeToggle from "./PlexScopeToggle";
|
||||
|
||||
beforeEach(() => {
|
||||
plexState.scope = "both";
|
||||
controls.kind = "discover";
|
||||
setScope.mockReset();
|
||||
controls.open.mockReset();
|
||||
});
|
||||
afterEach(cleanup);
|
||||
|
||||
const buttons = () => [
|
||||
...screen.getByRole("group", { name: "plex.filter.scope" }).querySelectorAll("button"),
|
||||
];
|
||||
const labels = () => buttons().map((b) => b.textContent);
|
||||
const pressed = () =>
|
||||
buttons()
|
||||
.filter((b) => b.getAttribute("aria-pressed") === "true")
|
||||
.map((b) => b.textContent);
|
||||
|
||||
describe("PlexScopeToggle", () => {
|
||||
it("renders the three library scopes, current one pressed", () => {
|
||||
it("renders the three scopes plus the two browse modes, the current scope pressed", () => {
|
||||
plexState.scope = "movie";
|
||||
render(<PlexScopeToggle />);
|
||||
const group = screen.getByRole("group", { name: "plex.filter.scope" });
|
||||
const btns = [...group.querySelectorAll("button")];
|
||||
expect(btns.map((b) => b.textContent)).toEqual([
|
||||
expect(labels()).toEqual([
|
||||
"plex.filter.scopeOpt.both",
|
||||
"plex.filter.scopeOpt.movie",
|
||||
"plex.filter.scopeOpt.show",
|
||||
"plex.filter.scopeOpt.playlists",
|
||||
"plex.filter.scopeOpt.collections",
|
||||
]);
|
||||
const pressed = btns.filter((b) => b.getAttribute("aria-pressed") === "true");
|
||||
expect(pressed.map((b) => b.textContent)).toEqual(["plex.filter.scopeOpt.movie"]);
|
||||
expect(pressed()).toEqual(["plex.filter.scopeOpt.movie"]);
|
||||
});
|
||||
|
||||
it("writes the picked scope back to the provider", () => {
|
||||
it("writes a picked scope back to the provider (no view switch when already on a title view)", () => {
|
||||
render(<PlexScopeToggle />);
|
||||
fireEvent.click(screen.getByRole("button", { name: "plex.filter.scopeOpt.show" }));
|
||||
expect(setScope).toHaveBeenCalledWith("show");
|
||||
expect(controls.open).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("opens the playlists view (not a scope change) for the Playlist mode", () => {
|
||||
render(<PlexScopeToggle />);
|
||||
fireEvent.click(screen.getByRole("button", { name: "plex.filter.scopeOpt.playlists" }));
|
||||
expect(controls.open).toHaveBeenCalledWith({ kind: "playlists" });
|
||||
expect(setScope).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("marks the Collection mode active when the current view is collections", () => {
|
||||
controls.kind = "collections";
|
||||
render(<PlexScopeToggle />);
|
||||
expect(pressed()).toEqual(["plex.filter.scopeOpt.collections"]);
|
||||
});
|
||||
|
||||
it("returns to the title grid when a scope is picked from a browse mode", () => {
|
||||
controls.kind = "playlists";
|
||||
render(<PlexScopeToggle />);
|
||||
fireEvent.click(screen.getByRole("button", { name: "plex.filter.scopeOpt.movie" }));
|
||||
expect(setScope).toHaveBeenCalledWith("movie");
|
||||
expect(controls.open).toHaveBeenCalledWith({ kind: "grid" });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,25 +1,48 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import SegmentedToggle from "./SegmentedToggle";
|
||||
import { usePlex, usePlexActions } from "./PlexProvider";
|
||||
import { usePlexViewControls } from "./PlexViewBridge";
|
||||
|
||||
// The Plex library scope (both | movie | show) as a compact header control, sitting next to the
|
||||
// search box (S3 toolbar relocation — it used to be a sidebar island). Reads/writes the same
|
||||
// provider state the sidebar did, so scope stays in sync everywhere; reuses the shared
|
||||
// SegmentedToggle and the existing plex.filter.scopeOpt.* labels.
|
||||
// The Plex "browse target" as a compact header control next to the search box: the three library
|
||||
// scopes (both | movie | show, which browse TITLES) plus two extra entry points — Playlist and
|
||||
// Collection — that switch the content area to a grid of the user's playlists / of the collections.
|
||||
// The scopes read/write the shared provider state (so scope stays in sync everywhere); the two extra
|
||||
// modes drive PlexBrowse's sub-view through the PlexViewBridge. Picking a scope while in a
|
||||
// playlists/collections surface returns to the title grid at that scope.
|
||||
const SCOPES = ["both", "movie", "show"] as const;
|
||||
|
||||
export default function PlexScopeToggle() {
|
||||
const { t } = useTranslation();
|
||||
const { scope } = usePlex();
|
||||
const { setScope } = usePlexActions();
|
||||
const ctl = usePlexViewControls();
|
||||
const inPlaylists = ctl?.kind === "playlists";
|
||||
const inCollections = ctl?.kind === "collections";
|
||||
// The active segment: the browse-mode when in one, else the library scope.
|
||||
const value = inPlaylists ? "playlists" : inCollections ? "collections" : scope;
|
||||
|
||||
const onChange = (v: string) => {
|
||||
if (v === "playlists" || v === "collections") {
|
||||
ctl?.open({ kind: v });
|
||||
return;
|
||||
}
|
||||
setScope(v);
|
||||
// Leaving the playlists/collections surface for a title scope → show the title grid.
|
||||
if (inPlaylists || inCollections) ctl?.open({ kind: "grid" });
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="pointer-events-auto shrink-0">
|
||||
<SegmentedToggle
|
||||
size="md"
|
||||
ariaLabel={t("plex.filter.scope")}
|
||||
options={SCOPES.map((s) => ({ value: s, label: t(`plex.filter.scopeOpt.${s}`) }))}
|
||||
value={scope}
|
||||
onChange={setScope}
|
||||
options={[
|
||||
...SCOPES.map((s) => ({ value: s, label: t(`plex.filter.scopeOpt.${s}`) })),
|
||||
{ value: "playlists", label: t("plex.filter.scopeOpt.playlists") },
|
||||
{ value: "collections", label: t("plex.filter.scopeOpt.collections") },
|
||||
]}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -12,8 +12,7 @@ import { type Sub } from "./plexSubUrl";
|
||||
// lose the remount and let the view go stale across navigation. Keeping it in PlexBrowse preserves the
|
||||
// reseed; the bridge only mirrors the small slice the header needs.
|
||||
export type PlexViewControls = {
|
||||
isDiscover: boolean; // the Discover landing vs the flat Browse grid
|
||||
toggleable: boolean; // only discover|grid host the toggle; deeper views (show/info/person/…) hide it
|
||||
kind: Sub["kind"]; // the current top-level plex view — the header controls derive their state from it
|
||||
open: (v: Sub) => void;
|
||||
};
|
||||
|
||||
|
||||
@@ -8,7 +8,9 @@ import { usePlexViewControls } from "./PlexViewBridge";
|
||||
export default function PlexViewToggle() {
|
||||
const { t } = useTranslation();
|
||||
const ctl = usePlexViewControls();
|
||||
if (!ctl || !ctl.toggleable) return null;
|
||||
// Only the two title-browse views host the Discover/Browse choice; deeper views (show/info/person/…)
|
||||
// and the playlists/collections browse surfaces hide it.
|
||||
if (!ctl || (ctl.kind !== "discover" && ctl.kind !== "grid")) return null;
|
||||
return (
|
||||
<SegmentedToggle
|
||||
size="md"
|
||||
@@ -17,7 +19,7 @@ export default function PlexViewToggle() {
|
||||
{ value: "discover", label: t("plex.discover.tab") },
|
||||
{ value: "grid", label: t("plex.view.browse") },
|
||||
]}
|
||||
value={ctl.isDiscover ? "discover" : "grid"}
|
||||
value={ctl.kind === "discover" ? "discover" : "grid"}
|
||||
onChange={(v) => ctl.open(v === "discover" ? { kind: "discover" } : { kind: "grid" })}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
import { useState, type ReactNode } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Layers, ListMusic } from "lucide-react";
|
||||
import { qk } from "../lib/queryKeys";
|
||||
import { api } from "../lib/api";
|
||||
import { useDebounced } from "../lib/useDebounced";
|
||||
import { LoadingState, StateMessage } from "./QueryState";
|
||||
|
||||
// The Playlist / Collection browse surfaces reached from the header pill (Piece 2): a grid of tiles,
|
||||
// each a way INTO that area — a playlist tile opens the playlist view, a collection tile filters the
|
||||
// title grid by that collection. Both reuse the existing list endpoints (plexPlaylists / plexCollections
|
||||
// union), so this is a pure front-end surface. Tiles are coloured by a title hash (no per-item art to
|
||||
// fetch/proxy), matching the Discover genre tiles.
|
||||
|
||||
// A stable, pleasant hue per title string — same idea as PlexDiscover's genre tiles.
|
||||
function hueOf(name: string): number {
|
||||
let h = 0;
|
||||
for (let i = 0; i < name.length; i++) h = (h * 31 + name.charCodeAt(i)) % 360;
|
||||
return h;
|
||||
}
|
||||
|
||||
function Tile({
|
||||
title,
|
||||
count,
|
||||
icon,
|
||||
onClick,
|
||||
}: {
|
||||
title: string;
|
||||
count: ReactNode;
|
||||
icon: ReactNode;
|
||||
onClick: () => void;
|
||||
}) {
|
||||
const hue = hueOf(title);
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
title={title}
|
||||
className="glass-hover group relative flex h-28 flex-col justify-end overflow-hidden rounded-xl p-3 text-left transition"
|
||||
style={{
|
||||
background: `linear-gradient(135deg, hsl(${hue} 55% 32%), hsl(${(hue + 40) % 360} 60% 22%))`,
|
||||
}}
|
||||
>
|
||||
<span className="absolute right-2 top-2 text-white/70">{icon}</span>
|
||||
<span className="line-clamp-2 text-sm font-semibold text-white drop-shadow">{title}</span>
|
||||
<span className="text-2xs text-white/70">{count}</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
const GRID = "grid grid-cols-2 gap-3 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6";
|
||||
|
||||
// A grid of the user's Plex playlists; a tile opens that playlist's view.
|
||||
export function PlexPlaylistsBrowse({ onOpen }: { onOpen: (id: number) => void }) {
|
||||
const { t } = useTranslation();
|
||||
const q = useQuery({ queryKey: qk.plexPlaylists(), queryFn: () => api.plexPlaylists() });
|
||||
const playlists = q.data?.playlists ?? [];
|
||||
|
||||
if (q.isError && !q.data) {
|
||||
return (
|
||||
<StateMessage tone="error" onRetry={() => q.refetch()}>
|
||||
{t("common.loadError")}
|
||||
</StateMessage>
|
||||
);
|
||||
}
|
||||
if (q.isLoading) return <LoadingState label={t("plex.loading")} />;
|
||||
if (playlists.length === 0)
|
||||
return <p className="text-muted text-sm">{t("plex.playlist.none")}</p>;
|
||||
|
||||
return (
|
||||
<div className={GRID}>
|
||||
{playlists.map((p) => (
|
||||
<Tile
|
||||
key={p.id}
|
||||
title={p.title}
|
||||
count={t("plex.browse.items", { count: p.item_count })}
|
||||
icon={<ListMusic className="h-4 w-4" />}
|
||||
onClick={() => onOpen(p.id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// A grid of Plex collections (searchable — the union can run to hundreds); a tile filters the title
|
||||
// grid by that collection (via the parent's onPick → applyFilter).
|
||||
export function PlexCollectionsBrowse({ onPick }: { onPick: (id: string, title: string) => void }) {
|
||||
const { t } = useTranslation();
|
||||
const [search, setSearch] = useState("");
|
||||
const searchDeb = useDebounced(search.trim(), 300);
|
||||
const q = useQuery({
|
||||
queryKey: qk.plexCollections("union", searchDeb),
|
||||
queryFn: () => api.plexCollections(undefined, searchDeb || undefined),
|
||||
});
|
||||
const collections = q.data?.collections ?? [];
|
||||
|
||||
return (
|
||||
<div>
|
||||
<input
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
placeholder={t("plex.filter.collectionSearch")}
|
||||
className="glass-card mb-4 w-full max-w-sm rounded-lg px-3 py-1.5 text-sm focus:outline-hidden focus:ring-2 focus:ring-accent/40"
|
||||
/>
|
||||
{q.isError && !q.data ? (
|
||||
<StateMessage tone="error" onRetry={() => q.refetch()}>
|
||||
{t("common.loadError")}
|
||||
</StateMessage>
|
||||
) : q.isLoading ? (
|
||||
<LoadingState label={t("plex.loading")} />
|
||||
) : collections.length === 0 ? (
|
||||
<p className="text-muted text-sm">{t("plex.filter.collectionEmpty")}</p>
|
||||
) : (
|
||||
<div className={GRID}>
|
||||
{collections.map((c) => (
|
||||
<Tile
|
||||
key={c.id}
|
||||
title={c.title}
|
||||
count={c.child_count != null ? t("plex.browse.items", { count: c.child_count }) : ""}
|
||||
icon={<Layers className="h-4 w-4" />}
|
||||
onClick={() => onPick(c.id, c.title)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -6,6 +6,8 @@ import { PLEX_SUB_URL as U, type Sub } from "./plexSubUrl";
|
||||
describe("PLEX_SUB_URL encode", () => {
|
||||
it("mirrors each bookmarkable sub-view to ?v=<kind>:<args>", () => {
|
||||
expect(U.encode({ kind: "grid" })).toBe("browse"); // the flat "Browse all" grid (E5 S4)
|
||||
expect(U.encode({ kind: "playlists" })).toBe("playlists"); // browse-all-playlists (Piece 2)
|
||||
expect(U.encode({ kind: "collections" })).toBe("collections"); // browse-all-collections (Piece 2)
|
||||
expect(U.encode({ kind: "show", id: "s1" })).toBe("show:s1");
|
||||
expect(U.encode({ kind: "season", showId: "s1", seasonId: "se2" })).toBe("season:s1:se2");
|
||||
expect(U.encode({ kind: "info", id: "m9" })).toBe("info:m9");
|
||||
@@ -22,6 +24,8 @@ describe("PLEX_SUB_URL encode", () => {
|
||||
describe("PLEX_SUB_URL round-trip", () => {
|
||||
const cases: Sub[] = [
|
||||
{ kind: "grid" },
|
||||
{ kind: "playlists" },
|
||||
{ kind: "collections" },
|
||||
{ kind: "show", id: "s1" },
|
||||
{ kind: "season", showId: "s1", seasonId: "se2" },
|
||||
{ kind: "info", id: "m9" },
|
||||
|
||||
@@ -7,6 +7,8 @@ import { parsePositiveIntId } from "../lib/parseId";
|
||||
export type Sub =
|
||||
| { kind: "discover" }
|
||||
| { kind: "grid" }
|
||||
| { kind: "playlists" } // browse-all-playlists surface (header pill "Playlist" mode)
|
||||
| { kind: "collections" } // browse-all-collections surface (header pill "Collection" mode)
|
||||
| { kind: "show"; id: string }
|
||||
| { kind: "season"; showId: string; seasonId: string }
|
||||
| { kind: "player"; id: string; queue?: string[] }
|
||||
@@ -25,6 +27,10 @@ export const PLEX_SUB_URL: SubviewUrl<Sub> = {
|
||||
switch (v.kind) {
|
||||
case "grid":
|
||||
return "browse";
|
||||
case "playlists":
|
||||
return "playlists";
|
||||
case "collections":
|
||||
return "collections";
|
||||
case "show":
|
||||
return `show:${v.id}`;
|
||||
case "season":
|
||||
@@ -46,6 +52,10 @@ export const PLEX_SUB_URL: SubviewUrl<Sub> = {
|
||||
switch (kind) {
|
||||
case "browse":
|
||||
return { kind: "grid" };
|
||||
case "playlists":
|
||||
return { kind: "playlists" };
|
||||
case "collections":
|
||||
return { kind: "collections" };
|
||||
case "show":
|
||||
return arg ? { kind: "show", id: arg } : null;
|
||||
case "info":
|
||||
|
||||
@@ -27,7 +27,9 @@
|
||||
"scopeOpt": {
|
||||
"both": "All",
|
||||
"movie": "Movies",
|
||||
"show": "Shows"
|
||||
"show": "Shows",
|
||||
"playlists": "Playlists",
|
||||
"collections": "Collections"
|
||||
},
|
||||
"show": "Show",
|
||||
"showOpt": {
|
||||
@@ -284,6 +286,9 @@
|
||||
"label": "View",
|
||||
"browse": "Browse"
|
||||
},
|
||||
"browse": {
|
||||
"items": "{{count}} items"
|
||||
},
|
||||
"discover": {
|
||||
"tab": "Discover",
|
||||
"continueWatching": "Continue watching",
|
||||
|
||||
@@ -27,7 +27,9 @@
|
||||
"scopeOpt": {
|
||||
"both": "Mind",
|
||||
"movie": "Filmek",
|
||||
"show": "Sorozatok"
|
||||
"show": "Sorozatok",
|
||||
"playlists": "Listák",
|
||||
"collections": "Gyűjtemények"
|
||||
},
|
||||
"show": "Megjelenítés",
|
||||
"showOpt": {
|
||||
@@ -284,6 +286,9 @@
|
||||
"label": "Nézet",
|
||||
"browse": "Böngészés"
|
||||
},
|
||||
"browse": {
|
||||
"items": "{{count}} elem"
|
||||
},
|
||||
"discover": {
|
||||
"tab": "Felfedezés",
|
||||
"continueWatching": "Folytatás",
|
||||
|
||||
Reference in New Issue
Block a user