feat(e24): the Home surface — one question, one answer, both sources
E24 S2. A new `home` module in layout C+ (the mockup the owner picked): the time-budget chips on top, one large hero that answers them, then the comfort shelf and a cross-source resume rail. Home is the landing — deliberately OVER the remembered last module, since landing on whatever you happened to close the app on is the habit this epic exists to replace; `LS.startOnHome = "0"` opts an account back out (Settings toggle lands in S4). No third card component: each unified card carries the native feed / Plex payload, so the rails render through the existing VideoCard (`cardsSmall`) and PlexPosterCard, and `aspect` decides the tile shape without branching on source. Plex playback hands off to the Plex module via a new `jumpTo` type — the player lives there with its lazy hls.js and its provider tree, and mounting a second one on the landing page to save one hand-off would be the expensive way round. Four defects found by driving it in a real browser against the live account, each now pinned by a mutation-checked test: - **The reroll gave the same card back.** A new seed only re-draws the SURPRISE tier; the resume tier deterministically returns the most recently progressed thing that fits, so anyone with something half-finished pressed a dead button. `/pick` now takes an `exclude` list of `<source>:<id>` keys and the client keeps a rolling window of 8. Excluding a SHOW also excludes the episode it resolves to — otherwise the same suggestion comes back wearing a hat. - **"Nothing to suggest yet" flashed over a full library** whenever a reroll was in flight: the new query key has no data for a beat. An empty state standing in for a loading one is the dishonesty the house rules name outright. The hero now falls back to the loaded payload while a pick is pending, and only trusts a null once the pick has RESOLVED. - **A budget change replaced the page — chips included — with a spinner**, so the control you just clicked vanished under your cursor. `keepPreviousData`, the same reason the feed uses it. - **The hero printed twice**, once as itself and once as the first resume tile. Two smaller UI calls made from the same session: the budget caption sits NEXT to its chips rather than in SegmentedToggle's justify-between mode, which on an 80rem page throws the answers to the far edge; and the feed takes the YouTube mark its own toolbar already uses, since Home now owns the house icon. Gate green: ruff, ruff format, 3x tsc, eslint, prettier, knip, vitest 780, pytest 989. Verified end to end on localdev against the real account: budget chips re-answer (matched on REMAINING time — a 51-minute video with 2:46 left is a ten-minute answer), the reroll walks three distinct heroes, and a Plex show tile opens its drill-down in the Plex module.
This commit is contained in:
@@ -59,6 +59,13 @@ def _base(
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def key(card: dict) -> str:
|
||||||
|
"""The identity of a card ACROSS sources — `<source>:<id>`. A Plex rating_key and a YouTube video
|
||||||
|
id could in principle collide as bare strings, and the reroll's exclusion list has to be able to
|
||||||
|
say "not that one" about either."""
|
||||||
|
return f"{card['source']}:{card['id']}"
|
||||||
|
|
||||||
|
|
||||||
def from_video(row: dict) -> dict:
|
def from_video(row: dict) -> dict:
|
||||||
"""`row` is a `feedquery.serialize` dict — the same object the feed grid renders."""
|
"""`row` is a `feedquery.serialize` dict — the same object the feed grid renders."""
|
||||||
card = _base(
|
card = _base(
|
||||||
|
|||||||
@@ -29,6 +29,10 @@ from app.plex.lookups import by_rating_key
|
|||||||
|
|
||||||
HERO_TIERS = ("resume", "comfort", "surprise")
|
HERO_TIERS = ("resume", "comfort", "surprise")
|
||||||
|
|
||||||
|
# How deep the surprise tier draws before giving up. The client's exclusion window is 8 (see
|
||||||
|
# `Home.tsx`), so a draw of 12 can always step past a full window of already-shown picks.
|
||||||
|
SURPRISE_DRAW = 12
|
||||||
|
|
||||||
|
|
||||||
def _playable(db: Session, uid: int, card: dict) -> dict | None:
|
def _playable(db: Session, uid: int, card: dict) -> dict | None:
|
||||||
"""A hero has to have a Play button, so a Plex SHOW is resolved to the episode that would start.
|
"""A hero has to have a Play button, so a Plex SHOW is resolved to the episode that would start.
|
||||||
@@ -50,22 +54,37 @@ def pick(
|
|||||||
*,
|
*,
|
||||||
resume: list[dict] | None = None,
|
resume: list[dict] | None = None,
|
||||||
shelf: list[dict] | None = None,
|
shelf: list[dict] | None = None,
|
||||||
|
exclude: set[str] | None = None,
|
||||||
) -> dict | None:
|
) -> dict | None:
|
||||||
"""`{"tier": ..., "card": ...}`, or None when nothing at all fits.
|
"""`{"tier": ..., "card": ...}`, or None when nothing at all fits.
|
||||||
|
|
||||||
`resume` and `shelf` may be passed pre-computed so the consolidated `/api/discover` response does
|
`resume` and `shelf` may be passed pre-computed so the consolidated `/api/discover` response does
|
||||||
not build the same two rails twice; the reroll endpoint passes neither and pays only for what it
|
not build the same two rails twice; the reroll endpoint passes neither and pays only for what it
|
||||||
reaches."""
|
reaches.
|
||||||
|
|
||||||
|
`exclude` is what makes "Mást kérek" mean something. A new seed alone only re-draws the SURPRISE
|
||||||
|
tier: the resume tier deterministically returns the most recently progressed thing that fits, so
|
||||||
|
pressing reroll on a user who has something half-finished handed back the identical card and the
|
||||||
|
button looked dead (measured in the browser, 2026-09-06). Skipping the cards already shown makes
|
||||||
|
every press produce a different answer, at every tier."""
|
||||||
|
skip = exclude or set()
|
||||||
for card in rails.continue_watching(db, user) if resume is None else resume:
|
for card in rails.continue_watching(db, user) if resume is None else resume:
|
||||||
playable = _playable(db, user.id, card)
|
playable = _playable(db, user.id, card)
|
||||||
|
# Excluded on the card the USER saw (the show), not only on what it resolves to, so a
|
||||||
|
# rerolled-past show does not come straight back as its own next episode.
|
||||||
|
if cards.key(card) in skip or (playable is not None and cards.key(playable) in skip):
|
||||||
|
continue
|
||||||
if playable is not None and fits(playable["remaining_seconds"], budget):
|
if playable is not None and fits(playable["remaining_seconds"], budget):
|
||||||
return {"tier": "resume", "card": playable}
|
return {"tier": "resume", "card": playable}
|
||||||
|
|
||||||
for card in pins.playables(pins.shelf(db, user) if shelf is None else shelf):
|
for card in pins.playables(pins.shelf(db, user) if shelf is None else shelf):
|
||||||
if fits(card["remaining_seconds"], budget):
|
if cards.key(card) not in skip and fits(card["remaining_seconds"], budget):
|
||||||
return {"tier": "comfort", "card": card}
|
return {"tier": "comfort", "card": card}
|
||||||
|
|
||||||
for card in rails.surprise(db, user, budget, seed, limit=1):
|
# A wider draw than the one card we need: the shuffle is seeded, so without the headroom an
|
||||||
return {"tier": "surprise", "card": card}
|
# excluded pick would leave the tier empty rather than move past it.
|
||||||
|
for card in rails.surprise(db, user, budget, seed, limit=SURPRISE_DRAW):
|
||||||
|
if cards.key(card) not in skip:
|
||||||
|
return {"tier": "surprise", "card": card}
|
||||||
|
|
||||||
return None
|
return None
|
||||||
|
|||||||
@@ -60,18 +60,32 @@ def get_discover(
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# How many already-shown cards a reroll may carry. The client keeps a rolling window (see
|
||||||
|
# `Home.tsx`); the cap is here so a crafted URL cannot turn the exclusion list into an unbounded scan.
|
||||||
|
MAX_EXCLUDE = 32
|
||||||
|
|
||||||
|
|
||||||
@router.get("/pick")
|
@router.get("/pick")
|
||||||
def get_pick(
|
def get_pick(
|
||||||
budget: Budget = DEFAULT_BUDGET,
|
budget: Budget = DEFAULT_BUDGET,
|
||||||
seed: int = 0,
|
seed: int = 0,
|
||||||
|
exclude: str = "",
|
||||||
user: User = Depends(current_user),
|
user: User = Depends(current_user),
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
) -> dict:
|
) -> dict:
|
||||||
"""The "Mást kérek" reroll: a hero on its own, for a new seed and/or a new budget.
|
"""The "Mást kérek" reroll: a hero on its own, for a new seed and/or a new budget.
|
||||||
|
|
||||||
One seed drives BOTH sources (see `discover/queries.py`) — otherwise a reroll would re-draw the
|
One seed drives BOTH sources (see `discover/queries.py`) — otherwise a reroll would re-draw the
|
||||||
Plex half while the YouTube half stood still, and the button would look broken half the time."""
|
Plex half while the YouTube half stood still, and the button would look broken half the time.
|
||||||
return {"budget": budget, "hero": hero_mod.pick(db, user, budget, seed)}
|
|
||||||
|
`exclude` is a comma-separated list of `<source>:<id>` keys the user has already been shown. It
|
||||||
|
is what makes the button honest: a seed alone only moves the surprise tier, so a user with
|
||||||
|
something half-finished got the same card back every press."""
|
||||||
|
skip = {p for p in exclude.split(",") if p}
|
||||||
|
return {
|
||||||
|
"budget": budget,
|
||||||
|
"hero": hero_mod.pick(db, user, budget, seed, exclude=set(list(skip)[:MAX_EXCLUDE])),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@router.get("/pins")
|
@router.get("/pins")
|
||||||
|
|||||||
@@ -356,6 +356,62 @@ def test_the_seed_decides_which_source_leads(db):
|
|||||||
assert leaders == {"youtube", "plex"}
|
assert leaders == {"youtube", "plex"}
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_reroll_must_be_able_to_move_past_the_resume_tier(db):
|
||||||
|
"""A new seed alone only re-draws the SURPRISE tier — the resume tier deterministically returns
|
||||||
|
the most recently progressed thing that fits, so without an exclusion the reroll handed the same
|
||||||
|
card straight back and the button looked dead (found in the browser, 2026-09-06)."""
|
||||||
|
user, _ = _seed_yt(db)
|
||||||
|
db.add_all(
|
||||||
|
[
|
||||||
|
VideoState(
|
||||||
|
user_id=user.id,
|
||||||
|
video_id="vmed",
|
||||||
|
status="new",
|
||||||
|
position_seconds=300,
|
||||||
|
progress_updated_at=NOW,
|
||||||
|
),
|
||||||
|
VideoState(
|
||||||
|
user_id=user.id,
|
||||||
|
video_id="vshort",
|
||||||
|
status="new",
|
||||||
|
position_seconds=60,
|
||||||
|
progress_updated_at=NOW - timedelta(hours=1),
|
||||||
|
),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
first = hero.pick(db, user, "any", seed=1)
|
||||||
|
assert first["card"]["id"] == "vmed"
|
||||||
|
# Same seed, same everything — only the exclusion differs.
|
||||||
|
second = hero.pick(db, user, "any", seed=1, exclude={"youtube:vmed"})
|
||||||
|
assert second["card"]["id"] == "vshort"
|
||||||
|
|
||||||
|
|
||||||
|
def test_excluding_a_show_also_excludes_the_episode_it_resolves_to(db):
|
||||||
|
"""The user rerolled past the SHOW they were shown; handing back its next episode under a
|
||||||
|
different title would be the same suggestion wearing a hat."""
|
||||||
|
user, _ = _seed_yt(db)
|
||||||
|
_, show, ep1 = _seed_plex(db, user)
|
||||||
|
_enable_plex(db)
|
||||||
|
db.add(
|
||||||
|
PlexState(
|
||||||
|
user_id=user.id,
|
||||||
|
item_id=ep1.id,
|
||||||
|
status="new",
|
||||||
|
position_seconds=120,
|
||||||
|
progress_updated_at=NOW,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
shown = hero.pick(db, user, "any", seed=1)
|
||||||
|
assert shown["card"]["id"] == "ps1e1" # the show resolved to its in-progress episode
|
||||||
|
# Excluding what the USER saw — the show — must move the tier on, not return the episode again.
|
||||||
|
after = hero.pick(db, user, "any", seed=1, exclude={"plex:ps1"})
|
||||||
|
assert after is None or after["card"]["id"] != "ps1e1"
|
||||||
|
|
||||||
|
|
||||||
# ── comfort shelf ─────────────────────────────────────────────────────────────────────────────────
|
# ── comfort shelf ─────────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,184 @@
|
|||||||
|
// @vitest-environment jsdom
|
||||||
|
//
|
||||||
|
// E24 S2: the Home surface. Covers the four behaviours the browser round actually caught, each of
|
||||||
|
// which is invisible to tsc and to a render-only check:
|
||||||
|
// • the hero's kicker comes from the SERVER's tier, never from the card's shape;
|
||||||
|
// • the hero is not printed twice (once as the hero, once as the first resume tile);
|
||||||
|
// • a budget change asks for the new budget and keeps the old answer on screen meanwhile;
|
||||||
|
// • the reroll sends the already-shown card as an exclusion — without it the resume tier hands the
|
||||||
|
// identical card straight back and the button looks dead.
|
||||||
|
// i18n is key-passthrough; the api is mocked so queries resolve synchronously. The rails live inside
|
||||||
|
// a Carousel (useHScroll → ResizeObserver), stubbed for jsdom.
|
||||||
|
import { describe, it, expect, afterEach, beforeAll, beforeEach, vi } from "vitest";
|
||||||
|
import { render, screen, fireEvent, cleanup, waitFor } from "@testing-library/react";
|
||||||
|
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||||
|
|
||||||
|
beforeAll(() => {
|
||||||
|
class Stub {
|
||||||
|
observe() {}
|
||||||
|
unobserve() {}
|
||||||
|
disconnect() {}
|
||||||
|
takeRecords() {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
globalThis.ResizeObserver ??= Stub as unknown as typeof ResizeObserver;
|
||||||
|
globalThis.IntersectionObserver ??= Stub as unknown as typeof IntersectionObserver;
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.mock("react-i18next", async (importOriginal) => {
|
||||||
|
const actual = await importOriginal<typeof import("react-i18next")>();
|
||||||
|
return {
|
||||||
|
...actual,
|
||||||
|
useTranslation: () => ({
|
||||||
|
t: (key: string, opts?: Record<string, unknown>) =>
|
||||||
|
opts?.time ? `${key}:${opts.time}` : key,
|
||||||
|
i18n: { language: "en" },
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const discover = vi.fn();
|
||||||
|
const discoverPick = vi.fn();
|
||||||
|
vi.mock("../lib/api", () => ({
|
||||||
|
api: {
|
||||||
|
discover: (...a: unknown[]) => discover(...a),
|
||||||
|
discoverPick: (...a: unknown[]) => discoverPick(...a),
|
||||||
|
setState: () => Promise.resolve(),
|
||||||
|
watchLaterAdd: () => Promise.resolve(),
|
||||||
|
watchLaterRemove: () => Promise.resolve(),
|
||||||
|
},
|
||||||
|
discoverCardKey: (c: { source: string; id: string }) => `${c.source}:${c.id}`,
|
||||||
|
}));
|
||||||
|
|
||||||
|
// The page hands Plex playback off to the Plex module; neither provider is under test here.
|
||||||
|
vi.mock("./NavigationProvider", () => ({ useNavigationActions: () => ({ setPage: vi.fn() }) }));
|
||||||
|
vi.mock("./PlexProvider", () => ({ usePlexActions: () => ({ setJumpTo: vi.fn() }) }));
|
||||||
|
|
||||||
|
import Home from "./Home";
|
||||||
|
|
||||||
|
function ytCard(id: string, title: string, extra: Record<string, unknown> = {}) {
|
||||||
|
return {
|
||||||
|
source: "youtube",
|
||||||
|
kind: "video",
|
||||||
|
id,
|
||||||
|
title,
|
||||||
|
subtitle: "A channel",
|
||||||
|
thumb: null,
|
||||||
|
aspect: "wide",
|
||||||
|
duration_seconds: 600,
|
||||||
|
position_seconds: 120,
|
||||||
|
remaining_seconds: 480,
|
||||||
|
status: "new",
|
||||||
|
plex: null,
|
||||||
|
video: {
|
||||||
|
id,
|
||||||
|
title,
|
||||||
|
channel_id: "UC1",
|
||||||
|
channel_title: "A channel",
|
||||||
|
channel_thumbnail: null,
|
||||||
|
channel_url: "https://youtube.com/channel/UC1",
|
||||||
|
published_at: "2026-01-01T00:00:00Z",
|
||||||
|
thumbnail_url: null,
|
||||||
|
duration_seconds: 600,
|
||||||
|
view_count: 10,
|
||||||
|
is_short: false,
|
||||||
|
live_status: "none",
|
||||||
|
status: "new",
|
||||||
|
position_seconds: 120,
|
||||||
|
saved: false,
|
||||||
|
watch_url: `https://www.youtube.com/watch?v=${id}`,
|
||||||
|
},
|
||||||
|
...extra,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload = (heroId: string, tier = "resume") => ({
|
||||||
|
budget: "any",
|
||||||
|
hero: { tier, card: ytCard(heroId, `Hero ${heroId}`) },
|
||||||
|
continue_watching: [ytCard(heroId, `Hero ${heroId}`), ytCard("v2", "Second thing")],
|
||||||
|
comfort: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
function mount() {
|
||||||
|
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||||
|
return render(
|
||||||
|
<QueryClientProvider client={qc}>
|
||||||
|
<Home />
|
||||||
|
</QueryClientProvider>,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
discover.mockReset();
|
||||||
|
discoverPick.mockReset();
|
||||||
|
discover.mockResolvedValue(payload("v1"));
|
||||||
|
});
|
||||||
|
afterEach(cleanup);
|
||||||
|
|
||||||
|
describe("Home", () => {
|
||||||
|
it("labels the hero from the server's tier, not from the card", async () => {
|
||||||
|
// The same card shape is served under a `surprise` tier: a client that inferred "where you left
|
||||||
|
// off" from the resume position would get this wrong, which is the dishonest state we forbid.
|
||||||
|
discover.mockResolvedValue(payload("v1", "surprise"));
|
||||||
|
mount();
|
||||||
|
expect(await screen.findByText("home.hero.surprise")).toBeTruthy();
|
||||||
|
expect(screen.queryByText(/home\.hero\.resume/)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("interpolates the remaining time into the resume kicker", async () => {
|
||||||
|
mount();
|
||||||
|
// 480s left → 8:00, passed through the key-passthrough t() as `<key>:<time>`.
|
||||||
|
expect(await screen.findByText("home.hero.resumeLeft:8:00")).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not print the hero again as the first resume tile", async () => {
|
||||||
|
mount();
|
||||||
|
await screen.findByText("home.hero.resumeLeft:8:00");
|
||||||
|
// "Hero v1" is the hero's own heading; the rail below must not repeat it.
|
||||||
|
expect(screen.getAllByText("Hero v1")).toHaveLength(1);
|
||||||
|
expect(screen.getByText("Second thing")).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("asks for the chosen budget and keeps the previous answer on screen meanwhile", async () => {
|
||||||
|
mount();
|
||||||
|
await screen.findByText("Hero v1");
|
||||||
|
// A never-resolving refetch: the point is what the page shows WHILE it waits.
|
||||||
|
discover.mockReturnValue(new Promise(() => {}));
|
||||||
|
fireEvent.click(screen.getByText("home.budget.short"));
|
||||||
|
await waitFor(() => expect(discover).toHaveBeenLastCalledWith("short", 0));
|
||||||
|
// Still the old answer — not a spinner that would take the chips away with it.
|
||||||
|
expect(screen.getByText("Hero v1")).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rerolls with the shown card excluded, and holds the hero while it loads", async () => {
|
||||||
|
discoverPick.mockResolvedValue({
|
||||||
|
budget: "any",
|
||||||
|
hero: { tier: "surprise", card: ytCard("v9", "Rerolled") },
|
||||||
|
});
|
||||||
|
mount();
|
||||||
|
await screen.findByText("Hero v1");
|
||||||
|
fireEvent.click(screen.getByText("home.hero.reroll"));
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(discoverPick).toHaveBeenCalledWith("any", expect.any(Number), ["youtube:v1"]),
|
||||||
|
);
|
||||||
|
expect(await screen.findByText("Rerolled")).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("never shows the empty state while a reroll is in flight", async () => {
|
||||||
|
// The regression the browser round caught: a new query key has no data for a beat, so the hero
|
||||||
|
// fell to null and the page rendered "nothing to suggest" over a perfectly good library.
|
||||||
|
discoverPick.mockReturnValue(new Promise(() => {}));
|
||||||
|
mount();
|
||||||
|
await screen.findByText("Hero v1");
|
||||||
|
fireEvent.click(screen.getByText("home.hero.reroll"));
|
||||||
|
await waitFor(() => expect(discoverPick).toHaveBeenCalled());
|
||||||
|
expect(screen.queryByText("home.hero.emptyAny")).toBeNull();
|
||||||
|
expect(screen.getByText("Hero v1")).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("says the shelf is empty rather than hiding it", async () => {
|
||||||
|
mount();
|
||||||
|
expect(await screen.findByText("home.comfort.empty")).toBeTruthy();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,446 @@
|
|||||||
|
import { lazy, Suspense, useCallback, useState } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { keepPreviousData, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import { Play, RotateCcw } from "lucide-react";
|
||||||
|
import { qk } from "../lib/queryKeys";
|
||||||
|
import {
|
||||||
|
api,
|
||||||
|
discoverCardKey,
|
||||||
|
type DiscoverBudget,
|
||||||
|
type DiscoverCard,
|
||||||
|
type HeroTier,
|
||||||
|
type PlexCard,
|
||||||
|
type Video,
|
||||||
|
type VideoStatus,
|
||||||
|
} from "../lib/api";
|
||||||
|
import { formatDuration } from "../lib/format";
|
||||||
|
import { toggleWatchState } from "../lib/plexWatch";
|
||||||
|
import { Button } from "./ui/Button";
|
||||||
|
import { Carousel } from "./ui/Carousel";
|
||||||
|
import SegmentedToggle from "./ui/SegmentedToggle";
|
||||||
|
import { LoadingState, StateMessage } from "./ui/QueryState";
|
||||||
|
import VideoCard from "./VideoCard";
|
||||||
|
import { PlexPosterCard } from "./plexCards";
|
||||||
|
import { useNavigationActions } from "./NavigationProvider";
|
||||||
|
import { usePlexActions } from "./PlexProvider";
|
||||||
|
|
||||||
|
// The Home surface (E24) — "Mit nézzek most?".
|
||||||
|
//
|
||||||
|
// Layout C+, chosen from mockups: the time-budget chips sit ABOVE a single large hero, so the
|
||||||
|
// question comes first and the hero is its answer; the shelves follow, compact. The page's whole
|
||||||
|
// promise is that it ANSWERS rather than offers, which is why there is one hero and not a grid of
|
||||||
|
// three: a shortlist is still a comparison, and comparing is the thing the user cannot do on the
|
||||||
|
// evenings this page exists for.
|
||||||
|
//
|
||||||
|
// Everything domain-shaped is already built: the cards are the feed's `VideoCard` and Plex's
|
||||||
|
// `PlexPosterCard`, rendered from the native payload each unified card carries, and the rails are
|
||||||
|
// the platform `Carousel`. Nothing here is a third card component.
|
||||||
|
|
||||||
|
// Lazy, exactly as the feed does it: the YouTube IFrame player loads only once something is played.
|
||||||
|
const PlayerModal = lazy(() => import("./PlayerModal"));
|
||||||
|
|
||||||
|
const BUDGETS: readonly DiscoverBudget[] = ["short", "medium", "long", "any"];
|
||||||
|
|
||||||
|
// A rail tile's slot width. `cardsSmall` is the feed's own dense card (its column bottoms out at
|
||||||
|
// 180px), so a 200px slot is the same object the feed shows — not a Home-only variant that would
|
||||||
|
// drift from it.
|
||||||
|
const YT_SLOT = 200;
|
||||||
|
|
||||||
|
// How many already-offered cards the reroll remembers. Matched by the server's own draw depth
|
||||||
|
// (`hero.SURPRISE_DRAW`), which has to be able to step past a full window.
|
||||||
|
const SEEN_WINDOW = 8;
|
||||||
|
|
||||||
|
function Rail({
|
||||||
|
title,
|
||||||
|
ariaLabel,
|
||||||
|
cards,
|
||||||
|
emptyLabel,
|
||||||
|
onPlay,
|
||||||
|
onInfo,
|
||||||
|
onState,
|
||||||
|
onToggleSave,
|
||||||
|
onToggleWatched,
|
||||||
|
}: {
|
||||||
|
title: string;
|
||||||
|
ariaLabel: string;
|
||||||
|
cards: DiscoverCard[];
|
||||||
|
emptyLabel: string;
|
||||||
|
onPlay: (card: DiscoverCard, startAt?: number | null) => void;
|
||||||
|
onInfo: (card: DiscoverCard) => void;
|
||||||
|
onState: (id: string, status: VideoStatus) => void;
|
||||||
|
onToggleSave: (id: string, saved: boolean) => void;
|
||||||
|
onToggleWatched: (card: PlexCard) => void;
|
||||||
|
}) {
|
||||||
|
// An empty rail says why it is empty rather than vanishing: a shelf that silently disappears when
|
||||||
|
// you have not pinned anything teaches nothing about how to fill it.
|
||||||
|
if (!cards.length) {
|
||||||
|
return (
|
||||||
|
<section className="mb-6">
|
||||||
|
<h2 className="text-sm font-semibold mb-2">{title}</h2>
|
||||||
|
<StateMessage className="!py-6">{emptyLabel}</StateMessage>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<section className="mb-6">
|
||||||
|
<Carousel
|
||||||
|
title={<span className="text-sm font-semibold">{title}</span>}
|
||||||
|
ariaLabel={ariaLabel}
|
||||||
|
>
|
||||||
|
{cards.map((c) => (
|
||||||
|
<div
|
||||||
|
key={`${c.source}:${c.kind}:${c.id}`}
|
||||||
|
className={c.aspect === "poster" ? "w-[150px] shrink-0" : "shrink-0"}
|
||||||
|
style={c.aspect === "poster" ? undefined : { width: YT_SLOT }}
|
||||||
|
>
|
||||||
|
{c.video ? (
|
||||||
|
<VideoCard
|
||||||
|
video={c.video}
|
||||||
|
view="cardsSmall"
|
||||||
|
width={YT_SLOT}
|
||||||
|
onState={onState}
|
||||||
|
onToggleSave={onToggleSave}
|
||||||
|
onOpen={(v, startAt) => onPlay({ ...c, video: v }, startAt)}
|
||||||
|
/>
|
||||||
|
) : c.plex ? (
|
||||||
|
<PlexPosterCard
|
||||||
|
card={c.plex}
|
||||||
|
onOpen={() => onPlay(c)}
|
||||||
|
onInfo={() => onInfo(c)}
|
||||||
|
onToggleWatched={onToggleWatched}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<ShelfTile card={c} onPlay={onPlay} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</Carousel>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// A pinned CHANNEL or PLAYLIST has no native card of its own — neither is a playable thing, so
|
||||||
|
// neither VideoCard nor PlexPosterCard fits. It advertises what is waiting behind it instead of a
|
||||||
|
// runtime it does not have.
|
||||||
|
function ShelfTile({ card, onPlay }: { card: DiscoverCard; onPlay: (card: DiscoverCard) => void }) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const count = card.kind === "channel" ? card.unwatched_count : card.item_count;
|
||||||
|
const countLabel =
|
||||||
|
card.kind === "channel"
|
||||||
|
? t("home.shelf.unwatchedCount", { count: count ?? 0 })
|
||||||
|
: t("home.shelf.itemCount", { count: count ?? 0 });
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onPlay(card)}
|
||||||
|
disabled={!card.next}
|
||||||
|
className="block w-full text-left rounded-xl overflow-hidden border border-border bg-surface disabled:opacity-60 enabled:hover:border-accent transition-colors"
|
||||||
|
>
|
||||||
|
<span className="block aspect-video bg-card overflow-hidden">
|
||||||
|
{card.thumb ? (
|
||||||
|
<img src={card.thumb} alt="" loading="lazy" className="w-full h-full object-cover" />
|
||||||
|
) : null}
|
||||||
|
</span>
|
||||||
|
<span className="block p-2">
|
||||||
|
<span className="block text-xs font-medium truncate">{card.title}</span>
|
||||||
|
<span className="block text-2xs text-muted">{countLabel}</span>
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Hero({
|
||||||
|
tier,
|
||||||
|
card,
|
||||||
|
onPlay,
|
||||||
|
onReroll,
|
||||||
|
rerolling,
|
||||||
|
}: {
|
||||||
|
tier: HeroTier;
|
||||||
|
card: DiscoverCard;
|
||||||
|
onPlay: (card: DiscoverCard) => void;
|
||||||
|
onReroll: () => void;
|
||||||
|
rerolling: boolean;
|
||||||
|
}) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const started = card.position_seconds > 0;
|
||||||
|
const left = card.remaining_seconds;
|
||||||
|
// The kicker is driven by the tier the SERVER chose, never guessed from the card's shape: "where
|
||||||
|
// you left off" over something never opened is exactly the quiet lie the house rules forbid.
|
||||||
|
const kicker =
|
||||||
|
tier === "resume"
|
||||||
|
? left != null
|
||||||
|
? t("home.hero.resumeLeft", { time: formatDuration(left) })
|
||||||
|
: t("home.hero.resume")
|
||||||
|
: tier === "comfort"
|
||||||
|
? t("home.hero.comfort")
|
||||||
|
: t("home.hero.surprise");
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="mb-6 rounded-xl border border-border bg-surface p-4 grid gap-4 sm:grid-cols-[minmax(0,18rem)_1fr]">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onPlay(card)}
|
||||||
|
className={`relative block w-full overflow-hidden rounded-lg border border-border bg-card ${
|
||||||
|
card.aspect === "poster" ? "aspect-[2/3] max-w-[12rem]" : "aspect-video"
|
||||||
|
}`}
|
||||||
|
aria-label={t("home.hero.play")}
|
||||||
|
>
|
||||||
|
{card.thumb ? <img src={card.thumb} alt="" className="w-full h-full object-cover" /> : null}
|
||||||
|
{card.duration_seconds != null && (
|
||||||
|
<span className="absolute bottom-1.5 right-1.5 rounded-sm bg-black/80 px-1.5 py-0.5 text-2xs font-medium text-white tabular-nums">
|
||||||
|
{formatDuration(card.duration_seconds)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{started && card.duration_seconds ? (
|
||||||
|
<span className="absolute inset-x-0 bottom-0 h-1 bg-black/60">
|
||||||
|
<span
|
||||||
|
className="block h-full bg-accent"
|
||||||
|
style={{
|
||||||
|
width: `${Math.min(100, (card.position_seconds / card.duration_seconds) * 100)}%`,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div className="flex min-w-0 flex-col">
|
||||||
|
<p className="text-2xs font-semibold uppercase tracking-wider text-accent">{kicker}</p>
|
||||||
|
<h2 className="mt-1 text-lg font-semibold leading-tight">{card.title}</h2>
|
||||||
|
{card.subtitle && <p className="mt-0.5 text-xs text-muted truncate">{card.subtitle}</p>}
|
||||||
|
<div className="mt-auto flex flex-wrap items-center gap-2 pt-3">
|
||||||
|
<Button icon={<Play className="h-4 w-4" />} onClick={() => onPlay(card)}>
|
||||||
|
{started ? t("home.hero.continue") : t("home.hero.play")}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
icon={<RotateCcw className="h-4 w-4" />}
|
||||||
|
loading={rerolling}
|
||||||
|
onClick={onReroll}
|
||||||
|
>
|
||||||
|
{t("home.hero.reroll")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Home() {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const qc = useQueryClient();
|
||||||
|
const { setPage } = useNavigationActions();
|
||||||
|
const { setJumpTo } = usePlexActions();
|
||||||
|
|
||||||
|
const [budget, setBudget] = useState<DiscoverBudget>("any");
|
||||||
|
// 0 = "the hero the page loaded with". Any other value is a reroll, answered by its own query so
|
||||||
|
// pressing the button does not refetch the rails under the user's cursor.
|
||||||
|
const [seed, setSeed] = useState(0);
|
||||||
|
// The cards the reroll has already offered this session, newest last. A ROLLING window rather than
|
||||||
|
// the whole history: an unbounded list would eventually exclude everything and leave the button
|
||||||
|
// with nothing to give, whereas a window of 8 can never exhaust a library and only repeats after
|
||||||
|
// eight presses. Kept here (not in the URL or a pref) because it is a property of this sitting.
|
||||||
|
const [seen, setSeen] = useState<string[]>([]);
|
||||||
|
const [player, setPlayer] = useState<{ video: Video; startAt: number | null } | null>(null);
|
||||||
|
|
||||||
|
const q = useQuery({
|
||||||
|
queryKey: qk.discover(budget),
|
||||||
|
queryFn: () => api.discover(budget, 0),
|
||||||
|
// Keep the previous budget's answer on screen while the new one loads. Without this the page
|
||||||
|
// falls back to `isPending` and replaces itself — chips included — with a spinner, so the
|
||||||
|
// control you just clicked vanishes under your cursor. (Same reason the feed does it.)
|
||||||
|
placeholderData: keepPreviousData,
|
||||||
|
});
|
||||||
|
const pickQ = useQuery({
|
||||||
|
queryKey: qk.discoverPick(budget, seed, seen),
|
||||||
|
queryFn: () => api.discoverPick(budget, seed, seen),
|
||||||
|
enabled: seed !== 0,
|
||||||
|
staleTime: 0,
|
||||||
|
// Hold the previous hero while the next one is in flight. Without this the reroll's new query key
|
||||||
|
// has no data for a beat, `hero` falls to null, and the page renders "nothing to suggest yet" —
|
||||||
|
// an EMPTY state standing in for a loading one, which is the exact dishonesty the house rules
|
||||||
|
// forbid. Measured in the browser (2026-09-06); the button's own spinner is the loading signal.
|
||||||
|
placeholderData: keepPreviousData,
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = q.data;
|
||||||
|
// Which hero is on screen. The middle branch is the load-bearing one: `keepPreviousData` holds the
|
||||||
|
// PREVIOUS reroll, but the FIRST reroll has no previous pick to hold, so without falling back to
|
||||||
|
// the loaded payload the page renders "nothing to suggest" over a perfectly good library for a
|
||||||
|
// beat. `pickQ.data !== undefined` (not `?? null`) is what keeps a genuinely empty answer honest:
|
||||||
|
// once the pick has RESOLVED, a null hero really means nothing is left and the empty state is the
|
||||||
|
// truth.
|
||||||
|
const hero =
|
||||||
|
seed === 0
|
||||||
|
? (data?.hero ?? null)
|
||||||
|
: pickQ.data !== undefined
|
||||||
|
? pickQ.data.hero
|
||||||
|
: (data?.hero ?? null);
|
||||||
|
// The hero is the top of the resume rail by construction, so leaving it in the rail below prints
|
||||||
|
// the same card twice, a hand's width apart. Matched on source+id rather than on the tier alone:
|
||||||
|
// a rerolled hero can be a surprise pick that happens to be nothing to do with this rail.
|
||||||
|
const heroKey = hero ? discoverCardKey(hero.card) : null;
|
||||||
|
const resumeRail = (data?.continue_watching ?? []).filter((c) => discoverCardKey(c) !== heroKey);
|
||||||
|
|
||||||
|
const onState = useCallback(
|
||||||
|
(id: string, status: VideoStatus) => {
|
||||||
|
// A full refetch, unlike the feed's stale-mark: these rails are two dozen rows, and marking
|
||||||
|
// something watched genuinely changes what the page should answer (it can retire the hero).
|
||||||
|
// The feed avoids this because it would refetch ten infinite-query pages per click.
|
||||||
|
void api.setState(id, status).then(() => qc.invalidateQueries({ queryKey: qk.discover() }));
|
||||||
|
},
|
||||||
|
[qc],
|
||||||
|
);
|
||||||
|
|
||||||
|
const onToggleSave = useCallback(
|
||||||
|
(id: string, saved: boolean) => {
|
||||||
|
void (saved ? api.watchLaterAdd(id) : api.watchLaterRemove(id)).then(() => {
|
||||||
|
qc.invalidateQueries({ queryKey: qk.playlists() });
|
||||||
|
qc.invalidateQueries({ queryKey: qk.playlist() });
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[qc],
|
||||||
|
);
|
||||||
|
|
||||||
|
// One click plays. A shelf tile resolves through its `next` (a show → the episode that would
|
||||||
|
// start, a channel → its newest unwatched video); everything else plays itself. Plex hands off to
|
||||||
|
// its own module, which owns the hls.js player and its whole provider tree — the alternative would
|
||||||
|
// be a second player mounted on the landing page.
|
||||||
|
const onPlay = useCallback(
|
||||||
|
(card: DiscoverCard, startAt?: number | null) => {
|
||||||
|
const target = card.next ?? card;
|
||||||
|
if (target.video) {
|
||||||
|
setPlayer({ video: target.video, startAt: startAt ?? null });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (target.plex) {
|
||||||
|
setJumpTo({ id: target.plex.id, type: target.plex.type === "show" ? "show" : "play" });
|
||||||
|
setPage("plex");
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[setJumpTo, setPage],
|
||||||
|
);
|
||||||
|
|
||||||
|
// The ⓘ affordance on a Plex tile: the item's own page in the Plex module, exactly where the same
|
||||||
|
// click goes from the Plex grid. A show tile's card click lands there too — a show is not playable.
|
||||||
|
const onInfo = useCallback(
|
||||||
|
(card: DiscoverCard) => {
|
||||||
|
if (!card.plex) return;
|
||||||
|
setJumpTo({ id: card.plex.id, type: card.plex.type === "show" ? "show" : "movie" });
|
||||||
|
setPage("plex");
|
||||||
|
},
|
||||||
|
[setJumpTo, setPage],
|
||||||
|
);
|
||||||
|
|
||||||
|
// Watched-toggle on a Plex tile. No optimistic cache patch here (unlike the Plex Discover
|
||||||
|
// surface): marking something watched can retire the hero as well as the tile, so this page
|
||||||
|
// re-asks the server rather than trying to predict a cross-rail consequence.
|
||||||
|
const onToggleWatched = useCallback(
|
||||||
|
(card: PlexCard) =>
|
||||||
|
void toggleWatchState(card, t, {
|
||||||
|
optimistic: () => {},
|
||||||
|
resync: () => qc.invalidateQueries({ queryKey: qk.discover() }),
|
||||||
|
}),
|
||||||
|
[qc, t],
|
||||||
|
);
|
||||||
|
|
||||||
|
const reroll = useCallback(() => {
|
||||||
|
// Remember what is on screen so the next pick cannot be the same card, then take a fresh seed —
|
||||||
|
// never 0 (which means "the hero the page loaded with") and never the current one (a cache hit
|
||||||
|
// would look like a dead button).
|
||||||
|
if (heroKey)
|
||||||
|
setSeen((prev) => [...prev.filter((k) => k !== heroKey), heroKey].slice(-SEEN_WINDOW));
|
||||||
|
setSeed((prev) => {
|
||||||
|
let next = prev;
|
||||||
|
while (next === prev || next === 0) next = Math.floor(Math.random() * 1_000_000) + 1;
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}, [heroKey]);
|
||||||
|
|
||||||
|
if (q.isPending) return <LoadingState />;
|
||||||
|
if (q.isError)
|
||||||
|
return (
|
||||||
|
<StateMessage tone="error" onRetry={() => void q.refetch()}>
|
||||||
|
{t("home.error")}
|
||||||
|
</StateMessage>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="p-4">
|
||||||
|
{/* The caption sits NEXT TO the chips, not opposite them: SegmentedToggle's own `label` prop
|
||||||
|
renders a justify-between row, which is right for a narrow filter rail and wrong here — on
|
||||||
|
an 80rem page it throws the answers to the far edge, away from the question they answer. */}
|
||||||
|
<div className="mb-4 flex flex-wrap items-center gap-x-3 gap-y-2">
|
||||||
|
<span className="text-sm text-muted">{t("home.budget.ask")}</span>
|
||||||
|
<SegmentedToggle
|
||||||
|
size="md"
|
||||||
|
ariaLabel={t("home.budget.ask")}
|
||||||
|
value={budget}
|
||||||
|
onChange={(v) => {
|
||||||
|
setBudget(v);
|
||||||
|
setSeed(0); // the payload for the new budget carries its own hero
|
||||||
|
setSeen([]); // a new question deserves the whole library again
|
||||||
|
}}
|
||||||
|
options={BUDGETS.map((b) => ({ value: b, label: t(`home.budget.${b}`) }))}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{hero ? (
|
||||||
|
<Hero
|
||||||
|
tier={hero.tier}
|
||||||
|
card={hero.card}
|
||||||
|
onPlay={onPlay}
|
||||||
|
onReroll={reroll}
|
||||||
|
rerolling={pickQ.isFetching}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
// Honest: nothing in the library fits what was asked. Offering something that does not fit
|
||||||
|
// would make the one control on this page meaningless.
|
||||||
|
<section className="mb-6 rounded-xl border border-border bg-surface p-4">
|
||||||
|
<StateMessage className="!py-6" onRetry={budget === "any" ? undefined : reroll}>
|
||||||
|
{budget === "any" ? t("home.hero.emptyAny") : t("home.hero.emptyBudget")}
|
||||||
|
</StateMessage>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Rail
|
||||||
|
title={t("home.comfort.title")}
|
||||||
|
ariaLabel={t("home.comfort.title")}
|
||||||
|
cards={data?.comfort ?? []}
|
||||||
|
emptyLabel={t("home.comfort.empty")}
|
||||||
|
onPlay={onPlay}
|
||||||
|
onInfo={onInfo}
|
||||||
|
onState={onState}
|
||||||
|
onToggleSave={onToggleSave}
|
||||||
|
onToggleWatched={onToggleWatched}
|
||||||
|
/>
|
||||||
|
<Rail
|
||||||
|
title={t("home.continue.title")}
|
||||||
|
ariaLabel={t("home.continue.title")}
|
||||||
|
cards={resumeRail}
|
||||||
|
emptyLabel={t("home.continue.empty")}
|
||||||
|
onPlay={onPlay}
|
||||||
|
onInfo={onInfo}
|
||||||
|
onState={onState}
|
||||||
|
onToggleSave={onToggleSave}
|
||||||
|
onToggleWatched={onToggleWatched}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{player && (
|
||||||
|
<Suspense fallback={null}>
|
||||||
|
<PlayerModal
|
||||||
|
video={player.video}
|
||||||
|
startAt={player.startAt}
|
||||||
|
onClose={() => {
|
||||||
|
setPlayer(null);
|
||||||
|
// The player saves progress on close, so the rails and the hero are stale by
|
||||||
|
// definition once it shuts.
|
||||||
|
void qc.invalidateQueries({ queryKey: qk.discover() });
|
||||||
|
}}
|
||||||
|
onState={onState}
|
||||||
|
/>
|
||||||
|
</Suspense>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -134,7 +134,11 @@ export default function PlexBrowse() {
|
|||||||
if (jumpTo != null) {
|
if (jumpTo != null) {
|
||||||
if (jumpTo.type !== "show") infoScrollRef.current = 0; // a jumped-to info page starts at the top
|
if (jumpTo.type !== "show") infoScrollRef.current = 0; // a jumped-to info page starts at the top
|
||||||
sub.open(
|
sub.open(
|
||||||
jumpTo.type === "show" ? { kind: "show", id: jumpTo.id } : { kind: "info", id: jumpTo.id },
|
jumpTo.type === "show"
|
||||||
|
? { kind: "show", id: jumpTo.id }
|
||||||
|
: jumpTo.type === "play"
|
||||||
|
? { kind: "player", id: jumpTo.id }
|
||||||
|
: { kind: "info", id: jumpTo.id },
|
||||||
);
|
);
|
||||||
setJumpTo(null);
|
setJumpTo(null);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -28,9 +28,12 @@ import { useNavigation } from "./NavigationProvider";
|
|||||||
// search term is deliberately its OWN state, NOT the feed's `filters.q`, so a Plex search never leaks
|
// search term is deliberately its OWN state, NOT the feed's `filters.q`, so a Plex search never leaks
|
||||||
// into (and is never greeted by a stale value from) the feed.
|
// into (and is never greeted by a stale value from) the feed.
|
||||||
|
|
||||||
// A "jump straight to this title" hand-off from the header typeahead: PlexBrowse opens a movie's
|
// A "jump straight to this title" hand-off: PlexBrowse opens a movie's info page (like the card's ⓘ),
|
||||||
// info page (like the card's ⓘ) or a show's drill-down (like clicking a show card) by type.
|
// a show's drill-down (like clicking a show card), or — for E24's Home hero, whose whole promise is a
|
||||||
export type PlexJumpTarget = { id: string; type: "movie" | "show" };
|
// single Play button — the PLAYER for a movie/episode rating_key. `play` exists because the player
|
||||||
|
// lives inside this module with its lazy hls.js and its provider tree; mounting a second one on the
|
||||||
|
// landing page to avoid one hand-off would be the expensive way round.
|
||||||
|
export type PlexJumpTarget = { id: string; type: "movie" | "show" | "play" };
|
||||||
|
|
||||||
type PlexState = {
|
type PlexState = {
|
||||||
scope: string; // movie | show | both (unified cross-library scope)
|
scope: string; // movie | show | both (unified cross-library scope)
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ import { useSavedViews } from "./SavedViewsProvider";
|
|||||||
|
|
||||||
// Route-level code splitting: each page is its own lazy chunk (admin-only pages never reach
|
// Route-level code splitting: each page is its own lazy chunk (admin-only pages never reach
|
||||||
// non-admins; the logged-out landing pulls only the shell). Rendered inside App's <Suspense>.
|
// non-admins; the logged-out landing pulls only the shell). Rendered inside App's <Suspense>.
|
||||||
|
const Home = lazy(() => import("./Home"));
|
||||||
const Feed = lazy(() => import("./Feed"));
|
const Feed = lazy(() => import("./Feed"));
|
||||||
const Channels = lazy(() => import("./Channels"));
|
const Channels = lazy(() => import("./Channels"));
|
||||||
const Playlists = lazy(() => import("./Playlists"));
|
const Playlists = lazy(() => import("./Playlists"));
|
||||||
@@ -92,6 +93,7 @@ export type PageDef = {
|
|||||||
|
|
||||||
// page id → the component App renders in the content column (+ its shell chrome config).
|
// page id → the component App renders in the content column (+ its shell chrome config).
|
||||||
export const PAGE_CONTENT: Record<Page, PageDef> = {
|
export const PAGE_CONTENT: Record<Page, PageDef> = {
|
||||||
|
home: { Component: Home, maxWidth: "80rem" },
|
||||||
feed: { Component: FeedPage },
|
feed: { Component: FeedPage },
|
||||||
channels: { Component: Channels, fadeTop: false, maxWidth: "96rem" },
|
channels: { Component: Channels, fadeTop: false, maxWidth: "96rem" },
|
||||||
playlists: { Component: Playlists, fadeTop: false, maxWidth: "64rem" },
|
playlists: { Component: Playlists, fadeTop: false, maxWidth: "64rem" },
|
||||||
|
|||||||
@@ -58,15 +58,28 @@ describe("loadInitialPage precedence", () => {
|
|||||||
setUrl("?v=show:1&pl=3&tags=1");
|
setUrl("?v=show:1&pl=3&tags=1");
|
||||||
expect(loadInitialPage()).toBe("plex");
|
expect(loadInitialPage()).toBe("plex");
|
||||||
});
|
});
|
||||||
it("a bare URL uses the account's stored landing page", () => {
|
// E24 changed this rung: a bare URL lands on Home, OVER the remembered module. Landing on
|
||||||
|
// whatever you happened to close the app on is precisely the habit the anti-paralysis surface
|
||||||
|
// exists to replace, so the remembered page only wins once the account opts out.
|
||||||
|
it("a bare URL lands on Home even when another module is remembered", () => {
|
||||||
|
stored[LS.page] = "downloads";
|
||||||
|
expect(loadInitialPage()).toBe("home");
|
||||||
|
});
|
||||||
|
it("opting out restores the account's stored landing page", () => {
|
||||||
|
stored[LS.startOnHome] = "0";
|
||||||
stored[LS.page] = "downloads";
|
stored[LS.page] = "downloads";
|
||||||
expect(loadInitialPage()).toBe("downloads");
|
expect(loadInitialPage()).toBe("downloads");
|
||||||
});
|
});
|
||||||
it("a bare URL with no / invalid stored page defaults to feed", () => {
|
it("opted out with no / invalid stored page still defaults to feed", () => {
|
||||||
|
stored[LS.startOnHome] = "0";
|
||||||
expect(loadInitialPage()).toBe("feed");
|
expect(loadInitialPage()).toBe("feed");
|
||||||
stored[LS.page] = "nope";
|
stored[LS.page] = "nope";
|
||||||
expect(loadInitialPage()).toBe("feed");
|
expect(loadInitialPage()).toBe("feed");
|
||||||
});
|
});
|
||||||
|
it("a deep link still beats Home", () => {
|
||||||
|
setUrl("?pl=3");
|
||||||
|
expect(loadInitialPage()).toBe("playlists");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("channelViewFrom", () => {
|
describe("channelViewFrom", () => {
|
||||||
|
|||||||
@@ -6,6 +6,9 @@ import { hasFilterParams, isPage, readPage, type Page } from "../lib/urlState";
|
|||||||
import { getAccountRaw, LS } from "../lib/storage";
|
import { getAccountRaw, LS } from "../lib/storage";
|
||||||
|
|
||||||
export const PAGE_KEY = LS.page;
|
export const PAGE_KEY = LS.page;
|
||||||
|
// Module-local: the Settings opt-out (S4) writes `LS.startOnHome` directly, so exporting this
|
||||||
|
// alias would only be a second name for the same key.
|
||||||
|
const START_HOME_KEY = LS.startOnHome;
|
||||||
|
|
||||||
export interface ChannelView {
|
export interface ChannelView {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -20,7 +23,11 @@ export function loadInitialPage(): Page {
|
|||||||
if (params.has("v")) return "plex";
|
if (params.has("v")) return "plex";
|
||||||
if (params.has("pl")) return "playlists";
|
if (params.has("pl")) return "playlists";
|
||||||
if (hasFilterParams(params) || params.has("chan")) return "feed";
|
if (hasFilterParams(params) || params.has("chan")) return "feed";
|
||||||
// A bare URL → the account's stored landing page.
|
// A bare URL → Home (E24), the surface whose whole job is to answer "what should I watch" before
|
||||||
|
// the user has to decide anything. It deliberately OVERRIDES the remembered last module: landing
|
||||||
|
// on whatever you happened to close the app on is the behaviour the anti-paralysis epic exists to
|
||||||
|
// replace. The account can opt out (Settings) and get the remembered module back.
|
||||||
|
if (getAccountRaw(START_HOME_KEY) !== "0") return "home";
|
||||||
const stored = getAccountRaw(PAGE_KEY);
|
const stored = getAccountRaw(PAGE_KEY);
|
||||||
return isPage(stored) ? stored : "feed";
|
return isPage(stored) ? stored : "feed";
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,37 @@
|
|||||||
|
{
|
||||||
|
"navLabel": "Home",
|
||||||
|
"title": "What should I watch?",
|
||||||
|
"error": "Could not load your suggestions.",
|
||||||
|
"budget": {
|
||||||
|
"ask": "How much time do you have?",
|
||||||
|
"short": "10 minutes",
|
||||||
|
"medium": "30 minutes",
|
||||||
|
"long": "An evening",
|
||||||
|
"any": "Doesn't matter"
|
||||||
|
},
|
||||||
|
"hero": {
|
||||||
|
"resume": "Where you left off",
|
||||||
|
"resumeLeft": "Where you left off · {{time}} to go",
|
||||||
|
"comfort": "From your shelf",
|
||||||
|
"surprise": "Surprise",
|
||||||
|
"play": "Play",
|
||||||
|
"continue": "Continue",
|
||||||
|
"reroll": "Something else",
|
||||||
|
"emptyAny": "Nothing to suggest yet. Subscribe to a channel, or pin something to your shelf.",
|
||||||
|
"emptyBudget": "Nothing in your library fits that much time."
|
||||||
|
},
|
||||||
|
"comfort": {
|
||||||
|
"title": "Comfort shelf",
|
||||||
|
"empty": "Nothing pinned yet. Pin a channel, a show or a playlist you always come back to."
|
||||||
|
},
|
||||||
|
"continue": {
|
||||||
|
"title": "Continue watching",
|
||||||
|
"empty": "Nothing half-finished right now."
|
||||||
|
},
|
||||||
|
"shelf": {
|
||||||
|
"unwatchedCount_one": "{{count}} unwatched",
|
||||||
|
"unwatchedCount_other": "{{count}} unwatched",
|
||||||
|
"itemCount_one": "{{count}} video",
|
||||||
|
"itemCount_other": "{{count}} videos"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
{
|
||||||
|
"navLabel": "Kezdőlap",
|
||||||
|
"title": "Mit nézzek most?",
|
||||||
|
"error": "Nem sikerült betölteni az ajánlatokat.",
|
||||||
|
"budget": {
|
||||||
|
"ask": "Mennyi időd van?",
|
||||||
|
"short": "10 perc",
|
||||||
|
"medium": "30 perc",
|
||||||
|
"long": "Egy este",
|
||||||
|
"any": "Mindegy"
|
||||||
|
},
|
||||||
|
"hero": {
|
||||||
|
"resume": "Ott hagytad abba",
|
||||||
|
"resumeLeft": "Ott hagytad abba · {{time}} van hátra",
|
||||||
|
"comfort": "A polcodról",
|
||||||
|
"surprise": "Meglepetés",
|
||||||
|
"play": "Lejátszás",
|
||||||
|
"continue": "Folytatás",
|
||||||
|
"reroll": "Mást kérek",
|
||||||
|
"emptyAny": "Még nincs mit ajánlani. Iratkozz fel egy csatornára, vagy tűzz valamit a polcodra.",
|
||||||
|
"emptyBudget": "Ennyi időbe semmi sem fér bele a könyvtáradból."
|
||||||
|
},
|
||||||
|
"comfort": {
|
||||||
|
"title": "Komfort polc",
|
||||||
|
"empty": "Még nincs ide tűzve semmi. Tűzz ki egy csatornát, sorozatot vagy listát, amihez mindig visszatérsz."
|
||||||
|
},
|
||||||
|
"continue": {
|
||||||
|
"title": "Folytatás",
|
||||||
|
"empty": "Most nincs félbehagyott dolgod."
|
||||||
|
},
|
||||||
|
"shelf": {
|
||||||
|
"unwatchedCount_one": "{{count}} megnézetlen",
|
||||||
|
"unwatchedCount_other": "{{count}} megnézetlen",
|
||||||
|
"itemCount_one": "{{count}} videó",
|
||||||
|
"itemCount_other": "{{count}} videó"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -32,6 +32,9 @@ import { setupApi } from "./api/setup";
|
|||||||
export type * from "./api/setup";
|
export type * from "./api/setup";
|
||||||
import { feedApi } from "./api/feed";
|
import { feedApi } from "./api/feed";
|
||||||
export type * from "./api/feed";
|
export type * from "./api/feed";
|
||||||
|
import { discoverApi } from "./api/discover";
|
||||||
|
export type * from "./api/discover";
|
||||||
|
export { DISCOVER_BUDGETS, PIN_KINDS, discoverCardKey } from "./api/discover";
|
||||||
export type * from "./api/tagModes";
|
export type * from "./api/tagModes";
|
||||||
export {
|
export {
|
||||||
DEFAULT_TAG_MODES,
|
DEFAULT_TAG_MODES,
|
||||||
@@ -77,6 +80,7 @@ export const api = {
|
|||||||
...authApi,
|
...authApi,
|
||||||
...setupApi,
|
...setupApi,
|
||||||
...feedApi,
|
...feedApi,
|
||||||
|
...discoverApi,
|
||||||
...channelsApi,
|
...channelsApi,
|
||||||
...syncApi,
|
...syncApi,
|
||||||
...playlistsApi,
|
...playlistsApi,
|
||||||
|
|||||||
@@ -0,0 +1,98 @@
|
|||||||
|
// The Home surface ("Mit nézzek most?", E24): one hero suggestion, a cross-source resume rail, and
|
||||||
|
// the comfort shelf. Types mirror `backend/app/discover/` — the card carries the NATIVE feed / Plex
|
||||||
|
// payload under `video` / `plex`, so the page renders through the existing VideoCard /
|
||||||
|
// PlexPosterCard instead of a third card component.
|
||||||
|
import { req } from "./core";
|
||||||
|
import type { Video } from "./feed";
|
||||||
|
import type { PlexCard } from "./plex";
|
||||||
|
|
||||||
|
// How much time the user says they have. The deterministic replacement for a mood question: the band
|
||||||
|
// also decides the source (ten minutes is a video, an evening is a film). Bands live server-side
|
||||||
|
// (`app/discover/budget.py`) — these are just the names on the wire.
|
||||||
|
export const DISCOVER_BUDGETS = ["short", "medium", "long", "any"] as const;
|
||||||
|
export type DiscoverBudget = (typeof DISCOVER_BUDGETS)[number];
|
||||||
|
|
||||||
|
// Where the hero came from. Travels with the card so the page can label it honestly instead of
|
||||||
|
// inferring "where you left off" over something the user has never opened.
|
||||||
|
export type HeroTier = "resume" | "comfort" | "surprise";
|
||||||
|
|
||||||
|
export type DiscoverKind = "video" | "movie" | "show" | "episode" | "channel" | "playlist";
|
||||||
|
|
||||||
|
// What can go on the comfort shelf. `plex_item` covers a movie or a single episode; a show is pinned
|
||||||
|
// as `plex_show` because "put that show on" resolves to whichever episode is next.
|
||||||
|
export const PIN_KINDS = ["video", "channel", "plex_item", "plex_show", "playlist"] as const;
|
||||||
|
export type PinKind = (typeof PIN_KINDS)[number];
|
||||||
|
|
||||||
|
export interface DiscoverCard {
|
||||||
|
source: "youtube" | "plex";
|
||||||
|
kind: DiscoverKind;
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
subtitle: string | null;
|
||||||
|
thumb: string | null;
|
||||||
|
// The ARTWORK's shape, not the module's: a Plex episode ships a landscape still exactly like a
|
||||||
|
// YouTube video, while movies and shows ship posters. Lets a rail size its tiles without
|
||||||
|
// branching on `source`.
|
||||||
|
aspect: "wide" | "poster";
|
||||||
|
duration_seconds: number | null;
|
||||||
|
position_seconds: number;
|
||||||
|
remaining_seconds: number | null;
|
||||||
|
status: string;
|
||||||
|
// Exactly one is set (both null for a channel / playlist tile, which are not themselves playable).
|
||||||
|
video: Video | null;
|
||||||
|
plex: PlexCard | null;
|
||||||
|
// Shelf entries only.
|
||||||
|
pin_id?: number;
|
||||||
|
/** What actually plays when the tile is clicked: a show's next episode, a channel's newest
|
||||||
|
* unwatched video, or a copy of the tile itself when it plays directly. Null when the pin has
|
||||||
|
* nothing to offer right now (a fully-watched channel, an empty playlist). */
|
||||||
|
next?: DiscoverCard | null;
|
||||||
|
unwatched_count?: number;
|
||||||
|
item_count?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DiscoverHero {
|
||||||
|
tier: HeroTier;
|
||||||
|
card: DiscoverCard;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DiscoverPayload {
|
||||||
|
budget: DiscoverBudget;
|
||||||
|
hero: DiscoverHero | null;
|
||||||
|
continue_watching: DiscoverCard[];
|
||||||
|
comfort: DiscoverCard[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export const discoverApi = {
|
||||||
|
discover: (budget: DiscoverBudget, seed: number): Promise<DiscoverPayload> =>
|
||||||
|
req(`/api/discover?budget=${budget}&seed=${seed}`),
|
||||||
|
// The "Mást kérek" reroll: a new hero alone, so pressing it does not re-fetch the rails under the
|
||||||
|
// user's cursor.
|
||||||
|
// `exclude` is the rolling list of `<source>:<id>` keys already shown. Without it a reroll only
|
||||||
|
// moves the surprise tier, so a user with something half-finished gets the same hero back.
|
||||||
|
discoverPick: (
|
||||||
|
budget: DiscoverBudget,
|
||||||
|
seed: number,
|
||||||
|
exclude: string[] = [],
|
||||||
|
): Promise<{ budget: DiscoverBudget; hero: DiscoverHero | null }> =>
|
||||||
|
req(
|
||||||
|
`/api/discover/pick?budget=${budget}&seed=${seed}` +
|
||||||
|
(exclude.length ? `&exclude=${encodeURIComponent(exclude.join(","))}` : ""),
|
||||||
|
),
|
||||||
|
comfortPins: (): Promise<{ items: DiscoverCard[] }> => req("/api/discover/pins"),
|
||||||
|
addComfortPin: (
|
||||||
|
kind: PinKind,
|
||||||
|
ref: string,
|
||||||
|
): Promise<{ ok: boolean; id: number; created: boolean }> =>
|
||||||
|
req("/api/discover/pins", { method: "POST", body: JSON.stringify({ kind, ref }) }),
|
||||||
|
removeComfortPin: (pinId: number): Promise<{ deleted: number }> =>
|
||||||
|
req(`/api/discover/pins/${pinId}`, { method: "DELETE" }),
|
||||||
|
reorderComfortPins: (ids: number[]): Promise<{ ok: boolean }> =>
|
||||||
|
req("/api/discover/pins/reorder", { method: "POST", body: JSON.stringify({ ids }) }),
|
||||||
|
};
|
||||||
|
|
||||||
|
/** A card's identity across sources — mirrors `app/discover/cards.py::key`. The reroll's exclusion
|
||||||
|
* list and the "is this tile the hero?" check both speak it, so the two cannot drift. */
|
||||||
|
export function discoverCardKey(card: Pick<DiscoverCard, "source" | "id">): string {
|
||||||
|
return `${card.source}:${card.id}`;
|
||||||
|
}
|
||||||
@@ -20,6 +20,7 @@ const SLICE_MODULES = [
|
|||||||
["authApi", () => import("./auth")],
|
["authApi", () => import("./auth")],
|
||||||
["setupApi", () => import("./setup")],
|
["setupApi", () => import("./setup")],
|
||||||
["feedApi", () => import("./feed")],
|
["feedApi", () => import("./feed")],
|
||||||
|
["discoverApi", () => import("./discover")],
|
||||||
["channelsApi", () => import("./channels")],
|
["channelsApi", () => import("./channels")],
|
||||||
["syncApi", () => import("./sync")],
|
["syncApi", () => import("./sync")],
|
||||||
["playlistsApi", () => import("./playlists")],
|
["playlistsApi", () => import("./playlists")],
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import {
|
|||||||
SlidersHorizontal,
|
SlidersHorizontal,
|
||||||
Tv,
|
Tv,
|
||||||
Users,
|
Users,
|
||||||
|
Youtube,
|
||||||
type LucideIcon,
|
type LucideIcon,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import type { Me } from "./api";
|
import type { Me } from "./api";
|
||||||
@@ -46,7 +47,11 @@ const notDemo = (me: Me) => !me.is_demo;
|
|||||||
|
|
||||||
// Ordered in nav-rail order — this array IS the sequence the rail and the stepper walk.
|
// Ordered in nav-rail order — this array IS the sequence the rail and the stepper walk.
|
||||||
const MODULES: readonly ModuleDef[] = [
|
const MODULES: readonly ModuleDef[] = [
|
||||||
{ id: "feed", labelKey: "header.account.feed", icon: Home },
|
// Home leads: it is the landing (see navigationInit.loadInitialPage), and the module order IS the
|
||||||
|
// nav order and the header stepper's sequence. The feed takes the YouTube mark its own toolbar
|
||||||
|
// already uses, so the two are apart by shape and not just by position.
|
||||||
|
{ id: "home", labelKey: "home.navLabel", titleKey: "home.title", icon: Home },
|
||||||
|
{ id: "feed", labelKey: "header.account.feed", icon: Youtube },
|
||||||
{
|
{
|
||||||
id: "channels",
|
id: "channels",
|
||||||
labelKey: "header.account.channels",
|
labelKey: "header.account.channels",
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import type { Page } from "./urlState";
|
|||||||
// browser-tab title keys the old hand-written switch produced — pin every page so a future labelKey
|
// browser-tab title keys the old hand-written switch produced — pin every page so a future labelKey
|
||||||
// rename (or a dropped titleKey) can't silently change a tab title.
|
// rename (or a dropped titleKey) can't silently change a tab title.
|
||||||
const EXPECTED: Record<Page, string> = {
|
const EXPECTED: Record<Page, string> = {
|
||||||
|
home: "home.title",
|
||||||
feed: "header.account.feed",
|
feed: "header.account.feed",
|
||||||
channels: "header.channelManager",
|
channels: "header.channelManager",
|
||||||
playlists: "header.account.playlists",
|
playlists: "header.account.playlists",
|
||||||
|
|||||||
@@ -100,6 +100,12 @@ export const qk = {
|
|||||||
// array, a "union"/"group"/"contains" discriminator), so they take a variadic pass-through:
|
// array, a "union"/"group"/"contains" discriminator), so they take a variadic pass-through:
|
||||||
// the value this factory centralizes is the ROOT string, and TanStack keys are `unknown[]`
|
// the value this factory centralizes is the ROOT string, and TanStack keys are `unknown[]`
|
||||||
// anyway. Single-id plex keys stay precisely typed.
|
// anyway. Single-id plex keys stay precisely typed.
|
||||||
|
// The Home surface (E24). `discover()` with no args is the prefix every mutation invalidates —
|
||||||
|
// pinning something changes the shelf AND can change the hero, so the two are never invalidated
|
||||||
|
// separately. The reroll has its own key so pressing "Mást kérek" does not refetch the rails.
|
||||||
|
discover: (...rest: unknown[]) => ["discover", ...rest],
|
||||||
|
discoverPick: (...rest: unknown[]) => ["discover-pick", ...rest],
|
||||||
|
comfortPins: () => ["comfort-pins"] as const,
|
||||||
plex: () => ["plex"] as const,
|
plex: () => ["plex"] as const,
|
||||||
plexWatchLink: () => ["plex-watch-link"] as const,
|
plexWatchLink: () => ["plex-watch-link"] as const,
|
||||||
plexLibrary: (...rest: unknown[]) => ["plex-library", ...rest],
|
plexLibrary: (...rest: unknown[]) => ["plex-library", ...rest],
|
||||||
|
|||||||
@@ -15,6 +15,11 @@ export const LS = {
|
|||||||
lang: "siftlode.lang",
|
lang: "siftlode.lang",
|
||||||
filters: "siftlode.filters",
|
filters: "siftlode.filters",
|
||||||
page: "siftlode.page",
|
page: "siftlode.page",
|
||||||
|
// E24: start every session on the Home surface rather than on the module you happened to leave
|
||||||
|
// open. Per-account, "0" = opt out and go back to remembering the last module. It has to be
|
||||||
|
// localStorage rather than a server pref because `loadInitialPage` runs before /api/me lands —
|
||||||
|
// a landing page decided after the first paint would be a visible jump.
|
||||||
|
startOnHome: "siftlode.startOnHome",
|
||||||
perfMode: "siftlode.perfMode",
|
perfMode: "siftlode.perfMode",
|
||||||
// The feed's view mode (see lib/feedView.ts) — a cache of the server pref, so a reload of an
|
// The feed's view mode (see lib/feedView.ts) — a cache of the server pref, so a reload of an
|
||||||
// established tab paints your view straight away. A brand-new tab still starts on the default
|
// established tab paints your view straight away. A brand-new tab still starts on the default
|
||||||
|
|||||||
@@ -119,6 +119,7 @@ export function hasFilterParams(params: URLSearchParams): boolean {
|
|||||||
// derive from it, so adding a page is a one-line change (no parallel allowlists to keep in
|
// derive from it, so adding a page is a one-line change (no parallel allowlists to keep in
|
||||||
// sync). "feed" is the default/fallback.
|
// sync). "feed" is the default/fallback.
|
||||||
const PAGES = [
|
const PAGES = [
|
||||||
|
"home",
|
||||||
"feed",
|
"feed",
|
||||||
"channels",
|
"channels",
|
||||||
"stats",
|
"stats",
|
||||||
|
|||||||
Reference in New Issue
Block a user