diff --git a/.changes/stalker-account-info-dialog.md b/.changes/stalker-account-info-dialog.md new file mode 100644 index 000000000..17fdbffb4 --- /dev/null +++ b/.changes/stalker-account-info-dialog.md @@ -0,0 +1,10 @@ +--- +type: feature +area: stalker +--- + +Stalker portals now have an Account info dialog — subscription status, +tariff plan, expiry date with a days-left counter, login, and portal +details. Open it from the header playlist menu, the dashboard source card, +or the command palette, same as for Xtream. If the portal is unreachable, +the data saved when the portal was added is shown instead. diff --git a/CLAUDE.md b/CLAUDE.md index 32868bb61..6f6c85c3d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1016,6 +1016,12 @@ engine` (restart required) or - TMDB attribution (logo + disclaimer) is required and shown in the settings TMDB section and About - See `docs/architecture/tmdb-metadata-enrichment.md` +**Portal Account Info**: + +- Both portal types expose an account-info dialog through the same entry points: header playlist switcher (bottom section for the active playlist + per-row ⋮ menu), dashboard source card ⋮ menu, and the command palette. Gates use the shared predicates in `libs/shared/interfaces/src/lib/portal-account-playlist.utils.ts`; `WorkspaceShellHeaderService.openAccountInfoFor()` picks the dialog by playlist type. +- Xtream: `AccountInfoComponent` (`libs/portal/xtream/feature/src/lib/account-info/`), queries `get_account_info` live. +- Stalker: `StalkerAccountInfoComponent` (`libs/portal/stalker/feature/src/lib/stalker-account-info/`), cached-first — renders the import-time `stalkerAccountInfo` snapshot instantly, then `StalkerAccountInfoService` refreshes (full portals: handshake+`get_profile`; `portal.php`: best-effort `account_info/get_main_info`, nested `js.account_info` envelope or flat fields). Details: `docs/architecture/stalker-portal.md` ("Account Info Dialog"). + **Favorites and Recently Viewed**: - Per-playlist favorites and global favorites diff --git a/apps/stalker-mock-server/src/app/handlers/get-main-info.handler.ts b/apps/stalker-mock-server/src/app/handlers/get-main-info.handler.ts new file mode 100644 index 000000000..4f0e4793f --- /dev/null +++ b/apps/stalker-mock-server/src/app/handlers/get-main-info.handler.ts @@ -0,0 +1,26 @@ +import { Request, Response } from 'express'; + +/** + * Stalker `account_info/get_main_info` — subscription facts for the + * account-info dialog. Uses the nested `js.account_info` envelope that + * Ministra-style portals answer with (the shape `fetchStalkerExpireDate()` + * already consumes), plus flat aliases some panels emit alongside it. + */ +export function handleGetMainInfo(req: Request, res: Response): void { + const expiry = new Date(); + expiry.setDate(expiry.getDate() + 180); + const mac = (req.query['mac'] as string) || '00:1A:79:00:00:01'; + + res.json({ + js: { + mac, + phone: '10042', + account_info: { + login: 'mockuser', + status: 1, + tariff_plan: 'Mock Premium 180', + end_date: expiry.toISOString().slice(0, 10), + }, + }, + }); +} diff --git a/apps/stalker-mock-server/src/app/routes/dispatch.ts b/apps/stalker-mock-server/src/app/routes/dispatch.ts index bc42e9bee..41e99eed6 100644 --- a/apps/stalker-mock-server/src/app/routes/dispatch.ts +++ b/apps/stalker-mock-server/src/app/routes/dispatch.ts @@ -13,6 +13,7 @@ import { handleFavorites } from '../handlers/favorites.handler.js'; import { handleGetEpgInfo } from '../handlers/get-epg-info.handler.js'; import { handleGetShortEpg } from '../handlers/get-short-epg.handler.js'; import { handleGetGenres } from '../handlers/get-genres.handler.js'; +import { handleGetMainInfo } from '../handlers/get-main-info.handler.js'; export interface DispatchOptions { /** @@ -63,6 +64,9 @@ export default function dispatchPortalAction( case 'do_auth': handleDoAuth(req, res); break; + case 'get_main_info': + handleGetMainInfo(req, res); + break; case 'get_categories': case 'get_genres_vod': case 'get_genres_itv': diff --git a/apps/web-e2e/src/stalker.e2e.ts b/apps/web-e2e/src/stalker.e2e.ts index f9c7ee264..bdda49a59 100644 --- a/apps/web-e2e/src/stalker.e2e.ts +++ b/apps/web-e2e/src/stalker.e2e.ts @@ -309,6 +309,26 @@ test('@stalker add a Stalker portal and see it in the playlist list', async ({ ).toBeVisible(); }); +test('@stalker account info dialog shows subscription facts from the portal', async ({ + page, +}) => { + await addStalkerPortal(page); + + // Open the header playlist switcher and its "Account info" entry for + // the active stalker portal. + await page.locator('.playlist-switcher-trigger').click(); + await page + .locator('.context-action-item', { hasText: 'Account info' }) + .click(); + + const dialog = page.locator('mat-dialog-container'); + await expect(dialog).toBeVisible(); + await expect(dialog.getByText('Account Information')).toBeVisible(); + // Facts served by the mock's account_info/get_main_info handler. + await expect(dialog.getByText('Mock Premium 180').first()).toBeVisible(); + await expect(dialog.getByText(DEFAULT_MAC).first()).toBeVisible(); +}); + test('@stalker VOD — categories load from mock server', async ({ page }) => { await addStalkerPortal(page); diff --git a/apps/web/src/app/app.component.spec.ts b/apps/web/src/app/app.component.spec.ts index 0aa298fe4..3bb625036 100644 --- a/apps/web/src/app/app.component.spec.ts +++ b/apps/web/src/app/app.component.spec.ts @@ -138,6 +138,7 @@ describe('AppComponent', () => { openGlobalRecent: jest.fn(), openGlobalSearch: jest.fn(), openAccountInfo: jest.fn(), + openStalkerAccountInfo: jest.fn(), }, }, ], diff --git a/apps/web/src/app/services/workspace-shell-actions.service.ts b/apps/web/src/app/services/workspace-shell-actions.service.ts index 14d2bbfb9..a110883a6 100644 --- a/apps/web/src/app/services/workspace-shell-actions.service.ts +++ b/apps/web/src/app/services/workspace-shell-actions.service.ts @@ -7,6 +7,7 @@ import { WorkspaceAccountInfoData, WorkspacePlaylistType, WorkspaceShellActions, + WorkspaceStalkerAccountInfoData, } from '@iptvnator/workspace/shell/util'; @Injectable({ providedIn: 'root' }) @@ -54,6 +55,19 @@ export class AppWorkspaceShellActionsService implements WorkspaceShellActions { } ); } + + openStalkerAccountInfo(data: WorkspaceStalkerAccountInfoData): void { + void import('@iptvnator/portal/stalker/feature').then( + ({ StalkerAccountInfoComponent }) => { + this.dialog.open(StalkerAccountInfoComponent, { + width: '80%', + maxWidth: '1200px', + maxHeight: '90vh', + data, + }); + } + ); + } } export function provideWorkspaceShellActions(): Provider[] { diff --git a/apps/web/src/assets/i18n/ar.json b/apps/web/src/assets/i18n/ar.json index b14d45fa3..8dc608feb 100644 --- a/apps/web/src/assets/i18n/ar.json +++ b/apps/web/src/assets/i18n/ar.json @@ -1346,7 +1346,8 @@ "GO_TO_SERIES_DESCRIPTION": "الانتقال إلى قسم المسلسلات", "SWITCH_PLAYER_LABEL": "تبديل المشغل إلى {{name}}", "SWITCH_PLAYER_DESCRIPTION": "تعيين {{name}} كمشغل الفيديو النشط", - "SWITCH_PLAYER_FEEDBACK": "تم تعيين المشغل إلى {{name}}" + "SWITCH_PLAYER_FEEDBACK": "تم تعيين المشغل إلى {{name}}", + "ACCOUNT_INFO_DESCRIPTION_STALKER": "عرض بيانات حساب بوابة Stalker" }, "XTREAM_REFRESH_TITLE": "جارٍ تحديث قائمة التشغيل", "XTREAM_REFRESH_COLLECTING_DATA": "جارٍ حفظ بيانات مكتبتك...", @@ -1456,5 +1457,31 @@ "NO_ITEMS_TITLE": "Nothing watched yet", "NO_ITEMS_BODY": "Start watching anything from your library and it will show up here." } + }, + "LOADING": "جارٍ التحميل", + "RETRY": "إعادة المحاولة", + "STALKER": { + "ACCOUNT_INFO": { + "TITLE": "معلومات الحساب", + "ACCOUNT": "الحساب", + "PORTAL_INFO": "البوابة", + "LOGIN": "اسم المستخدم", + "STATUS": "الحالة", + "ACTIVE": "نشط", + "INACTIVE": "غير نشط", + "TARIFF_PLAN": "خطة الاشتراك", + "EXPIRES": "ينتهي في", + "DAYS_LEFT": "الأيام المتبقية", + "EXPIRED": "منتهي", + "MAC_ADDRESS": "عنوان MAC", + "PHONE": "الهاتف", + "PORTAL_URL": "رابط البوابة", + "PORTAL_TYPE_FULL": "بوابة Ministra", + "PORTAL_TYPE_LEGACY": "بوابة تقليدية (portal.php)", + "CACHED_BADGE": "بيانات محفوظة", + "REFRESH_FAILED": "تعذّر الوصول إلى البوابة — يتم عرض البيانات المحفوظة عند إضافة البوابة.", + "NO_ACCOUNT_DATA_TITLE": "لا توجد بيانات للحساب", + "NO_ACCOUNT_DATA_DESCRIPTION": "لا تنشر هذه البوابة معلومات الحساب لهذا الجهاز." + } } } diff --git a/apps/web/src/assets/i18n/ary.json b/apps/web/src/assets/i18n/ary.json index d7845a1b8..7f8a4dc63 100644 --- a/apps/web/src/assets/i18n/ary.json +++ b/apps/web/src/assets/i18n/ary.json @@ -1346,7 +1346,8 @@ "GO_TO_SERIES_DESCRIPTION": "اقفز لقسم السلاسل", "SWITCH_PLAYER_LABEL": "بدل المشغل ل{{name}}", "SWITCH_PLAYER_DESCRIPTION": "حدد {{name}} كمشغل الفيديو النشط", - "SWITCH_PLAYER_FEEDBACK": "المشغل تحدد ل{{name}}" + "SWITCH_PLAYER_FEEDBACK": "المشغل تحدد ل{{name}}", + "ACCOUNT_INFO_DESCRIPTION_STALKER": "عرض بيانات حساب بوابة Stalker" }, "XTREAM_REFRESH_TITLE": "كنحدّثو البلايليست", "XTREAM_REFRESH_COLLECTING_DATA": "كنحافظو على معطيات المكتبة ديالك...", @@ -1456,5 +1457,31 @@ "NO_ITEMS_TITLE": "Nothing watched yet", "NO_ITEMS_BODY": "Start watching anything from your library and it will show up here." } + }, + "LOADING": "كيتشارجا", + "RETRY": "عاود المحاولة", + "STALKER": { + "ACCOUNT_INFO": { + "TITLE": "معلومات الحساب", + "ACCOUNT": "الحساب", + "PORTAL_INFO": "البوابة", + "LOGIN": "سمية المستخدم", + "STATUS": "الحالة", + "ACTIVE": "خدام", + "INACTIVE": "ماشي خدام", + "TARIFF_PLAN": "خطة الاشتراك", + "EXPIRES": "كتسالي ف", + "DAYS_LEFT": "الأيام اللي بقاو", + "EXPIRED": "سالات", + "MAC_ADDRESS": "عنوان MAC", + "PHONE": "التيليفون", + "PORTAL_URL": "رابط البوابة", + "PORTAL_TYPE_FULL": "بوابة Ministra", + "PORTAL_TYPE_LEGACY": "بوابة قديمة (portal.php)", + "CACHED_BADGE": "بيانات محفوظة", + "REFRESH_FAILED": "ما قدرناش نوصلو للبوابة — كنعرضو البيانات اللي تسجلات ملي تزادت البوابة.", + "NO_ACCOUNT_DATA_TITLE": "ما كايناش بيانات الحساب", + "NO_ACCOUNT_DATA_DESCRIPTION": "هاد البوابة ما كتعطيش معلومات الحساب لهاد الجهاز." + } } } diff --git a/apps/web/src/assets/i18n/by.json b/apps/web/src/assets/i18n/by.json index d6636bc64..c700eb2ca 100644 --- a/apps/web/src/assets/i18n/by.json +++ b/apps/web/src/assets/i18n/by.json @@ -1346,7 +1346,8 @@ "GO_TO_SERIES_DESCRIPTION": "Перайсці да раздзела серыялаў", "SWITCH_PLAYER_LABEL": "Пераключыць прайгравальнік на {{name}}", "SWITCH_PLAYER_DESCRIPTION": "Усталяваць {{name}} як актыўны відэапрайгравальнік", - "SWITCH_PLAYER_FEEDBACK": "Прайгравальнік усталяваны: {{name}}" + "SWITCH_PLAYER_FEEDBACK": "Прайгравальнік усталяваны: {{name}}", + "ACCOUNT_INFO_DESCRIPTION_STALKER": "Паказаць даныя акаўнта Stalker-партала" }, "XTREAM_REFRESH_TITLE": "Абнаўленне плэйліста", "XTREAM_REFRESH_COLLECTING_DATA": "Захаванне даных вашай бібліятэкі...", @@ -1456,5 +1457,31 @@ "NO_ITEMS_TITLE": "Nothing watched yet", "NO_ITEMS_BODY": "Start watching anything from your library and it will show up here." } + }, + "LOADING": "Загрузка", + "RETRY": "Паўтарыць", + "STALKER": { + "ACCOUNT_INFO": { + "TITLE": "Інфармацыя пра акаўнт", + "ACCOUNT": "Акаўнт", + "PORTAL_INFO": "Партал", + "LOGIN": "Лагін", + "STATUS": "Статус", + "ACTIVE": "Актыўны", + "INACTIVE": "Неактыўны", + "TARIFF_PLAN": "Тарыфны план", + "EXPIRES": "Сканчаецца", + "DAYS_LEFT": "Засталося дзён", + "EXPIRED": "Скончыўся", + "MAC_ADDRESS": "MAC-адрас", + "PHONE": "Тэлефон", + "PORTAL_URL": "URL партала", + "PORTAL_TYPE_FULL": "Партал Ministra", + "PORTAL_TYPE_LEGACY": "Класічны партал (portal.php)", + "CACHED_BADGE": "Захаваныя даныя", + "REFRESH_FAILED": "Партал недаступны — паказаны даныя, захаваныя пры даданні партала.", + "NO_ACCOUNT_DATA_TITLE": "Няма даных пра акаўнт", + "NO_ACCOUNT_DATA_DESCRIPTION": "Гэты партал не дае інфармацыі пра акаўнт для гэтай прылады." + } } } diff --git a/apps/web/src/assets/i18n/de.json b/apps/web/src/assets/i18n/de.json index 277f8fda0..7379c62aa 100644 --- a/apps/web/src/assets/i18n/de.json +++ b/apps/web/src/assets/i18n/de.json @@ -1346,7 +1346,8 @@ "GO_TO_SERIES_DESCRIPTION": "Zum Serienbereich wechseln", "SWITCH_PLAYER_LABEL": "Player auf {{name}} wechseln", "SWITCH_PLAYER_DESCRIPTION": "{{name}} als aktiven Videoplayer festlegen", - "SWITCH_PLAYER_FEEDBACK": "Player auf {{name}} festgelegt" + "SWITCH_PLAYER_FEEDBACK": "Player auf {{name}} festgelegt", + "ACCOUNT_INFO_DESCRIPTION_STALKER": "Kontodaten des Stalker-Portals anzeigen" }, "XTREAM_REFRESH_TITLE": "Playlist wird aktualisiert", "XTREAM_REFRESH_COLLECTING_DATA": "Mediathekdaten werden gesichert...", @@ -1456,5 +1457,31 @@ "NO_ITEMS_TITLE": "Nothing watched yet", "NO_ITEMS_BODY": "Start watching anything from your library and it will show up here." } + }, + "LOADING": "Laden", + "RETRY": "Erneut versuchen", + "STALKER": { + "ACCOUNT_INFO": { + "TITLE": "Kontoinformationen", + "ACCOUNT": "Konto", + "PORTAL_INFO": "Portal", + "LOGIN": "Login", + "STATUS": "Status", + "ACTIVE": "Aktiv", + "INACTIVE": "Inaktiv", + "TARIFF_PLAN": "Tarif", + "EXPIRES": "Läuft ab", + "DAYS_LEFT": "Verbleibende Tage", + "EXPIRED": "Abgelaufen", + "MAC_ADDRESS": "MAC-Adresse", + "PHONE": "Telefon", + "PORTAL_URL": "Portal-URL", + "PORTAL_TYPE_FULL": "Ministra-Portal", + "PORTAL_TYPE_LEGACY": "Klassisches Portal (portal.php)", + "CACHED_BADGE": "Gespeicherte Daten", + "REFRESH_FAILED": "Das Portal ist nicht erreichbar — es werden die beim Hinzufügen gespeicherten Daten angezeigt.", + "NO_ACCOUNT_DATA_TITLE": "Keine Kontodaten", + "NO_ACCOUNT_DATA_DESCRIPTION": "Dieses Portal stellt für dieses Gerät keine Kontoinformationen bereit." + } } } diff --git a/apps/web/src/assets/i18n/el.json b/apps/web/src/assets/i18n/el.json index dfa78a790..5d79a7ddd 100644 --- a/apps/web/src/assets/i18n/el.json +++ b/apps/web/src/assets/i18n/el.json @@ -1346,7 +1346,8 @@ "GO_TO_SERIES_DESCRIPTION": "Μετάβαση στην ενότητα σειρών", "SWITCH_PLAYER_LABEL": "Εναλλαγή προγράμματος αναπαραγωγής σε {{name}}", "SWITCH_PLAYER_DESCRIPTION": "Ορισμός του {{name}} ως ενεργό πρόγραμμα αναπαραγωγής βίντεο", - "SWITCH_PLAYER_FEEDBACK": "Το πρόγραμμα αναπαραγωγής ορίστηκε σε {{name}}" + "SWITCH_PLAYER_FEEDBACK": "Το πρόγραμμα αναπαραγωγής ορίστηκε σε {{name}}", + "ACCOUNT_INFO_DESCRIPTION_STALKER": "Εμφάνιση στοιχείων λογαριασμού της πύλης Stalker" }, "XTREAM_REFRESH_TITLE": "Ανανέωση λίστας αναπαραγωγής", "XTREAM_REFRESH_COLLECTING_DATA": "Διατήρηση των δεδομένων της βιβλιοθήκης σας...", @@ -1456,5 +1457,31 @@ "NO_ITEMS_TITLE": "Nothing watched yet", "NO_ITEMS_BODY": "Start watching anything from your library and it will show up here." } + }, + "LOADING": "Φόρτωση", + "RETRY": "Επανάληψη", + "STALKER": { + "ACCOUNT_INFO": { + "TITLE": "Πληροφορίες λογαριασμού", + "ACCOUNT": "Λογαριασμός", + "PORTAL_INFO": "Πύλη", + "LOGIN": "Όνομα χρήστη", + "STATUS": "Κατάσταση", + "ACTIVE": "Ενεργός", + "INACTIVE": "Ανενεργός", + "TARIFF_PLAN": "Πρόγραμμα χρέωσης", + "EXPIRES": "Λήγει", + "DAYS_LEFT": "Ημέρες που απομένουν", + "EXPIRED": "Έληξε", + "MAC_ADDRESS": "Διεύθυνση MAC", + "PHONE": "Τηλέφωνο", + "PORTAL_URL": "URL πύλης", + "PORTAL_TYPE_FULL": "Πύλη Ministra", + "PORTAL_TYPE_LEGACY": "Κλασική πύλη (portal.php)", + "CACHED_BADGE": "Αποθηκευμένα δεδομένα", + "REFRESH_FAILED": "Η πύλη δεν είναι προσβάσιμη — εμφανίζονται τα δεδομένα που αποθηκεύτηκαν κατά την προσθήκη της.", + "NO_ACCOUNT_DATA_TITLE": "Δεν υπάρχουν στοιχεία λογαριασμού", + "NO_ACCOUNT_DATA_DESCRIPTION": "Αυτή η πύλη δεν δημοσιεύει πληροφορίες λογαριασμού για αυτήν τη συσκευή." + } } } diff --git a/apps/web/src/assets/i18n/en.json b/apps/web/src/assets/i18n/en.json index 7e3523182..d354d8a03 100644 --- a/apps/web/src/assets/i18n/en.json +++ b/apps/web/src/assets/i18n/en.json @@ -1346,7 +1346,8 @@ "GO_TO_SERIES_DESCRIPTION": "Jump to series section", "SWITCH_PLAYER_LABEL": "Switch player to {{name}}", "SWITCH_PLAYER_DESCRIPTION": "Set {{name}} as the active video player", - "SWITCH_PLAYER_FEEDBACK": "Player set to {{name}}" + "SWITCH_PLAYER_FEEDBACK": "Player set to {{name}}", + "ACCOUNT_INFO_DESCRIPTION_STALKER": "Show Stalker portal account details" }, "XTREAM_REFRESH_TITLE": "Refreshing playlist", "XTREAM_REFRESH_COLLECTING_DATA": "Preserving your library data...", @@ -1456,5 +1457,31 @@ "NO_ITEMS_TITLE": "Nothing watched yet", "NO_ITEMS_BODY": "Start watching anything from your library and it will show up here." } + }, + "LOADING": "Loading", + "RETRY": "Retry", + "STALKER": { + "ACCOUNT_INFO": { + "TITLE": "Account Information", + "ACCOUNT": "Account", + "PORTAL_INFO": "Portal", + "LOGIN": "Login", + "STATUS": "Status", + "ACTIVE": "Active", + "INACTIVE": "Inactive", + "TARIFF_PLAN": "Tariff plan", + "EXPIRES": "Expires", + "DAYS_LEFT": "Days left", + "EXPIRED": "Expired", + "MAC_ADDRESS": "MAC address", + "PHONE": "Phone", + "PORTAL_URL": "Portal URL", + "PORTAL_TYPE_FULL": "Ministra portal", + "PORTAL_TYPE_LEGACY": "Legacy portal (portal.php)", + "CACHED_BADGE": "Saved data", + "REFRESH_FAILED": "The portal could not be reached — showing the data saved when the portal was added.", + "NO_ACCOUNT_DATA_TITLE": "No account details", + "NO_ACCOUNT_DATA_DESCRIPTION": "This portal does not publish account information for this device." + } } } diff --git a/apps/web/src/assets/i18n/es.json b/apps/web/src/assets/i18n/es.json index e446f7958..f1a6802fe 100644 --- a/apps/web/src/assets/i18n/es.json +++ b/apps/web/src/assets/i18n/es.json @@ -1346,7 +1346,8 @@ "GO_TO_SERIES_DESCRIPTION": "Ir a la sección de series", "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}}" + "SWITCH_PLAYER_FEEDBACK": "Reproductor establecido a {{name}}", + "ACCOUNT_INFO_DESCRIPTION_STALKER": "Mostrar los datos de la cuenta del portal Stalker" }, "XTREAM_REFRESH_TITLE": "Actualizando lista de reproducción", "XTREAM_REFRESH_COLLECTING_DATA": "Conservando los datos de tu biblioteca...", @@ -1456,5 +1457,31 @@ "NO_ITEMS_TITLE": "Nothing watched yet", "NO_ITEMS_BODY": "Start watching anything from your library and it will show up here." } + }, + "LOADING": "Cargando", + "RETRY": "Reintentar", + "STALKER": { + "ACCOUNT_INFO": { + "TITLE": "Información de la cuenta", + "ACCOUNT": "Cuenta", + "PORTAL_INFO": "Portal", + "LOGIN": "Usuario", + "STATUS": "Estado", + "ACTIVE": "Activa", + "INACTIVE": "Inactiva", + "TARIFF_PLAN": "Plan de tarifa", + "EXPIRES": "Caduca", + "DAYS_LEFT": "Días restantes", + "EXPIRED": "Caducada", + "MAC_ADDRESS": "Dirección MAC", + "PHONE": "Teléfono", + "PORTAL_URL": "URL del portal", + "PORTAL_TYPE_FULL": "Portal Ministra", + "PORTAL_TYPE_LEGACY": "Portal clásico (portal.php)", + "CACHED_BADGE": "Datos guardados", + "REFRESH_FAILED": "No se pudo conectar con el portal — se muestran los datos guardados al añadirlo.", + "NO_ACCOUNT_DATA_TITLE": "Sin datos de la cuenta", + "NO_ACCOUNT_DATA_DESCRIPTION": "Este portal no publica información de la cuenta para este dispositivo." + } } } diff --git a/apps/web/src/assets/i18n/fr.json b/apps/web/src/assets/i18n/fr.json index 486dab1f7..b74eb8086 100644 --- a/apps/web/src/assets/i18n/fr.json +++ b/apps/web/src/assets/i18n/fr.json @@ -1346,7 +1346,8 @@ "GO_TO_SERIES_DESCRIPTION": "Aller à la section séries", "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}}" + "SWITCH_PLAYER_FEEDBACK": "Lecteur défini sur {{name}}", + "ACCOUNT_INFO_DESCRIPTION_STALKER": "Afficher les informations du compte du portail Stalker" }, "XTREAM_REFRESH_TITLE": "Actualisation de la playlist", "XTREAM_REFRESH_COLLECTING_DATA": "Préservation des données de votre bibliothèque...", @@ -1456,5 +1457,31 @@ "NO_ITEMS_TITLE": "Nothing watched yet", "NO_ITEMS_BODY": "Start watching anything from your library and it will show up here." } + }, + "LOADING": "Chargement", + "RETRY": "Réessayer", + "STALKER": { + "ACCOUNT_INFO": { + "TITLE": "Informations du compte", + "ACCOUNT": "Compte", + "PORTAL_INFO": "Portail", + "LOGIN": "Identifiant", + "STATUS": "Statut", + "ACTIVE": "Actif", + "INACTIVE": "Inactif", + "TARIFF_PLAN": "Forfait", + "EXPIRES": "Expire", + "DAYS_LEFT": "Jours restants", + "EXPIRED": "Expiré", + "MAC_ADDRESS": "Adresse MAC", + "PHONE": "Téléphone", + "PORTAL_URL": "URL du portail", + "PORTAL_TYPE_FULL": "Portail Ministra", + "PORTAL_TYPE_LEGACY": "Portail classique (portal.php)", + "CACHED_BADGE": "Données enregistrées", + "REFRESH_FAILED": "Le portail est injoignable — affichage des données enregistrées lors de son ajout.", + "NO_ACCOUNT_DATA_TITLE": "Aucune donnée de compte", + "NO_ACCOUNT_DATA_DESCRIPTION": "Ce portail ne publie pas d’informations de compte pour cet appareil." + } } } diff --git a/apps/web/src/assets/i18n/hu.json b/apps/web/src/assets/i18n/hu.json index 0f26026a3..4f41acc5f 100644 --- a/apps/web/src/assets/i18n/hu.json +++ b/apps/web/src/assets/i18n/hu.json @@ -1346,7 +1346,8 @@ "GO_TO_SERIES_DESCRIPTION": "Ugrás a sorozatokhoz", "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}}" + "SWITCH_PLAYER_FEEDBACK": "Aktív lejátszó: {{name}}", + "ACCOUNT_INFO_DESCRIPTION_STALKER": "A Stalker portál fiókadatainak megjelenítése" }, "XTREAM_REFRESH_TITLE": "Lejátszási lista frissítése", "XTREAM_REFRESH_COLLECTING_DATA": "A médiatár adatainak megőrzése…", @@ -1456,5 +1457,31 @@ "NO_ITEMS_TITLE": "Még nincs megtekintési előzmény", "NO_ITEMS_BODY": "Indítson el valamilyen tartalmat a médiatárból; ezután itt is megjelenik." } + }, + "LOADING": "Betöltés", + "RETRY": "Újra", + "STALKER": { + "ACCOUNT_INFO": { + "TITLE": "Fiókadatok", + "ACCOUNT": "Fiók", + "PORTAL_INFO": "Portál", + "LOGIN": "Felhasználónév", + "STATUS": "Állapot", + "ACTIVE": "Aktív", + "INACTIVE": "Inaktív", + "TARIFF_PLAN": "Díjcsomag", + "EXPIRES": "Lejárat", + "DAYS_LEFT": "Hátralévő napok", + "EXPIRED": "Lejárt", + "MAC_ADDRESS": "MAC-cím", + "PHONE": "Telefon", + "PORTAL_URL": "Portál URL", + "PORTAL_TYPE_FULL": "Ministra portál", + "PORTAL_TYPE_LEGACY": "Klasszikus portál (portal.php)", + "CACHED_BADGE": "Mentett adatok", + "REFRESH_FAILED": "A portál nem érhető el — a portál hozzáadásakor mentett adatok láthatók.", + "NO_ACCOUNT_DATA_TITLE": "Nincsenek fiókadatok", + "NO_ACCOUNT_DATA_DESCRIPTION": "Ez a portál nem tesz közzé fiókadatokat ehhez az eszközhöz." + } } } diff --git a/apps/web/src/assets/i18n/it.json b/apps/web/src/assets/i18n/it.json index 585e5706f..8e728760f 100644 --- a/apps/web/src/assets/i18n/it.json +++ b/apps/web/src/assets/i18n/it.json @@ -1346,7 +1346,8 @@ "GO_TO_SERIES_DESCRIPTION": "Vai alla sezione serie", "SWITCH_PLAYER_LABEL": "Passa al lettore {{name}}", "SWITCH_PLAYER_DESCRIPTION": "Imposta {{name}} come lettore video attivo", - "SWITCH_PLAYER_FEEDBACK": "Lettore impostato su {{name}}" + "SWITCH_PLAYER_FEEDBACK": "Lettore impostato su {{name}}", + "ACCOUNT_INFO_DESCRIPTION_STALKER": "Mostra i dati dell’account del portale Stalker" }, "XTREAM_REFRESH_TITLE": "Aggiornamento playlist", "XTREAM_REFRESH_COLLECTING_DATA": "Conservazione dei dati della libreria...", @@ -1456,5 +1457,31 @@ "NO_ITEMS_TITLE": "Nothing watched yet", "NO_ITEMS_BODY": "Start watching anything from your library and it will show up here." } + }, + "LOADING": "Caricamento", + "RETRY": "Riprova", + "STALKER": { + "ACCOUNT_INFO": { + "TITLE": "Informazioni account", + "ACCOUNT": "Account", + "PORTAL_INFO": "Portale", + "LOGIN": "Login", + "STATUS": "Stato", + "ACTIVE": "Attivo", + "INACTIVE": "Inattivo", + "TARIFF_PLAN": "Piano tariffario", + "EXPIRES": "Scade", + "DAYS_LEFT": "Giorni rimanenti", + "EXPIRED": "Scaduto", + "MAC_ADDRESS": "Indirizzo MAC", + "PHONE": "Telefono", + "PORTAL_URL": "URL del portale", + "PORTAL_TYPE_FULL": "Portale Ministra", + "PORTAL_TYPE_LEGACY": "Portale classico (portal.php)", + "CACHED_BADGE": "Dati salvati", + "REFRESH_FAILED": "Impossibile raggiungere il portale — vengono mostrati i dati salvati al momento dell’aggiunta.", + "NO_ACCOUNT_DATA_TITLE": "Nessun dato account", + "NO_ACCOUNT_DATA_DESCRIPTION": "Questo portale non pubblica informazioni sull’account per questo dispositivo." + } } } diff --git a/apps/web/src/assets/i18n/ja.json b/apps/web/src/assets/i18n/ja.json index 9b557ba75..5e29290a7 100644 --- a/apps/web/src/assets/i18n/ja.json +++ b/apps/web/src/assets/i18n/ja.json @@ -1346,7 +1346,8 @@ "GO_TO_SERIES_DESCRIPTION": "シリーズセクションに移動", "SWITCH_PLAYER_LABEL": "プレーヤーを{{name}}に切り替え", "SWITCH_PLAYER_DESCRIPTION": "{{name}}をアクティブなビデオプレーヤーに設定", - "SWITCH_PLAYER_FEEDBACK": "プレーヤーを{{name}}に設定しました" + "SWITCH_PLAYER_FEEDBACK": "プレーヤーを{{name}}に設定しました", + "ACCOUNT_INFO_DESCRIPTION_STALKER": "Stalkerポータルのアカウント情報を表示" }, "XTREAM_REFRESH_TITLE": "プレイリストを更新中", "XTREAM_REFRESH_COLLECTING_DATA": "ライブラリデータを保持しています...", @@ -1456,5 +1457,31 @@ "NO_ITEMS_TITLE": "Nothing watched yet", "NO_ITEMS_BODY": "Start watching anything from your library and it will show up here." } + }, + "LOADING": "読み込み中", + "RETRY": "再試行", + "STALKER": { + "ACCOUNT_INFO": { + "TITLE": "アカウント情報", + "ACCOUNT": "アカウント", + "PORTAL_INFO": "ポータル", + "LOGIN": "ログイン", + "STATUS": "ステータス", + "ACTIVE": "有効", + "INACTIVE": "無効", + "TARIFF_PLAN": "料金プラン", + "EXPIRES": "有効期限", + "DAYS_LEFT": "残り日数", + "EXPIRED": "期限切れ", + "MAC_ADDRESS": "MACアドレス", + "PHONE": "電話番号", + "PORTAL_URL": "ポータルURL", + "PORTAL_TYPE_FULL": "Ministraポータル", + "PORTAL_TYPE_LEGACY": "レガシーポータル(portal.php)", + "CACHED_BADGE": "保存済みデータ", + "REFRESH_FAILED": "ポータルに接続できません — ポータル追加時に保存されたデータを表示しています。", + "NO_ACCOUNT_DATA_TITLE": "アカウント情報なし", + "NO_ACCOUNT_DATA_DESCRIPTION": "このポータルは、このデバイスのアカウント情報を公開していません。" + } } } diff --git a/apps/web/src/assets/i18n/ko.json b/apps/web/src/assets/i18n/ko.json index 9e4c7f322..ae9eb9cb9 100644 --- a/apps/web/src/assets/i18n/ko.json +++ b/apps/web/src/assets/i18n/ko.json @@ -1346,7 +1346,8 @@ "GO_TO_SERIES_DESCRIPTION": "시리즈 섹션으로 이동", "SWITCH_PLAYER_LABEL": "플레이어를 {{name}}(으)로 전환", "SWITCH_PLAYER_DESCRIPTION": "{{name}}을(를) 활성 비디오 플레이어로 설정", - "SWITCH_PLAYER_FEEDBACK": "플레이어가 {{name}}(으)로 설정되었습니다" + "SWITCH_PLAYER_FEEDBACK": "플레이어가 {{name}}(으)로 설정되었습니다", + "ACCOUNT_INFO_DESCRIPTION_STALKER": "Stalker 포털 계정 정보 표시" }, "XTREAM_REFRESH_TITLE": "재생목록 새로 고침 중", "XTREAM_REFRESH_COLLECTING_DATA": "라이브러리 데이터를 보존하는 중...", @@ -1456,5 +1457,31 @@ "NO_ITEMS_TITLE": "Nothing watched yet", "NO_ITEMS_BODY": "Start watching anything from your library and it will show up here." } + }, + "LOADING": "로드 중", + "RETRY": "다시 시도", + "STALKER": { + "ACCOUNT_INFO": { + "TITLE": "계정 정보", + "ACCOUNT": "계정", + "PORTAL_INFO": "포털", + "LOGIN": "로그인", + "STATUS": "상태", + "ACTIVE": "활성", + "INACTIVE": "비활성", + "TARIFF_PLAN": "요금제", + "EXPIRES": "만료일", + "DAYS_LEFT": "남은 일수", + "EXPIRED": "만료됨", + "MAC_ADDRESS": "MAC 주소", + "PHONE": "전화번호", + "PORTAL_URL": "포털 URL", + "PORTAL_TYPE_FULL": "Ministra 포털", + "PORTAL_TYPE_LEGACY": "레거시 포털(portal.php)", + "CACHED_BADGE": "저장된 데이터", + "REFRESH_FAILED": "포털에 연결할 수 없습니다 — 포털 추가 시 저장된 데이터를 표시합니다.", + "NO_ACCOUNT_DATA_TITLE": "계정 정보 없음", + "NO_ACCOUNT_DATA_DESCRIPTION": "이 포털은 이 기기에 대한 계정 정보를 제공하지 않습니다." + } } } diff --git a/apps/web/src/assets/i18n/nl.json b/apps/web/src/assets/i18n/nl.json index 58b3d7044..5031f2e9f 100644 --- a/apps/web/src/assets/i18n/nl.json +++ b/apps/web/src/assets/i18n/nl.json @@ -1346,7 +1346,8 @@ "GO_TO_SERIES_DESCRIPTION": "Spring naar de seriessectie", "SWITCH_PLAYER_LABEL": "Speler wisselen naar {{name}}", "SWITCH_PLAYER_DESCRIPTION": "Stel {{name}} in als de actieve videospeler", - "SWITCH_PLAYER_FEEDBACK": "Speler ingesteld op {{name}}" + "SWITCH_PLAYER_FEEDBACK": "Speler ingesteld op {{name}}", + "ACCOUNT_INFO_DESCRIPTION_STALKER": "Accountgegevens van het Stalker-portaal tonen" }, "XTREAM_REFRESH_TITLE": "Afspeellijst vernieuwen", "XTREAM_REFRESH_COLLECTING_DATA": "Bibliotheekgegevens worden bewaard...", @@ -1456,5 +1457,31 @@ "NO_ITEMS_TITLE": "Nothing watched yet", "NO_ITEMS_BODY": "Start watching anything from your library and it will show up here." } + }, + "LOADING": "Laden", + "RETRY": "Opnieuw proberen", + "STALKER": { + "ACCOUNT_INFO": { + "TITLE": "Accountinformatie", + "ACCOUNT": "Account", + "PORTAL_INFO": "Portaal", + "LOGIN": "Gebruikersnaam", + "STATUS": "Status", + "ACTIVE": "Actief", + "INACTIVE": "Inactief", + "TARIFF_PLAN": "Tariefplan", + "EXPIRES": "Verloopt", + "DAYS_LEFT": "Dagen resterend", + "EXPIRED": "Verlopen", + "MAC_ADDRESS": "MAC-adres", + "PHONE": "Telefoon", + "PORTAL_URL": "Portaal-URL", + "PORTAL_TYPE_FULL": "Ministra-portaal", + "PORTAL_TYPE_LEGACY": "Klassiek portaal (portal.php)", + "CACHED_BADGE": "Opgeslagen gegevens", + "REFRESH_FAILED": "Het portaal is onbereikbaar — de bij het toevoegen opgeslagen gegevens worden getoond.", + "NO_ACCOUNT_DATA_TITLE": "Geen accountgegevens", + "NO_ACCOUNT_DATA_DESCRIPTION": "Dit portaal publiceert geen accountinformatie voor dit apparaat." + } } } diff --git a/apps/web/src/assets/i18n/pl.json b/apps/web/src/assets/i18n/pl.json index 47aa42d8d..5ba87e878 100644 --- a/apps/web/src/assets/i18n/pl.json +++ b/apps/web/src/assets/i18n/pl.json @@ -1346,7 +1346,8 @@ "GO_TO_SERIES_DESCRIPTION": "Przejdź do sekcji seriali", "SWITCH_PLAYER_LABEL": "Przełącz odtwarzacz na {{name}}", "SWITCH_PLAYER_DESCRIPTION": "Ustaw {{name}} jako aktywny odtwarzacz wideo", - "SWITCH_PLAYER_FEEDBACK": "Odtwarzacz ustawiony na {{name}}" + "SWITCH_PLAYER_FEEDBACK": "Odtwarzacz ustawiony na {{name}}", + "ACCOUNT_INFO_DESCRIPTION_STALKER": "Pokaż dane konta portalu Stalker" }, "XTREAM_REFRESH_TITLE": "Odświeżanie listy odtwarzania", "XTREAM_REFRESH_COLLECTING_DATA": "Zachowywanie danych biblioteki...", @@ -1456,5 +1457,31 @@ "NO_ITEMS_TITLE": "Nothing watched yet", "NO_ITEMS_BODY": "Start watching anything from your library and it will show up here." } + }, + "LOADING": "Ładowanie", + "RETRY": "Ponów", + "STALKER": { + "ACCOUNT_INFO": { + "TITLE": "Informacje o koncie", + "ACCOUNT": "Konto", + "PORTAL_INFO": "Portal", + "LOGIN": "Login", + "STATUS": "Status", + "ACTIVE": "Aktywne", + "INACTIVE": "Nieaktywne", + "TARIFF_PLAN": "Plan taryfowy", + "EXPIRES": "Wygasa", + "DAYS_LEFT": "Pozostało dni", + "EXPIRED": "Wygasło", + "MAC_ADDRESS": "Adres MAC", + "PHONE": "Telefon", + "PORTAL_URL": "Adres URL portalu", + "PORTAL_TYPE_FULL": "Portal Ministra", + "PORTAL_TYPE_LEGACY": "Klasyczny portal (portal.php)", + "CACHED_BADGE": "Zapisane dane", + "REFRESH_FAILED": "Portal jest niedostępny — wyświetlane są dane zapisane podczas dodawania portalu.", + "NO_ACCOUNT_DATA_TITLE": "Brak danych konta", + "NO_ACCOUNT_DATA_DESCRIPTION": "Ten portal nie udostępnia informacji o koncie dla tego urządzenia." + } } } diff --git a/apps/web/src/assets/i18n/pt.json b/apps/web/src/assets/i18n/pt.json index 88f056e3f..4361aa9ed 100644 --- a/apps/web/src/assets/i18n/pt.json +++ b/apps/web/src/assets/i18n/pt.json @@ -1346,7 +1346,8 @@ "GO_TO_SERIES_DESCRIPTION": "Ir para a seção de séries", "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}}" + "SWITCH_PLAYER_FEEDBACK": "Reprodutor definido como {{name}}", + "ACCOUNT_INFO_DESCRIPTION_STALKER": "Mostrar dados da conta do portal Stalker" }, "XTREAM_REFRESH_TITLE": "Atualizando playlist", "XTREAM_REFRESH_COLLECTING_DATA": "Preservando os dados da sua biblioteca...", @@ -1456,5 +1457,31 @@ "NO_ITEMS_TITLE": "Nothing watched yet", "NO_ITEMS_BODY": "Start watching anything from your library and it will show up here." } + }, + "LOADING": "A carregar", + "RETRY": "Tentar novamente", + "STALKER": { + "ACCOUNT_INFO": { + "TITLE": "Informações da conta", + "ACCOUNT": "Conta", + "PORTAL_INFO": "Portal", + "LOGIN": "Utilizador", + "STATUS": "Estado", + "ACTIVE": "Ativa", + "INACTIVE": "Inativa", + "TARIFF_PLAN": "Plano tarifário", + "EXPIRES": "Expira", + "DAYS_LEFT": "Dias restantes", + "EXPIRED": "Expirada", + "MAC_ADDRESS": "Endereço MAC", + "PHONE": "Telefone", + "PORTAL_URL": "URL do portal", + "PORTAL_TYPE_FULL": "Portal Ministra", + "PORTAL_TYPE_LEGACY": "Portal clássico (portal.php)", + "CACHED_BADGE": "Dados guardados", + "REFRESH_FAILED": "Não foi possível contactar o portal — a mostrar os dados guardados quando o portal foi adicionado.", + "NO_ACCOUNT_DATA_TITLE": "Sem dados da conta", + "NO_ACCOUNT_DATA_DESCRIPTION": "Este portal não publica informações da conta para este dispositivo." + } } } diff --git a/apps/web/src/assets/i18n/ru.json b/apps/web/src/assets/i18n/ru.json index 14492bf55..884541a6e 100644 --- a/apps/web/src/assets/i18n/ru.json +++ b/apps/web/src/assets/i18n/ru.json @@ -1346,7 +1346,8 @@ "GO_TO_SERIES_DESCRIPTION": "Перейти к разделу сериалов", "SWITCH_PLAYER_LABEL": "Переключить плеер на {{name}}", "SWITCH_PLAYER_DESCRIPTION": "Сделать {{name}} активным видеоплеером", - "SWITCH_PLAYER_FEEDBACK": "Плеер переключен на {{name}}" + "SWITCH_PLAYER_FEEDBACK": "Плеер переключен на {{name}}", + "ACCOUNT_INFO_DESCRIPTION_STALKER": "Показать данные аккаунта Stalker-портала" }, "XTREAM_REFRESH_TITLE": "Обновление плейлиста", "XTREAM_REFRESH_COLLECTING_DATA": "Сохраняем данные вашей библиотеки...", @@ -1456,5 +1457,31 @@ "NO_ITEMS_TITLE": "Nothing watched yet", "NO_ITEMS_BODY": "Start watching anything from your library and it will show up here." } + }, + "LOADING": "Загрузка", + "RETRY": "Повторить", + "STALKER": { + "ACCOUNT_INFO": { + "TITLE": "Информация об аккаунте", + "ACCOUNT": "Аккаунт", + "PORTAL_INFO": "Портал", + "LOGIN": "Логин", + "STATUS": "Статус", + "ACTIVE": "Активен", + "INACTIVE": "Неактивен", + "TARIFF_PLAN": "Тарифный план", + "EXPIRES": "Истекает", + "DAYS_LEFT": "Осталось дней", + "EXPIRED": "Истёк", + "MAC_ADDRESS": "MAC-адрес", + "PHONE": "Телефон", + "PORTAL_URL": "URL портала", + "PORTAL_TYPE_FULL": "Портал Ministra", + "PORTAL_TYPE_LEGACY": "Классический портал (portal.php)", + "CACHED_BADGE": "Сохранённые данные", + "REFRESH_FAILED": "Портал недоступен — показаны данные, сохранённые при добавлении портала.", + "NO_ACCOUNT_DATA_TITLE": "Нет данных об аккаунте", + "NO_ACCOUNT_DATA_DESCRIPTION": "Этот портал не предоставляет информацию об аккаунте для этого устройства." + } } } diff --git a/apps/web/src/assets/i18n/tr.json b/apps/web/src/assets/i18n/tr.json index f1fcb6d6d..9b77c9023 100644 --- a/apps/web/src/assets/i18n/tr.json +++ b/apps/web/src/assets/i18n/tr.json @@ -1346,7 +1346,8 @@ "GO_TO_SERIES_DESCRIPTION": "Diziler bölümüne atla", "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ı" + "SWITCH_PLAYER_FEEDBACK": "Oynatıcı {{name}} olarak ayarlandı", + "ACCOUNT_INFO_DESCRIPTION_STALKER": "Stalker portalı hesap bilgilerini göster" }, "XTREAM_REFRESH_TITLE": "Oynatma listesi yenileniyor", "XTREAM_REFRESH_COLLECTING_DATA": "Kitaplık verileriniz korunuyor...", @@ -1456,5 +1457,31 @@ "NO_ITEMS_TITLE": "Nothing watched yet", "NO_ITEMS_BODY": "Start watching anything from your library and it will show up here." } + }, + "LOADING": "Yükleniyor", + "RETRY": "Yeniden dene", + "STALKER": { + "ACCOUNT_INFO": { + "TITLE": "Hesap Bilgileri", + "ACCOUNT": "Hesap", + "PORTAL_INFO": "Portal", + "LOGIN": "Kullanıcı adı", + "STATUS": "Durum", + "ACTIVE": "Etkin", + "INACTIVE": "Devre dışı", + "TARIFF_PLAN": "Tarife planı", + "EXPIRES": "Bitiş tarihi", + "DAYS_LEFT": "Kalan gün", + "EXPIRED": "Süresi doldu", + "MAC_ADDRESS": "MAC adresi", + "PHONE": "Telefon", + "PORTAL_URL": "Portal URL’si", + "PORTAL_TYPE_FULL": "Ministra portalı", + "PORTAL_TYPE_LEGACY": "Klasik portal (portal.php)", + "CACHED_BADGE": "Kayıtlı veriler", + "REFRESH_FAILED": "Portala ulaşılamadı — portal eklenirken kaydedilen veriler gösteriliyor.", + "NO_ACCOUNT_DATA_TITLE": "Hesap verisi yok", + "NO_ACCOUNT_DATA_DESCRIPTION": "Bu portal, bu cihaz için hesap bilgisi yayınlamıyor." + } } } diff --git a/apps/web/src/assets/i18n/zh.json b/apps/web/src/assets/i18n/zh.json index 121218bd1..f704117f6 100644 --- a/apps/web/src/assets/i18n/zh.json +++ b/apps/web/src/assets/i18n/zh.json @@ -1346,7 +1346,8 @@ "GO_TO_SERIES_DESCRIPTION": "跳转到剧集区", "SWITCH_PLAYER_LABEL": "切换播放器为 {{name}}", "SWITCH_PLAYER_DESCRIPTION": "将 {{name}} 设为当前视频播放器", - "SWITCH_PLAYER_FEEDBACK": "播放器已设为 {{name}}" + "SWITCH_PLAYER_FEEDBACK": "播放器已设为 {{name}}", + "ACCOUNT_INFO_DESCRIPTION_STALKER": "显示 Stalker 门户账户信息" }, "XTREAM_REFRESH_TITLE": "正在刷新播放列表", "XTREAM_REFRESH_COLLECTING_DATA": "正在保留你的媒体库数据...", @@ -1456,5 +1457,31 @@ "NO_ITEMS_TITLE": "Nothing watched yet", "NO_ITEMS_BODY": "Start watching anything from your library and it will show up here." } + }, + "LOADING": "加载中", + "RETRY": "重试", + "STALKER": { + "ACCOUNT_INFO": { + "TITLE": "账户信息", + "ACCOUNT": "账户", + "PORTAL_INFO": "门户", + "LOGIN": "登录名", + "STATUS": "状态", + "ACTIVE": "有效", + "INACTIVE": "无效", + "TARIFF_PLAN": "资费套餐", + "EXPIRES": "到期时间", + "DAYS_LEFT": "剩余天数", + "EXPIRED": "已过期", + "MAC_ADDRESS": "MAC 地址", + "PHONE": "电话", + "PORTAL_URL": "门户 URL", + "PORTAL_TYPE_FULL": "Ministra 门户", + "PORTAL_TYPE_LEGACY": "传统门户(portal.php)", + "CACHED_BADGE": "已保存的数据", + "REFRESH_FAILED": "无法连接门户 — 正在显示添加门户时保存的数据。", + "NO_ACCOUNT_DATA_TITLE": "无账户数据", + "NO_ACCOUNT_DATA_DESCRIPTION": "该门户不为此设备提供账户信息。" + } } } diff --git a/apps/web/src/assets/i18n/zhtw.json b/apps/web/src/assets/i18n/zhtw.json index 00bde5baa..c7afdbbfa 100644 --- a/apps/web/src/assets/i18n/zhtw.json +++ b/apps/web/src/assets/i18n/zhtw.json @@ -1346,7 +1346,8 @@ "GO_TO_SERIES_DESCRIPTION": "跳至影集區段", "SWITCH_PLAYER_LABEL": "切換播放器至 {{name}}", "SWITCH_PLAYER_DESCRIPTION": "將 {{name}} 設為使用中的影片播放器", - "SWITCH_PLAYER_FEEDBACK": "播放器已設為 {{name}}" + "SWITCH_PLAYER_FEEDBACK": "播放器已設為 {{name}}", + "ACCOUNT_INFO_DESCRIPTION_STALKER": "顯示 Stalker 入口網站帳戶資訊" }, "XTREAM_REFRESH_TITLE": "正在重新整理播放清單", "XTREAM_REFRESH_COLLECTING_DATA": "正在保留你的媒體庫資料...", @@ -1456,5 +1457,31 @@ "NO_ITEMS_TITLE": "Nothing watched yet", "NO_ITEMS_BODY": "Start watching anything from your library and it will show up here." } + }, + "LOADING": "載入中", + "RETRY": "重試", + "STALKER": { + "ACCOUNT_INFO": { + "TITLE": "帳戶資訊", + "ACCOUNT": "帳戶", + "PORTAL_INFO": "入口網站", + "LOGIN": "登入名稱", + "STATUS": "狀態", + "ACTIVE": "有效", + "INACTIVE": "無效", + "TARIFF_PLAN": "資費方案", + "EXPIRES": "到期日", + "DAYS_LEFT": "剩餘天數", + "EXPIRED": "已過期", + "MAC_ADDRESS": "MAC 位址", + "PHONE": "電話", + "PORTAL_URL": "入口網站 URL", + "PORTAL_TYPE_FULL": "Ministra 入口網站", + "PORTAL_TYPE_LEGACY": "傳統入口網站(portal.php)", + "CACHED_BADGE": "已儲存的資料", + "REFRESH_FAILED": "無法連線至入口網站 — 顯示新增入口網站時儲存的資料。", + "NO_ACCOUNT_DATA_TITLE": "沒有帳戶資料", + "NO_ACCOUNT_DATA_DESCRIPTION": "此入口網站不提供此裝置的帳戶資訊。" + } } } diff --git a/docs/architecture/stalker-portal.md b/docs/architecture/stalker-portal.md index 933a41581..c12535110 100644 --- a/docs/architecture/stalker-portal.md +++ b/docs/architecture/stalker-portal.md @@ -521,6 +521,41 @@ Import rule: - a fresh handshake must happen after import for full-portal sessions; imported backups never trust a serialized token +## Account Info Dialog + +`StalkerAccountInfoComponent` +(`libs/portal/stalker/feature/src/lib/stalker-account-info/`) mirrors the +Xtream account-info dialog's visual language and shows subscription facts +for a portal: status, login, tariff plan, expiry date with a days-left +counter, MAC/phone, and portal details. + +Data flow (two sources, cached-first): + +- Cached: `Playlist.stalkerAccountInfo`, captured from `get_profile` at + import time for full `/stalker_portal/` installations. The dialog loads it + by playlist id (the meta row does not carry it) and renders instantly with + a "Saved data" badge. +- Fresh: `StalkerAccountInfoService` + (`libs/portal/stalker/data-access/src/lib/stalker-account-info.service.ts`). + Full portals re-run handshake + `get_profile`; `portal.php` panels are + queried with `account_info/get_main_info`, whose field set varies between + panels and is mapped best-effort (absent fields render nothing). A failed + refresh keeps the cached snapshot and flags it. The two no-data outcomes + differ: a portal that answers but publishes no account facts (and no + cached snapshot exists) renders the ready-state "No account details" + panel, while only an unreachable portal without a cached snapshot enters + the error state with retry. + +Entry points are shared with Xtream and gated on the shared predicates in +`libs/shared/interfaces/src/lib/portal-account-playlist.utils.ts` +(`isXtreamAccountPlaylist` / `isStalkerAccountPlaylist`): the header playlist +switcher (bottom section for the active playlist and the per-row ⋮ menu), +the dashboard source card ⋮ menu, and the command palette. The +`WorkspaceShellHeaderService.openAccountInfoFor()` branch picks the dialog +by playlist type; `WORKSPACE_SHELL_ACTIONS.openStalkerAccountInfo()` lazy +loads the component. The stalker-mock-server implements `get_main_info` for +dev/E2E. + ## Remote Control Integration Stalker live remote control is implemented in: diff --git a/libs/playlist/shared/ui/src/lib/playlist-switcher/playlist-switcher.component.html b/libs/playlist/shared/ui/src/lib/playlist-switcher/playlist-switcher.component.html index d662a4c60..102890e08 100644 --- a/libs/playlist/shared/ui/src/lib/playlist-switcher/playlist-switcher.component.html +++ b/libs/playlist/shared/ui/src/lib/playlist-switcher/playlist-switcher.component.html @@ -247,6 +247,15 @@ info {{ 'WORKSPACE.SHELL.PLAYLIST_INFO' | translate }} + @if (hasAccountInfo(playlist)) { + + } @if (canRefreshPlaylist(playlist)) { + + } @else if (loadState() === 'loading') { +
+ @for (panel of skeletonPanels; track panel) { +
+ + + + +
+ } +
+ } @else { +
+
+ + + @if (accountDetails().length > 0) { + + } @else { + + } +
+ +
+ + + +
+
+ } + + + + + diff --git a/libs/portal/stalker/feature/src/lib/stalker-account-info/stalker-account-info.component.scss b/libs/portal/stalker/feature/src/lib/stalker-account-info/stalker-account-info.component.scss new file mode 100644 index 000000000..ddabac486 --- /dev/null +++ b/libs/portal/stalker/feature/src/lib/stalker-account-info/stalker-account-info.component.scss @@ -0,0 +1,21 @@ +@use '../../../../../../ui/styles/account-dialog'; + +:host { + display: block; + color: var(--app-body-color, var(--mat-sys-on-surface)); + --account-dialog-accent: var( + --app-provider-stalker, + var(--mat-sys-primary) + ); +} + +// Stalker shows three hero stats (days left / tariff / MAC), not four. +.account-dialog__hero-stats { + grid-template-columns: repeat(3, minmax(0, 1fr)); +} + +@media (max-width: 720px) { + .account-dialog__hero-stats { + grid-template-columns: 1fr; + } +} diff --git a/libs/portal/stalker/feature/src/lib/stalker-account-info/stalker-account-info.component.spec.ts b/libs/portal/stalker/feature/src/lib/stalker-account-info/stalker-account-info.component.spec.ts new file mode 100644 index 000000000..48d2187e7 --- /dev/null +++ b/libs/portal/stalker/feature/src/lib/stalker-account-info/stalker-account-info.component.spec.ts @@ -0,0 +1,259 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { MAT_DIALOG_DATA } from '@angular/material/dialog'; +import { NoopAnimationsModule } from '@angular/platform-browser/animations'; +import { TranslateModule } from '@ngx-translate/core'; +import { of, throwError } from 'rxjs'; +import { StalkerAccountInfoService } from '@iptvnator/portal/stalker/data-access'; +import { PlaylistsService } from '@iptvnator/services'; +import { PlaylistMeta } from '@iptvnator/shared/interfaces'; +import { StalkerAccountInfoComponent } from './stalker-account-info.component'; + +describe('StalkerAccountInfoComponent', () => { + let fixture: ComponentFixture; + let component: StalkerAccountInfoComponent; + let accountInfoService: { fetchAccountInfo: jest.Mock }; + let playlistsService: { getPlaylist: jest.Mock }; + + const playlist = { + _id: 'stalker-1', + title: 'Living Room Portal', + count: 0, + importDate: '2026-04-05T10:00:00.000Z', + autoRefresh: false, + macAddress: '00:1A:79:00:00:01', + portalUrl: 'http://portal.example/portal.php', + } as PlaylistMeta; + + const freshSnapshot = { + login: 'user-42', + // floor, not round: rounding the epoch up puts the expiry a few + // hundred ms past the 30-day mark and ceil() then yields 31. + expireDate: Math.floor(Date.now() / 1000) + 30 * 86_400, + tariffPlanName: 'Premium', + status: 1, + }; + + const cachedSnapshot = { + login: 'cached-user', + expireDate: Math.floor(Date.now() / 1000) + 3 * 86_400, + tariffPlanName: 'Basic', + status: 1, + }; + + async function createComponent(): Promise { + await TestBed.configureTestingModule({ + imports: [ + StalkerAccountInfoComponent, + NoopAnimationsModule, + TranslateModule.forRoot(), + ], + providers: [ + { provide: MAT_DIALOG_DATA, useValue: { playlist } }, + { + provide: StalkerAccountInfoService, + useValue: accountInfoService, + }, + { provide: PlaylistsService, useValue: playlistsService }, + ], + }).compileComponents(); + + fixture = TestBed.createComponent(StalkerAccountInfoComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + await fixture.whenStable(); + // The constructor load chains several awaits (cache read, portal + // fetch); a macrotask hop drains the remaining microtask chain. + await new Promise((resolve) => setTimeout(resolve)); + fixture.detectChanges(); + } + + beforeEach(() => { + accountInfoService = { + fetchAccountInfo: jest.fn().mockResolvedValue(freshSnapshot), + }; + playlistsService = { + getPlaylist: jest.fn().mockReturnValue(of(null)), + }; + }); + + it('shows the fresh snapshot when the portal answers', async () => { + await createComponent(); + + expect(component.loadState()).toBe('ready'); + expect(component.snapshot()).toEqual(freshSnapshot); + expect(component.snapshotSource()).toBe('fresh'); + expect(component.isCachedSnapshot()).toBe(false); + expect(component.statusKind()).toBe('active'); + expect(component.daysLeft()).toBe(30); + expect(component.expiresSoon()).toBe(false); + }); + + it('falls back to the cached import snapshot when the refresh fails', async () => { + accountInfoService.fetchAccountInfo.mockRejectedValue( + new Error('offline') + ); + playlistsService.getPlaylist.mockReturnValue( + of({ stalkerAccountInfo: cachedSnapshot }) + ); + + await createComponent(); + + expect(component.loadState()).toBe('ready'); + expect(component.snapshot()).toEqual(cachedSnapshot); + expect(component.snapshotSource()).toBe('cached'); + expect(component.refreshFailed()).toBe(true); + expect(component.expiresSoon()).toBe(true); + }); + + it('normalizes stringly-typed cached values persisted at import time', async () => { + accountInfoService.fetchAccountInfo.mockRejectedValue( + new Error('offline') + ); + playlistsService.getPlaylist.mockReturnValue( + of({ + stalkerAccountInfo: { + login: 'cached-user', + // Import persists portal values verbatim; a date string + // must not blow up daysLeft/expiry rendering. + expireDate: '2099-12-31', + status: '1', + }, + }) + ); + + await createComponent(); + + expect(component.loadState()).toBe('ready'); + expect(component.statusKind()).toBe('active'); + expect(component.expiryDate()?.getFullYear()).toBe(2099); + expect(component.daysLeft()).toBeGreaterThan(0); + }); + + it('keeps the cached snapshot when the portal publishes nothing', async () => { + accountInfoService.fetchAccountInfo.mockResolvedValue(null); + playlistsService.getPlaylist.mockReturnValue( + of({ stalkerAccountInfo: cachedSnapshot }) + ); + + await createComponent(); + + expect(component.loadState()).toBe('ready'); + expect(component.snapshot()).toEqual(cachedSnapshot); + expect(component.snapshotSource()).toBe('cached'); + expect(component.refreshFailed()).toBe(false); + }); + + it('shows the error state when there is no data at all', async () => { + accountInfoService.fetchAccountInfo.mockRejectedValue( + new Error('offline') + ); + + await createComponent(); + + expect(component.loadState()).toBe('error'); + expect(component.snapshot()).toBeNull(); + }); + + it('stays ready with an empty account panel when the portal is reachable but silent', async () => { + accountInfoService.fetchAccountInfo.mockResolvedValue(null); + + await createComponent(); + + expect(component.loadState()).toBe('ready'); + expect(component.snapshot()).toBeNull(); + expect(component.accountDetails()).toEqual([]); + // Portal facts are always known, even without account data. + expect(component.portalDetails().map((row) => row.value)).toEqual([ + playlist.portalUrl, + playlist.macAddress, + ]); + }); + + it('survives a broken cache read and still shows fresh data', async () => { + playlistsService.getPlaylist.mockReturnValue( + throwError(() => new Error('db locked')) + ); + + await createComponent(); + + expect(component.loadState()).toBe('ready'); + expect(component.snapshot()).toEqual(freshSnapshot); + expect(component.snapshotSource()).toBe('fresh'); + }); + + it('reports an expiry that passed hours ago as expired, not "0 days left"', async () => { + accountInfoService.fetchAccountInfo.mockResolvedValue({ + ...freshSnapshot, + expireDate: Math.floor(Date.now() / 1000) - 3600, + }); + + await createComponent(); + + // Math.ceil of a fraction of a day rounds up to 0/-0, so the + // counter must not decide expiry. + expect(component.daysLeft()).toBe(-0); + expect(component.hasExpired()).toBe(true); + expect(component.heroStats()[0].labelKey).toBe( + 'STALKER.ACCOUNT_INFO.EXPIRED' + ); + }); + + it('labels a restored full portal by URL when the flag is absent', async () => { + await TestBed.resetTestingModule(); + await TestBed.configureTestingModule({ + imports: [ + StalkerAccountInfoComponent, + NoopAnimationsModule, + TranslateModule.forRoot(), + ], + providers: [ + { + provide: MAT_DIALOG_DATA, + useValue: { + playlist: { + ...playlist, + portalUrl: + 'http://portal.example/stalker_portal/c/', + isFullStalkerPortal: undefined, + }, + }, + }, + { + provide: StalkerAccountInfoService, + useValue: accountInfoService, + }, + { provide: PlaylistsService, useValue: playlistsService }, + ], + }).compileComponents(); + + const restored = TestBed.createComponent(StalkerAccountInfoComponent); + restored.detectChanges(); + await restored.whenStable(); + + // Presentation must agree with the fetch path, which resolves the + // portal type from the URL when the flag is missing. + expect(restored.componentInstance.isFullPortal).toBe(true); + expect(restored.nativeElement.textContent).toContain( + 'STALKER.ACCOUNT_INFO.PORTAL_TYPE_FULL' + ); + expect(restored.nativeElement.textContent).not.toContain( + 'STALKER.ACCOUNT_INFO.PORTAL_TYPE_LEGACY' + ); + }); + + it('renders no status pill for unknown status values', async () => { + accountInfoService.fetchAccountInfo.mockResolvedValue({ + ...freshSnapshot, + status: 7, + }); + + await createComponent(); + + expect(component.statusKind()).toBeNull(); + expect( + component + .accountDetails() + .find((row) => row.labelKey === 'STALKER.ACCOUNT_INFO.STATUS') + ).toBeUndefined(); + }); +}); diff --git a/libs/portal/stalker/feature/src/lib/stalker-account-info/stalker-account-info.component.ts b/libs/portal/stalker/feature/src/lib/stalker-account-info/stalker-account-info.component.ts new file mode 100644 index 000000000..3d914ae6d --- /dev/null +++ b/libs/portal/stalker/feature/src/lib/stalker-account-info/stalker-account-info.component.ts @@ -0,0 +1,287 @@ +import { + ChangeDetectionStrategy, + Component, + computed, + inject, + signal, +} from '@angular/core'; +import { MatButtonModule } from '@angular/material/button'; +import { MAT_DIALOG_DATA, MatDialogModule } from '@angular/material/dialog'; +import { MatIconModule } from '@angular/material/icon'; +import { TranslatePipe } from '@ngx-translate/core'; +import { firstValueFrom } from 'rxjs'; +import { + isFullStalkerPortalPlaylist, + normalizeStoredStalkerAccountInfo, + StalkerAccountInfoService, + StalkerAccountSnapshot, +} from '@iptvnator/portal/stalker/data-access'; +import { createLogger } from '@iptvnator/portal/shared/util'; +import { PlaylistsService } from '@iptvnator/services'; +import { + PlaylistMeta, + type StalkerAccountInfoDialogData, +} from '@iptvnator/shared/interfaces'; + +type AccountLoadState = 'loading' | 'ready' | 'error'; + +/** Where the currently displayed snapshot came from. */ +type SnapshotSource = 'fresh' | 'cached'; + +interface AccountDetailRow { + labelKey: string; + value: string; + mono?: boolean; + tone?: 'positive' | 'warning'; +} + +interface AccountStat { + icon: string; + labelKey: string; + value: string; + warning?: boolean; +} + +const DAY_IN_MS = 86_400_000; + +@Component({ + selector: 'app-stalker-account-info', + imports: [MatButtonModule, MatDialogModule, MatIconModule, TranslatePipe], + templateUrl: './stalker-account-info.component.html', + styleUrl: './stalker-account-info.component.scss', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class StalkerAccountInfoComponent { + private readonly data = inject( + MAT_DIALOG_DATA + ); + private readonly accountInfoService = inject(StalkerAccountInfoService); + private readonly playlistsService = inject(PlaylistsService); + private readonly logger = createLogger('StalkerAccountInfo'); + + readonly playlist: PlaylistMeta = this.data.playlist; + /** + * Presentation must use the same resolution as the fetch path: a + * playlist restored from an older backup carries no explicit flag, + * and the raw field would label a real Ministra portal as legacy. + */ + readonly isFullPortal = isFullStalkerPortalPlaylist(this.playlist); + readonly loadState = signal('loading'); + readonly snapshot = signal(null); + readonly snapshotSource = signal(null); + readonly refreshFailed = signal(false); + readonly skeletonStats = [1, 2, 3]; + readonly skeletonPanels = [1, 2]; + + readonly playlistLabel = computed( + () => this.playlist.title || this.playlist.portalUrl || '-' + ); + readonly isCachedSnapshot = computed( + () => this.snapshotSource() === 'cached' + ); + /** + * 1 = active, 0 = inactive is the convention of the panels that report + * the field at all; anything else renders no pill instead of a guess. + */ + readonly statusKind = computed<'active' | 'inactive' | null>(() => { + const status = this.snapshot()?.status; + if (status === 1) return 'active'; + if (status === 0) return 'inactive'; + return null; + }); + readonly expiryDate = computed(() => { + const expireDate = this.snapshot()?.expireDate; + return expireDate ? new Date(expireDate * 1000) : null; + }); + readonly daysLeft = computed(() => { + const expiry = this.expiryDate(); + if (!expiry) { + return null; + } + return Math.ceil((expiry.getTime() - Date.now()) / DAY_IN_MS); + }); + /** + * Read from the raw timestamp, not daysLeft: an expiry that passed + * less than 24h ago rounds up to 0 (or -0), which is not negative, so + * the counter would claim "0 days left" on a dead subscription. + */ + readonly hasExpired = computed(() => { + const expiry = this.expiryDate(); + return expiry ? expiry.getTime() <= Date.now() : false; + }); + readonly expiresSoon = computed(() => { + const daysLeft = this.daysLeft(); + return daysLeft !== null && daysLeft <= 7; + }); + + readonly heroStats = computed(() => { + const stats: AccountStat[] = []; + const daysLeft = this.daysLeft(); + + if (daysLeft !== null) { + const expired = this.hasExpired(); + stats.push({ + icon: 'timer', + labelKey: expired + ? 'STALKER.ACCOUNT_INFO.EXPIRED' + : 'STALKER.ACCOUNT_INFO.DAYS_LEFT', + value: expired + ? this.formatDate(this.expiryDate()) + : String(daysLeft), + warning: this.expiresSoon(), + }); + } + + const tariff = this.snapshot()?.tariffPlanName; + if (tariff) { + stats.push({ + icon: 'loyalty', + labelKey: 'STALKER.ACCOUNT_INFO.TARIFF_PLAN', + value: tariff, + }); + } + + stats.push({ + icon: 'router', + labelKey: 'STALKER.ACCOUNT_INFO.MAC_ADDRESS', + value: this.snapshot()?.mac || this.playlist.macAddress || '-', + }); + + return stats; + }); + + readonly accountDetails = computed(() => { + const snapshot = this.snapshot(); + if (!snapshot) { + return []; + } + + const rows: AccountDetailRow[] = []; + const statusKind = this.statusKind(); + + if (statusKind) { + rows.push({ + labelKey: 'STALKER.ACCOUNT_INFO.STATUS', + value: + statusKind === 'active' + ? 'STALKER.ACCOUNT_INFO.ACTIVE' + : 'STALKER.ACCOUNT_INFO.INACTIVE', + tone: statusKind === 'active' ? 'positive' : 'warning', + }); + } + if (snapshot.login) { + rows.push({ + labelKey: 'STALKER.ACCOUNT_INFO.LOGIN', + value: snapshot.login, + mono: true, + }); + } + if (snapshot.tariffPlanName) { + rows.push({ + labelKey: 'STALKER.ACCOUNT_INFO.TARIFF_PLAN', + value: snapshot.tariffPlanName, + }); + } + if (this.expiryDate()) { + rows.push({ + labelKey: 'STALKER.ACCOUNT_INFO.EXPIRES', + value: this.formatDate(this.expiryDate()), + tone: this.expiresSoon() ? 'warning' : undefined, + }); + } + if (snapshot.phone) { + rows.push({ + labelKey: 'STALKER.ACCOUNT_INFO.PHONE', + value: snapshot.phone, + mono: true, + }); + } + + return rows; + }); + + readonly portalDetails = computed(() => [ + { + labelKey: 'STALKER.ACCOUNT_INFO.PORTAL_URL', + value: this.playlist.portalUrl || '-', + mono: true, + }, + { + labelKey: 'STALKER.ACCOUNT_INFO.MAC_ADDRESS', + value: this.playlist.macAddress || '-', + mono: true, + }, + ]); + + readonly translatedRowKeys: ReadonlySet = new Set([ + 'STALKER.ACCOUNT_INFO.ACTIVE', + 'STALKER.ACCOUNT_INFO.INACTIVE', + ]); + + constructor() { + void this.load(); + } + + async reload(): Promise { + await this.load(); + } + + private async load(): Promise { + this.refreshFailed.set(false); + if (!this.snapshot()) { + this.loadState.set('loading'); + } + + await this.loadCachedSnapshot(); + + try { + const fresh = await this.accountInfoService.fetchAccountInfo( + this.playlist + ); + if (fresh) { + this.snapshot.set(fresh); + this.snapshotSource.set('fresh'); + } + this.loadState.set('ready'); + } catch (error) { + this.logger.error('Failed to fetch account info', error); + if (this.snapshot()) { + // Keep showing the cached snapshot; just flag the refresh. + this.refreshFailed.set(true); + this.loadState.set('ready'); + } else { + this.loadState.set('error'); + } + } + } + + private async loadCachedSnapshot(): Promise { + if (this.snapshot()) { + return; + } + + try { + const playlist = await firstValueFrom( + this.playlistsService.getPlaylist(this.playlist._id) + ); + // The import path persists portal values verbatim, so the + // stored expiry may be a date string or milliseconds despite + // the declared type — normalize before rendering. + const cached = normalizeStoredStalkerAccountInfo( + playlist?.stalkerAccountInfo + ); + if (cached) { + this.snapshot.set(cached); + this.snapshotSource.set('cached'); + this.loadState.set('ready'); + } + } catch (error) { + // Cache miss is non-fatal — the fresh fetch decides the state. + this.logger.warn('Failed to read cached account info', error); + } + } + + private formatDate(date: Date | null): string { + return date ? date.toLocaleDateString() : '-'; + } +} diff --git a/libs/portal/xtream/feature/src/lib/account-info/account-info.component.scss b/libs/portal/xtream/feature/src/lib/account-info/account-info.component.scss index f553b6fb3..e5c01a1cc 100644 --- a/libs/portal/xtream/feature/src/lib/account-info/account-info.component.scss +++ b/libs/portal/xtream/feature/src/lib/account-info/account-info.component.scss @@ -1,477 +1,10 @@ +@use '../../../../../../ui/styles/account-dialog'; + :host { display: block; color: var(--app-body-color, var(--mat-sys-on-surface)); -} - -// ─── Hero header ──────────────────────────────────────────────────────────── -.account-dialog__hero { - margin: 0; - padding: 20px 20px 18px; - display: grid; - gap: 18px; - background: - var(--app-accent-gradient), - var(--app-widget-header-bg, var(--mat-sys-surface-container-high)); - border-bottom: 1px solid var(--app-widget-header-border, rgba(255, 255, 255, 0.05)); -} - -.account-dialog__hero-copy { - display: grid; - gap: 10px; -} - -.account-dialog__eyebrow { - display: inline-flex; - align-items: center; - gap: 8px; - margin: 0; - color: var(--app-eyebrow-color, var(--mat-sys-on-surface-variant)); - font-size: 0.72rem; - font-weight: 600; - letter-spacing: 0.1em; - text-transform: uppercase; -} - -.account-dialog__eyebrow-mark { - width: 8px; - height: 8px; - border-radius: 999px; - background: var(--app-provider-xtream, var(--mat-sys-primary)); - box-shadow: 0 0 0 4px color-mix(in srgb, var(--app-provider-xtream, var(--mat-sys-primary)) 14%, transparent); -} - -.account-dialog__eyebrow-divider { - width: 3px; - height: 3px; - border-radius: 999px; - background: currentColor; - opacity: 0.5; -} - -.account-dialog__headline-row { - display: flex; - align-items: flex-start; - justify-content: space-between; - gap: 14px; -} - -.account-dialog__title { - margin: 0; - font-size: 1.3rem; - line-height: 1.2; - font-weight: 650; - letter-spacing: -0.02em; - color: var(--app-heading-color, var(--mat-sys-on-surface)); -} - -.account-dialog__subtitle { - margin: 4px 0 0; - color: var(--app-muted-color, var(--mat-sys-on-surface-variant)); - font-size: 0.88rem; -} - -// ─── Status pills ─────────────────────────────────────────────────────────── -.account-dialog__status-cluster { - display: flex; - flex-wrap: wrap; - justify-content: flex-end; - gap: 6px; -} - -.account-dialog__status-pill { - display: inline-flex; - align-items: center; - justify-content: center; - min-height: 28px; - padding: 0 10px; - border-radius: 999px; - border: 1px solid var(--app-separator); - background: var(--mat-sys-surface-container); - color: var(--app-body-color, var(--mat-sys-on-surface)); - font-size: 0.76rem; - font-weight: 600; - letter-spacing: 0.06em; - text-transform: uppercase; -} - -.account-dialog__status-pill--active { - border-color: color-mix(in srgb, #34d399 30%, transparent); - background: color-mix(in srgb, #34d399 12%, transparent); - color: #2db36f; -} - -.account-dialog__status-pill--trial { - border-color: color-mix(in srgb, #fbbf24 28%, transparent); - background: color-mix(in srgb, #f59e0b 12%, transparent); - color: #d18b09; -} - -// ─── Messages ─────────────────────────────────────────────────────────────── -.account-dialog__message, -.account-dialog__loading-copy { - margin: 0; - max-width: 60ch; - color: var(--app-body-color, var(--mat-sys-on-surface)); -} - -.account-dialog__message { - padding: 10px 14px; - border-radius: 10px; - border: 1px solid var(--app-separator); - background: var(--mat-sys-surface-container); - line-height: 1.5; - font-size: 0.88rem; -} - -// ─── Hero stat cards ──────────────────────────────────────────────────────── -.account-dialog__hero-stats { - display: grid; - grid-template-columns: repeat(4, minmax(0, 1fr)); - gap: 10px; -} - -.account-stat { - display: grid; - gap: 6px; - padding: 12px 14px; - border-radius: 12px; - border: 1px solid var(--app-widget-border, transparent); - background: var(--app-widget-bg, var(--mat-sys-surface-container)); - box-shadow: var(--app-widget-shadow, none); - // Subtle inset highlight matching widget-shell pattern - outline: 1px solid rgba(255, 255, 255, 0.04); - outline-offset: -1px; -} - -.account-stat__icon { - width: 32px; - height: 32px; - display: inline-flex; - align-items: center; - justify-content: center; - border-radius: 10px; - background: color-mix(in srgb, var(--app-provider-xtream, var(--mat-sys-primary)) 12%, transparent); - color: var(--app-provider-xtream, var(--mat-sys-primary)); - - mat-icon { - font-size: 18px; - width: 18px; - height: 18px; - } -} - -.account-stat__label { - color: var(--app-muted-color, var(--mat-sys-on-surface-variant)); - font-size: 0.72rem; - font-weight: 600; - letter-spacing: 0.1em; - text-transform: uppercase; -} - -.account-stat__value { - font-size: 1.3rem; - font-weight: 650; - letter-spacing: -0.03em; - color: var(--app-heading-color, var(--mat-sys-on-surface)); -} - -.account-stat__meter { - position: relative; - overflow: hidden; - height: 4px; - border-radius: 999px; - background: var(--app-separator); -} - -.account-stat__meter span { - display: block; - height: 100%; - border-radius: inherit; - background: var(--app-provider-xtream, var(--mat-sys-primary)); -} - -// ─── Content area ─────────────────────────────────────────────────────────── -.account-dialog__content { - margin: 0; - padding: 20px; - background: var(--app-widget-bg, var(--mat-sys-surface)); -} - -.account-dialog__grid { - display: grid; - grid-template-columns: repeat(2, minmax(0, 1fr)); - gap: 14px; -} - -// ─── Detail panels ────────────────────────────────────────────────────────── -.account-panel { - display: grid; - gap: 14px; - padding: 16px; - border-radius: 16px; - border: 1px solid var(--app-widget-border, transparent); - background: var(--app-widget-bg, var(--mat-sys-surface-container-low)); - box-shadow: var(--app-widget-shadow, none); - outline: 1px solid rgba(255, 255, 255, 0.04); - outline-offset: -1px; -} - -.account-panel__header { - display: grid; - gap: 4px; -} - -.account-panel__header h3 { - margin: 0; - font-size: 1rem; - font-weight: 600; - letter-spacing: -0.01em; - color: var(--app-heading-color, var(--mat-sys-on-surface)); -} - -.account-panel__eyebrow { - margin: 0; - color: var(--app-eyebrow-color, var(--mat-sys-on-surface-variant)); - font-size: 0.72rem; - font-weight: 600; - letter-spacing: 0.1em; - text-transform: uppercase; -} - -// ─── Detail rows ──────────────────────────────────────────────────────────── -.account-detail-list { - display: grid; - gap: 0; - margin: 0; -} - -.account-detail-row { - display: grid; - grid-template-columns: minmax(120px, 140px) minmax(0, 1fr); - gap: 12px; - align-items: baseline; - padding: 10px 0; - border-bottom: 1px solid var(--app-separator); -} - -.account-detail-row:last-child { - border-bottom: 0; - padding-bottom: 0; -} - -.account-detail-row dt { - margin: 0; - color: var(--app-muted-color, var(--mat-sys-on-surface-variant)); - font-size: 0.72rem; - font-weight: 600; - letter-spacing: 0.1em; - text-transform: uppercase; -} - -.account-detail-row dd { - margin: 0; - color: var(--app-body-color, var(--mat-sys-on-surface)); - font-size: 0.9rem; - font-weight: 500; - word-break: break-word; -} - -.account-detail-row__value--mono { - font-family: 'JetBrains Mono', 'Roboto Mono', monospace; - font-size: 0.84rem; -} - -.account-detail-row__value--accent { - color: var(--app-provider-xtream, var(--mat-sys-primary)); -} - -.account-detail-row__value--positive { - color: #2db36f; -} - -.account-detail-row__value--warning { - color: #d18b09; -} - -// ─── Chip sections ────────────────────────────────────────────────────────── -.account-chip-section { - display: grid; - gap: 10px; -} - -.account-chip-section__label { - margin: 0; - color: var(--app-muted-color, var(--mat-sys-on-surface-variant)); - font-size: 0.72rem; - font-weight: 600; - letter-spacing: 0.1em; - text-transform: uppercase; -} - -.account-chip-group { - display: flex; - flex-wrap: wrap; - gap: 8px; -} - -.account-chip { - display: inline-flex; - align-items: center; - min-height: 28px; - padding: 0 10px; - border-radius: 999px; - border: 1px solid color-mix(in srgb, var(--app-provider-xtream, var(--mat-sys-primary)) 16%, transparent); - background: color-mix(in srgb, var(--app-provider-xtream, var(--mat-sys-primary)) 8%, transparent); - color: var(--app-body-color, var(--mat-sys-on-surface)); - font-size: 0.8rem; - font-weight: 600; - text-transform: uppercase; -} - -.account-chip--mono { - font-family: 'JetBrains Mono', 'Roboto Mono', monospace; - text-transform: none; -} - -.account-chip--muted { - border-color: var(--app-separator); - background: var(--mat-sys-surface-container); - color: var(--app-muted-color, var(--mat-sys-on-surface-variant)); -} - -// ─── Error state ──────────────────────────────────────────────────────────── -.account-dialog__error-state { - display: grid; - justify-items: center; - gap: 12px; - padding: 36px 20px; - border-radius: 16px; - border: 1px dashed var(--app-separator); - background: var(--app-widget-bg, var(--mat-sys-surface-container-low)); - text-align: center; -} - -.account-dialog__error-state h3, -.account-dialog__error-state p { - margin: 0; -} - -.account-dialog__error-state p { - max-width: 52ch; - color: var(--app-muted-color, var(--mat-sys-on-surface-variant)); - line-height: 1.5; - font-size: 0.88rem; -} - -.account-dialog__error-icon { - width: 48px; - height: 48px; - display: inline-flex; - align-items: center; - justify-content: center; - border-radius: 14px; - background: color-mix(in srgb, var(--mat-sys-error) 12%, transparent); - color: var(--mat-sys-error); -} - -// ─── Skeleton loading ─────────────────────────────────────────────────────── -.account-panel--loading { - gap: 12px; -} - -.account-skeleton { - position: relative; - overflow: hidden; - border-radius: 999px; - background: var(--app-separator); -} - -.account-skeleton::after { - content: ''; - position: absolute; - inset: 0; - transform: translateX(-100%); - background: linear-gradient( - 90deg, - transparent, - color-mix(in srgb, var(--app-heading-color, var(--mat-sys-on-surface)) 10%, transparent), - transparent + --account-dialog-accent: var( + --app-provider-xtream, + var(--mat-sys-primary) ); - animation: account-dialog-shimmer 1.4s infinite; -} - -.account-skeleton--stat { - min-height: 110px; - border-radius: 12px; -} - -.account-skeleton--heading { - width: 54%; - height: 20px; -} - -.account-skeleton--row { - width: 100%; - height: 16px; -} - -.account-skeleton--row.short { - width: 62%; -} - -// ─── Footer actions ───────────────────────────────────────────────────────── -.account-dialog__actions { - padding: 0 20px 20px; - display: flex; - justify-content: flex-end; - gap: 10px; - border-top: 1px solid var(--app-widget-header-border, rgba(255, 255, 255, 0.05)); - background: var(--app-widget-bg, var(--mat-sys-surface)); -} - -.account-dialog__close { - min-width: 100px; - border-radius: 999px; -} - -@keyframes account-dialog-shimmer { - 100% { - transform: translateX(100%); - } -} - -// ─── Responsive breakpoints ───────────────────────────────────────────────── -@media (max-width: 980px) { - .account-dialog__hero-stats, - .account-dialog__grid { - grid-template-columns: repeat(2, minmax(0, 1fr)); - } -} - -@media (max-width: 720px) { - .account-dialog__hero, - .account-dialog__content, - .account-dialog__actions { - padding-left: 16px; - padding-right: 16px; - } - - .account-dialog__headline-row { - flex-direction: column; - align-items: flex-start; - } - - .account-dialog__status-cluster { - justify-content: flex-start; - } - - .account-dialog__hero-stats, - .account-dialog__grid { - grid-template-columns: 1fr; - } - - .account-detail-row { - grid-template-columns: 1fr; - gap: 4px; - } } diff --git a/libs/shared/interfaces/src/index.ts b/libs/shared/interfaces/src/index.ts index 597ab8443..5f9d2bc9e 100644 --- a/libs/shared/interfaces/src/index.ts +++ b/libs/shared/interfaces/src/index.ts @@ -25,6 +25,7 @@ export * from './lib/playback-position.interface'; export * from './lib/playlist-auto-update.interface'; export * from './lib/playlist-backup.interface'; export * from './lib/playlist-meta.type'; +export * from './lib/portal-account-playlist.utils'; export * from './lib/preload-performance-marker.interface'; export * from './lib/playlist-recently-viewed.interface'; export * from './lib/playlist-recently-viewed.utils'; @@ -44,6 +45,7 @@ export * from './lib/stream-format.enum'; export * from './lib/catalog-title-match.interface'; export * from './lib/theme.enum'; export * from './lib/season-marker.util'; +export * from './lib/stalker-account-info-dialog-data.interface'; export * from './lib/title-normalization.util'; export * from './lib/tmdb.interface'; export * from './lib/vod-source.interface'; diff --git a/libs/shared/interfaces/src/lib/portal-account-playlist.utils.ts b/libs/shared/interfaces/src/lib/portal-account-playlist.utils.ts new file mode 100644 index 000000000..5b7e631a2 --- /dev/null +++ b/libs/shared/interfaces/src/lib/portal-account-playlist.utils.ts @@ -0,0 +1,40 @@ +import { PlaylistMeta } from './playlist-meta.type'; + +/** + * A playlist backed by an Xtream Codes account — the only kind the Xtream + * account-info dialog can query (`player_api.php` needs all three fields). + */ +export function isXtreamAccountPlaylist( + playlist: PlaylistMeta +): playlist is PlaylistMeta & { + serverUrl: string; + username: string; + password: string; +} { + return Boolean( + playlist.serverUrl && playlist.username && playlist.password + ); +} + +/** + * A playlist backed by a Stalker/Ministra portal. Portal URL + MAC address + * are what every Stalker request is keyed on, so together they identify an + * account the Stalker account-info dialog can describe. + */ +export function isStalkerAccountPlaylist( + playlist: PlaylistMeta +): playlist is PlaylistMeta & { + portalUrl: string; + macAddress: string; +} { + return Boolean(playlist.portalUrl && playlist.macAddress); +} + +/** + * Any playlist for which an account-info dialog exists. + */ +export function isPortalAccountPlaylist(playlist: PlaylistMeta): boolean { + return ( + isXtreamAccountPlaylist(playlist) || isStalkerAccountPlaylist(playlist) + ); +} diff --git a/libs/shared/interfaces/src/lib/stalker-account-info-dialog-data.interface.ts b/libs/shared/interfaces/src/lib/stalker-account-info-dialog-data.interface.ts new file mode 100644 index 000000000..a8841ba9b --- /dev/null +++ b/libs/shared/interfaces/src/lib/stalker-account-info-dialog-data.interface.ts @@ -0,0 +1,11 @@ +import { PlaylistMeta } from './playlist-meta.type'; + +/** + * Dialog input for the Stalker account-info dialog. The meta row carries the + * portal URL, MAC address, and device identity needed for a fresh portal + * query; the cached `stalkerAccountInfo` snapshot lives only in the full + * playlist payload, so the dialog loads it by id. + */ +export interface StalkerAccountInfoDialogData { + playlist: PlaylistMeta; +} diff --git a/libs/ui/styles/_account-dialog.scss b/libs/ui/styles/_account-dialog.scss new file mode 100644 index 000000000..36a10e950 --- /dev/null +++ b/libs/ui/styles/_account-dialog.scss @@ -0,0 +1,533 @@ +// Shared account-info dialog stylesheet, used by both portal dialogs: +// - libs/portal/xtream/feature/src/lib/account-info/ +// - libs/portal/stalker/feature/src/lib/stalker-account-info/ +// +// The provider accent is injected via the `--account-dialog-accent` custom +// property, set on :host by each consumer. Provider-specific structural +// tweaks (e.g. the stats column count) stay in the consumer stylesheet. + +// ─── Hero header ──────────────────────────────────────────────────────────── +.account-dialog__hero { + margin: 0; + padding: 20px 20px 18px; + display: grid; + gap: 18px; + background: + var(--app-accent-gradient), + var(--app-widget-header-bg, var(--mat-sys-surface-container-high)); + border-bottom: 1px solid + var(--app-widget-header-border, rgba(255, 255, 255, 0.05)); +} + +.account-dialog__hero-copy { + display: grid; + gap: 10px; +} + +.account-dialog__eyebrow { + display: inline-flex; + align-items: center; + gap: 8px; + margin: 0; + color: var(--app-eyebrow-color, var(--mat-sys-on-surface-variant)); + font-size: 0.72rem; + font-weight: 600; + letter-spacing: 0.1em; + text-transform: uppercase; +} + +.account-dialog__eyebrow-mark { + width: 8px; + height: 8px; + border-radius: 999px; + background: var(--account-dialog-accent, var(--mat-sys-primary)); + box-shadow: 0 0 0 4px + color-mix( + in srgb, + var(--account-dialog-accent, var(--mat-sys-primary)) 14%, + transparent + ); +} + +.account-dialog__eyebrow-divider { + width: 3px; + height: 3px; + border-radius: 999px; + background: currentColor; + opacity: 0.5; +} + +.account-dialog__headline-row { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 14px; +} + +.account-dialog__title { + margin: 0; + font-size: 1.3rem; + line-height: 1.2; + font-weight: 650; + letter-spacing: -0.02em; + color: var(--app-heading-color, var(--mat-sys-on-surface)); +} + +.account-dialog__subtitle { + margin: 4px 0 0; + color: var(--app-muted-color, var(--mat-sys-on-surface-variant)); + font-size: 0.88rem; +} + +// ─── Status pills ─────────────────────────────────────────────────────────── +.account-dialog__status-cluster { + display: flex; + flex-wrap: wrap; + justify-content: flex-end; + gap: 6px; +} + +.account-dialog__status-pill { + display: inline-flex; + align-items: center; + justify-content: center; + min-height: 28px; + padding: 0 10px; + border-radius: 999px; + border: 1px solid var(--app-separator); + background: var(--mat-sys-surface-container); + color: var(--app-body-color, var(--mat-sys-on-surface)); + font-size: 0.76rem; + font-weight: 600; + letter-spacing: 0.06em; + text-transform: uppercase; +} + +.account-dialog__status-pill--active { + border-color: color-mix(in srgb, #34d399 30%, transparent); + background: color-mix(in srgb, #34d399 12%, transparent); + color: #2db36f; +} + +.account-dialog__status-pill--trial, +.account-dialog__status-pill--cached { + border-color: color-mix(in srgb, #fbbf24 28%, transparent); + background: color-mix(in srgb, #f59e0b 12%, transparent); + color: #d18b09; +} + +// ─── Messages ─────────────────────────────────────────────────────────────── +.account-dialog__message, +.account-dialog__loading-copy { + margin: 0; + max-width: 60ch; + color: var(--app-body-color, var(--mat-sys-on-surface)); +} + +.account-dialog__message { + padding: 10px 14px; + border-radius: 10px; + border: 1px solid var(--app-separator); + background: var(--mat-sys-surface-container); + line-height: 1.5; + font-size: 0.88rem; +} + +// ─── Hero stat cards ──────────────────────────────────────────────────────── +.account-dialog__hero-stats { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 10px; +} + +.account-stat { + display: grid; + gap: 6px; + padding: 12px 14px; + border-radius: 12px; + border: 1px solid var(--app-widget-border, transparent); + background: var(--app-widget-bg, var(--mat-sys-surface-container)); + box-shadow: var(--app-widget-shadow, none); + // Subtle inset highlight matching widget-shell pattern + outline: 1px solid rgba(255, 255, 255, 0.04); + outline-offset: -1px; +} + +.account-stat__icon { + width: 32px; + height: 32px; + display: inline-flex; + align-items: center; + justify-content: center; + border-radius: 10px; + background: color-mix( + in srgb, + var(--account-dialog-accent, var(--mat-sys-primary)) 12%, + transparent + ); + color: var(--account-dialog-accent, var(--mat-sys-primary)); + + mat-icon { + font-size: 18px; + width: 18px; + height: 18px; + } +} + +.account-stat__label { + color: var(--app-muted-color, var(--mat-sys-on-surface-variant)); + font-size: 0.72rem; + font-weight: 600; + letter-spacing: 0.1em; + text-transform: uppercase; +} + +.account-stat__value { + font-size: 1.3rem; + font-weight: 650; + letter-spacing: -0.03em; + color: var(--app-heading-color, var(--mat-sys-on-surface)); + overflow-wrap: anywhere; +} + +.account-stat__value--warning { + color: #d18b09; +} + +.account-stat__meter { + position: relative; + overflow: hidden; + height: 4px; + border-radius: 999px; + background: var(--app-separator); +} + +.account-stat__meter span { + display: block; + height: 100%; + border-radius: inherit; + background: var(--account-dialog-accent, var(--mat-sys-primary)); +} + +// ─── Content area ─────────────────────────────────────────────────────────── +.account-dialog__content { + margin: 0; + padding: 20px; + background: var(--app-widget-bg, var(--mat-sys-surface)); +} + +.account-dialog__grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 14px; +} + +// ─── Detail panels ────────────────────────────────────────────────────────── +.account-panel { + display: grid; + gap: 14px; + padding: 16px; + border-radius: 16px; + border: 1px solid var(--app-widget-border, transparent); + background: var(--app-widget-bg, var(--mat-sys-surface-container-low)); + box-shadow: var(--app-widget-shadow, none); + outline: 1px solid rgba(255, 255, 255, 0.04); + outline-offset: -1px; + align-content: start; +} + +.account-panel__header { + display: grid; + gap: 4px; +} + +.account-panel__header h3 { + margin: 0; + font-size: 1rem; + font-weight: 600; + letter-spacing: -0.01em; + color: var(--app-heading-color, var(--mat-sys-on-surface)); + overflow-wrap: anywhere; +} + +.account-panel__eyebrow { + margin: 0; + color: var(--app-eyebrow-color, var(--mat-sys-on-surface-variant)); + font-size: 0.72rem; + font-weight: 600; + letter-spacing: 0.1em; + text-transform: uppercase; +} + +// ─── Detail rows ──────────────────────────────────────────────────────────── +.account-detail-list { + display: grid; + gap: 0; + margin: 0; +} + +.account-detail-row { + display: grid; + grid-template-columns: minmax(120px, 140px) minmax(0, 1fr); + gap: 12px; + align-items: baseline; + padding: 10px 0; + border-bottom: 1px solid var(--app-separator); +} + +.account-detail-row:last-child { + border-bottom: 0; + padding-bottom: 0; +} + +.account-detail-row dt { + margin: 0; + color: var(--app-muted-color, var(--mat-sys-on-surface-variant)); + font-size: 0.72rem; + font-weight: 600; + letter-spacing: 0.1em; + text-transform: uppercase; +} + +.account-detail-row dd { + margin: 0; + color: var(--app-body-color, var(--mat-sys-on-surface)); + font-size: 0.9rem; + font-weight: 500; + word-break: break-word; +} + +.account-detail-row__value--mono { + font-family: 'JetBrains Mono', 'Roboto Mono', monospace; + font-size: 0.84rem; +} + +.account-detail-row__value--accent { + color: var(--account-dialog-accent, var(--mat-sys-primary)); +} + +.account-detail-row__value--positive { + color: #2db36f; +} + +.account-detail-row__value--warning { + color: #d18b09; +} + +// ─── Chip sections ────────────────────────────────────────────────────────── +.account-chip-section { + display: grid; + gap: 10px; +} + +.account-chip-section__label { + margin: 0; + color: var(--app-muted-color, var(--mat-sys-on-surface-variant)); + font-size: 0.72rem; + font-weight: 600; + letter-spacing: 0.1em; + text-transform: uppercase; +} + +.account-chip-group { + display: flex; + flex-wrap: wrap; + gap: 8px; +} + +.account-chip { + display: inline-flex; + align-items: center; + min-height: 28px; + padding: 0 10px; + border-radius: 999px; + border: 1px solid + color-mix( + in srgb, + var(--account-dialog-accent, var(--mat-sys-primary)) 16%, + transparent + ); + background: color-mix( + in srgb, + var(--account-dialog-accent, var(--mat-sys-primary)) 8%, + transparent + ); + color: var(--app-body-color, var(--mat-sys-on-surface)); + font-size: 0.8rem; + font-weight: 600; + text-transform: uppercase; +} + +.account-chip--mono { + font-family: 'JetBrains Mono', 'Roboto Mono', monospace; + text-transform: none; +} + +.account-chip--muted { + border-color: var(--app-separator); + background: var(--mat-sys-surface-container); + color: var(--app-muted-color, var(--mat-sys-on-surface-variant)); +} + +// ─── Empty account block ──────────────────────────────────────────────────── +.account-dialog__empty-copy { + display: grid; + gap: 6px; + + h4 { + margin: 0; + font-size: 0.92rem; + font-weight: 600; + color: var(--app-heading-color, var(--mat-sys-on-surface)); + } + + p { + margin: 0; + max-width: 46ch; + color: var(--app-muted-color, var(--mat-sys-on-surface-variant)); + font-size: 0.85rem; + line-height: 1.5; + } +} + +// ─── Error state ──────────────────────────────────────────────────────────── +.account-dialog__error-state { + display: grid; + justify-items: center; + gap: 12px; + padding: 36px 20px; + border-radius: 16px; + border: 1px dashed var(--app-separator); + background: var(--app-widget-bg, var(--mat-sys-surface-container-low)); + text-align: center; +} + +.account-dialog__error-state h3, +.account-dialog__error-state p { + margin: 0; +} + +.account-dialog__error-state p { + max-width: 52ch; + color: var(--app-muted-color, var(--mat-sys-on-surface-variant)); + line-height: 1.5; + font-size: 0.88rem; +} + +.account-dialog__error-icon { + width: 48px; + height: 48px; + display: inline-flex; + align-items: center; + justify-content: center; + border-radius: 14px; + background: color-mix(in srgb, var(--mat-sys-error) 12%, transparent); + color: var(--mat-sys-error); +} + +// ─── Skeleton loading ─────────────────────────────────────────────────────── +.account-panel--loading { + gap: 12px; +} + +.account-skeleton { + position: relative; + overflow: hidden; + border-radius: 999px; + background: var(--app-separator); +} + +.account-skeleton::after { + content: ''; + position: absolute; + inset: 0; + transform: translateX(-100%); + background: linear-gradient( + 90deg, + transparent, + color-mix( + in srgb, + var(--app-heading-color, var(--mat-sys-on-surface)) 10%, + transparent + ), + transparent + ); + animation: account-dialog-shimmer 1.4s infinite; +} + +.account-skeleton--stat { + min-height: 110px; + border-radius: 12px; +} + +.account-skeleton--heading { + width: 54%; + height: 20px; +} + +.account-skeleton--row { + width: 100%; + height: 16px; +} + +.account-skeleton--row.short { + width: 62%; +} + +// ─── Footer actions ───────────────────────────────────────────────────────── +.account-dialog__actions { + padding: 0 20px 20px; + display: flex; + justify-content: flex-end; + gap: 10px; + border-top: 1px solid + var(--app-widget-header-border, rgba(255, 255, 255, 0.05)); + background: var(--app-widget-bg, var(--mat-sys-surface)); +} + +.account-dialog__close { + min-width: 100px; + border-radius: 999px; +} + +@keyframes account-dialog-shimmer { + 100% { + transform: translateX(100%); + } +} + +// ─── Responsive breakpoints ───────────────────────────────────────────────── +@media (max-width: 980px) { + .account-dialog__hero-stats, + .account-dialog__grid { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } +} + +@media (max-width: 720px) { + .account-dialog__hero, + .account-dialog__content, + .account-dialog__actions { + padding-left: 16px; + padding-right: 16px; + } + + .account-dialog__headline-row { + flex-direction: column; + align-items: flex-start; + } + + .account-dialog__status-cluster { + justify-content: flex-start; + } + + .account-dialog__hero-stats, + .account-dialog__grid { + grid-template-columns: 1fr; + } + + .account-detail-row { + grid-template-columns: 1fr; + gap: 4px; + } +} diff --git a/libs/workspace/dashboard/feature/src/lib/rails/dashboard-rail.utils.ts b/libs/workspace/dashboard/feature/src/lib/rails/dashboard-rail.utils.ts index 767946b7d..f84ec2dee 100644 --- a/libs/workspace/dashboard/feature/src/lib/rails/dashboard-rail.utils.ts +++ b/libs/workspace/dashboard/feature/src/lib/rails/dashboard-rail.utils.ts @@ -1,4 +1,5 @@ import type { PlaylistMeta } from '@iptvnator/shared/interfaces'; +import { isPortalAccountPlaylist } from '@iptvnator/shared/interfaces'; import { buildCollectionViewState, COLLECTION_VIEW_STATE_KEY, @@ -48,7 +49,7 @@ export function buildDashboardSourceActions( labelKey: 'HOME.PLAYLISTS.SHOW_DETAILS', }); - if (isXtreamAccountPlaylist(playlist)) { + if (isPortalAccountPlaylist(playlist)) { actions.push({ id: 'account-info', icon: 'person', @@ -67,18 +68,6 @@ export function buildDashboardSourceActions( return actions; } -export function isXtreamAccountPlaylist( - playlist: PlaylistMeta -): playlist is PlaylistMeta & { - serverUrl: string; - username: string; - password: string; -} { - return Boolean( - playlist.serverUrl && playlist.username && playlist.password - ); -} - export function liveRailTitleKeyForSource( source: 'favorites' | 'recent' ): string { diff --git a/libs/workspace/dashboard/feature/src/lib/rails/workspace-dashboard-rails.component.spec.ts b/libs/workspace/dashboard/feature/src/lib/rails/workspace-dashboard-rails.component.spec.ts index de9db5143..80ffce6cd 100644 --- a/libs/workspace/dashboard/feature/src/lib/rails/workspace-dashboard-rails.component.spec.ts +++ b/libs/workspace/dashboard/feature/src/lib/rails/workspace-dashboard-rails.component.spec.ts @@ -81,14 +81,18 @@ describe('buildDashboardSourceActions', () => { ]); }); - it('exposes info and remove for Stalker sources', () => { + it('exposes info, account, and remove for Stalker sources', () => { const playlist = { ...basePlaylist, macAddress: '00:1A:79:00:00:01', portalUrl: 'https://stalker.example.test', } as PlaylistMeta; - expect(actionIds(playlist, false)).toEqual(['playlist-info', 'remove']); + expect(actionIds(playlist, false)).toEqual([ + 'playlist-info', + 'account-info', + 'remove', + ]); }); }); diff --git a/libs/workspace/dashboard/feature/src/lib/rails/workspace-dashboard-rails.component.ts b/libs/workspace/dashboard/feature/src/lib/rails/workspace-dashboard-rails.component.ts index 62fb50393..ee5f7230f 100644 --- a/libs/workspace/dashboard/feature/src/lib/rails/workspace-dashboard-rails.component.ts +++ b/libs/workspace/dashboard/feature/src/lib/rails/workspace-dashboard-rails.component.ts @@ -12,6 +12,8 @@ import { interval, of, startWith, switchMap } from 'rxjs'; import { EpgService } from '@iptvnator/epg/data-access'; import { type EpgProgram, + isStalkerAccountPlaylist, + isXtreamAccountPlaylist, normalizeDashboardRailsSettings, } from '@iptvnator/shared/interfaces'; import { MatButtonModule } from '@angular/material/button'; @@ -73,7 +75,6 @@ import { buildDashboardCollectionViewState, buildDashboardRailSeeAllState, buildDashboardSourceActions, - isXtreamAccountPlaylist, liveRailTitleKeyForSource, RAIL_ITEM_LIMIT, shouldShowLiveFavoritesSkeleton, @@ -450,7 +451,7 @@ export class WorkspaceDashboardRailsComponent { this.dialog.open(PlaylistInfoComponent, { data: playlist }); break; case 'account-info': - this.openXtreamAccountInfo(playlist); + this.openAccountInfo(playlist); break; case 'remove': this.confirmRemovePlaylist(playlist); @@ -602,26 +603,29 @@ export class WorkspaceDashboardRailsComponent { return 'video_library'; } - private openXtreamAccountInfo(playlist: PlaylistMeta): void { - if (!isXtreamAccountPlaylist(playlist)) { + private openAccountInfo(playlist: PlaylistMeta): void { + if (isXtreamAccountPlaylist(playlist)) { + const title = + playlist.title || + playlist.filename || + this.t('WORKSPACE.DASHBOARD.UNTITLED_SOURCE'); + + this.shellActions.openAccountInfo({ + playlist: { + id: playlist._id, + name: title, + title, + serverUrl: playlist.serverUrl, + username: playlist.username, + password: playlist.password, + }, + }); return; } - const title = - playlist.title || - playlist.filename || - this.t('WORKSPACE.DASHBOARD.UNTITLED_SOURCE'); - - this.shellActions.openAccountInfo({ - playlist: { - id: playlist._id, - name: title, - title, - serverUrl: playlist.serverUrl, - username: playlist.username, - password: playlist.password, - }, - }); + if (isStalkerAccountPlaylist(playlist)) { + this.shellActions.openStalkerAccountInfo({ playlist }); + } } private confirmRemovePlaylist(playlist: PlaylistMeta): void { diff --git a/libs/workspace/shell/feature/src/lib/workspace-shell/components/workspace-shell-header/workspace-shell-header.component.html b/libs/workspace/shell/feature/src/lib/workspace-shell/components/workspace-shell-header/workspace-shell-header.component.html index 1e055b323..32b3d22fe 100644 --- a/libs/workspace/shell/feature/src/lib/workspace-shell/components/workspace-shell-header/workspace-shell-header.component.html +++ b/libs/workspace/shell/feature/src/lib/workspace-shell/components/workspace-shell-header/workspace-shell-header.component.html @@ -34,6 +34,9 @@ [isRefreshingActivePlaylist]="isRefreshingPlaylist()" (playlistInfoRequested)="onPlaylistInfoRequested()" (accountInfoRequested)="onAccountInfoRequested()" + (accountInfoForPlaylistRequested)=" + onAccountInfoForPlaylistRequested($event) + " (addPlaylistRequested)="onAddPlaylistRequested()" (refreshPlaylistRequested)="onRefreshPlaylistRequested()" /> diff --git a/libs/workspace/shell/feature/src/lib/workspace-shell/components/workspace-shell-header/workspace-shell-header.component.ts b/libs/workspace/shell/feature/src/lib/workspace-shell/components/workspace-shell-header/workspace-shell-header.component.ts index b7ee92f85..692510216 100644 --- a/libs/workspace/shell/feature/src/lib/workspace-shell/components/workspace-shell-header/workspace-shell-header.component.ts +++ b/libs/workspace/shell/feature/src/lib/workspace-shell/components/workspace-shell-header/workspace-shell-header.component.ts @@ -13,6 +13,7 @@ import { MatTooltip } from '@angular/material/tooltip'; import { TranslatePipe } from '@ngx-translate/core'; import { PlaylistSwitcherComponent } from '@iptvnator/playlist/shared/ui'; import { WorkspaceHeaderAction } from '@iptvnator/portal/shared/util'; +import { PlaylistMeta } from '@iptvnator/shared/interfaces'; import { WorkspaceHeaderBulkAction } from '../../services/helpers/workspace-shell-constants'; @Component({ @@ -105,6 +106,7 @@ export class WorkspaceShellHeaderComponent { readonly playlistInfoRequested = output(); readonly accountInfoRequested = output(); readonly contextDrawerToggleRequested = output(); + readonly accountInfoForPlaylistRequested = output(); focusSearchInput(options: { select?: boolean } = {}): void { const inputElement = this.searchInput()?.nativeElement; @@ -141,6 +143,10 @@ export class WorkspaceShellHeaderComponent { this.accountInfoRequested.emit(); } + onAccountInfoForPlaylistRequested(playlist: PlaylistMeta): void { + this.accountInfoForPlaylistRequested.emit(playlist); + } + onAddPlaylistRequested(): void { this.addPlaylistRequested.emit(); } diff --git a/libs/workspace/shell/feature/src/lib/workspace-shell/services/helpers/workspace-shell-command-builders.ts b/libs/workspace/shell/feature/src/lib/workspace-shell/services/helpers/workspace-shell-command-builders.ts index 34582064a..c65eab3e4 100644 --- a/libs/workspace/shell/feature/src/lib/workspace-shell/services/helpers/workspace-shell-command-builders.ts +++ b/libs/workspace/shell/feature/src/lib/workspace-shell/services/helpers/workspace-shell-command-builders.ts @@ -128,9 +128,13 @@ export function getPlaylistCommandDefinitions( icon: 'account_circle', labelKey: 'WORKSPACE.SHELL.COMMANDS.ACCOUNT_INFO_LABEL', descriptionKey: - 'WORKSPACE.SHELL.COMMANDS.ACCOUNT_INFO_DESCRIPTION', + context.provider === 'stalker' + ? 'WORKSPACE.SHELL.COMMANDS.ACCOUNT_INFO_DESCRIPTION_STALKER' + : 'WORKSPACE.SHELL.COMMANDS.ACCOUNT_INFO_DESCRIPTION', priority: 40, - visible: context.provider === 'xtreams', + visible: + context.provider === 'xtreams' || + context.provider === 'stalker', run: () => ctx.actions.openAccountInfo(), }, ]; diff --git a/libs/workspace/shell/feature/src/lib/workspace-shell/services/workspace-shell-header.service.ts b/libs/workspace/shell/feature/src/lib/workspace-shell/services/workspace-shell-header.service.ts index ca2655dd6..51aecd51d 100644 --- a/libs/workspace/shell/feature/src/lib/workspace-shell/services/workspace-shell-header.service.ts +++ b/libs/workspace/shell/feature/src/lib/workspace-shell/services/workspace-shell-header.service.ts @@ -16,7 +16,11 @@ import { import { XtreamStore } from '@iptvnator/portal/xtream/data-access'; import { PlaylistsService } from '@iptvnator/services'; import { PlaylistActions } from '@iptvnator/m3u-state'; -import { PlaylistMeta } from '@iptvnator/shared/interfaces'; +import { + isStalkerAccountPlaylist, + isXtreamAccountPlaylist, + PlaylistMeta, +} from '@iptvnator/shared/interfaces'; import { WorkspaceAccountInfoData, WorkspacePortalContext, @@ -74,9 +78,14 @@ export class WorkspaceShellHeaderService { readonly canOpenPlaylistInfo = computed(() => Boolean(this.routeState.activePlaylist()) ); - readonly canOpenAccountInfo = computed(() => - Boolean(this.routeState.activePlaylist()?.serverUrl) - ); + readonly canOpenAccountInfo = computed(() => { + const playlist = this.routeState.activePlaylist(); + return Boolean( + playlist && + (isXtreamAccountPlaylist(playlist) || + isStalkerAccountPlaylist(playlist)) + ); + }); readonly canRefreshPlaylist = computed(() => this.playlistRefreshAction.canRefresh( this.routeState.activePlaylist() @@ -234,16 +243,51 @@ export class WorkspaceShellHeaderService { } openAccountInfo(): void { - if (!this.canOpenAccountInfo()) { + const playlist = this.routeState.activePlaylist(); + if (!playlist) { return; } - const data: WorkspaceAccountInfoData = { - vodStreamsCount: this.xtreamStore.vodStreams().length, - liveStreamsCount: this.xtreamStore.liveStreams().length, - seriesCount: this.xtreamStore.serialStreams().length, - }; - this.workspaceActions.openAccountInfo(data); + this.openAccountInfoFor(playlist); + } + + /** + * Opens the matching account-info dialog for any portal playlist — + * the active one (header button, command palette) or a specific row + * (playlist switcher item menu). + */ + openAccountInfoFor(playlist: PlaylistMeta): void { + if (isXtreamAccountPlaylist(playlist)) { + const isActivePlaylist = + this.routeState.activePlaylist()?._id === playlist._id; + const data: WorkspaceAccountInfoData = { + // Stream counts describe the loaded portal session, so they + // only apply when the dialog targets the active playlist. + ...(isActivePlaylist + ? { + vodStreamsCount: + this.xtreamStore.vodStreams().length, + liveStreamsCount: + this.xtreamStore.liveStreams().length, + seriesCount: this.xtreamStore.serialStreams().length, + } + : {}), + playlist: { + id: playlist._id, + name: playlist.title, + title: playlist.title, + serverUrl: playlist.serverUrl, + username: playlist.username, + password: playlist.password, + }, + }; + this.workspaceActions.openAccountInfo(data); + return; + } + + if (isStalkerAccountPlaylist(playlist)) { + this.workspaceActions.openStalkerAccountInfo({ playlist }); + } } refreshCurrentPlaylist(): void { diff --git a/libs/workspace/shell/feature/src/lib/workspace-shell/services/workspace-shell.facade.spec.ts b/libs/workspace/shell/feature/src/lib/workspace-shell/services/workspace-shell.facade.spec.ts index 09acb4a83..3ad9b87b3 100644 --- a/libs/workspace/shell/feature/src/lib/workspace-shell/services/workspace-shell.facade.spec.ts +++ b/libs/workspace/shell/feature/src/lib/workspace-shell/services/workspace-shell.facade.spec.ts @@ -135,6 +135,7 @@ describe('WorkspaceShellFacade', () => { openGlobalSearch: jest.Mock; openGlobalRecent: jest.Mock; openAccountInfo: jest.Mock; + openStalkerAccountInfo: jest.Mock; }; let storeDispatch: jest.Mock; let activePlaylistSignal: ReturnType< @@ -209,6 +210,7 @@ describe('WorkspaceShellFacade', () => { openGlobalSearch: jest.fn(), openGlobalRecent: jest.fn(), openAccountInfo: jest.fn(), + openStalkerAccountInfo: jest.fn(), }; startupPreferences = { getFirstAvailableWorkspacePath: jest.fn((showDashboard: boolean) => @@ -893,6 +895,100 @@ describe('WorkspaceShellFacade', () => { ); }); + it('opens the Xtream account dialog with credentials and session counts for the active playlist', () => { + activePlaylistSignal.set({ + _id: 'pl-xtream', + title: 'Xtream One', + count: 42, + importDate: '2026-04-22T10:00:00.000Z', + autoRefresh: false, + serverUrl: 'https://provider.example.test', + username: 'demo', + password: 'secret', + } as PlaylistSignalMeta); + + facade.openAccountInfo(); + + expect(workspaceActions.openAccountInfo).toHaveBeenCalledWith({ + vodStreamsCount: 1, + liveStreamsCount: 1, + seriesCount: 1, + playlist: expect.objectContaining({ + id: 'pl-xtream', + serverUrl: 'https://provider.example.test', + username: 'demo', + password: 'secret', + }), + }); + expect(workspaceActions.openStalkerAccountInfo).not.toHaveBeenCalled(); + }); + + it('opens the Stalker account dialog for an active stalker playlist', () => { + const stalkerPlaylist = { + _id: 'pl-stalker', + title: 'Stalker Portal', + count: 0, + importDate: '2026-04-22T10:00:00.000Z', + autoRefresh: false, + macAddress: '00:1A:79:00:00:01', + portalUrl: 'http://portal.example/portal.php', + } as PlaylistSignalMeta; + activePlaylistSignal.set(stalkerPlaylist); + + expect(facade.canOpenAccountInfo()).toBe(true); + + facade.openAccountInfo(); + + expect(workspaceActions.openStalkerAccountInfo).toHaveBeenCalledWith({ + playlist: stalkerPlaylist, + }); + expect(workspaceActions.openAccountInfo).not.toHaveBeenCalled(); + }); + + it('opens the account dialog for a non-active playlist without session counts', () => { + activePlaylistSignal.set(null); + const otherXtream = { + _id: 'pl-other', + title: 'Other Xtream', + count: 5, + importDate: '2026-04-22T10:00:00.000Z', + autoRefresh: false, + serverUrl: 'https://other.example.test', + username: 'user2', + password: 'pass2', + } as PlaylistSignalMeta; + + facade.openAccountInfoFor(otherXtream); + + expect(workspaceActions.openAccountInfo).toHaveBeenCalledWith({ + playlist: expect.objectContaining({ id: 'pl-other' }), + }); + }); + + it('offers the account-info command with a Stalker description on stalker routes', () => { + activePlaylistSignal.set({ + _id: 'pl-stalker', + title: 'Stalker Portal', + count: 0, + importDate: '2026-04-22T10:00:00.000Z', + autoRefresh: false, + macAddress: '00:1A:79:00:00:01', + portalUrl: 'http://portal.example/portal.php', + } as PlaylistSignalMeta); + facade.currentUrl.set('/workspace/stalker/pl-stalker/itv'); + + const accountCommand = facade + .commandPaletteCommands() + .find((command) => command.id === 'account-info'); + + expect(accountCommand).toBeDefined(); + // TranslateModule.forRoot() has no catalog, so the resolved + // description is the raw key — which is exactly what we assert. + expect(accountCommand?.description).toBe( + 'WORKSPACE.SHELL.COMMANDS.ACCOUNT_INFO_DESCRIPTION_STALKER' + ); + }); + it('places registered current-view commands ahead of globals on global favorites routes', () => { const viewCommands = TestBed.inject(WorkspaceViewCommandService); const clearCurrent = jest.fn(); diff --git a/libs/workspace/shell/feature/src/lib/workspace-shell/services/workspace-shell.facade.ts b/libs/workspace/shell/feature/src/lib/workspace-shell/services/workspace-shell.facade.ts index 83d705f4e..e68d22867 100644 --- a/libs/workspace/shell/feature/src/lib/workspace-shell/services/workspace-shell.facade.ts +++ b/libs/workspace/shell/feature/src/lib/workspace-shell/services/workspace-shell.facade.ts @@ -17,6 +17,7 @@ import { CommandBuilderActions, CommandBuilderContext, } from './helpers/workspace-shell-command-builders'; +import { PlaylistMeta } from '@iptvnator/shared/interfaces'; import { isWorkspaceGlobalSearchablePlaylist } from './helpers/workspace-shell-searchable-playlists'; import { WorkspaceShellCommandPaletteService } from './workspace-shell-command-palette.service'; import { WorkspaceShellHeaderService } from './workspace-shell-header.service'; @@ -225,6 +226,10 @@ export class WorkspaceShellFacade { this.header.openAccountInfo(); } + openAccountInfoFor(playlist: PlaylistMeta): void { + this.header.openAccountInfoFor(playlist); + } + refreshCurrentPlaylist(): void { this.header.refreshCurrentPlaylist(); } diff --git a/libs/workspace/shell/feature/src/lib/workspace-shell/workspace-shell.component.html b/libs/workspace/shell/feature/src/lib/workspace-shell/workspace-shell.component.html index ac7670f97..52f8c28f5 100644 --- a/libs/workspace/shell/feature/src/lib/workspace-shell/workspace-shell.component.html +++ b/libs/workspace/shell/feature/src/lib/workspace-shell/workspace-shell.component.html @@ -62,6 +62,7 @@ (downloadsRequested)="facade.openDownloadsShortcut()" (playlistInfoRequested)="facade.openPlaylistInfo()" (accountInfoRequested)="facade.openAccountInfo()" + (accountInfoForPlaylistRequested)="facade.openAccountInfoFor($event)" />