feat(keys): Back returns focus to the item a view was opened from; "Feed" everywhere; every backend id named

Owner answers to the E11 S5 raises (2026-09-13): 1 → A, 2 → "Feed" in every language, 3 → translate the
proxy label and fix every config key missing in BOTH languages ("maintenance", "tmdb" — old bugs).

Focus across a view change (1-A). Opening a Plex title from Home's rail, a channel from the Channels
table or a title from a Plex grid switches the page or the sub-view, which unmounts the list; Back used
to hand focus to <body>, so the next `j` started over at the top. The YouTube player (an overlay)
already restored onto the card.
- `lib/listNav`: `navPosition()` (list's place in reading order, the page's list count, item index)
  and `restoreNavPosition()`: focuses the item once the page has as many lists on screen as when it was
  left, polls on a timer (a hidden tab runs no animation frames), gives up after 2 s or at once when
  something already holds focus, focuses without scrolling (the scroll restore owns the position).
- `lib/history`: `rememberNavPosition()` stamps `sfNav` onto the entry being left and returns the state
  without it (a new entry must not inherit it); one popstate listener restores and spends the stamp,
  so a later landing on the same entry (an overlay closing over it) cannot pull focus to an old item.
- Called before every view push: `setPage`, `openChannel`, `enterYtSearch`, `useHistorySubview.open`,
  the Channels tab push. `navPosition.wiring.contract.test` fails on any `history.pushState(` without
  it (the overlay stack's own push is the one exemption).

"Feed" (2). The module is "Feed" in both languages: every "hírfolyam" in the Hungarian UI (≈45 strings,
suffixed: Feedből, Feeded, Feedben…) and the prose "feed" in English that means the module ("your
Feed", "Hide from Feed"); "RSS feed" keeps its meaning. The welcome email says it too.

Backend ids without a name (3). The Configuration page showed `maintenance`, `tmdb`,
`maintenance_revalidate_batch`, `download_upload_max_bytes`, `audit_retention_days`, `plex_overlay_map`,
`tmdb_api_key` and `tmdb_language` as raw identifiers — the lookup falls back to the id, and the i18n
parity test stayed green because both languages missed the same keys. Added labels + hints (EN/HU),
the two groups (and their tab order), the missing `scheduler.jobDesc.demo_reset`, removed the stale
`plex_sync_interval_min` field, and "YouTube API kimenő proxy". `i18n/backendIds.contract.test` reads
`sysconfig.SPECS`, `scheduler.JOB_INTERVALS` and the audit/quota action enums from the backend sources
and fails on an id without its name in either language, or a name left for an id the backend dropped.
This commit is contained in:
2026-09-13 07:36:41 +02:00
parent 8731508645
commit bbd31c7143
38 changed files with 519 additions and 113 deletions
+1
View File
@@ -91,6 +91,7 @@ CQRS, a repository/DI framework, a router library, a generic CRUD-router factory
| FE action button | `ui/Button` — pick a `SIZES` rung | a hand-rolled `<button className="px-3 py-2 …">` |
| FE keyboard shortcut | a row in `lib/keymap.ts` + `dispatch()` | a private `keydown` listener with its own key literals |
| FE list/grid keys | `NavGrid`/`keyboardNav` + `navItemAttrs` + `data-nav-open` | a private arrow-key / roving-tabindex handler |
| FE view push (history) | `rememberNavPosition()` right before `history.pushState` | a push that forgets: Back strands focus on `<body>` |
| FE control | the chip/segmented primitives (`Select` only for >6 options) | a native `<select>` |
| FE loading/empty/error | `QueryState` / `StateMessage` | ad-hoc conditionals |
| FE failed write | `meta.failTitle`, else `notifyActionError(err, title)` | a bare `catch {}` or `notify()` with no error attached |
+2 -2
View File
@@ -175,7 +175,7 @@ MAIL_TEXT: dict[str, dict[str, str]] = {
"en": (
"Hi,\n\n"
"Someone has opened up Siftlode for you — a quiet, self-hosted reader for your YouTube "
"subscriptions. Every upload from the channels you follow lands in one feed you can "
"subscriptions. Every upload from the channels you follow lands in one Feed you can "
"actually filter and sort, with no algorithm deciding what you see, and no Shorts or "
"livestreams unless you want them.\n\n"
"{next_step}\n\n"
@@ -186,7 +186,7 @@ MAIL_TEXT: dict[str, dict[str, str]] = {
"Szia,\n\n"
"Valaki megnyitotta előtted a Siftlode-ot — ez egy csendes, saját üzemeltetésű olvasó "
"a YouTube-feliratkozásaidhoz. A követett csatornák minden feltöltése egyetlen "
"hírfolyamba érkezik, amit tényleg tudsz szűrni és rendezni; nincs algoritmus, ami "
"Feedbe érkezik, amit tényleg tudsz szűrni és rendezni; nincs algoritmus, ami "
"eldönti, mit látsz, és nincs Shorts vagy élő adás, hacsak nem kéred.\n\n"
"{next_step}\n\n"
"Nem sürget semmi — az ajtó nyitva marad.\n\n"
@@ -16,6 +16,7 @@ import { useSortState } from "../lib/useSortState";
import { useFilterState } from "../lib/useFilterState";
import { useActiveAccountId } from "../lib/activeAccount";
import { useUrlMirror } from "../lib/useUrlMirror";
import { rememberNavPosition } from "../lib/history";
import {
channelsParamsToState,
channelsParamUpdates,
@@ -243,6 +244,7 @@ export function ChannelsProvider({ children }: { children: ReactNode }) {
if (window.history.state?.sfPage === "channels") {
// A FRESH state, like setPage's: carrying the old entry's `_chan`/`_yt` markers over would make
// Back re-open the channel overlay or a spent search on top of the tab.
rememberNavPosition();
window.history.pushState({ sfPage: "channels", _chTab: v }, "");
}
},
+2
View File
@@ -30,9 +30,11 @@ const GROUP_ORDER = [
"backfill",
"shorts",
"batch",
"maintenance",
"downloads",
"audit",
"plex",
"tmdb",
];
export default function ConfigPanel() {
+21 -7
View File
@@ -14,7 +14,12 @@ import { pageTitleKey } from "../lib/pageMeta";
import { setAccountRaw } from "../lib/storage";
import { useAccountRehydrate } from "../lib/useAccountRehydrate";
import { setNavigator } from "../lib/nav";
import { closeOverlaysThenNavigate, nextNavId, notifyNav } from "../lib/history";
import {
closeOverlaysThenNavigate,
nextNavId,
notifyNav,
rememberNavPosition,
} from "../lib/history";
import { channelViewFrom, loadInitialPage, PAGE_KEY, type ChannelView } from "./navigationInit";
// Owns the app's navigation + browser-history state, lifted out of the App god component so pages
@@ -136,7 +141,9 @@ export function NavigationProvider({ children }: { children: ReactNode }) {
// view-specific params (chan/v/pl and the plex content keys pscope/psort/pshow/pf) — a page
// switch resets to that module's root. Feed filters (owned by FeedFiltersProvider) are left
// intact; the plex/channels keys re-mirror from PlexProvider / ChannelsProvider on return to
// their page.
// their page. The item it was opened from is still focused: the state setters above have not
// re-rendered anything yet.
rememberNavPosition();
window.history.pushState(
{ sfPage: next, sfScroll: nextNavId() },
"",
@@ -167,12 +174,15 @@ export function NavigationProvider({ children }: { children: ReactNode }) {
// channel rows call it from inside one.
const openChannel = useCallback((id: string, name?: string) => {
closeOverlaysThenNavigate(() => {
setChannelView({ id, name });
const st = window.history.state || {};
const url = urlWithParams({ chan: id });
if (st._chan)
if (window.history.state?._chan) {
const st = window.history.state;
window.history.replaceState({ ...st, _chan: id, _chanName: name ?? null }, "", url);
else window.history.pushState({ ...st, _chan: id, _chanName: name ?? null }, "", url);
} else {
const st = rememberNavPosition();
window.history.pushState({ ...st, _chan: id, _chanName: name ?? null }, "", url);
}
setChannelView({ id, name });
});
}, []);
@@ -194,7 +204,11 @@ export function NavigationProvider({ children }: { children: ReactNode }) {
const { _ytScrape: _drop, ...st } = window.history.state || {};
if (st._yt)
window.history.replaceState({ ...st, _yt: q }, ""); // refine current search
else window.history.pushState({ ...st, sfPage: "feed", _yt: q }, ""); // new sub-view entry
else {
// New sub-view entry.
const { _ytScrape: _dropToo, ...left } = rememberNavPosition();
window.history.pushState({ ...left, sfPage: "feed", _yt: q }, "");
}
}, []);
const exitYtSearch = useCallback(() => {
@@ -0,0 +1,103 @@
import { readFileSync } from "node:fs";
import { describe, expect, it } from "vitest";
// Every id the BACKEND owns and the UI names must have its name in both languages. The UI looks these
// up with the raw id as the fallback — `t(\`config.fields.${key}.label\`, key)` — so a missing key never
// fails loudly: the admin Configuration page showed `maintenance`, `tmdb`, `tmdb_api_key`,
// `plex_overlay_map`… as bare identifiers, for months, and the i18n parity test stayed green because EN
// and HU were missing the SAME keys (owner report, E11 S5). The ids live in Python, so the check reads
// the registries straight from the backend sources:
// - `sysconfig.SPECS` → `config.groups.<group>`, `config.fields.<key>.label` + `.hint`
// - `scheduler.JOB_INTERVALS` → `scheduler.jobs.<id>` + `scheduler.jobDesc.<id>`
// - the `audit` action enum → `auditActions.<id>`; the `quota` action enum → `quotaActions.<id>`
// and the other way round: a name left behind for an id the backend dropped is dead weight that reads
// as a live setting to the next person (`plex_sync_interval_min` moved to the scheduler long ago).
// Progress phases are not covered: jobs pass most of them as variables, not literals a scan can see.
const backend = (file: string) =>
readFileSync(new URL(`../../../backend/app/${file}`, import.meta.url), "utf-8");
const mods = import.meta.glob("./locales/*/*.json", { eager: true }) as Record<
string,
{ default: Record<string, unknown> }
>;
function flatten(obj: Record<string, unknown>, prefix: string, out: Set<string>): Set<string> {
for (const [k, v] of Object.entries(obj)) {
const key = `${prefix}.${k}`;
if (v && typeof v === "object" && !Array.isArray(v))
flatten(v as Record<string, unknown>, key, out);
else out.add(key);
}
return out;
}
const keysOf = (lang: string): Set<string> => {
const out = new Set<string>();
for (const [path, mod] of Object.entries(mods)) {
const m = path.match(/\/locales\/([^/]+)\/([^/]+)\.json$/);
if (m?.[1] === lang) flatten(mod.default, m[2]!, out);
}
return out;
};
const specs = [
...backend("sysconfig.py").matchAll(/ConfigSpec\(\s*"(\w+)",\s*"\w+",\s*"(\w+)"/g),
].map((m) => ({ key: m[1]!, group: m[2]! }));
const jobs = [
...(backend("scheduler.py").split("JOB_INTERVALS")[1] ?? "")
.split("}")[0]!
.matchAll(/"(\w+)": settings\./g),
].map((m) => m[1]!);
const enumValues = (file: string) =>
[...backend(file).matchAll(/^\s+[A-Z_]+ = "(\w+)"$/gm)].map((m) => m[1]!);
const auditActions = enumValues("audit.py");
const quotaActions = enumValues("quota.py");
/** The ids under `prefix` a locale names, `prefix.<id>` or `prefix.<id>.<anything>`. */
const idsUnder = (keys: Set<string>, prefix: string) =>
new Set(
[...keys]
.filter((k) => k.startsWith(`${prefix}.`))
.map((k) => k.split(".")[prefix.split(".").length]!),
);
describe("backend ids have their names in both languages", () => {
it("read the registries (a scan that matches nothing is green over nothing)", () => {
expect(specs.map((s) => s.key)).toEqual(expect.arrayContaining(["smtp_host", "tmdb_language"]));
expect(jobs).toEqual(expect.arrayContaining(["rss_poll", "audit_gc"]));
expect(auditActions).toEqual(expect.arrayContaining(["config_set", "audit_clear"]));
expect(quotaActions.length).toBeGreaterThan(0);
});
for (const lang of ["en", "hu"]) {
const keys = keysOf(lang);
const missing = (wanted: string[]) => wanted.filter((k) => !keys.has(k));
it(`${lang}: every config key and group`, () => {
expect(
missing([
...new Set(specs.map((s) => `config.groups.${s.group}`)),
...specs.flatMap((s) => [`config.fields.${s.key}.label`, `config.fields.${s.key}.hint`]),
]),
).toEqual([]);
const known = new Set(specs.map((s) => s.key));
expect([...idsUnder(keys, "config.fields")].filter((k) => !known.has(k))).toEqual([]);
const groups = new Set(specs.map((s) => s.group));
expect([...idsUnder(keys, "config.groups")].filter((g) => !groups.has(g))).toEqual([]);
});
it(`${lang}: every scheduler job`, () => {
expect(
missing(jobs.flatMap((j) => [`scheduler.jobs.${j}`, `scheduler.jobDesc.${j}`])),
).toEqual([]);
for (const prefix of ["scheduler.jobs", "scheduler.jobDesc"])
expect([...idsUnder(keys, prefix)].filter((j) => !jobs.includes(j))).toEqual([]);
});
it(`${lang}: every audit and quota action`, () => {
expect(auditActions.filter((a) => !idsUnder(keys, "auditActions").has(a))).toEqual([]);
expect(quotaActions.filter((a) => !idsUnder(keys, "quotaActions").has(a))).toEqual([]);
});
}
});
+4 -4
View File
@@ -22,15 +22,15 @@
"block": "Block channel",
"unblock": "Unblock channel",
"blockedBadge": "Blocked",
"hide": "Hide from feed",
"hide": "Hide from Feed",
"unhide": "Unhide",
"hideHint": "Hide this channel's videos from your feed. It stays subscribed; this doesn't unsubscribe on YouTube.",
"hiddenHint": "Hidden — this channel's videos are kept out of your feed. Click to show them again.",
"hideHint": "Hide this channel's videos from your Feed. It stays subscribed; this doesn't unsubscribe on YouTube.",
"hiddenHint": "Hidden — this channel's videos are kept out of your Feed. Click to show them again.",
"country": "Country",
"language": "Language",
"topics": "Topics",
"keywords": "Keywords",
"showInFeed": "Show in my feed",
"showInFeed": "Show in my Feed",
"fullHistory": "Full history",
"fullHistoryLoading": "Fetching history…",
"fullHistoryProgress": "Fetching history… {{stored}} / {{total}}",
+13 -13
View File
@@ -23,14 +23,14 @@
},
"cardsPerPage": "Cards per page",
"discovery": {
"intro": "Channels that appear in your playlists but that you don't subscribe to. Subscribe to follow them — their new uploads will start showing in your feed (subscribing costs a little YouTube quota; existing videos aren't re-fetched).",
"intro": "Channels that appear in your playlists but that you don't subscribe to. Subscribe to follow them — their new uploads will start showing in your Feed (subscribing costs a little YouTube quota; existing videos aren't re-fetched).",
"loading": "Finding channels…",
"empty": "No new channels — every channel in your playlists is one you already follow.",
"subscribe": "Subscribe",
"subscribeHint": "Subscribe to this channel on YouTube (changes your real account; uses a little API quota). Its videos arrive on the next background sync.",
"needWriteHint": "Enable playlist editing in Settings to subscribe on YouTube.",
"subscribedTitle": "Subscribed on YouTube",
"subscribedBody": "You're now following {{name}} — its new uploads will start arriving in your feed.",
"subscribedBody": "You're now following {{name}} — its new uploads will start arriving in your Feed.",
"subscribeFailed": "Subscribe failed",
"confirmSubscribe": "Subscribe to \"{{name}}\" on YouTube? This changes your real YouTube account and spends a little API quota.",
"cols": {
@@ -48,7 +48,7 @@
"tags": {
"label": "Tags",
"add": "Tag",
"yourTagsHint": "Your personal labels. Attach them to channels below, then filter the feed by tag from the sidebar. (Separate from the automatic language/topic tags.)",
"yourTagsHint": "Your personal labels. Attach them to channels below, then filter the Feed by tag from the sidebar. (Separate from the automatic language/topic tags.)",
"noneYet": "No tags yet — create one below.",
"create": "Create tag",
"createPlaceholder": "New tag…",
@@ -93,7 +93,7 @@
"channels": "Channels",
"channelsHint": "Channels you're subscribed to.",
"recentSynced": "Recent synced",
"recentSyncedHint": "Channels whose recent uploads are in the local DB and show in your feed.",
"recentSyncedHint": "Channels whose recent uploads are in the local DB and show in your Feed.",
"fullHistory": "Full history",
"fullHistoryHint": "Channels whose entire back-catalog is fetched, out of the ones you've requested full history for.",
"left": "left",
@@ -106,8 +106,8 @@
"row": {
"openOnYouTube": "Open on YouTube",
"editTags": "Edit tags",
"filterFeedByTag": "Filter the feed by “{{name}}”",
"priorityHint": "Your ranking for this channel. Sort the feed by “Channel priority” to bring higher-priority channels to the top.",
"filterFeedByTag": "Filter the Feed by “{{name}}”",
"priorityHint": "Your ranking for this channel. Sort the Feed by “Channel priority” to bring higher-priority channels to the top.",
"raisePriority": "Raise priority",
"lowerPriority": "Lower priority",
"editPriority": "Click to type a priority (negatives allowed)",
@@ -120,10 +120,10 @@
"resetHint": "Admin: re-fetch this channel from scratch (recent + full back-catalog), regardless of its current sync state.",
"queuedRequestedHint": "Full history requested — this channel's whole back-catalog will backfill as the shared quota allows. Click to cancel your request.",
"queuedByOtherHint": "Another subscriber already requested this channel's full history, so its whole back-catalog is on its way to everyone — nothing to do here.",
"hiddenHint": "Hidden — this channel's videos are kept out of your feed. Click to show them again.",
"hideHint": "Hide this channel's videos from your feed. It stays subscribed; this doesn't unsubscribe on YouTube.",
"hiddenHint": "Hidden — this channel's videos are kept out of your Feed. Click to show them again.",
"hideHint": "Hide this channel's videos from your Feed. It stays subscribed; this doesn't unsubscribe on YouTube.",
"unhide": "Unhide",
"hideFromFeed": "Hide from feed",
"hideFromFeed": "Hide from Feed",
"unsubscribeHint": "Unsubscribe from this channel on YouTube (changes your real account). Read-only mode hides this — use Hide instead.",
"unsubscribeOnYoutube": "Unsubscribe on YouTube",
"thisChannel": "This channel"
@@ -141,18 +141,18 @@
"resetDone": "{{name}} reset — re-fetching now",
"resetFailed": "Couldn't reset this channel"
},
"confirmUnsubscribe": "Unsubscribe from \"{{name}}\" on YouTube? This changes your real YouTube account. To just remove it from your feed, hide it instead.",
"confirmUnsubscribe": "Unsubscribe from \"{{name}}\" on YouTube? This changes your real YouTube account. To just remove it from your Feed, hide it instead.",
"confirmReset": "Re-fetch \"{{name}}\" from scratch? This re-runs its backfill (recent + full history) and spends some API quota.",
"searchPlaceholder": "Search channels…",
"blocked": {
"title": "Blocked channels",
"hint": "Their videos are hidden from your feed, Library, and live search.",
"hint": "Their videos are hidden from your Feed, Library, and live search.",
"empty": "No blocked channels. Block one from its channel page to hide all its videos without unsubscribing."
},
"hidden": {
"title": "Hidden channels",
"hint": "Their videos are kept out of your personal feed, but still appear in the shared Library.",
"empty": "No hidden channels. Hide a subscription (the eye icon on its row) to keep its uploads out of your personal feed without unsubscribing.",
"hint": "Their videos are kept out of your personal Feed, but still appear in the shared Library.",
"empty": "No hidden channels. Hide a subscription (the eye icon on its row) to keep its uploads out of your personal Feed without unsubscribing.",
"unhide": "Unhide"
}
}
+26 -4
View File
@@ -10,9 +10,11 @@
"backfill": "Backfill",
"shorts": "Shorts probe",
"batch": "Batch sizes",
"maintenance": "Maintenance",
"explore": "Channel explore",
"downloads": "Downloads",
"plex": "Plex",
"tmdb": "TMDB",
"audit": "Audit log"
},
"fields": {
@@ -76,6 +78,10 @@
"label": "Auto-tag title sample",
"hint": "Recent video titles sampled per channel for language detection."
},
"maintenance_revalidate_batch": {
"label": "Videos re-checked per run",
"hint": "How many least-recently-checked videos the maintenance job re-validates each run (1 API unit per 50 videos). Higher = a faster full cycle but more quota. Also adjustable on the Scheduler page."
},
"youtube_api_key": {
"label": "YouTube API key",
"hint": "Optional. Used for public reads (channels/videos) so shared backfill doesn't depend on a user's OAuth. Stored encrypted; write-only."
@@ -120,6 +126,10 @@
"label": "Default job limit / user",
"hint": "Max number of downloads a user may keep in their library."
},
"download_upload_max_bytes": {
"label": "Upload size cap per file (bytes)",
"hint": "Largest single file a user may upload to the download center. At least 1, so the cap is always on; the user's total storage is still bounded by their quota. Default 500 MiB."
},
"download_retention_days": {
"label": "Retention (days)",
"hint": "A download expires this many days after its last access; the cleanup job then removes it (once nothing references it) after the grace window."
@@ -140,9 +150,13 @@
"label": "SponsorBlock by default",
"hint": "Enable SponsorBlock (skip sponsor segments) for newly created custom download profiles."
},
"audit_retention_days": {
"label": "Retention (days)",
"hint": "How long admin audit-log entries are kept before the audit-log cleanup job prunes them. 0 = keep forever. Default 180."
},
"plex_enabled": {
"label": "Enable Plex",
"hint": "Expose the Plex library as a feed source with a rich player. Requires the Plex server to be reachable from this host and its media on a local mount (see the path map)."
"hint": "Expose the Plex library as a Feed source with a rich player. Requires the Plex server to be reachable from this host and its media on a local mount (see the path map)."
},
"plex_server_url": {
"label": "Plex server URL",
@@ -160,9 +174,9 @@
"label": "Exposed libraries",
"hint": "Comma-separated Plex section keys to expose. Empty = all sections. Use the library picker below after testing the connection."
},
"plex_sync_interval_min": {
"label": "Sync interval (minutes)",
"hint": "How often the catalog sync job mirrors Plex metadata."
"plex_overlay_map": {
"label": "Hungarian overlay map",
"hint": "Pairs a Hungarian Plex library with its English twin so titles show Hungarian metadata: comma-separated huSectionKey:enSectionKey pairs, e.g. 3:4,5:1. The Hungarian library isn't browsable on its own; its titles are matched to the English ones by id. Empty = no overlay."
},
"plex_max_transcodes": {
"label": "Max concurrent transcodes",
@@ -187,6 +201,14 @@
"plex_audio_loudnorm": {
"label": "Normalize audio loudness",
"hint": "EBU R128 loudness normalization of the stereo downmix, for films whose dialogue is a whisper and whose action is deafening. Applies to remux/transcode playback only (a direct-played file is served untouched). Measured on a 5.1 film: −27.6 → −16.6 LUFS with the loud/quiet range narrowed — everything gets about 11 dB louder, so turn your volume down after switching it on. Takes effect on the next playback."
},
"tmdb_api_key": {
"label": "TMDB API key",
"hint": "Optional. A TMDB v3 API key for enriching Plex titles (keywords, crew, budget, TMDb score, recommendations, artwork). Used server-side only; the data is served from the app's own database. Empty = the enrichment job does nothing. Stored encrypted; write-only."
},
"tmdb_language": {
"label": "TMDB metadata language",
"hint": "Language of the data fetched from TMDB (keywords, overviews), as a TMDB locale code such as en-US or hu-HU. Default en-US."
}
},
"save": "Save",
+7 -7
View File
@@ -11,17 +11,17 @@
"maxAge_one": "Last {{count}} day",
"maxAge_other": "Last {{count}} days"
},
"loading": "Loading feed…",
"loadError": "Couldn't load the feed.",
"emptyTitle": "Your feed is empty",
"emptyBody": "Connect your YouTube account to import your subscriptions and build your feed.",
"setUp": "Set up my feed",
"loading": "Loading Feed…",
"loadError": "Couldn't load the Feed.",
"emptyTitle": "Your Feed is empty",
"emptyBody": "Connect your YouTube account to import your subscriptions and build your Feed.",
"setUp": "Set up my Feed",
"browseShared": "Or just browse the shared library",
"demoEmptyTitle": "Nothing here in the demo",
"demoEmptyBody": "The demo account has no subscriptions of its own — but the whole shared library is yours to explore, filter and sort.",
"browseLibrary": "Browse the library",
"noMatches": "No videos match these filters.",
"channelScopeHint": "This channel isn't in your subscriptions feed — it may be hidden. Its videos are in the shared library.",
"channelScopeHint": "This channel isn't in your Feed — it may be hidden. Its videos are in the shared library.",
"viewInLibrary": "View in Library",
"videoCount_one": "{{formattedCount}} video",
"videoCount_other": "{{formattedCount}} videos",
@@ -69,7 +69,7 @@
"resultsFor": "YouTube results for “{{query}}”",
"quotaNote": "Live results — uses shared API quota",
"freeNote": "Live results — no search quota used",
"back": "Back to feed",
"back": "Back to Feed",
"searching": "Searching YouTube…",
"noResults": "No YouTube videos found.",
"error": "YouTube search failed.",
+1 -1
View File
@@ -1,5 +1,5 @@
{
"tagline": "A self-hosted, filterable feed of your YouTube subscriptions — sort, tag, and tune out the noise. Sign in with Google to get started; YouTube access is an optional, separate step you control.",
"tagline": "A self-hosted, filterable Feed of your YouTube subscriptions — sort, tag, and tune out the noise. Sign in with Google to get started; YouTube access is an optional, separate step you control.",
"signIn": "Sign in with Google",
"approved": "You're already approved — just sign in with Google above.",
"requested": "Thanks — your request is in. We'll email you when it's approved.",
@@ -5,7 +5,7 @@
"empty": "No notifications yet.",
"actionNeeded": "Action needed",
"dismiss": "Dismiss",
"findInFeed": "Find in feed",
"findInFeed": "Find in Feed",
"unhide": "Unhide",
"unwatch": "Unwatch",
"openInManager": "Channel manager",
+3 -3
View File
@@ -3,18 +3,18 @@
"consent": "Google will show a <0>\"Google hasn't verified this app\"</0> screen — that's expected, because Siftlode hasn't gone through Google's full review. It's safe to continue: click <1>Advanced</1> → <2>Go to Siftlode</2>. You can revoke access anytime from your <3>Google Account</3>.",
"read": {
"title": "Connect your YouTube subscriptions",
"body": "You're signed in. To build your feed, Siftlode needs <0>read-only</0> access to the channels you're subscribed to on YouTube. It never posts, deletes, or changes anything with this.",
"body": "You're signed in. To build your Feed, Siftlode needs <0>read-only</0> access to the channels you're subscribed to on YouTube. It never posts, deletes, or changes anything with this.",
"connect": "Connect (read-only)",
"skip": "Skip for now"
},
"importing": {
"title": "Building your feed…",
"title": "Building your Feed…",
"body": "Importing your YouTube subscriptions. Channels already in Siftlode show up instantly; any new ones fill in automatically in the background."
},
"write": {
"title": "You're connected",
"importFailed": "Your YouTube account is connected, but importing subscriptions failed — you can retry from Settings → Sync. ",
"feedReady": "Your feed is ready{{channels}}. Optionally, let Siftlode ",
"feedReady": "Your Feed is ready{{channels}}. Optionally, let Siftlode ",
"feedReadyChannels": " ({{count}} channels)",
"rationale": "<0>unsubscribe</0> from channels on YouTube for you — this needs an extra write permission. Skip it to stay read-only; you can always enable it later in Settings.",
"retryImport": "Retry import",
+1 -1
View File
@@ -15,7 +15,7 @@
"itemCount_other": "{{count}} videos",
"pickOne": "Pick a playlist on the left.",
"loading": "Loading…",
"empty": "This playlist is empty — add videos from the feed.",
"empty": "This playlist is empty — add videos from the Feed.",
"playAll": "Play all",
"delete": "Delete",
"rename": "Rename",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"navLabel": "Plex",
"backToFeed": "YouTube feed",
"backToFeed": "YouTube Feed",
"backToLibrary": "Back to library",
"collection": {
"playAll": "Play all",
+4 -3
View File
@@ -59,14 +59,15 @@
"skipped": "skipped (sync paused)"
},
"jobDesc": {
"rss_poll": "Checks each channel's RSS feed for new uploads (free, no quota). If it stops, new videos only appear later when a backfill pass catches them, so your feed lags behind YouTube.",
"rss_poll": "Checks each channel's RSS feed for new uploads (free, no quota). If it stops, new videos only appear later when a backfill pass catches them, so your Feed lags behind YouTube.",
"enrich": "Fetches video details (duration, views, live/Shorts flags) and re-checks live videos until they end. If it stops, videos stay without a duration/stats and ended livestreams stay stuck as “live”.",
"backfill": "Pulls in recent uploads, then the full back-catalogue for opted-in channels, within the shared quota. If it stops, older videos and full history don't fill in.",
"autotag": "Detects each channel's language/topics and applies automatic tags. If it stops, new channels get no auto tags, so language/topic filters miss them.",
"shorts": "Probes youtube.com/shorts to mark which videos are Shorts. If it stops, Shorts aren't separated from normal videos in the feed.",
"shorts": "Probes youtube.com/shorts to mark which videos are Shorts. If it stops, Shorts aren't separated from normal videos in the Feed.",
"subscriptions": "Re-imports every user's YouTube subscriptions. If it stops, channels subscribed to or dropped on YouTube aren't reflected here.",
"playlist_sync": "Mirrors each user's YouTube playlists into the app. If it stops, changes to your YouTube playlists don't show up locally.",
"maintenance": "Finds videos that can no longer be played (deleted, made private, abandoned premieres), hides them from the feed and removes them after a grace period; re-checks the least-recently-checked videos. If it stops, dead videos linger in the catalogue.",
"demo_reset": "Resets the shared demo account (its playlists and state) so every visitor starts from the same sandbox. Does nothing when there is no demo account. If it stops, the demo keeps whatever the last visitor left behind.",
"maintenance": "Finds videos that can no longer be played (deleted, made private, abandoned premieres), hides them from the Feed and removes them after a grace period; re-checks the least-recently-checked videos. If it stops, dead videos linger in the catalogue.",
"explore_cleanup": "Removes channels you explored but never subscribed to (and their videos) after a grace period, so curiosity browsing doesn't pile up in the catalogue. If it stops, abandoned explored channels linger.",
"download_gc": "Deletes downloaded files past their retention window, reclaims space no one is holding anymore, and warns owners before a shared file expires. If it stops, old downloads pile up and disk space isn't freed.",
"plex_sync": "Mirrors the enabled Plex libraries into the app's catalogue — the metadata behind Plex browsing, search, filters and the info pages (genres, cast, ratings, artwork). If it stops, newly added or changed Plex titles don't appear until the next sync.",
+4 -4
View File
@@ -54,9 +54,9 @@
"title": "Account",
"youtubeAccess": "YouTube access",
"youtubeAccessIntro": "Sign-in only shares your name and email. YouTube access is granted separately, below — each is optional and revocable anytime in your Google Account.",
"readTitle": "Read subscriptions (your feed)",
"readGrantedHint": "Granted. Siftlode reads the channels you follow to build your feed. It never changes your YouTube account with this.",
"readEnableHint": "Read-only access to your subscriptions — required to build your feed. Siftlode can't modify anything with it.",
"readTitle": "Read subscriptions (your Feed)",
"readGrantedHint": "Granted. Siftlode reads the channels you follow to build your Feed. It never changes your YouTube account with this.",
"readEnableHint": "Read-only access to your subscriptions — required to build your Feed. Siftlode can't modify anything with it.",
"writeTitle": "Unsubscribe (write)",
"writeGrantedHint": "Granted. You can unsubscribe from channels on YouTube from inside Siftlode. It only writes when you ask it to.",
"writeEnableHint": "Lets Siftlode unsubscribe from channels on YouTube for you. Optional — skip it to stay read-only.",
@@ -76,7 +76,7 @@
"title": "Google account",
"connected": "Connected",
"connectedHint": "Your Google account is linked. You can sign in with Google and grant YouTube access below.",
"connectHint": "Link a Google account to sign in with Google and to enable YouTube access for your feed.",
"connectHint": "Link a Google account to sign in with Google and to enable YouTube access for your Feed.",
"connect": "Connect Google",
"linked": "Google account linked.",
"conflict": "That Google account is already linked to a different Siftlode account.",
+1 -1
View File
@@ -20,7 +20,7 @@
"channels": "Channels",
"channelsHint": "How many channels you're subscribed to.",
"recentSynced": "Recent synced",
"recentSyncedHint": "Channels whose recent uploads (~last year) are already in the local database, so they show in your feed.",
"recentSyncedHint": "Channels whose recent uploads (~last year) are already in the local database, so they show in your Feed.",
"fullHistory": "Full history",
"fullHistoryHint": "Channels whose entire back-catalog has been fetched, out of the ones you've requested full history for (opt in per channel on the Channels page).",
"fullHistoryEta": "Full history ETA",
+5 -5
View File
@@ -1,6 +1,6 @@
{
"hero": {
"title": "Your subscriptions, in one quiet feed.",
"title": "Your subscriptions, in one quiet Feed.",
"subtitle": "Every upload from the channels you follow, in one place you can actually filter, search and sort. No algorithm deciding what you see — and no Shorts or livestreams unless you want them.",
"trust": "Self-hosted and private: your data stays on your own server. Sign in with email, or continue with Google."
},
@@ -10,7 +10,7 @@
"link": "View the source"
},
"preview": {
"feed": "The feed — filter by channel, language, topic, length and date.",
"feed": "The Feed — filter by channel, language, topic, length and date.",
"channels": "Channel manager",
"playlists": "Playlists",
"player": "In-app player",
@@ -26,11 +26,11 @@
},
"neverMiss": {
"title": "Never miss an upload",
"body": "Every video from every channel lands in one feed; Shorts and livestreams stay tucked away by default."
"body": "Every video from every channel lands in one Feed; Shorts and livestreams stay tucked away by default."
},
"search": {
"title": "Search all of YouTube",
"body": "Search right from your feed — results play, save and add to playlists like any other video."
"body": "Search right from your Feed — results play, save and add to playlists like any other video."
},
"playlists": {
"title": "Playlists that sync both ways",
@@ -46,7 +46,7 @@
},
"plex": {
"title": "Your Plex library too",
"body": "Browse and play what your Plex server already holds, right alongside your feed."
"body": "Browse and play what your Plex server already holds, right alongside your Feed."
},
"private": {
"title": "Self-hosted and private",
+4 -4
View File
@@ -22,15 +22,15 @@
"block": "Csatorna tiltása",
"unblock": "Tiltás feloldása",
"blockedBadge": "Tiltva",
"hide": "Elrejtés a hírfolyamból",
"hide": "Elrejtés a Feedből",
"unhide": "Megjelenítés",
"hideHint": "A csatorna videóinak elrejtése a hírfolyamodból. Feliratkozva marad; ez nem iratkoztat le a YouTube-on.",
"hiddenHint": "Elrejtve — a csatorna videói nem jelennek meg a hírfolyamodban. Kattints, hogy újra láthatóak legyenek.",
"hideHint": "A csatorna videóinak elrejtése a Feededből. Feliratkozva marad; ez nem iratkoztat le a YouTube-on.",
"hiddenHint": "Elrejtve — a csatorna videói nem jelennek meg a Feededben. Kattints, hogy újra láthatóak legyenek.",
"country": "Ország",
"language": "Nyelv",
"topics": "Témák",
"keywords": "Kulcsszavak",
"showInFeed": "Mutasd a hírfolyamomban",
"showInFeed": "Mutasd a Feedemben",
"fullHistory": "Teljes előzmény",
"fullHistoryLoading": "Előzmény letöltése…",
"fullHistoryProgress": "Előzmény letöltése… {{stored}} / {{total}}",
+14 -14
View File
@@ -23,14 +23,14 @@
},
"cardsPerPage": "Kártya / oldal",
"discovery": {
"intro": "Csatornák, amelyek megjelennek a lejátszási listáidban, de még nem iratkoztál fel rájuk. Iratkozz fel, hogy kövesd őket — az új feltöltéseik megjelennek a hírfolyamodban (a feliratkozás kevés YouTube-kvótát használ; a meglévő videókat nem tölti le újra).",
"intro": "Csatornák, amelyek megjelennek a lejátszási listáidban, de még nem iratkoztál fel rájuk. Iratkozz fel, hogy kövesd őket — az új feltöltéseik megjelennek a Feededben (a feliratkozás kevés YouTube-kvótát használ; a meglévő videókat nem tölti le újra).",
"loading": "Csatornák keresése…",
"empty": "Nincs új csatorna — a lejátszási listáidban minden csatornát már követsz.",
"subscribe": "Feliratkozás",
"subscribeHint": "Feliratkozás erre a csatornára a YouTube-on (a valódi fiókodat módosítja; kevés API-kvótát használ). A videói a következő háttér-szinkronnál érkeznek.",
"needWriteHint": "Engedélyezd a lejátszási listák szerkesztését a Beállításokban a feliratkozáshoz.",
"subscribedTitle": "Feliratkozva a YouTube-on",
"subscribedBody": "Mostantól követed: {{name}} — az új feltöltései megjelennek a hírfolyamodban.",
"subscribedBody": "Mostantól követed: {{name}} — az új feltöltései megjelennek a Feededben.",
"subscribeFailed": "A feliratkozás sikertelen",
"confirmSubscribe": "Feliratkozol erre a YouTube-on: „{{name}}”? Ez módosítja a valódi YouTube-fiókodat és kevés API-kvótát használ.",
"cols": {
@@ -48,7 +48,7 @@
"tags": {
"label": "Címkék",
"add": "Címke",
"yourTagsHint": "Saját személyes címkéid. Csatold őket az alábbi csatornákhoz, majd szűrd a hírfolyamot címke szerint az oldalsávból. (Az automatikus nyelvi/téma címkéktől függetlenül.)",
"yourTagsHint": "Saját személyes címkéid. Csatold őket az alábbi csatornákhoz, majd szűrd a Feedet címke szerint az oldalsávból. (Az automatikus nyelvi/téma címkéktől függetlenül.)",
"noneYet": "Még nincs címke — hozz létre egyet lent.",
"create": "Címke létrehozása",
"createPlaceholder": "Új címke…",
@@ -93,7 +93,7 @@
"channels": "Csatorna",
"channelsHint": "Csatornák, amelyekre feliratkoztál.",
"recentSynced": "Legutóbbi szinkronizálva",
"recentSyncedHint": "Csatornák, amelyek legutóbbi feltöltései a helyi adatbázisban vannak, és megjelennek a hírfolyamodban.",
"recentSyncedHint": "Csatornák, amelyek legutóbbi feltöltései a helyi adatbázisban vannak, és megjelennek a Feededben.",
"fullHistory": "Teljes előzmény",
"fullHistoryHint": "Csatornák, amelyek teljes archívuma le van töltve, azok közül, amelyekhez teljes előzményt kértél.",
"left": "hátra",
@@ -106,8 +106,8 @@
"row": {
"openOnYouTube": "Megnyitás a YouTube-on",
"editTags": "Címkék szerkesztése",
"filterFeedByTag": "Hírfolyam szűrése: „{{name}}”",
"priorityHint": "A te rangsorod ehhez a csatornához. Rendezd a hírfolyamot „Csatorna prioritás” szerint, hogy a magasabb prioritású csatornák felülre kerüljenek.",
"filterFeedByTag": "Feed szűrése: „{{name}}”",
"priorityHint": "A te rangsorod ehhez a csatornához. Rendezd a Feedet „Csatorna prioritás” szerint, hogy a magasabb prioritású csatornák felülre kerüljenek.",
"raisePriority": "Prioritás növelése",
"lowerPriority": "Prioritás csökkentése",
"editPriority": "Kattints a prioritás beírásához (negatív is lehet)",
@@ -120,10 +120,10 @@
"resetHint": "Admin: a csatorna teljes újraletöltése a nulláról (legutóbbi + teljes archívum), a jelenlegi szinkron-állapottól függetlenül.",
"queuedRequestedHint": "Teljes előzmény kérve — a csatorna teljes archívuma letöltődik, ahogy a megosztott kvóta engedi. Kattints a kérés visszavonásához.",
"queuedByOtherHint": "Egy másik feliratkozó már kérte ennek a csatornának a teljes előzményét, így a teljes archívuma már úton van mindenkihez — itt nincs teendőd.",
"hiddenHint": "Elrejtve — a csatorna videói nem jelennek meg a hírfolyamodban. Kattints, hogy újra láthatóak legyenek.",
"hideHint": "A csatorna videóinak elrejtése a hírfolyamodból. Feliratkozva marad; ez nem iratkoztat le a YouTube-on.",
"hiddenHint": "Elrejtve — a csatorna videói nem jelennek meg a Feededben. Kattints, hogy újra láthatóak legyenek.",
"hideHint": "A csatorna videóinak elrejtése a Feededből. Feliratkozva marad; ez nem iratkoztat le a YouTube-on.",
"unhide": "Megjelenítés",
"hideFromFeed": "Elrejtés a hírfolyamból",
"hideFromFeed": "Elrejtés a Feedből",
"unsubscribeHint": "Leiratkozás erről a csatornáról a YouTube-on (megváltoztatja a valódi fiókodat). Csak olvasható módban ez rejtve van — használd helyette az Elrejtést.",
"unsubscribeOnYoutube": "Leiratkozás a YouTube-on",
"thisChannel": "Ez a csatorna"
@@ -137,22 +137,22 @@
"fullHistoryFailed": "Nem sikerült teljes előzményt kérni",
"needYouTube": "Ehhez csatlakoztasd a YouTube-fiókod.",
"connect": "Csatlakoztatás",
"filteredByTag": "Hírfolyam szűrve címkére: {{name}}",
"filteredByTag": "Feed szűrve címkére: {{name}}",
"resetDone": "{{name}} resetelve — újraletöltés folyamatban",
"resetFailed": "Nem sikerült resetelni a csatornát"
},
"confirmUnsubscribe": "Leiratkozol a(z) „{{name}}” csatornáról a YouTube-on? Ez megváltoztatja a valódi YouTube-fiókodat. Ha csak a hírfolyamodból szeretnéd eltávolítani, inkább rejtsd el.",
"confirmUnsubscribe": "Leiratkozol a(z) „{{name}}” csatornáról a YouTube-on? Ez megváltoztatja a valódi YouTube-fiókodat. Ha csak a Feededből szeretnéd eltávolítani, inkább rejtsd el.",
"confirmReset": "Újraletöltöd a(z) „{{name}}” csatornát a nulláról? Ez újrafuttatja a backfillt (legutóbbi + teljes előzmény), és némi API-kvótát fogyaszt.",
"searchPlaceholder": "Csatornák keresése…",
"blocked": {
"title": "Tiltott csatornák",
"hint": "A videóik el vannak rejtve a feededből, a Library-ből és a YouTube-keresésből.",
"hint": "A videóik el vannak rejtve a Feededből, a Library-ből és a YouTube-keresésből.",
"empty": "Nincs tiltott csatorna. Egy csatorna oldaláról tilthatsz le egyet, hogy minden videóját elrejtsd leiratkozás nélkül."
},
"hidden": {
"title": "Elrejtett csatornák",
"hint": "A videóik kimaradnak a személyes feededből, de a megosztott Library-ben továbbra is megjelennek.",
"empty": "Nincs elrejtett csatorna. Egy feliratkozást a sorában (a szem ikonnal) rejthetsz el, hogy kimaradjon a személyes feededből leiratkozás nélkül.",
"hint": "A videóik kimaradnak a személyes Feededből, de a megosztott Library-ben továbbra is megjelennek.",
"empty": "Nincs elrejtett csatorna. Egy feliratkozást a sorában (a szem ikonnal) rejthetsz el, hogy kimaradjon a személyes Feededből leiratkozás nélkül.",
"unhide": "Felfedés"
}
}
+26 -4
View File
@@ -10,9 +10,11 @@
"backfill": "Letöltés (backfill)",
"shorts": "Shorts-vizsgálat",
"batch": "Kötegméretek",
"maintenance": "Karbantartás",
"explore": "Csatorna-felfedezés",
"downloads": "Letöltések",
"plex": "Plex",
"tmdb": "TMDB",
"audit": "Naplózás"
},
"fields": {
@@ -76,12 +78,16 @@
"label": "Auto-címke címmintavétel",
"hint": "Csatornánként ennyi friss videócímet mintázunk a nyelvfelismeréshez."
},
"maintenance_revalidate_batch": {
"label": "Futásonként újraellenőrzött videók",
"hint": "Hány, legrégebben ellenőrzött videót ellenőriz újra a karbantartó feladat futásonként (50 videónként 1 API-egység). Magasabb = gyorsabb teljes ciklus, de több kvóta. Az Ütemező oldalon is állítható."
},
"youtube_api_key": {
"label": "YouTube API-kulcs",
"hint": "Opcionális. Publikus olvasásokhoz (csatornák/videók), hogy a megosztott letöltés ne függjön egy felhasználó OAuth-jától. Titkosítva tárolva; csak írható."
},
"youtube_api_proxy": {
"label": "YouTube API egress proxy",
"label": "YouTube API kimenő proxy",
"hint": "Opcionális. HTTP(S) proxy URL a kimenő YouTube API-hívásokhoz — fix IP-jű hoston átengedve egy IP-korlátozott kulcs dinamikus IP-jű szerverről is működik. Üresen = közvetlen."
},
"google_client_id": {
@@ -120,6 +126,10 @@
"label": "Alap. feladatkorlát / felhasználó",
"hint": "Legfeljebb hány letöltést tarthat meg egy felhasználó a könyvtárában."
},
"download_upload_max_bytes": {
"label": "Feltöltési méretkorlát fájlonként (bájt)",
"hint": "A letöltési központba feltölthető legnagyobb egyedi fájl. Legalább 1, így a korlát mindig él; a felhasználó teljes tárhelyét továbbra is a kvótája korlátozza. Alapérték 500 MiB."
},
"download_retention_days": {
"label": "Megőrzés (nap)",
"hint": "Egy letöltés az utolsó hozzáféréstől számítva ennyi nap múlva jár le; a takarító feladat ezután (ha semmi nem hivatkozik rá) a türelmi idő letelte után törli."
@@ -140,6 +150,10 @@
"label": "SponsorBlock alapból",
"hint": "SponsorBlock (szponzorszakaszok átugrása) bekapcsolása az újonnan létrehozott egyéni letöltési profilokhoz."
},
"audit_retention_days": {
"label": "Megőrzés (nap)",
"hint": "Meddig maradnak meg az admin-naplóbejegyzések, mielőtt a napló-karbantartó feladat törli őket. 0 = örökre. Alapérték 180."
},
"plex_enabled": {
"label": "Plex engedélyezése",
"hint": "A Plex-könyvtár megjelenítése forrásként, gazdag lejátszóval. Ehhez a Plex-szervernek elérhetőnek kell lennie erről a gépről, a média pedig helyi mounton (lásd az útvonal-leképezést)."
@@ -160,9 +174,9 @@
"label": "Megjelenített könyvtárak",
"hint": "Vesszővel elválasztott Plex-szekciókulcsok. Üres = minden szekció. Kapcsolat-teszt után használd az alábbi könyvtár-választót."
},
"plex_sync_interval_min": {
"label": "Szinkron gyakorisága (perc)",
"hint": "Milyen gyakran tükrözi a katalógus-szinkron a Plex metaadatait."
"plex_overlay_map": {
"label": "Magyar metaadat-átfedés",
"hint": "Egy magyar Plex-könyvtárat párosít az angol ikerpárjával, hogy a címek magyar metaadattal jelenjenek meg: vesszővel elválasztott huSectionKey:enSectionKey párok, pl. 3:4,5:1. A magyar könyvtár önmagában nem böngészhető; a címeit azonosító alapján párosítjuk az angolokhoz. Üres = nincs átfedés."
},
"plex_max_transcodes": {
"label": "Max párhuzamos transcode",
@@ -187,6 +201,14 @@
"plex_audio_loudnorm": {
"label": "Hangerő normalizálása",
"hint": "A sztereó lekeverés EBU R128 szerinti hangerő-normalizálása — azokhoz a filmekhez, ahol a párbeszéd suttogás, az akció meg fülsiketítő. Csak remux/transcode lejátszásnál él (a direktben játszott fájl érintetlen marad). Egy 5.1-es filmen mérve: −27,6 → −16,6 LUFS, szűkebb halk/hangos különbséggel — minden kb. 11 dB-lel hangosabb lesz, szóval bekapcsolás után vedd lejjebb a hangerőt. A következő lejátszástól érvényes."
},
"tmdb_api_key": {
"label": "TMDB API-kulcs",
"hint": "Opcionális. TMDB v3 API-kulcs a Plex-címek dúsításához (kulcsszavak, stáb, költségvetés, TMDb-pontszám, ajánlások, artwork). Csak szerveroldalon használjuk; az adatokat az app saját adatbázisából szolgáljuk ki. Üres = a dúsító feladat nem csinál semmit. Titkosítva tárolva; csak írható."
},
"tmdb_language": {
"label": "TMDB metaadat-nyelv",
"hint": "A TMDB-ből lekért adatok (kulcsszavak, leírások) nyelve, TMDB nyelvkóddal, pl. en-US vagy hu-HU. Alapérték en-US."
}
},
"save": "Mentés",
+7 -7
View File
@@ -11,17 +11,17 @@
"maxAge_one": "Utolsó {{count}} nap",
"maxAge_other": "Utolsó {{count}} nap"
},
"loading": "Hírfolyam betöltése…",
"loadError": "Nem sikerült betölteni a hírfolyamot.",
"emptyTitle": "A hírfolyamod üres",
"emptyBody": "Kösd össze a YouTube-fiókodat, hogy importáld a feliratkozásaidat, és felépítsd a hírfolyamodat.",
"setUp": "Hírfolyam beállítása",
"loading": "Feed betöltése…",
"loadError": "Nem sikerült betölteni a Feedet.",
"emptyTitle": "A Feeded üres",
"emptyBody": "Kösd össze a YouTube-fiókodat, hogy importáld a feliratkozásaidat, és felépítsd a Feededet.",
"setUp": "Feed beállítása",
"browseShared": "Vagy csak böngészd a közös könyvtárat",
"demoEmptyTitle": "Itt a demóban nincs semmi",
"demoEmptyBody": "A demo fióknak nincsenek saját feliratkozásai — de a teljes közös könyvtár a tiéd: böngészd, szűrd és rendezd kedvedre.",
"browseLibrary": "Könyvtár böngészése",
"noMatches": "Egy videó sem felel meg ezeknek a szűrőknek.",
"channelScopeHint": "Ez a csatorna nincs a feliratkozásaid feedjében – lehet, hogy el van rejtve. A videói a közös könyvtárban vannak.",
"channelScopeHint": "Ez a csatorna nincs a Feededben – lehet, hogy el van rejtve. A videói a közös könyvtárban vannak.",
"viewInLibrary": "Megnézés a Könyvtárban",
"videoCount_one": "{{formattedCount}} videó",
"videoCount_other": "{{formattedCount}} videó",
@@ -69,7 +69,7 @@
"resultsFor": "YouTube találatok erre: „{{query}}”",
"quotaNote": "Élő találatok — a közös API-kvótát fogyasztja",
"freeNote": "Élő találatok — nem fogyaszt keresési kvótát",
"back": "Vissza a hírfolyamhoz",
"back": "Vissza a Feedhez",
"searching": "Keresés a YouTube-on…",
"noResults": "Nincs YouTube-találat.",
"error": "A YouTube-keresés nem sikerült.",
+3 -3
View File
@@ -1,5 +1,5 @@
{
"feed": "Hírfolyam",
"feed": "Feed",
"searchPlaceholder": "Keresés a feliratkozásaid között…",
"clearSearch": "Keresés törlése",
"searchYoutube": "Keresés a YouTube-on",
@@ -14,7 +14,7 @@
"configuration": "Konfiguráció",
"users": "Felhasználók",
"scope": {
"label": "Hírfolyam forrása",
"label": "Feed forrása",
"my": "Sajátom",
"myTip": "Csak a saját feliratkozásaid videói",
"all": "Könyvtár",
@@ -22,7 +22,7 @@
},
"account": {
"admin": "Adminisztrátor",
"feed": "Hírfolyam",
"feed": "Feed",
"channels": "Csatornák",
"playlists": "Lejátszási listák",
"stats": "Statisztika",
+1 -1
View File
@@ -22,7 +22,7 @@
"browseBack": "Vissza egy szintet",
"closePlayer": "Lejátszó bezárása",
"palette": "Ugrás-paletta megnyitása",
"goFeed": "Ugrás: Hírfolyam",
"goFeed": "Ugrás: Feed",
"goChannels": "Ugrás: Csatornák",
"goPlaylists": "Ugrás: Lejátszási listák",
"goPlex": "Ugrás: Plex",
+1 -1
View File
@@ -1,5 +1,5 @@
{
"tagline": "Saját üzemeltetésű, szűrhető hírfolyam a YouTube-feliratkozásaidból — rendezd, címkézd, és szűrd ki a zajt. A kezdéshez jelentkezz be Google-fiókkal; a YouTube-hozzáférés külön, általad vezérelt lépés.",
"tagline": "Saját üzemeltetésű, szűrhető Feed a YouTube-feliratkozásaidból — rendezd, címkézd, és szűrd ki a zajt. A kezdéshez jelentkezz be Google-fiókkal; a YouTube-hozzáférés külön, általad vezérelt lépés.",
"signIn": "Bejelentkezés Google-fiókkal",
"approved": "Már jóvá vagy hagyva — csak jelentkezz be a fenti Google-gombbal.",
"requested": "Köszönjük — a kérésedet rögzítettük. E-mailben értesítünk, ha jóváhagytuk.",
@@ -5,7 +5,7 @@
"empty": "Még nincs értesítés.",
"actionNeeded": "Teendő",
"dismiss": "Elvetés",
"findInFeed": "Keresés a hírfolyamban",
"findInFeed": "Keresés a Feedben",
"unhide": "Megjelenítés",
"unwatch": "Megtekintés visszavonása",
"openInManager": "Csatornakezelő",
+3 -3
View File
@@ -3,18 +3,18 @@
"consent": "A Google megjelenít egy <0>„A Google nem ellenőrizte ezt az alkalmazást”</0> képernyőt — ez várható, mert a Siftlode nem ment át a Google teljes ellenőrzésén. Biztonságos folytatni: kattints a <1>Speciális</1> → <2>Ugrás a Siftlode-ra</2> lehetőségre. A hozzáférést bármikor visszavonhatod a <3>Google-fiókodban</3>.",
"read": {
"title": "Csatlakoztasd a YouTube-feliratkozásaidat",
"body": "Be vagy jelentkezve. A hírfolyamod felépítéséhez a Siftlode-nak <0>csak olvasható</0> hozzáférésre van szüksége azokhoz a csatornákhoz, amelyekre a YouTube-on feliratkoztál. Ezzel soha nem tesz közzé, nem töröl és nem módosít semmit.",
"body": "Be vagy jelentkezve. A Feeded felépítéséhez a Siftlode-nak <0>csak olvasható</0> hozzáférésre van szüksége azokhoz a csatornákhoz, amelyekre a YouTube-on feliratkoztál. Ezzel soha nem tesz közzé, nem töröl és nem módosít semmit.",
"connect": "Csatlakozás (csak olvasható)",
"skip": "Most kihagyom"
},
"importing": {
"title": "A hírfolyamod felépítése…",
"title": "A Feeded felépítése…",
"body": "A YouTube-feliratkozásaid importálása folyamatban. A Siftlode-ban már meglévő csatornák azonnal megjelennek; az újak automatikusan feltöltődnek a háttérben."
},
"write": {
"title": "Csatlakoztatva",
"importFailed": "A YouTube-fiókod csatlakoztatva, de a feliratkozások importálása sikertelen volt — újrapróbálhatod a Beállítások → Szinkronizálás menüben. ",
"feedReady": "A hírfolyamod készen áll{{channels}}. Választhatóan engedélyezheted, hogy a Siftlode ",
"feedReady": "A Feeded készen áll{{channels}}. Választhatóan engedélyezheted, hogy a Siftlode ",
"feedReadyChannels": " ({{count}} csatorna)",
"rationale": "<0>leiratkozzon</0> helyetted a YouTube-csatornákról — ehhez egy plusz írási engedély szükséges. Hagyd ki, ha csak olvasható módban szeretnél maradni; ezt később bármikor engedélyezheted a Beállításokban.",
"retryImport": "Importálás újra",
+1 -1
View File
@@ -15,7 +15,7 @@
"itemCount_other": "{{count}} videó",
"pickOne": "Válassz egy listát a bal oldalon.",
"loading": "Betöltés…",
"empty": "Ez a lista üres — adj hozzá videókat a hírfolyamból.",
"empty": "Ez a lista üres — adj hozzá videókat a Feedből.",
"playAll": "Összes lejátszása",
"delete": "Törlés",
"rename": "Átnevezés",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"navLabel": "Plex",
"backToFeed": "YouTube feed",
"backToFeed": "YouTube Feed",
"backToLibrary": "Vissza a könyvtárhoz",
"collection": {
"playAll": "Összes lejátszása",
+4 -3
View File
@@ -59,14 +59,15 @@
"skipped": "kihagyva (szinkron szüneteltetve)"
},
"jobDesc": {
"rss_poll": "Az egyes csatornák RSS-feedjét nézi új feltöltésekért (ingyenes, nem fogyaszt kvótát). Ha leáll, az új videók csak később jelennek meg, amikor egy backfill behúzza őket — így a hírfolyam lemarad a YouTube mögött.",
"rss_poll": "Az egyes csatornák RSS-feedjét nézi új feltöltésekért (ingyenes, nem fogyaszt kvótát). Ha leáll, az új videók csak később jelennek meg, amikor egy backfill behúzza őket — így a Feed lemarad a YouTube mögött.",
"enrich": "Lekéri a videók adatait (hossz, megtekintés, élő/Shorts jelzők), és frissíti az élő videókat, amíg véget nem érnek. Ha leáll, a videók hossz/statisztika nélkül maradnak, a véget ért adások pedig „élő” állapotban ragadnak.",
"backfill": "Behúzza a friss feltöltéseket, majd a kijelölt csatornák teljes előzményét, a közös kvótán belül. Ha leáll, a régebbi videók és a teljes előzmény nem töltődik fel.",
"autotag": "Felismeri a csatornák nyelvét/témáit, és automatikus címkéket ad. Ha leáll, az új csatornák címke nélkül maradnak, így a nyelv/téma szűrők kihagyják őket.",
"shorts": "A youtube.com/shorts próbával jelöli, mely videók Shorts-ok. Ha leáll, a Shorts-ok nem különülnek el a normál videóktól a hírfolyamban.",
"shorts": "A youtube.com/shorts próbával jelöli, mely videók Shorts-ok. Ha leáll, a Shorts-ok nem különülnek el a normál videóktól a Feedben.",
"subscriptions": "Újraimportálja minden felhasználó YouTube-feliratkozásait. Ha leáll, a YouTube-on felvett vagy törölt feliratkozások nem tükröződnek itt.",
"playlist_sync": "Tükrözi a felhasználók YouTube lejátszási listáit az appba. Ha leáll, a YouTube-listák változásai nem jelennek meg lokálisan.",
"maintenance": "Megkeresi a már sehol le nem játszható videókat (törölt, priváttá tett, elhagyott premier), elrejti őket a hírfolyamból, és türelmi idő után eltávolítja; újraellenőrzi a legrégebben ellenőrzött videókat. Ha leáll, a halott videók a katalógusban maradnak.",
"demo_reset": "Visszaállítja a közös demó fiókot (lejátszási listák és állapot), hogy minden látogató ugyanabból a homokozóból induljon. Ha nincs demó fiók, nem csinál semmit. Ha leáll, a demóban az marad, amit az előző látogató hagyott benne.",
"maintenance": "Megkeresi a már sehol le nem játszható videókat (törölt, priváttá tett, elhagyott premier), elrejti őket a Feedből, és türelmi idő után eltávolítja; újraellenőrzi a legrégebben ellenőrzött videókat. Ha leáll, a halott videók a katalógusban maradnak.",
"explore_cleanup": "A türelmi idő után eltávolítja a felfedezett, de nem követett csatornákat (és videóikat), hogy a kíváncsiság-böngészés ne halmozódjon a katalógusban. Ha leáll, az elhagyott felfedezett csatornák megmaradnak.",
"download_gc": "Törli a megőrzési időn túli letöltéseket, felszabadítja a már senki által nem használt helyet, és a lejárat előtt figyelmezteti a tulajdonost. Ha leáll, a régi letöltések felhalmozódnak, és a tárhely nem szabadul fel.",
"plex_sync": "Tükrözi az engedélyezett Plex-könyvtárakat az app katalógusába — ez adja a Plex böngészés, keresés, szűrők és infó-oldalak metaadatait (műfaj, szereplők, pontszám, borítók). Ha leáll, az újonnan hozzáadott vagy módosított Plex-címek csak a következő sync után jelennek meg.",
+4 -4
View File
@@ -54,9 +54,9 @@
"title": "Fiók",
"youtubeAccess": "YouTube-hozzáférés",
"youtubeAccessIntro": "A bejelentkezés csak a nevedet és e-mail-címedet osztja meg. A YouTube-hozzáférést külön kell megadni, lent — mindegyik opcionális és bármikor visszavonható a Google-fiókodban.",
"readTitle": "Feliratkozások olvasása (a hírfolyamod)",
"readGrantedHint": "Megadva. A Siftlode beolvassa az általad követett csatornákat, hogy felépítse a hírfolyamodat. Ezzel sosem módosítja a YouTube-fiókodat.",
"readEnableHint": "Csak olvasási hozzáférés a feliratkozásaidhoz — a hírfolyam felépítéséhez szükséges. A Siftlode ezzel semmit sem tud módosítani.",
"readTitle": "Feliratkozások olvasása (a Feeded)",
"readGrantedHint": "Megadva. A Siftlode beolvassa az általad követett csatornákat, hogy felépítse a Feededet. Ezzel sosem módosítja a YouTube-fiókodat.",
"readEnableHint": "Csak olvasási hozzáférés a feliratkozásaidhoz — a Feed felépítéséhez szükséges. A Siftlode ezzel semmit sem tud módosítani.",
"writeTitle": "Leiratkozás (írás)",
"writeGrantedHint": "Megadva. A Siftlode-on belülről leiratkozhatsz csatornákról a YouTube-on. Csak akkor ír, amikor te kéred.",
"writeEnableHint": "Lehetővé teszi, hogy a Siftlode leiratkozzon helyetted csatornákról a YouTube-on. Opcionális — hagyd ki, ha csak olvasási módban szeretnél maradni.",
@@ -76,7 +76,7 @@
"title": "Google-fiók",
"connected": "Csatlakoztatva",
"connectedHint": "A Google-fiókod össze van kapcsolva. Bejelentkezhetsz Google-lel, és alább engedélyezheted a YouTube-hozzáférést.",
"connectHint": "Kapcsolj össze egy Google-fiókot, hogy Google-lel jelentkezhess be, és engedélyezhesd a YouTube-hozzáférést a feededhez.",
"connectHint": "Kapcsolj össze egy Google-fiókot, hogy Google-lel jelentkezhess be, és engedélyezhesd a YouTube-hozzáférést a Feededhez.",
"connect": "Google összekapcsolása",
"linked": "Google-fiók összekapcsolva.",
"conflict": "Ez a Google-fiók már egy másik Siftlode-fiókhoz van kapcsolva.",
+1 -1
View File
@@ -20,7 +20,7 @@
"channels": "Csatornák",
"channelsHint": "Hány csatornára vagy feliratkozva.",
"recentSynced": "Friss szinkronizálva",
"recentSyncedHint": "Azok a csatornák, amelyek legutóbbi feltöltései (~az elmúlt év) már a helyi adatbázisban vannak, így megjelennek a hírfolyamodban.",
"recentSyncedHint": "Azok a csatornák, amelyek legutóbbi feltöltései (~az elmúlt év) már a helyi adatbázisban vannak, így megjelennek a Feededben.",
"fullHistory": "Teljes előzmény",
"fullHistoryHint": "Azok a csatornák, amelyek teljes archívuma letöltődött, azokból, amelyekhez teljes előzményt kértél (csatornánként választható a Csatornák oldalon).",
"fullHistoryEta": "Teljes előzmény becsült ideje",
+5 -5
View File
@@ -1,6 +1,6 @@
{
"hero": {
"title": "A feliratkozásaid egyetlen nyugodt hírfolyamban.",
"title": "A feliratkozásaid egyetlen nyugodt Feedben.",
"subtitle": "Minden feltöltés a követett csatornáidról egy helyen, amit tényleg szűrhetsz, kereshetsz és rendezhetsz. Nincs algoritmus, ami eldönti, mit látsz — és nincs Shorts vagy élő adás, hacsak nem kéred.",
"trust": "Self-hosted és privát: az adataid a saját szervereden maradnak. Lépj be e-maillel, vagy folytasd Google-fiókkal."
},
@@ -10,7 +10,7 @@
"link": "Forráskód megtekintése"
},
"preview": {
"feed": "A hírfolyam — szűrés csatorna, nyelv, téma, hossz és dátum szerint.",
"feed": "A Feed — szűrés csatorna, nyelv, téma, hossz és dátum szerint.",
"channels": "Csatorna-kezelő",
"playlists": "Lejátszási listák",
"player": "Beépített lejátszó",
@@ -26,11 +26,11 @@
},
"neverMiss": {
"title": "Egy feltöltés se vesszen el",
"body": "Minden videó minden csatornádról egyetlen hírfolyamba kerül; a Shorts és az élő adások alapból félrerakva."
"body": "Minden videó minden csatornádról egyetlen Feedbe kerül; a Shorts és az élő adások alapból félrerakva."
},
"search": {
"title": "Keress az egész YouTube-on",
"body": "Keress közvetlenül a hírfolyamból — a találatok ugyanúgy lejátszhatók, menthetők és listához adhatók, mint bármely más videó."
"body": "Keress közvetlenül a Feedből — a találatok ugyanúgy lejátszhatók, menthetők és listához adhatók, mint bármely más videó."
},
"playlists": {
"title": "Kétirányban szinkronizált listák",
@@ -46,7 +46,7 @@
},
"plex": {
"title": "A Plex-könyvtárad is",
"body": "Böngészd és játszd le, amit a Plex-szervered már tárol, a hírfolyamod mellett."
"body": "Böngészd és játszd le, amit a Plex-szervered már tárol, a Feeded mellett."
},
"private": {
"title": "Self-hosted és privát",
@@ -0,0 +1,106 @@
// @vitest-environment jsdom
//
// Back hands focus to the list item a view was left from (E11 S5): opening a Plex title from Home's
// rail switches the page, which unmounts the rail, so focus used to come back to <body>. The position is
// stamped onto the entry being left (`rememberNavPosition`) and a popstate landing on it focuses the item
// once the page's lists are back. A landing is simulated the way the browser delivers one: the entry's
// state is current and a popstate carries it. Its own file: history.ts and listNav keep module state.
import { useRef } from "react";
import { afterEach, describe, expect, it } from "vitest";
import { act, cleanup, render, waitFor } from "@testing-library/react";
import { rememberNavPosition } from "./history";
import { navItemAttrs, useListNav } from "./listNav";
function List({ id, count }: { id: string; count: number }) {
const ref = useRef<HTMLDivElement>(null);
useListNav(ref, { enabled: true });
return (
<div ref={ref}>
{Array.from({ length: count }, (_, i) => (
<div key={i} data-testid={`${id}${i}`} {...navItemAttrs(i)}>
{id}
{i}
</div>
))}
</div>
);
}
const Page = () => (
<>
<List id="a" count={2} />
<List id="b" count={3} />
</>
);
const press = (key: string) =>
act(() => {
(document.activeElement ?? document.body).dispatchEvent(
new KeyboardEvent("keydown", { key, bubbles: true, cancelable: true }),
);
});
const focused = () => (document.activeElement as HTMLElement | null)?.dataset.testid;
const sleep = (ms: number) => act(() => new Promise((r) => setTimeout(r, ms)));
/** Walk `presses` items in, stamp the entry and open another view over it; returns the stamped state. */
function leaveFrom(item: string, presses: number): unknown {
for (let i = 0; i < presses; i++) press("j");
expect(focused()).toBe(item);
const spread = rememberNavPosition();
const stamped = window.history.state;
window.history.pushState({ ...spread, view: "other" }, "");
return stamped;
}
function landOn(state: unknown) {
window.history.replaceState(state, "");
act(() => {
window.dispatchEvent(new PopStateEvent("popstate", { state }));
});
}
afterEach(() => {
cleanup();
(document.activeElement as HTMLElement | null)?.blur?.();
window.history.replaceState(null, "");
});
describe("rememberNavPosition · Back focuses the item a view was left from", () => {
it("focuses the item once the page's lists are back, and spends the stamp", async () => {
const { unmount } = render(<Page />);
const stamped = leaveFrom("b1", 4);
expect(window.history.state.sfNav).toBeUndefined();
unmount();
landOn(stamped);
await sleep(120);
render(<Page />);
await waitFor(() => expect(focused()).toBe("b1"));
expect(window.history.state.sfNav).toBeUndefined();
});
// Left from a1 (list 0 of 2). If the second list mounts first, "list 0" on screen is list b — so the
// restore must wait until the page has both lists again, not land on b1.
it("waits for every list the page had — the first list to mount is not list 0", async () => {
const { unmount } = render(<Page />);
const stamped = leaveFrom("a1", 2);
unmount();
landOn(stamped);
const { rerender } = render(<List id="b" count={3} />);
await sleep(120);
expect(focused()).toBeUndefined();
rerender(<Page />);
await waitFor(() => expect(focused()).toBe("a1"));
});
it("leaves focus where it is when something already has it", async () => {
const { unmount } = render(<Page />);
const stamped = leaveFrom("b1", 4);
unmount();
const { getByRole } = render(<input aria-label="search" />);
landOn(stamped);
getByRole("textbox").focus();
render(<Page />);
await sleep(150);
expect(document.activeElement).toBe(getByRole("textbox"));
});
});
+32 -1
View File
@@ -10,6 +10,7 @@
//
// setPage() pushes a clean { sfPage } entry (dropping _sub/_ov), so each page starts at its root.
import { useEffect, useRef, useState } from "react";
import { navPosition, restoreNavPosition, type NavPosition } from "./listNav";
import { urlWithParams } from "./urlState";
// --- Per-history-entry scroll id + a nav bus (for scroll restoration) ------------------------
@@ -64,6 +65,36 @@ export function subscribeNav(cb: () => void): () => void {
return () => navListeners.delete(cb);
}
// --- The list item a view was left from (history.state.sfNav) -------------------------------------
// See lib/listNav "The item a view was left from". Stamped onto the entry being LEFT, right before a
// push opens a new view over it; read when Back/Forward lands on that entry, and dropped from it there,
// so a later landing on the same entry — an overlay closing over it — cannot pull focus to an old item.
let cancelNavRestore = () => {};
let navRestoreInstalled = false;
function ensureNavRestore(): void {
if (navRestoreInstalled) return;
navRestoreInstalled = true;
window.addEventListener("popstate", (e) => {
cancelNavRestore();
const pos = (e.state as { sfNav?: NavPosition } | null)?.sfNav;
if (!pos) return;
const { sfNav: _spent, ...st } = (window.history.state ?? {}) as Record<string, unknown>;
window.history.replaceState(st, "");
cancelNavRestore = restoreNavPosition(pos);
});
}
/** Call right before a pushState that opens a new view over the current entry (a page, a sub-view, a
* channel, a tab). Returns the current state WITHOUT the stamp: a push that spreads the current state
* must spread this, so the new entry does not inherit a position it was never left from. */
export function rememberNavPosition(): Record<string, unknown> {
ensureNavRestore();
const { sfNav: _stale, ...st } = (window.history.state ?? {}) as Record<string, unknown>;
const pos = navPosition();
window.history.replaceState(pos ? { ...st, sfNav: pos } : st, "");
return st;
}
// --- Sub-views (value carried in history.state._sub) ---------------------------------------
/** Optional address-bar mirroring for a sub-view, so a deep sub-view (e.g. a Plex show page) is
* bookmarkable. history.state._sub stays the source of truth for Back/Forward; this only adds the URL. */
@@ -127,7 +158,7 @@ export function useHistorySubview<T>(
// A fresh sfScroll so the opened sub-view is its own scroll entry (starts at the top, and Back
// restores the view we came from) — then notify PageScroller, which this push doesn't re-render.
window.history.pushState(
{ ...window.history.state, _sub: next, sfScroll: nextNavId() },
{ ...rememberNavPosition(), _sub: next, sfScroll: nextNavId() },
"",
href,
);
+49 -2
View File
@@ -120,7 +120,7 @@ function firstVisible(): { list: NavList; index: number } | null {
return fallback;
}
function focusItem(el: HTMLElement): void {
function focusItem(el: HTMLElement, reveal = true): void {
if (roved && roved !== el) roved.removeAttribute("tabindex");
roved = null;
// An item that is focusable in its own right (a Plex card is a role=button) keeps its tabindex.
@@ -131,7 +131,7 @@ function focusItem(el: HTMLElement): void {
el.focus({ preventScroll: true });
// `nearest` + the item's scroll margins: a visible item does not move, an item under the floating
// header or past the bottom edge scrolls just far enough to clear it.
el.scrollIntoView?.({ block: "nearest" });
if (reveal) el.scrollIntoView?.({ block: "nearest" });
}
function focusIndex(list: NavList, index: number): void {
@@ -241,6 +241,53 @@ function onKey(e: KeyboardEvent): void {
});
}
// ── The item a view was left from ────────────────────────────────────────────────────────────────
// Opening something that switches the page or the view — a Plex title from Home's rail, a channel from
// the Channels table — unmounts the list the keyboard was on, so Back used to hand focus to <body> and
// the next `j` started over at the top (E11 S5 UAT). The history entry being left remembers the
// position (lib/history `rememberNavPosition`) and Back gives focus back to that item once its list is
// on screen again. An overlay (the YouTube player) never unmounts the list; it restores focus itself.
/** Where focus sits in the page's lists: the list's place in reading order, how many lists the page
* had on screen, and the item's index. */
export interface NavPosition {
readonly list: number;
readonly lists: number;
readonly index: number;
}
export function navPosition(): NavPosition | null {
const cur = current();
if (!cur) return null;
const all = ordered();
const list = all.indexOf(cur.list);
return list < 0 ? null : { list, lists: all.length, index: cur.index };
}
// How long a returning page gets to put its lists back (a lazy chunk, cached data), and how often we
// look — on a timer, not animation frames: a hidden tab runs none.
const RESTORE_MS = 2000;
const RESTORE_POLL_MS = 50;
/** Focus the item at `pos` once the page has as many lists on screen as when it was left — before
* that, list N may not yet be the list it was. Gives up when the time is up, and at once when focus
* is already somewhere (the reader moved on, a dialog took it). The item is focused where the scroll
* restore put it, never scrolled to. Returns a cancel. */
export function restoreNavPosition(pos: NavPosition): () => void {
const deadline = Date.now() + RESTORE_MS;
let timer: ReturnType<typeof setTimeout> | undefined;
const attempt = () => {
const active = document.activeElement;
if (active && active !== document.body) return;
const all = ordered();
const el = all.length >= pos.lists ? itemAt(all[pos.list]!, pos.index) : null;
if (el) return focusItem(el, false);
if (Date.now() < deadline) timer = setTimeout(attempt, RESTORE_POLL_MS);
};
attempt();
return () => clearTimeout(timer);
}
/** Register `ref` as a keyboard-navigable list while `enabled`. A virtualized list must say how many
* items it has (`count` — the loaded ones, for an infinite list), how many per row (`cols`) and how
* to scroll an unmounted item into view (`reveal`). A list that mounts everything omits all three:
@@ -0,0 +1,54 @@
import { describe, expect, it } from "vitest";
// Every push that opens a new view over the current one remembers the list item it was opened from
// (`lib/history.rememberNavPosition`), so Back hands focus back to that item (E11 S5). A push that
// forgets strands keyboard focus on <body> again with no test noticing — the flow only shows in a real
// browser, after a round trip. So the source is the check: each `history.pushState(` in the app has a
// `rememberNavPosition(` right before it or inside its arguments. The overlay stack's own push is the
// one exception — an overlay entry opens no view, and its list stays mounted underneath.
//
// The assertion pins the SET of offending sites and checks the scan found pushes at all: a guard whose
// glob stops matching is green over nothing (E10 review round 4).
const files = import.meta.glob(["../**/*.ts", "../**/*.tsx", "!../**/*.test.*"], {
query: "?raw",
import: "default",
eager: true,
}) as Record<string, string>;
const EXEMPT: Record<string, string> = {
"lib/history.ts#_ov": "the overlay stack: an overlay entry opens no view over the page",
};
// Lines before a push, and lines of the push call itself, that may carry the remember call.
const BEFORE = 4;
const WITHIN = 4;
function offenders(): { hits: string[]; pushes: number } {
const hits: string[] = [];
let pushes = 0;
for (const [path, code] of Object.entries(files)) {
// Glob keys are relative to this file: `./x.ts` is lib, `../components/x.tsx` is a sibling folder.
const rel = path.startsWith("./") ? `lib/${path.slice(2)}` : path.replace(/^\.\.\//, "");
const lines = code.split("\n");
lines.forEach((line, i) => {
if (!line.includes("history.pushState(")) return;
pushes++;
const around = lines.slice(Math.max(0, i - BEFORE), i + WITHIN).join("\n");
if (around.includes("rememberNavPosition(")) return;
const exempt = Object.keys(EXEMPT).find(
(id) => id.startsWith(`${rel}#`) && around.includes(id.split("#")[1]!),
);
if (!exempt) hits.push(`${rel}:${i + 1}`);
});
}
return { hits: hits.sort(), pushes };
}
describe("every view push remembers the item it was opened from", () => {
it("finds no pushState without rememberNavPosition", () => {
const { hits, pushes } = offenders();
expect(pushes).toBeGreaterThan(4);
expect(hits).toEqual([]);
});
});