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>
This commit is contained in:
4grayandClaude Fable 5.1 committed 2026-09-26 19:10:48 +02:00
1 parent 1d945e91b1
commit 225e8de78e
34 files changed
+677 -81

No files matched your search

+3 -1
View File
@@ -567,7 +567,7 @@
"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 the lock icons in the category list's manage dialog (Xtream, M3U groups) or the lock button above Stalker categories.",
"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",
@@ -1918,6 +1918,8 @@
"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+)",
+3 -1
View File
@@ -567,7 +567,7 @@
"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 the lock icons in the category list's manage dialog (Xtream, M3U groups) or the lock button above Stalker categories.",
"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",
@@ -1918,6 +1918,8 @@
"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+)",
+3 -1
View File
@@ -567,7 +567,7 @@
"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 the lock icons in the category list's manage dialog (Xtream, M3U groups) or the lock button above Stalker categories.",
"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",
@@ -1918,6 +1918,8 @@
"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+)",
+3 -1
View File
@@ -567,7 +567,7 @@
"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 nutze die Schloss-Symbole im Verwaltungsdialog der Kategorienliste (Xtream, M3U-Gruppen) oder die Schloss-Schaltfläche über den Stalker-Kategorien.",
"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",
@@ -1918,6 +1918,8 @@
"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",
+3 -1
View File
@@ -567,7 +567,7 @@
"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 the lock icons in the category list's manage dialog (Xtream, M3U groups) or the lock button above Stalker categories.",
"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",
@@ -1918,6 +1918,8 @@
"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+)",
+3 -1
View File
@@ -567,7 +567,7 @@
"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 the lock icons in the category list's manage dialog (Xtream, M3U groups) or the lock button above Stalker categories.",
"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",
@@ -1918,6 +1918,8 @@
"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+)",
+3 -1
View File
@@ -567,7 +567,7 @@
"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 the lock icons in the category list's manage dialog (Xtream, M3U groups) or the lock button above Stalker categories.",
"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",
@@ -1918,6 +1918,8 @@
"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+)",
+3 -1
View File
@@ -567,7 +567,7 @@
"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 the lock icons in the category list's manage dialog (Xtream, M3U groups) or the lock button above Stalker categories.",
"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",
@@ -1918,6 +1918,8 @@
"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+)",
+3 -1
View File
@@ -567,7 +567,7 @@
"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 the lock icons in the category list's manage dialog (Xtream, M3U groups) or the lock button above Stalker categories.",
"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",
@@ -1918,6 +1918,8 @@
"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+)",
+3 -1
View File
@@ -567,7 +567,7 @@
"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 the lock icons in the category list's manage dialog (Xtream, M3U groups) or the lock button above Stalker categories.",
"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",
@@ -1918,6 +1918,8 @@
"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+)",
+3 -1
View File
@@ -567,7 +567,7 @@
"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 the lock icons in the category list's manage dialog (Xtream, M3U groups) or the lock button above Stalker categories.",
"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",
@@ -1918,6 +1918,8 @@
"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+)",
+3 -1
View File
@@ -567,7 +567,7 @@
"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 the lock icons in the category list's manage dialog (Xtream, M3U groups) or the lock button above Stalker categories.",
"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",
@@ -1918,6 +1918,8 @@
"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+)",
+3 -1
View File
@@ -567,7 +567,7 @@
"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 the lock icons in the category list's manage dialog (Xtream, M3U groups) or the lock button above Stalker categories.",
"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",
@@ -1918,6 +1918,8 @@
"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+)",
+3 -1
View File
@@ -567,7 +567,7 @@
"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 the lock icons in the category list's manage dialog (Xtream, M3U groups) or the lock button above Stalker categories.",
"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",
@@ -1918,6 +1918,8 @@
"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+)",
+3 -1
View File
@@ -567,7 +567,7 @@
"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 the lock icons in the category list's manage dialog (Xtream, M3U groups) or the lock button above Stalker categories.",
"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",
@@ -1918,6 +1918,8 @@
"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+)",
+3 -1
View File
@@ -567,7 +567,7 @@
"CHANGE_PIN": "Сменить PIN",
"CHANGE_PIN_DESCRIPTION": "Сначала запрашивается текущий PIN.",
"HOW_TO_LOCK": "Что блокировать",
"HOW_TO_LOCK_DESCRIPTION": "Откройте источник и используйте значки замка в диалоге управления категориями (Xtream, группы M3U) или кнопку замка над категориями Stalker.",
"HOW_TO_LOCK_DESCRIPTION": "Откройте источник и нажмите «Управлять категориями» (иконка настройки над списком категорий; в плейлисте M3U — «Управление группами»): у каждой строки есть переключатель замка, а для Stalker доступно «Заблокировать 18+» для жанров, помеченных порталом. Правый клик по категории или группе — быстрое «Заблокировать / Разблокировать».",
"DISCLAIMER": "Это детская блокировка, а не защита: файлы данных приложения доступны любому, у кого есть доступ к этому компьютеру.",
"ENABLED_TOAST": "Родительский контроль включён",
"DISABLED_TOAST": "Родительский контроль выключен",
@@ -1918,6 +1918,8 @@
"LOCKED_COUNT": "Заблокировано",
"LOCK_CATEGORY": "Заблокировать категорию",
"UNLOCK_CATEGORY": "Разблокировать категорию",
"LOCK_GROUP": "Заблокировать группу",
"UNLOCK_GROUP": "Разблокировать группу",
"MANAGE_LOCKS": "Заблокировать категории",
"MANAGE_LOCKS_DESCRIPTION": "Заблокированные категории и их каналы исчезают из всего приложения, пока не введён родительский PIN.",
"LOCK_CENSORED": "Заблокировать 18+",
+3 -1
View File
@@ -567,7 +567,7 @@
"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 the lock icons in the category list's manage dialog (Xtream, M3U groups) or the lock button above Stalker categories.",
"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",
@@ -1918,6 +1918,8 @@
"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+)",
+3 -1
View File
@@ -567,7 +567,7 @@
"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 the lock icons in the category list's manage dialog (Xtream, M3U groups) or the lock button above Stalker categories.",
"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",
@@ -1918,6 +1918,8 @@
"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+)",
+3 -1
View File
@@ -567,7 +567,7 @@
"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 the lock icons in the category list's manage dialog (Xtream, M3U groups) or the lock button above Stalker categories.",
"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",
@@ -1918,6 +1918,8 @@
"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+)",
+4 -1
View File
@@ -151,7 +151,10 @@ dialog renders a lock toggle per row and the whole dialog opens only after the
PIN. Locks are a separate concept from `hidden`: they live in the renderer's
lock store keyed by provider category id and are mirrored into
`categories.locked` through `DB_SET_CATEGORY_LOCKS`, so a refresh keeps them
the same way it keeps `hidden`. Contract: [parental lock](parental-lock.md).
the same way it keeps `hidden`. The dialog is the one entry point on every
portal type (Stalker's "Manage categories" button opens its lock-only
dialog); a right-click on a category row offers a single-row Lock/Unlock
through the same lock store. Contract: [parental lock](parental-lock.md).
## Files Changed
+41 -40
View File
@@ -284,46 +284,47 @@ on either side.
disclaimer. Everything applies immediately (it gates on the PIN), outside
the shared dirty form; `createSettingsFromFormValue` carries both values
from the current settings so Save cannot undo them.
- Lock toggles in the Xtream category-management dialog and the M3U group
dialog (rendered only while the feature is on), plus a new Stalker
`StalkerCategoryLockDialogComponent` reached from a lock button above the
categories rail; it offers "Lock adult (18+)" for genres the portal flags
`censored`. All three list the locked names and can rewrite the locks, so
each opens only after `requestUnlock()` succeeds — and closes itself,
discarding the draft, the moment `active` becomes true again (idle relock,
Lock now), since the PIN gate covers only the opening. The M3U group
dialog does this only while it carries lock toggles; the plain hide/show
editor is not behind the PIN. The Xtream dialog loads
its candidates through the capability-selected data source
(`IXtreamDataSource.getAllCategories`, which the PWA source answers from
its session cache or the API), so PWA users can set locks too; the
hide/show checkboxes remain Electron-only. The relock timeout persists
through the same undo-on-failure pattern as the switch
(`setRelockMinutes` reverts the in-memory value and the facade shows the
settings save-failure snackbar). The M3U dialog hands its lock
list back to `ChannelListContainerComponent`, which awaits the write and
reports a failed save in a snackbar (the dialog has closed by then). On
Electron the `categories.locked` re-stamp (`setCategoryLocks`) clears and
re-locks one playlist/type inside ONE transaction, so a failed restamp
keeps the previous index instead of leaving every category unlocked. The
store commits BEFORE that re-stamp, so a failed re-stamp rolls the store
back to the previous locks AND re-stamps every touched type from them (a
backup restore stamps three types, and the ones before the failing type
already carry the new locks; a category must not be recorded and shown
as locked while Electron reads, which filter by the index alone, still
serve it), and the store revision consumers reload on is published only
once every touched type is stamped, so a reload cannot read a later type
through its old stamps; if the rollback write or its re-stamp fails too, the playlist is
re-stamped on the next store access, and every launch re-derives the
index from the store for each playlist that has locks — which is why a
write that removes a playlist's LAST lock clears the index first and
drops the key afterwards (the reconcile finds playlists only through
their key; an interruption then leaves store-with-lock and index-without,
which the next reconcile repairs toward locked; a store recovered by a
later successful read marks its playlists stale the same way) — awaited inside
the store's `load()`, so `readable` (and with it every catalog read the
renderer gates) stays false until the index agrees with the store; a
re-stamp that keeps failing keeps the session fail-closed.
- One entry point per rail, the same on every portal type: the "Manage
categories" button (`tune` icon; "Manage groups" on an M3U playlist).
Xtream and M3U render lock toggles inside their existing hide/show
dialogs while the feature is on; Stalker has no hide/show dialog, so its
button — shown only while the feature is on — opens the lock-only
`StalkerCategoryLockDialogComponent`, which also offers "Lock adult
(18+)" for genres the portal flags `censored`. All three list the locked
names and can rewrite the locks, so each opens only after
`requestUnlock()` succeeds — and closes itself, discarding the draft, the
moment `active` becomes true again (idle relock, Lock now), since the
PIN gate covers only the opening. The M3U group dialog does this only
while it carries lock toggles; the plain hide/show editor is not behind
the PIN. The Xtream dialog loads its candidates through the
capability-selected data source (`IXtreamDataSource.getAllCategories`,
which the PWA source answers from its session cache or the API), so PWA
users can set locks too; the hide/show checkboxes remain Electron-only.
A deliberate non-choice: no dedicated lock button in the rail header
(the header already carries search, sort and manage, and the lock is a
category-management concern) and no keyword-based "adult" recognition
for Xtream/M3U (provider naming is arbitrary; the only automation is the
portal's own `censored` flag on Stalker).
- Right-click accelerator: a `contextmenu` on a category row (Xtream and
Stalker rail, `WorkspaceContextCategoryViewComponent`) or an M3U group
row opens the shared `CategoryLockMenuComponent` (`libs/ui/components`)
with one "Lock / Unlock category (group)" item, only while the feature
is on. The portal rail persists through
`WorkspaceCategoryLockActionService` (PIN gate, then one-id edit of the
lock store, failure snackbar); the M3U rail emits the edited list to
`ChannelListContainerComponent`, which persists it like the dialog's
result. Bulk actions stay in the dialog, and while the session is locked
a locked category is not in the rail, so unlocking always goes through
the "N locked · Enter PIN to show" row or the dialog. The M3U dialog
hands its lock list back to `ChannelListContainerComponent`, which
awaits the write and reports a failed save in a snackbar (the dialog has
closed by then). On Electron the `categories.locked` re-stamp
(`setCategoryLocks`) clears and re-locks one playlist/type inside ONE
transaction, so a failed restamp keeps the previous index instead of
leaving every category unlocked. The relock timeout persists through the
same undo-on-failure pattern as the switch (`setRelockMinutes` reverts
the in-memory value and the facade shows the settings save-failure
snackbar).
- Header lock/unlock button and the `parental-lock-now` /
`parental-unlock` palette commands.
+1
View File
@@ -32,3 +32,4 @@ export * from './lib/watched-badge/watched-badge.component';
export * from './lib/window-controls/window-controls.component';
export * from './lib/channel-scroll-focus/channel-scroll-focus.directive';
export * from './lib/category-lock-menu/category-lock-menu.component';
@@ -0,0 +1,49 @@
import { Component, viewChild } from '@angular/core';
import { TestBed } from '@angular/core/testing';
import { NoopAnimationsModule } from '@angular/platform-browser/animations';
import { TranslateModule } from '@ngx-translate/core';
import { CategoryLockMenuComponent } from './category-lock-menu.component';
@Component({
imports: [CategoryLockMenuComponent],
template: `<app-category-lock-menu
(toggleRequested)="requested.push($event)"
/>`,
})
class HostComponent {
readonly menu = viewChild.required(CategoryLockMenuComponent);
readonly requested: boolean[] = [];
}
describe('CategoryLockMenuComponent', () => {
it('opens at the pointer and requests the opposite lock state', async () => {
await TestBed.configureTestingModule({
imports: [
HostComponent,
NoopAnimationsModule,
TranslateModule.forRoot(),
],
}).compileComponents();
const fixture = TestBed.createComponent(HostComponent);
fixture.detectChanges();
const host = fixture.componentInstance;
const event = new MouseEvent('contextmenu', {
clientX: 40,
clientY: 60,
cancelable: true,
});
host.menu().open(event, true);
await Promise.resolve();
fixture.detectChanges();
expect(event.defaultPrevented).toBe(true);
expect(host.menu().position()).toEqual({ x: '40px', y: '60px' });
const item = document.querySelector<HTMLButtonElement>(
'[data-test-id="category-lock-menu-toggle"]'
);
expect(item?.textContent).toContain('PARENTAL_LOCK.UNLOCK_CATEGORY');
item?.click();
expect(host.requested).toEqual([false]);
});
});
@@ -0,0 +1,85 @@
import {
ChangeDetectionStrategy,
Component,
input,
output,
signal,
viewChild,
} from '@angular/core';
import { MatIconModule } from '@angular/material/icon';
import { MatMenuModule, MatMenuTrigger } from '@angular/material/menu';
import { TranslatePipe } from '@ngx-translate/core';
/**
* Right-click accelerator of the parental lock: one positioned context menu
* with a single "Lock … / Unlock …" item. Hosts (the portal category rail,
* the M3U groups rail) call `open()` from a `contextmenu` event and persist
* the emitted target state themselves — the menu knows nothing about
* playlists or lock stores. Bulk actions and the full list stay in the
* "Manage categories" dialog; this only shortens the single-category case.
*/
@Component({
selector: 'app-category-lock-menu',
imports: [MatIconModule, MatMenuModule, TranslatePipe],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<div
aria-hidden="true"
class="category-lock-menu__anchor"
[style.left]="position().x"
[style.top]="position().y"
[matMenuTriggerFor]="menu"
#trigger="matMenuTrigger"
></div>
<mat-menu #menu="matMenu">
<button
mat-menu-item
type="button"
data-test-id="category-lock-menu-toggle"
(click)="toggleRequested.emit(!locked())"
>
<mat-icon>{{ locked() ? 'lock_open' : 'lock' }}</mat-icon>
<span>{{
(locked() ? unlockLabelKey() : lockLabelKey()) | translate
}}</span>
</button>
</mat-menu>
`,
styles: `
:host {
display: contents;
}
.category-lock-menu__anchor {
position: fixed;
width: 0;
height: 0;
pointer-events: none;
}
`,
})
export class CategoryLockMenuComponent {
readonly lockLabelKey = input('PARENTAL_LOCK.LOCK_CATEGORY');
readonly unlockLabelKey = input('PARENTAL_LOCK.UNLOCK_CATEGORY');
/** The desired state: `true` = lock the row, `false` = unlock it. */
readonly toggleRequested = output<boolean>();
readonly locked = signal(false);
readonly position = signal({ x: '0px', y: '0px' });
private readonly trigger = viewChild.required<MatMenuTrigger>('trigger');
/** Opens the menu at the pointer for a row whose lock state is `locked`. */
open(event: MouseEvent, locked: boolean): void {
event.preventDefault();
event.stopPropagation();
this.locked.set(locked);
this.position.set({
x: `${event.clientX}px`,
y: `${event.clientY}px`,
});
const trigger = this.trigger();
if (trigger.menuOpen) {
trigger.closeMenu();
}
queueMicrotask(() => this.trigger().openMenu());
}
}
@@ -127,6 +127,9 @@
: null
"
(click)="selectGroup(group.key)"
(contextmenu)="
onGroupContextMenu(group.key, $event)
"
>
<span class="nav-item-label">
@if (group.key) {
@@ -354,3 +357,10 @@
<span>{{ 'CHANNELS.SHOW_DETAILS' | translate }}</span>
</button>
</mat-menu>
<app-category-lock-menu
#groupLockMenu
lockLabelKey="PARENTAL_LOCK.LOCK_GROUP"
unlockLabelKey="PARENTAL_LOCK.UNLOCK_GROUP"
(toggleRequested)="onGroupLockToggle($event)"
/>
@@ -537,6 +537,37 @@ describe('GroupsViewComponent', () => {
]);
});
it('locks and unlocks one group from the right-click menu behind the PIN gate', async () => {
const lockedGroupTitlesChanged = jest.fn();
component.lockedGroupTitlesChanged.subscribe(lockedGroupTitlesChanged);
fixture.componentRef.setInput('lockedGroupTitles', ['News']);
fixture.detectChanges();
const event = new MouseEvent('contextmenu', { cancelable: true });
component.onGroupContextMenu('Sports', event);
expect(event.defaultPrevented).toBe(true);
await component.onGroupLockToggle(true);
expect(lockedGroupTitlesChanged).toHaveBeenLastCalledWith([
'News',
'Sports',
]);
component.onGroupContextMenu('News', event);
await component.onGroupLockToggle(false);
expect(lockedGroupTitlesChanged).toHaveBeenLastCalledWith([]);
parentalLock.requestUnlock.mockResolvedValueOnce(false);
component.onGroupContextMenu('Movies', event);
await component.onGroupLockToggle(true);
expect(lockedGroupTitlesChanged).toHaveBeenCalledTimes(2);
});
it('offers no group lock menu while the feature is off', () => {
const event = new MouseEvent('contextmenu', { cancelable: true });
component.onGroupContextMenu('Sports', event);
expect(event.defaultPrevented).toBe(false);
});
it('keeps the manage-groups dialog closed when the parental PIN is refused', async () => {
// The dialog names every locked group and can rewrite the locks.
parentalLock.requestUnlock.mockResolvedValueOnce(false);
@@ -41,6 +41,7 @@ import { resolveChannelLogo } from '../channel-logo-fallback.util';
import { EpgMappingDialogComponent } from '../epg-mapping-dialog/epg-mapping-dialog.component';
import { ChannelDetailsDialogComponent } from '../channel-details-dialog/channel-details-dialog.component';
import { ChannelListItemComponent } from '../channel-list-item/channel-list-item.component';
import { CategoryLockMenuComponent } from '../../category-lock-menu/category-lock-menu.component';
import { ResizableDirective } from '../../resizable/resizable.directive';
import {
GroupManagementDialogComponent,
@@ -70,6 +71,7 @@ interface FilteredGroupView {
styleUrls: ['./groups-view.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [
CategoryLockMenuComponent,
ChannelScrollFocusDirective,
ChannelListItemComponent,
MatButtonModule,
@@ -188,6 +190,10 @@ export class GroupsViewComponent {
*/
readonly selectedGroupChange = output<string | null>();
private readonly groupLockMenu =
viewChild<CategoryLockMenuComponent>('groupLockMenu');
private groupLockKey: string | null = null;
readonly isGroupSearchOpen = signal(false);
readonly localGroupSearchTerm = signal('');
readonly selectedGroupKey = signal<string | null>(null);
@@ -489,6 +495,37 @@ export class GroupsViewComponent {
this.localGroupSearchTerm.set(value);
}
/**
* Right-click accelerator of the parental lock for one group; the host
* persists the emitted list exactly as it does for the dialog. No menu
* while the feature is off (`lockedGroupTitles` is null then).
*/
onGroupContextMenu(groupKey: string, event: MouseEvent): void {
const locked = this.lockedGroupTitles();
if (locked === null) {
return;
}
this.groupLockKey = groupKey;
this.groupLockMenu()?.open(event, locked.includes(groupKey));
}
async onGroupLockToggle(lock: boolean): Promise<void> {
const groupKey = this.groupLockKey;
this.groupLockKey = null;
const current = this.lockedGroupTitles();
if (groupKey === null || current === null) {
return;
}
if (!(await this.parentalLock.requestUnlock())) {
return;
}
this.lockedGroupTitlesChanged.emit(
lock
? [...new Set([...current, groupKey])]
: current.filter((title) => title !== groupKey)
);
}
async openGroupManagement(): Promise<void> {
// The dialog lists every group by name, locked ones included, and can
// rewrite the locks — so it sits behind the PIN like the portal
@@ -23,6 +23,7 @@
[attr.aria-disabled]="!interactionEnabled()"
[disabled]="!interactionEnabled()"
(click)="onCategoryClick(item)"
(contextmenu)="onCategoryContextMenu(item, $event)"
>
<span class="nav-item-label">
{{
@@ -12,7 +12,7 @@ import { MatListModule } from '@angular/material/list';
import { TranslatePipe } from '@ngx-translate/core';
import { WorkspaceContextErrorViewComponent } from './workspace-context-error-view.component';
interface WorkspaceCategoryViewItem {
export interface WorkspaceCategoryViewItem {
readonly category_id?: string | number;
readonly category_name?: string;
readonly count?: number;
@@ -28,7 +28,6 @@ interface WorkspaceCategoryViewItem {
styleUrl: './workspace-context-category-view.component.scss',
})
export class WorkspaceContextCategoryViewComponent {
readonly items = input<ReadonlyArray<WorkspaceCategoryViewItem>>([]);
readonly selectedCategoryId = input<string | number | null | undefined>();
readonly itemCounts = input<Map<number, number>>(new Map());
@@ -64,6 +63,11 @@ export class WorkspaceContextCategoryViewComponent {
private readonly hostEl = inject(ElementRef<HTMLElement>);
readonly categoryClicked = output<WorkspaceCategoryViewItem>();
/** Right-click on a row; the host decides whether a menu applies. */
readonly categoryContextMenuRequested = output<{
item: WorkspaceCategoryViewItem;
event: MouseEvent;
}>();
constructor() {
effect(() => {
@@ -128,6 +132,13 @@ export class WorkspaceContextCategoryViewComponent {
return this.itemCounts().has(Number(item.id ?? item.category_id));
}
onCategoryContextMenu(
item: WorkspaceCategoryViewItem,
event: MouseEvent
): void {
this.categoryContextMenuRequested.emit({ item, event });
}
onCategoryClick(item: WorkspaceCategoryViewItem): void {
if (!this.interactionEnabled()) {
return;
@@ -0,0 +1,122 @@
import { signal } from '@angular/core';
import { TestBed } from '@angular/core/testing';
import { MatSnackBar } from '@angular/material/snack-bar';
import { TranslateService } from '@ngx-translate/core';
import { ParentalLockService } from '@iptvnator/services';
import { WorkspaceCategoryLockActionService } from './workspace-category-lock-action.service';
describe('WorkspaceCategoryLockActionService', () => {
const snackBar = { open: jest.fn() };
const parentalLock = {
enabled: signal(true),
requestUnlock: jest.fn(async () => true),
lockedXtreamIds: jest.fn(() => [7]),
lockedStalkerIds: jest.fn(() => ['9']),
setXtreamLocks: jest.fn(async () => true),
setStalkerLocks: jest.fn(async () => true),
};
let service: WorkspaceCategoryLockActionService;
beforeEach(() => {
jest.clearAllMocks();
parentalLock.requestUnlock.mockResolvedValue(true);
parentalLock.setXtreamLocks.mockResolvedValue(true);
TestBed.configureTestingModule({
providers: [
{ provide: ParentalLockService, useValue: parentalLock },
{ provide: MatSnackBar, useValue: snackBar },
{
provide: TranslateService,
useValue: { instant: (key: string) => key },
},
],
});
service = TestBed.inject(WorkspaceCategoryLockActionService);
});
it('reads the Xtream lock state through the provider id', () => {
const base = {
provider: 'xtreams' as const,
playlistId: 'p',
section: 'vod',
};
expect(
service.isLocked({ ...base, item: { id: 1, xtream_id: 7 } })
).toBe(true);
expect(service.isLocked({ ...base, item: { category_id: '7' } })).toBe(
true
);
expect(
service.isLocked({ ...base, item: { id: 2, xtream_id: 8 } })
).toBe(false);
expect(parentalLock.lockedXtreamIds).toHaveBeenCalledWith(
'p',
'movies'
);
});
it('adds and removes one Xtream category behind the PIN gate', async () => {
const target = {
provider: 'xtreams' as const,
playlistId: 'p',
section: 'live',
item: { id: 2, xtream_id: 8 },
};
await expect(service.setLocked(target, true)).resolves.toBe(true);
expect(parentalLock.setXtreamLocks).toHaveBeenCalledWith(
'p',
'live',
[7, 8]
);
await service.setLocked(
{ ...target, item: { id: 1, xtream_id: 7 } },
false
);
expect(parentalLock.setXtreamLocks).toHaveBeenLastCalledWith(
'p',
'live',
[]
);
});
it('toggles a Stalker genre and never the All pseudo-category', async () => {
const target = {
provider: 'stalker' as const,
playlistId: 'p',
section: 'itv',
item: { category_id: '5' },
};
expect(
service.isLocked({ ...target, item: { category_id: '9' } })
).toBe(true);
await expect(service.setLocked(target, true)).resolves.toBe(true);
expect(parentalLock.setStalkerLocks).toHaveBeenCalledWith('p', 'itv', [
'9',
'5',
]);
await expect(
service.setLocked({ ...target, item: { category_id: '*' } }, true)
).resolves.toBe(false);
});
it('does nothing when the PIN is refused and reports a failed write', async () => {
const target = {
provider: 'xtreams' as const,
playlistId: 'p',
section: 'live',
item: { xtream_id: 8 },
};
parentalLock.requestUnlock.mockResolvedValueOnce(false);
await expect(service.setLocked(target, true)).resolves.toBe(false);
expect(parentalLock.setXtreamLocks).not.toHaveBeenCalled();
parentalLock.setXtreamLocks.mockResolvedValueOnce(false);
await expect(service.setLocked(target, true)).resolves.toBe(false);
expect(snackBar.open).toHaveBeenCalledWith(
'PARENTAL_LOCK.SAVE_FAILED',
'CLOSE',
expect.any(Object)
);
});
});
@@ -0,0 +1,132 @@
import { inject, Injectable } from '@angular/core';
import { MatSnackBar } from '@angular/material/snack-bar';
import { TranslateService } from '@ngx-translate/core';
import { ParentalLockService } from '@iptvnator/services';
import {
toParentalLockStalkerCategoryType,
toParentalLockXtreamCategoryType,
} from '@iptvnator/shared/interfaces';
export interface CategoryLockTarget {
readonly provider: 'xtreams' | 'stalker';
readonly playlistId: string;
readonly section: string;
readonly item: {
readonly category_id?: string | number;
readonly xtream_id?: number;
readonly id?: string | number;
};
}
/**
* The single-category lock toggle behind the category rail's right-click
* menu. Same persistence as the "Manage categories" dialog (the lock store
* is the source of truth), same PIN gate, same failure snackbar — only the
* entry point differs.
*/
@Injectable({ providedIn: 'root' })
export class WorkspaceCategoryLockActionService {
private readonly parentalLock = inject(ParentalLockService);
private readonly snackBar = inject(MatSnackBar);
private readonly translate = inject(TranslateService);
/** The menu exists only while the feature is on. */
readonly enabled = this.parentalLock.enabled;
/**
* Xtream rows carry the provider id as `xtream_id` (Electron) or as
* `category_id` (PWA); Stalker genres are keyed by `category_id`.
*/
isLocked(target: CategoryLockTarget): boolean {
if (target.provider === 'xtreams') {
const type = toParentalLockXtreamCategoryType(target.section);
const id = this.xtreamProviderId(target);
return (
!!type &&
id !== null &&
this.parentalLock
.lockedXtreamIds(target.playlistId, type)
.includes(id)
);
}
const type = toParentalLockStalkerCategoryType(target.section);
const id = this.stalkerId(target);
return (
!!type &&
id !== null &&
this.parentalLock
.lockedStalkerIds(target.playlistId, type)
.includes(id)
);
}
/** Locks or unlocks one category; false when refused or failed. */
async setLocked(
target: CategoryLockTarget,
locked: boolean
): Promise<boolean> {
if (!(await this.parentalLock.requestUnlock())) {
return false;
}
const saved = await this.persist(target, locked);
if (!saved) {
this.snackBar.open(
this.translate.instant('PARENTAL_LOCK.SAVE_FAILED'),
this.translate.instant('CLOSE'),
{ duration: 5000 }
);
}
return saved;
}
private persist(
target: CategoryLockTarget,
locked: boolean
): Promise<boolean> {
if (target.provider === 'xtreams') {
const type = toParentalLockXtreamCategoryType(target.section);
const id = this.xtreamProviderId(target);
if (!type || id === null) {
return Promise.resolve(false);
}
const current = this.parentalLock.lockedXtreamIds(
target.playlistId,
type
);
return this.parentalLock.setXtreamLocks(
target.playlistId,
type,
locked
? [...current, id]
: current.filter((entry) => entry !== id)
);
}
const type = toParentalLockStalkerCategoryType(target.section);
const id = this.stalkerId(target);
if (!type || id === null) {
return Promise.resolve(false);
}
const current = this.parentalLock.lockedStalkerIds(
target.playlistId,
type
);
return this.parentalLock.setStalkerLocks(
target.playlistId,
type,
locked ? [...current, id] : current.filter((entry) => entry !== id)
);
}
private xtreamProviderId(target: CategoryLockTarget): number | null {
const id = Number(target.item.xtream_id ?? target.item.category_id);
return Number.isFinite(id) ? id : null;
}
private stalkerId(target: CategoryLockTarget): string | null {
const raw = target.item.category_id ?? target.item.id;
if (raw === undefined || raw === null || String(raw) === '*') {
return null;
}
return String(raw);
}
}
@@ -78,19 +78,21 @@
<mat-icon>tune</mat-icon>
</button>
}
@if (canLockStalkerCategories()) {
@if (canManageStalkerCategories()) {
<button
type="button"
mat-icon-button
class="context-header__action"
data-test-id="context-lock-categories"
(click)="openLockStalkerCategories()"
[matTooltip]="'PARENTAL_LOCK.MANAGE_LOCKS' | translate"
data-test-id="context-manage-categories"
(click)="openManageStalkerCategories()"
[matTooltip]="
'WORKSPACE.CONTEXT.MANAGE_CATEGORIES' | translate
"
[attr.aria-label]="
'PARENTAL_LOCK.MANAGE_LOCKS' | translate
'WORKSPACE.CONTEXT.MANAGE_CATEGORIES' | translate
"
>
<mat-icon>lock</mat-icon>
<mat-icon>tune</mat-icon>
</button>
}
@if (canHideCategories()) {
@@ -170,6 +172,9 @@
[interactionEnabled]="isXtreamCategoryInteractionEnabled()"
[statusText]="xtreamStatusText()"
(categoryClicked)="onXtreamCategoryClicked($event)"
(categoryContextMenuRequested)="
onCategoryContextMenu('xtreams', $event)
"
/>
}
} @else if (isStalkerCategories()) {
@@ -202,6 +207,9 @@
[countDisplayMode]="stalkerCountDisplayMode()"
[omitMissingCounts]="true"
(categoryClicked)="onStalkerCategoryClicked($any($event))"
(categoryContextMenuRequested)="
onCategoryContextMenu('stalker', $event)
"
/>
}
}
@@ -227,3 +235,8 @@
</button>
}
</div>
<app-category-lock-menu
#categoryLockMenu
(toggleRequested)="onCategoryLockToggle($event)"
/>
@@ -473,9 +473,9 @@ describe('WorkspaceContextPanelComponent', () => {
el.textContent?.includes('For adults')
);
expect(documentary?.querySelector('.item-count')?.textContent).toContain(
'190'
);
expect(
documentary?.querySelector('.item-count')?.textContent
).toContain('190');
expect(adults?.querySelector('.item-count')).toBeNull();
});
@@ -527,7 +527,9 @@ describe('WorkspaceContextPanelComponent', () => {
button?.click();
expect(liveSidebarService.stateOf('portal')()).toBe('categories-hidden');
expect(liveSidebarService.stateOf('portal')()).toBe(
'categories-hidden'
);
});
it('offers it for Stalker itv and radio too', () => {
@@ -826,7 +828,9 @@ describe('WorkspaceContextPanelComponent', () => {
) as never
);
expect(fixture.componentInstance.stalkerCategoryErrorDescription()).toBe(
expect(
fixture.componentInstance.stalkerCategoryErrorDescription()
).toBe(
'PORTALS.ERROR_VIEW.STALKER_DEVICE_CONFLICT device conflict - device_id mismatch — Your STB is damaged.'
);
});
@@ -836,9 +840,9 @@ describe('WorkspaceContextPanelComponent', () => {
new StalkerPortalError('blocked', 'Account disabled') as never
);
expect(fixture.componentInstance.stalkerCategoryErrorDescription()).toBe(
'Account disabled'
);
expect(
fixture.componentInstance.stalkerCategoryErrorDescription()
).toBe('Account disabled');
});
});
});
@@ -35,7 +35,15 @@ import {
toParentalLockStalkerCategoryType,
toParentalLockXtreamCategoryType,
} from '@iptvnator/shared/interfaces';
import { WorkspaceContextCategoryViewComponent } from './components/workspace-context-category-view.component';
import { CategoryLockMenuComponent } from '@iptvnator/ui/components';
import {
WorkspaceCategoryViewItem,
WorkspaceContextCategoryViewComponent,
} from './components/workspace-context-category-view.component';
import {
CategoryLockTarget,
WorkspaceCategoryLockActionService,
} from './workspace-category-lock-action.service';
import { WorkspaceContextErrorViewComponent } from './components/workspace-context-error-view.component';
import { hasActiveLiveCategoryRoute } from './workspace-context-panel-route.utils';
import { WorkspaceShellContextDrawerService } from '@iptvnator/workspace/shell/util';
@@ -63,6 +71,7 @@ interface WorkspaceCategoryLike {
@Component({
selector: 'app-workspace-context-panel',
imports: [
CategoryLockMenuComponent,
MatIconButton,
MatIcon,
MatMenuModule,
@@ -94,6 +103,12 @@ export class WorkspaceContextPanelComponent {
);
private readonly liveSidebarState = inject(LiveLayoutSidebarStateService);
private readonly parentalLock = inject(ParentalLockService);
private readonly categoryLockAction = inject(
WorkspaceCategoryLockActionService
);
private readonly categoryLockMenu =
viewChild<CategoryLockMenuComponent>('categoryLockMenu');
private categoryLockTarget: CategoryLockTarget | null = null;
readonly context = input.required<WorkspaceContextRoute>();
readonly section = input.required<string>();
@@ -180,8 +195,12 @@ export class WorkspaceContextPanelComponent {
readonly canManageXtreamCategories = computed(
() => this.isXtreamCategories() && this.xtreamSelectedTypeCountsReady()
);
/** Stalker has no hide/show dialog; its lock dialog exists only with the lock on. */
readonly canLockStalkerCategories = computed(
/**
* Stalker has no hide/show dialog, so its "Manage categories" is the
* lock dialog alone and exists only while the lock is on — same button,
* same icon as the Xtream rail, so the entry point is one pattern.
*/
readonly canManageStalkerCategories = computed(
() =>
this.isStalkerCategories() &&
this.parentalLock.enabled() &&
@@ -460,8 +479,47 @@ export class WorkspaceContextPanelComponent {
await this.parentalLock.requestUnlock();
}
async openLockStalkerCategories(): Promise<void> {
if (!this.canLockStalkerCategories()) {
/**
* Right-click accelerator: one row's lock toggled without the dialog.
* Silently no menu while the feature is off — the row keeps its
* ordinary behaviour.
*/
onCategoryContextMenu(
provider: 'xtreams' | 'stalker',
request: { item: WorkspaceCategoryViewItem; event: MouseEvent }
): void {
if (!this.categoryLockAction.enabled()) {
return;
}
const target: CategoryLockTarget = {
provider,
playlistId: this.context().playlistId,
section: this.section(),
item: request.item,
};
if (
provider === 'stalker' &&
String(request.item.category_id ?? request.item.id) === '*'
) {
return;
}
this.categoryLockTarget = target;
this.categoryLockMenu()?.open(
request.event,
this.categoryLockAction.isLocked(target)
);
}
async onCategoryLockToggle(locked: boolean): Promise<void> {
const target = this.categoryLockTarget;
this.categoryLockTarget = null;
if (target) {
await this.categoryLockAction.setLocked(target, locked);
}
}
async openManageStalkerCategories(): Promise<void> {
if (!this.canManageStalkerCategories()) {
return;
}
if (!(await this.parentalLock.requestUnlock())) {