feat(settings): PIN-protected parental lock for categories (#285) (#1601)

* feat(settings): add PIN-protected parental lock for categories (#285)

Locks are per category (Xtream category ids, Stalker genre ids, M3U group
titles) and kept in one renderer lock store persisted to app_state /
localStorage; `categories.locked` is the SQLite index re-stamped from it.
While the lock is active the DB worker filters every content read, the PWA
data source, the Stalker store and the M3U channel list filter in memory,
and the enforcement service reloads the stores and steps off withheld
selections. Settings → Parental lock sets the PIN (PBKDF2, never in
Settings), the relock timeout and Lock now; lock toggles live in the
Xtream/M3U management dialogs and a new Stalker lock dialog, all behind
the PIN. Backups carry the locks per playlist entry.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* fix(settings): harden the parental lock after review

- The M3U group dialog opens only after the PIN, like the Xtream and Stalker
  dialogs: it lists locked group names and can rewrite the locks.
- Change PIN and Disable always verify the stored hash, even while the
  session is unlocked, so an app left unlocked cannot lose its lock.
- Stalker paging judges progress on the raw portal page: withheld ids the
  list has not seen count as progress, a page made only of locked rows
  requests the next one itself, and the VOD total is reduced by withheld
  ids so the grid stops asking once every visible row is in.
- Parental lock contract linked from the agent context map after the
  guidance reorganization; bridge helpers split out to stay under the
  file-size cap.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* fix(settings): guard locked categories on routes, PWA search and paging

- Xtream and Stalker `:categoryId` routes carry a parental-lock guard: a
  locked category reached by URL prompts for the PIN and redirects to the
  section root on refusal (Electron row ids are mapped to provider ids).
- PWA search filters withheld categories like the catalog reads.
- Electron warm-cache detection confirms an empty, lock-filtered read with
  the unfiltered existence check instead of refetching from the provider.
- A Stalker lock flip past page 1 drops withheld rows at once and restarts
  the list from page 1 instead of appending onto stale pages.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* fix(settings): compile the PIN hashing helper in the Node backend build

The web backend compiles the shared interfaces library without DOM typings,
so the DOM-only `SubtleCrypto` / `BufferSource` names broke its Docker
build. The helper now describes the WebCrypto surface it needs structurally
and reaches it through `globalThis`.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* fix(settings): close the remaining parental-lock gaps from review

- Detail routes check the item's own category: a locked movie or series
  paired with an unlocked category id in the URL is still refused.
- `requestUnlock()` awaits the settings load before it can answer "not
  active", so a slow startup cannot open a management dialog unguarded.
- `SETTINGS_UPDATE` only persists the `parentalLockEnabled` mirror and
  releases the worker on switch-off; it no longer re-locks the worker on
  every ordinary settings save under a renderer that shows "unlocked".

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* fix(settings): cover PWA cold navigation, Stalker search and the PWA lock editor

- The Xtream detail guard hydrates the PWA session cache before judging an
  item on a cold navigation and fails closed when the catalog cannot place
  the item.
- The dedicated Stalker search route filters withheld genres, re-fires on
  lock changes, judges paging on the raw page and restarts from page 1 on a
  lock flip.
- The Xtream category dialog loads its lock candidates through the
  capability-selected data source; the PWA source now lists its raw
  categories with lock flags, so locks can be configured there too.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* fix(settings): arm the relock timer on enable and harden Stalker search relock

- The idle timer follows the unlocked transition instead of `active`, so the
  session that just enabled the lock still locks itself later.
- Stalker search closes an open detail whose genre became withheld on
  relock and advances by itself past pages made only of locked rows (only
  while they add ids the list has not seen).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* fix(settings): close lock editors on relock and clear withheld details opened from All

The Xtream, Stalker and M3U category editors are gated by the PIN only when
they open; an idle relock left them on screen listing locked names with a
lock-rewriting Save. Each now closes itself when the session relocks.

ParentalLockEnforcementService also judges the selected Xtream/Stalker
item by its own category: a detail opened from All, recently added or
search has no selected category to vanish with, so it stayed open after a
relock.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* fix(settings): fail closed on unreadable settings and finish relock clean-up

- Unreadable settings (IndexedDB load failure) left the feature switch at
  its default and announced "unlocked" to the main process. A stored PIN
  now stands in for the switch, and without one nothing is announced, so
  the worker keeps its mirrored locked default.
- Lock applies run one at a time and abandon superseded results; the
  Electron data source keys its in-flight share by lock version so a
  relock can never reuse an unlock refresh's unfiltered rows.
- The stored in-portal Xtream search is re-run on a lock change.
- Stalker live/radio selections are judged by tv_genre_id, and both live
  layouts drop the playback of a channel whose category became withheld.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* fix(settings): fail closed on an unreadable lock store and make lock writes reliable

- A lock store that cannot be read is no longer treated as empty: while
  the lock is active every category is withheld (renderer predicates and
  set-based filters alike) until the PIN is entered or the store reads
  again, and writes are refused meanwhile so an empty in-memory store can
  never wipe the persisted locks. The lock set now lives in its own
  ParentalLockLockStore service.
- The M3U group dialog's lock write is awaited and a failed save is
  reported in a snackbar instead of being silently dropped.
- The Electron categories.locked re-stamp clears and re-locks inside one
  transaction, so a failed restamp keeps the previous index.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* fix(settings): drop pre-relock Stalker search pages and fail closed on a corrupt lock store

- A Stalker search page issued before a relock was filtered with the
  pre-relock withheld set and could still be applied after it; the
  staleness check now includes the parental lock version.
- A lock store payload that does not parse or is not an object is a
  failed read (everything withheld until it reads again), no longer an
  empty store.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* fix(settings): close the startup, re-stamp, relock-refresh and switch-persistence gaps

- The window before the initial lock store read settles now withholds
  everything, like an unreadable store: settings can report the feature as
  on before the locks are known.
- The store commits before the SQLite index re-stamp; a failed re-stamp
  now rolls the store back, a failed rollback re-stamps on the next
  access, and every launch re-derives the index from the store.
- Xtream category/content reloads fail closed: a rejected reload empties
  the affected lists (content types drop back to idle) instead of keeping
  rows read under the previous lock state.
- Enabling/disabling the feature persists through one guarded path that
  undoes the in-memory switch and skips the Electron mirror on a failed
  settings write.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* test(xtream): move the parental-lock reload specs beside the content spec

The content feature spec sits at the 1200-line spec cap.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* fix(settings): await the startup lock-index reconciliation and withhold genre-less rows when failing closed

- The lock store is readable only once the SQLite index has been re-derived
  from it, and a re-stamp that keeps failing keeps the session fail-closed,
  so catalog reads can never serve rows stamped unlocked by a stale index.
- While everything is withheld, Stalker rows without a genre are withheld
  as well (the store filter and the renderer predicate).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* fix(settings): await the enablement mirror, restore partial lock stamps and validate nested lock-store entries

- The Electron mirror of the feature switch is awaited; a mirror that
  cannot be written undoes the settings write, so a reload never starts
  from a mirror that disagrees with the persisted switch.
- A failed multi-type re-stamp rolls the store back AND re-stamps every
  touched type from it, since earlier types may already carry the new
  locks; a failed rollback keeps the playlist stale (fail-closed).
- A persisted lock store whose nested entries are not what writeLocks
  produces is a failed read, not an empty store.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* fix(settings): withhold the Xtream catalog at relock time, keep exact M3U titles in backups, roll back a failed relock-timeout save

- A relock now fails closed immediately: the selected detail is stepped
  off against the lock store, the catalog lists and stored search results
  are emptied, and the filtered reloads publish only while the captured
  lock version is still current.
- Backups carry M3U lock titles verbatim (exact dedup), since the locks
  match group titles exactly.
- A relock-timeout write that fails reverts the in-memory value and shows
  the settings save-failure snackbar.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* fix(settings): clear the lock index before a playlist's last lock leaves the store, retry failed PIN reads, guard backups on the lock store

- A write that removes a playlist's last lock clears the SQLite index
  first and drops the store key afterwards, so an interruption between the
  two can only leave a state the startup reconcile repairs toward locked.
- A PIN hash read failure is distinct from an absent PIN: the session stays
  locked and every PIN-protected step re-reads it first.
- Backup export awaits parental lock initialization and refuses to run
  while the lock store is not readable, since an absent lock field means
  "no opinion" on restore.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* fix(settings): withhold Electron Xtream reads while locks are unknown, persist the switch when settings are unreadable, re-stamp after a recovered read

- ElectronXtreamDataSource serves no categories, content or search hits
  while the lock store withholds everything; its SQLite index may still
  carry a stale stamp.
- setupPin decides whether to persist the switch from the settings value
  before the PIN is stored, since enabled follows hasPin while the switch
  is unknown.
- A lock store recovered by a later read marks its playlists stale so the
  index is re-derived, a persisted entry must carry all three lists, and a
  stale Stalker search page is dropped before touching the withheld-id
  bookkeeping.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* fix(settings): keep unlocked category routes reachable and defer a relock reload that overtakes the initial hydration

- The Xtream category guard no longer runs the item check on category-only
  routes (Number(null) is 0), which prompted for the PIN on every unlocked
  VOD and series category while the lock was active.
- A lock change during the initial Xtream hydration withholds the rows the
  hydration publishes and runs the filtered reload once it has settled,
  on every path that marks the content initialized.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* fix(settings): resolve hidden live categories before relocking playback and reload categories in the deferred hydration path

- The Xtream live layout resolves a playing channel's category through
  the unfiltered rows when the visible list lacks it (search can play a
  hidden category's channel); until that lookup lands the category is
  unknown and a relock stops the channel.
- A relock that overtakes the initial hydration now withholds the
  category publications too and reloads categories with the content.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* fix(settings): step off the M3U channel and Stalker selection before awaiting the Xtream relock reload

The Xtream store stays populated after leaving that portal, so its reload
runs on every apply; a locked M3U channel no longer keeps playing behind a
slow database or provider read.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* fix(settings): gate the workspace on parental lock init, edit only a readable lock store, guard the deferred reload, validate backup lock entries

- The workspace route resolver awaits ParentalLockService.initialize()
  next to the settings load, so no route or catalog activates before the
  PIN and lock store are known.
- Every lock write re-reads a failed store before building its edit, so a
  recovered store is edited rather than overwritten.
- The deferred hydration reload runs under the publish guard of the
  request that deferred it.
- Backup import validates every parental lock entry and rejects a damaged
  list instead of erasing the persisted locks on restore.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* fix(settings): discard stale hidden-category lookups and key withheld Stalker rows by their real identity

- A hidden-category lookup that lands after a later playback (same
  provider id, another playlist) no longer overwrites the newer channel's
  category; resolutions are generation- and playlist-checked.
- Withheld Stalker rows are keyed by id, stream_id, movie_id, series_id
  or the row's cmd/name, so id-less rows no longer collapse onto one key
  and stall paging past locked pages.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* fix(settings): gate the Electron cached category/content reads while locks are unknown

The warm-route hydration reads the cache directly; it now returns nothing
while the lock store withholds everything, like the live reads.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* fix(settings): retire in-flight searches on relock clearing and publish lock revisions after the stamps

- clearSearchResults() advances the search request version, so a search
  issued under the previous lock state cannot republish what a relock
  just cleared.
- A lock write publishes its store revision only once every touched type
  is stamped, so a reload triggered by it cannot read a later type
  through its old stamps.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* fix(settings): retire a resolving Stalker live playback when the session relocks

The embedded player defers selecting the channel until its stream
resolves, so the enforcement service's cleared selection could not retire
the request; it now carries the lock version it was issued under and is
dropped when a relock happened meanwhile.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* feat(settings): one lock entry point per rail plus a right-click Lock/Unlock

- Stalker's dedicated lock button becomes the same "Manage categories"
  (tune) button the Xtream rail has; it opens the lock-only dialog, so
  every portal type shares one entry point and the rail header keeps
  three actions.
- Right-clicking a category (Xtream, Stalker) or an M3U group offers a
  single-row Lock / Unlock through the shared CategoryLockMenuComponent,
  behind the same PIN gate and lock store as the dialog.
- The settings hint explains where locks are set; group lock strings added
  to all locales (ru/de translated).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* fix(settings): serialize lock-store writes and drop a deleted playlist's locks

- Lock-store mutations run through one write queue: each rewrites the
  whole persisted store, so overlapping edits could otherwise snapshot
  the same store and the later write would drop the earlier edit.
- Deleting a playlist removes its locks through the PLAYLIST_DELETE_CLEANUP
  hook; "Remove all playlists" clears the lock store once the deletion
  has succeeded.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(settings): apply single-row lock toggles inside the lock store's write queue

The right-click Lock/Unlock (portal categories and M3U groups) built the
new list before entering the queue, so two quick toggles shared one
snapshot and the second dropped the first. Lock writes now accept an edit
of the current list, evaluated inside the queue.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(settings): retry a failed settings read before any parental-lock settings write

updateSettings writes the whole settings object, which after a failed
startup read is the defaults; enabling the lock or changing the relock
timeout then replaced the user's persisted preferences. The read is
retried first and the write refused while settings stay unreadable. The
settings writes move to parental-lock-settings-writer.ts.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(settings): show the locked-groups row on the M3U rail and roll the relock timeout back to the recovered value

- The M3U groups rail now renders the same "N locked · Enter PIN to show"
  row as the portal category rail, so locked groups no longer vanish
  without an in-context unlock.
- A failed relock-timeout write rolls back to the value read after the
  settings retry, not to the pre-retry default.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(settings): retry a failed clear-all of the lock store and keep restored new playlists free of stale locks

A lock-store clear that failed after "Remove all playlists" only logged,
so a later restore reusing a playlist id could inherit the deleted
playlist's locks. The in-memory store now empties at once and the
persisted clear is retried on the next access; a restore that creates a
playlist starts it from empty locks.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(settings): count radio playback as lock activity and read the lock store before a restore's stale-id check

- The idle relock no longer interrupts a playing radio station: playing
  <audio> counts as activity, like video.
- A restore retries a failed lock-store read before checking a reused id
  for stale locks, and aborts while the store stays unreadable.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(settings): drop withheld Stalker search rows at relock time and keep the M3U unlock row when every group is locked

- A relock during a page-1 Stalker search now filters the rows already on
  screen at once, so old unlocked results are not clickable while the
  replacement page is pending.
- When every M3U group is locked the groups rail still renders, with its
  "N locked · Enter PIN to show" row, instead of the plain empty state.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* perf(settings): keep the PIN dialog and the Stalker enforcement step off the initial path

Master (#1712) moved the UI component barrel and the Stalker data layer out
of main.js and tightened the initial budget to 2 MB. The parental-lock
prompt imported the PIN dialog through the ui/components barrel and the
enforcement service injected the Stalker store at startup, which pulled
both back in (2.55 MB, over budget). The PIN dialog now loads through a
local lazy file on the first prompt, and the Stalker step loads only while
a Stalker route is open: initial total 1.65 MB.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(settings): restore the lock index when an emptying write fails and publish rollbacks after re-stamping

- Removing a playlist's last lock clears the SQLite index first; if the
  clear or the store write then fails, the index is re-stamped from the
  previous locks at once. Title matching and multi-source discovery query
  the worker directly and trust the index, so the stale flag alone did not
  protect them.
- A rollback publishes its store revision only after every type is
  re-stamped, so a reload cannot read a later type through the attempted
  stamps.
- docs: restore the index rules the earlier surfaces rewrite dropped from
  the contract, now in the Lock store lifetime section.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(settings): keep the M3U groups view when every group is locked

With every group locked the filtered channel list is empty, so the
container showed its generic empty state and the groups rail's
"N locked · Enter PIN to show" row never appeared.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(settings): fail closed when the lazy Stalker enforcement step cannot load

A rejected chunk (e.g. a stale PWA page after a deployment) escaped
applyStalker(), so a locked Stalker selection kept playing after a relock
and the Xtream step was skipped. The step now leaves the Stalker route on
a load failure, which clears the selection and stops playback, and the
Xtream step still runs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(settings): defer a stale Xtream hydration as soon as the catalog is withheld

On Electron a relock that overtook the initial content hydration waited
for the category reload before the content reload set the deferral flag;
the older unlocked hydration could publish its streams in that window.
withholdCatalog() now sets the flag itself, before anything is awaited.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(settings): restore backup locks after the Xtream merge and snapshot the Stalker lock dialog's categories

- A backup restore now writes the parental locks last, so a failed Xtream
  merge leaves the playlist's previous locks in place instead of the
  backup's possibly smaller set.
- The Stalker lock dialog snapshots the category list before its lazy
  import and opens only if the route is unchanged, so another portal's
  categories can never be saved under this playlist.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(settings): fail closed on relock ahead of the apply queue and judge Xtream selections by the lock store

- On relock the synchronous fail-closed steps (M3U channel, Stalker
  selection, the locked Xtream detail, catalog lists, stored search) run
  immediately instead of queueing behind an earlier apply that may still
  wait on a slow or hung read.
- The post-reload Xtream checks decide by the lock store through the
  unfiltered category rows rather than by absence from the reloaded list,
  which also omits merely hidden categories; unreadable rows fail closed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(settings): require the PIN for lock-bearing backup restores and fail closed during index re-stamps

- A backup carrying lock lists replaces the matching playlists' locks,
  possibly with an emptier set; the import now asks for the PIN (after the
  file was chosen) and aborts when it is refused.
- While a write re-stamps the SQLite index the playlist counts as stale,
  so a relock inside that window reloads fail-closed instead of through
  the old stamps. The internal store write now needs only a readable
  store, so a rollback can still land.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(settings): drop parental-lock Xtream reloads once the playlist is switched

A reload issued for playlist A no longer publishes into the shared Xtream
store after the user opened playlist B: the store's reloads guard on the
playlist they read for, and the enforcement apply retires its search
refresh and selection checks on a playlist switch as on a newer lock
version.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(settings): re-ask the PIN before a relocked backup merge and keep the PIN cooldown across prompts

A backup merge now asks for the PIN again right before it replaces a
playlist's locks when the app relocked during the import, instead of
relying on the answer given at the start. The wrong-PIN count and the
30-second pause move from the dialog into the lock service, so
dismissing and reopening the prompt no longer resets them.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(settings): refuse lock removals that commit after a relock and keep the index stale until the store write lands

Lock edits that take a lock away now commit only while the session is
unlocked, checked inside the write queue at commit time, so an editor
save still in flight (or queued) when the app relocks cannot remove
locks. Adding locks stays allowed. The Xtream category dialog drops its
lock draft after a relock, and a backup restore re-asks the PIN only
when it would remove a lock. Clearing a playlist's last lock keeps its
index stale until the store write has landed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(settings): clear an Xtream detail from a hidden category synchronously on relock

The synchronous relock step now clears a selected Xtream item whose
category the visible category list cannot place (a manually hidden
category opened through search), instead of leaving it usable until the
awaited reloads and lookup finish.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(settings): fail closed when the Electron bridge lacks the parental lock worker filter

A new runtime capability requires the lock-state and index-stamping IPC.
When Electron reads Xtream through the SQLite worker without it (a
partial or older preload), the locked session withholds every category
instead of trusting a worker that never learned the lock state.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(settings): re-check lock removals at their durable commit points

A lock removal that passed the unlocked check before its write is asked
again right after the store write and, for Xtream, after the index
stamps. A relock in between writes the previous store back or rolls the
stamps back before anything is published. The stale-index bookkeeping,
index stamping and store merge move into helpers to keep the lock store
within the file size limit.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(settings): retry a failed revert of a refused lock removal and fail closed meanwhile

When writing the previous store back after a relock-refused removal
fails, the lock store now keeps a pending rewrite, is not readable (the
locked session withholds everything) and rewrites the persisted store
from memory on the next access, so a restart cannot load the removal.
A failed "Remove all playlists" clear shares the same retry.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(settings): authorize lock removals when issued and close genre-less Stalker details in fail-closed mode

A lock removal is now authorized right before its first write is issued;
a relock that lands after that is ordered after the write, which
completes. This drops the post-write rollback, whose own failure could
leave the persisted store diverged from memory across a restart. The
Stalker search closes a detail without a genre on relock while every
category is withheld.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(settings): restore the previous locks when an Xtream rollback write fails

When an Xtream lock edit's re-stamp fails and the rollback store write
fails too, memory now goes back to the previous locks and a pending
rewrite persists them on the next store access before the index is
re-stamped, so the failed edit cannot take effect through that re-stamp.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* test(stalker): cover page-one rows leaving the screen on relock while the reload hangs

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(settings): offer the portal unlock row in fail-closed mode

When the lock store cannot be read every portal category is withheld
but no locked ids are known, so the rail showed no "Enter PIN to show"
row. It now shows the row without a count in that state.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(settings): fail closed for direct worker title lookups and capture the Stalker lock dialog context before the PIN

Catalog title matching and multi-source discovery query the SQLite
worker directly; they now return nothing while the parental lock
withholds everything (unreadable store or a bridge without the worker
filter). The Stalker lock dialog captures its playlist, provider and
section before the PIN prompt and re-checks them after it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(settings): retire multi-source alternatives on a lock change and keep reconcile off in-flight stamps

The VOD multi-source host keys its discovery session to the parental
lock version: a lock change drops the discovered sources, retires
discoveries and switches in flight, and rediscovers through the
worker's new lock state. Stale-index entries of a write still stamping
are no longer retried by a concurrent reconcile, which could re-stamp
from a store the write had not committed yet.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(settings): fail closed on a rejected worker lock sync, tear down Stalker synchronously and capture the Xtream dialog context before the PIN

- A rejected lock-state sync to the SQLite worker makes the locked
  session withhold everything until a later sync succeeds.
- The Stalker enforcement chunk is preloaded when a Stalker route
  opens; a relock runs it synchronously, or leaves the route at once
  while it is not loaded, instead of awaiting the chunk.
- Xtream "Manage categories" captures playlist, provider and section
  before the PIN prompt and re-checks them after it and after the
  dialog import.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(settings): keep the relock timeout behind the PIN and bind M3U group lock toggles to their playlist

A locked session can no longer change the relock timeout: the Settings
selector is disabled until the PIN is entered and the service refuses
the change while locked. An M3U right-click lock toggle now captures its
playlist before the PIN prompt and is saved only if that playlist is
still open.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(settings): reset a locked M3U channel however it becomes active

The enforcement service now checks the active M3U channel whenever it
changes while locked, so numeric zapping, next/previous and remote
commands, which select from the full channel list, cannot start a
channel of a locked group. Numeric zapping also skips such a channel.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(settings): bind the M3U group management result to its playlist

The groups view captures the playlist before the PIN prompt and drops
the management dialog's hidden and locked group lists once another
playlist is open, so they cannot be saved under that playlist.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* docs(parental-lock): record the accepted restart case of a failed rollback write

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(settings): build bulk lock drafts only from a readable lock store

The Xtream and M3U management dialogs offer lock toggles, and the
Stalker lock dialog opens, only once the lock store has been read. A
draft built from the empty fail-closed snapshot would otherwise replace
the real locks with nothing on Save if storage recovered in between.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(settings): keep the parental lock switch on the saved state and roll back to the recovered value

The Settings switch snaps back to the saved state when clicked and
follows it once the PIN action succeeds, so a cancelled or refused PIN
no longer leaves it showing the opposite state. A failed switch write is
undone to the value read after the settings retry instead of the
hard-coded inverse.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(settings): match noncanonical PWA Xtream category ids against their locks

The PWA data source compared raw provider category ids such as "009"
with locks stored as numbers, so such a category stayed visible while
locked. Both sides are now compared in canonical numeric form.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(settings): close the M3U group editor on any relock

The group management dialog lists every group name, locked ones
included, even when it opened without lock toggles (unreadable lock
store). It now closes on any relock, and the groups view re-checks the
lock state before opening it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: 4gray <fourgray@proton.me>
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
authored and GitHub committed 2026-09-27 16:38:34 +02:00
1 parent f0e51d2806
commit e45cd85a78
203 files changed
+15022 -432

No files matched your search

@@ -176,9 +176,15 @@ export const dbPreloadCases: PreloadInvokeCase[] = [
},
{
method: 'dbSaveCategories',
args: [playlistId, categories, 'live', categoryIds],
args: [playlistId, categories, 'live', categoryIds, categoryIds],
channel: 'DB_SAVE_CATEGORIES',
forwardedArgs: [playlistId, categories, 'live', categoryIds],
forwardedArgs: [
playlistId,
categories,
'live',
categoryIds,
categoryIds,
],
},
{
method: 'dbGetAllCategories',
@@ -192,6 +198,12 @@ export const dbPreloadCases: PreloadInvokeCase[] = [
channel: 'DB_UPDATE_CATEGORY_VISIBILITY',
forwardedArgs: [categoryIds, true],
},
{
method: 'dbSetCategoryLocks',
args: [playlistId, 'live', categoryIds],
channel: 'DB_SET_CATEGORY_LOCKS',
forwardedArgs: [playlistId, 'live', categoryIds],
},
{
method: 'dbHasContent',
args: [playlistId, 'movie'],
@@ -99,6 +99,7 @@ const WINDOW_CONFIRM_CLOSE = 'WINDOW:CONFIRM_CLOSE';
const WINDOW_CANCEL_CLOSE = 'WINDOW:CANCEL_CLOSE';
const WINDOW_CLOSE_REQUESTED = 'WINDOW:CLOSE_REQUESTED';
const PLAYBACK_SET_KEEP_AWAKE = 'PLAYBACK:SET_KEEP_AWAKE';
const PARENTAL_LOCK_SET_STATE = 'PARENTAL_LOCK:SET_STATE';
const dbSaveContentProgressListeners = new Set<
(
@@ -461,6 +462,8 @@ const electronApi: ElectronBridgeApi = {
},
setPlaybackKeepAwake: (active: boolean) =>
ipcRenderer.invoke(PLAYBACK_SET_KEEP_AWAKE, active === true),
setParentalLockState: (active: boolean) =>
ipcRenderer.invoke(PARENTAL_LOCK_SET_STATE, active === true),
fetchPlaylistByUrl: (
url: string,
title?: string,
@@ -827,14 +830,16 @@ const electronApi: ElectronBridgeApi = {
playlistId: string,
categories: XtreamCategory[],
type: string,
hiddenCategoryXtreamIds?: number[]
hiddenCategoryXtreamIds?: number[],
lockedCategoryXtreamIds?: number[]
) =>
ipcRenderer.invoke(
'DB_SAVE_CATEGORIES',
playlistId,
categories,
type,
hiddenCategoryXtreamIds
hiddenCategoryXtreamIds,
lockedCategoryXtreamIds
),
dbGetAllCategories: (playlistId: string, type: string) =>
ipcRenderer.invoke('DB_GET_ALL_CATEGORIES', playlistId, type),
@@ -844,6 +849,17 @@ const electronApi: ElectronBridgeApi = {
categoryIds,
hidden
),
dbSetCategoryLocks: (
playlistId: string,
type: string,
lockedXtreamIds: number[]
) =>
ipcRenderer.invoke(
'DB_SET_CATEGORY_LOCKS',
playlistId,
type,
lockedXtreamIds
),
dbHasContent: (playlistId: string, type: string) =>
ipcRenderer.invoke('DB_HAS_CONTENT', playlistId, type),
dbGetContent: (playlistId: string, type: string) =>
@@ -91,6 +91,9 @@ export const XTREAM_PRELOAD_TARGET_CASES: TargetCase[] = [
categoryItems,
'live',
[91],
// Parental-lock ids: the preload forwards the optional fifth
// argument as-is, so an omitted one travels as undefined.
undefined,
],
expectedMetadata: {
...EMPTY_METADATA,
@@ -74,6 +74,7 @@ describe('category operation performance phases', () => {
],
'live',
[102],
undefined,
recording.capture
)
).resolves.toEqual({ success: true });
@@ -4,7 +4,15 @@ import {
getAllCategories,
getCategories,
saveCategories,
setCategoryLocks,
} from './category.operations';
import { SQLiteSyncDialect } from 'drizzle-orm/sqlite-core';
import type { SQL } from 'drizzle-orm';
import { setParentalLockActive } from '../parental-lock-state';
function renderSql(query: SQL): string {
return new SQLiteSyncDialect().sqlToQuery(query).sql;
}
// Renderer consumers (XCategoryFromDb/XtreamCategoryFromDb) expect category
// rows in this snake_case wire shape. A bare select() would return Drizzle's
@@ -17,6 +25,7 @@ const categoryWireShape = {
type: schema.categories.type,
xtream_id: schema.categories.xtreamId,
hidden: schema.categories.hidden,
locked: schema.categories.locked,
};
function createDbMock(existingCount = 0) {
@@ -93,6 +102,7 @@ describe('category.operations', () => {
type: 'live',
xtreamId: 101,
hidden: false,
locked: false,
},
{
playlistId: 'playlist-1',
@@ -100,6 +110,7 @@ describe('category.operations', () => {
type: 'live',
xtreamId: 102,
hidden: true,
locked: false,
},
]);
});
@@ -125,6 +136,7 @@ describe('category.operations', () => {
type: 'movies',
xtreamId: 201,
hidden: true,
locked: false,
},
]);
});
@@ -143,3 +155,120 @@ describe('category.operations', () => {
expect(insert).not.toHaveBeenCalled();
});
});
describe('category.operations parental lock', () => {
afterEach(() => {
setParentalLockActive(false);
});
function createReadDb() {
const orderBy = jest.fn().mockResolvedValue([]);
const where = jest.fn().mockReturnValue({ orderBy });
const from = jest.fn().mockReturnValue({ where });
const select = jest.fn().mockReturnValue({ from });
return { db: { select } as unknown as AppDatabase, where };
}
it('adds the locked filter to visible-category reads only while active', async () => {
const unlocked = createReadDb();
await getCategories(unlocked.db, 'playlist-1', 'live');
expect(renderSql(unlocked.where.mock.calls[0][0])).not.toContain(
'"locked"'
);
setParentalLockActive(true);
const locked = createReadDb();
await getCategories(locked.db, 'playlist-1', 'live');
expect(renderSql(locked.where.mock.calls[0][0])).toContain(
'"categories"."locked" = ?'
);
});
it('never filters the management read, which lists locked rows by design', async () => {
setParentalLockActive(true);
const { db, where } = createReadDb();
await getAllCategories(db, 'playlist-1', 'movies');
expect(renderSql(where.mock.calls[0][0])).not.toContain('"locked"');
});
it('stamps locked from the caller-supplied provider ids on insert', async () => {
const { db, values } = createDbMock(0);
await saveCategories(
db,
'playlist-1',
[
{ category_id: '1', category_name: 'Kids' },
{ category_id: '2', category_name: 'Adult' },
],
'live',
undefined,
[2]
);
expect(values).toHaveBeenCalledWith([
expect.objectContaining({ xtreamId: 1, locked: false }),
expect.objectContaining({ xtreamId: 2, locked: true }),
]);
});
function lockIndexDb() {
const run = jest.fn();
const where = jest.fn().mockReturnValue({ run });
const set = jest.fn().mockReturnValue({ where });
const update = jest.fn().mockReturnValue({ set });
const transaction = jest.fn((callback: () => void) => callback());
return {
db: { update, transaction } as unknown as AppDatabase,
run,
set,
transaction,
where,
};
}
it('re-stamps one playlist/type in one transaction: clears everything, then locks the listed ids', async () => {
const { db, run, set, transaction, where } = lockIndexDb();
await setCategoryLocks(db, 'playlist-1', 'live', [5, 5, 7, 1.5]);
expect(transaction).toHaveBeenCalledTimes(1);
expect(run).toHaveBeenCalledTimes(2);
expect(set).toHaveBeenNthCalledWith(1, { locked: false });
expect(set).toHaveBeenNthCalledWith(2, { locked: true });
const lockScope = new SQLiteSyncDialect().sqlToQuery(
where.mock.calls[1][0]
);
expect(lockScope.sql).toContain('"categories"."xtream_id" in (?, ?)');
expect(lockScope.params).toEqual(['playlist-1', 'live', 5, 7]);
});
it('only clears when no id is locked', async () => {
const { db, set } = lockIndexDb();
await setCategoryLocks(db, 'playlist-1', 'series', []);
expect(set).toHaveBeenCalledTimes(1);
expect(set).toHaveBeenCalledWith({ locked: false });
});
});
describe('setCategoryLocks atomicity', () => {
it('runs both statements inside the transaction callback', async () => {
const order: string[] = [];
const run = jest.fn(() => order.push('run'));
const where = jest.fn().mockReturnValue({ run });
const set = jest.fn().mockReturnValue({ where });
const update = jest.fn().mockReturnValue({ set });
const transaction = jest.fn((callback: () => void) => {
order.push('begin');
callback();
order.push('commit');
});
const db = { update, transaction } as unknown as AppDatabase;
await setCategoryLocks(db, 'playlist-1', 'live', [5]);
expect(order).toEqual(['begin', 'run', 'run', 'commit']);
});
});
@@ -2,6 +2,7 @@ import { and, eq, inArray, sql } from 'drizzle-orm';
import * as schema from '@iptvnator/shared/database/schema';
import { XTREAM_DATABASE_PERFORMANCE_PHASE } from '@iptvnator/shared/interfaces';
import type { AppDatabase } from '../database.types';
import { unlockedCategoryCondition } from '../parental-lock-state';
import type { DatabaseOperationPerformancePhaseCapture } from './performance-phase-capture';
type XtreamCategoryInput = {
@@ -23,6 +24,7 @@ const categoryWireShape = {
type: schema.categories.type,
xtream_id: schema.categories.xtreamId,
hidden: schema.categories.hidden,
locked: schema.categories.locked,
};
function normalizeXtreamCategoryId(
@@ -37,9 +39,11 @@ function normalizeXtreamCategories(
playlistId: string,
categories: XtreamCategoryInput[],
type: 'live' | 'movies' | 'series',
hiddenCategoryXtreamIds?: number[]
hiddenCategoryXtreamIds?: number[],
lockedCategoryXtreamIds?: number[]
): XtreamCategoryValue[] {
const hiddenSet = new Set(hiddenCategoryXtreamIds || []);
const lockedSet = new Set(lockedCategoryXtreamIds || []);
return categories.flatMap((category) => {
const xtreamId = normalizeXtreamCategoryId(category.category_id);
@@ -55,6 +59,7 @@ function normalizeXtreamCategories(
type,
xtreamId,
hidden: hiddenSet.has(xtreamId),
locked: lockedSet.has(xtreamId),
},
];
});
@@ -111,7 +116,8 @@ export async function getCategories(
and(
eq(schema.categories.playlistId, playlistId),
eq(schema.categories.type, type),
eq(schema.categories.hidden, false)
eq(schema.categories.hidden, false),
unlockedCategoryCondition()
)
)
.orderBy(schema.categories.id);
@@ -131,6 +137,7 @@ export async function saveCategories(
categories: XtreamCategoryInput[],
type: 'live' | 'movies' | 'series',
hiddenCategoryXtreamIds?: number[],
lockedCategoryXtreamIds?: number[],
capturePhase?: DatabaseOperationPerformancePhaseCapture
): Promise<{ success: boolean }> {
if (!categories || categories.length === 0) {
@@ -159,7 +166,8 @@ export async function saveCategories(
playlistId,
categories,
type,
hiddenCategoryXtreamIds
hiddenCategoryXtreamIds,
lockedCategoryXtreamIds
),
(result) => ({ itemCount: result.length })
)
@@ -167,7 +175,8 @@ export async function saveCategories(
playlistId,
categories,
type,
hiddenCategoryXtreamIds
hiddenCategoryXtreamIds,
lockedCategoryXtreamIds
);
if (values.length === 0) {
@@ -220,3 +229,43 @@ export async function updateCategoryVisibility(
return { success: true };
}
/**
* Re-stamps the parental lock index for one playlist and category type from
* the renderer's lock store: listed provider ids become locked, every other
* row of that playlist/type is unlocked. Idempotent by construction, so the
* renderer can replay the store after a refresh or a backup restore.
*/
export async function setCategoryLocks(
db: AppDatabase,
playlistId: string,
type: 'live' | 'movies' | 'series',
lockedXtreamIds: number[]
): Promise<{ success: boolean }> {
const scope = and(
eq(schema.categories.playlistId, playlistId),
eq(schema.categories.type, type)
);
const lockedIds = [
...new Set(lockedXtreamIds.filter((id) => Number.isInteger(id))),
];
// One transaction: a re-stamp that fails after the clear would otherwise
// leave every category of this playlist/type unlocked while the lock
// store still lists the intended locks. `.run()` (synchronous), not
// `.execute()`: see playback-position.operations.ts.
await db.transaction(() => {
db.update(schema.categories).set({ locked: false }).where(scope).run();
if (lockedIds.length > 0) {
db.update(schema.categories)
.set({ locked: true })
.where(
and(scope, inArray(schema.categories.xtreamId, lockedIds))
)
.run();
}
});
return { success: true };
}
@@ -15,6 +15,10 @@ import {
XtreamGlobalSearchResult,
} from '@iptvnator/shared/interfaces';
import type { AppDatabase } from '../database.types';
import {
unlockedCategoryCondition,
unlockedCategorySql,
} from '../parental-lock-state';
import {
countContentRowsByCategory,
sumCategoryRowCounts,
@@ -311,6 +315,7 @@ async function selectXtreamGlobalSearchCandidatesWithTitleIndex(
)})
AND ${sql.join(titleConditions, sql` AND `)}
${excludeHidden ? sql`AND cat.hidden = 0` : sql``}
${unlockedCategorySql()}
ORDER BY c.title
LIMIT ${candidateLimit}
`)) as XtreamGlobalSearchCandidate[];
@@ -359,6 +364,7 @@ async function selectXtreamGlobalSearchCandidatesWithFts(
: sql``
}
${excludeHidden ? sql`AND cat.hidden = 0` : sql``}
${unlockedCategorySql()}
ORDER BY rank, c.title
LIMIT ${candidateLimit}
`)) as XtreamGlobalSearchCandidate[];
@@ -382,6 +388,10 @@ async function selectXtreamGlobalSearchCandidatesWithContentScan(
if (excludeHidden) {
conditions.push(eq(schema.categories.hidden, false));
}
const unlockedCondition = unlockedCategoryCondition();
if (unlockedCondition) {
conditions.push(unlockedCondition);
}
return db
.select({
@@ -694,7 +704,8 @@ export async function getContent(
.where(
and(
eq(schema.categories.playlistId, playlistId),
eq(schema.content.type, type)
eq(schema.content.type, type),
unlockedCategoryCondition()
)
);
@@ -775,6 +786,7 @@ function getGlobalRecentlyAddedByType(
const whereConditions = [
eq(schema.content.type, type),
eq(schema.categories.hidden, false),
unlockedCategoryCondition(),
sql`${schema.content.added} <> ''`,
sql`${schema.content.added} <= ${getXtreamRecentlyAddedMaxEpochSeconds()}`,
];
@@ -1074,6 +1086,10 @@ export async function searchContent(
if (excludeHidden) {
conditions.push(eq(schema.categories.hidden, false));
}
const unlockedCondition = unlockedCategoryCondition();
if (unlockedCondition) {
conditions.push(unlockedCondition);
}
const query = db
.select(selectContentFields())
@@ -4,6 +4,7 @@ import {
normalizeTitleKeys,
} from '@iptvnator/shared/interfaces';
import type { AppDatabase } from '../database.types';
import { unlockedCategorySql } from '../parental-lock-state';
/**
* Batched cross-playlist title matching for the actor page's "All portals"
@@ -75,6 +76,7 @@ export async function matchTitles(
WHERE content_title_fts MATCH ${matchQuery}
AND c.type IN ('movie', 'series')
AND cat.hidden = 0
${unlockedCategorySql()}
ORDER BY rank, c.title
LIMIT ${PER_TITLE_CANDIDATE_LIMIT}
`)) as TitleMatchRow[];
@@ -5,6 +5,7 @@ import {
type VodSourceCandidateRow,
} from '@iptvnator/shared/interfaces';
import type { AppDatabase } from '../database.types';
import { unlockedCategorySql } from '../parental-lock-state';
import {
caseInsensitiveGlobBody,
caseInsensitiveGlobPattern,
@@ -258,6 +259,7 @@ function scanCandidateQuery(
WHERE c.type = 'movie'
AND cat.hidden = 0
AND p.type = 'xtream'
${unlockedCategorySql()}
AND ${wordMatches}
${excludePlaylist}
ORDER BY LENGTH(c.title), c.title
@@ -292,6 +294,7 @@ function ftsCandidateQuery(matchQuery: string, excludePlaylist: SQL) {
AND c.type = 'movie'
AND cat.hidden = 0
AND p.type = 'xtream'
${unlockedCategorySql()}
${excludePlaylist}
GROUP BY cat.playlist_id, c.xtream_id
ORDER BY rank, c.title
@@ -0,0 +1,47 @@
import { and, eq, type SQL } from 'drizzle-orm';
import { SQLiteSyncDialect } from 'drizzle-orm/sqlite-core';
import * as schema from '@iptvnator/shared/database/schema';
function renderSql(query: SQL): string {
return new SQLiteSyncDialect().sqlToQuery(query).sql;
}
import {
isParentalLockActive,
setParentalLockActive,
unlockedCategoryCondition,
unlockedCategorySql,
} from './parental-lock-state';
describe('parental-lock-state', () => {
afterEach(() => {
setParentalLockActive(false);
});
it('starts unlocked and contributes nothing to queries', () => {
expect(isParentalLockActive()).toBe(false);
expect(unlockedCategoryCondition()).toBeUndefined();
expect(renderSql(unlockedCategorySql())).toBe('');
// `and()` must tolerate the undefined condition so callers can add
// it unconditionally.
expect(
and(eq(schema.categories.type, 'live'), undefined)
).toBeDefined();
});
it('filters on categories.locked while active', () => {
setParentalLockActive(true);
expect(isParentalLockActive()).toBe(true);
expect(unlockedCategoryCondition()).toEqual(
eq(schema.categories.locked, false)
);
expect(renderSql(unlockedCategorySql())).toContain(
'AND cat.locked = 0'
);
});
it('coerces anything but true to inactive', () => {
setParentalLockActive('yes' as unknown as boolean);
expect(isParentalLockActive()).toBe(false);
});
});
@@ -0,0 +1,39 @@
import { eq, sql, type SQL } from 'drizzle-orm';
import * as schema from '@iptvnator/shared/database/schema';
/**
* Process-wide parental lock enforcement flag for the SQLite worker.
*
* While active, every content-returning read appends "category is not
* locked", so a surface added later is withheld by default instead of
* leaking. The value arrives from the main process: seeded through
* `workerData` when the worker is (re)started and updated by the
* `DB_SET_PARENTAL_LOCK_STATE` request. It is deliberately not read from
* settings inside the worker, which has no access to the renderer's
* IndexedDB or to electron-conf.
*/
let parentalLockActive = false;
export function setParentalLockActive(active: boolean): void {
parentalLockActive = active === true;
}
export function isParentalLockActive(): boolean {
return parentalLockActive;
}
/**
* Drizzle condition for query-builder reads joined on `categories`;
* `undefined` while unlocked so `and(...)` ignores it.
*/
export function unlockedCategoryCondition(): SQL | undefined {
return parentalLockActive ? eq(schema.categories.locked, false) : undefined;
}
/**
* Raw fragment for `sql` template reads that alias the categories table as
* `cat`; empty while unlocked.
*/
export function unlockedCategorySql(): SQL {
return parentalLockActive ? sql`AND cat.locked = 0` : sql``;
}
@@ -30,12 +30,27 @@ handleWorkerRequest(
category_id: string | number;
}>,
type: 'live' | 'movies' | 'series',
hiddenCategoryXtreamIds?: number[]
hiddenCategoryXtreamIds?: number[],
lockedCategoryXtreamIds?: number[]
) => ({
playlistId,
categories,
type,
hiddenCategoryXtreamIds,
lockedCategoryXtreamIds,
})
);
handleWorkerRequest(
'DB_SET_CATEGORY_LOCKS',
(
playlistId: string,
type: 'live' | 'movies' | 'series',
lockedXtreamIds: number[]
) => ({
playlistId,
type,
lockedXtreamIds: Array.isArray(lockedXtreamIds) ? lockedXtreamIds : [],
})
);
@@ -154,12 +154,13 @@ export const workerIpcContractCases: WorkerIpcContractCase[] = [
},
{
operation: 'DB_SAVE_CATEGORIES',
args: [playlistId, categories, 'live', categoryIds],
args: [playlistId, categories, 'live', categoryIds, categoryIds],
payload: {
playlistId,
categories,
type: 'live',
hiddenCategoryXtreamIds: categoryIds,
lockedCategoryXtreamIds: categoryIds,
},
},
{
@@ -172,6 +173,11 @@ export const workerIpcContractCases: WorkerIpcContractCase[] = [
args: [categoryIds, true],
payload: { categoryIds, hidden: true },
},
{
operation: 'DB_SET_CATEGORY_LOCKS',
args: [playlistId, 'live', categoryIds],
payload: { playlistId, type: 'live', lockedXtreamIds: categoryIds },
},
{
operation: 'DB_HAS_CONTENT',
args: [playlistId, 'movie'],
@@ -0,0 +1,96 @@
type IpcHandler = (event: unknown, ...args: unknown[]) => Promise<unknown>;
const mockRegisteredHandlers = new Map<string, IpcHandler>();
const mockSetParentalLockState = jest.fn();
const mockStoreGet = jest.fn();
jest.mock('electron', () => ({
ipcMain: {
handle: jest.fn((channel: string, handler: IpcHandler) => {
mockRegisteredHandlers.set(channel, handler);
}),
},
}));
jest.mock('../services/database-worker-client', () => ({
databaseWorkerClient: {
setParentalLockState: (...args: unknown[]) =>
mockSetParentalLockState(...args),
},
}));
jest.mock('../services/store.service', () => ({
PARENTAL_LOCK_ENABLED: 'PARENTAL_LOCK_ENABLED',
store: { get: (...args: unknown[]) => mockStoreGet(...args) },
}));
type Listener = (...args: unknown[]) => void;
function createSender() {
const listeners = new Map<string, Listener>();
return {
id: 1,
on: jest.fn((event: string, listener: Listener) => {
listeners.set(event, listener);
}),
off: jest.fn(),
emit(event: string, ...args: unknown[]) {
listeners.get(event)?.(...args);
},
};
}
describe('parental-lock.events', () => {
beforeEach(async () => {
jest.resetModules();
mockRegisteredHandlers.clear();
mockSetParentalLockState.mockReset().mockResolvedValue(undefined);
mockStoreGet.mockReset().mockReturnValue(true);
const module = await import('./parental-lock.events');
module.default.bootstrapParentalLockEvents();
});
it('forwards the renderer state to the worker and remembers it', async () => {
const { getParentalLockActive } =
await import('../services/parental-lock-state');
const handler = mockRegisteredHandlers.get('PARENTAL_LOCK:SET_STATE');
expect(handler).toBeDefined();
await handler?.({ sender: createSender() }, false);
expect(mockSetParentalLockState).toHaveBeenLastCalledWith(false);
expect(getParentalLockActive()).toBe(false);
await handler?.({ sender: createSender() }, 'yes');
expect(mockSetParentalLockState).toHaveBeenLastCalledWith(false);
});
it('locks again from the mirrored setting when the renderer reloads or dies', async () => {
const handler = mockRegisteredHandlers.get('PARENTAL_LOCK:SET_STATE');
const sender = createSender();
await handler?.({ sender }, false);
mockSetParentalLockState.mockClear();
sender.emit('did-start-navigation', {
isMainFrame: true,
isSameDocument: true,
});
expect(mockSetParentalLockState).not.toHaveBeenCalled();
sender.emit('did-start-navigation', {
isMainFrame: true,
isSameDocument: false,
});
expect(mockSetParentalLockState).toHaveBeenLastCalledWith(true);
mockStoreGet.mockReturnValue(false);
sender.emit('render-process-gone');
expect(mockSetParentalLockState).toHaveBeenLastCalledWith(false);
});
it('seeds the main-process copy from the mirrored setting at bootstrap', async () => {
const { getParentalLockActive } =
await import('../services/parental-lock-state');
expect(getParentalLockActive()).toBe(true);
});
});
@@ -0,0 +1,85 @@
import { ipcMain, WebContents } from 'electron';
import { PARENTAL_LOCK_SET_STATE } from '@iptvnator/shared/interfaces';
import { databaseWorkerClient } from '../services/database-worker-client';
import {
getParentalLockActive,
setParentalLockActiveState,
} from '../services/parental-lock-state';
import { PARENTAL_LOCK_ENABLED, store } from '../services/store.service';
/**
* Parental lock IPC.
*
* The renderer reports whether locked categories must be withheld (feature
* enabled and no PIN entered). The value is kept in the main process and
* pushed into the SQLite worker, which filters every content read on it.
*
* Like the playback keep-awake vote, the renderer's word must not outlive the
* page that gave it: on reload, navigation or a dead render process the flag
* falls back to the mirrored `parentalLockEnabled` setting, i.e. locked
* while the feature is on. A crashed page can therefore never leave the
* library unlocked.
*/
const senderCleanups = new Map<number, () => void>();
export async function applyParentalLockState(active: boolean): Promise<void> {
setParentalLockActiveState(active);
try {
await databaseWorkerClient.setParentalLockState(
getParentalLockActive()
);
} catch (error) {
console.error('Failed to update parental lock state:', error);
throw error;
}
}
/** The state a renderer that has not announced itself yet must get. */
export function defaultParentalLockState(): boolean {
return store.get(PARENTAL_LOCK_ENABLED, false) === true;
}
function watchSenderLifetime(sender: WebContents): void {
const senderId = sender.id;
if (senderCleanups.has(senderId)) {
return;
}
const relock = () => {
void applyParentalLockState(defaultParentalLockState()).catch(
() => undefined
);
};
const onNavigation = (
event: Electron.Event<Electron.WebContentsDidStartNavigationEventParams>
) => {
if (event.isMainFrame && !event.isSameDocument) {
relock();
}
};
const onDestroyed = () => {
relock();
senderCleanups.get(senderId)?.();
senderCleanups.delete(senderId);
};
sender.on('destroyed', onDestroyed);
sender.on('render-process-gone', relock);
sender.on('did-start-navigation', onNavigation);
senderCleanups.set(senderId, () => {
sender.off('destroyed', onDestroyed);
sender.off('render-process-gone', relock);
sender.off('did-start-navigation', onNavigation);
});
}
export default class ParentalLockEvents {
static bootstrapParentalLockEvents(): Electron.IpcMain {
setParentalLockActiveState(defaultParentalLockState());
return ipcMain;
}
}
ipcMain.handle(PARENTAL_LOCK_SET_STATE, async (event, active: boolean) => {
watchSenderLifetime(event.sender);
await applyParentalLockState(active === true);
});
@@ -38,6 +38,7 @@ const handlers = new Map<string, SettingsUpdateHandler>();
const mockStoreGet = jest.fn();
const mockStoreSet = jest.fn();
const mockUpdateSettings = jest.fn();
const mockApplyParentalLockState = jest.fn();
const mockIpcHandle = jest.fn(
(channel: string, handler: SettingsUpdateHandler): void => {
handlers.set(channel, handler);
@@ -61,12 +62,18 @@ jest.mock('../services/store.service', () => ({
PORTAL_CONNECTIVITY_GUARD: STORE_KEYS.PORTAL_CONNECTIVITY_GUARD,
VLC_PLAYER_ARGUMENTS: STORE_KEYS.VLC_PLAYER_ARGUMENTS,
VLC_REUSE_INSTANCE: STORE_KEYS.VLC_REUSE_INSTANCE,
PARENTAL_LOCK_ENABLED: 'PARENTAL_LOCK_ENABLED',
store: {
get: mockStoreGet,
set: mockStoreSet,
},
}));
jest.mock('./parental-lock.events', () => ({
applyParentalLockState: (...args: unknown[]) =>
mockApplyParentalLockState(...args),
}));
jest.mock('../server/http-server', () => ({
httpServer: {
updateSettings: mockUpdateSettings,
@@ -84,6 +91,7 @@ describe('SETTINGS_UPDATE', () => {
mockStoreGet.mockReset();
mockStoreSet.mockReset();
mockUpdateSettings.mockReset();
mockApplyParentalLockState.mockReset().mockResolvedValue(undefined);
mockStoreGet.mockImplementation(
(_key: string, fallbackValue: unknown): unknown => fallbackValue
);
@@ -136,6 +144,35 @@ describe('SETTINGS_UPDATE', () => {
);
});
it('mirrors the parental lock switch and releases the worker only on switch-off', () => {
// Off → on: persist; the renderer announces its own live state.
settingsUpdateHandler({}, { parentalLockEnabled: true });
expect(mockStoreSet).toHaveBeenCalledWith(
'PARENTAL_LOCK_ENABLED',
true
);
expect(mockApplyParentalLockState).not.toHaveBeenCalled();
// An ordinary save carrying the unchanged flag must not re-lock a
// worker the renderer believes is unlocked.
mockStoreGet.mockImplementation((key: string, fallback: unknown) =>
key === 'PARENTAL_LOCK_ENABLED' ? true : fallback
);
settingsUpdateHandler(
{},
{ parentalLockEnabled: true, showCaptions: true }
);
expect(mockApplyParentalLockState).not.toHaveBeenCalled();
// On → off: release at once.
settingsUpdateHandler({}, { parentalLockEnabled: false });
expect(mockStoreSet).toHaveBeenCalledWith(
'PARENTAL_LOCK_ENABLED',
false
);
expect(mockApplyParentalLockState).toHaveBeenCalledWith(false);
});
it('normalizes external-player arguments and preserves explicit false reuse settings', () => {
settingsUpdateHandler(
{},
@@ -11,6 +11,7 @@ import {
EMBEDDED_MPV_FRAME_COPY,
MPV_PLAYER_ARGUMENTS,
MPV_REUSE_INSTANCE,
PARENTAL_LOCK_ENABLED,
STARTUP_WINDOW_MODE,
PORTAL_CONNECTIVITY_GUARD,
store,
@@ -19,6 +20,7 @@ import {
} from '../services/store.service';
import { httpServer } from '../server/http-server';
import { setHostConnectivityGuardEnabled } from '../util/host-connectivity-guard';
import { applyParentalLockState } from './parental-lock.events';
import { persistAppUpdateChannel } from '../services/app-update-channel';
export default class SettingsEvents {
@@ -42,6 +44,22 @@ ipcMain.handle('SETTINGS_UPDATE', (_event, arg) => {
setHostConnectivityGuardEnabled(enabled);
}
// Mirrored so the database worker and a reloaded renderer start locked
// whenever the feature is on. The LIVE enforcement state is the
// renderer's to announce through PARENTAL_LOCK_SET_STATE: every full
// settings save carries this flag unchanged, so applying it here would
// silently re-lock the worker under a renderer that still shows
// "unlocked". Only a switch-off releases the worker at once — nothing
// may stay withheld once the feature is gone.
if (arg.parentalLockEnabled !== undefined) {
const enabled = arg.parentalLockEnabled === true;
const wasEnabled = store.get(PARENTAL_LOCK_ENABLED, false) === true;
store.set(PARENTAL_LOCK_ENABLED, enabled);
if (wasEnabled && !enabled) {
void applyParentalLockState(false).catch(() => undefined);
}
}
if (arg.mpvPlayerArguments !== undefined) {
store.set(
MPV_PLAYER_ARGUMENTS,
@@ -86,6 +86,7 @@ describe('DatabaseWorkerClient', () => {
nativeModuleSearchPaths: [
'/mock/resources/app.asar.unpacked/node_modules',
],
parentalLockActive: false,
},
});
@@ -2,6 +2,7 @@ import { app } from 'electron';
import { randomUUID } from 'crypto';
import * as path from 'path';
import { pathToFileURL } from 'url';
import { getParentalLockActive } from './parental-lock-state';
import { Worker } from 'worker_threads';
import type {
DbOperationEvent,
@@ -83,6 +84,19 @@ export class DatabaseWorkerClient {
});
}
/**
* Flips the worker's parental lock filter. Awaits readiness so the
* message is never lost to a worker that has not started, and is posted
* on the request port so later reads observe it in order.
*/
async setParentalLockState(active: boolean): Promise<void> {
await this.ensureWorker();
this.worker?.postMessage({
type: 'parental-lock',
active: active === true,
});
}
async cancel(operationId: string): Promise<{ success: boolean }> {
if (!operationId) {
return { success: false };
@@ -157,6 +171,10 @@ export class DatabaseWorkerClient {
this.worker = new Worker(workerURL, {
workerData: {
nativeModuleSearchPaths: bootstrap.nativeModuleSearchPaths,
// Seeded here rather than requested afterwards, so a
// restarted worker never answers a read unfiltered while
// the lock state is still in flight.
parentalLockActive: getParentalLockActive(),
},
});
} catch (error) {
@@ -0,0 +1,18 @@
/**
* Main-process copy of the parental lock enforcement flag.
*
* The renderer owns the unlock decision and reports it over
* `PARENTAL_LOCK_SET_STATE`; this module remembers the latest value so the
* database worker can be seeded with it whenever it is (re)started, and so a
* renderer reload or crash can fall back to "locked while the feature is on"
* without waiting for a page that may never come back.
*/
let parentalLockActive = false;
export function getParentalLockActive(): boolean {
return parentalLockActive;
}
export function setParentalLockActiveState(active: boolean): void {
parentalLockActive = active === true;
}
@@ -33,6 +33,14 @@ export const STARTUP_WINDOW_MODE = 'STARTUP_WINDOW_MODE';
/** Desktop portal request cooldown; absent means enabled. */
export const PORTAL_CONNECTIVITY_GUARD = 'PORTAL_CONNECTIVITY_GUARD';
/**
* Parental lock feature switch, mirrored from the renderer's settings by the
* SETTINGS_UPDATE handler. Read when the database worker starts and when a
* renderer reloads or dies, so the SQLite reads are locked before any page
* has announced its lock state. Absent means off.
*/
export const PARENTAL_LOCK_ENABLED = 'PARENTAL_LOCK_ENABLED';
/**
* Update channel (`stable` / `nightly`). Mirrored here from the renderer's
* settings by the SETTINGS_UPDATE handler because the startup update check
@@ -78,6 +86,7 @@ export type StoreType = {
[EMBEDDED_MPV_AUTO_RECONNECT]: boolean;
[STARTUP_WINDOW_MODE]: StartupWindowMode;
[PORTAL_CONNECTIVITY_GUARD]: boolean;
[PARENTAL_LOCK_ENABLED]: boolean;
[APP_UPDATE_CHANNEL]: AppUpdateChannel;
[TRUSTED_LOCAL_EPG_SOURCES]: string[];
};
@@ -35,6 +35,7 @@ import { shutdownMpvSession } from '../events/mpv-session.service';
import PlayerEvents from '../events/player.events';
import { shutdownVlcSession } from '../events/vlc-session.service';
import PlaylistEvents from '../events/playlist.events';
import ParentalLockEvents from '../events/parental-lock.events';
import RemoteControlEvents from '../events/remote-control.events';
import SettingsEvents from '../events/settings.events';
import SharedEvents from '../events/shared.events';
@@ -85,6 +86,7 @@ export function bootstrapDeferredEvents(
SharedEvents.bootstrapSharedEvents();
PlayerEvents.bootstrapPlayerEvents();
SettingsEvents.bootstrapSettingsEvents();
ParentalLockEvents.bootstrapParentalLockEvents();
StalkerEvents.bootstrapStalkerEvents();
XtreamEvents.bootstrapXtreamEvents();
registerStreamProbeHandlers();
@@ -6,6 +6,7 @@ export const DB_WORKER_OPERATIONS = [
'DB_SAVE_CATEGORIES',
'DB_GET_ALL_CATEGORIES',
'DB_UPDATE_CATEGORY_VISIBILITY',
'DB_SET_CATEGORY_LOCKS',
'DB_HAS_CONTENT',
'DB_GET_CONTENT',
'DB_GET_GLOBAL_RECENTLY_ADDED',
@@ -131,6 +132,16 @@ export interface DbWorkerCancelMessage {
operationId: string;
}
/**
* Main-process control message, not a renderer request: flips the worker's
* parental lock filter. Ordered with the requests on the same port, so a
* read posted after it observes the new state.
*/
export interface DbWorkerParentalLockMessage {
type: 'parental-lock';
active: boolean;
}
export interface DbWorkerReadyMessage {
type: 'ready';
}
@@ -158,7 +169,9 @@ export interface DbWorkerResponseMessage<TResult = unknown> {
}
export type DbWorkerIncomingMessage =
DbWorkerRequestMessage | DbWorkerCancelMessage;
| DbWorkerRequestMessage
| DbWorkerCancelMessage
| DbWorkerParentalLockMessage;
export type DbWorkerMessage =
| DbWorkerReadyMessage
@@ -3,7 +3,7 @@ import {
closeWorkerDatabase,
getWorkerDatabase,
} from './database.worker-connection';
import { parentPort } from 'worker_threads';
import { parentPort, workerData } from 'worker_threads';
import type {
ContentMetadataPatch,
VodSourcePin,
@@ -25,8 +25,10 @@ import {
getCategories,
hasCategories,
saveCategories,
setCategoryLocks,
updateCategoryVisibility,
} from '../database/operations/category.operations';
import { setParentalLockActive } from '../database/parental-lock-state';
import {
addFavorite,
getAllGlobalFavorites,
@@ -133,6 +135,12 @@ import {
} from './operation-progress-throttle';
const loggerLabel = '[DB Worker]';
// Seeded by the main process so a (re)started worker is locked before its
// first read; DB_SET_PARENTAL_LOCK_STATE updates it afterwards.
setParentalLockActive(
(workerData as { parentalLockActive?: unknown } | undefined)
?.parentalLockActive === true
);
const batchDelayMs = Number.parseInt(
process.env['IPTVNATOR_DB_WORKER_BATCH_DELAY_MS'] ?? '0',
10
@@ -440,6 +448,7 @@ async function executeRequest(
}>;
type: 'live' | 'movies' | 'series';
hiddenCategoryXtreamIds?: number[];
lockedCategoryXtreamIds?: number[];
};
const capturePhase =
createWorkerPerformancePhaseAdapter(performanceCapture);
@@ -449,6 +458,7 @@ async function executeRequest(
payload.categories,
payload.type,
payload.hiddenCategoryXtreamIds,
payload.lockedCategoryXtreamIds,
capturePhase
);
}
@@ -473,6 +483,20 @@ async function executeRequest(
);
}
case 'DB_SET_CATEGORY_LOCKS': {
const payload = message.payload as {
playlistId: string;
type: 'live' | 'movies' | 'series';
lockedXtreamIds: number[];
};
return setCategoryLocks(
db,
payload.playlistId,
payload.type,
payload.lockedXtreamIds
);
}
case 'DB_HAS_CONTENT': {
const payload = message.payload as {
playlistId: string;
@@ -1246,6 +1270,11 @@ parentPort.on('message', async (message: DbWorkerIncomingMessage) => {
return;
}
if (message.type === 'parental-lock') {
setParentalLockActive(message.active === true);
return;
}
if (message.type === 'cancel') {
const activeOperation = activeOperations.get(message.operationId);
if (activeOperation) {
Binary file not shown.

After

Width:  |  Height:  |  Size: 98 KiB

+70 -14
View File
@@ -12,7 +12,9 @@ async function openSettings(page: Page) {
/** Settings render one section page at a time — open it via the rail. */
async function openSettingsSection(page: Page, sectionId: string) {
await page.locator(`[data-test-id="settings-section-${sectionId}"]`).click();
await page
.locator(`[data-test-id="settings-section-${sectionId}"]`)
.click();
await page.waitForURL(new RegExp(`/workspace/settings/${sectionId}$`));
}
@@ -41,11 +43,11 @@ test.describe('Settings', () => {
await openSettings(page);
await openSettingsSection(page, 'playback');
const playerSelect = page.locator('[data-test-id="select-video-player"]');
await expect(playerSelect).toContainText(
/Video\.js/i
const playerSelect = page.locator(
'[data-test-id="select-video-player"]'
);
await expect(playerSelect).toContainText(/Video\.js/i);
await playerSelect.click();
await page.locator('mat-option[data-test-id="html5"]').click();
@@ -54,9 +56,7 @@ test.describe('Settings', () => {
await openSettings(page);
await openSettingsSection(page, 'playback');
await expect(playerSelect).toContainText(
/HTML5/i
);
await expect(playerSelect).toContainText(/HTML5/i);
});
test('@settings @web Opt out of shared web player controls', async ({
@@ -235,13 +235,71 @@ test.describe('Settings', () => {
).toHaveAttribute('aria-checked', 'true');
});
test('@settings @web Parental lock — set a PIN, lock, survive a reload, unlock', async ({
page,
}) => {
await openSettings(page);
await openSettingsSection(page, 'parental');
const enableToggle = page.locator(
'[data-test-id="parental-lock-enabled"] button[role="switch"]'
);
const pinInput = page.locator('[data-test-id="parental-lock-pin"]');
const pinConfirm = page.locator(
'[data-test-id="parental-lock-pin-confirm"]'
);
const pinSubmit = page.locator(
'[data-test-id="parental-lock-pin-submit"]'
);
const lockNow = page.locator('[data-test-id="parental-lock-lock-now"]');
const unlock = page.locator('[data-test-id="parental-lock-unlock"]');
const headerLock = page.locator(
'[data-test-id="header-parental-lock"]'
);
// Enabling asks for a new PIN twice; the parent stays unlocked.
await expect(enableToggle).toHaveAttribute('aria-checked', 'false');
await enableToggle.click();
await expect(pinInput).toBeVisible();
await pinInput.fill('2468');
await pinConfirm.fill('2468');
await pinSubmit.click();
await expect(enableToggle).toHaveAttribute('aria-checked', 'true');
await expect(lockNow).toBeVisible();
await expect(headerLock).toBeVisible();
// Lock now flips the state; a reload keeps the lock (never persisted
// as unlocked).
await lockNow.click();
await expect(unlock).toBeVisible();
await page.reload();
await openSettings(page);
await openSettingsSection(page, 'parental');
await expect(enableToggle).toHaveAttribute('aria-checked', 'true');
await expect(unlock).toBeVisible();
// A wrong PIN is refused, the right one unlocks.
await unlock.click();
await expect(pinInput).toBeVisible();
await pinInput.fill('0000');
await pinSubmit.click();
await expect(
page.locator('[data-test-id="parental-lock-pin-error"]')
).toBeVisible();
await pinInput.fill('2468');
await pinSubmit.click();
await expect(lockNow).toBeVisible();
// The header button locks from anywhere.
await headerLock.click();
await expect(unlock).toBeVisible();
});
test('@settings @web Change app language', async ({ page }) => {
await openSettings(page);
const languageSelect = page.locator('[data-test-id="select-language"]');
await expect(languageSelect).toContainText(
'English'
);
await expect(languageSelect).toContainText('English');
await languageSelect.click();
await page.locator('mat-option[data-test-id="de"]').click();
@@ -249,9 +307,7 @@ test.describe('Settings', () => {
await page.reload();
await openSettings(page);
await expect(languageSelect).toContainText(
'Deutsch'
);
await expect(languageSelect).toContainText('Deutsch');
});
test('@settings @search @web Search settings from the header and open a result', async ({
+10
View File
@@ -17,9 +17,11 @@ import { EMPTY, of } from 'rxjs';
import {
DataService,
EpgSourceSettingsService,
ParentalLockService,
SettingsStore,
RuntimeCapabilitiesService,
} from '@iptvnator/services';
import { ParentalLockEnforcementService } from './services/parental-lock-enforcement.service';
import {
Language,
Settings,
@@ -97,6 +99,14 @@ describe('AppComponent', () => {
imports: [AppComponent],
providers: [
provideMockStore(),
// The parental lock boots from AppComponent; its collaborators
// (Xtream/Stalker stores, SQLite bridge) are out of scope here.
MockProvider(ParentalLockService, {
initialize: jest.fn().mockResolvedValue(undefined),
}),
MockProvider(ParentalLockEnforcementService, {
start: jest.fn(),
}),
{
provide: Actions,
useValue: new Actions(EMPTY),
+11
View File
@@ -28,6 +28,7 @@ import { PlaylistActions, selectAllPlaylistsMeta } from '@iptvnator/m3u-state';
import { filter, take } from 'rxjs';
import {
DataService,
ParentalLockService,
RuntimeCapabilitiesService,
SettingsStore,
EpgSourceSettingsService,
@@ -42,6 +43,7 @@ import {
} from '@iptvnator/shared/interfaces';
import { AppDateLocaleService } from './app-date-locales';
import { SettingsService } from './services/settings.service';
import { ParentalLockEnforcementService } from './services/parental-lock-enforcement.service';
import { PlaybackKeepAwakeService } from './services/playback-keep-awake.service';
import { PlaylistOpenRequestService } from './services/playlist-open-request.service';
import { AppUpdateNotificationPanelComponent } from './app-update-notification-panel.component';
@@ -83,6 +85,10 @@ export class AppComponent implements OnInit {
private settingsStore = inject(SettingsStore);
private readonly epgSources = inject(EpgSourceSettingsService);
private playbackKeepAwake = inject(PlaybackKeepAwakeService);
private readonly parentalLock = inject(ParentalLockService);
private readonly parentalLockEnforcement = inject(
ParentalLockEnforcementService
);
private playlistOpenRequests = inject(PlaylistOpenRequestService);
private runtime = inject(RuntimeCapabilitiesService);
private readonly workspaceShellActions = inject(WORKSPACE_SHELL_ACTIONS);
@@ -108,6 +114,11 @@ export class AppComponent implements OnInit {
// (Electron powerSaveBlocker / PWA Screen Wake Lock, issue #1095).
this.playbackKeepAwake.start();
// Parental lock: load the PIN hash and lock store, then keep the
// in-memory catalogs in step with lock/unlock (issue #285).
void this.parentalLock.initialize();
this.parentalLockEnforcement.start();
effect(() => {
const size = this.settingsStore.coverSize?.() ?? 'medium';
document.documentElement.dataset.coverSize = size;
+7 -1
View File
@@ -33,13 +33,17 @@ import {
} from '@iptvnator/portal/shared/util';
import { STALKER_PLAYLIST_CONNECTION_EDITOR } from '@iptvnator/playlist/shared/ui/stalker-connection-editor';
import { provideXtreamDataSource } from '@iptvnator/portal/xtream/data-access';
import { DataService } from '@iptvnator/services';
import {
provideParentalLockPlaylistCleanup,
DataService,
} from '@iptvnator/services';
import { dbConfig } from '@iptvnator/shared/interfaces';
import { AppConfig } from '../environments/environment';
import { routes } from './app.routes';
import { ElectronService } from './services/electron.service';
import { ExternalPlaybackService } from './services/external-playback.service';
import { PlayerService } from './services/player.service';
import { provideParentalLockPrompt } from './services/parental-lock-prompt.service';
import { providePortalPlaybackPositions } from './services/portal-playback-positions.service';
import { PwaService } from './services/pwa.service';
import { shouldEnableServiceWorker } from './services/runtime-config';
@@ -160,6 +164,8 @@ export const appConfig: ApplicationConfig = {
useExisting: LazyStalkerPlaylistConnectionEditor,
},
...provideWorkspaceShellActions(),
...provideParentalLockPrompt(),
provideParentalLockPlaylistCleanup(),
...provideXtreamDataSource(),
{
provide: MAT_FORM_FIELD_DEFAULT_OPTIONS,
+54 -3
View File
@@ -1,5 +1,5 @@
import { TestBed } from '@angular/core/testing';
import { SettingsStore } from '@iptvnator/services';
import { ParentalLockService, SettingsStore } from '@iptvnator/services';
describe('app routes', () => {
let workspaceRoute: import('@angular/router').Route | undefined;
@@ -67,6 +67,52 @@ describe('app routes', () => {
TestBed.resetTestingModule();
});
it('waits for the parental lock state before activating workspace children', async () => {
let releaseLock!: () => void;
const lockPending = new Promise<void>((resolve) => {
releaseLock = resolve;
});
const initialize = jest.fn(() => lockPending);
TestBed.configureTestingModule({
providers: [
{
provide: SettingsStore,
useValue: {
loadSettings: jest.fn().mockResolvedValue(undefined),
},
},
{ provide: ParentalLockService, useValue: { initialize } },
],
});
const settingsReadyResolver =
workspaceRoute?.resolve?.['settingsReady'];
if (typeof settingsReadyResolver !== 'function') {
throw new Error('resolver missing');
}
let resolved = false;
const resolution = TestBed.runInInjectionContext(() =>
Promise.resolve(
(
settingsReadyResolver as import('@angular/router').ResolveFn<void>
)(
{} as import('@angular/router').ActivatedRouteSnapshot,
{} as import('@angular/router').RouterStateSnapshot
)
)
).then(() => {
resolved = true;
});
await Promise.resolve();
await Promise.resolve();
expect(initialize).toHaveBeenCalled();
expect(resolved).toBe(false);
releaseLock();
await resolution;
expect(resolved).toBe(true);
});
it('waits for settings before activating workspace children', async () => {
let releaseSettings!: () => void;
const settingsPending = new Promise<void>((resolve) => {
@@ -79,6 +125,12 @@ describe('app routes', () => {
provide: SettingsStore,
useValue: { loadSettings },
},
{
provide: ParentalLockService,
useValue: {
initialize: jest.fn().mockResolvedValue(undefined),
},
},
],
});
const settingsReadyResolver =
@@ -120,8 +172,7 @@ describe('app routes', () => {
(route) => route.path === 'playlists/:id'
);
const loadChildren = playlistRoute?.loadChildren as
| (() => Promise<typeof workspaceChildren>)
| undefined;
(() => Promise<typeof workspaceChildren>) | undefined;
const m3uRoutes = (await loadChildren?.()) ?? [];
const defaultRoute = m3uRoutes.find((route) => route.path === '');
const favoritesRoute = m3uRoutes.find(
+16 -2
View File
@@ -1,10 +1,24 @@
import { inject } from '@angular/core';
import { Router, Routes } from '@angular/router';
import { RuntimeCapabilitiesService, SettingsStore } from '@iptvnator/services';
import {
ParentalLockService,
RuntimeCapabilitiesService,
SettingsStore,
} from '@iptvnator/services';
import { WorkspaceStartupPreferencesService } from '@iptvnator/workspace/shell/util';
import { settingsUnsavedChangesGuard } from './settings/settings-unsaved-changes.guard';
const settingsReadyResolver = () => inject(SettingsStore).loadSettings();
// The workspace activates only once settings AND the parental lock state
// (PIN, lock store) are known: before that the lock reads as off and a
// slower IndexedDB read would let the catalogs admit protected rows.
const settingsReadyResolver = async () => {
const settingsStore = inject(SettingsStore);
const parentalLock = inject(ParentalLockService);
await Promise.all([
settingsStore.loadSettings(),
parentalLock.initialize(),
]);
};
const workspaceEntryRedirect = async () =>
inject(WorkspaceStartupPreferencesService).resolveInitialWorkspacePath();
@@ -0,0 +1,583 @@
import { signal } from '@angular/core';
import { TestBed } from '@angular/core/testing';
import { Router } from '@angular/router';
import { Store } from '@ngrx/store';
import { StalkerStore } from '@iptvnator/portal/stalker/data-access';
import {
XTREAM_DATA_SOURCE,
XtreamStore,
} from '@iptvnator/portal/xtream/data-access';
import { ParentalLockService } from '@iptvnator/services';
import { ParentalLockEnforcementService } from './parental-lock-enforcement.service';
import { PlaybackKeepAwakeService } from './playback-keep-awake.service';
interface Applier {
apply(): Promise<void>;
failClosedNow(): void;
applyXtream(version: number): Promise<void>;
applyStalker(): Promise<void>;
}
describe('ParentalLockEnforcementService', () => {
const router = { url: '/', navigate: jest.fn() };
const activeChannel = signal<{ group?: { title: string } } | null>(null);
const dispatch = jest.fn();
const lockedStalkerIds = new Set<string>();
const parentalLock = {
version: signal(0),
active: signal(false),
registerBusyProbe: jest.fn(),
isXtreamCategoryLocked: jest.fn(() => false),
isStalkerCategoryLocked: jest.fn(
(_playlistId: string, _type: string, id: unknown) =>
id !== null &&
id !== undefined &&
lockedStalkerIds.has(String(id))
),
isM3uGroupLocked: jest.fn(() => false),
};
const stalkerStore = {
currentPlaylist: signal<{ _id: string } | null>({ _id: 'stalker-1' }),
selectedContentType: signal<string>('vod'),
selectedCategoryId: signal<string | null>('*'),
selectedItem: signal<{
category_id?: string;
tv_genre_id?: string;
} | null>(null),
clearSelectedItem: jest.fn(),
setSelectedCategory: jest.fn(),
};
const xtreamStore = {
playlistId: signal<string | null>('xtream-1'),
selectedCategoryId: signal<number | null>(null),
selectedItem: signal<{ category_id?: number } | null>(null),
reloadCategories: jest.fn(async () => undefined),
reloadCachedContent: jest.fn(async () => undefined),
refreshSearchResults: jest.fn(async () => undefined),
withholdCatalog: jest.fn(),
clearSearchResults: jest.fn(),
getCategoriesBySelectedType: jest.fn(() => [
{ id: 7, xtream_id: 70 },
{ id: 8, xtream_id: 80 },
]),
setSelectedItem: jest.fn(),
setSelectedCategory: jest.fn(),
};
const xtreamDataSource = {
getAllCategories: jest.fn(async () => [
{ id: 7, xtream_id: 70 },
{ id: 8, xtream_id: 80 },
{ id: 55, xtream_id: 550 },
{ id: 99, xtream_id: 990 },
]),
};
let service: Applier;
beforeEach(() => {
jest.clearAllMocks();
lockedStalkerIds.clear();
router.url = '/';
parentalLock.active.set(false);
parentalLock.isXtreamCategoryLocked.mockReturnValue(false);
stalkerStore.selectedCategoryId.set('*');
stalkerStore.selectedItem.set(null);
xtreamStore.selectedCategoryId.set(null);
xtreamStore.selectedItem.set(null);
TestBed.configureTestingModule({
providers: [
{ provide: ParentalLockService, useValue: parentalLock },
{ provide: XtreamStore, useValue: xtreamStore },
{ provide: XTREAM_DATA_SOURCE, useValue: xtreamDataSource },
{ provide: StalkerStore, useValue: stalkerStore },
{ provide: Router, useValue: router },
{
provide: Store,
useValue: { selectSignal: () => activeChannel, dispatch },
},
{
provide: PlaybackKeepAwakeService,
useValue: { hasPlayingVideo: () => false },
},
],
});
activeChannel.set(null);
service = TestBed.inject(
ParentalLockEnforcementService
) as unknown as Applier;
});
describe('M3U', () => {
it('resets a locked playing channel before awaiting the portal reloads', async () => {
router.url = '/workspace/playlists/m3u-1';
activeChannel.set({ group: { title: 'Adult' } });
parentalLock.isM3uGroupLocked.mockReturnValue(true);
let releaseReload: () => void = () => undefined;
xtreamStore.reloadCategories.mockImplementationOnce(
() => new Promise<void>((resolve) => (releaseReload = resolve))
);
const applying = service.apply();
await Promise.resolve();
expect(parentalLock.isM3uGroupLocked).toHaveBeenCalledWith(
'm3u-1',
'Adult'
);
expect(dispatch).toHaveBeenCalledTimes(1);
expect(xtreamStore.reloadCategories).toHaveBeenCalledTimes(1);
releaseReload();
await applying;
parentalLock.isM3uGroupLocked.mockReturnValue(false);
});
});
it('resets a channel of a locked group as soon as it becomes active while locked', () => {
// Numeric zapping, next/previous or a remote command can select a
// channel without any lock-version change following it.
router.url = '/workspace/playlists/m3u-1';
parentalLock.active.set(true);
parentalLock.isM3uGroupLocked.mockImplementation(
(_playlistId: string, group: string) => group === 'Adult'
);
TestBed.runInInjectionContext(() =>
(service as unknown as { start(): void }).start()
);
TestBed.flushEffects();
dispatch.mockClear();
activeChannel.set({ group: { title: 'News' } });
TestBed.flushEffects();
expect(dispatch).not.toHaveBeenCalled();
activeChannel.set({ group: { title: 'Adult' } });
TestBed.flushEffects();
expect(dispatch).toHaveBeenCalledTimes(1);
parentalLock.isM3uGroupLocked.mockReset();
parentalLock.isM3uGroupLocked.mockReturnValue(false);
});
describe('Stalker', () => {
it('leaves the Stalker route when the Stalker step cannot load', async () => {
router.url = '/workspace/stalker/stalker-1/itv';
jest.spyOn(console, 'error').mockImplementation(() => undefined);
(
service as unknown as {
loadStalkerEnforcement: () => Promise<unknown>;
}
).loadStalkerEnforcement = () =>
Promise.reject(new Error('ChunkLoadError'));
await expect(service.applyStalker()).resolves.toBeUndefined();
expect(router.navigate).toHaveBeenCalledWith([
'/workspace',
'sources',
]);
});
it('leaves the Stalker route synchronously on relock while the step is not loaded', () => {
router.url = '/workspace/stalker/stalker-1/itv';
(
service as unknown as {
loadStalkerEnforcement: () => Promise<unknown>;
}
).loadStalkerEnforcement = () => new Promise(() => undefined);
service.failClosedNow();
expect(router.navigate).toHaveBeenCalledWith([
'/workspace',
'sources',
]);
});
it('runs a preloaded Stalker step synchronously on relock', async () => {
router.url = '/workspace/stalker/stalker-1/vod/42';
await service.applyStalker(); // loads the step
lockedStalkerIds.add('9');
stalkerStore.selectedItem.set({ category_id: '9' });
stalkerStore.clearSelectedItem.mockClear();
service.failClosedNow();
expect(stalkerStore.clearSelectedItem).toHaveBeenCalled();
});
it('does not load the Stalker step outside a Stalker route', async () => {
router.url = '/workspace/xtreams/xtream-1/live';
const load = jest.spyOn(
service as unknown as { loadStalkerEnforcement: () => unknown },
'loadStalkerEnforcement'
);
await service.applyStalker();
expect(load).not.toHaveBeenCalled();
expect(stalkerStore.clearSelectedItem).not.toHaveBeenCalled();
});
it('clears a detail opened from All whose own genre is withheld', async () => {
router.url = '/workspace/stalker/stalker-1/vod/42';
lockedStalkerIds.add('9');
stalkerStore.selectedItem.set({ category_id: '9' });
await service.applyStalker();
expect(stalkerStore.clearSelectedItem).toHaveBeenCalled();
// "All" itself is not locked, so the category stays selected.
expect(stalkerStore.setSelectedCategory).not.toHaveBeenCalled();
expect(router.navigate).toHaveBeenCalledWith([
'/workspace',
'stalker',
'stalker-1',
'vod',
]);
});
it('leaves a detail from All alone when its genre is not locked', async () => {
router.url = '/workspace/stalker/stalker-1/vod/42';
lockedStalkerIds.add('9');
stalkerStore.selectedItem.set({ category_id: '3' });
await service.applyStalker();
expect(stalkerStore.clearSelectedItem).not.toHaveBeenCalled();
expect(router.navigate).not.toHaveBeenCalled();
});
it('judges a live channel from All by its genre, not by category_id', async () => {
router.url = '/workspace/stalker/stalker-1/itv';
stalkerStore.selectedContentType.set('itv');
lockedStalkerIds.add('9');
stalkerStore.selectedItem.set({
tv_genre_id: '9',
category_id: '3',
});
await service.applyStalker();
expect(stalkerStore.clearSelectedItem).toHaveBeenCalled();
expect(router.navigate).toHaveBeenCalledWith([
'/workspace',
'stalker',
'stalker-1',
'itv',
]);
stalkerStore.selectedContentType.set('vod');
});
it('steps off a locked selected category', async () => {
router.url = '/workspace/stalker/stalker-1/vod';
lockedStalkerIds.add('9');
stalkerStore.selectedCategoryId.set('9');
await service.applyStalker();
expect(stalkerStore.clearSelectedItem).toHaveBeenCalled();
expect(stalkerStore.setSelectedCategory).toHaveBeenCalledWith(null);
});
});
describe('Xtream', () => {
function lockProvider(providerId: number): void {
parentalLock.active.set(true);
parentalLock.isXtreamCategoryLocked.mockImplementation(
(_p: string, _t: string, id: number) => id === providerId
);
}
it('clears a selected item whose category the lock store withholds', async () => {
router.url = '/workspace/xtreams/xtream-1/vod/42';
lockProvider(990);
xtreamStore.selectedItem.set({ category_id: 99 });
await service.applyXtream(parentalLock.version());
expect(xtreamStore.reloadCategories).toHaveBeenCalled();
expect(xtreamDataSource.getAllCategories).toHaveBeenCalledWith(
'xtream-1',
'movies'
);
expect(xtreamStore.setSelectedItem).toHaveBeenCalledWith(null);
expect(xtreamStore.setSelectedCategory).not.toHaveBeenCalled();
expect(router.navigate).toHaveBeenCalledWith([
'/workspace',
'xtreams',
'xtream-1',
'vod',
]);
});
it('keeps a detail from a category that is merely hidden, not locked', async () => {
router.url = '/workspace/xtreams/xtream-1/vod/42';
lockProvider(990);
// Row 55 is absent from the (hidden-filtered) visible list but
// exists unlocked in the unfiltered rows.
xtreamStore.selectedItem.set({ category_id: 55 });
await service.applyXtream(parentalLock.version());
expect(xtreamStore.setSelectedItem).not.toHaveBeenCalled();
expect(router.navigate).not.toHaveBeenCalled();
});
it('steps off a selected locked category', async () => {
router.url = '/workspace/xtreams/xtream-1/live';
lockProvider(990);
xtreamStore.selectedCategoryId.set(99);
await service.applyXtream(parentalLock.version());
expect(xtreamStore.setSelectedItem).toHaveBeenCalledWith(null);
expect(xtreamStore.setSelectedCategory).toHaveBeenCalledWith(null);
expect(router.navigate).toHaveBeenCalledWith([
'/workspace',
'xtreams',
'xtream-1',
'live',
]);
});
it('fails closed when the category rows cannot be read', async () => {
router.url = '/workspace/xtreams/xtream-1/vod/42';
parentalLock.active.set(true);
xtreamDataSource.getAllCategories.mockRejectedValueOnce(
new Error('db')
);
xtreamStore.selectedItem.set({ category_id: 7 });
await service.applyXtream(parentalLock.version());
expect(xtreamStore.setSelectedItem).toHaveBeenCalledWith(null);
});
it('skips the post-reload checks while unlocked and hands the reloads a publish guard', async () => {
router.url = '/workspace/xtreams/xtream-1/vod';
await service.applyXtream(parentalLock.version());
expect(xtreamStore.withholdCatalog).not.toHaveBeenCalled();
expect(xtreamDataSource.getAllCategories).not.toHaveBeenCalled();
const guard = xtreamStore.reloadCategories.mock.calls[0][0] as
(() => boolean) | undefined;
expect(guard?.()).toBe(true);
parentalLock.version.set(parentalLock.version() + 1);
expect(guard?.()).toBe(false);
});
it('abandons the reloads and checks once the Xtream playlist is switched', async () => {
router.url = '/workspace/xtreams/xtream-1/vod/7';
parentalLock.active.set(true);
xtreamStore.selectedCategoryId.set(7);
parentalLock.isXtreamCategoryLocked.mockReturnValue(true);
let guard: () => boolean = () => true;
(xtreamStore.reloadCategories as jest.Mock).mockImplementationOnce(
async (shouldPublish: () => boolean) => {
guard = shouldPublish;
xtreamStore.playlistId.set('xtream-2');
router.url = '/workspace/xtreams/xtream-2/vod';
}
);
try {
await service.applyXtream(parentalLock.version());
expect(guard()).toBe(false);
expect(xtreamStore.refreshSearchResults).not.toHaveBeenCalled();
expect(
xtreamDataSource.getAllCategories
).not.toHaveBeenCalled();
expect(xtreamStore.setSelectedCategory).not.toHaveBeenCalled();
expect(router.navigate).not.toHaveBeenCalled();
} finally {
xtreamStore.playlistId.set('xtream-1');
}
});
it('re-runs the stored in-portal search after the reload', async () => {
router.url = '/workspace/xtreams/xtream-1/search';
await service.applyXtream(parentalLock.version());
expect(xtreamStore.refreshSearchResults).toHaveBeenCalled();
});
it('clears on relock, synchronously, a detail whose category is not in the visible list', () => {
// Opened through search from a manually hidden category: the
// on-screen list cannot place it, and the unfiltered lookup is
// an awaited read that may hang.
router.url = '/workspace/xtreams/xtream-1/vod/55/900';
xtreamStore.selectedItem.set({ category_id: 55 });
service.failClosedNow();
expect(xtreamStore.setSelectedItem).toHaveBeenCalledWith(null);
expect(router.navigate).toHaveBeenCalledWith([
'/workspace',
'xtreams',
'xtream-1',
'vod',
]);
});
it('keeps a detail on relock whose visible category is not locked', () => {
router.url = '/workspace/xtreams/xtream-1/vod/7/900';
xtreamStore.selectedItem.set({ category_id: 7 });
service.failClosedNow();
expect(xtreamStore.setSelectedItem).not.toHaveBeenCalled();
});
it('fails closed synchronously on relock: detail, catalog and search', () => {
router.url = '/workspace/xtreams/xtream-1/vod/42';
lockProvider(70);
xtreamStore.selectedItem.set({ category_id: 7 });
service.failClosedNow();
expect(xtreamStore.setSelectedItem).toHaveBeenCalledWith(null);
expect(xtreamStore.withholdCatalog).toHaveBeenCalled();
expect(xtreamStore.clearSearchResults).toHaveBeenCalled();
expect(xtreamStore.reloadCategories).not.toHaveBeenCalled();
});
});
});
describe('ParentalLockEnforcementService apply serialization', () => {
it('runs applies one at a time and abandons a result superseded by a newer version', async () => {
const version = signal(0);
let releaseReload: () => void = () => undefined;
const xtreamStore = {
playlistId: signal('xtream-1'),
selectedCategoryId: signal<number | null>(99),
selectedItem: signal(null),
reloadCategories: jest.fn(
() => new Promise<void>((resolve) => (releaseReload = resolve))
),
reloadCachedContent: jest.fn(async () => undefined),
refreshSearchResults: jest.fn(async () => undefined),
withholdCatalog: jest.fn(),
clearSearchResults: jest.fn(),
getCategoriesBySelectedType: jest.fn(() => [] as unknown[]),
setSelectedItem: jest.fn(),
setSelectedCategory: jest.fn(),
};
TestBed.configureTestingModule({
providers: [
{
provide: ParentalLockService,
useValue: {
version,
active: signal(true),
isXtreamCategoryLocked: jest.fn(() => false),
registerBusyProbe: jest.fn(),
isStalkerCategoryLocked: jest.fn(() => false),
isM3uGroupLocked: jest.fn(() => false),
},
},
{ provide: XtreamStore, useValue: xtreamStore },
{
// No rows: the selected category cannot be placed and
// fails closed once an apply gets to judge it.
provide: XTREAM_DATA_SOURCE,
useValue: { getAllCategories: jest.fn(async () => []) },
},
{
provide: StalkerStore,
useValue: { currentPlaylist: signal(null) },
},
{
provide: Router,
useValue: {
url: '/workspace/xtreams/xtream-1/live',
navigate: jest.fn(),
},
},
{
provide: Store,
useValue: {
selectSignal: () => signal(null),
dispatch: jest.fn(),
},
},
{
provide: PlaybackKeepAwakeService,
useValue: { hasPlayingVideo: () => false },
},
],
});
const service = TestBed.inject(ParentalLockEnforcementService);
TestBed.runInInjectionContext(() => service.start());
TestBed.flushEffects();
// Unlock: the reload is held open...
version.set(1);
TestBed.flushEffects();
await Promise.resolve();
expect(xtreamStore.reloadCategories).toHaveBeenCalledTimes(1);
// ...and "Lock now" arrives meanwhile: no second reload starts yet,
// but the catalog is withheld at once rather than behind the hung
// read.
xtreamStore.withholdCatalog.mockClear();
version.set(2);
TestBed.flushEffects();
expect(xtreamStore.withholdCatalog).toHaveBeenCalledTimes(1);
await Promise.resolve();
expect(xtreamStore.reloadCategories).toHaveBeenCalledTimes(1);
// The superseded apply must not act on its (unlocked) rows.
releaseReload();
await new Promise((resolve) => setTimeout(resolve, 0));
expect(xtreamStore.reloadCategories).toHaveBeenCalledTimes(2);
expect(xtreamStore.setSelectedCategory).not.toHaveBeenCalled();
releaseReload();
await new Promise((resolve) => setTimeout(resolve, 0));
expect(xtreamStore.setSelectedCategory).toHaveBeenCalledTimes(1);
});
});
describe('ParentalLockEnforcementService busy probe', () => {
it('counts playing audio (the radio player) as activity', () => {
let probe: (() => boolean) | undefined;
TestBed.resetTestingModule();
TestBed.configureTestingModule({
providers: [
{
provide: ParentalLockService,
useValue: {
version: signal(0),
registerBusyProbe: (fn: () => boolean) => (probe = fn),
},
},
{ provide: XtreamStore, useValue: {} },
{ provide: XTREAM_DATA_SOURCE, useValue: {} },
{ provide: StalkerStore, useValue: {} },
{ provide: Router, useValue: { url: '/' } },
{
provide: Store,
useValue: { selectSignal: () => signal(null) },
},
{
provide: PlaybackKeepAwakeService,
useValue: { hasPlayingVideo: () => false },
},
],
});
const service = TestBed.inject(ParentalLockEnforcementService);
TestBed.runInInjectionContext(() => service.start());
expect(probe?.()).toBe(false);
const audio = document.createElement('audio');
Object.defineProperty(audio, 'paused', { value: false });
Object.defineProperty(audio, 'ended', { value: false });
document.body.appendChild(audio);
try {
expect(probe?.()).toBe(true);
} finally {
audio.remove();
}
});
});
@@ -0,0 +1,394 @@
import {
effect,
EnvironmentInjector,
inject,
Injectable,
untracked,
} from '@angular/core';
import { NavigationEnd, Router } from '@angular/router';
import { Store } from '@ngrx/store';
import { ChannelActions, selectActive } from '@iptvnator/m3u-state';
import {
XTREAM_DATA_SOURCE,
XtreamStore,
} from '@iptvnator/portal/xtream/data-access';
import { ParentalLockService } from '@iptvnator/services';
import { toParentalLockXtreamCategoryType } from '@iptvnator/shared/interfaces';
import { PlaybackKeepAwakeService } from './playback-keep-awake.service';
const XTREAM_ROUTE =
/^\/workspace\/xtreams\/([^/?#]+)\/(live|vod|series)(?:\/(\d+))?/;
export const STALKER_ROUTE =
/^\/workspace\/stalker\/([^/?#]+)\/(itv|vod|series|radio)(?:\/([^/?#]+))?/;
/**
* Applies a parental lock change to the parts of the app that hold catalog
* data in memory. The stores and the SQLite worker filter what they READ;
* this service makes them read again and steps off anything that is now
* withheld — a selected category, a playing channel — so a locked category
* cannot stay on screen just because it was opened before the lock.
*/
@Injectable({ providedIn: 'root' })
export class ParentalLockEnforcementService {
private readonly parentalLock = inject(ParentalLockService);
private readonly xtreamStore = inject(XtreamStore);
private readonly xtreamDataSource = inject(XTREAM_DATA_SOURCE);
private readonly injector = inject(EnvironmentInjector);
private readonly router = inject(Router);
private readonly store = inject(Store);
private readonly keepAwake = inject(PlaybackKeepAwakeService);
private readonly activeChannel = this.store.selectSignal(selectActive);
private started = false;
private lastVersion = -1;
private applyChain: Promise<void> = Promise.resolve();
private stalkerModule: StalkerEnforcementModule | null = null;
private stalkerModuleLoad: Promise<StalkerEnforcementModule> | null = null;
start(): void {
if (this.started) {
return;
}
this.started = true;
// Playback counts as activity: video through the keep-awake
// tracker, and audio (the radio player) read directly, since the
// keep-awake service deliberately ignores <audio>.
this.parentalLock.registerBusyProbe(
() => this.keepAwake.hasPlayingVideo() || hasPlayingAudio()
);
// The Stalker step is loaded as soon as a Stalker route opens, so a
// relock there can run it synchronously instead of awaiting a chunk.
this.router.events?.subscribe((event) => {
if (
event instanceof NavigationEnd &&
STALKER_ROUTE.test(event.urlAfterRedirects)
) {
void this.loadStalker().catch(() => undefined);
}
});
// Any path that makes a channel active (numeric zapping, next /
// previous, remote commands, a stale list) is checked here: a
// channel of a locked group is reset as soon as it becomes active.
effect(() => {
this.activeChannel();
untracked(() => {
if (this.parentalLock.active()) {
this.applyM3u();
}
});
});
effect(() => {
const version = this.parentalLock.version();
untracked(() => {
if (version === this.lastVersion) {
return;
}
const first = this.lastVersion === -1;
this.lastVersion = version;
if (first) {
return;
}
// Fail closed NOW, ahead of the serialized queue: an earlier
// apply may still be waiting on a slow (or hung) read, and
// the catalog, details and playback read while unlocked must
// not stay usable until it settles.
if (this.parentalLock.active()) {
this.failClosedNow();
}
this.scheduleApply();
});
});
}
/**
* Applies run one at a time. `ElectronXtreamDataSource` shares in-flight
* reads per playlist and type, so an unlock refresh still running when
* "Lock now" arrives would hand the relock its unfiltered rows. Each
* apply also abandons its result once a newer version exists, leaving
* the queued apply to read the latest state.
*/
private scheduleApply(): void {
this.applyChain = this.applyChain
.then(() => this.apply())
.catch((error) => {
console.error(
'Failed to apply the parental lock change.',
error
);
});
}
/**
* The synchronous half of a relock: M3U channel, Stalker selection (its
* step is lazy, so it runs as soon as the chunk is there), the Xtream
* detail the lock store already places in a locked category, the
* catalog lists and the stored search. The queued apply then reloads
* the filtered rows and repeats the checks against them.
*/
private failClosedNow(): void {
this.applyM3u();
this.failClosedStalkerNow();
const playlistId = this.xtreamStore.playlistId?.();
if (!playlistId) {
return;
}
this.stepOffLockedXtreamSelection(
playlistId,
XTREAM_ROUTE.exec(this.router.url)
);
this.xtreamStore.withholdCatalog?.();
this.xtreamStore.clearSearchResults?.();
}
private async apply(): Promise<void> {
const version = this.parentalLock.version();
// The synchronous surfaces first: an M3U channel or a Stalker
// selection must not keep playing behind a slow Xtream database or
// provider reload — the Xtream store stays populated after leaving
// that portal, so its reload runs on every apply.
this.applyM3u();
await this.applyStalker();
await this.applyXtream(version);
}
private async applyXtream(version: number): Promise<void> {
const playlistId = this.xtreamStore.playlistId?.();
if (!playlistId) {
return;
}
// A playlist switch retires the apply as a newer lock version
// does: the store now holds another portal, whose own load reads
// under the current lock state.
const shouldPublish = (): boolean =>
this.parentalLock.version() === version &&
this.xtreamStore.playlistId?.() === playlistId;
await this.xtreamStore.reloadCategories(shouldPublish);
await this.xtreamStore.reloadCachedContent(shouldPublish);
if (!shouldPublish()) {
return;
}
// Stored in-portal search results are a separate array the search
// page renders directly; re-run the search so it reads filtered.
await this.xtreamStore.refreshSearchResults?.();
if (!shouldPublish() || !this.parentalLock.active()) {
return;
}
await this.stepOffWithheldXtreamSelection(playlistId, shouldPublish);
}
/**
* Post-reload check of the selected Xtream category and item, judged by
* the LOCK STORE through the unfiltered category rows (hidden and locked
* ones included): the reloaded list also omits categories the user
* merely hid, which are not parental-locked. The item is judged on its
* own category — opened from "All", recently added or search it has no
* selected category to vanish with. Rows that cannot be read fail
* closed.
*/
private async stepOffWithheldXtreamSelection(
playlistId: string,
shouldPublish: () => boolean
): Promise<void> {
const match = XTREAM_ROUTE.exec(this.router.url);
const categoryType = toParentalLockXtreamCategoryType(match?.[2]);
if (!categoryType) {
return;
}
const rows = await this.xtreamDataSource
.getAllCategories(playlistId, categoryType)
.catch(() => null);
if (!shouldPublish()) {
return;
}
const isWithheld = (categoryId: unknown): boolean => {
const id = Number(categoryId);
if (
categoryId === null ||
categoryId === undefined ||
!Number.isFinite(id)
) {
return false;
}
const row = rows?.find((candidate) => candidate.id === id);
return (
!row ||
this.parentalLock.isXtreamCategoryLocked(
playlistId,
categoryType,
row.xtream_id
)
);
};
const selectedItem = this.xtreamStore.selectedItem?.() as {
category_id?: string | number;
} | null;
const itemWithheld = isWithheld(selectedItem?.category_id);
const categoryWithheld = isWithheld(
this.xtreamStore.selectedCategoryId()
);
if (!itemWithheld && !categoryWithheld) {
return;
}
this.xtreamStore.setSelectedItem(null);
if (categoryWithheld) {
this.xtreamStore.setSelectedCategory(null);
}
if (match && match[1] === playlistId) {
void this.router.navigate([
'/workspace',
'xtreams',
match[1],
match[2],
]);
}
}
/**
* Clears a selected Xtream item whose category the lock store already
* says is locked. Electron rows carry the SQLite category row id; the
* category list still on screen maps it to the provider id the store is
* keyed by, the PWA carries the provider id directly. An item whose
* category is not in that list (a manually hidden category, opened
* through search) cannot be judged without an awaited read that may
* hang, so it is cleared too: fail closed.
*/
private stepOffLockedXtreamSelection(
playlistId: string,
match: RegExpExecArray | null
): void {
const categoryType = toParentalLockXtreamCategoryType(match?.[2]);
const selectedItem = this.xtreamStore.selectedItem?.() as {
category_id?: string | number;
} | null;
const categoryId = Number(selectedItem?.category_id);
if (!categoryType || !selectedItem || !Number.isFinite(categoryId)) {
return;
}
const category = this.xtreamStore
.getCategoriesBySelectedType()
.find(
(candidate) =>
Number((candidate as { id?: number }).id) === categoryId ||
Number(
(candidate as { category_id?: string }).category_id
) === categoryId
) as { xtream_id?: number; category_id?: string } | undefined;
const providerId = Number(category?.xtream_id ?? category?.category_id);
if (
category &&
Number.isFinite(providerId) &&
!this.parentalLock.isXtreamCategoryLocked(
playlistId,
categoryType,
providerId
)
) {
return;
}
this.xtreamStore.setSelectedItem(null);
if (match && match[1] === playlistId) {
void this.router.navigate([
'/workspace',
'xtreams',
match[1],
match[2],
]);
}
}
/** The dynamic import; a field so specs can substitute it. */
loadStalkerEnforcement = (): Promise<StalkerEnforcementModule> =>
import('./parental-lock-stalker-enforcement');
/** Loads the Stalker step once; a failed load may be retried. */
private loadStalker(): Promise<StalkerEnforcementModule> {
this.stalkerModuleLoad ??= this.loadStalkerEnforcement().then(
(module) => (this.stalkerModule = module),
(error: unknown) => {
this.stalkerModuleLoad = null;
throw error;
}
);
return this.stalkerModuleLoad;
}
/**
* The relock's Stalker step, synchronously: run it when the chunk is
* there, otherwise leave the Stalker route at once (its route session
* clears the selection and the live layout stops playback) rather than
* wait for a chunk that may be slow or never arrive.
*/
private failClosedStalkerNow(): void {
if (!STALKER_ROUTE.test(this.router.url)) {
return;
}
if (!this.stalkerModule) {
void this.router.navigate(['/workspace', 'sources']);
return;
}
this.stalkerModule.applyParentalLockToStalker(
this.injector,
this.parentalLock,
this.router
);
}
/**
* The Stalker data layer stays off the initial path: the step is loaded
* only while a Stalker route is open. Outside one there is nothing to
* step off — the Stalker route session clears the selection (and the
* live layout its playback) when the route is left.
*/
private async applyStalker(): Promise<void> {
if (!STALKER_ROUTE.test(this.router.url)) {
return;
}
let module: StalkerEnforcementModule;
try {
module = await this.loadStalker();
} catch (error) {
// Fail closed (e.g. a stale PWA page whose chunk is gone): leave
// the Stalker route. Its route session clears the selection and
// the live layout stops playback on the way out, so no locked
// channel keeps playing; the Xtream step still runs afterwards.
console.error(
'The parental lock Stalker step could not be loaded.',
error
);
void this.router.navigate(['/workspace', 'sources']);
return;
}
module.applyParentalLockToStalker(
this.injector,
this.parentalLock,
this.router
);
}
private applyM3u(): void {
const channel = this.activeChannel();
const playlistId = this.activeM3uPlaylistId();
if (!channel || !playlistId) {
return;
}
const groupTitle = channel.group?.title ?? '';
if (this.parentalLock.isM3uGroupLocked(playlistId, groupTitle)) {
this.store.dispatch(ChannelActions.resetActiveChannel());
}
}
private activeM3uPlaylistId(): string | null {
const match = /^\/workspace\/playlists\/([^/?#]+)/.exec(
this.router.url
);
return match ? decodeURIComponent(match[1]) : null;
}
}
type StalkerEnforcementModule =
typeof import('./parental-lock-stalker-enforcement');
function hasPlayingAudio(): boolean {
return Array.from(document.querySelectorAll('audio')).some(
(audio) => !audio.paused && !audio.ended
);
}
@@ -0,0 +1,8 @@
/**
* Lazy boundary for the parental-lock prompt: the prompt service imports
* this file dynamically, so the PIN dialog and the @angular/forms and
* Material form-field modules it brings stay off the initial path. The
* boundary is a local file backed by a file-level alias, like the other
* root-shell lazy dialogs (see docs/architecture/nx-workspace-boundaries.md).
*/
export { ParentalLockPinDialogComponent } from '@iptvnator/ui/components/parental-lock-pin-dialog';
@@ -0,0 +1,44 @@
import { TestBed } from '@angular/core/testing';
import { MatDialog } from '@angular/material/dialog';
import { of } from 'rxjs';
import { AppParentalLockPromptService } from './parental-lock-prompt.service';
describe('AppParentalLockPromptService', () => {
const dialog = { open: jest.fn() };
let service: AppParentalLockPromptService;
beforeEach(() => {
jest.clearAllMocks();
TestBed.configureTestingModule({
providers: [
AppParentalLockPromptService,
{ provide: MatDialog, useValue: dialog },
],
});
service = TestBed.inject(AppParentalLockPromptService);
});
it('loads the PIN dialog on demand and returns the entered PIN', async () => {
const open = jest.fn(() => ({ afterClosed: () => of('1234') }));
service.loadPinDialog = jest.fn(async () => ({
ParentalLockPinDialogComponent: { open },
})) as never;
await expect(service.requestPin({ mode: 'set' })).resolves.toBe('1234');
expect(open).toHaveBeenCalledWith(
dialog,
expect.objectContaining({ mode: 'set' })
);
});
it('treats a failed dialog chunk load as a cancelled prompt', async () => {
jest.spyOn(console, 'error').mockImplementation(() => undefined);
service.loadPinDialog = jest.fn(async () => {
throw new Error('ChunkLoadError');
}) as never;
await expect(
service.requestPin({ mode: 'unlock' })
).resolves.toBeNull();
});
});
@@ -0,0 +1,62 @@
import { inject, Injectable, Provider } from '@angular/core';
import { MatDialog } from '@angular/material/dialog';
import { firstValueFrom } from 'rxjs';
import {
PARENTAL_LOCK_PROMPT,
ParentalLockPrompt,
ParentalLockPromptRequest,
} from '@iptvnator/services';
/**
* Application implementation of the parental lock prompt: the shared PIN
* dialog from `@iptvnator/ui/components`, which the data-access lock
* service cannot import itself. The dialog is loaded on first use (the
* service is constructed at startup, the dialog is not needed until a PIN
* is asked for).
*/
@Injectable({ providedIn: 'root' })
export class AppParentalLockPromptService implements ParentalLockPrompt {
private readonly dialog = inject(MatDialog);
/** The dynamic import; a field so specs can substitute it. */
loadPinDialog = () => import('./parental-lock-pin-dialog.lazy');
async requestPin(
request: ParentalLockPromptRequest
): Promise<string | null> {
let module: Awaited<ReturnType<typeof this.loadPinDialog>>;
try {
module = await this.loadPinDialog();
} catch (error) {
// A failed chunk load reads as a cancelled prompt: the gate it
// guards stays closed.
console.error(
'The parental lock PIN dialog could not be loaded.',
error
);
return null;
}
const dialogRef = module.ParentalLockPinDialogComponent.open(
this.dialog,
{
mode: request.mode,
verify: request.verify,
throttle: request.throttle,
titleKey: request.titleKey,
descriptionKey: request.descriptionKey,
}
);
const pin = await firstValueFrom(dialogRef.afterClosed());
return typeof pin === 'string' ? pin : null;
}
}
export function provideParentalLockPrompt(): Provider[] {
return [
AppParentalLockPromptService,
{
provide: PARENTAL_LOCK_PROMPT,
useExisting: AppParentalLockPromptService,
},
];
}
@@ -0,0 +1,69 @@
import { Injector } from '@angular/core';
import { Router } from '@angular/router';
import { StalkerStore } from '@iptvnator/portal/stalker/data-access';
import { ParentalLockService } from '@iptvnator/services';
import { toParentalLockStalkerCategoryType } from '@iptvnator/shared/interfaces';
import { STALKER_ROUTE } from './parental-lock-enforcement.service';
/**
* The Stalker step of `ParentalLockEnforcementService`, in its own file so
* the enforcement service can import it dynamically: a static import of the
* Stalker store would put the whole Stalker data layer back on the initial
* path (see docs/architecture/nx-workspace-boundaries.md).
*/
export function applyParentalLockToStalker(
injector: Injector,
parentalLock: ParentalLockService,
router: Router
): void {
const stalkerStore = injector.get(StalkerStore);
const playlist = stalkerStore.currentPlaylist();
const playlistId = playlist?._id;
if (!playlistId) {
return;
}
const contentType = toParentalLockStalkerCategoryType(
stalkerStore.selectedContentType()
);
if (!contentType) {
return;
}
const selectedCategoryId = stalkerStore.selectedCategoryId();
const categoryWithheld =
!!selectedCategoryId &&
parentalLock.isStalkerCategoryLocked(
playlistId,
contentType,
selectedCategoryId
);
// An item opened from "All" (`*`) or search has its own genre to be
// judged by; the list dropping its row is not enough. Live and radio
// rows carry that genre in `tv_genre_id`, VOD and series rows in
// `category_id` (the store's withheld filter applies the same rule).
const selectedItem = stalkerStore.selectedItem?.() as {
category_id?: string | number;
tv_genre_id?: string | number;
} | null;
const itemCategoryId =
contentType === 'itv' || contentType === 'radio'
? selectedItem?.tv_genre_id
: selectedItem?.category_id;
const itemWithheld =
!!selectedItem &&
parentalLock.isStalkerCategoryLocked(
playlistId,
contentType,
itemCategoryId
);
if (!categoryWithheld && !itemWithheld) {
return;
}
stalkerStore.clearSelectedItem();
if (categoryWithheld) {
stalkerStore.setSelectedCategory(null);
}
const match = STALKER_ROUTE.exec(router.url);
if (match && match[1] === playlistId) {
void router.navigate(['/workspace', 'stalker', match[1], match[2]]);
}
}
@@ -72,6 +72,11 @@ export class PlaybackKeepAwakeService {
this.sync();
};
/** Whether any tracked `<video>` is currently playing. */
hasPlayingVideo(): boolean {
return this.playingVideos.size > 0;
}
start(): void {
if (this.started) {
return;
@@ -19,6 +19,7 @@ import {
normalizeEmbeddedMpvExtraOptions,
normalizeExternalPlayerArguments,
normalizeEpgOffsetMinutes,
normalizeParentalLockRelockMinutes,
normalizeStartupWindowMode,
Settings,
StartupBehavior,
@@ -228,6 +229,12 @@ export function createSettingsFromFormValue(
enabled: value.tmdb?.enabled ?? DEFAULT_TMDB_SETTINGS.enabled,
apiKey: value.tmdb?.apiKey?.trim() ?? '',
},
// Not on the form: the parental lock section applies its changes
// immediately (they gate on the PIN), so Save must not undo them.
parentalLockEnabled: currentSettings.parentalLockEnabled === true,
parentalLockRelockMinutes: normalizeParentalLockRelockMinutes(
currentSettings.parentalLockRelockMinutes
),
};
}
@@ -94,6 +94,7 @@ describe('buildSettingsSectionNavItems', () => {
'dashboard',
'remote-control',
'tmdb',
'parental',
'backup',
'reset',
'about',
@@ -0,0 +1,90 @@
import { computed, inject, Injectable, signal } from '@angular/core';
import { TranslateService } from '@ngx-translate/core';
import { ParentalLockService } from '@iptvnator/services';
import {
PARENTAL_LOCK_RELOCK_MINUTES_OPTIONS,
ParentalLockRelockMinutes,
} from '@iptvnator/shared/interfaces';
import { SettingsSnackbarService } from './settings-snackbar.service';
/**
* Settings → Parental lock. Every control here applies immediately: the
* enable toggle and the PIN actions gate on the PIN dialog, so staging them
* in the shared dirty form (Save/Discard) would make no sense — a
* "discarded" PIN would already have been typed.
*/
@Injectable()
export class SettingsParentalLockFacade {
private readonly parentalLock = inject(ParentalLockService);
private readonly snackbar = inject(SettingsSnackbarService);
private readonly translate = inject(TranslateService);
readonly enabled = this.parentalLock.enabled;
readonly unlocked = this.parentalLock.unlocked;
readonly hasPin = this.parentalLock.hasPin;
readonly relockMinutes = this.parentalLock.relockMinutes;
readonly relockOptions = PARENTAL_LOCK_RELOCK_MINUTES_OPTIONS;
readonly busy = signal(false);
readonly canLockNow = computed(() => this.enabled() && this.unlocked());
constructor() {
void this.parentalLock.initialize();
}
async toggleEnabled(enabled: boolean): Promise<void> {
await this.run(async () => {
if (enabled) {
if (await this.parentalLock.setupPin()) {
this.notify('SETTINGS.PARENTAL_LOCK.ENABLED_TOAST');
}
return;
}
if (await this.parentalLock.disable()) {
this.notify('SETTINGS.PARENTAL_LOCK.DISABLED_TOAST');
}
});
}
async changePin(): Promise<void> {
await this.run(async () => {
if (await this.parentalLock.changePin()) {
this.notify('SETTINGS.PARENTAL_LOCK.PIN_CHANGED_TOAST');
}
});
}
async setRelockMinutes(minutes: ParentalLockRelockMinutes): Promise<void> {
await this.run(async () => {
if (!(await this.parentalLock.setRelockMinutes(minutes))) {
this.snackbar.storageFailure('save');
}
});
}
lockNow(): void {
this.parentalLock.lock();
this.notify('SETTINGS.PARENTAL_LOCK.LOCKED_TOAST');
}
async unlock(): Promise<void> {
await this.run(async () => {
await this.parentalLock.requestUnlock();
});
}
private notify(key: string): void {
this.snackbar.open(this.translate.instant(key));
}
private async run(action: () => Promise<unknown>): Promise<void> {
if (this.busy()) {
return;
}
this.busy.set(true);
try {
await action();
} finally {
this.busy.set(false);
}
}
}
@@ -0,0 +1,145 @@
<section class="settings-group" id="parental">
<header class="settings-group__header">
<div class="settings-group__header-icon">
<mat-icon>family_restroom</mat-icon>
</div>
<div class="settings-group__header-text">
<h3>{{ 'SETTINGS.PARENTAL_LOCK.TITLE' | translate }}</h3>
<p>{{ 'SETTINGS.PARENTAL_LOCK.DESCRIPTION' | translate }}</p>
</div>
</header>
<div class="setting-item" data-setting-id="parental-lock-enable">
<div class="setting-item__meta">
<h4>{{ 'SETTINGS.PARENTAL_LOCK.ENABLE' | translate }}</h4>
<p>{{ 'SETTINGS.PARENTAL_LOCK.ENABLE_DESCRIPTION' | translate }}</p>
</div>
<div class="setting-item__control setting-item__toggle">
<mat-slide-toggle
data-test-id="parental-lock-enabled"
[checked]="enabled()"
[disabled]="busy()"
(change)="onToggleEnabled($event)"
[attr.aria-label]="'SETTINGS.PARENTAL_LOCK.ENABLE' | translate"
></mat-slide-toggle>
</div>
</div>
@if (enabled()) {
<div class="setting-item" data-setting-id="parental-lock-status">
<div class="setting-item__meta">
<h4>{{ 'SETTINGS.PARENTAL_LOCK.STATUS' | translate }}</h4>
<p>
{{
(unlocked()
? 'SETTINGS.PARENTAL_LOCK.STATUS_UNLOCKED'
: 'SETTINGS.PARENTAL_LOCK.STATUS_LOCKED'
) | translate
}}
</p>
</div>
<div class="setting-item__control">
@if (unlocked()) {
<button
mat-stroked-button
type="button"
data-test-id="parental-lock-lock-now"
[disabled]="busy()"
(click)="lockNow.emit()"
>
<mat-icon>lock</mat-icon>
{{ 'SETTINGS.PARENTAL_LOCK.LOCK_NOW' | translate }}
</button>
} @else {
<button
mat-stroked-button
type="button"
data-test-id="parental-lock-unlock"
[disabled]="busy()"
(click)="unlock.emit()"
>
<mat-icon>lock_open</mat-icon>
{{ 'SETTINGS.PARENTAL_LOCK.UNLOCK' | translate }}
</button>
}
</div>
</div>
<div class="setting-item" data-setting-id="parental-lock-relock">
<div class="setting-item__meta">
<h4>{{ 'SETTINGS.PARENTAL_LOCK.RELOCK' | translate }}</h4>
<p>
{{
'SETTINGS.PARENTAL_LOCK.RELOCK_DESCRIPTION' | translate
}}
</p>
</div>
<div class="setting-item__control">
<mat-form-field appearance="outline" subscriptSizing="dynamic">
<mat-label>{{
'SETTINGS.PARENTAL_LOCK.RELOCK' | translate
}}</mat-label>
<mat-select
data-test-id="parental-lock-relock"
[value]="relockMinutes()"
[disabled]="busy() || !unlocked()"
(selectionChange)="
relockMinutesChanged.emit($event.value)
"
>
@for (option of relockOptions(); track option) {
<mat-option [value]="option">
{{
relockLabelKey(option)
| translate: { minutes: option }
}}
</mat-option>
}
</mat-select>
</mat-form-field>
</div>
</div>
<div class="setting-item" data-setting-id="parental-lock-change-pin">
<div class="setting-item__meta">
<h4>{{ 'SETTINGS.PARENTAL_LOCK.CHANGE_PIN' | translate }}</h4>
<p>
{{
'SETTINGS.PARENTAL_LOCK.CHANGE_PIN_DESCRIPTION'
| translate
}}
</p>
</div>
<div class="setting-item__control">
<button
mat-stroked-button
type="button"
data-test-id="parental-lock-change-pin"
[disabled]="busy() || !hasPin()"
(click)="changePin.emit()"
>
<mat-icon>password</mat-icon>
{{ 'SETTINGS.PARENTAL_LOCK.CHANGE_PIN' | translate }}
</button>
</div>
</div>
<div class="setting-item" data-setting-id="parental-lock-how-to">
<div class="setting-item__meta">
<h4>{{ 'SETTINGS.PARENTAL_LOCK.HOW_TO_LOCK' | translate }}</h4>
<p>
{{
'SETTINGS.PARENTAL_LOCK.HOW_TO_LOCK_DESCRIPTION'
| translate
}}
</p>
</div>
</div>
}
<div class="setting-item">
<div class="setting-item__meta">
<p>{{ 'SETTINGS.PARENTAL_LOCK.DISCLAIMER' | translate }}</p>
</div>
</div>
</section>
@@ -0,0 +1,46 @@
import { TestBed } from '@angular/core/testing';
import { MatSlideToggle } from '@angular/material/slide-toggle';
import { By } from '@angular/platform-browser';
import { NoopAnimationsModule } from '@angular/platform-browser/animations';
import { TranslateModule } from '@ngx-translate/core';
import { SettingsParentalLockSectionComponent } from './settings-parental-section.component';
describe('SettingsParentalLockSectionComponent', () => {
it('keeps the enable switch on the saved state until the PIN action succeeds', async () => {
await TestBed.configureTestingModule({
imports: [
SettingsParentalLockSectionComponent,
NoopAnimationsModule,
TranslateModule.forRoot(),
],
}).compileComponents();
const fixture = TestBed.createComponent(
SettingsParentalLockSectionComponent
);
fixture.componentRef.setInput('enabled', false);
fixture.componentRef.setInput('unlocked', false);
fixture.componentRef.setInput('hasPin', false);
fixture.componentRef.setInput('relockMinutes', 15);
fixture.componentRef.setInput('relockOptions', [0, 15]);
fixture.detectChanges();
const requested = jest.fn();
fixture.componentInstance.toggleEnabled.subscribe(requested);
const toggle = fixture.debugElement.query(By.directive(MatSlideToggle))
.componentInstance as MatSlideToggle;
const button = fixture.nativeElement.querySelector(
'[data-test-id="parental-lock-enabled"] button'
) as HTMLButtonElement;
// The set-PIN prompt is then cancelled: `enabled` never changes.
button.click();
fixture.detectChanges();
expect(requested).toHaveBeenCalledWith(true);
expect(toggle.checked).toBe(false);
// The PIN was set: the switch follows the saved state.
fixture.componentRef.setInput('enabled', true);
fixture.detectChanges();
expect(toggle.checked).toBe(true);
});
});
@@ -0,0 +1,59 @@
import { Component, input, output, ViewEncapsulation } from '@angular/core';
import { MatButtonModule } from '@angular/material/button';
import { MatFormFieldModule } from '@angular/material/form-field';
import { MatIconModule } from '@angular/material/icon';
import { MatSelectModule } from '@angular/material/select';
import {
MatSlideToggleChange,
MatSlideToggleModule,
} from '@angular/material/slide-toggle';
import { TranslateModule } from '@ngx-translate/core';
import { ParentalLockRelockMinutes } from '@iptvnator/shared/interfaces';
@Component({
selector: 'app-settings-parental-lock-section',
imports: [
MatButtonModule,
MatFormFieldModule,
MatIconModule,
MatSelectModule,
MatSlideToggleModule,
TranslateModule,
],
templateUrl: './settings-parental-section.component.html',
encapsulation: ViewEncapsulation.None,
styles: [':host { display: contents; }'],
})
export class SettingsParentalLockSectionComponent {
readonly enabled = input.required<boolean>();
readonly unlocked = input.required<boolean>();
readonly hasPin = input.required<boolean>();
readonly relockMinutes = input.required<ParentalLockRelockMinutes>();
readonly relockOptions =
input.required<readonly ParentalLockRelockMinutes[]>();
readonly busy = input(false);
readonly toggleEnabled = output<boolean>();
readonly changePin = output<void>();
readonly relockMinutesChanged = output<ParentalLockRelockMinutes>();
readonly lockNow = output<void>();
readonly unlock = output<void>();
/**
* The switch only REQUESTS the change: enabling and disabling both go
* through the PIN, which may be cancelled. It is put back to the saved
* state at once and follows `enabled()` when the action succeeds;
* otherwise the slide toggle would keep showing the state it flipped to
* (`[checked]` is not re-applied while the bound value is unchanged).
*/
onToggleEnabled(event: MatSlideToggleChange): void {
event.source.checked = this.enabled();
this.toggleEnabled.emit(event.checked);
}
relockLabelKey(minutes: ParentalLockRelockMinutes): string {
return minutes === 0
? 'SETTINGS.PARENTAL_LOCK.RELOCK_NEVER'
: 'SETTINGS.PARENTAL_LOCK.RELOCK_MINUTES';
}
}
@@ -6,7 +6,11 @@ import {
selectAllPlaylistsMeta,
selectIsEpgAvailable,
} from '@iptvnator/m3u-state';
import { DatabaseService, PlaylistsService } from '@iptvnator/services';
import {
ParentalLockService,
DatabaseService,
PlaylistsService,
} from '@iptvnator/services';
import { PlaylistMeta } from '@iptvnator/shared/interfaces';
import { Store } from '@ngrx/store';
import { MockStore, provideMockStore } from '@ngrx/store/testing';
@@ -59,6 +63,9 @@ describe('SettingsPlaylistResetFacade', () => {
MockProvider(PlaylistsService, {
removeAll: jest.fn().mockReturnValue(of(undefined)),
}),
MockProvider(ParentalLockService, {
clearAllLocks: jest.fn().mockResolvedValue(true),
}),
provideMockStore({
selectors: [
{ selector: selectAllPlaylistsMeta, value: [] },
@@ -218,6 +225,9 @@ describe('SettingsPlaylistResetFacade', () => {
expect(playlistsService.removeAll).toHaveBeenCalled();
expect(databaseService.deleteAllPlaylists).not.toHaveBeenCalled();
expect(
TestBed.inject(ParentalLockService).clearAllLocks
).toHaveBeenCalled();
expect(dispatchSpy).toHaveBeenCalledWith(
PlaylistActions.removeAllPlaylists()
);
@@ -235,6 +245,10 @@ describe('SettingsPlaylistResetFacade', () => {
await new Promise<void>((resolve) => setTimeout(resolve, 0));
expect(facade.isRemovingAllPlaylists()).toBe(false);
// Nothing was deleted, so the locks stay.
expect(
TestBed.inject(ParentalLockService).clearAllLocks
).not.toHaveBeenCalled();
expect(snackBar.open).toHaveBeenCalledWith(
'SETTINGS.PLAYLISTS_REMOVE_FAILED',
undefined,
@@ -5,6 +5,7 @@ import { TranslateService } from '@ngx-translate/core';
import { firstValueFrom, take } from 'rxjs';
import { PlaylistActions, selectAllPlaylistsMeta } from '@iptvnator/m3u-state';
import {
ParentalLockService,
DatabaseService,
DbOperationEvent,
PlaylistsService,
@@ -24,6 +25,7 @@ import { SettingsSnackbarService } from './settings-snackbar.service';
@Injectable()
export class SettingsPlaylistResetFacade {
private readonly databaseService = inject(DatabaseService);
private readonly parentalLock = inject(ParentalLockService);
private readonly matDialog = inject(MatDialog);
private readonly playlistsService = inject(PlaylistsService);
private readonly runtime = inject(RuntimeCapabilitiesService);
@@ -107,6 +109,12 @@ export class SettingsPlaylistResetFacade {
if (!deleted) {
throw new Error('Delete all playlists returned success=false');
}
// The playlists are gone; their locks must not linger in the
// lock store (the deletion itself already succeeded, so a failed
// clear is only logged).
if (!(await this.parentalLock.clearAllLocks())) {
console.warn('Clearing the parental locks failed.');
}
this.store.dispatch(PlaylistActions.removeAllPlaylists());
this.settingsSnackbar.open(
@@ -379,6 +379,10 @@ describe('SettingsComponent form', () => {
...component.settingsForm.value,
trustedPrivateNetworkEpgUrls: [],
trustedInsecureTlsHosts: [],
// Parental lock settings are not on the form; Save
// carries the current values through unchanged.
parentalLockEnabled: false,
parentalLockRelockMinutes: 15,
},
{ retryEpgCleanup: false }
);
@@ -386,6 +390,8 @@ describe('SettingsComponent form', () => {
...component.settingsForm.value,
trustedPrivateNetworkEpgUrls: [],
trustedInsecureTlsHosts: [],
parentalLockEnabled: false,
parentalLockRelockMinutes: 15,
});
});
@@ -78,6 +78,23 @@
@case ('tmdb') {
<app-settings-tmdb-section [form]="settingsForm" />
}
@case ('parental') {
<app-settings-parental-lock-section
[enabled]="parentalLock.enabled()"
[unlocked]="parentalLock.unlocked()"
[hasPin]="parentalLock.hasPin()"
[relockMinutes]="parentalLock.relockMinutes()"
[relockOptions]="parentalLock.relockOptions"
[busy]="parentalLock.busy()"
(toggleEnabled)="parentalLock.toggleEnabled($event)"
(changePin)="parentalLock.changePin()"
(relockMinutesChanged)="
parentalLock.setRelockMinutes($event)
"
(lockNow)="parentalLock.lockNow()"
(unlock)="parentalLock.unlock()"
/>
}
@case ('backup') {
<app-settings-backup-section
[isPwa]="isPwa"
@@ -46,6 +46,8 @@ import {
SETTINGS_UPDATE_CHANNEL_OPTIONS,
SETTINGS_THEME_OPTIONS,
} from './settings-options';
import { SettingsParentalLockFacade } from './settings-parental-lock.facade';
import { SettingsParentalLockSectionComponent } from './settings-parental-section.component';
import { SettingsPlaybackSectionComponent } from './settings-playback-section.component';
import { SettingsRemoteControlFacade } from './settings-remote-control.facade';
import { SettingsRemoteControlSectionComponent } from './settings-remote-control-section.component';
@@ -93,6 +95,7 @@ export const SETTINGS_DEFAULT_SECTION = 'general';
SettingsDashboardSectionComponent,
SettingsEpgSectionComponent,
SettingsGeneralSectionComponent,
SettingsParentalLockSectionComponent,
SettingsPlaybackSectionComponent,
SettingsRemoteControlSectionComponent,
SettingsResetSectionComponent,
@@ -107,6 +110,7 @@ export const SETTINGS_DEFAULT_SECTION = 'general';
SettingsEmbeddedMpvFacade,
SettingsEpgFacade,
SettingsFormFacade,
SettingsParentalLockFacade,
SettingsPlaylistResetFacade,
SettingsRemoteControlFacade,
SettingsSearchFacade,
@@ -122,6 +126,7 @@ export class SettingsComponent
readonly embeddedMpv = inject(SettingsEmbeddedMpvFacade);
readonly epg = inject(SettingsEpgFacade);
readonly form = inject(SettingsFormFacade);
readonly parentalLock = inject(SettingsParentalLockFacade);
readonly playlistReset = inject(SettingsPlaylistResetFacade);
readonly remoteControl = inject(SettingsRemoteControlFacade);
readonly search = inject(SettingsSearchFacade);
+65 -2
View File
@@ -551,7 +551,32 @@
"EMBEDDED_MPV_AUTO_RECONNECT": "إعادة الاتصال بالبث المنقطع تلقائيًا",
"EMBEDDED_MPV_AUTO_RECONNECT_DESCRIPTION": "عند انقطاع بث قيد التشغيل، يعيد المشغّل المدمج تحميله بفواصل زمنية متزايدة، حتى 6 محاولات، قبل أن يتوقف. أما البث الذي لم يبدأ أصلًا فلا يزال يحتاج إلى إعادة المحاولة يدويًا.",
"VOD_AUTO_FAILOVER": "التبديل التلقائي للمصدر عند فشل التشغيل",
"VOD_AUTO_FAILOVER_DESCRIPTION": "عند فشل تشغيل فيلم وتوفّر الفيلم نفسه في قائمة تشغيل أخرى، يتم التبديل إليها تلقائيًا مع الاحتفاظ بموضع التشغيل. الخيار معطّل افتراضيًا، ويُعلَن عن التبديل دائمًا لأن المصدر الآخر قد يحمل دبلجة مختلفة."
"VOD_AUTO_FAILOVER_DESCRIPTION": "عند فشل تشغيل فيلم وتوفّر الفيلم نفسه في قائمة تشغيل أخرى، يتم التبديل إليها تلقائيًا مع الاحتفاظ بموضع التشغيل. الخيار معطّل افتراضيًا، ويُعلَن عن التبديل دائمًا لأن المصدر الآخر قد يحمل دبلجة مختلفة.",
"NAV_PARENTAL": "Parental lock",
"PARENTAL_LOCK": {
"TITLE": "Parental lock",
"DESCRIPTION": "Hide chosen categories behind a PIN. Locked categories, their channels and titles are removed from the whole app until the PIN is entered.",
"ENABLE": "Enable parental lock",
"ENABLE_DESCRIPTION": "Turning it on asks for a new PIN. Turning it off asks for the current PIN; your locks are kept for later.",
"STATUS": "Current state",
"STATUS_LOCKED": "Locked — locked categories are hidden.",
"STATUS_UNLOCKED": "Unlocked — locked categories are visible until the app locks again.",
"LOCK_NOW": "Lock now",
"UNLOCK": "Unlock",
"RELOCK": "Lock again after",
"RELOCK_DESCRIPTION": "Minutes without interaction before the app locks itself again. Playback counts as interaction. Restarting the app always locks.",
"RELOCK_NEVER": "Only when restarting the app",
"RELOCK_MINUTES": "{{minutes}} minutes of inactivity",
"CHANGE_PIN": "Change PIN",
"CHANGE_PIN_DESCRIPTION": "Asks for the current PIN first.",
"HOW_TO_LOCK": "Choosing what to lock",
"HOW_TO_LOCK_DESCRIPTION": "Open a source, then use “Manage categories” (the tune icon above the category list; “Manage groups” in an M3U playlist): every row has a lock toggle, and Stalker offers “Lock adult (18+)” for genres the portal flags. Right-click a category or group for a quick Lock / Unlock.",
"DISCLAIMER": "This is a child lock, not a security feature: the app's data files are readable by anyone with access to this computer.",
"ENABLED_TOAST": "Parental lock enabled",
"DISABLED_TOAST": "Parental lock disabled",
"PIN_CHANGED_TOAST": "PIN changed",
"LOCKED_TOAST": "Locked"
}
},
"THEMES": {
"DARK_THEME": "داكن",
@@ -1681,7 +1706,11 @@
"SWITCH_PLAYER_LABEL": "تبديل المشغل إلى {{name}}",
"SWITCH_PLAYER_DESCRIPTION": "تعيين {{name}} كمشغل الفيديو النشط",
"SWITCH_PLAYER_FEEDBACK": "تم تعيين المشغل إلى {{name}}",
"ACCOUNT_INFO_DESCRIPTION_STALKER": "عرض بيانات حساب بوابة Stalker"
"ACCOUNT_INFO_DESCRIPTION_STALKER": "عرض بيانات حساب بوابة Stalker",
"PARENTAL_LOCK_NOW_LABEL": "Lock now",
"PARENTAL_LOCK_NOW_DESCRIPTION": "Hide the parental-locked categories again",
"PARENTAL_UNLOCK_LABEL": "Enter parental PIN",
"PARENTAL_UNLOCK_DESCRIPTION": "Show the parental-locked categories"
},
"XTREAM_REFRESH_TITLE": "جارٍ تحديث قائمة التشغيل",
"XTREAM_REFRESH_COLLECTING_DATA": "جارٍ الحفاظ على بيانات مكتبتك...",
@@ -1872,5 +1901,39 @@
"failed": "Deletion failed — select to retry"
},
"DELETE_PROGRESS": "Processed {{done}} of {{total}} selected sources"
},
"PARENTAL_LOCK": {
"PIN_DIALOG": {
"UNLOCK_TITLE": "Enter parental PIN",
"UNLOCK_DESCRIPTION": "Locked categories stay hidden until the PIN is entered.",
"SET_TITLE": "Set parental PIN",
"SET_DESCRIPTION": "Choose a PIN that will be required to show locked categories.",
"PIN_LABEL": "PIN",
"CONFIRM_LABEL": "Repeat PIN",
"PIN_HINT": "{{min}} to {{max}} digits",
"WRONG_PIN": "Wrong PIN. Try again.",
"MISMATCH": "The two PINs do not match.",
"COOLDOWN": "Too many attempts. Wait 30 seconds and try again.",
"NO_RECOVERY": "There is no way to recover a forgotten PIN — only resetting the app data removes it.",
"SAVE": "Save PIN",
"UNLOCK": "Unlock",
"CONFIRM_TITLE": "Confirm parental PIN",
"CONFIRM_DESCRIPTION": "Enter the current PIN to change this setting."
},
"LOCKED_COUNT": "Locked",
"LOCK_CATEGORY": "Lock category",
"UNLOCK_CATEGORY": "Unlock category",
"LOCK_GROUP": "Lock group",
"UNLOCK_GROUP": "Unlock group",
"MANAGE_LOCKS": "Lock categories",
"MANAGE_LOCKS_DESCRIPTION": "Locked categories and their channels disappear from the whole app until the parental PIN is entered.",
"LOCK_CENSORED": "Lock adult (18+)",
"UNLOCK_ALL": "Unlock all",
"CENSORED_HINT": "The portal flags this category as adult content",
"SAVE_FAILED": "Saving the category locks failed",
"LOCKED_CATEGORIES_ROW": "{{count}} locked",
"UNLOCK_TO_SHOW": "Enter PIN to show",
"HEADER_LOCK": "Lock now (parental lock)",
"HEADER_UNLOCK": "Enter parental PIN"
}
}
+65 -2
View File
@@ -551,7 +551,32 @@
"EMBEDDED_MPV_AUTO_RECONNECT": "عاود الاتصال أوتوماتيكياً بالبث اللي تقطع",
"EMBEDDED_MPV_AUTO_RECONNECT_DESCRIPTION": "ملي شي بث خدام كيتقطع، المشغل المدمج كيعاود يشرجو بفواصل كتزيد شوية بشوية، حتى 6 محاولات، قبل ما يوقف. البث اللي عمرو ما بدا كيبقى محتاج تحاول مرة أخرى يدوياً.",
"VOD_AUTO_FAILOVER": "بدل المصدر أوتوماتيكياً عند خطأ فالتشغيل",
"VOD_AUTO_FAILOVER_DESCRIPTION": "ملي شي فيلم ما يتشغلش وكان نفس الفيلم كاين ف قائمة تشغيل أخرى، التطبيق كيبدل ليها أوتوماتيكياً وكيحافظ على التوقيت. معطل افتراضياً؛ التبديل ديما كيتعلن حيت مصدر آخر يمكن يكون فيه دوبلاج مختلف."
"VOD_AUTO_FAILOVER_DESCRIPTION": "ملي شي فيلم ما يتشغلش وكان نفس الفيلم كاين ف قائمة تشغيل أخرى، التطبيق كيبدل ليها أوتوماتيكياً وكيحافظ على التوقيت. معطل افتراضياً؛ التبديل ديما كيتعلن حيت مصدر آخر يمكن يكون فيه دوبلاج مختلف.",
"NAV_PARENTAL": "Parental lock",
"PARENTAL_LOCK": {
"TITLE": "Parental lock",
"DESCRIPTION": "Hide chosen categories behind a PIN. Locked categories, their channels and titles are removed from the whole app until the PIN is entered.",
"ENABLE": "Enable parental lock",
"ENABLE_DESCRIPTION": "Turning it on asks for a new PIN. Turning it off asks for the current PIN; your locks are kept for later.",
"STATUS": "Current state",
"STATUS_LOCKED": "Locked — locked categories are hidden.",
"STATUS_UNLOCKED": "Unlocked — locked categories are visible until the app locks again.",
"LOCK_NOW": "Lock now",
"UNLOCK": "Unlock",
"RELOCK": "Lock again after",
"RELOCK_DESCRIPTION": "Minutes without interaction before the app locks itself again. Playback counts as interaction. Restarting the app always locks.",
"RELOCK_NEVER": "Only when restarting the app",
"RELOCK_MINUTES": "{{minutes}} minutes of inactivity",
"CHANGE_PIN": "Change PIN",
"CHANGE_PIN_DESCRIPTION": "Asks for the current PIN first.",
"HOW_TO_LOCK": "Choosing what to lock",
"HOW_TO_LOCK_DESCRIPTION": "Open a source, then use “Manage categories” (the tune icon above the category list; “Manage groups” in an M3U playlist): every row has a lock toggle, and Stalker offers “Lock adult (18+)” for genres the portal flags. Right-click a category or group for a quick Lock / Unlock.",
"DISCLAIMER": "This is a child lock, not a security feature: the app's data files are readable by anyone with access to this computer.",
"ENABLED_TOAST": "Parental lock enabled",
"DISABLED_TOAST": "Parental lock disabled",
"PIN_CHANGED_TOAST": "PIN changed",
"LOCKED_TOAST": "Locked"
}
},
"THEMES": {
"DARK_THEME": "داكن",
@@ -1681,7 +1706,11 @@
"SWITCH_PLAYER_LABEL": "بدل المشغل ل{{name}}",
"SWITCH_PLAYER_DESCRIPTION": "حدد {{name}} كمشغل الفيديو النشط",
"SWITCH_PLAYER_FEEDBACK": "المشغل تحدد ل{{name}}",
"ACCOUNT_INFO_DESCRIPTION_STALKER": "عرض بيانات حساب بوابة Stalker"
"ACCOUNT_INFO_DESCRIPTION_STALKER": "عرض بيانات حساب بوابة Stalker",
"PARENTAL_LOCK_NOW_LABEL": "Lock now",
"PARENTAL_LOCK_NOW_DESCRIPTION": "Hide the parental-locked categories again",
"PARENTAL_UNLOCK_LABEL": "Enter parental PIN",
"PARENTAL_UNLOCK_DESCRIPTION": "Show the parental-locked categories"
},
"XTREAM_REFRESH_TITLE": "كنحدّثو البلايليست",
"XTREAM_REFRESH_COLLECTING_DATA": "كنحافظو على معطيات المكتبة ديالك...",
@@ -1872,5 +1901,39 @@
"failed": "Deletion failed — select to retry"
},
"DELETE_PROGRESS": "Processed {{done}} of {{total}} selected sources"
},
"PARENTAL_LOCK": {
"PIN_DIALOG": {
"UNLOCK_TITLE": "Enter parental PIN",
"UNLOCK_DESCRIPTION": "Locked categories stay hidden until the PIN is entered.",
"SET_TITLE": "Set parental PIN",
"SET_DESCRIPTION": "Choose a PIN that will be required to show locked categories.",
"PIN_LABEL": "PIN",
"CONFIRM_LABEL": "Repeat PIN",
"PIN_HINT": "{{min}} to {{max}} digits",
"WRONG_PIN": "Wrong PIN. Try again.",
"MISMATCH": "The two PINs do not match.",
"COOLDOWN": "Too many attempts. Wait 30 seconds and try again.",
"NO_RECOVERY": "There is no way to recover a forgotten PIN — only resetting the app data removes it.",
"SAVE": "Save PIN",
"UNLOCK": "Unlock",
"CONFIRM_TITLE": "Confirm parental PIN",
"CONFIRM_DESCRIPTION": "Enter the current PIN to change this setting."
},
"LOCKED_COUNT": "Locked",
"LOCK_CATEGORY": "Lock category",
"UNLOCK_CATEGORY": "Unlock category",
"LOCK_GROUP": "Lock group",
"UNLOCK_GROUP": "Unlock group",
"MANAGE_LOCKS": "Lock categories",
"MANAGE_LOCKS_DESCRIPTION": "Locked categories and their channels disappear from the whole app until the parental PIN is entered.",
"LOCK_CENSORED": "Lock adult (18+)",
"UNLOCK_ALL": "Unlock all",
"CENSORED_HINT": "The portal flags this category as adult content",
"SAVE_FAILED": "Saving the category locks failed",
"LOCKED_CATEGORIES_ROW": "{{count}} locked",
"UNLOCK_TO_SHOW": "Enter PIN to show",
"HEADER_LOCK": "Lock now (parental lock)",
"HEADER_UNLOCK": "Enter parental PIN"
}
}
+65 -2
View File
@@ -551,7 +551,32 @@
"EMBEDDED_MPV_AUTO_RECONNECT": "Аўтаматычна перападключацца да абарваных патокаў",
"EMBEDDED_MPV_AUTO_RECONNECT_DESCRIPTION": "Калі паток, які прайграецца, абрываецца, убудаваны прайгравальнік перазагружае яго з усё большымі паўзамі, да 6 спроб, перш чым спыніцца. Паток, які так і не запусціўся, па-ранейшаму патрабуе ручнога «Паўтарыць».",
"VOD_AUTO_FAILOVER": "Аўтапераключэнне крыніцы пры памылцы прайгравання",
"VOD_AUTO_FAILOVER_DESCRIPTION": "Калі фільм не прайграецца, а той жа фільм ёсць у іншым плэйлісце, аўтаматычна пераключацца на яго, захоўваючы пазіцыю прайгравання. Па змаўчанні выключана; пра пераключэнне заўсёды паведамляецца, бо ў іншай крыніцы можа быць іншая агучка."
"VOD_AUTO_FAILOVER_DESCRIPTION": "Калі фільм не прайграецца, а той жа фільм ёсць у іншым плэйлісце, аўтаматычна пераключацца на яго, захоўваючы пазіцыю прайгравання. Па змаўчанні выключана; пра пераключэнне заўсёды паведамляецца, бо ў іншай крыніцы можа быць іншая агучка.",
"NAV_PARENTAL": "Parental lock",
"PARENTAL_LOCK": {
"TITLE": "Parental lock",
"DESCRIPTION": "Hide chosen categories behind a PIN. Locked categories, their channels and titles are removed from the whole app until the PIN is entered.",
"ENABLE": "Enable parental lock",
"ENABLE_DESCRIPTION": "Turning it on asks for a new PIN. Turning it off asks for the current PIN; your locks are kept for later.",
"STATUS": "Current state",
"STATUS_LOCKED": "Locked — locked categories are hidden.",
"STATUS_UNLOCKED": "Unlocked — locked categories are visible until the app locks again.",
"LOCK_NOW": "Lock now",
"UNLOCK": "Unlock",
"RELOCK": "Lock again after",
"RELOCK_DESCRIPTION": "Minutes without interaction before the app locks itself again. Playback counts as interaction. Restarting the app always locks.",
"RELOCK_NEVER": "Only when restarting the app",
"RELOCK_MINUTES": "{{minutes}} minutes of inactivity",
"CHANGE_PIN": "Change PIN",
"CHANGE_PIN_DESCRIPTION": "Asks for the current PIN first.",
"HOW_TO_LOCK": "Choosing what to lock",
"HOW_TO_LOCK_DESCRIPTION": "Open a source, then use “Manage categories” (the tune icon above the category list; “Manage groups” in an M3U playlist): every row has a lock toggle, and Stalker offers “Lock adult (18+)” for genres the portal flags. Right-click a category or group for a quick Lock / Unlock.",
"DISCLAIMER": "This is a child lock, not a security feature: the app's data files are readable by anyone with access to this computer.",
"ENABLED_TOAST": "Parental lock enabled",
"DISABLED_TOAST": "Parental lock disabled",
"PIN_CHANGED_TOAST": "PIN changed",
"LOCKED_TOAST": "Locked"
}
},
"THEMES": {
"DARK_THEME": "Цёмная",
@@ -1681,7 +1706,11 @@
"SWITCH_PLAYER_LABEL": "Пераключыць прайгравальнік на {{name}}",
"SWITCH_PLAYER_DESCRIPTION": "Усталяваць {{name}} як актыўны відэапрайгравальнік",
"SWITCH_PLAYER_FEEDBACK": "Прайгравальнік усталяваны: {{name}}",
"ACCOUNT_INFO_DESCRIPTION_STALKER": "Паказаць даныя акаўнта Stalker-партала"
"ACCOUNT_INFO_DESCRIPTION_STALKER": "Паказаць даныя акаўнта Stalker-партала",
"PARENTAL_LOCK_NOW_LABEL": "Lock now",
"PARENTAL_LOCK_NOW_DESCRIPTION": "Hide the parental-locked categories again",
"PARENTAL_UNLOCK_LABEL": "Enter parental PIN",
"PARENTAL_UNLOCK_DESCRIPTION": "Show the parental-locked categories"
},
"XTREAM_REFRESH_TITLE": "Абнаўленне плэйліста",
"XTREAM_REFRESH_COLLECTING_DATA": "Захаванне даных вашай бібліятэкі...",
@@ -1872,5 +1901,39 @@
"failed": "Deletion failed — select to retry"
},
"DELETE_PROGRESS": "Processed {{done}} of {{total}} selected sources"
},
"PARENTAL_LOCK": {
"PIN_DIALOG": {
"UNLOCK_TITLE": "Enter parental PIN",
"UNLOCK_DESCRIPTION": "Locked categories stay hidden until the PIN is entered.",
"SET_TITLE": "Set parental PIN",
"SET_DESCRIPTION": "Choose a PIN that will be required to show locked categories.",
"PIN_LABEL": "PIN",
"CONFIRM_LABEL": "Repeat PIN",
"PIN_HINT": "{{min}} to {{max}} digits",
"WRONG_PIN": "Wrong PIN. Try again.",
"MISMATCH": "The two PINs do not match.",
"COOLDOWN": "Too many attempts. Wait 30 seconds and try again.",
"NO_RECOVERY": "There is no way to recover a forgotten PIN — only resetting the app data removes it.",
"SAVE": "Save PIN",
"UNLOCK": "Unlock",
"CONFIRM_TITLE": "Confirm parental PIN",
"CONFIRM_DESCRIPTION": "Enter the current PIN to change this setting."
},
"LOCKED_COUNT": "Locked",
"LOCK_CATEGORY": "Lock category",
"UNLOCK_CATEGORY": "Unlock category",
"LOCK_GROUP": "Lock group",
"UNLOCK_GROUP": "Unlock group",
"MANAGE_LOCKS": "Lock categories",
"MANAGE_LOCKS_DESCRIPTION": "Locked categories and their channels disappear from the whole app until the parental PIN is entered.",
"LOCK_CENSORED": "Lock adult (18+)",
"UNLOCK_ALL": "Unlock all",
"CENSORED_HINT": "The portal flags this category as adult content",
"SAVE_FAILED": "Saving the category locks failed",
"LOCKED_CATEGORIES_ROW": "{{count}} locked",
"UNLOCK_TO_SHOW": "Enter PIN to show",
"HEADER_LOCK": "Lock now (parental lock)",
"HEADER_UNLOCK": "Enter parental PIN"
}
}
+65 -2
View File
@@ -551,7 +551,32 @@
"EMBEDDED_MPV_AUTO_RECONNECT": "Unterbrochene Streams automatisch neu verbinden",
"EMBEDDED_MPV_AUTO_RECONNECT_DESCRIPTION": "Wenn ein laufender Stream unterbrochen wird, lädt der eingebettete Player ihn in bis zu 6 Versuchen mit immer längeren Wartezeiten neu, bevor er aufgibt. Ein Stream, der gar nicht erst gestartet ist, erfordert weiterhin ein manuelles „Erneut versuchen“.",
"VOD_AUTO_FAILOVER": "Quelle bei Wiedergabefehler automatisch wechseln",
"VOD_AUTO_FAILOVER_DESCRIPTION": "Wenn sich ein Film nicht abspielen lässt und derselbe Film in einer anderen Playlist vorhanden ist, wird automatisch dorthin gewechselt und die Wiedergabeposition beibehalten. Standardmäßig deaktiviert; der Wechsel wird immer angekündigt, da eine andere Quelle eine andere Synchronfassung enthalten kann."
"VOD_AUTO_FAILOVER_DESCRIPTION": "Wenn sich ein Film nicht abspielen lässt und derselbe Film in einer anderen Playlist vorhanden ist, wird automatisch dorthin gewechselt und die Wiedergabeposition beibehalten. Standardmäßig deaktiviert; der Wechsel wird immer angekündigt, da eine andere Quelle eine andere Synchronfassung enthalten kann.",
"NAV_PARENTAL": "Kindersicherung",
"PARENTAL_LOCK": {
"TITLE": "Kindersicherung",
"DESCRIPTION": "Verbirgt ausgewählte Kategorien hinter einer PIN. Gesperrte Kategorien, ihre Sender und Titel werden aus der gesamten App entfernt, bis die PIN eingegeben wird.",
"ENABLE": "Kindersicherung aktivieren",
"ENABLE_DESCRIPTION": "Beim Aktivieren wird eine neue PIN abgefragt. Beim Deaktivieren die aktuelle PIN; die Sperren bleiben erhalten.",
"STATUS": "Aktueller Zustand",
"STATUS_LOCKED": "Gesperrt – gesperrte Kategorien sind verborgen.",
"STATUS_UNLOCKED": "Entsperrt – gesperrte Kategorien sind sichtbar, bis die App wieder sperrt.",
"LOCK_NOW": "Jetzt sperren",
"UNLOCK": "Entsperren",
"RELOCK": "Erneut sperren nach",
"RELOCK_DESCRIPTION": "Minuten ohne Interaktion, nach denen sich die App wieder sperrt. Wiedergabe zählt als Interaktion. Ein Neustart sperrt immer.",
"RELOCK_NEVER": "Nur beim Neustart der App",
"RELOCK_MINUTES": "{{minutes}} Minuten Inaktivität",
"CHANGE_PIN": "PIN ändern",
"CHANGE_PIN_DESCRIPTION": "Fragt zuerst die aktuelle PIN ab.",
"HOW_TO_LOCK": "Was gesperrt wird",
"HOW_TO_LOCK_DESCRIPTION": "Öffne eine Quelle und wähle „Kategorien verwalten“ (das Einstellungssymbol über der Kategorienliste; in einer M3U-Playlist „Gruppen verwalten“): jede Zeile hat einen Schloss-Schalter, und Stalker bietet „Erwachsene (18+) sperren“ für vom Portal markierte Genres. Rechtsklick auf eine Kategorie oder Gruppe für schnelles Sperren / Entsperren.",
"DISCLAIMER": "Das ist eine Kindersicherung, kein Sicherheitsmerkmal: Die Datendateien der App sind für jeden mit Zugriff auf diesen Computer lesbar.",
"ENABLED_TOAST": "Kindersicherung aktiviert",
"DISABLED_TOAST": "Kindersicherung deaktiviert",
"PIN_CHANGED_TOAST": "PIN geändert",
"LOCKED_TOAST": "Gesperrt"
}
},
"THEMES": {
"DARK_THEME": "Dunkel",
@@ -1681,7 +1706,11 @@
"SWITCH_PLAYER_LABEL": "Player auf {{name}} wechseln",
"SWITCH_PLAYER_DESCRIPTION": "{{name}} als aktiven Videoplayer festlegen",
"SWITCH_PLAYER_FEEDBACK": "Player auf {{name}} festgelegt",
"ACCOUNT_INFO_DESCRIPTION_STALKER": "Kontodaten des Stalker-Portals anzeigen"
"ACCOUNT_INFO_DESCRIPTION_STALKER": "Kontodaten des Stalker-Portals anzeigen",
"PARENTAL_LOCK_NOW_LABEL": "Jetzt sperren",
"PARENTAL_LOCK_NOW_DESCRIPTION": "Kindergesicherte Kategorien wieder verbergen",
"PARENTAL_UNLOCK_LABEL": "Kindersicherungs-PIN eingeben",
"PARENTAL_UNLOCK_DESCRIPTION": "Kindergesicherte Kategorien anzeigen"
},
"XTREAM_REFRESH_TITLE": "Playlist wird aktualisiert",
"XTREAM_REFRESH_COLLECTING_DATA": "Deine Bibliotheksdaten werden gesichert...",
@@ -1872,5 +1901,39 @@
"failed": "Deletion failed — select to retry"
},
"DELETE_PROGRESS": "Processed {{done}} of {{total}} selected sources"
},
"PARENTAL_LOCK": {
"PIN_DIALOG": {
"UNLOCK_TITLE": "Kindersicherungs-PIN eingeben",
"UNLOCK_DESCRIPTION": "Gesperrte Kategorien bleiben verborgen, bis die PIN eingegeben wird.",
"SET_TITLE": "Kindersicherungs-PIN festlegen",
"SET_DESCRIPTION": "Wähle eine PIN, die zum Anzeigen gesperrter Kategorien nötig ist.",
"PIN_LABEL": "PIN",
"CONFIRM_LABEL": "PIN wiederholen",
"PIN_HINT": "{{min}} bis {{max}} Ziffern",
"WRONG_PIN": "Falsche PIN. Bitte erneut versuchen.",
"MISMATCH": "Die beiden PINs stimmen nicht überein.",
"COOLDOWN": "Zu viele Versuche. Warte 30 Sekunden.",
"NO_RECOVERY": "Eine vergessene PIN kann nicht wiederhergestellt werden – nur das Zurücksetzen der App-Daten entfernt sie.",
"SAVE": "PIN speichern",
"UNLOCK": "Entsperren",
"CONFIRM_TITLE": "Kindersicherungs-PIN bestätigen",
"CONFIRM_DESCRIPTION": "Gib die aktuelle PIN ein, um diese Einstellung zu ändern."
},
"LOCKED_COUNT": "Gesperrt",
"LOCK_CATEGORY": "Kategorie sperren",
"UNLOCK_CATEGORY": "Kategorie entsperren",
"LOCK_GROUP": "Gruppe sperren",
"UNLOCK_GROUP": "Gruppe entsperren",
"MANAGE_LOCKS": "Kategorien sperren",
"MANAGE_LOCKS_DESCRIPTION": "Gesperrte Kategorien und ihre Sender verschwinden aus der gesamten App, bis die Kindersicherungs-PIN eingegeben wird.",
"LOCK_CENSORED": "18+ sperren",
"UNLOCK_ALL": "Alle entsperren",
"CENSORED_HINT": "Das Portal kennzeichnet diese Kategorie als Inhalt für Erwachsene",
"SAVE_FAILED": "Die Kategoriesperren konnten nicht gespeichert werden",
"LOCKED_CATEGORIES_ROW": "{{count}} gesperrt",
"UNLOCK_TO_SHOW": "PIN eingeben zum Anzeigen",
"HEADER_LOCK": "Jetzt sperren (Kindersicherung)",
"HEADER_UNLOCK": "Kindersicherungs-PIN eingeben"
}
}
+65 -2
View File
@@ -551,7 +551,32 @@
"EMBEDDED_MPV_AUTO_RECONNECT": "Αυτόματη επανασύνδεση σε ροές που διακόπηκαν",
"EMBEDDED_MPV_AUTO_RECONNECT_DESCRIPTION": "Όταν μια ροή που αναπαράγεται διακοπεί, το ενσωματωμένο πρόγραμμα αναπαραγωγής τη φορτώνει ξανά με αυξανόμενες καθυστερήσεις, έως 6 προσπάθειες, πριν τα παρατήσει. Μια ροή που δεν ξεκίνησε ποτέ εξακολουθεί να χρειάζεται χειροκίνητη Επανάληψη.",
"VOD_AUTO_FAILOVER": "Αυτόματη αλλαγή πηγής σε σφάλμα αναπαραγωγής",
"VOD_AUTO_FAILOVER_DESCRIPTION": "Όταν μια ταινία δεν μπορεί να αναπαραχθεί και η ίδια ταινία υπάρχει σε άλλη λίστα αναπαραγωγής, γίνεται αυτόματη μετάβαση σε αυτήν με διατήρηση της θέσης αναπαραγωγής. Απενεργοποιημένο από προεπιλογή· η αλλαγή ανακοινώνεται πάντα, επειδή μια άλλη πηγή μπορεί να έχει διαφορετική μεταγλώττιση."
"VOD_AUTO_FAILOVER_DESCRIPTION": "Όταν μια ταινία δεν μπορεί να αναπαραχθεί και η ίδια ταινία υπάρχει σε άλλη λίστα αναπαραγωγής, γίνεται αυτόματη μετάβαση σε αυτήν με διατήρηση της θέσης αναπαραγωγής. Απενεργοποιημένο από προεπιλογή· η αλλαγή ανακοινώνεται πάντα, επειδή μια άλλη πηγή μπορεί να έχει διαφορετική μεταγλώττιση.",
"NAV_PARENTAL": "Parental lock",
"PARENTAL_LOCK": {
"TITLE": "Parental lock",
"DESCRIPTION": "Hide chosen categories behind a PIN. Locked categories, their channels and titles are removed from the whole app until the PIN is entered.",
"ENABLE": "Enable parental lock",
"ENABLE_DESCRIPTION": "Turning it on asks for a new PIN. Turning it off asks for the current PIN; your locks are kept for later.",
"STATUS": "Current state",
"STATUS_LOCKED": "Locked — locked categories are hidden.",
"STATUS_UNLOCKED": "Unlocked — locked categories are visible until the app locks again.",
"LOCK_NOW": "Lock now",
"UNLOCK": "Unlock",
"RELOCK": "Lock again after",
"RELOCK_DESCRIPTION": "Minutes without interaction before the app locks itself again. Playback counts as interaction. Restarting the app always locks.",
"RELOCK_NEVER": "Only when restarting the app",
"RELOCK_MINUTES": "{{minutes}} minutes of inactivity",
"CHANGE_PIN": "Change PIN",
"CHANGE_PIN_DESCRIPTION": "Asks for the current PIN first.",
"HOW_TO_LOCK": "Choosing what to lock",
"HOW_TO_LOCK_DESCRIPTION": "Open a source, then use “Manage categories” (the tune icon above the category list; “Manage groups” in an M3U playlist): every row has a lock toggle, and Stalker offers “Lock adult (18+)” for genres the portal flags. Right-click a category or group for a quick Lock / Unlock.",
"DISCLAIMER": "This is a child lock, not a security feature: the app's data files are readable by anyone with access to this computer.",
"ENABLED_TOAST": "Parental lock enabled",
"DISABLED_TOAST": "Parental lock disabled",
"PIN_CHANGED_TOAST": "PIN changed",
"LOCKED_TOAST": "Locked"
}
},
"THEMES": {
"DARK_THEME": "Σκούρο",
@@ -1681,7 +1706,11 @@
"SWITCH_PLAYER_LABEL": "Εναλλαγή προγράμματος αναπαραγωγής σε {{name}}",
"SWITCH_PLAYER_DESCRIPTION": "Ορισμός του {{name}} ως ενεργό πρόγραμμα αναπαραγωγής βίντεο",
"SWITCH_PLAYER_FEEDBACK": "Το πρόγραμμα αναπαραγωγής ορίστηκε σε {{name}}",
"ACCOUNT_INFO_DESCRIPTION_STALKER": "Εμφάνιση στοιχείων λογαριασμού της πύλης Stalker"
"ACCOUNT_INFO_DESCRIPTION_STALKER": "Εμφάνιση στοιχείων λογαριασμού της πύλης Stalker",
"PARENTAL_LOCK_NOW_LABEL": "Lock now",
"PARENTAL_LOCK_NOW_DESCRIPTION": "Hide the parental-locked categories again",
"PARENTAL_UNLOCK_LABEL": "Enter parental PIN",
"PARENTAL_UNLOCK_DESCRIPTION": "Show the parental-locked categories"
},
"XTREAM_REFRESH_TITLE": "Ανανέωση λίστας αναπαραγωγής",
"XTREAM_REFRESH_COLLECTING_DATA": "Διατήρηση των δεδομένων της βιβλιοθήκης σας...",
@@ -1872,5 +1901,39 @@
"failed": "Deletion failed — select to retry"
},
"DELETE_PROGRESS": "Processed {{done}} of {{total}} selected sources"
},
"PARENTAL_LOCK": {
"PIN_DIALOG": {
"UNLOCK_TITLE": "Enter parental PIN",
"UNLOCK_DESCRIPTION": "Locked categories stay hidden until the PIN is entered.",
"SET_TITLE": "Set parental PIN",
"SET_DESCRIPTION": "Choose a PIN that will be required to show locked categories.",
"PIN_LABEL": "PIN",
"CONFIRM_LABEL": "Repeat PIN",
"PIN_HINT": "{{min}} to {{max}} digits",
"WRONG_PIN": "Wrong PIN. Try again.",
"MISMATCH": "The two PINs do not match.",
"COOLDOWN": "Too many attempts. Wait 30 seconds and try again.",
"NO_RECOVERY": "There is no way to recover a forgotten PIN — only resetting the app data removes it.",
"SAVE": "Save PIN",
"UNLOCK": "Unlock",
"CONFIRM_TITLE": "Confirm parental PIN",
"CONFIRM_DESCRIPTION": "Enter the current PIN to change this setting."
},
"LOCKED_COUNT": "Locked",
"LOCK_CATEGORY": "Lock category",
"UNLOCK_CATEGORY": "Unlock category",
"LOCK_GROUP": "Lock group",
"UNLOCK_GROUP": "Unlock group",
"MANAGE_LOCKS": "Lock categories",
"MANAGE_LOCKS_DESCRIPTION": "Locked categories and their channels disappear from the whole app until the parental PIN is entered.",
"LOCK_CENSORED": "Lock adult (18+)",
"UNLOCK_ALL": "Unlock all",
"CENSORED_HINT": "The portal flags this category as adult content",
"SAVE_FAILED": "Saving the category locks failed",
"LOCKED_CATEGORIES_ROW": "{{count}} locked",
"UNLOCK_TO_SHOW": "Enter PIN to show",
"HEADER_LOCK": "Lock now (parental lock)",
"HEADER_UNLOCK": "Enter parental PIN"
}
}
+65 -2
View File
@@ -551,7 +551,32 @@
"EMBEDDED_MPV_AUTO_RECONNECT": "Reconnect dropped streams automatically",
"EMBEDDED_MPV_AUTO_RECONNECT_DESCRIPTION": "When a playing stream drops, the embedded player reloads it with increasing delays, up to 6 attempts, before giving up. A stream that never started still needs a manual Retry.",
"VOD_AUTO_FAILOVER": "Auto-switch source on playback error",
"VOD_AUTO_FAILOVER_DESCRIPTION": "When a movie fails to play and the same film exists in another playlist, switch to it automatically and keep the timecode. Off by default; the switch is always announced because another source can carry a different dub."
"VOD_AUTO_FAILOVER_DESCRIPTION": "When a movie fails to play and the same film exists in another playlist, switch to it automatically and keep the timecode. Off by default; the switch is always announced because another source can carry a different dub.",
"NAV_PARENTAL": "Parental lock",
"PARENTAL_LOCK": {
"TITLE": "Parental lock",
"DESCRIPTION": "Hide chosen categories behind a PIN. Locked categories, their channels and titles are removed from the whole app until the PIN is entered.",
"ENABLE": "Enable parental lock",
"ENABLE_DESCRIPTION": "Turning it on asks for a new PIN. Turning it off asks for the current PIN; your locks are kept for later.",
"STATUS": "Current state",
"STATUS_LOCKED": "Locked — locked categories are hidden.",
"STATUS_UNLOCKED": "Unlocked — locked categories are visible until the app locks again.",
"LOCK_NOW": "Lock now",
"UNLOCK": "Unlock",
"RELOCK": "Lock again after",
"RELOCK_DESCRIPTION": "Minutes without interaction before the app locks itself again. Playback counts as interaction. Restarting the app always locks.",
"RELOCK_NEVER": "Only when restarting the app",
"RELOCK_MINUTES": "{{minutes}} minutes of inactivity",
"CHANGE_PIN": "Change PIN",
"CHANGE_PIN_DESCRIPTION": "Asks for the current PIN first.",
"HOW_TO_LOCK": "Choosing what to lock",
"HOW_TO_LOCK_DESCRIPTION": "Open a source, then use “Manage categories” (the tune icon above the category list; “Manage groups” in an M3U playlist): every row has a lock toggle, and Stalker offers “Lock adult (18+)” for genres the portal flags. Right-click a category or group for a quick Lock / Unlock.",
"DISCLAIMER": "This is a child lock, not a security feature: the app's data files are readable by anyone with access to this computer.",
"ENABLED_TOAST": "Parental lock enabled",
"DISABLED_TOAST": "Parental lock disabled",
"PIN_CHANGED_TOAST": "PIN changed",
"LOCKED_TOAST": "Locked"
}
},
"THEMES": {
"DARK_THEME": "Dark",
@@ -1681,7 +1706,11 @@
"SWITCH_PLAYER_LABEL": "Switch player to {{name}}",
"SWITCH_PLAYER_DESCRIPTION": "Set {{name}} as the active video player",
"SWITCH_PLAYER_FEEDBACK": "Player set to {{name}}",
"ACCOUNT_INFO_DESCRIPTION_STALKER": "Show Stalker portal account details"
"ACCOUNT_INFO_DESCRIPTION_STALKER": "Show Stalker portal account details",
"PARENTAL_LOCK_NOW_LABEL": "Lock now",
"PARENTAL_LOCK_NOW_DESCRIPTION": "Hide the parental-locked categories again",
"PARENTAL_UNLOCK_LABEL": "Enter parental PIN",
"PARENTAL_UNLOCK_DESCRIPTION": "Show the parental-locked categories"
},
"XTREAM_REFRESH_TITLE": "Refreshing playlist",
"XTREAM_REFRESH_COLLECTING_DATA": "Preserving your library data...",
@@ -1872,5 +1901,39 @@
"failed": "Deletion failed — select to retry"
},
"DELETE_PROGRESS": "Processed {{done}} of {{total}} selected sources"
},
"PARENTAL_LOCK": {
"PIN_DIALOG": {
"UNLOCK_TITLE": "Enter parental PIN",
"UNLOCK_DESCRIPTION": "Locked categories stay hidden until the PIN is entered.",
"SET_TITLE": "Set parental PIN",
"SET_DESCRIPTION": "Choose a PIN that will be required to show locked categories.",
"PIN_LABEL": "PIN",
"CONFIRM_LABEL": "Repeat PIN",
"PIN_HINT": "{{min}} to {{max}} digits",
"WRONG_PIN": "Wrong PIN. Try again.",
"MISMATCH": "The two PINs do not match.",
"COOLDOWN": "Too many attempts. Wait 30 seconds and try again.",
"NO_RECOVERY": "There is no way to recover a forgotten PIN — only resetting the app data removes it.",
"SAVE": "Save PIN",
"UNLOCK": "Unlock",
"CONFIRM_TITLE": "Confirm parental PIN",
"CONFIRM_DESCRIPTION": "Enter the current PIN to change this setting."
},
"LOCKED_COUNT": "Locked",
"LOCK_CATEGORY": "Lock category",
"UNLOCK_CATEGORY": "Unlock category",
"LOCK_GROUP": "Lock group",
"UNLOCK_GROUP": "Unlock group",
"MANAGE_LOCKS": "Lock categories",
"MANAGE_LOCKS_DESCRIPTION": "Locked categories and their channels disappear from the whole app until the parental PIN is entered.",
"LOCK_CENSORED": "Lock adult (18+)",
"UNLOCK_ALL": "Unlock all",
"CENSORED_HINT": "The portal flags this category as adult content",
"SAVE_FAILED": "Saving the category locks failed",
"LOCKED_CATEGORIES_ROW": "{{count}} locked",
"UNLOCK_TO_SHOW": "Enter PIN to show",
"HEADER_LOCK": "Lock now (parental lock)",
"HEADER_UNLOCK": "Enter parental PIN"
}
}
+65 -2
View File
@@ -551,7 +551,32 @@
"EMBEDDED_MPV_AUTO_RECONNECT": "Reconectar automáticamente los streams interrumpidos",
"EMBEDDED_MPV_AUTO_RECONNECT_DESCRIPTION": "Cuando un stream en reproducción se interrumpe, el reproductor integrado lo vuelve a cargar con intervalos cada vez más largos, hasta 6 intentos, antes de desistir. Un stream que nunca llegó a iniciarse sigue necesitando un Reintentar manual.",
"VOD_AUTO_FAILOVER": "Cambiar de fuente automáticamente al fallar la reproducción",
"VOD_AUTO_FAILOVER_DESCRIPTION": "Cuando una película no se puede reproducir y la misma película está en otra lista, cambia a ella automáticamente y conserva la posición de reproducción. Desactivado de forma predeterminada; el cambio siempre se avisa, porque otra fuente puede tener un doblaje distinto."
"VOD_AUTO_FAILOVER_DESCRIPTION": "Cuando una película no se puede reproducir y la misma película está en otra lista, cambia a ella automáticamente y conserva la posición de reproducción. Desactivado de forma predeterminada; el cambio siempre se avisa, porque otra fuente puede tener un doblaje distinto.",
"NAV_PARENTAL": "Parental lock",
"PARENTAL_LOCK": {
"TITLE": "Parental lock",
"DESCRIPTION": "Hide chosen categories behind a PIN. Locked categories, their channels and titles are removed from the whole app until the PIN is entered.",
"ENABLE": "Enable parental lock",
"ENABLE_DESCRIPTION": "Turning it on asks for a new PIN. Turning it off asks for the current PIN; your locks are kept for later.",
"STATUS": "Current state",
"STATUS_LOCKED": "Locked — locked categories are hidden.",
"STATUS_UNLOCKED": "Unlocked — locked categories are visible until the app locks again.",
"LOCK_NOW": "Lock now",
"UNLOCK": "Unlock",
"RELOCK": "Lock again after",
"RELOCK_DESCRIPTION": "Minutes without interaction before the app locks itself again. Playback counts as interaction. Restarting the app always locks.",
"RELOCK_NEVER": "Only when restarting the app",
"RELOCK_MINUTES": "{{minutes}} minutes of inactivity",
"CHANGE_PIN": "Change PIN",
"CHANGE_PIN_DESCRIPTION": "Asks for the current PIN first.",
"HOW_TO_LOCK": "Choosing what to lock",
"HOW_TO_LOCK_DESCRIPTION": "Open a source, then use “Manage categories” (the tune icon above the category list; “Manage groups” in an M3U playlist): every row has a lock toggle, and Stalker offers “Lock adult (18+)” for genres the portal flags. Right-click a category or group for a quick Lock / Unlock.",
"DISCLAIMER": "This is a child lock, not a security feature: the app's data files are readable by anyone with access to this computer.",
"ENABLED_TOAST": "Parental lock enabled",
"DISABLED_TOAST": "Parental lock disabled",
"PIN_CHANGED_TOAST": "PIN changed",
"LOCKED_TOAST": "Locked"
}
},
"THEMES": {
"DARK_THEME": "Oscuro",
@@ -1681,7 +1706,11 @@
"SWITCH_PLAYER_LABEL": "Cambiar reproductor a {{name}}",
"SWITCH_PLAYER_DESCRIPTION": "Establecer {{name}} como reproductor de vídeo activo",
"SWITCH_PLAYER_FEEDBACK": "Reproductor establecido a {{name}}",
"ACCOUNT_INFO_DESCRIPTION_STALKER": "Mostrar los datos de la cuenta del portal Stalker"
"ACCOUNT_INFO_DESCRIPTION_STALKER": "Mostrar los datos de la cuenta del portal Stalker",
"PARENTAL_LOCK_NOW_LABEL": "Lock now",
"PARENTAL_LOCK_NOW_DESCRIPTION": "Hide the parental-locked categories again",
"PARENTAL_UNLOCK_LABEL": "Enter parental PIN",
"PARENTAL_UNLOCK_DESCRIPTION": "Show the parental-locked categories"
},
"XTREAM_REFRESH_TITLE": "Actualizando lista de reproducción",
"XTREAM_REFRESH_COLLECTING_DATA": "Conservando los datos de tu biblioteca...",
@@ -1872,5 +1901,39 @@
"failed": "Deletion failed — select to retry"
},
"DELETE_PROGRESS": "Processed {{done}} of {{total}} selected sources"
},
"PARENTAL_LOCK": {
"PIN_DIALOG": {
"UNLOCK_TITLE": "Enter parental PIN",
"UNLOCK_DESCRIPTION": "Locked categories stay hidden until the PIN is entered.",
"SET_TITLE": "Set parental PIN",
"SET_DESCRIPTION": "Choose a PIN that will be required to show locked categories.",
"PIN_LABEL": "PIN",
"CONFIRM_LABEL": "Repeat PIN",
"PIN_HINT": "{{min}} to {{max}} digits",
"WRONG_PIN": "Wrong PIN. Try again.",
"MISMATCH": "The two PINs do not match.",
"COOLDOWN": "Too many attempts. Wait 30 seconds and try again.",
"NO_RECOVERY": "There is no way to recover a forgotten PIN — only resetting the app data removes it.",
"SAVE": "Save PIN",
"UNLOCK": "Unlock",
"CONFIRM_TITLE": "Confirm parental PIN",
"CONFIRM_DESCRIPTION": "Enter the current PIN to change this setting."
},
"LOCKED_COUNT": "Locked",
"LOCK_CATEGORY": "Lock category",
"UNLOCK_CATEGORY": "Unlock category",
"LOCK_GROUP": "Lock group",
"UNLOCK_GROUP": "Unlock group",
"MANAGE_LOCKS": "Lock categories",
"MANAGE_LOCKS_DESCRIPTION": "Locked categories and their channels disappear from the whole app until the parental PIN is entered.",
"LOCK_CENSORED": "Lock adult (18+)",
"UNLOCK_ALL": "Unlock all",
"CENSORED_HINT": "The portal flags this category as adult content",
"SAVE_FAILED": "Saving the category locks failed",
"LOCKED_CATEGORIES_ROW": "{{count}} locked",
"UNLOCK_TO_SHOW": "Enter PIN to show",
"HEADER_LOCK": "Lock now (parental lock)",
"HEADER_UNLOCK": "Enter parental PIN"
}
}
+65 -2
View File
@@ -551,7 +551,32 @@
"EMBEDDED_MPV_AUTO_RECONNECT": "Reconnecter automatiquement les flux interrompus",
"EMBEDDED_MPV_AUTO_RECONNECT_DESCRIPTION": "Lorsqu'un flux en cours de lecture est interrompu, le lecteur intégré le recharge avec des délais croissants, jusqu'à 6 tentatives, avant d'abandonner. Un flux qui n'a jamais démarré doit toujours être relancé manuellement avec « Réessayer ».",
"VOD_AUTO_FAILOVER": "Changer de source automatiquement en cas d'erreur de lecture",
"VOD_AUTO_FAILOVER_DESCRIPTION": "Lorsqu'un film ne parvient pas à démarrer et que le même film existe dans une autre liste de lecture, bascule automatiquement vers celle-ci en conservant la position de lecture. Désactivé par défaut ; le changement est toujours annoncé, car une autre source peut proposer un doublage différent."
"VOD_AUTO_FAILOVER_DESCRIPTION": "Lorsqu'un film ne parvient pas à démarrer et que le même film existe dans une autre liste de lecture, bascule automatiquement vers celle-ci en conservant la position de lecture. Désactivé par défaut ; le changement est toujours annoncé, car une autre source peut proposer un doublage différent.",
"NAV_PARENTAL": "Parental lock",
"PARENTAL_LOCK": {
"TITLE": "Parental lock",
"DESCRIPTION": "Hide chosen categories behind a PIN. Locked categories, their channels and titles are removed from the whole app until the PIN is entered.",
"ENABLE": "Enable parental lock",
"ENABLE_DESCRIPTION": "Turning it on asks for a new PIN. Turning it off asks for the current PIN; your locks are kept for later.",
"STATUS": "Current state",
"STATUS_LOCKED": "Locked — locked categories are hidden.",
"STATUS_UNLOCKED": "Unlocked — locked categories are visible until the app locks again.",
"LOCK_NOW": "Lock now",
"UNLOCK": "Unlock",
"RELOCK": "Lock again after",
"RELOCK_DESCRIPTION": "Minutes without interaction before the app locks itself again. Playback counts as interaction. Restarting the app always locks.",
"RELOCK_NEVER": "Only when restarting the app",
"RELOCK_MINUTES": "{{minutes}} minutes of inactivity",
"CHANGE_PIN": "Change PIN",
"CHANGE_PIN_DESCRIPTION": "Asks for the current PIN first.",
"HOW_TO_LOCK": "Choosing what to lock",
"HOW_TO_LOCK_DESCRIPTION": "Open a source, then use “Manage categories” (the tune icon above the category list; “Manage groups” in an M3U playlist): every row has a lock toggle, and Stalker offers “Lock adult (18+)” for genres the portal flags. Right-click a category or group for a quick Lock / Unlock.",
"DISCLAIMER": "This is a child lock, not a security feature: the app's data files are readable by anyone with access to this computer.",
"ENABLED_TOAST": "Parental lock enabled",
"DISABLED_TOAST": "Parental lock disabled",
"PIN_CHANGED_TOAST": "PIN changed",
"LOCKED_TOAST": "Locked"
}
},
"THEMES": {
"DARK_THEME": "Sombre",
@@ -1681,7 +1706,11 @@
"SWITCH_PLAYER_LABEL": "Basculer le lecteur sur {{name}}",
"SWITCH_PLAYER_DESCRIPTION": "Définir {{name}} comme lecteur vidéo actif",
"SWITCH_PLAYER_FEEDBACK": "Lecteur défini sur {{name}}",
"ACCOUNT_INFO_DESCRIPTION_STALKER": "Afficher les informations du compte du portail Stalker"
"ACCOUNT_INFO_DESCRIPTION_STALKER": "Afficher les informations du compte du portail Stalker",
"PARENTAL_LOCK_NOW_LABEL": "Lock now",
"PARENTAL_LOCK_NOW_DESCRIPTION": "Hide the parental-locked categories again",
"PARENTAL_UNLOCK_LABEL": "Enter parental PIN",
"PARENTAL_UNLOCK_DESCRIPTION": "Show the parental-locked categories"
},
"XTREAM_REFRESH_TITLE": "Actualisation de la playlist",
"XTREAM_REFRESH_COLLECTING_DATA": "Préservation des données de votre bibliothèque...",
@@ -1872,5 +1901,39 @@
"failed": "Deletion failed — select to retry"
},
"DELETE_PROGRESS": "Processed {{done}} of {{total}} selected sources"
},
"PARENTAL_LOCK": {
"PIN_DIALOG": {
"UNLOCK_TITLE": "Enter parental PIN",
"UNLOCK_DESCRIPTION": "Locked categories stay hidden until the PIN is entered.",
"SET_TITLE": "Set parental PIN",
"SET_DESCRIPTION": "Choose a PIN that will be required to show locked categories.",
"PIN_LABEL": "PIN",
"CONFIRM_LABEL": "Repeat PIN",
"PIN_HINT": "{{min}} to {{max}} digits",
"WRONG_PIN": "Wrong PIN. Try again.",
"MISMATCH": "The two PINs do not match.",
"COOLDOWN": "Too many attempts. Wait 30 seconds and try again.",
"NO_RECOVERY": "There is no way to recover a forgotten PIN — only resetting the app data removes it.",
"SAVE": "Save PIN",
"UNLOCK": "Unlock",
"CONFIRM_TITLE": "Confirm parental PIN",
"CONFIRM_DESCRIPTION": "Enter the current PIN to change this setting."
},
"LOCKED_COUNT": "Locked",
"LOCK_CATEGORY": "Lock category",
"UNLOCK_CATEGORY": "Unlock category",
"LOCK_GROUP": "Lock group",
"UNLOCK_GROUP": "Unlock group",
"MANAGE_LOCKS": "Lock categories",
"MANAGE_LOCKS_DESCRIPTION": "Locked categories and their channels disappear from the whole app until the parental PIN is entered.",
"LOCK_CENSORED": "Lock adult (18+)",
"UNLOCK_ALL": "Unlock all",
"CENSORED_HINT": "The portal flags this category as adult content",
"SAVE_FAILED": "Saving the category locks failed",
"LOCKED_CATEGORIES_ROW": "{{count}} locked",
"UNLOCK_TO_SHOW": "Enter PIN to show",
"HEADER_LOCK": "Lock now (parental lock)",
"HEADER_UNLOCK": "Enter parental PIN"
}
}
+65 -2
View File
@@ -551,7 +551,32 @@
"EMBEDDED_MPV_AUTO_RECONNECT": "Megszakadt adatfolyamok automatikus újracsatlakoztatása",
"EMBEDDED_MPV_AUTO_RECONNECT_DESCRIPTION": "Ha egy lejátszás alatt álló adatfolyam megszakad, a beágyazott lejátszó egyre hosszabb várakozással, legfeljebb 6 próbálkozással tölti be újra, mielőtt feladná. Az el sem indult adatfolyamhoz továbbra is kézi újrapróbálkozás szükséges.",
"VOD_AUTO_FAILOVER": "Automatikus forrásváltás lejátszási hiba esetén",
"VOD_AUTO_FAILOVER_DESCRIPTION": "Ha egy film lejátszása meghiúsul, és ugyanaz a film egy másik lejátszási listában is megtalálható, az alkalmazás automatikusan átvált rá, és megőrzi a lejátszási pozíciót. Alapértelmezés szerint kikapcsolva; a váltásról mindig értesítés jelenik meg, mert egy másik forráson eltérő szinkron lehet."
"VOD_AUTO_FAILOVER_DESCRIPTION": "Ha egy film lejátszása meghiúsul, és ugyanaz a film egy másik lejátszási listában is megtalálható, az alkalmazás automatikusan átvált rá, és megőrzi a lejátszási pozíciót. Alapértelmezés szerint kikapcsolva; a váltásról mindig értesítés jelenik meg, mert egy másik forráson eltérő szinkron lehet.",
"NAV_PARENTAL": "Parental lock",
"PARENTAL_LOCK": {
"TITLE": "Parental lock",
"DESCRIPTION": "Hide chosen categories behind a PIN. Locked categories, their channels and titles are removed from the whole app until the PIN is entered.",
"ENABLE": "Enable parental lock",
"ENABLE_DESCRIPTION": "Turning it on asks for a new PIN. Turning it off asks for the current PIN; your locks are kept for later.",
"STATUS": "Current state",
"STATUS_LOCKED": "Locked — locked categories are hidden.",
"STATUS_UNLOCKED": "Unlocked — locked categories are visible until the app locks again.",
"LOCK_NOW": "Lock now",
"UNLOCK": "Unlock",
"RELOCK": "Lock again after",
"RELOCK_DESCRIPTION": "Minutes without interaction before the app locks itself again. Playback counts as interaction. Restarting the app always locks.",
"RELOCK_NEVER": "Only when restarting the app",
"RELOCK_MINUTES": "{{minutes}} minutes of inactivity",
"CHANGE_PIN": "Change PIN",
"CHANGE_PIN_DESCRIPTION": "Asks for the current PIN first.",
"HOW_TO_LOCK": "Choosing what to lock",
"HOW_TO_LOCK_DESCRIPTION": "Open a source, then use “Manage categories” (the tune icon above the category list; “Manage groups” in an M3U playlist): every row has a lock toggle, and Stalker offers “Lock adult (18+)” for genres the portal flags. Right-click a category or group for a quick Lock / Unlock.",
"DISCLAIMER": "This is a child lock, not a security feature: the app's data files are readable by anyone with access to this computer.",
"ENABLED_TOAST": "Parental lock enabled",
"DISABLED_TOAST": "Parental lock disabled",
"PIN_CHANGED_TOAST": "PIN changed",
"LOCKED_TOAST": "Locked"
}
},
"THEMES": {
"DARK_THEME": "Sötét",
@@ -1681,7 +1706,11 @@
"SWITCH_PLAYER_LABEL": "Lejátszóváltás: {{name}}",
"SWITCH_PLAYER_DESCRIPTION": "{{name}} beállítása aktív videólejátszóként",
"SWITCH_PLAYER_FEEDBACK": "Aktív lejátszó: {{name}}",
"ACCOUNT_INFO_DESCRIPTION_STALKER": "A Stalker portál fiókadatainak megjelenítése"
"ACCOUNT_INFO_DESCRIPTION_STALKER": "A Stalker portál fiókadatainak megjelenítése",
"PARENTAL_LOCK_NOW_LABEL": "Lock now",
"PARENTAL_LOCK_NOW_DESCRIPTION": "Hide the parental-locked categories again",
"PARENTAL_UNLOCK_LABEL": "Enter parental PIN",
"PARENTAL_UNLOCK_DESCRIPTION": "Show the parental-locked categories"
},
"XTREAM_REFRESH_TITLE": "Lejátszási lista frissítése",
"XTREAM_REFRESH_COLLECTING_DATA": "A médiatár adatainak megőrzése…",
@@ -1872,5 +1901,39 @@
"failed": "Deletion failed — select to retry"
},
"DELETE_PROGRESS": "Processed {{done}} of {{total}} selected sources"
},
"PARENTAL_LOCK": {
"PIN_DIALOG": {
"UNLOCK_TITLE": "Enter parental PIN",
"UNLOCK_DESCRIPTION": "Locked categories stay hidden until the PIN is entered.",
"SET_TITLE": "Set parental PIN",
"SET_DESCRIPTION": "Choose a PIN that will be required to show locked categories.",
"PIN_LABEL": "PIN",
"CONFIRM_LABEL": "Repeat PIN",
"PIN_HINT": "{{min}} to {{max}} digits",
"WRONG_PIN": "Wrong PIN. Try again.",
"MISMATCH": "The two PINs do not match.",
"COOLDOWN": "Too many attempts. Wait 30 seconds and try again.",
"NO_RECOVERY": "There is no way to recover a forgotten PIN — only resetting the app data removes it.",
"SAVE": "Save PIN",
"UNLOCK": "Unlock",
"CONFIRM_TITLE": "Confirm parental PIN",
"CONFIRM_DESCRIPTION": "Enter the current PIN to change this setting."
},
"LOCKED_COUNT": "Locked",
"LOCK_CATEGORY": "Lock category",
"UNLOCK_CATEGORY": "Unlock category",
"LOCK_GROUP": "Lock group",
"UNLOCK_GROUP": "Unlock group",
"MANAGE_LOCKS": "Lock categories",
"MANAGE_LOCKS_DESCRIPTION": "Locked categories and their channels disappear from the whole app until the parental PIN is entered.",
"LOCK_CENSORED": "Lock adult (18+)",
"UNLOCK_ALL": "Unlock all",
"CENSORED_HINT": "The portal flags this category as adult content",
"SAVE_FAILED": "Saving the category locks failed",
"LOCKED_CATEGORIES_ROW": "{{count}} locked",
"UNLOCK_TO_SHOW": "Enter PIN to show",
"HEADER_LOCK": "Lock now (parental lock)",
"HEADER_UNLOCK": "Enter parental PIN"
}
}
+65 -2
View File
@@ -551,7 +551,32 @@
"EMBEDDED_MPV_AUTO_RECONNECT": "Riconnetti automaticamente gli stream interrotti",
"EMBEDDED_MPV_AUTO_RECONNECT_DESCRIPTION": "Quando uno stream in riproduzione si interrompe, il lettore integrato lo ricarica a intervalli crescenti, fino a 6 tentativi, prima di rinunciare. Per uno stream che non è mai partito è comunque necessario premere Riprova manualmente.",
"VOD_AUTO_FAILOVER": "Cambia sorgente automaticamente in caso di errore di riproduzione",
"VOD_AUTO_FAILOVER_DESCRIPTION": "Quando la riproduzione di un film non riesce e lo stesso film è presente in un'altra playlist, l'app passa automaticamente a quella sorgente mantenendo la posizione di riproduzione. Disattivo per impostazione predefinita; il cambio viene sempre segnalato perché un'altra sorgente può avere un doppiaggio diverso."
"VOD_AUTO_FAILOVER_DESCRIPTION": "Quando la riproduzione di un film non riesce e lo stesso film è presente in un'altra playlist, l'app passa automaticamente a quella sorgente mantenendo la posizione di riproduzione. Disattivo per impostazione predefinita; il cambio viene sempre segnalato perché un'altra sorgente può avere un doppiaggio diverso.",
"NAV_PARENTAL": "Parental lock",
"PARENTAL_LOCK": {
"TITLE": "Parental lock",
"DESCRIPTION": "Hide chosen categories behind a PIN. Locked categories, their channels and titles are removed from the whole app until the PIN is entered.",
"ENABLE": "Enable parental lock",
"ENABLE_DESCRIPTION": "Turning it on asks for a new PIN. Turning it off asks for the current PIN; your locks are kept for later.",
"STATUS": "Current state",
"STATUS_LOCKED": "Locked — locked categories are hidden.",
"STATUS_UNLOCKED": "Unlocked — locked categories are visible until the app locks again.",
"LOCK_NOW": "Lock now",
"UNLOCK": "Unlock",
"RELOCK": "Lock again after",
"RELOCK_DESCRIPTION": "Minutes without interaction before the app locks itself again. Playback counts as interaction. Restarting the app always locks.",
"RELOCK_NEVER": "Only when restarting the app",
"RELOCK_MINUTES": "{{minutes}} minutes of inactivity",
"CHANGE_PIN": "Change PIN",
"CHANGE_PIN_DESCRIPTION": "Asks for the current PIN first.",
"HOW_TO_LOCK": "Choosing what to lock",
"HOW_TO_LOCK_DESCRIPTION": "Open a source, then use “Manage categories” (the tune icon above the category list; “Manage groups” in an M3U playlist): every row has a lock toggle, and Stalker offers “Lock adult (18+)” for genres the portal flags. Right-click a category or group for a quick Lock / Unlock.",
"DISCLAIMER": "This is a child lock, not a security feature: the app's data files are readable by anyone with access to this computer.",
"ENABLED_TOAST": "Parental lock enabled",
"DISABLED_TOAST": "Parental lock disabled",
"PIN_CHANGED_TOAST": "PIN changed",
"LOCKED_TOAST": "Locked"
}
},
"THEMES": {
"DARK_THEME": "Scuro",
@@ -1681,7 +1706,11 @@
"SWITCH_PLAYER_LABEL": "Passa al lettore {{name}}",
"SWITCH_PLAYER_DESCRIPTION": "Imposta {{name}} come lettore video attivo",
"SWITCH_PLAYER_FEEDBACK": "Lettore impostato su {{name}}",
"ACCOUNT_INFO_DESCRIPTION_STALKER": "Mostra i dati dell’account del portale Stalker"
"ACCOUNT_INFO_DESCRIPTION_STALKER": "Mostra i dati dell’account del portale Stalker",
"PARENTAL_LOCK_NOW_LABEL": "Lock now",
"PARENTAL_LOCK_NOW_DESCRIPTION": "Hide the parental-locked categories again",
"PARENTAL_UNLOCK_LABEL": "Enter parental PIN",
"PARENTAL_UNLOCK_DESCRIPTION": "Show the parental-locked categories"
},
"XTREAM_REFRESH_TITLE": "Aggiornamento playlist",
"XTREAM_REFRESH_COLLECTING_DATA": "Conservazione dei dati della tua libreria...",
@@ -1872,5 +1901,39 @@
"failed": "Deletion failed — select to retry"
},
"DELETE_PROGRESS": "Processed {{done}} of {{total}} selected sources"
},
"PARENTAL_LOCK": {
"PIN_DIALOG": {
"UNLOCK_TITLE": "Enter parental PIN",
"UNLOCK_DESCRIPTION": "Locked categories stay hidden until the PIN is entered.",
"SET_TITLE": "Set parental PIN",
"SET_DESCRIPTION": "Choose a PIN that will be required to show locked categories.",
"PIN_LABEL": "PIN",
"CONFIRM_LABEL": "Repeat PIN",
"PIN_HINT": "{{min}} to {{max}} digits",
"WRONG_PIN": "Wrong PIN. Try again.",
"MISMATCH": "The two PINs do not match.",
"COOLDOWN": "Too many attempts. Wait 30 seconds and try again.",
"NO_RECOVERY": "There is no way to recover a forgotten PIN — only resetting the app data removes it.",
"SAVE": "Save PIN",
"UNLOCK": "Unlock",
"CONFIRM_TITLE": "Confirm parental PIN",
"CONFIRM_DESCRIPTION": "Enter the current PIN to change this setting."
},
"LOCKED_COUNT": "Locked",
"LOCK_CATEGORY": "Lock category",
"UNLOCK_CATEGORY": "Unlock category",
"LOCK_GROUP": "Lock group",
"UNLOCK_GROUP": "Unlock group",
"MANAGE_LOCKS": "Lock categories",
"MANAGE_LOCKS_DESCRIPTION": "Locked categories and their channels disappear from the whole app until the parental PIN is entered.",
"LOCK_CENSORED": "Lock adult (18+)",
"UNLOCK_ALL": "Unlock all",
"CENSORED_HINT": "The portal flags this category as adult content",
"SAVE_FAILED": "Saving the category locks failed",
"LOCKED_CATEGORIES_ROW": "{{count}} locked",
"UNLOCK_TO_SHOW": "Enter PIN to show",
"HEADER_LOCK": "Lock now (parental lock)",
"HEADER_UNLOCK": "Enter parental PIN"
}
}
+65 -2
View File
@@ -551,7 +551,32 @@
"EMBEDDED_MPV_AUTO_RECONNECT": "切断されたストリームに自動的に再接続",
"EMBEDDED_MPV_AUTO_RECONNECT_DESCRIPTION": "再生中のストリームが切断されると、埋め込みプレーヤーは間隔を延ばしながら最大 6 回まで再読み込みを試み、その後あきらめます。一度も開始しなかったストリームには、引き続き手動の再試行が必要です。",
"VOD_AUTO_FAILOVER": "再生エラー時にソースを自動切り替え",
"VOD_AUTO_FAILOVER_DESCRIPTION": "映画の再生に失敗したとき、同じ作品が別のプレイリストにもある場合は自動的にそちらへ切り替え、再生位置を引き継ぎます。標準では無効です。別のソースでは吹き替えが異なる場合があるため、切り替え時は必ず通知されます。"
"VOD_AUTO_FAILOVER_DESCRIPTION": "映画の再生に失敗したとき、同じ作品が別のプレイリストにもある場合は自動的にそちらへ切り替え、再生位置を引き継ぎます。標準では無効です。別のソースでは吹き替えが異なる場合があるため、切り替え時は必ず通知されます。",
"NAV_PARENTAL": "Parental lock",
"PARENTAL_LOCK": {
"TITLE": "Parental lock",
"DESCRIPTION": "Hide chosen categories behind a PIN. Locked categories, their channels and titles are removed from the whole app until the PIN is entered.",
"ENABLE": "Enable parental lock",
"ENABLE_DESCRIPTION": "Turning it on asks for a new PIN. Turning it off asks for the current PIN; your locks are kept for later.",
"STATUS": "Current state",
"STATUS_LOCKED": "Locked — locked categories are hidden.",
"STATUS_UNLOCKED": "Unlocked — locked categories are visible until the app locks again.",
"LOCK_NOW": "Lock now",
"UNLOCK": "Unlock",
"RELOCK": "Lock again after",
"RELOCK_DESCRIPTION": "Minutes without interaction before the app locks itself again. Playback counts as interaction. Restarting the app always locks.",
"RELOCK_NEVER": "Only when restarting the app",
"RELOCK_MINUTES": "{{minutes}} minutes of inactivity",
"CHANGE_PIN": "Change PIN",
"CHANGE_PIN_DESCRIPTION": "Asks for the current PIN first.",
"HOW_TO_LOCK": "Choosing what to lock",
"HOW_TO_LOCK_DESCRIPTION": "Open a source, then use “Manage categories” (the tune icon above the category list; “Manage groups” in an M3U playlist): every row has a lock toggle, and Stalker offers “Lock adult (18+)” for genres the portal flags. Right-click a category or group for a quick Lock / Unlock.",
"DISCLAIMER": "This is a child lock, not a security feature: the app's data files are readable by anyone with access to this computer.",
"ENABLED_TOAST": "Parental lock enabled",
"DISABLED_TOAST": "Parental lock disabled",
"PIN_CHANGED_TOAST": "PIN changed",
"LOCKED_TOAST": "Locked"
}
},
"THEMES": {
"DARK_THEME": "ダーク",
@@ -1681,7 +1706,11 @@
"SWITCH_PLAYER_LABEL": "プレーヤーを{{name}}に切り替え",
"SWITCH_PLAYER_DESCRIPTION": "{{name}}をアクティブなビデオプレーヤーに設定",
"SWITCH_PLAYER_FEEDBACK": "プレーヤーを{{name}}に設定しました",
"ACCOUNT_INFO_DESCRIPTION_STALKER": "Stalkerポータルのアカウント情報を表示"
"ACCOUNT_INFO_DESCRIPTION_STALKER": "Stalkerポータルのアカウント情報を表示",
"PARENTAL_LOCK_NOW_LABEL": "Lock now",
"PARENTAL_LOCK_NOW_DESCRIPTION": "Hide the parental-locked categories again",
"PARENTAL_UNLOCK_LABEL": "Enter parental PIN",
"PARENTAL_UNLOCK_DESCRIPTION": "Show the parental-locked categories"
},
"XTREAM_REFRESH_TITLE": "プレイリストを更新中",
"XTREAM_REFRESH_COLLECTING_DATA": "ライブラリデータを保持しています...",
@@ -1872,5 +1901,39 @@
"failed": "Deletion failed — select to retry"
},
"DELETE_PROGRESS": "Processed {{done}} of {{total}} selected sources"
},
"PARENTAL_LOCK": {
"PIN_DIALOG": {
"UNLOCK_TITLE": "Enter parental PIN",
"UNLOCK_DESCRIPTION": "Locked categories stay hidden until the PIN is entered.",
"SET_TITLE": "Set parental PIN",
"SET_DESCRIPTION": "Choose a PIN that will be required to show locked categories.",
"PIN_LABEL": "PIN",
"CONFIRM_LABEL": "Repeat PIN",
"PIN_HINT": "{{min}} to {{max}} digits",
"WRONG_PIN": "Wrong PIN. Try again.",
"MISMATCH": "The two PINs do not match.",
"COOLDOWN": "Too many attempts. Wait 30 seconds and try again.",
"NO_RECOVERY": "There is no way to recover a forgotten PIN — only resetting the app data removes it.",
"SAVE": "Save PIN",
"UNLOCK": "Unlock",
"CONFIRM_TITLE": "Confirm parental PIN",
"CONFIRM_DESCRIPTION": "Enter the current PIN to change this setting."
},
"LOCKED_COUNT": "Locked",
"LOCK_CATEGORY": "Lock category",
"UNLOCK_CATEGORY": "Unlock category",
"LOCK_GROUP": "Lock group",
"UNLOCK_GROUP": "Unlock group",
"MANAGE_LOCKS": "Lock categories",
"MANAGE_LOCKS_DESCRIPTION": "Locked categories and their channels disappear from the whole app until the parental PIN is entered.",
"LOCK_CENSORED": "Lock adult (18+)",
"UNLOCK_ALL": "Unlock all",
"CENSORED_HINT": "The portal flags this category as adult content",
"SAVE_FAILED": "Saving the category locks failed",
"LOCKED_CATEGORIES_ROW": "{{count}} locked",
"UNLOCK_TO_SHOW": "Enter PIN to show",
"HEADER_LOCK": "Lock now (parental lock)",
"HEADER_UNLOCK": "Enter parental PIN"
}
}
+65 -2
View File
@@ -551,7 +551,32 @@
"EMBEDDED_MPV_AUTO_RECONNECT": "끊긴 스트림 자동 재연결",
"EMBEDDED_MPV_AUTO_RECONNECT_DESCRIPTION": "재생 중인 스트림이 끊기면 내장 플레이어가 점점 길어지는 간격으로 최대 6회까지 다시 불러온 뒤 포기합니다. 한 번도 시작되지 않은 스트림은 여전히 수동으로 다시 시도해야 합니다.",
"VOD_AUTO_FAILOVER": "재생 오류 시 소스 자동 전환",
"VOD_AUTO_FAILOVER_DESCRIPTION": "영화 재생에 실패했을 때 같은 작품이 다른 재생목록에 있으면 재생 위치를 유지한 채 자동으로 전환합니다. 기본값은 꺼짐이며, 다른 소스는 더빙이 다를 수 있으므로 전환 시 항상 알려 드립니다."
"VOD_AUTO_FAILOVER_DESCRIPTION": "영화 재생에 실패했을 때 같은 작품이 다른 재생목록에 있으면 재생 위치를 유지한 채 자동으로 전환합니다. 기본값은 꺼짐이며, 다른 소스는 더빙이 다를 수 있으므로 전환 시 항상 알려 드립니다.",
"NAV_PARENTAL": "Parental lock",
"PARENTAL_LOCK": {
"TITLE": "Parental lock",
"DESCRIPTION": "Hide chosen categories behind a PIN. Locked categories, their channels and titles are removed from the whole app until the PIN is entered.",
"ENABLE": "Enable parental lock",
"ENABLE_DESCRIPTION": "Turning it on asks for a new PIN. Turning it off asks for the current PIN; your locks are kept for later.",
"STATUS": "Current state",
"STATUS_LOCKED": "Locked — locked categories are hidden.",
"STATUS_UNLOCKED": "Unlocked — locked categories are visible until the app locks again.",
"LOCK_NOW": "Lock now",
"UNLOCK": "Unlock",
"RELOCK": "Lock again after",
"RELOCK_DESCRIPTION": "Minutes without interaction before the app locks itself again. Playback counts as interaction. Restarting the app always locks.",
"RELOCK_NEVER": "Only when restarting the app",
"RELOCK_MINUTES": "{{minutes}} minutes of inactivity",
"CHANGE_PIN": "Change PIN",
"CHANGE_PIN_DESCRIPTION": "Asks for the current PIN first.",
"HOW_TO_LOCK": "Choosing what to lock",
"HOW_TO_LOCK_DESCRIPTION": "Open a source, then use “Manage categories” (the tune icon above the category list; “Manage groups” in an M3U playlist): every row has a lock toggle, and Stalker offers “Lock adult (18+)” for genres the portal flags. Right-click a category or group for a quick Lock / Unlock.",
"DISCLAIMER": "This is a child lock, not a security feature: the app's data files are readable by anyone with access to this computer.",
"ENABLED_TOAST": "Parental lock enabled",
"DISABLED_TOAST": "Parental lock disabled",
"PIN_CHANGED_TOAST": "PIN changed",
"LOCKED_TOAST": "Locked"
}
},
"THEMES": {
"DARK_THEME": "어둡게",
@@ -1681,7 +1706,11 @@
"SWITCH_PLAYER_LABEL": "플레이어를 {{name}}(으)로 전환",
"SWITCH_PLAYER_DESCRIPTION": "{{name}}을(를) 활성 비디오 플레이어로 설정",
"SWITCH_PLAYER_FEEDBACK": "플레이어가 {{name}}(으)로 설정되었습니다",
"ACCOUNT_INFO_DESCRIPTION_STALKER": "Stalker 포털 계정 정보 표시"
"ACCOUNT_INFO_DESCRIPTION_STALKER": "Stalker 포털 계정 정보 표시",
"PARENTAL_LOCK_NOW_LABEL": "Lock now",
"PARENTAL_LOCK_NOW_DESCRIPTION": "Hide the parental-locked categories again",
"PARENTAL_UNLOCK_LABEL": "Enter parental PIN",
"PARENTAL_UNLOCK_DESCRIPTION": "Show the parental-locked categories"
},
"XTREAM_REFRESH_TITLE": "재생목록 새로 고침 중",
"XTREAM_REFRESH_COLLECTING_DATA": "라이브러리 데이터 보존 중...",
@@ -1872,5 +1901,39 @@
"failed": "Deletion failed — select to retry"
},
"DELETE_PROGRESS": "Processed {{done}} of {{total}} selected sources"
},
"PARENTAL_LOCK": {
"PIN_DIALOG": {
"UNLOCK_TITLE": "Enter parental PIN",
"UNLOCK_DESCRIPTION": "Locked categories stay hidden until the PIN is entered.",
"SET_TITLE": "Set parental PIN",
"SET_DESCRIPTION": "Choose a PIN that will be required to show locked categories.",
"PIN_LABEL": "PIN",
"CONFIRM_LABEL": "Repeat PIN",
"PIN_HINT": "{{min}} to {{max}} digits",
"WRONG_PIN": "Wrong PIN. Try again.",
"MISMATCH": "The two PINs do not match.",
"COOLDOWN": "Too many attempts. Wait 30 seconds and try again.",
"NO_RECOVERY": "There is no way to recover a forgotten PIN — only resetting the app data removes it.",
"SAVE": "Save PIN",
"UNLOCK": "Unlock",
"CONFIRM_TITLE": "Confirm parental PIN",
"CONFIRM_DESCRIPTION": "Enter the current PIN to change this setting."
},
"LOCKED_COUNT": "Locked",
"LOCK_CATEGORY": "Lock category",
"UNLOCK_CATEGORY": "Unlock category",
"LOCK_GROUP": "Lock group",
"UNLOCK_GROUP": "Unlock group",
"MANAGE_LOCKS": "Lock categories",
"MANAGE_LOCKS_DESCRIPTION": "Locked categories and their channels disappear from the whole app until the parental PIN is entered.",
"LOCK_CENSORED": "Lock adult (18+)",
"UNLOCK_ALL": "Unlock all",
"CENSORED_HINT": "The portal flags this category as adult content",
"SAVE_FAILED": "Saving the category locks failed",
"LOCKED_CATEGORIES_ROW": "{{count}} locked",
"UNLOCK_TO_SHOW": "Enter PIN to show",
"HEADER_LOCK": "Lock now (parental lock)",
"HEADER_UNLOCK": "Enter parental PIN"
}
}
+65 -2
View File
@@ -551,7 +551,32 @@
"EMBEDDED_MPV_AUTO_RECONNECT": "Weggevallen streams automatisch opnieuw verbinden",
"EMBEDDED_MPV_AUTO_RECONNECT_DESCRIPTION": "Wanneer een stream tijdens het afspelen wegvalt, laadt de ingebedde speler deze opnieuw met steeds langere tussenpozen, tot maximaal 6 pogingen, voordat hij het opgeeft. Voor een stream die nooit is gestart, blijft handmatig Opnieuw proberen nodig.",
"VOD_AUTO_FAILOVER": "Automatisch van bron wisselen bij een afspeelfout",
"VOD_AUTO_FAILOVER_DESCRIPTION": "Als een film niet kan worden afgespeeld en dezelfde film in een andere afspeellijst staat, wordt daar automatisch naar overgeschakeld met behoud van de afspeelpositie. Standaard uitgeschakeld; de wissel wordt altijd gemeld, omdat een andere bron een andere nasynchronisatie kan hebben."
"VOD_AUTO_FAILOVER_DESCRIPTION": "Als een film niet kan worden afgespeeld en dezelfde film in een andere afspeellijst staat, wordt daar automatisch naar overgeschakeld met behoud van de afspeelpositie. Standaard uitgeschakeld; de wissel wordt altijd gemeld, omdat een andere bron een andere nasynchronisatie kan hebben.",
"NAV_PARENTAL": "Parental lock",
"PARENTAL_LOCK": {
"TITLE": "Parental lock",
"DESCRIPTION": "Hide chosen categories behind a PIN. Locked categories, their channels and titles are removed from the whole app until the PIN is entered.",
"ENABLE": "Enable parental lock",
"ENABLE_DESCRIPTION": "Turning it on asks for a new PIN. Turning it off asks for the current PIN; your locks are kept for later.",
"STATUS": "Current state",
"STATUS_LOCKED": "Locked — locked categories are hidden.",
"STATUS_UNLOCKED": "Unlocked — locked categories are visible until the app locks again.",
"LOCK_NOW": "Lock now",
"UNLOCK": "Unlock",
"RELOCK": "Lock again after",
"RELOCK_DESCRIPTION": "Minutes without interaction before the app locks itself again. Playback counts as interaction. Restarting the app always locks.",
"RELOCK_NEVER": "Only when restarting the app",
"RELOCK_MINUTES": "{{minutes}} minutes of inactivity",
"CHANGE_PIN": "Change PIN",
"CHANGE_PIN_DESCRIPTION": "Asks for the current PIN first.",
"HOW_TO_LOCK": "Choosing what to lock",
"HOW_TO_LOCK_DESCRIPTION": "Open a source, then use “Manage categories” (the tune icon above the category list; “Manage groups” in an M3U playlist): every row has a lock toggle, and Stalker offers “Lock adult (18+)” for genres the portal flags. Right-click a category or group for a quick Lock / Unlock.",
"DISCLAIMER": "This is a child lock, not a security feature: the app's data files are readable by anyone with access to this computer.",
"ENABLED_TOAST": "Parental lock enabled",
"DISABLED_TOAST": "Parental lock disabled",
"PIN_CHANGED_TOAST": "PIN changed",
"LOCKED_TOAST": "Locked"
}
},
"THEMES": {
"DARK_THEME": "Donker",
@@ -1681,7 +1706,11 @@
"SWITCH_PLAYER_LABEL": "Speler wisselen naar {{name}}",
"SWITCH_PLAYER_DESCRIPTION": "Stel {{name}} in als de actieve videospeler",
"SWITCH_PLAYER_FEEDBACK": "Speler ingesteld op {{name}}",
"ACCOUNT_INFO_DESCRIPTION_STALKER": "Accountgegevens van het Stalker-portaal tonen"
"ACCOUNT_INFO_DESCRIPTION_STALKER": "Accountgegevens van het Stalker-portaal tonen",
"PARENTAL_LOCK_NOW_LABEL": "Lock now",
"PARENTAL_LOCK_NOW_DESCRIPTION": "Hide the parental-locked categories again",
"PARENTAL_UNLOCK_LABEL": "Enter parental PIN",
"PARENTAL_UNLOCK_DESCRIPTION": "Show the parental-locked categories"
},
"XTREAM_REFRESH_TITLE": "Afspeellijst vernieuwen",
"XTREAM_REFRESH_COLLECTING_DATA": "Uw bibliotheekgegevens behouden...",
@@ -1872,5 +1901,39 @@
"failed": "Deletion failed — select to retry"
},
"DELETE_PROGRESS": "Processed {{done}} of {{total}} selected sources"
},
"PARENTAL_LOCK": {
"PIN_DIALOG": {
"UNLOCK_TITLE": "Enter parental PIN",
"UNLOCK_DESCRIPTION": "Locked categories stay hidden until the PIN is entered.",
"SET_TITLE": "Set parental PIN",
"SET_DESCRIPTION": "Choose a PIN that will be required to show locked categories.",
"PIN_LABEL": "PIN",
"CONFIRM_LABEL": "Repeat PIN",
"PIN_HINT": "{{min}} to {{max}} digits",
"WRONG_PIN": "Wrong PIN. Try again.",
"MISMATCH": "The two PINs do not match.",
"COOLDOWN": "Too many attempts. Wait 30 seconds and try again.",
"NO_RECOVERY": "There is no way to recover a forgotten PIN — only resetting the app data removes it.",
"SAVE": "Save PIN",
"UNLOCK": "Unlock",
"CONFIRM_TITLE": "Confirm parental PIN",
"CONFIRM_DESCRIPTION": "Enter the current PIN to change this setting."
},
"LOCKED_COUNT": "Locked",
"LOCK_CATEGORY": "Lock category",
"UNLOCK_CATEGORY": "Unlock category",
"LOCK_GROUP": "Lock group",
"UNLOCK_GROUP": "Unlock group",
"MANAGE_LOCKS": "Lock categories",
"MANAGE_LOCKS_DESCRIPTION": "Locked categories and their channels disappear from the whole app until the parental PIN is entered.",
"LOCK_CENSORED": "Lock adult (18+)",
"UNLOCK_ALL": "Unlock all",
"CENSORED_HINT": "The portal flags this category as adult content",
"SAVE_FAILED": "Saving the category locks failed",
"LOCKED_CATEGORIES_ROW": "{{count}} locked",
"UNLOCK_TO_SHOW": "Enter PIN to show",
"HEADER_LOCK": "Lock now (parental lock)",
"HEADER_UNLOCK": "Enter parental PIN"
}
}
+65 -2
View File
@@ -551,7 +551,32 @@
"EMBEDDED_MPV_AUTO_RECONNECT": "Automatycznie wznawiaj przerwane strumienie",
"EMBEDDED_MPV_AUTO_RECONNECT_DESCRIPTION": "Gdy odtwarzany strumień zostanie przerwany, wbudowany odtwarzacz wczytuje go ponownie z rosnącymi odstępami, maksymalnie 6 prób, zanim się podda. Strumień, który w ogóle się nie uruchomił, nadal wymaga ręcznego użycia „Spróbuj ponownie”.",
"VOD_AUTO_FAILOVER": "Automatyczna zmiana źródła przy błędzie odtwarzania",
"VOD_AUTO_FAILOVER_DESCRIPTION": "Gdy filmu nie da się odtworzyć, a ten sam tytuł jest dostępny na innej liście odtwarzania, przełącz się na nią automatycznie i zachowaj pozycję odtwarzania. Domyślnie wyłączone; zmiana jest zawsze sygnalizowana, ponieważ inne źródło może mieć inną wersję językową."
"VOD_AUTO_FAILOVER_DESCRIPTION": "Gdy filmu nie da się odtworzyć, a ten sam tytuł jest dostępny na innej liście odtwarzania, przełącz się na nią automatycznie i zachowaj pozycję odtwarzania. Domyślnie wyłączone; zmiana jest zawsze sygnalizowana, ponieważ inne źródło może mieć inną wersję językową.",
"NAV_PARENTAL": "Parental lock",
"PARENTAL_LOCK": {
"TITLE": "Parental lock",
"DESCRIPTION": "Hide chosen categories behind a PIN. Locked categories, their channels and titles are removed from the whole app until the PIN is entered.",
"ENABLE": "Enable parental lock",
"ENABLE_DESCRIPTION": "Turning it on asks for a new PIN. Turning it off asks for the current PIN; your locks are kept for later.",
"STATUS": "Current state",
"STATUS_LOCKED": "Locked — locked categories are hidden.",
"STATUS_UNLOCKED": "Unlocked — locked categories are visible until the app locks again.",
"LOCK_NOW": "Lock now",
"UNLOCK": "Unlock",
"RELOCK": "Lock again after",
"RELOCK_DESCRIPTION": "Minutes without interaction before the app locks itself again. Playback counts as interaction. Restarting the app always locks.",
"RELOCK_NEVER": "Only when restarting the app",
"RELOCK_MINUTES": "{{minutes}} minutes of inactivity",
"CHANGE_PIN": "Change PIN",
"CHANGE_PIN_DESCRIPTION": "Asks for the current PIN first.",
"HOW_TO_LOCK": "Choosing what to lock",
"HOW_TO_LOCK_DESCRIPTION": "Open a source, then use “Manage categories” (the tune icon above the category list; “Manage groups” in an M3U playlist): every row has a lock toggle, and Stalker offers “Lock adult (18+)” for genres the portal flags. Right-click a category or group for a quick Lock / Unlock.",
"DISCLAIMER": "This is a child lock, not a security feature: the app's data files are readable by anyone with access to this computer.",
"ENABLED_TOAST": "Parental lock enabled",
"DISABLED_TOAST": "Parental lock disabled",
"PIN_CHANGED_TOAST": "PIN changed",
"LOCKED_TOAST": "Locked"
}
},
"THEMES": {
"DARK_THEME": "Ciemny",
@@ -1681,7 +1706,11 @@
"SWITCH_PLAYER_LABEL": "Przełącz odtwarzacz na {{name}}",
"SWITCH_PLAYER_DESCRIPTION": "Ustaw {{name}} jako aktywny odtwarzacz wideo",
"SWITCH_PLAYER_FEEDBACK": "Odtwarzacz ustawiony na {{name}}",
"ACCOUNT_INFO_DESCRIPTION_STALKER": "Pokaż dane konta portalu Stalker"
"ACCOUNT_INFO_DESCRIPTION_STALKER": "Pokaż dane konta portalu Stalker",
"PARENTAL_LOCK_NOW_LABEL": "Lock now",
"PARENTAL_LOCK_NOW_DESCRIPTION": "Hide the parental-locked categories again",
"PARENTAL_UNLOCK_LABEL": "Enter parental PIN",
"PARENTAL_UNLOCK_DESCRIPTION": "Show the parental-locked categories"
},
"XTREAM_REFRESH_TITLE": "Odświeżanie listy odtwarzania",
"XTREAM_REFRESH_COLLECTING_DATA": "Zachowywanie danych biblioteki...",
@@ -1872,5 +1901,39 @@
"failed": "Deletion failed — select to retry"
},
"DELETE_PROGRESS": "Processed {{done}} of {{total}} selected sources"
},
"PARENTAL_LOCK": {
"PIN_DIALOG": {
"UNLOCK_TITLE": "Enter parental PIN",
"UNLOCK_DESCRIPTION": "Locked categories stay hidden until the PIN is entered.",
"SET_TITLE": "Set parental PIN",
"SET_DESCRIPTION": "Choose a PIN that will be required to show locked categories.",
"PIN_LABEL": "PIN",
"CONFIRM_LABEL": "Repeat PIN",
"PIN_HINT": "{{min}} to {{max}} digits",
"WRONG_PIN": "Wrong PIN. Try again.",
"MISMATCH": "The two PINs do not match.",
"COOLDOWN": "Too many attempts. Wait 30 seconds and try again.",
"NO_RECOVERY": "There is no way to recover a forgotten PIN — only resetting the app data removes it.",
"SAVE": "Save PIN",
"UNLOCK": "Unlock",
"CONFIRM_TITLE": "Confirm parental PIN",
"CONFIRM_DESCRIPTION": "Enter the current PIN to change this setting."
},
"LOCKED_COUNT": "Locked",
"LOCK_CATEGORY": "Lock category",
"UNLOCK_CATEGORY": "Unlock category",
"LOCK_GROUP": "Lock group",
"UNLOCK_GROUP": "Unlock group",
"MANAGE_LOCKS": "Lock categories",
"MANAGE_LOCKS_DESCRIPTION": "Locked categories and their channels disappear from the whole app until the parental PIN is entered.",
"LOCK_CENSORED": "Lock adult (18+)",
"UNLOCK_ALL": "Unlock all",
"CENSORED_HINT": "The portal flags this category as adult content",
"SAVE_FAILED": "Saving the category locks failed",
"LOCKED_CATEGORIES_ROW": "{{count}} locked",
"UNLOCK_TO_SHOW": "Enter PIN to show",
"HEADER_LOCK": "Lock now (parental lock)",
"HEADER_UNLOCK": "Enter parental PIN"
}
}
+65 -2
View File
@@ -551,7 +551,32 @@
"EMBEDDED_MPV_AUTO_RECONNECT": "Reconectar streams interrompidos automaticamente",
"EMBEDDED_MPV_AUTO_RECONNECT_DESCRIPTION": "Quando um stream em reprodução é interrompido, o reprodutor integrado o recarrega com intervalos crescentes, em até 6 tentativas, antes de desistir. Um stream que nunca chegou a iniciar ainda precisa de um \"Tentar novamente\" manual.",
"VOD_AUTO_FAILOVER": "Trocar de fonte automaticamente ao falhar a reprodução",
"VOD_AUTO_FAILOVER_DESCRIPTION": "Quando um filme não consegue ser reproduzido e o mesmo filme existe em outra playlist, troca para ela automaticamente e mantém a posição de reprodução. Desativado por padrão; a troca é sempre avisada, porque outra fonte pode ter uma dublagem diferente."
"VOD_AUTO_FAILOVER_DESCRIPTION": "Quando um filme não consegue ser reproduzido e o mesmo filme existe em outra playlist, troca para ela automaticamente e mantém a posição de reprodução. Desativado por padrão; a troca é sempre avisada, porque outra fonte pode ter uma dublagem diferente.",
"NAV_PARENTAL": "Parental lock",
"PARENTAL_LOCK": {
"TITLE": "Parental lock",
"DESCRIPTION": "Hide chosen categories behind a PIN. Locked categories, their channels and titles are removed from the whole app until the PIN is entered.",
"ENABLE": "Enable parental lock",
"ENABLE_DESCRIPTION": "Turning it on asks for a new PIN. Turning it off asks for the current PIN; your locks are kept for later.",
"STATUS": "Current state",
"STATUS_LOCKED": "Locked — locked categories are hidden.",
"STATUS_UNLOCKED": "Unlocked — locked categories are visible until the app locks again.",
"LOCK_NOW": "Lock now",
"UNLOCK": "Unlock",
"RELOCK": "Lock again after",
"RELOCK_DESCRIPTION": "Minutes without interaction before the app locks itself again. Playback counts as interaction. Restarting the app always locks.",
"RELOCK_NEVER": "Only when restarting the app",
"RELOCK_MINUTES": "{{minutes}} minutes of inactivity",
"CHANGE_PIN": "Change PIN",
"CHANGE_PIN_DESCRIPTION": "Asks for the current PIN first.",
"HOW_TO_LOCK": "Choosing what to lock",
"HOW_TO_LOCK_DESCRIPTION": "Open a source, then use “Manage categories” (the tune icon above the category list; “Manage groups” in an M3U playlist): every row has a lock toggle, and Stalker offers “Lock adult (18+)” for genres the portal flags. Right-click a category or group for a quick Lock / Unlock.",
"DISCLAIMER": "This is a child lock, not a security feature: the app's data files are readable by anyone with access to this computer.",
"ENABLED_TOAST": "Parental lock enabled",
"DISABLED_TOAST": "Parental lock disabled",
"PIN_CHANGED_TOAST": "PIN changed",
"LOCKED_TOAST": "Locked"
}
},
"THEMES": {
"DARK_THEME": "Escuro",
@@ -1681,7 +1706,11 @@
"SWITCH_PLAYER_LABEL": "Alternar reprodutor para {{name}}",
"SWITCH_PLAYER_DESCRIPTION": "Definir {{name}} como reprodutor de vídeo ativo",
"SWITCH_PLAYER_FEEDBACK": "Reprodutor definido como {{name}}",
"ACCOUNT_INFO_DESCRIPTION_STALKER": "Mostrar dados da conta do portal Stalker"
"ACCOUNT_INFO_DESCRIPTION_STALKER": "Mostrar dados da conta do portal Stalker",
"PARENTAL_LOCK_NOW_LABEL": "Lock now",
"PARENTAL_LOCK_NOW_DESCRIPTION": "Hide the parental-locked categories again",
"PARENTAL_UNLOCK_LABEL": "Enter parental PIN",
"PARENTAL_UNLOCK_DESCRIPTION": "Show the parental-locked categories"
},
"XTREAM_REFRESH_TITLE": "Atualizando playlist",
"XTREAM_REFRESH_COLLECTING_DATA": "Preservando os dados da sua biblioteca...",
@@ -1872,5 +1901,39 @@
"failed": "Deletion failed — select to retry"
},
"DELETE_PROGRESS": "Processed {{done}} of {{total}} selected sources"
},
"PARENTAL_LOCK": {
"PIN_DIALOG": {
"UNLOCK_TITLE": "Enter parental PIN",
"UNLOCK_DESCRIPTION": "Locked categories stay hidden until the PIN is entered.",
"SET_TITLE": "Set parental PIN",
"SET_DESCRIPTION": "Choose a PIN that will be required to show locked categories.",
"PIN_LABEL": "PIN",
"CONFIRM_LABEL": "Repeat PIN",
"PIN_HINT": "{{min}} to {{max}} digits",
"WRONG_PIN": "Wrong PIN. Try again.",
"MISMATCH": "The two PINs do not match.",
"COOLDOWN": "Too many attempts. Wait 30 seconds and try again.",
"NO_RECOVERY": "There is no way to recover a forgotten PIN — only resetting the app data removes it.",
"SAVE": "Save PIN",
"UNLOCK": "Unlock",
"CONFIRM_TITLE": "Confirm parental PIN",
"CONFIRM_DESCRIPTION": "Enter the current PIN to change this setting."
},
"LOCKED_COUNT": "Locked",
"LOCK_CATEGORY": "Lock category",
"UNLOCK_CATEGORY": "Unlock category",
"LOCK_GROUP": "Lock group",
"UNLOCK_GROUP": "Unlock group",
"MANAGE_LOCKS": "Lock categories",
"MANAGE_LOCKS_DESCRIPTION": "Locked categories and their channels disappear from the whole app until the parental PIN is entered.",
"LOCK_CENSORED": "Lock adult (18+)",
"UNLOCK_ALL": "Unlock all",
"CENSORED_HINT": "The portal flags this category as adult content",
"SAVE_FAILED": "Saving the category locks failed",
"LOCKED_CATEGORIES_ROW": "{{count}} locked",
"UNLOCK_TO_SHOW": "Enter PIN to show",
"HEADER_LOCK": "Lock now (parental lock)",
"HEADER_UNLOCK": "Enter parental PIN"
}
}
+65 -2
View File
@@ -551,7 +551,32 @@
"EMBEDDED_MPV_AUTO_RECONNECT": "Автоматически переподключаться при обрыве потока",
"EMBEDDED_MPV_AUTO_RECONNECT_DESCRIPTION": "Если поток обрывается во время воспроизведения, встроенный плеер перезагружает его с возрастающими задержками (до 6 попыток), после чего прекращает переподключение. Для потока, который так и не запустился, по-прежнему нужно нажать «Повторить» вручную.",
"VOD_AUTO_FAILOVER": "Авто-переключение источника при ошибке воспроизведения",
"VOD_AUTO_FAILOVER_DESCRIPTION": "Если фильм не воспроизводится, а тот же фильм есть в другом плейлисте, автоматически переключаться на него, сохраняя таймкод. По умолчанию выключено; о переключении всегда сообщается, так как в другом источнике может быть другая озвучка."
"VOD_AUTO_FAILOVER_DESCRIPTION": "Если фильм не воспроизводится, а тот же фильм есть в другом плейлисте, автоматически переключаться на него, сохраняя таймкод. По умолчанию выключено; о переключении всегда сообщается, так как в другом источнике может быть другая озвучка.",
"NAV_PARENTAL": "Родительский контроль",
"PARENTAL_LOCK": {
"TITLE": "Родительский контроль",
"DESCRIPTION": "Скрывайте выбранные категории за PIN-кодом. Заблокированные категории, их каналы и фильмы убираются из всего приложения, пока не введён PIN.",
"ENABLE": "Включить родительский контроль",
"ENABLE_DESCRIPTION": "При включении будет запрошен новый PIN. При выключении — текущий PIN; блокировки сохраняются на будущее.",
"STATUS": "Текущее состояние",
"STATUS_LOCKED": "Заблокировано — заблокированные категории скрыты.",
"STATUS_UNLOCKED": "Разблокировано — заблокированные категории видны до следующей блокировки.",
"LOCK_NOW": "Заблокировать сейчас",
"UNLOCK": "Разблокировать",
"RELOCK": "Блокировать снова через",
"RELOCK_DESCRIPTION": "Минуты без действий, после которых приложение блокируется само. Воспроизведение считается действием. Перезапуск приложения всегда блокирует.",
"RELOCK_NEVER": "Только при перезапуске приложения",
"RELOCK_MINUTES": "{{minutes}} минут бездействия",
"CHANGE_PIN": "Сменить PIN",
"CHANGE_PIN_DESCRIPTION": "Сначала запрашивается текущий PIN.",
"HOW_TO_LOCK": "Что блокировать",
"HOW_TO_LOCK_DESCRIPTION": "Откройте источник и нажмите «Управлять категориями» (иконка настройки над списком категорий; в плейлисте M3U — «Управление группами»): у каждой строки есть переключатель замка, а для Stalker доступно «Заблокировать 18+» для жанров, помеченных порталом. Правый клик по категории или группе — быстрое «Заблокировать / Разблокировать».",
"DISCLAIMER": "Это детская блокировка, а не защита: файлы данных приложения доступны любому, у кого есть доступ к этому компьютеру.",
"ENABLED_TOAST": "Родительский контроль включён",
"DISABLED_TOAST": "Родительский контроль выключен",
"PIN_CHANGED_TOAST": "PIN изменён",
"LOCKED_TOAST": "Заблокировано"
}
},
"THEMES": {
"DARK_THEME": "Тёмная",
@@ -1681,7 +1706,11 @@
"SWITCH_PLAYER_LABEL": "Переключить плеер на {{name}}",
"SWITCH_PLAYER_DESCRIPTION": "Сделать {{name}} активным видеоплеером",
"SWITCH_PLAYER_FEEDBACK": "Плеер переключен на {{name}}",
"ACCOUNT_INFO_DESCRIPTION_STALKER": "Показать данные аккаунта Stalker-портала"
"ACCOUNT_INFO_DESCRIPTION_STALKER": "Показать данные аккаунта Stalker-портала",
"PARENTAL_LOCK_NOW_LABEL": "Заблокировать сейчас",
"PARENTAL_LOCK_NOW_DESCRIPTION": "Снова скрыть категории под родительским контролем",
"PARENTAL_UNLOCK_LABEL": "Ввести родительский PIN",
"PARENTAL_UNLOCK_DESCRIPTION": "Показать категории под родительским контролем"
},
"XTREAM_REFRESH_TITLE": "Обновление плейлиста",
"XTREAM_REFRESH_COLLECTING_DATA": "Сохраняем данные вашей библиотеки...",
@@ -1872,5 +1901,39 @@
"failed": "Не удалось удалить — выберите для повтора"
},
"DELETE_PROGRESS": "Обработано {{done}} из {{total}} выбранных источников"
},
"PARENTAL_LOCK": {
"PIN_DIALOG": {
"UNLOCK_TITLE": "Введите родительский PIN",
"UNLOCK_DESCRIPTION": "Заблокированные категории скрыты, пока не введён PIN.",
"SET_TITLE": "Задайте родительский PIN",
"SET_DESCRIPTION": "Придумайте PIN, который потребуется для показа заблокированных категорий.",
"PIN_LABEL": "PIN",
"CONFIRM_LABEL": "Повторите PIN",
"PIN_HINT": "от {{min}} до {{max}} цифр",
"WRONG_PIN": "Неверный PIN. Попробуйте ещё раз.",
"MISMATCH": "PIN-коды не совпадают.",
"COOLDOWN": "Слишком много попыток. Подождите 30 секунд.",
"NO_RECOVERY": "Забытый PIN восстановить нельзя — его удаляет только сброс данных приложения.",
"SAVE": "Сохранить PIN",
"UNLOCK": "Разблокировать",
"CONFIRM_TITLE": "Подтвердите родительский PIN",
"CONFIRM_DESCRIPTION": "Введите текущий PIN, чтобы изменить эту настройку."
},
"LOCKED_COUNT": "Заблокировано",
"LOCK_CATEGORY": "Заблокировать категорию",
"UNLOCK_CATEGORY": "Разблокировать категорию",
"LOCK_GROUP": "Заблокировать группу",
"UNLOCK_GROUP": "Разблокировать группу",
"MANAGE_LOCKS": "Заблокировать категории",
"MANAGE_LOCKS_DESCRIPTION": "Заблокированные категории и их каналы исчезают из всего приложения, пока не введён родительский PIN.",
"LOCK_CENSORED": "Заблокировать 18+",
"UNLOCK_ALL": "Разблокировать все",
"CENSORED_HINT": "Портал помечает эту категорию как контент для взрослых",
"SAVE_FAILED": "Не удалось сохранить блокировки категорий",
"LOCKED_CATEGORIES_ROW": "Заблокировано: {{count}}",
"UNLOCK_TO_SHOW": "Введите PIN, чтобы показать",
"HEADER_LOCK": "Заблокировать сейчас (родительский контроль)",
"HEADER_UNLOCK": "Введите родительский PIN"
}
}
+65 -2
View File
@@ -551,7 +551,32 @@
"EMBEDDED_MPV_AUTO_RECONNECT": "Kesilen yayınlara otomatik yeniden bağlan",
"EMBEDDED_MPV_AUTO_RECONNECT_DESCRIPTION": "Oynatılan bir yayın kesildiğinde gömülü oynatıcı, vazgeçmeden önce yayını giderek artan gecikmelerle en fazla 6 kez yeniden yüklemeyi dener. Hiç başlamamış bir yayın için yine de Tekrar dene’ye el ile basmanız gerekir.",
"VOD_AUTO_FAILOVER": "Oynatma hatasında kaynağı otomatik değiştir",
"VOD_AUTO_FAILOVER_DESCRIPTION": "Bir film oynatılamadığında ve aynı film başka bir oynatma listesinde de varsa, otomatik olarak o listeye geçilir ve oynatma konumu korunur. Varsayılan olarak kapalıdır; başka bir kaynakta dublaj farklı olabileceği için geçiş her zaman bildirilir."
"VOD_AUTO_FAILOVER_DESCRIPTION": "Bir film oynatılamadığında ve aynı film başka bir oynatma listesinde de varsa, otomatik olarak o listeye geçilir ve oynatma konumu korunur. Varsayılan olarak kapalıdır; başka bir kaynakta dublaj farklı olabileceği için geçiş her zaman bildirilir.",
"NAV_PARENTAL": "Parental lock",
"PARENTAL_LOCK": {
"TITLE": "Parental lock",
"DESCRIPTION": "Hide chosen categories behind a PIN. Locked categories, their channels and titles are removed from the whole app until the PIN is entered.",
"ENABLE": "Enable parental lock",
"ENABLE_DESCRIPTION": "Turning it on asks for a new PIN. Turning it off asks for the current PIN; your locks are kept for later.",
"STATUS": "Current state",
"STATUS_LOCKED": "Locked — locked categories are hidden.",
"STATUS_UNLOCKED": "Unlocked — locked categories are visible until the app locks again.",
"LOCK_NOW": "Lock now",
"UNLOCK": "Unlock",
"RELOCK": "Lock again after",
"RELOCK_DESCRIPTION": "Minutes without interaction before the app locks itself again. Playback counts as interaction. Restarting the app always locks.",
"RELOCK_NEVER": "Only when restarting the app",
"RELOCK_MINUTES": "{{minutes}} minutes of inactivity",
"CHANGE_PIN": "Change PIN",
"CHANGE_PIN_DESCRIPTION": "Asks for the current PIN first.",
"HOW_TO_LOCK": "Choosing what to lock",
"HOW_TO_LOCK_DESCRIPTION": "Open a source, then use “Manage categories” (the tune icon above the category list; “Manage groups” in an M3U playlist): every row has a lock toggle, and Stalker offers “Lock adult (18+)” for genres the portal flags. Right-click a category or group for a quick Lock / Unlock.",
"DISCLAIMER": "This is a child lock, not a security feature: the app's data files are readable by anyone with access to this computer.",
"ENABLED_TOAST": "Parental lock enabled",
"DISABLED_TOAST": "Parental lock disabled",
"PIN_CHANGED_TOAST": "PIN changed",
"LOCKED_TOAST": "Locked"
}
},
"THEMES": {
"DARK_THEME": "Koyu",
@@ -1681,7 +1706,11 @@
"SWITCH_PLAYER_LABEL": "Oynatıcıyı {{name}} olarak değiştir",
"SWITCH_PLAYER_DESCRIPTION": "{{name}} oynatıcısını etkin video oynatıcı olarak ayarla",
"SWITCH_PLAYER_FEEDBACK": "Oynatıcı {{name}} olarak ayarlandı",
"ACCOUNT_INFO_DESCRIPTION_STALKER": "Stalker portalı hesap bilgilerini göster"
"ACCOUNT_INFO_DESCRIPTION_STALKER": "Stalker portalı hesap bilgilerini göster",
"PARENTAL_LOCK_NOW_LABEL": "Lock now",
"PARENTAL_LOCK_NOW_DESCRIPTION": "Hide the parental-locked categories again",
"PARENTAL_UNLOCK_LABEL": "Enter parental PIN",
"PARENTAL_UNLOCK_DESCRIPTION": "Show the parental-locked categories"
},
"XTREAM_REFRESH_TITLE": "Oynatma listesi yenileniyor",
"XTREAM_REFRESH_COLLECTING_DATA": "Kitaplık verileriniz korunuyor...",
@@ -1872,5 +1901,39 @@
"failed": "Deletion failed — select to retry"
},
"DELETE_PROGRESS": "Processed {{done}} of {{total}} selected sources"
},
"PARENTAL_LOCK": {
"PIN_DIALOG": {
"UNLOCK_TITLE": "Enter parental PIN",
"UNLOCK_DESCRIPTION": "Locked categories stay hidden until the PIN is entered.",
"SET_TITLE": "Set parental PIN",
"SET_DESCRIPTION": "Choose a PIN that will be required to show locked categories.",
"PIN_LABEL": "PIN",
"CONFIRM_LABEL": "Repeat PIN",
"PIN_HINT": "{{min}} to {{max}} digits",
"WRONG_PIN": "Wrong PIN. Try again.",
"MISMATCH": "The two PINs do not match.",
"COOLDOWN": "Too many attempts. Wait 30 seconds and try again.",
"NO_RECOVERY": "There is no way to recover a forgotten PIN — only resetting the app data removes it.",
"SAVE": "Save PIN",
"UNLOCK": "Unlock",
"CONFIRM_TITLE": "Confirm parental PIN",
"CONFIRM_DESCRIPTION": "Enter the current PIN to change this setting."
},
"LOCKED_COUNT": "Locked",
"LOCK_CATEGORY": "Lock category",
"UNLOCK_CATEGORY": "Unlock category",
"LOCK_GROUP": "Lock group",
"UNLOCK_GROUP": "Unlock group",
"MANAGE_LOCKS": "Lock categories",
"MANAGE_LOCKS_DESCRIPTION": "Locked categories and their channels disappear from the whole app until the parental PIN is entered.",
"LOCK_CENSORED": "Lock adult (18+)",
"UNLOCK_ALL": "Unlock all",
"CENSORED_HINT": "The portal flags this category as adult content",
"SAVE_FAILED": "Saving the category locks failed",
"LOCKED_CATEGORIES_ROW": "{{count}} locked",
"UNLOCK_TO_SHOW": "Enter PIN to show",
"HEADER_LOCK": "Lock now (parental lock)",
"HEADER_UNLOCK": "Enter parental PIN"
}
}
+65 -2
View File
@@ -551,7 +551,32 @@
"EMBEDDED_MPV_AUTO_RECONNECT": "自动重连中断的流",
"EMBEDDED_MPV_AUTO_RECONNECT_DESCRIPTION": "正在播放的流中断时,嵌入式播放器会以逐渐增加的间隔重新加载,最多尝试 6 次后放弃。从未成功启动的流仍需手动重试。",
"VOD_AUTO_FAILOVER": "播放出错时自动切换播放源",
"VOD_AUTO_FAILOVER_DESCRIPTION": "当某部电影无法播放,而其他播放列表中也有同一部影片时,自动切换过去并保留播放进度。默认关闭;由于其他播放源的配音可能不同,每次切换都会给出提示。"
"VOD_AUTO_FAILOVER_DESCRIPTION": "当某部电影无法播放,而其他播放列表中也有同一部影片时,自动切换过去并保留播放进度。默认关闭;由于其他播放源的配音可能不同,每次切换都会给出提示。",
"NAV_PARENTAL": "Parental lock",
"PARENTAL_LOCK": {
"TITLE": "Parental lock",
"DESCRIPTION": "Hide chosen categories behind a PIN. Locked categories, their channels and titles are removed from the whole app until the PIN is entered.",
"ENABLE": "Enable parental lock",
"ENABLE_DESCRIPTION": "Turning it on asks for a new PIN. Turning it off asks for the current PIN; your locks are kept for later.",
"STATUS": "Current state",
"STATUS_LOCKED": "Locked — locked categories are hidden.",
"STATUS_UNLOCKED": "Unlocked — locked categories are visible until the app locks again.",
"LOCK_NOW": "Lock now",
"UNLOCK": "Unlock",
"RELOCK": "Lock again after",
"RELOCK_DESCRIPTION": "Minutes without interaction before the app locks itself again. Playback counts as interaction. Restarting the app always locks.",
"RELOCK_NEVER": "Only when restarting the app",
"RELOCK_MINUTES": "{{minutes}} minutes of inactivity",
"CHANGE_PIN": "Change PIN",
"CHANGE_PIN_DESCRIPTION": "Asks for the current PIN first.",
"HOW_TO_LOCK": "Choosing what to lock",
"HOW_TO_LOCK_DESCRIPTION": "Open a source, then use “Manage categories” (the tune icon above the category list; “Manage groups” in an M3U playlist): every row has a lock toggle, and Stalker offers “Lock adult (18+)” for genres the portal flags. Right-click a category or group for a quick Lock / Unlock.",
"DISCLAIMER": "This is a child lock, not a security feature: the app's data files are readable by anyone with access to this computer.",
"ENABLED_TOAST": "Parental lock enabled",
"DISABLED_TOAST": "Parental lock disabled",
"PIN_CHANGED_TOAST": "PIN changed",
"LOCKED_TOAST": "Locked"
}
},
"THEMES": {
"DARK_THEME": "深色",
@@ -1681,7 +1706,11 @@
"SWITCH_PLAYER_LABEL": "切换播放器为 {{name}}",
"SWITCH_PLAYER_DESCRIPTION": "将 {{name}} 设为当前视频播放器",
"SWITCH_PLAYER_FEEDBACK": "播放器已设为 {{name}}",
"ACCOUNT_INFO_DESCRIPTION_STALKER": "显示 Stalker 门户账户信息"
"ACCOUNT_INFO_DESCRIPTION_STALKER": "显示 Stalker 门户账户信息",
"PARENTAL_LOCK_NOW_LABEL": "Lock now",
"PARENTAL_LOCK_NOW_DESCRIPTION": "Hide the parental-locked categories again",
"PARENTAL_UNLOCK_LABEL": "Enter parental PIN",
"PARENTAL_UNLOCK_DESCRIPTION": "Show the parental-locked categories"
},
"XTREAM_REFRESH_TITLE": "正在刷新播放列表",
"XTREAM_REFRESH_COLLECTING_DATA": "正在保留您的媒体库数据…",
@@ -1872,5 +1901,39 @@
"failed": "Deletion failed — select to retry"
},
"DELETE_PROGRESS": "Processed {{done}} of {{total}} selected sources"
},
"PARENTAL_LOCK": {
"PIN_DIALOG": {
"UNLOCK_TITLE": "Enter parental PIN",
"UNLOCK_DESCRIPTION": "Locked categories stay hidden until the PIN is entered.",
"SET_TITLE": "Set parental PIN",
"SET_DESCRIPTION": "Choose a PIN that will be required to show locked categories.",
"PIN_LABEL": "PIN",
"CONFIRM_LABEL": "Repeat PIN",
"PIN_HINT": "{{min}} to {{max}} digits",
"WRONG_PIN": "Wrong PIN. Try again.",
"MISMATCH": "The two PINs do not match.",
"COOLDOWN": "Too many attempts. Wait 30 seconds and try again.",
"NO_RECOVERY": "There is no way to recover a forgotten PIN — only resetting the app data removes it.",
"SAVE": "Save PIN",
"UNLOCK": "Unlock",
"CONFIRM_TITLE": "Confirm parental PIN",
"CONFIRM_DESCRIPTION": "Enter the current PIN to change this setting."
},
"LOCKED_COUNT": "Locked",
"LOCK_CATEGORY": "Lock category",
"UNLOCK_CATEGORY": "Unlock category",
"LOCK_GROUP": "Lock group",
"UNLOCK_GROUP": "Unlock group",
"MANAGE_LOCKS": "Lock categories",
"MANAGE_LOCKS_DESCRIPTION": "Locked categories and their channels disappear from the whole app until the parental PIN is entered.",
"LOCK_CENSORED": "Lock adult (18+)",
"UNLOCK_ALL": "Unlock all",
"CENSORED_HINT": "The portal flags this category as adult content",
"SAVE_FAILED": "Saving the category locks failed",
"LOCKED_CATEGORIES_ROW": "{{count}} locked",
"UNLOCK_TO_SHOW": "Enter PIN to show",
"HEADER_LOCK": "Lock now (parental lock)",
"HEADER_UNLOCK": "Enter parental PIN"
}
}
+65 -2
View File
@@ -551,7 +551,32 @@
"EMBEDDED_MPV_AUTO_RECONNECT": "自動重新連線中斷的串流",
"EMBEDDED_MPV_AUTO_RECONNECT_DESCRIPTION": "播放中的串流中斷時,內嵌播放器會以逐漸拉長的間隔重新載入,最多嘗試 6 次後放棄。從未成功啟動的串流仍需手動重試。",
"VOD_AUTO_FAILOVER": "播放發生錯誤時自動切換來源",
"VOD_AUTO_FAILOVER_DESCRIPTION": "當電影無法播放,且其他播放清單中也有同一部影片時,會自動切換過去並保留播放進度。預設為關閉;由於其他來源可能使用不同的配音,每次切換都會提示您。"
"VOD_AUTO_FAILOVER_DESCRIPTION": "當電影無法播放,且其他播放清單中也有同一部影片時,會自動切換過去並保留播放進度。預設為關閉;由於其他來源可能使用不同的配音,每次切換都會提示您。",
"NAV_PARENTAL": "Parental lock",
"PARENTAL_LOCK": {
"TITLE": "Parental lock",
"DESCRIPTION": "Hide chosen categories behind a PIN. Locked categories, their channels and titles are removed from the whole app until the PIN is entered.",
"ENABLE": "Enable parental lock",
"ENABLE_DESCRIPTION": "Turning it on asks for a new PIN. Turning it off asks for the current PIN; your locks are kept for later.",
"STATUS": "Current state",
"STATUS_LOCKED": "Locked — locked categories are hidden.",
"STATUS_UNLOCKED": "Unlocked — locked categories are visible until the app locks again.",
"LOCK_NOW": "Lock now",
"UNLOCK": "Unlock",
"RELOCK": "Lock again after",
"RELOCK_DESCRIPTION": "Minutes without interaction before the app locks itself again. Playback counts as interaction. Restarting the app always locks.",
"RELOCK_NEVER": "Only when restarting the app",
"RELOCK_MINUTES": "{{minutes}} minutes of inactivity",
"CHANGE_PIN": "Change PIN",
"CHANGE_PIN_DESCRIPTION": "Asks for the current PIN first.",
"HOW_TO_LOCK": "Choosing what to lock",
"HOW_TO_LOCK_DESCRIPTION": "Open a source, then use “Manage categories” (the tune icon above the category list; “Manage groups” in an M3U playlist): every row has a lock toggle, and Stalker offers “Lock adult (18+)” for genres the portal flags. Right-click a category or group for a quick Lock / Unlock.",
"DISCLAIMER": "This is a child lock, not a security feature: the app's data files are readable by anyone with access to this computer.",
"ENABLED_TOAST": "Parental lock enabled",
"DISABLED_TOAST": "Parental lock disabled",
"PIN_CHANGED_TOAST": "PIN changed",
"LOCKED_TOAST": "Locked"
}
},
"THEMES": {
"DARK_THEME": "深色",
@@ -1681,7 +1706,11 @@
"SWITCH_PLAYER_LABEL": "切換播放器至 {{name}}",
"SWITCH_PLAYER_DESCRIPTION": "將 {{name}} 設為使用中的影片播放器",
"SWITCH_PLAYER_FEEDBACK": "播放器已設為 {{name}}",
"ACCOUNT_INFO_DESCRIPTION_STALKER": "顯示 Stalker 入口網站帳戶資訊"
"ACCOUNT_INFO_DESCRIPTION_STALKER": "顯示 Stalker 入口網站帳戶資訊",
"PARENTAL_LOCK_NOW_LABEL": "Lock now",
"PARENTAL_LOCK_NOW_DESCRIPTION": "Hide the parental-locked categories again",
"PARENTAL_UNLOCK_LABEL": "Enter parental PIN",
"PARENTAL_UNLOCK_DESCRIPTION": "Show the parental-locked categories"
},
"XTREAM_REFRESH_TITLE": "正在重新整理播放清單",
"XTREAM_REFRESH_COLLECTING_DATA": "正在保留你的媒體庫資料...",
@@ -1872,5 +1901,39 @@
"failed": "Deletion failed — select to retry"
},
"DELETE_PROGRESS": "Processed {{done}} of {{total}} selected sources"
},
"PARENTAL_LOCK": {
"PIN_DIALOG": {
"UNLOCK_TITLE": "Enter parental PIN",
"UNLOCK_DESCRIPTION": "Locked categories stay hidden until the PIN is entered.",
"SET_TITLE": "Set parental PIN",
"SET_DESCRIPTION": "Choose a PIN that will be required to show locked categories.",
"PIN_LABEL": "PIN",
"CONFIRM_LABEL": "Repeat PIN",
"PIN_HINT": "{{min}} to {{max}} digits",
"WRONG_PIN": "Wrong PIN. Try again.",
"MISMATCH": "The two PINs do not match.",
"COOLDOWN": "Too many attempts. Wait 30 seconds and try again.",
"NO_RECOVERY": "There is no way to recover a forgotten PIN — only resetting the app data removes it.",
"SAVE": "Save PIN",
"UNLOCK": "Unlock",
"CONFIRM_TITLE": "Confirm parental PIN",
"CONFIRM_DESCRIPTION": "Enter the current PIN to change this setting."
},
"LOCKED_COUNT": "Locked",
"LOCK_CATEGORY": "Lock category",
"UNLOCK_CATEGORY": "Unlock category",
"LOCK_GROUP": "Lock group",
"UNLOCK_GROUP": "Unlock group",
"MANAGE_LOCKS": "Lock categories",
"MANAGE_LOCKS_DESCRIPTION": "Locked categories and their channels disappear from the whole app until the parental PIN is entered.",
"LOCK_CENSORED": "Lock adult (18+)",
"UNLOCK_ALL": "Unlock all",
"CENSORED_HINT": "The portal flags this category as adult content",
"SAVE_FAILED": "Saving the category locks failed",
"LOCKED_CATEGORIES_ROW": "{{count}} locked",
"UNLOCK_TO_SHOW": "Enter PIN to show",
"HEADER_LOCK": "Lock now (parental lock)",
"HEADER_UNLOCK": "Enter parental PIN"
}
}