From 9feab4f83b5a79ba4ee2029eeb2f3951d2fdb47d Mon Sep 17 00:00:00 2001 From: 4gray Date: Mon, 12 Oct 2026 21:53:16 +0200 Subject: [PATCH] feat(switcher): rebuild the playlist switcher as an overlay popover MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The switcher's mat-menu becomes a CDK connected overlay (app-playlist-switcher-panel), 440px wide, with a dimmed, blurred backdrop; only its list scrolls and row menus open above it. - Header with the source count, Add playlist and All sources. - Search by name or host, autofocused; the type filter is one segmented control (All / M3U / Xtream / Stalker with counts) instead of three checkboxes, remembered between openings (the old checkbox setting is migrated). The search is no longer kept between openings. - "Recent" (the three most recently opened, kept across restarts) above "All" in the Sources page order, as compact source rows: the current one tinted with a check. Each row's ⋯ menu holds Refresh, Playlist info, Account info, Check again and Remove (with Undo); the footer's info, account and add actions moved there and to the header. - Keyboard: a listbox driven by aria-activedescendant from the search field. Arrow keys move and wrap, Enter opens, Shift+F10 opens the row's menu, Escape closes and returns focus to the trigger. Hints in the footer. - Deleting from a row menu moves focus to the toast's Undo, since the row (and its focus) left the page. Co-Authored-By: Claude Opus 5.5 --- .../src/playlist-switcher.e2e.ts | 202 ++++-- .../src/sources-keyboard.e2e.ts | 65 ++ apps/electron-backend-e2e/src/sources.e2e.ts | 15 +- .../src/player-settings-panel.fixture.ts | 2 +- apps/web-e2e/src/stalker.e2e.ts | 11 +- apps/web-e2e/src/xtream.e2e.ts | 11 +- apps/web/src/assets/i18n/ar.json | 15 +- apps/web/src/assets/i18n/ary.json | 15 +- apps/web/src/assets/i18n/by.json | 15 +- apps/web/src/assets/i18n/de.json | 15 +- apps/web/src/assets/i18n/el.json | 15 +- apps/web/src/assets/i18n/en.json | 15 +- apps/web/src/assets/i18n/es.json | 15 +- apps/web/src/assets/i18n/fr.json | 15 +- apps/web/src/assets/i18n/hu.json | 15 +- apps/web/src/assets/i18n/it.json | 15 +- apps/web/src/assets/i18n/ja.json | 15 +- apps/web/src/assets/i18n/ko.json | 15 +- apps/web/src/assets/i18n/nl.json | 15 +- apps/web/src/assets/i18n/pl.json | 15 +- apps/web/src/assets/i18n/pt.json | 15 +- apps/web/src/assets/i18n/ru.json | 15 +- apps/web/src/assets/i18n/tr.json | 15 +- apps/web/src/assets/i18n/zh.json | 15 +- apps/web/src/assets/i18n/zhtw.json | 15 +- .../playlist-switcher-list.spec.ts | 104 +++ .../playlist-switcher-list.ts | 114 ++++ .../playlist-switcher-panel.component.html | 167 +++++ .../playlist-switcher-panel.component.scss | 185 ++++++ .../playlist-switcher-panel.component.spec.ts | 238 +++++++ .../playlist-switcher-panel.component.ts | 281 +++++++++ .../playlist-switcher.component.html | 287 +-------- .../playlist-switcher.component.scss | 460 +------------- .../playlist-switcher.component.spec.ts | 529 +++------------- .../playlist-switcher.component.ts | 597 +++--------------- .../source-removal-toast.component.spec.ts | 83 +++ .../source-removal-toast.component.ts | 27 +- libs/playlist/shared/util/src/index.ts | 1 + .../util/src/lib/playlist-context.facade.ts | 3 + .../playlist-recent-history.service.spec.ts | 35 + .../lib/playlist-recent-history.service.ts | 51 ++ .../lib/source-display/source-format.spec.ts | 6 + .../src/lib/source-display/source-format.ts | 7 +- .../workspace-shell-header.component.html | 5 - .../workspace-shell-header.component.spec.ts | 5 - .../workspace-shell-header.component.ts | 12 - .../workspace-shell.component.html | 4 - .../workspace-shell.component.spec.ts | 4 - ...space-sources-filters-panel.component.html | 4 +- 49 files changed, 1987 insertions(+), 1813 deletions(-) create mode 100644 libs/playlist/shared/ui/src/lib/playlist-switcher/playlist-switcher-list.spec.ts create mode 100644 libs/playlist/shared/ui/src/lib/playlist-switcher/playlist-switcher-list.ts create mode 100644 libs/playlist/shared/ui/src/lib/playlist-switcher/playlist-switcher-panel.component.html create mode 100644 libs/playlist/shared/ui/src/lib/playlist-switcher/playlist-switcher-panel.component.scss create mode 100644 libs/playlist/shared/ui/src/lib/playlist-switcher/playlist-switcher-panel.component.spec.ts create mode 100644 libs/playlist/shared/ui/src/lib/playlist-switcher/playlist-switcher-panel.component.ts create mode 100644 libs/playlist/shared/ui/src/lib/source-removal/source-removal-toast.component.spec.ts create mode 100644 libs/playlist/shared/util/src/lib/playlist-recent-history.service.spec.ts create mode 100644 libs/playlist/shared/util/src/lib/playlist-recent-history.service.ts diff --git a/apps/electron-backend-e2e/src/playlist-switcher.e2e.ts b/apps/electron-backend-e2e/src/playlist-switcher.e2e.ts index 024972a30..5579a4390 100644 --- a/apps/electron-backend-e2e/src/playlist-switcher.e2e.ts +++ b/apps/electron-backend-e2e/src/playlist-switcher.e2e.ts @@ -245,44 +245,41 @@ test.describe('Electron Playlist Switcher', () => { await waitForM3uCatalog(app.mainWindow); await openPlaylistFavorites(app.mainWindow); - await expect(channelItemByTitle(app.mainWindow, localAChannelName)).toHaveCount( - 1 - ); - await expect(channelItemByTitle(app.mainWindow, localBChannelName)).toHaveCount( - 0 - ); + await expect( + channelItemByTitle(app.mainWindow, localAChannelName) + ).toHaveCount(1); + await expect( + channelItemByTitle(app.mainWindow, localBChannelName) + ).toHaveCount(0); await switchPlaylistFromHeader(app.mainWindow, localBDisplayName); await expectPathname( app.mainWindow, /\/workspace\/global-favorites$/ ); - await expect(channelItemByTitle(app.mainWindow, localBChannelName)).toHaveCount( - 1 - ); - await expect(channelItemByTitle(app.mainWindow, localAChannelName)).toHaveCount( - 0 - ); + await expect( + channelItemByTitle(app.mainWindow, localBChannelName) + ).toHaveCount(1); + await expect( + channelItemByTitle(app.mainWindow, localAChannelName) + ).toHaveCount(0); await openPlaylistRecent(app.mainWindow); - await expect(channelItemByTitle(app.mainWindow, localBChannelName)).toHaveCount( - 1 - ); - await expect(channelItemByTitle(app.mainWindow, localAChannelName)).toHaveCount( - 0 - ); + await expect( + channelItemByTitle(app.mainWindow, localBChannelName) + ).toHaveCount(1); + await expect( + channelItemByTitle(app.mainWindow, localAChannelName) + ).toHaveCount(0); await switchPlaylistFromHeader(app.mainWindow, localADisplayName); - await expectPathname( - app.mainWindow, - /\/workspace\/global-recent$/ - ); - await expect(channelItemByTitle(app.mainWindow, localAChannelName)).toHaveCount( - 1 - ); - await expect(channelItemByTitle(app.mainWindow, localBChannelName)).toHaveCount( - 0 - ); + await expectPathname(app.mainWindow, /\/workspace\/global-recent$/); + await expect( + channelItemByTitle(app.mainWindow, localAChannelName) + ).toHaveCount(1); + await expect( + channelItemByTitle(app.mainWindow, localBChannelName) + ).toHaveCount(0); } finally { await closeElectronApp(app); } @@ -319,7 +316,9 @@ test.describe('Electron Playlist Switcher', () => { username: xtreamStressUsername, }); - const overlay = app.mainWindow.locator('.workspace-loading-overlay'); + const overlay = app.mainWindow.locator( + '.workspace-loading-overlay' + ); await expect(overlay).toBeVisible({ timeout: 15000 }); await waitForDbOperationEvent(app.mainWindow, { operation: 'save-content', @@ -428,17 +427,116 @@ test.describe('Electron Playlist Switcher', () => { await switchPlaylistFromHeader(app.mainWindow, offlineXtreamName); await waitForXtreamCatalog(app.mainWindow); await expectXtreamUnavailableWarning(app.mainWindow); - await expect(app.mainWindow.locator('.xtream-content-gate')).toHaveCount( - 0 - ); + await expect( + app.mainWindow.locator('.xtream-content-gate') + ).toHaveCount(0); app = await restartElectronApp(app, dataDir); await waitForXtreamCatalog(app.mainWindow); await expectXtreamUnavailableWarning(app.mainWindow); - await expect(app.mainWindow.locator('.xtream-content-gate')).toHaveCount( - 0 + await expect( + app.mainWindow.locator('.xtream-content-gate') + ).toHaveCount(0); + } finally { + await closeElectronApp(app); + } + }); + + test('runs from the keyboard: Cmd/Ctrl+P, filter, arrows, Enter and Escape', async ({ + dataDir, + request, + }) => { + await resetMockServers(request, ['xtream']); + const localAPath = writeTemporaryM3uFile(dataDir, localAFileName, [ + { + groupTitle: 'News', + name: 'Switcher Local A', + url: 'https://streams.example.test/switcher-local-a.m3u8', + }, + ]); + const localBPath = writeTemporaryM3uFile(dataDir, localBFileName, [ + { + groupTitle: 'Sports', + name: 'Switcher Local B', + url: 'https://streams.example.test/switcher-local-b.m3u8', + }, + ]); + const app = await launchElectronApp(dataDir); + const page = app.mainWindow; + + try { + await importM3uPlaylistFromNativeDialog(app, localAPath); + await importM3uPlaylistFromNativeDialog(app, localBPath); + await addXtreamPortal(page, { name: xtreamAName }); + await waitForXtreamCatalog(page); + + const trigger = page.getByTestId('playlist-switcher-trigger'); + const panel = page.locator('app-playlist-switcher-panel'); + const search = panel.locator('input[role="combobox"]'); + await expect(playlistSwitcherTitle(page)).toContainText( + xtreamAName ); + + await page.keyboard.press('ControlOrMeta+p'); + await expect(search).toBeFocused(); + // No list shows a credential: the Xtream row names the account + // on purpose, never its password. + await expect(panel).not.toContainText(defaultXtreamPassword); + await expect(panel).not.toContainText('password='); + + await page.keyboard.type('switcher-local'); + await expect(switcherOptions(page)).toHaveCount(2); + // A search highlights its first match; ↓ moves on, ↑ wraps. + const options = switcherOptions(page); + const firstId = (await options.nth(0).getAttribute('id')) ?? ''; + const secondId = (await options.nth(1).getAttribute('id')) ?? ''; + await expect(search).toHaveAttribute( + 'aria-activedescendant', + firstId + ); + await page.keyboard.press('ArrowDown'); + await expect(search).toHaveAttribute( + 'aria-activedescendant', + secondId + ); + await page.keyboard.press('ArrowDown'); + await expect(search).toHaveAttribute( + 'aria-activedescendant', + firstId + ); + await page.keyboard.press('ArrowUp'); + await expect(search).toHaveAttribute( + 'aria-activedescendant', + secondId + ); + const targetName = + ( + await options + .nth(1) + .locator('.source-row__name') + .textContent() + )?.trim() ?? ''; + await page.keyboard.press('Enter'); + + await expect(panel).toHaveCount(0); + await waitForM3uCatalog(page); + await expect(playlistSwitcherTitle(page)).toContainText(targetName); + + // Escape closes and puts focus back on the trigger. + await page.keyboard.press('ControlOrMeta+p'); + await expect(search).toBeFocused(); + await page.keyboard.press('Escape'); + await expect(panel).toHaveCount(0); + await expect(trigger).toBeFocused(); + + await openSources(page); + await expect( + page.locator('app-workspace-sources app-source-row') + ).toHaveCount(3); + await expect( + page.locator('app-workspace-sources') + ).not.toContainText(defaultXtreamPassword); } finally { await closeElectronApp(app); } @@ -446,12 +544,12 @@ test.describe('Electron Playlist Switcher', () => { }); async function openPlaylistSwitcher(page: Page): Promise { - await page - .locator('app-playlist-switcher .playlist-switcher-trigger') - .click(); - await expect( - page.locator('.cdk-overlay-pane .playlist-item').first() - ).toBeVisible(); + await page.getByTestId('playlist-switcher-trigger').click(); + await expect(switcherOptions(page).first()).toBeVisible(); +} + +function switcherOptions(page: Page) { + return page.locator('app-playlist-switcher-panel [role="option"]'); } async function switchPlaylistFromHeader( @@ -459,16 +557,11 @@ async function switchPlaylistFromHeader( title: string ): Promise { await openPlaylistSwitcher(page); - const item = page - .locator('.cdk-overlay-pane .playlist-item') - .filter({ hasText: title }) - .first(); + const item = switcherOptions(page).filter({ hasText: title }).first(); await expect(item).toBeVisible(); await item.click(); - await expect(page.locator('.cdk-overlay-pane .playlist-item')).toHaveCount( - 0 - ); + await expect(page.locator('app-playlist-switcher-panel')).toHaveCount(0); } async function expectPathname(page: Page, pattern: RegExp): Promise { @@ -503,16 +596,21 @@ async function selectSettingsOption( } async function expectXtreamUnavailableWarning(page: Page): Promise { - await expect( - page.getByTestId('xtream-offline-warning') - ).toBeVisible({ timeout: 20000 }); + await expect(page.getByTestId('xtream-offline-warning')).toBeVisible({ + timeout: 20000, + }); } -async function toggleFavoriteForChannel(page: Page, title: string): Promise { +async function toggleFavoriteForChannel( + page: Page, + title: string +): Promise { const item = channelItemByTitle(page, title).first(); await expect(item).toBeVisible({ timeout: 20000 }); await item.hover(); await item.locator('.favorite-button').first().click(); - await expect(item.locator('.favorite-button mat-icon').first()).toHaveText(/star/); + await expect(item.locator('.favorite-button mat-icon').first()).toHaveText( + /star/ + ); } diff --git a/apps/electron-backend-e2e/src/sources-keyboard.e2e.ts b/apps/electron-backend-e2e/src/sources-keyboard.e2e.ts index 0ca32b047..231278419 100644 --- a/apps/electron-backend-e2e/src/sources-keyboard.e2e.ts +++ b/apps/electron-backend-e2e/src/sources-keyboard.e2e.ts @@ -175,4 +175,69 @@ test.describe('Electron Sources keyboard', () => { await closeElectronApp(app); } }); + + test('@m3u @electron deletes from the row menu and undoes from the toast, keyboard only', async ({ + dataDir, + }) => { + const alpha = writeTemporaryM3uFile(dataDir, 'undo-alpha.m3u', [ + { + groupTitle: 'News', + name: 'Undo Alpha News', + url: 'https://streams.example.test/undo-alpha.m3u8', + }, + ]); + const bravo = writeTemporaryM3uFile(dataDir, 'undo-bravo.m3u', [ + { + groupTitle: 'Sports', + name: 'Undo Bravo Sports', + url: 'https://streams.example.test/undo-bravo.m3u8', + }, + ]); + const app = await launchElectronApp(dataDir, { + appArgs: [alpha, bravo], + }); + const page = app.mainWindow; + const rows = page.locator('app-workspace-sources app-source-row'); + + try { + await page.waitForURL(/\/workspace\/playlists\/.+/, { + timeout: 30_000, + }); + await openSources(page); + await expect(rows).toHaveCount(2, { timeout: 30_000 }); + const before = await rows + .locator('.source-row__name') + .allTextContents(); + + // Focus the first row's ⋯ and open its menu from the keyboard. + const row = rows.first(); + await row.locator('.source-row__more').focus(); + await page.keyboard.press('Enter'); + const remove = page.getByRole('menuitem', { + name: 'Remove playlist', + }); + await expect(remove).toBeVisible(); + await page.keyboard.press('End'); + await expect(remove).toBeFocused(); + await page.keyboard.press('Enter'); + + // The row leaves at once; Undo takes the focus the row had. + await expect(rows).toHaveCount(1); + const undo = page.locator( + 'mat-snack-bar-container.app-toast .source-removal-toast__undo' + ); + await expect(undo).toBeFocused(); + await page.keyboard.press('Enter'); + + await expect(rows).toHaveCount(2); + expect( + await rows.locator('.source-row__name').allTextContents() + ).toEqual(before); + await expect( + page.locator('mat-snack-bar-container.app-toast') + ).toHaveCount(0); + } finally { + await closeElectronApp(app); + } + }); }); diff --git a/apps/electron-backend-e2e/src/sources.e2e.ts b/apps/electron-backend-e2e/src/sources.e2e.ts index 780daafe5..072585c71 100644 --- a/apps/electron-backend-e2e/src/sources.e2e.ts +++ b/apps/electron-backend-e2e/src/sources.e2e.ts @@ -785,11 +785,20 @@ https://streams.example.test/original-url.m3u8 try { await dropM3uPlaylistOntoWorkspace(app.mainWindow, localFilePath); await waitForM3uCatalog(app.mainWindow); + // Refresh for the active source lives in its switcher row menu. + await app.mainWindow.getByTestId('playlist-switcher-trigger').click(); + const currentRow = app.mainWindow.locator( + 'app-playlist-switcher-panel [role="option"][aria-current="true"]' + ); + await currentRow.locator('.source-row__more').click(); await expect( - app.mainWindow.locator( - 'app-playlist-switcher .trigger-refresh-button' - ) + app.mainWindow.getByRole('menuitem', { name: 'Refresh playlist' }) ).toBeVisible(); + await app.mainWindow.keyboard.press('Escape'); + await app.mainWindow.keyboard.press('Escape'); + await expect( + app.mainWindow.locator('app-playlist-switcher-panel') + ).toHaveCount(0); await goToDashboard(app.mainWindow); const sourceCard = app.mainWindow diff --git a/apps/web-e2e/src/player-settings-panel.fixture.ts b/apps/web-e2e/src/player-settings-panel.fixture.ts index f83995bba..898268107 100644 --- a/apps/web-e2e/src/player-settings-panel.fixture.ts +++ b/apps/web-e2e/src/player-settings-panel.fixture.ts @@ -80,7 +80,7 @@ export async function importPlaylist(page: Page): Promise { .getByRole('button', { name: 'Add playlist', exact: true }) .click(), ]); - await expect(page.getByText('1 channels')).toBeVisible(); + await expect(page.getByText('1 channel', { exact: false })).toBeVisible(); return page.url(); } diff --git a/apps/web-e2e/src/stalker.e2e.ts b/apps/web-e2e/src/stalker.e2e.ts index 1c744ef63..525fe7935 100644 --- a/apps/web-e2e/src/stalker.e2e.ts +++ b/apps/web-e2e/src/stalker.e2e.ts @@ -182,12 +182,15 @@ test('@stalker account info dialog shows subscription facts from the portal', as }) => { 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(); + // Open the header playlist switcher and the "Account info" entry in the + // active stalker portal's row menu. + await page.locator('[data-test-id="playlist-switcher-trigger"]').click(); await page - .locator('.context-action-item', { hasText: 'Account info' }) + .locator( + 'app-playlist-switcher-panel [role="option"][aria-current="true"] .source-row__more' + ) .click(); + await page.getByRole('menuitem', { name: 'Account info' }).click(); const dialog = page.locator('mat-dialog-container'); await expect(dialog).toBeVisible(); diff --git a/apps/web-e2e/src/xtream.e2e.ts b/apps/web-e2e/src/xtream.e2e.ts index 193eba2d8..60bc3a554 100644 --- a/apps/web-e2e/src/xtream.e2e.ts +++ b/apps/web-e2e/src/xtream.e2e.ts @@ -61,18 +61,15 @@ async function openPlaylistDetailsDialog(page: Page, title: string) { await playlistSettingsButton.click(); } else { await page - .locator('app-playlist-switcher .playlist-switcher-trigger') + .locator('[data-test-id="playlist-switcher-trigger"]') .click(); - const switcherMenu = page.getByRole('menu').filter({ hasText: title }); - const sourceRow = switcherMenu - .locator('.playlist-item') + const sourceRow = page + .locator('app-playlist-switcher-panel [role="option"]') .filter({ hasText: title }) .first(); await expect(sourceRow).toBeVisible(); - await sourceRow - .getByRole('button', { name: 'Playlist actions' }) - .click(); + await sourceRow.locator('.source-row__more').click(); await page.getByRole('menuitem', { name: 'Playlist info' }).click(); } diff --git a/apps/web/src/assets/i18n/ar.json b/apps/web/src/assets/i18n/ar.json index 2887ef30f..cc7332e25 100644 --- a/apps/web/src/assets/i18n/ar.json +++ b/apps/web/src/assets/i18n/ar.json @@ -1917,7 +1917,8 @@ "EXPIRED": "منتهي", "EDIT": "تعديل…", "COPY_URL": "نسخ الرابط", - "OPEN": "فتح" + "OPEN": "فتح", + "CHANNELS_ONE": "قناة واحدة" }, "STATUS": "الحالة", "STATUS_ANY": "أي حالة", @@ -1927,7 +1928,8 @@ "RESET_FILTERS": "إعادة تعيين عوامل التصفية", "REMOVED": "تمت إزالة «{{name}}»", "REMOVED_SWITCHED": "تمت إزالة «{{name}}»، والانتقال إلى «{{next}}»", - "REMOVE_FAILED": "تعذّرت إزالة «{{name}}»" + "REMOVE_FAILED": "تعذّرت إزالة «{{name}}»", + "ADD_SOURCE": "إضافة مصدر" }, "CONTEXT": { "CATEGORIES": "الفئات", @@ -2020,6 +2022,15 @@ "NO_PLAYLISTS_BODY": "أضف قائمة تشغيل وسيظهر سجل المشاهدة هنا.", "NO_ITEMS_TITLE": "لا توجد مشاهدات بعد", "NO_ITEMS_BODY": "ابدأ بمشاهدة أي محتوى من مكتبتك وسيظهر هنا." + }, + "SWITCHER": { + "SEARCH": "ابحث عن قائمة تشغيل…", + "RECENT": "الأخيرة", + "NO_MATCHES": "لم يُعثر على شيء", + "ALL_SOURCES": "كل المصادر", + "HINT_SELECT": "تحديد", + "HINT_OPEN": "فتح", + "HINT_CLOSE": "إغلاق" } }, "LOADING": "جارٍ التحميل", diff --git a/apps/web/src/assets/i18n/ary.json b/apps/web/src/assets/i18n/ary.json index 98290bda0..dbd546de3 100644 --- a/apps/web/src/assets/i18n/ary.json +++ b/apps/web/src/assets/i18n/ary.json @@ -1917,7 +1917,8 @@ "EXPIRED": "سالات", "EDIT": "بدّل…", "COPY_URL": "نسخ الرابط", - "OPEN": "حلّ" + "OPEN": "حلّ", + "CHANNELS_ONE": "قناة وحدة" }, "STATUS": "الحالة", "STATUS_ANY": "أي حالة", @@ -1927,7 +1928,8 @@ "RESET_FILTERS": "رجّع الفلاتر", "REMOVED": "تحيّدات «{{name}}»", "REMOVED_SWITCHED": "تحيّدات «{{name}}»، دوّزنا لـ «{{next}}»", - "REMOVE_FAILED": "ما قدرناش نحيّدو «{{name}}»" + "REMOVE_FAILED": "ما قدرناش نحيّدو «{{name}}»", + "ADD_SOURCE": "زيد مصدر" }, "CONTEXT": { "CATEGORIES": "الفئات", @@ -2020,6 +2022,15 @@ "NO_PLAYLISTS_BODY": "زيد قائمة تشغيل وسجل التفرج ديالك غادي يبان هنا.", "NO_ITEMS_TITLE": "ما تشاف حتى شي حاجة دابا", "NO_ITEMS_BODY": "بدا تفرج فشي حاجة من المكتبة ديالك وغادي تبان هنا." + }, + "SWITCHER": { + "SEARCH": "قلّب على ليستة…", + "RECENT": "الجداد", + "NO_MATCHES": "ما لقينا والو", + "ALL_SOURCES": "كاع المصادر", + "HINT_SELECT": "ختار", + "HINT_OPEN": "حلّ", + "HINT_CLOSE": "سدّ" } }, "LOADING": "كيتشارجا", diff --git a/apps/web/src/assets/i18n/by.json b/apps/web/src/assets/i18n/by.json index 54f4f8715..3016681bf 100644 --- a/apps/web/src/assets/i18n/by.json +++ b/apps/web/src/assets/i18n/by.json @@ -1917,7 +1917,8 @@ "EXPIRED": "Скончыўся", "EDIT": "Змяніць…", "COPY_URL": "Скапіяваць URL", - "OPEN": "Адкрыць" + "OPEN": "Адкрыць", + "CHANNELS_ONE": "1 канал" }, "STATUS": "Статус", "STATUS_ANY": "Любы", @@ -1927,7 +1928,8 @@ "RESET_FILTERS": "Скінуць фільтры", "REMOVED": "Выдалены «{{name}}»", "REMOVED_SWITCHED": "Выдалены «{{name}}», адкрыты «{{next}}»", - "REMOVE_FAILED": "Не ўдалося выдаліць «{{name}}»" + "REMOVE_FAILED": "Не ўдалося выдаліць «{{name}}»", + "ADD_SOURCE": "Дадаць крыніцу" }, "CONTEXT": { "CATEGORIES": "Катэгорыі", @@ -2020,6 +2022,15 @@ "NO_PLAYLISTS_BODY": "Дадайце плэйліст, і гісторыя прагляду з’явіцца тут.", "NO_ITEMS_TITLE": "Праглядаў пакуль няма", "NO_ITEMS_BODY": "Пачніце глядзець што-небудзь з бібліятэкі, і гэта з’явіцца тут." + }, + "SWITCHER": { + "SEARCH": "Знайсці плэйліст…", + "RECENT": "Нядаўнія", + "NO_MATCHES": "Нічога не знойдзена", + "ALL_SOURCES": "Усе крыніцы", + "HINT_SELECT": "выбар", + "HINT_OPEN": "адкрыць", + "HINT_CLOSE": "закрыць" } }, "LOADING": "Загрузка", diff --git a/apps/web/src/assets/i18n/de.json b/apps/web/src/assets/i18n/de.json index c57bcfba6..e060091e8 100644 --- a/apps/web/src/assets/i18n/de.json +++ b/apps/web/src/assets/i18n/de.json @@ -1917,7 +1917,8 @@ "EXPIRED": "Abgelaufen", "EDIT": "Bearbeiten…", "COPY_URL": "URL kopieren", - "OPEN": "Öffnen" + "OPEN": "Öffnen", + "CHANNELS_ONE": "1 Sender" }, "STATUS": "Status (Erreichbarkeit)", "STATUS_ANY": "Alle", @@ -1927,7 +1928,8 @@ "RESET_FILTERS": "Filter zurücksetzen", "REMOVED": "„{{name}}“ entfernt", "REMOVED_SWITCHED": "„{{name}}“ entfernt, zu „{{next}}“ gewechselt", - "REMOVE_FAILED": "„{{name}}“ konnte nicht entfernt werden" + "REMOVE_FAILED": "„{{name}}“ konnte nicht entfernt werden", + "ADD_SOURCE": "Quelle hinzufügen" }, "CONTEXT": { "CATEGORIES": "Kategorien", @@ -2020,6 +2022,15 @@ "NO_PLAYLISTS_BODY": "Füge eine Wiedergabeliste hinzu, und dein Wiedergabeverlauf erscheint hier.", "NO_ITEMS_TITLE": "Noch nichts angesehen", "NO_ITEMS_BODY": "Beginne, etwas aus deiner Bibliothek anzusehen, dann erscheint es hier." + }, + "SWITCHER": { + "SEARCH": "Playlist suchen…", + "RECENT": "Zuletzt geöffnet", + "NO_MATCHES": "Nichts gefunden", + "ALL_SOURCES": "Alle Quellen", + "HINT_SELECT": "auswählen", + "HINT_OPEN": "öffnen", + "HINT_CLOSE": "schließen" } }, "LOADING": "Laden", diff --git a/apps/web/src/assets/i18n/el.json b/apps/web/src/assets/i18n/el.json index ef1d6fb25..c8d45264e 100644 --- a/apps/web/src/assets/i18n/el.json +++ b/apps/web/src/assets/i18n/el.json @@ -1917,7 +1917,8 @@ "EXPIRED": "Έληξε", "EDIT": "Επεξεργασία…", "COPY_URL": "Αντιγραφή URL", - "OPEN": "Άνοιγμα" + "OPEN": "Άνοιγμα", + "CHANNELS_ONE": "1 κανάλι" }, "STATUS": "Κατάσταση", "STATUS_ANY": "Οποιαδήποτε", @@ -1927,7 +1928,8 @@ "RESET_FILTERS": "Επαναφορά φίλτρων", "REMOVED": "Καταργήθηκε το «{{name}}»", "REMOVED_SWITCHED": "Καταργήθηκε το «{{name}}», μετάβαση στο «{{next}}»", - "REMOVE_FAILED": "Δεν ήταν δυνατή η κατάργηση του «{{name}}»" + "REMOVE_FAILED": "Δεν ήταν δυνατή η κατάργηση του «{{name}}»", + "ADD_SOURCE": "Προσθήκη πηγής" }, "CONTEXT": { "CATEGORIES": "Κατηγορίες", @@ -2020,6 +2022,15 @@ "NO_PLAYLISTS_BODY": "Προσθέστε μια λίστα αναπαραγωγής και το ιστορικό προβολής σας θα εμφανιστεί εδώ.", "NO_ITEMS_TITLE": "Δεν έχει παρακολουθηθεί τίποτα ακόμη", "NO_ITEMS_BODY": "Ξεκινήστε να παρακολουθείτε οτιδήποτε από τη βιβλιοθήκη σας και θα εμφανιστεί εδώ." + }, + "SWITCHER": { + "SEARCH": "Εύρεση λίστας…", + "RECENT": "Πρόσφατα", + "NO_MATCHES": "Δεν βρέθηκε τίποτα", + "ALL_SOURCES": "Όλες οι πηγές", + "HINT_SELECT": "επιλογή", + "HINT_OPEN": "άνοιγμα", + "HINT_CLOSE": "κλείσιμο" } }, "LOADING": "Φόρτωση", diff --git a/apps/web/src/assets/i18n/en.json b/apps/web/src/assets/i18n/en.json index 29e2eba72..b38fd58b7 100644 --- a/apps/web/src/assets/i18n/en.json +++ b/apps/web/src/assets/i18n/en.json @@ -1917,7 +1917,8 @@ "EXPIRED": "Expired", "EDIT": "Edit…", "COPY_URL": "Copy URL", - "OPEN": "Open" + "OPEN": "Open", + "CHANNELS_ONE": "1 channel" }, "STATUS": "Status", "STATUS_ANY": "Any", @@ -1927,7 +1928,8 @@ "RESET_FILTERS": "Reset filters", "REMOVED": "Removed “{{name}}”", "REMOVED_SWITCHED": "Removed “{{name}}”, switched to “{{next}}”", - "REMOVE_FAILED": "Could not remove “{{name}}”" + "REMOVE_FAILED": "Could not remove “{{name}}”", + "ADD_SOURCE": "Add a source" }, "CONTEXT": { "CATEGORIES": "Categories", @@ -2020,6 +2022,15 @@ "NO_PLAYLISTS_BODY": "Add a playlist and your watching history will appear here.", "NO_ITEMS_TITLE": "Nothing watched yet", "NO_ITEMS_BODY": "Start watching anything from your library and it will show up here." + }, + "SWITCHER": { + "SEARCH": "Find a playlist…", + "RECENT": "Recent", + "NO_MATCHES": "Nothing found", + "ALL_SOURCES": "All sources", + "HINT_SELECT": "select", + "HINT_OPEN": "open", + "HINT_CLOSE": "close" } }, "LOADING": "Loading", diff --git a/apps/web/src/assets/i18n/es.json b/apps/web/src/assets/i18n/es.json index fe584ffbe..49cbc8d48 100644 --- a/apps/web/src/assets/i18n/es.json +++ b/apps/web/src/assets/i18n/es.json @@ -1917,7 +1917,8 @@ "EXPIRED": "Caducada", "EDIT": "Editar…", "COPY_URL": "Copiar URL", - "OPEN": "Abrir" + "OPEN": "Abrir", + "CHANNELS_ONE": "1 canal" }, "STATUS": "Estado", "STATUS_ANY": "Cualquiera", @@ -1927,7 +1928,8 @@ "RESET_FILTERS": "Restablecer filtros", "REMOVED": "Se eliminó «{{name}}»", "REMOVED_SWITCHED": "Se eliminó «{{name}}» y se cambió a «{{next}}»", - "REMOVE_FAILED": "No se pudo eliminar «{{name}}»" + "REMOVE_FAILED": "No se pudo eliminar «{{name}}»", + "ADD_SOURCE": "Añadir una fuente" }, "CONTEXT": { "CATEGORIES": "Categorías", @@ -2020,6 +2022,15 @@ "NO_PLAYLISTS_BODY": "Añade una lista de reproducción y tu historial de visualización aparecerá aquí.", "NO_ITEMS_TITLE": "Aún no has visto nada", "NO_ITEMS_BODY": "Empieza a ver cualquier contenido de tu biblioteca y aparecerá aquí." + }, + "SWITCHER": { + "SEARCH": "Buscar una lista…", + "RECENT": "Recientes", + "NO_MATCHES": "No se encontró nada", + "ALL_SOURCES": "Todas las fuentes", + "HINT_SELECT": "elegir", + "HINT_OPEN": "abrir", + "HINT_CLOSE": "cerrar" } }, "LOADING": "Cargando", diff --git a/apps/web/src/assets/i18n/fr.json b/apps/web/src/assets/i18n/fr.json index 692fb113b..6fc49172b 100644 --- a/apps/web/src/assets/i18n/fr.json +++ b/apps/web/src/assets/i18n/fr.json @@ -1917,7 +1917,8 @@ "EXPIRED": "Expiré", "EDIT": "Modifier…", "COPY_URL": "Copier l’URL", - "OPEN": "Ouvrir" + "OPEN": "Ouvrir", + "CHANNELS_ONE": "1 chaîne" }, "STATUS": "État", "STATUS_ANY": "Tous", @@ -1927,7 +1928,8 @@ "RESET_FILTERS": "Réinitialiser les filtres", "REMOVED": "« {{name}} » supprimée", "REMOVED_SWITCHED": "« {{name}} » supprimée, passage à « {{next}} »", - "REMOVE_FAILED": "Impossible de supprimer « {{name}} »" + "REMOVE_FAILED": "Impossible de supprimer « {{name}} »", + "ADD_SOURCE": "Ajouter une source" }, "CONTEXT": { "CATEGORIES": "Catégories", @@ -2020,6 +2022,15 @@ "NO_PLAYLISTS_BODY": "Ajoutez une playlist et votre historique de visionnage apparaîtra ici.", "NO_ITEMS_TITLE": "Rien regardé pour le moment", "NO_ITEMS_BODY": "Commencez à regarder un contenu de votre bibliothèque et il apparaîtra ici." + }, + "SWITCHER": { + "SEARCH": "Trouver une playlist…", + "RECENT": "Récentes", + "NO_MATCHES": "Aucun résultat", + "ALL_SOURCES": "Toutes les sources", + "HINT_SELECT": "choisir", + "HINT_OPEN": "ouvrir", + "HINT_CLOSE": "fermer" } }, "LOADING": "Chargement", diff --git a/apps/web/src/assets/i18n/hu.json b/apps/web/src/assets/i18n/hu.json index 067a95577..947b9158f 100644 --- a/apps/web/src/assets/i18n/hu.json +++ b/apps/web/src/assets/i18n/hu.json @@ -1917,7 +1917,8 @@ "EXPIRED": "Lejárt", "EDIT": "Szerkesztés…", "COPY_URL": "URL másolása", - "OPEN": "Megnyitás" + "OPEN": "Megnyitás", + "CHANNELS_ONE": "1 csatorna" }, "STATUS": "Állapot", "STATUS_ANY": "Bármelyik", @@ -1927,7 +1928,8 @@ "RESET_FILTERS": "Szűrők törlése", "REMOVED": "„{{name}}” eltávolítva", "REMOVED_SWITCHED": "„{{name}}” eltávolítva, váltás erre: „{{next}}”", - "REMOVE_FAILED": "„{{name}}” nem távolítható el" + "REMOVE_FAILED": "„{{name}}” nem távolítható el", + "ADD_SOURCE": "Forrás hozzáadása" }, "CONTEXT": { "CATEGORIES": "Kategóriák", @@ -2020,6 +2022,15 @@ "NO_PLAYLISTS_BODY": "Adjon hozzá egy lejátszási listát, és megtekintési előzményei itt fognak megjelenni.", "NO_ITEMS_TITLE": "Még nincs megtekintett tartalom", "NO_ITEMS_BODY": "Indítson el bármit a médiatárából, és itt meg fog jelenni." + }, + "SWITCHER": { + "SEARCH": "Lejátszási lista keresése…", + "RECENT": "Legutóbbiak", + "NO_MATCHES": "Nincs találat", + "ALL_SOURCES": "Összes forrás", + "HINT_SELECT": "kijelölés", + "HINT_OPEN": "megnyitás", + "HINT_CLOSE": "bezárás" } }, "LOADING": "Betöltés", diff --git a/apps/web/src/assets/i18n/it.json b/apps/web/src/assets/i18n/it.json index cb4459efe..3dbf095a7 100644 --- a/apps/web/src/assets/i18n/it.json +++ b/apps/web/src/assets/i18n/it.json @@ -1917,7 +1917,8 @@ "EXPIRED": "Scaduto", "EDIT": "Modifica…", "COPY_URL": "Copia URL", - "OPEN": "Apri" + "OPEN": "Apri", + "CHANNELS_ONE": "1 canale" }, "STATUS": "Stato", "STATUS_ANY": "Qualsiasi", @@ -1927,7 +1928,8 @@ "RESET_FILTERS": "Reimposta filtri", "REMOVED": "«{{name}}» rimossa", "REMOVED_SWITCHED": "«{{name}}» rimossa, passaggio a «{{next}}»", - "REMOVE_FAILED": "Impossibile rimuovere «{{name}}»" + "REMOVE_FAILED": "Impossibile rimuovere «{{name}}»", + "ADD_SOURCE": "Aggiungi una fonte" }, "CONTEXT": { "CATEGORIES": "Categorie", @@ -2020,6 +2022,15 @@ "NO_PLAYLISTS_BODY": "Aggiungi una playlist e la tua cronologia di visione apparirà qui.", "NO_ITEMS_TITLE": "Ancora nessun contenuto guardato", "NO_ITEMS_BODY": "Inizia a guardare qualsiasi contenuto della tua libreria e apparirà qui." + }, + "SWITCHER": { + "SEARCH": "Trova una playlist…", + "RECENT": "Recenti", + "NO_MATCHES": "Nessun risultato", + "ALL_SOURCES": "Tutte le fonti", + "HINT_SELECT": "scegli", + "HINT_OPEN": "apri", + "HINT_CLOSE": "chiudi" } }, "LOADING": "Caricamento", diff --git a/apps/web/src/assets/i18n/ja.json b/apps/web/src/assets/i18n/ja.json index 2f7452d78..de17d79ef 100644 --- a/apps/web/src/assets/i18n/ja.json +++ b/apps/web/src/assets/i18n/ja.json @@ -1917,7 +1917,8 @@ "EXPIRED": "期限切れ", "EDIT": "編集…", "COPY_URL": "URLをコピー", - "OPEN": "開く" + "OPEN": "開く", + "CHANNELS_ONE": "1 チャンネル" }, "STATUS": "状態", "STATUS_ANY": "すべて", @@ -1927,7 +1928,8 @@ "RESET_FILTERS": "フィルターをリセット", "REMOVED": "「{{name}}」を削除しました", "REMOVED_SWITCHED": "「{{name}}」を削除し、「{{next}}」に切り替えました", - "REMOVE_FAILED": "「{{name}}」を削除できませんでした" + "REMOVE_FAILED": "「{{name}}」を削除できませんでした", + "ADD_SOURCE": "ソースを追加" }, "CONTEXT": { "CATEGORIES": "カテゴリー", @@ -2020,6 +2022,15 @@ "NO_PLAYLISTS_BODY": "プレイリストを追加すると、視聴履歴がここに表示されます。", "NO_ITEMS_TITLE": "視聴履歴はまだありません", "NO_ITEMS_BODY": "ライブラリ内のコンテンツを視聴し始めると、ここに表示されます。" + }, + "SWITCHER": { + "SEARCH": "プレイリストを検索…", + "RECENT": "最近", + "NO_MATCHES": "見つかりませんでした", + "ALL_SOURCES": "すべてのソース", + "HINT_SELECT": "選択", + "HINT_OPEN": "開く", + "HINT_CLOSE": "閉じる" } }, "LOADING": "読み込み中", diff --git a/apps/web/src/assets/i18n/ko.json b/apps/web/src/assets/i18n/ko.json index 984c0e1b8..bfece3223 100644 --- a/apps/web/src/assets/i18n/ko.json +++ b/apps/web/src/assets/i18n/ko.json @@ -1917,7 +1917,8 @@ "EXPIRED": "만료됨", "EDIT": "편집…", "COPY_URL": "URL 복사", - "OPEN": "열기" + "OPEN": "열기", + "CHANNELS_ONE": "채널 1개" }, "STATUS": "상태", "STATUS_ANY": "전체", @@ -1927,7 +1928,8 @@ "RESET_FILTERS": "필터 초기화", "REMOVED": "'{{name}}' 삭제됨", "REMOVED_SWITCHED": "'{{name}}' 삭제됨, '{{next}}'(으)로 전환됨", - "REMOVE_FAILED": "'{{name}}'을(를) 삭제할 수 없습니다" + "REMOVE_FAILED": "'{{name}}'을(를) 삭제할 수 없습니다", + "ADD_SOURCE": "소스 추가" }, "CONTEXT": { "CATEGORIES": "카테고리", @@ -2020,6 +2022,15 @@ "NO_PLAYLISTS_BODY": "플레이리스트를 추가하면 시청 기록이 여기에 표시됩니다.", "NO_ITEMS_TITLE": "아직 시청 기록이 없습니다", "NO_ITEMS_BODY": "라이브러리의 콘텐츠를 시청하기 시작하면 여기에 표시됩니다." + }, + "SWITCHER": { + "SEARCH": "재생목록 찾기…", + "RECENT": "최근", + "NO_MATCHES": "찾은 항목 없음", + "ALL_SOURCES": "모든 소스", + "HINT_SELECT": "선택", + "HINT_OPEN": "열기", + "HINT_CLOSE": "닫기" } }, "LOADING": "로드 중", diff --git a/apps/web/src/assets/i18n/nl.json b/apps/web/src/assets/i18n/nl.json index dec55ffbb..5d59b2d9e 100644 --- a/apps/web/src/assets/i18n/nl.json +++ b/apps/web/src/assets/i18n/nl.json @@ -1917,7 +1917,8 @@ "EXPIRED": "Verlopen", "EDIT": "Bewerken…", "COPY_URL": "URL kopiëren", - "OPEN": "Openen" + "OPEN": "Openen", + "CHANNELS_ONE": "1 zender" }, "STATUS": "Bereikbaarheid", "STATUS_ANY": "Alle", @@ -1927,7 +1928,8 @@ "RESET_FILTERS": "Filters wissen", "REMOVED": "‘{{name}}’ verwijderd", "REMOVED_SWITCHED": "‘{{name}}’ verwijderd, overgeschakeld naar ‘{{next}}’", - "REMOVE_FAILED": "‘{{name}}’ kon niet worden verwijderd" + "REMOVE_FAILED": "‘{{name}}’ kon niet worden verwijderd", + "ADD_SOURCE": "Bron toevoegen" }, "CONTEXT": { "CATEGORIES": "Categorieën", @@ -2020,6 +2022,15 @@ "NO_PLAYLISTS_BODY": "Voeg een afspeellijst toe en uw kijkgeschiedenis verschijnt hier.", "NO_ITEMS_TITLE": "Nog niets bekeken", "NO_ITEMS_BODY": "Begin iets uit uw bibliotheek te kijken en het verschijnt hier." + }, + "SWITCHER": { + "SEARCH": "Afspeellijst zoeken…", + "RECENT": "Onlangs geopend", + "NO_MATCHES": "Niets gevonden", + "ALL_SOURCES": "Alle bronnen", + "HINT_SELECT": "kiezen", + "HINT_OPEN": "openen", + "HINT_CLOSE": "sluiten" } }, "LOADING": "Laden", diff --git a/apps/web/src/assets/i18n/pl.json b/apps/web/src/assets/i18n/pl.json index a1a9f42f9..56fa6b5c6 100644 --- a/apps/web/src/assets/i18n/pl.json +++ b/apps/web/src/assets/i18n/pl.json @@ -1917,7 +1917,8 @@ "EXPIRED": "Wygasło", "EDIT": "Edytuj…", "COPY_URL": "Kopiuj URL", - "OPEN": "Otwórz" + "OPEN": "Otwórz", + "CHANNELS_ONE": "1 kanał" }, "STATUS": "Stan", "STATUS_ANY": "Dowolny", @@ -1927,7 +1928,8 @@ "RESET_FILTERS": "Wyczyść filtry", "REMOVED": "Usunięto „{{name}}”", "REMOVED_SWITCHED": "Usunięto „{{name}}”, przełączono na „{{next}}”", - "REMOVE_FAILED": "Nie udało się usunąć „{{name}}”" + "REMOVE_FAILED": "Nie udało się usunąć „{{name}}”", + "ADD_SOURCE": "Dodaj źródło" }, "CONTEXT": { "CATEGORIES": "Kategorie", @@ -2020,6 +2022,15 @@ "NO_PLAYLISTS_BODY": "Dodaj listę odtwarzania, a historia oglądania pojawi się tutaj.", "NO_ITEMS_TITLE": "Jeszcze nic nie oglądano", "NO_ITEMS_BODY": "Zacznij oglądać cokolwiek ze swojej biblioteki, a pojawi się tutaj." + }, + "SWITCHER": { + "SEARCH": "Znajdź playlistę…", + "RECENT": "Ostatnie", + "NO_MATCHES": "Nic nie znaleziono", + "ALL_SOURCES": "Wszystkie źródła", + "HINT_SELECT": "wybór", + "HINT_OPEN": "otwórz", + "HINT_CLOSE": "zamknij" } }, "LOADING": "Ładowanie", diff --git a/apps/web/src/assets/i18n/pt.json b/apps/web/src/assets/i18n/pt.json index 11a55511f..6baec6229 100644 --- a/apps/web/src/assets/i18n/pt.json +++ b/apps/web/src/assets/i18n/pt.json @@ -1917,7 +1917,8 @@ "EXPIRED": "Expirada", "EDIT": "Editar…", "COPY_URL": "Copiar URL", - "OPEN": "Abrir" + "OPEN": "Abrir", + "CHANNELS_ONE": "1 canal" }, "STATUS": "Estado", "STATUS_ANY": "Qualquer", @@ -1927,7 +1928,8 @@ "RESET_FILTERS": "Repor filtros", "REMOVED": "«{{name}}» removida", "REMOVED_SWITCHED": "«{{name}}» removida, mudou para «{{next}}»", - "REMOVE_FAILED": "Não foi possível remover «{{name}}»" + "REMOVE_FAILED": "Não foi possível remover «{{name}}»", + "ADD_SOURCE": "Adicionar uma fonte" }, "CONTEXT": { "CATEGORIES": "Categorias", @@ -2020,6 +2022,15 @@ "NO_PLAYLISTS_BODY": "Adicione uma playlist e seu histórico de visualização aparecerá aqui.", "NO_ITEMS_TITLE": "Nada assistido ainda", "NO_ITEMS_BODY": "Comece a assistir qualquer item da sua biblioteca e ele aparecerá aqui." + }, + "SWITCHER": { + "SEARCH": "Procurar uma lista…", + "RECENT": "Recentes", + "NO_MATCHES": "Nada encontrado", + "ALL_SOURCES": "Todas as fontes", + "HINT_SELECT": "escolher", + "HINT_OPEN": "abrir", + "HINT_CLOSE": "fechar" } }, "LOADING": "A carregar", diff --git a/apps/web/src/assets/i18n/ru.json b/apps/web/src/assets/i18n/ru.json index 24de91446..9bfdffe20 100644 --- a/apps/web/src/assets/i18n/ru.json +++ b/apps/web/src/assets/i18n/ru.json @@ -1917,7 +1917,8 @@ "EXPIRED": "Истёк", "EDIT": "Изменить…", "COPY_URL": "Копировать URL", - "OPEN": "Открыть" + "OPEN": "Открыть", + "CHANNELS_ONE": "1 канал" }, "STATUS": "Статус", "STATUS_ANY": "Любой", @@ -1927,7 +1928,8 @@ "RESET_FILTERS": "Сбросить фильтры", "REMOVED": "Удалён «{{name}}»", "REMOVED_SWITCHED": "Удалён «{{name}}», открыт «{{next}}»", - "REMOVE_FAILED": "Не удалось удалить «{{name}}»" + "REMOVE_FAILED": "Не удалось удалить «{{name}}»", + "ADD_SOURCE": "Добавить источник" }, "CONTEXT": { "CATEGORIES": "Категории", @@ -2020,6 +2022,15 @@ "NO_PLAYLISTS_BODY": "Добавьте плейлист, и история просмотра появится здесь.", "NO_ITEMS_TITLE": "Пока ничего не просмотрено", "NO_ITEMS_BODY": "Начните смотреть любой контент из библиотеки, и он появится здесь." + }, + "SWITCHER": { + "SEARCH": "Найти плейлист…", + "RECENT": "Недавние", + "NO_MATCHES": "Ничего не найдено", + "ALL_SOURCES": "Все источники", + "HINT_SELECT": "выбор", + "HINT_OPEN": "открыть", + "HINT_CLOSE": "закрыть" } }, "LOADING": "Загрузка", diff --git a/apps/web/src/assets/i18n/tr.json b/apps/web/src/assets/i18n/tr.json index 143b86555..39b58ef6a 100644 --- a/apps/web/src/assets/i18n/tr.json +++ b/apps/web/src/assets/i18n/tr.json @@ -1917,7 +1917,8 @@ "EXPIRED": "Süresi doldu", "EDIT": "Düzenle…", "COPY_URL": "URL'yi kopyala", - "OPEN": "Aç" + "OPEN": "Aç", + "CHANNELS_ONE": "1 kanal" }, "STATUS": "Durum", "STATUS_ANY": "Tümü", @@ -1927,7 +1928,8 @@ "RESET_FILTERS": "Filtreleri sıfırla", "REMOVED": "“{{name}}” kaldırıldı", "REMOVED_SWITCHED": "“{{name}}” kaldırıldı, “{{next}}” açıldı", - "REMOVE_FAILED": "“{{name}}” kaldırılamadı" + "REMOVE_FAILED": "“{{name}}” kaldırılamadı", + "ADD_SOURCE": "Kaynak ekle" }, "CONTEXT": { "CATEGORIES": "Kategoriler", @@ -2020,6 +2022,15 @@ "NO_PLAYLISTS_BODY": "Bir oynatma listesi ekleyin; izleme geçmişiniz burada görünecek.", "NO_ITEMS_TITLE": "Henüz hiçbir şey izlenmedi", "NO_ITEMS_BODY": "Kitaplığınızdaki herhangi bir şeyi izlemeye başlayın, burada görünecek." + }, + "SWITCHER": { + "SEARCH": "Oynatma listesi bul…", + "RECENT": "Son açılanlar", + "NO_MATCHES": "Hiçbir şey bulunamadı", + "ALL_SOURCES": "Tüm kaynaklar", + "HINT_SELECT": "seç", + "HINT_OPEN": "aç", + "HINT_CLOSE": "kapat" } }, "LOADING": "Yükleniyor", diff --git a/apps/web/src/assets/i18n/zh.json b/apps/web/src/assets/i18n/zh.json index e6341ab4b..3fcd1c670 100644 --- a/apps/web/src/assets/i18n/zh.json +++ b/apps/web/src/assets/i18n/zh.json @@ -1917,7 +1917,8 @@ "EXPIRED": "已过期", "EDIT": "编辑…", "COPY_URL": "复制 URL", - "OPEN": "打开" + "OPEN": "打开", + "CHANNELS_ONE": "1 个频道" }, "STATUS": "状态", "STATUS_ANY": "全部", @@ -1927,7 +1928,8 @@ "RESET_FILTERS": "重置筛选", "REMOVED": "已删除“{{name}}”", "REMOVED_SWITCHED": "已删除“{{name}}”,已切换到“{{next}}”", - "REMOVE_FAILED": "无法删除“{{name}}”" + "REMOVE_FAILED": "无法删除“{{name}}”", + "ADD_SOURCE": "添加来源" }, "CONTEXT": { "CATEGORIES": "分类", @@ -2020,6 +2022,15 @@ "NO_PLAYLISTS_BODY": "添加播放列表后,您的观看历史会显示在这里。", "NO_ITEMS_TITLE": "还没有观看记录", "NO_ITEMS_BODY": "开始观看媒体库中的任意内容后,它会显示在这里。" + }, + "SWITCHER": { + "SEARCH": "查找播放列表…", + "RECENT": "最近", + "NO_MATCHES": "未找到", + "ALL_SOURCES": "所有来源", + "HINT_SELECT": "选择", + "HINT_OPEN": "打开", + "HINT_CLOSE": "关闭" } }, "LOADING": "加载中", diff --git a/apps/web/src/assets/i18n/zhtw.json b/apps/web/src/assets/i18n/zhtw.json index 51a4bfd06..fffab07b1 100644 --- a/apps/web/src/assets/i18n/zhtw.json +++ b/apps/web/src/assets/i18n/zhtw.json @@ -1917,7 +1917,8 @@ "EXPIRED": "已過期", "EDIT": "編輯…", "COPY_URL": "複製 URL", - "OPEN": "開啟" + "OPEN": "開啟", + "CHANNELS_ONE": "1 個頻道" }, "STATUS": "狀態", "STATUS_ANY": "全部", @@ -1927,7 +1928,8 @@ "RESET_FILTERS": "重設篩選", "REMOVED": "已移除「{{name}}」", "REMOVED_SWITCHED": "已移除「{{name}}」,已切換到「{{next}}」", - "REMOVE_FAILED": "無法移除「{{name}}」" + "REMOVE_FAILED": "無法移除「{{name}}」", + "ADD_SOURCE": "新增來源" }, "CONTEXT": { "CATEGORIES": "類別", @@ -2020,6 +2022,15 @@ "NO_PLAYLISTS_BODY": "新增播放清單後,您的觀看歷史會顯示在這裡。", "NO_ITEMS_TITLE": "還沒有觀看記錄", "NO_ITEMS_BODY": "開始觀看媒體庫中的任意內容後,它會顯示在這裡。" + }, + "SWITCHER": { + "SEARCH": "尋找播放清單…", + "RECENT": "最近", + "NO_MATCHES": "找不到", + "ALL_SOURCES": "所有來源", + "HINT_SELECT": "選擇", + "HINT_OPEN": "開啟", + "HINT_CLOSE": "關閉" } }, "LOADING": "載入中", diff --git a/libs/playlist/shared/ui/src/lib/playlist-switcher/playlist-switcher-list.spec.ts b/libs/playlist/shared/ui/src/lib/playlist-switcher/playlist-switcher-list.spec.ts new file mode 100644 index 000000000..373acc4f8 --- /dev/null +++ b/libs/playlist/shared/ui/src/lib/playlist-switcher/playlist-switcher-list.spec.ts @@ -0,0 +1,104 @@ +import { PlaylistMeta } from '@iptvnator/shared/interfaces'; +import { + buildSwitcherGroups, + moveHighlight, + readSwitcherType, + SWITCHER_TYPE_STORAGE_KEY, + writeSwitcherType, +} from './playlist-switcher-list'; + +const source = (id: string, fields: Partial = {}) => + ({ + _id: id, + title: id, + count: 0, + importDate: '', + autoRefresh: false, + ...fields, + }) as PlaylistMeta; + +const many = [ + source('a', { url: 'http://a.example/list.m3u' }), + source('b', { + serverUrl: 'http://b.example', + username: 'u', + password: 'p', + }), + source('c', { macAddress: 'm', portalUrl: 'http://c.example/c/' }), + source('d'), + source('e'), + source('f'), +]; + +describe('buildSwitcherGroups', () => { + it('lists recent sources first, newest first, and all of them below', () => { + const groups = buildSwitcherGroups(many, ['c', 'gone', 'a'], 'all', ''); + expect(groups.recent.map((o) => o.playlist._id)).toEqual(['c', 'a']); + expect(groups.all).toHaveLength(6); + expect(groups.recent[0].id).not.toBe( + groups.all.find((o) => o.playlist._id === 'c')?.id + ); + }); + + it('hides Recent while searching and for a short list', () => { + expect(buildSwitcherGroups(many, ['a'], 'all', 'b').recent).toEqual([]); + expect( + buildSwitcherGroups(many.slice(0, 3), ['a'], 'all', '').recent + ).toEqual([]); + }); + + it('filters by type and by name or host', () => { + expect( + buildSwitcherGroups(many, [], 'xtream', '').all.map( + (o) => o.playlist._id + ) + ).toEqual(['b']); + expect( + buildSwitcherGroups(many, [], 'all', 'c.example').all.map( + (o) => o.playlist._id + ) + ).toEqual(['c']); + }); +}); + +describe('moveHighlight', () => { + it('wraps at both ends and starts from either end', () => { + expect(moveHighlight(-1, 1, 3)).toBe(0); + expect(moveHighlight(-1, -1, 3)).toBe(2); + expect(moveHighlight(2, 1, 3)).toBe(0); + expect(moveHighlight(0, -1, 3)).toBe(2); + expect(moveHighlight(1, 1, 0)).toBe(-1); + }); +}); + +describe('switcher type memory', () => { + beforeEach(() => localStorage.clear()); + + it('remembers the chosen type', () => { + writeSwitcherType('stalker'); + expect(readSwitcherType()).toBe('stalker'); + }); + + it('migrates the old checkboxes to one type or All', () => { + localStorage.setItem( + 'playlist-switcher:type-filters', + JSON.stringify({ m3u: false, stalker: false, xtream: true }) + ); + localStorage.setItem('playlist-switcher:search-query', 'old query'); + expect(readSwitcherType()).toBe('xtream'); + expect(localStorage.getItem(SWITCHER_TYPE_STORAGE_KEY)).toBe('xtream'); + expect( + localStorage.getItem('playlist-switcher:type-filters') + ).toBeNull(); + expect( + localStorage.getItem('playlist-switcher:search-query') + ).toBeNull(); + + localStorage.clear(); + localStorage.setItem( + 'playlist-switcher:type-filters', + JSON.stringify({ m3u: true, stalker: true, xtream: false }) + ); + expect(readSwitcherType()).toBe('all'); + }); +}); diff --git a/libs/playlist/shared/ui/src/lib/playlist-switcher/playlist-switcher-list.ts b/libs/playlist/shared/ui/src/lib/playlist-switcher/playlist-switcher-list.ts new file mode 100644 index 000000000..4970daaa3 --- /dev/null +++ b/libs/playlist/shared/ui/src/lib/playlist-switcher/playlist-switcher-list.ts @@ -0,0 +1,114 @@ +import { + matchesSourceQuery, + matchesSourceType, + SourcesTypeFilter, +} from '@iptvnator/playlist/shared/util'; +import { PlaylistMeta } from '@iptvnator/shared/interfaces'; + +export const SWITCHER_TYPE_STORAGE_KEY = 'playlist-switcher:type:v2'; +/** The three checkboxes the segmented type filter replaced. */ +const LEGACY_TYPE_STORAGE_KEY = 'playlist-switcher:type-filters'; +/** The search is no longer kept between openings. */ +const LEGACY_QUERY_STORAGE_KEY = 'playlist-switcher:search-query'; +const TYPES: readonly SourcesTypeFilter[] = ['all', 'm3u', 'xtream', 'stalker']; +/** Below this many sources the "Recent" group would repeat the whole list. */ +export const SWITCHER_RECENT_MIN_SOURCES = 6; + +/** + * The remembered type. The old checkbox set maps to the one type it left + * on, or to "All" when it left several (the only states it could save). + */ +export function readSwitcherType(): SourcesTypeFilter { + try { + const stored = localStorage.getItem(SWITCHER_TYPE_STORAGE_KEY); + if (TYPES.includes(stored as SourcesTypeFilter)) { + return stored as SourcesTypeFilter; + } + const legacy = JSON.parse( + localStorage.getItem(LEGACY_TYPE_STORAGE_KEY) ?? 'null' + ) as Record | null; + localStorage.removeItem(LEGACY_QUERY_STORAGE_KEY); + if (legacy && typeof legacy === 'object') { + const on = (['m3u', 'xtream', 'stalker'] as const).filter( + (type) => legacy[type] !== false + ); + localStorage.removeItem(LEGACY_TYPE_STORAGE_KEY); + const type = on.length === 1 ? on[0] : 'all'; + writeSwitcherType(type); + return type; + } + } catch { + // Fall through to the default. + } + return 'all'; +} + +export function writeSwitcherType(type: SourcesTypeFilter): void { + try { + localStorage.setItem(SWITCHER_TYPE_STORAGE_KEY, type); + } catch { + // The choice still applies while the app runs. + } +} + +export interface SwitcherOption { + /** Unique per group: a recent source is also listed under "All". */ + id: string; + group: 'recent' | 'all'; + playlist: PlaylistMeta; +} + +export interface SwitcherGroups { + recent: SwitcherOption[]; + all: SwitcherOption[]; +} + +/** + * The switcher's two groups: "Recent" (newest first, only without a search + * and with enough sources to be worth it) and "All" in the given order, + * both narrowed by type and search. + */ +export function buildSwitcherGroups( + ordered: readonly PlaylistMeta[], + recentIds: readonly string[], + type: SourcesTypeFilter, + query: string +): SwitcherGroups { + const visible = ordered.filter( + (playlist) => + matchesSourceType(playlist, type) && + matchesSourceQuery(playlist, query) + ); + const option = + (group: SwitcherOption['group']) => + (playlist: PlaylistMeta): SwitcherOption => ({ + id: `playlist-switcher-${group}-${playlist._id}`, + group, + playlist, + }); + const showRecent = + !query.trim() && ordered.length >= SWITCHER_RECENT_MIN_SOURCES; + const byId = new Map(visible.map((playlist) => [playlist._id, playlist])); + const recent = showRecent + ? recentIds + .map((id) => byId.get(id)) + .filter((playlist): playlist is PlaylistMeta => !!playlist) + .map(option('recent')) + : []; + return { recent, all: visible.map(option('all')) }; +} + +/** The next highlight for ↑/↓, wrapping at both ends. */ +export function moveHighlight( + current: number, + delta: 1 | -1, + count: number +): number { + if (count === 0) { + return -1; + } + if (current < 0) { + return delta > 0 ? 0 : count - 1; + } + return (current + delta + count) % count; +} diff --git a/libs/playlist/shared/ui/src/lib/playlist-switcher/playlist-switcher-panel.component.html b/libs/playlist/shared/ui/src/lib/playlist-switcher/playlist-switcher-panel.component.html new file mode 100644 index 000000000..adf30668a --- /dev/null +++ b/libs/playlist/shared/ui/src/lib/playlist-switcher/playlist-switcher-panel.component.html @@ -0,0 +1,167 @@ + + + + + diff --git a/libs/playlist/shared/ui/src/lib/playlist-switcher/playlist-switcher-panel.component.scss b/libs/playlist/shared/ui/src/lib/playlist-switcher/playlist-switcher-panel.component.scss new file mode 100644 index 000000000..4f0aa030d --- /dev/null +++ b/libs/playlist/shared/ui/src/lib/playlist-switcher/playlist-switcher-panel.component.scss @@ -0,0 +1,185 @@ +@use '../../../../../../ui/styles/app-controls' as controls; + +// 440px popover on the widget surface; only the list scrolls. +:host { + display: block; + width: min(440px, calc(100vw - 16px)); +} + +.switcher { + display: flex; + flex-direction: column; + max-height: var(--playlist-switcher-max-height, calc(100vh - 88px)); + overflow: hidden; + border: 1px solid var(--app-line-strong); + border-radius: 14px; + background: var(--app-widget-bg); + box-shadow: var(--app-popover-shadow); + color: var(--app-heading-color); +} + +.switcher__header { + display: flex; + align-items: center; + gap: 8px; + padding: 14px 14px 10px; +} + +.switcher__title { + margin: 0; + font-size: 15px; + font-weight: 600; + letter-spacing: -0.2px; +} + +.switcher__count { + font-size: 12px; + font-variant-numeric: tabular-nums; + color: var(--app-muted-color); +} + +.switcher__spacer { + flex: 1; +} + +.switcher__action { + @include controls.ghost-icon-button(32px, 18px); +} + +// The search field is one step darker than the popover. Its focus shows on +// the field (token border and halo), not as an outline on the input. +.switcher__search { + display: flex; + align-items: center; + gap: 9px; + height: 36px; + margin: 0 14px 10px; + padding: 0 10px; + box-sizing: border-box; + border: 1px solid var(--app-line-strong); + border-radius: 9px; + background: var(--app-content-bg); + color: var(--app-muted-color); + + &:focus-within { + border-color: var(--app-focus-ring); + box-shadow: 0 0 0 3px + color-mix(in srgb, var(--app-focus-ring) 18%, transparent); + } + + .mat-icon { + flex: none; + width: 15px; + height: 15px; + font-size: 15px; + } + + input { + flex: 1; + min-width: 0; + border: 0; + outline: none; + background: none; + color: var(--app-heading-color); + font: inherit; + font-size: 13px; + + &::placeholder { + color: var(--app-muted-color); + } + } + + kbd { + font-family: var(--app-font-mono); + font-size: 11px; + font-weight: 500; + } +} + +.switcher__types { + @include controls.segmented-control(30px); + align-self: flex-start; + margin: 0 14px 8px; +} + +.switcher__type-count { + margin-inline-start: 4px; + font-size: 11px; + color: var(--app-muted-color); +} + +.switcher__list { + // Positioned, so options' offsetTop is relative to the list it scrolls. + position: relative; + flex: 1 1 auto; + min-height: 0; + overflow-y: auto; + padding: 4px 8px 8px; + border-top: 1px solid var(--app-widget-header-border); + scrollbar-width: thin; + + app-source-row { + --source-row-base: var(--app-widget-bg); + } +} + +.switcher__group { + padding: 10px 8px 4px; + font-size: 11px; + font-weight: 600; + letter-spacing: 0.8px; + text-transform: uppercase; + color: var(--app-muted-color); +} + +.switcher__empty { + margin: 0; + padding: 20px 16px; + font-size: 13px; + color: var(--app-body-color); +} + +.switcher__footer { + display: flex; + align-items: center; + gap: 4px; + padding: 8px; + border-top: 1px solid var(--app-widget-header-border); + background: color-mix(in srgb, var(--app-shell-bg) 40%, transparent); +} + +.switcher__hints { + display: flex; + align-items: center; + gap: 6px; + padding: 0 6px; + font-family: var(--app-font-mono); + font-size: 11px; + font-weight: 400; + color: var(--app-muted-color); + + kbd { + margin-inline-start: 4px; + padding: 0 4px; + border: 1px solid var(--app-line-strong); + border-radius: 3px; + font: inherit; + font-weight: 500; + color: var(--app-body-color); + } + + kbd:first-child { + margin-inline-start: 0; + } +} + +.switcher__all { + @include controls.ghost-button(sm); +} + +// Phones: the hints need a keyboard; the link stays. +@media (max-width: 480px) { + .switcher__hints { + display: none; + } +} diff --git a/libs/playlist/shared/ui/src/lib/playlist-switcher/playlist-switcher-panel.component.spec.ts b/libs/playlist/shared/ui/src/lib/playlist-switcher/playlist-switcher-panel.component.spec.ts new file mode 100644 index 000000000..2aef11c86 --- /dev/null +++ b/libs/playlist/shared/ui/src/lib/playlist-switcher/playlist-switcher-panel.component.spec.ts @@ -0,0 +1,238 @@ +import { signal } from '@angular/core'; +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { MatDialog } from '@angular/material/dialog'; +import { NoopAnimationsModule } from '@angular/platform-browser/animations'; +import { Router } from '@angular/router'; +import { TranslateModule } from '@ngx-translate/core'; +import { BehaviorSubject } from 'rxjs'; +import { SourceStatusService } from '@iptvnator/portal/shared/data-access'; +import { + PlaylistContextFacade, + PlaylistRecentHistoryService, +} from '@iptvnator/playlist/shared/util'; +import { + SortBy, + SortOptions, + SortOrder, + SortService, +} from '@iptvnator/services'; +import { PlaylistMeta } from '@iptvnator/shared/interfaces'; +import { PlaylistRefreshActionService } from '../playlist-refresh-action.service'; +import { SourceRemovalService } from '../source-removal/source-removal.service'; +import { PlaylistSwitcherPanelComponent } from './playlist-switcher-panel.component'; + +const source = (id: string, fields: Partial = {}) => + ({ + _id: id, + title: id, + count: 0, + importDate: '2026-01-01T00:00:00.000Z', + autoRefresh: false, + ...fields, + }) as PlaylistMeta; + +const ALPHA = source('alpha', { url: 'https://alpha.example/list.m3u' }); +const BRAVO = source('bravo', { + serverUrl: 'http://bravo.example', + username: 'u', + password: 'secret-pass', +}); +const CHARLIE = source('charlie', { + macAddress: '00:1A:79:00:00:01', + portalUrl: 'http://charlie.example/c/', +}); + +describe('PlaylistSwitcherPanelComponent', () => { + let fixture: ComponentFixture; + let component: PlaylistSwitcherPanelComponent; + let playlists: ReturnType>; + let pending: ReturnType>>; + let selectPlaylist: jest.Mock; + let remove: jest.Mock; + let navigate: jest.Mock; + + const input = () => + (fixture.nativeElement as HTMLElement).querySelector( + '.switcher__search input' + ) as HTMLInputElement; + const press = (key: string, init: KeyboardEventInit = {}) => { + input().dispatchEvent( + new KeyboardEvent('keydown', { key, bubbles: true, ...init }) + ); + fixture.detectChanges(); + }; + const type = (value: string) => { + input().value = value; + input().dispatchEvent(new Event('input')); + fixture.detectChanges(); + }; + const optionIds = () => + Array.from( + (fixture.nativeElement as HTMLElement).querySelectorAll( + '[role="option"]' + ) + ).map((option) => option.id); + + beforeEach(() => { + localStorage.clear(); + playlists = signal([ALPHA, BRAVO, CHARLIE]); + pending = signal>(new Set()); + selectPlaylist = jest.fn(); + remove = jest.fn(); + navigate = jest.fn(); + TestBed.configureTestingModule({ + imports: [ + PlaylistSwitcherPanelComponent, + NoopAnimationsModule, + TranslateModule.forRoot(), + ], + providers: [ + { + provide: PlaylistContextFacade, + useValue: { + playlists, + resolvedPlaylistId: signal('bravo'), + selectPlaylist, + }, + }, + { + provide: PlaylistRecentHistoryService, + useValue: { ids: signal(['charlie']) }, + }, + { + provide: SortService, + useValue: { + getSortOptions: () => + new BehaviorSubject({ + by: SortBy.NAME, + order: SortOrder.ASC, + }), + sortPlaylists: new SortService().sortPlaylists, + }, + }, + { + provide: SourceStatusService, + useValue: { + statusOf: () => null, + tracks: () => false, + ensureChecked: jest.fn(), + recheck: jest.fn(), + }, + }, + { + provide: SourceRemovalService, + useValue: { pendingIds: pending, remove }, + }, + { + provide: PlaylistRefreshActionService, + useValue: { canRefresh: () => true, refresh: jest.fn() }, + }, + { provide: MatDialog, useValue: { open: jest.fn() } }, + { provide: Router, useValue: { navigate } }, + ], + }); + fixture = TestBed.createComponent(PlaylistSwitcherPanelComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('lists every source in the Sources order as listbox options', () => { + expect(optionIds()).toEqual([ + 'playlist-switcher-all-alpha', + 'playlist-switcher-all-bravo', + 'playlist-switcher-all-charlie', + ]); + const current = (fixture.nativeElement as HTMLElement).querySelector( + '#playlist-switcher-all-bravo' + ); + expect(current?.getAttribute('aria-current')).toBe('true'); + expect( + (fixture.nativeElement as HTMLElement).textContent + ).not.toContain('secret-pass'); + }); + + it('shows Recent above All once the list is long enough', () => { + playlists.set([ + ALPHA, + BRAVO, + CHARLIE, + source('d'), + source('e'), + source('f'), + ]); + fixture.detectChanges(); + expect(optionIds()[0]).toBe('playlist-switcher-recent-charlie'); + expect(optionIds()).toHaveLength(7); + }); + + it('moves the highlight with the arrow keys, wrapping, and opens it on Enter', () => { + press('ArrowDown'); + expect(input().getAttribute('aria-activedescendant')).toBe( + 'playlist-switcher-all-alpha' + ); + press('ArrowUp'); + expect(input().getAttribute('aria-activedescendant')).toBe( + 'playlist-switcher-all-charlie' + ); + press('ArrowDown'); + expect(input().getAttribute('aria-activedescendant')).toBe( + 'playlist-switcher-all-alpha' + ); + + const closed = jest.fn(); + component.closeRequested.subscribe(closed); + press('Enter'); + expect(selectPlaylist).toHaveBeenCalledWith(ALPHA); + expect(closed).toHaveBeenCalled(); + }); + + it('filters by name or host as you type and highlights the first match', () => { + type('charlie.example'); + expect(optionIds()).toEqual(['playlist-switcher-all-charlie']); + expect(input().getAttribute('aria-activedescendant')).toBe( + 'playlist-switcher-all-charlie' + ); + + type('nothing like it'); + expect(optionIds()).toEqual([]); + expect( + (fixture.nativeElement as HTMLElement).querySelector( + '.switcher__empty' + )?.textContent + ).toContain('WORKSPACE.SWITCHER.NO_MATCHES'); + }); + + it('filters by type with one segment and remembers it', () => { + component.setType('xtream'); + fixture.detectChanges(); + expect(optionIds()).toEqual(['playlist-switcher-all-bravo']); + expect(localStorage.getItem('playlist-switcher:type:v2')).toBe( + 'xtream' + ); + }); + + it('closes on Escape', () => { + const closed = jest.fn(); + component.closeRequested.subscribe(closed); + press('Escape'); + expect(closed).toHaveBeenCalled(); + }); + + it('leaves out a source whose delete is pending and hands over on delete', () => { + component.remove(BRAVO); + expect(remove).toHaveBeenCalledWith(BRAVO, CHARLIE); + + pending.set(new Set(['bravo'])); + fixture.detectChanges(); + expect(optionIds()).not.toContain('playlist-switcher-all-bravo'); + }); + + it('goes to the Sources page from the footer', () => { + ( + (fixture.nativeElement as HTMLElement).querySelector( + '.switcher__all' + ) as HTMLButtonElement + ).click(); + expect(navigate).toHaveBeenCalledWith(['/workspace', 'sources']); + }); +}); diff --git a/libs/playlist/shared/ui/src/lib/playlist-switcher/playlist-switcher-panel.component.ts b/libs/playlist/shared/ui/src/lib/playlist-switcher/playlist-switcher-panel.component.ts new file mode 100644 index 000000000..e5174ffcf --- /dev/null +++ b/libs/playlist/shared/ui/src/lib/playlist-switcher/playlist-switcher-panel.component.ts @@ -0,0 +1,281 @@ +import { A11yModule } from '@angular/cdk/a11y'; +import { NgTemplateOutlet } from '@angular/common'; +import { + afterNextRender, + ChangeDetectionStrategy, + Component, + computed, + ElementRef, + inject, + linkedSignal, + output, + signal, + viewChild, + viewChildren, +} from '@angular/core'; +import { toSignal } from '@angular/core/rxjs-interop'; +import { MatButton, MatIconButton } from '@angular/material/button'; +import { MatButtonToggleModule } from '@angular/material/button-toggle'; +import { MatDialog } from '@angular/material/dialog'; +import { MatIcon } from '@angular/material/icon'; +import { MatTooltip } from '@angular/material/tooltip'; +import { Router } from '@angular/router'; +import { TranslatePipe } from '@ngx-translate/core'; +import { SourceStatusService } from '@iptvnator/portal/shared/data-access'; +import { + countSourcesByType, + orderFailingFirst, + PlaylistContextFacade, + PlaylistRecentHistoryService, + SourceBucketOf, + SourcesTypeFilter, +} from '@iptvnator/playlist/shared/util'; +import { SortBy, SortService } from '@iptvnator/services'; +import { + PlaylistMeta, + PlaylistSourceKind, + sourceStatusBucket, +} from '@iptvnator/shared/interfaces'; +import { PlaylistRefreshActionService } from '../playlist-refresh-action.service'; +import { PlaylistInfoComponent } from '../recent-playlists/playlist-info/playlist-info.component'; +import { SourceRemovalService } from '../source-removal/source-removal.service'; +import { SourceRowComponent } from '../source-row/source-row.component'; +import { + buildSwitcherGroups, + moveHighlight, + readSwitcherType, + SwitcherOption, + writeSwitcherType, +} from './playlist-switcher-list'; + +const KINDS: readonly PlaylistSourceKind[] = ['m3u', 'xtream', 'stalker']; + +/** + * The switcher popover: search, a type segment, "Recent" and "All" groups + * of compact source rows, and a footer. Focus stays in the search field; + * ↑/↓ move the highlight (`aria-activedescendant`), Enter opens it, + * Shift+F10 opens its menu and Escape closes the popover. + */ +@Component({ + selector: 'app-playlist-switcher-panel', + imports: [ + A11yModule, + MatButton, + MatButtonToggleModule, + MatIcon, + MatIconButton, + MatTooltip, + NgTemplateOutlet, + SourceRowComponent, + TranslatePipe, + ], + templateUrl: './playlist-switcher-panel.component.html', + styleUrl: './playlist-switcher-panel.component.scss', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class PlaylistSwitcherPanelComponent { + private readonly context = inject(PlaylistContextFacade); + private readonly dialog = inject(MatDialog); + private readonly recentHistory = inject(PlaylistRecentHistoryService); + private readonly refreshAction = inject(PlaylistRefreshActionService); + private readonly removal = inject(SourceRemovalService); + private readonly router = inject(Router); + private readonly sortService = inject(SortService); + private readonly statusService = inject(SourceStatusService); + private readonly list = viewChild.required>('list'); + private readonly searchInput = + viewChild.required>('searchInput'); + private readonly rows = viewChildren(SourceRowComponent); + private readonly rowElements = viewChildren(SourceRowComponent, { + read: ElementRef, + }); + + readonly closeRequested = output(); + readonly playlistSelected = output(); + readonly addPlaylistRequested = output(); + readonly accountInfoRequested = output(); + + readonly listId = 'playlist-switcher-listbox'; + readonly kinds = KINDS; + readonly kindLabels = { m3u: 'M3U', xtream: 'Xtream', stalker: 'Stalker' }; + readonly currentId = this.context.resolvedPlaylistId; + readonly query = signal(''); + readonly type = signal(readSwitcherType()); + + private readonly sortOptions = toSignal(this.sortService.getSortOptions(), { + requireSync: true, + }); + private readonly bucketOf: SourceBucketOf = (playlist) => { + const status = this.statusService.statusOf(playlist); + return status ? sourceStatusBucket(status.reachability) : null; + }; + + readonly playlists = computed(() => { + const pending = this.removal.pendingIds(); + return this.context + .playlists() + .filter((playlist) => !pending.has(playlist._id)); + }); + readonly typeCounts = computed(() => + countSourcesByType(this.playlists(), 'all', this.bucketOf) + ); + readonly showTypeFilter = computed( + () => KINDS.filter((kind) => this.typeCounts()[kind] > 0).length > 1 + ); + /** The Sources page order, so both lists agree. */ + private readonly ordered = computed(() => { + const options = this.sortOptions(); + const sorted = this.sortService.sortPlaylists( + this.playlists(), + options + ); + return options.failingFirst && options.by !== SortBy.CUSTOM + ? orderFailingFirst(sorted, this.bucketOf) + : sorted; + }); + readonly groups = computed(() => + buildSwitcherGroups( + this.ordered(), + this.recentHistory.ids(), + this.showTypeFilter() ? this.type() : 'all', + this.query() + ) + ); + readonly options = computed(() => [ + ...this.groups().recent, + ...this.groups().all, + ]); + /** A search highlights its first match, so Enter opens it. */ + readonly highlight = linkedSignal< + readonly [string, SourcesTypeFilter], + number + >({ + source: () => [this.query(), this.type()] as const, + computation: ([query]) => (query.trim() ? 0 : -1), + }); + readonly activeOptionId = computed( + () => this.options()[this.highlight()]?.id ?? null + ); + + constructor() { + // Typing goes straight to the search. Not the focus trap's + // auto-capture: on close it would move focus back to wherever it was + // before ⌘P, and closing returns it to the trigger instead. + afterNextRender(() => this.searchInput().nativeElement.focus()); + } + + setQuery(value: string): void { + this.query.set(value); + } + + setType(type: SourcesTypeFilter): void { + this.type.set(type); + writeSwitcherType(type); + } + + onSearchKeydown(event: KeyboardEvent): void { + const count = this.options().length; + switch (event.key) { + case 'ArrowDown': + case 'ArrowUp': + event.preventDefault(); + this.highlight.set( + moveHighlight( + this.highlight(), + event.key === 'ArrowDown' ? 1 : -1, + count + ) + ); + this.scrollHighlightIntoView(); + break; + case 'Enter': { + const option = this.options()[this.highlight()]; + if (option) { + event.preventDefault(); + this.select(option.playlist); + } + break; + } + case 'Escape': + event.preventDefault(); + event.stopPropagation(); + this.closeRequested.emit(); + break; + case 'ContextMenu': + case 'F10': + if (event.key === 'ContextMenu' || event.shiftKey) { + event.preventDefault(); + this.openHighlightedMenu(); + } + break; + } + } + + select(playlist: PlaylistMeta): void { + this.context.selectPlaylist(playlist); + this.playlistSelected.emit(playlist._id); + this.closeRequested.emit(); + } + + canRefresh(playlist: PlaylistMeta): boolean { + return this.refreshAction.canRefresh(playlist); + } + + refresh(playlist: PlaylistMeta): void { + this.closeRequested.emit(); + this.refreshAction.refresh(playlist); + } + + openInfo(playlist: PlaylistMeta): void { + this.closeRequested.emit(); + this.dialog.open(PlaylistInfoComponent, { data: playlist }); + } + + openAccountInfo(playlist: PlaylistMeta): void { + this.closeRequested.emit(); + this.accountInfoRequested.emit(playlist); + } + + /** Deletes with Undo; the active source hands over to its neighbour. */ + remove(playlist: PlaylistMeta): void { + const all = this.groups().all.map((option) => option.playlist); + const index = all.findIndex((item) => item._id === playlist._id); + this.removal.remove(playlist, all[index + 1] ?? all[index - 1] ?? null); + } + + addPlaylist(): void { + this.closeRequested.emit(); + this.addPlaylistRequested.emit(); + } + + openAllSources(): void { + this.closeRequested.emit(); + void this.router.navigate(['/workspace', 'sources']); + } + + /** Shift+F10 / the menu key: the highlighted row's ⋯ menu. */ + private openHighlightedMenu(): void { + const id = this.activeOptionId(); + const index = this.rowElements().findIndex( + (element) => (element.nativeElement as HTMLElement).id === id + ); + this.rows()[index]?.openMenu(); + } + + /** Keeps the highlighted option in the list's view, not the page's. */ + private scrollHighlightIntoView(): void { + const id = this.activeOptionId(); + const list = this.list().nativeElement; + const option = id ? list.querySelector(`#${id}`) : null; + if (!option) { + return; + } + const top = option.offsetTop; + const bottom = top + option.offsetHeight; + if (top < list.scrollTop) { + list.scrollTop = top - 8; + } else if (bottom > list.scrollTop + list.clientHeight) { + list.scrollTop = bottom - list.clientHeight + 8; + } + } +} 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 d38b87d2c..3a5f84a3b 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 @@ -1,14 +1,15 @@ - - - - @if (showSearchField()) { -
- search - - @if (searchQuery()) { - - } -
- } - - @if (showTypeFilters()) { -
- @if (hasType('m3u')) { - - } - @if (hasType('stalker')) { - - } - @if (hasType('xtream')) { - - } -
- } - - - - @if (showAddPlaylist() || showAccountInfo() || showPlaylistInfo()) { -
- @if (showPlaylistInfo()) { - - } - @if (showAccountInfo()) { - - } - @if (showAddPlaylist()) { - - } -
- } -
- - - - - @if (hasAccountInfo(playlist)) { - - } - @if (runtime.supportsSourceHealth && sourceHealthType(playlist)) { - - } - @if (canRefreshPlaylist(playlist)) { - - } - - - - + + diff --git a/libs/playlist/shared/ui/src/lib/playlist-switcher/playlist-switcher.component.scss b/libs/playlist/shared/ui/src/lib/playlist-switcher/playlist-switcher.component.scss index 8aa1b9733..69b5b3eae 100644 --- a/libs/playlist/shared/ui/src/lib/playlist-switcher/playlist-switcher.component.scss +++ b/libs/playlist/shared/ui/src/lib/playlist-switcher/playlist-switcher.component.scss @@ -124,457 +124,15 @@ } } -::ng-deep .playlist-switcher-menu.mat-mdc-menu-panel { - --mat-menu-container-color: var(--mat-sys-surface-container-high); - width: min( - var(--playlist-switcher-overlay-width, 400px), - calc(100vw - 24px) - ) !important; - min-width: min( - var(--playlist-switcher-overlay-width, 400px), - calc(100vw - 24px) - ) !important; - max-width: min( - var(--playlist-switcher-overlay-width, 400px), - calc(100vw - 24px) - ) !important; - margin-top: -1px !important; - border-radius: 0 0 12px 12px !important; - border: 1px solid var(--mat-sys-outline) !important; - border-top: 0 !important; - overflow: hidden !important; - background: var(--mat-menu-container-color) !important; +// The popover's backdrop dims and slightly blurs the window behind it. The +// overlay container is outside this component, so these are global. +::ng-deep .playlist-switcher-backdrop { + background: var(--app-popover-backdrop); + backdrop-filter: blur(2px); +} - .mat-mdc-menu-content { - padding: 0 !important; - background: var(--mat-menu-container-color) !important; - } -} - -.menu-header { - display: flex; - align-items: center; - gap: 10px; - padding: 8px 12px 6px; - font-size: 0.85em; - font-weight: 600; - text-transform: uppercase; - letter-spacing: 0.5px; - background: var(--mat-menu-container-color); - - .menu-header-title { - flex: 1; - min-width: 0; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; - opacity: 0.7; - } - - .menu-header-icon { - opacity: 0.7; - } - - mat-icon { - font-size: 18px; - width: 18px; - height: 18px; - } - - .search-toggle-btn { - width: 30px; - height: 30px; - line-height: 30px; - color: var(--mat-sys-on-surface-variant); - opacity: 0.8; - padding: 0; - - &:hover, - &.is-active { - opacity: 1; - color: var(--mat-sys-on-surface); - } - - mat-icon { - font-size: 18px; - width: 18px; - height: 18px; - } - } -} - -.search-container { - display: flex; - align-items: center; - gap: 8px; - padding: 8px 12px; - margin: 0 8px 4px; - background: color-mix(in srgb, var(--app-on-surface) 6%, transparent); - border-radius: 8px; - border: 1px solid transparent; - transition: all 0.2s ease; -} - -.search-container:focus-within { - border-color: var(--app-focus-ring); - background: var(--mat-sys-surface-container-highest); -} - -.search-container .search-icon { - font-size: 18px; - width: 18px; - height: 18px; - color: var(--mat-sys-on-surface-variant); -} - -.search-container .search-input { - flex: 1; - border: none; - background: transparent; - outline: none; - font-size: 0.86em; - font-weight: 500; - line-height: 1.4; - color: inherit; - min-width: 0; -} - -.search-container .search-input::placeholder { - color: var(--mat-sys-on-surface-variant); -} - -.search-container .clear-btn { - width: 24px; - height: 24px; - line-height: 24px; - padding: 0; -} - -.search-container .clear-btn mat-icon { - font-size: 16px; - width: 16px; - height: 16px; -} - -.type-filters { - display: flex; - flex-wrap: wrap; - gap: 6px; - padding: 0 8px 8px; - margin-top: 6px; -} - -.type-filter-chip { - height: 24px; - padding: 0 10px; - border-radius: 999px; - border: 1px solid - color-mix(in srgb, var(--mat-sys-outline-variant) 72%, transparent); - background: color-mix( - in srgb, - var(--mat-sys-surface-container-high) 78%, - transparent - ); - color: var(--mat-sys-on-surface-variant); - font-size: 0.72rem; - font-weight: 600; - letter-spacing: 0.02em; - line-height: 1; - cursor: pointer; - transition: all 0.16s ease; - - &:hover { - background: var(--mat-sys-surface-container); - color: var(--mat-sys-on-surface); - } - - &.selected { - border-color: var(--app-selection-border, var(--app-selection-color)); - background: var( - --app-selection-surface-strong, - var(--app-selection-surface) - ); - color: var(--app-selection-color, var(--mat-sys-primary)); - /* box-shadow: - inset 0 0 0 1px - color-mix(in srgb, var(--app-selection-color) 18%, transparent), - 0 0 0 1px - color-mix(in srgb, var(--app-selection-color) 12%, transparent), - 0 0 10px var(--app-selection-glow, transparent); */ - } - - &:focus-visible { - @include focus-ring.focus-ring-declarations($offset: 1px); - } -} - -.menu-content { - max-height: 360px; - overflow-y: auto; - padding: 4px 0; - background: color-mix(in srgb, #000 22%, transparent); - border-top: 1px solid - color-mix(in srgb, var(--app-on-surface) 14%, transparent); - border-bottom: 1px solid - color-mix(in srgb, var(--app-on-surface) 14%, transparent); -} - -.playlist-item { - display: flex; - flex-direction: row; - align-items: center; - gap: 10px; - padding: 4px 12px; - min-height: 44px; - transition: background-color 0.15s ease; - position: relative; - cursor: pointer; - - &:not(:last-child) { - border-bottom: 1px solid - color-mix(in srgb, var(--app-on-surface) 8%, transparent); - } -} - -.playlist-item.selected { - background: var(--app-selection-surface, rgba(139, 92, 246, 0.1)); - - &::before { - content: ''; - position: absolute; - left: 0; - top: 6px; - bottom: 6px; - width: 3px; - border-radius: 0 3px 3px 0; - background: var(--app-selection-color, #a78bfa); - } - - .playlist-title, - .playlist-meta { - color: var(--app-selection-color, #a78bfa); - } -} - -.item-action-trigger { - opacity: 0; - pointer-events: none; - transition: opacity 0.15s ease; - width: 32px; - height: 32px; - line-height: 32px; - flex-shrink: 0; - color: var(--mat-sys-on-surface-variant); - padding: 0; - - mat-icon { - font-size: 18px; - width: 18px; - height: 18px; - } -} - -.playlist-item:hover .item-action-trigger, -.playlist-item.selected .item-action-trigger, -.item-action-trigger:focus-visible, -.item-action-trigger[aria-expanded='true'] { - opacity: 1; - pointer-events: auto; -} - -::ng-deep .playlist-item-action-menu { - .danger, - .danger mat-icon { - color: var(--mat-sys-error, #f44336); - } -} - -.playlist-item:hover:not(.selected) { - background: color-mix(in srgb, var(--app-on-surface) 10%, transparent); -} - -.playlist-icon-container { - position: relative; - display: flex; - align-items: center; - justify-content: center; - width: 36px; - height: 36px; - border-radius: 8px; - background: color-mix(in srgb, var(--app-on-surface) 6%, transparent); - flex-shrink: 0; -} - -.playlist-type-icon { - font-size: 20px; - width: 20px; - height: 20px; - color: var(--mat-sys-on-surface-variant); -} - -.status-dot { - position: absolute; - width: 10px; - height: 10px; - border-radius: 50%; - right: -4px; - bottom: -4px; - border: 2px solid var(--mat-menu-container-color, #2a2a2e); -} - -.status-active { - background-color: #4caf50; - box-shadow: 0 0 6px rgba(76, 175, 80, 0.4); -} - -.status-inactive { - background-color: #f44336; - box-shadow: 0 0 6px rgba(244, 67, 54, 0.4); -} - -.status-expired { - background-color: #ff9800; - box-shadow: 0 0 6px rgba(255, 152, 0, 0.4); -} - -.status-unavailable { - background-color: #666666; -} - -.status-checking { - background-color: #9e9e9e; - animation: switcher-status-checking 1.4s ease-in-out infinite; -} - -@keyframes switcher-status-checking { - 0%, - 100% { - opacity: 0.4; - box-shadow: 0 0 0 0 rgba(158, 158, 158, 0); - } - 50% { - opacity: 1; - box-shadow: 0 0 4px 1px rgba(158, 158, 158, 0.6); - } -} - -@media (prefers-reduced-motion: reduce) { - .status-checking { - animation: none; - opacity: 0.7; - } -} - -.auto-refresh-indicator { - position: absolute; - right: -2px; - bottom: -2px; - font-size: 12px !important; - width: 14px !important; - height: 14px !important; - color: var(--mat-sys-primary); - background-color: var(--mat-menu-container-color, var(--mat-sys-surface)); - border-radius: 50%; - display: flex; - align-items: center; - justify-content: center; -} - -.playlist-details { - flex: 1; - min-width: 0; - display: flex; - flex-direction: column; - gap: 3px; -} - -.playlist-title { - font-size: 0.92em; - font-weight: 600; - line-height: 1.2; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} - -.playlist-meta { - font-size: 0.73em; - line-height: 1.25; - letter-spacing: 0.01em; - color: var(--mat-sys-on-surface-variant); - display: flex; - align-items: center; - gap: 4px; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; - - .separator { - color: var(--mat-sys-on-surface-variant); - } -} - -.empty-state { - display: flex; - flex-direction: column; - align-items: center; - justify-content: center; - padding: 32px 16px; - gap: 12px; - color: var(--mat-sys-on-surface-variant); - - mat-icon { - font-size: 36px; - width: 36px; - height: 36px; - } - - span { - font-size: 0.85em; - } -} - -.context-actions-section { - padding: 6px 8px 8px; - background: var(--mat-menu-container-color); - - .context-action-item { - display: flex; - align-items: center; - gap: 10px; - width: 100%; - padding: 8px 10px; - border: none; - border-radius: 8px; - background: transparent; - color: var(--mat-sys-on-surface-variant); - font: inherit; - font-size: 0.86em; - font-weight: 500; - cursor: pointer; - transition: - background 0.15s ease, - color 0.15s ease; - - &:hover { - background: var(--mat-sys-surface-container); - color: var(--mat-sys-on-surface); - } - - mat-icon { - font-size: 18px; - width: 18px; - height: 18px; - } - } -} - -@media only screen and (max-width: 480px) { - .playlist-info .name { - font-size: 0.9em; - } - - .playlist-icon-container { - display: none; +@media (prefers-reduced-transparency: reduce) { + ::ng-deep .playlist-switcher-backdrop { + backdrop-filter: none; } } diff --git a/libs/playlist/shared/ui/src/lib/playlist-switcher/playlist-switcher.component.spec.ts b/libs/playlist/shared/ui/src/lib/playlist-switcher/playlist-switcher.component.spec.ts index dc96f7bbd..ee85ebda3 100644 --- a/libs/playlist/shared/ui/src/lib/playlist-switcher/playlist-switcher.component.spec.ts +++ b/libs/playlist/shared/ui/src/lib/playlist-switcher/playlist-switcher.component.spec.ts @@ -1,112 +1,53 @@ -import { signal } from '@angular/core'; import { - ComponentFixture, - TestBed, - fakeAsync, - flushMicrotasks, - tick, -} from '@angular/core/testing'; -import { MatDialog } from '@angular/material/dialog'; -import { MatSnackBar } from '@angular/material/snack-bar'; -import { Store } from '@ngrx/store'; + ChangeDetectionStrategy, + Component, + output, + signal, +} from '@angular/core'; +import { ComponentFixture, TestBed } from '@angular/core/testing'; import { NoopAnimationsModule } from '@angular/platform-browser/animations'; -import { TranslateModule, TranslateService } from '@ngx-translate/core'; -import { PlaylistActions } from '@iptvnator/m3u-state'; +import { TranslateModule } from '@ngx-translate/core'; +import { SourceStatusService } from '@iptvnator/portal/shared/data-access'; import { PlaylistContextFacade } from '@iptvnator/playlist/shared/util'; -import { DialogService } from '@iptvnator/ui/components'; -import { - PlaylistDeleteActionService, - PortalStatusService, -} from '@iptvnator/services'; import { PlaylistMeta } from '@iptvnator/shared/interfaces'; -import { PlaylistRefreshActionService } from '../playlist-refresh-action.service'; -import { PlaylistSwitcherComponent } from './playlist-switcher.component'; import { PlaylistSwitcherOpenService } from './playlist-switcher-open.service'; +import { PlaylistSwitcherPanelComponent } from './playlist-switcher-panel.component'; +import { PlaylistSwitcherComponent } from './playlist-switcher.component'; -const SEARCH_QUERY_STORAGE_KEY = 'playlist-switcher:search-query'; -const PLAYLIST_TYPE_FILTER_STORAGE_KEY = 'playlist-switcher:type-filters'; - -function createPlaylist( - overrides: Partial & { _id: string } -): PlaylistMeta { - return { - title: overrides.title ?? overrides.filename ?? overrides._id, - filename: overrides.filename, - count: overrides.count ?? 0, - importDate: - overrides.importDate ?? - new Date('2026-04-05T10:00:00.000Z').toISOString(), - autoRefresh: overrides.autoRefresh ?? false, - ...overrides, - } as PlaylistMeta; +@Component({ + selector: 'app-playlist-switcher-panel', + template: '', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +class StubPanelComponent { + readonly closeRequested = output(); + readonly playlistSelected = output(); + readonly addPlaylistRequested = output(); + readonly accountInfoRequested = output(); } +const URL_NAMED = { + _id: 'url-named', + title: 'get.php?username=joe&password=hunter2&type=m3u_plus', + url: 'http://biggie.test:8080/get.php?username=joe&password=hunter2&type=m3u_plus', + count: 3220, + importDate: '', + autoRefresh: false, +} as PlaylistMeta; + describe('PlaylistSwitcherComponent', () => { - const testWindow = window as unknown as { electron?: unknown }; - const originalElectron = testWindow.electron; let fixture: ComponentFixture; - let component: PlaylistSwitcherComponent; - let playlistsSignal: ReturnType>; - let resolvedPlaylistIdSignal: ReturnType>; - let activePlaylistSignal: ReturnType>; - let playlistContext: { - playlists: typeof playlistsSignal; - allPlaylistsLoaded: ReturnType>; - resolvedPlaylistId: typeof resolvedPlaylistIdSignal; - activePlaylist: typeof activePlaylistSignal; - selectPlaylist: jest.Mock; - }; - let portalStatusService: { - checkPortalStatus: jest.Mock; - getCachedStatus: jest.Mock; - getStatusClass: jest.Mock; - }; - let refreshActionService: { - canRefresh: jest.Mock; - refresh: jest.Mock; - }; - let dialog: { - open: jest.Mock; - }; - let dialogService: { - openConfirmDialog: jest.Mock; - }; - let playlistDeleteAction: { - deletePlaylist: jest.Mock; - }; - let snackBar: { - open: jest.Mock; - }; - let store: { - dispatch: jest.Mock; - }; + let active: ReturnType>; - const m3uPlaylist = createPlaylist({ - _id: 'm3u-1', - count: 24, - importDate: '2026-04-05T09:00:00.000Z', - title: 'Morning M3U', - url: 'http://example.test/m3u.m3u', - }); - const stalkerPlaylist = createPlaylist({ - _id: 'stalker-1', - importDate: '2026-04-05T11:00:00.000Z', - macAddress: '00:1A:79:00:00:01', - portalUrl: 'http://127.0.0.1:3210/portal.php', - title: 'Living Room Stalker', - }); - const xtreamPlaylist = createPlaylist({ - _id: 'xtream-1', - importDate: '2026-04-05T12:00:00.000Z', - password: 'pass1', - serverUrl: 'http://127.0.0.1:3211', - title: 'Cinema Xtream', - updateDate: new Date('2026-04-05T12:30:00.000Z').getTime(), - username: 'user1', - }); + const trigger = () => + (fixture.nativeElement as HTMLElement).querySelector( + '[data-test-id="playlist-switcher-trigger"]' + ) as HTMLButtonElement; + const panel = () => document.querySelector('app-playlist-switcher-panel'); - async function createComponent(): Promise { - await TestBed.configureTestingModule({ + beforeEach(() => { + active = signal(URL_NAMED); + TestBed.configureTestingModule({ imports: [ PlaylistSwitcherComponent, NoopAnimationsModule, @@ -115,374 +56,72 @@ describe('PlaylistSwitcherComponent', () => { providers: [ { provide: PlaylistContextFacade, - useValue: playlistContext, + useValue: { + resolvedPlaylistId: signal('url-named'), + activePlaylist: active, + }, }, { - provide: PortalStatusService, - useValue: portalStatusService, - }, - { - provide: PlaylistRefreshActionService, - useValue: refreshActionService, - }, - { - provide: PlaylistDeleteActionService, - useValue: playlistDeleteAction, - }, - { - provide: MatDialog, - useValue: dialog, - }, - { - provide: DialogService, - useValue: dialogService, - }, - { - provide: MatSnackBar, - useValue: snackBar, - }, - { - provide: Store, - useValue: store, + provide: SourceStatusService, + useValue: { statusOf: () => ({ reachability: 'ok' }) }, }, ], - }).compileComponents(); - + }); + TestBed.overrideComponent(PlaylistSwitcherComponent, { + remove: { imports: [PlaylistSwitcherPanelComponent] }, + add: { imports: [StubPanelComponent] }, + }); fixture = TestBed.createComponent(PlaylistSwitcherComponent); - component = fixture.componentInstance; fixture.componentRef.setInput('currentTitle', ''); - fixture.componentRef.setInput('subtitle', '3 playlists'); + fixture.componentRef.setInput('subtitle', '3 sources'); fixture.detectChanges(); - } - - beforeEach(() => { - localStorage.clear(); - - playlistsSignal = signal([ - m3uPlaylist, - stalkerPlaylist, - xtreamPlaylist, - ]); - resolvedPlaylistIdSignal = signal(xtreamPlaylist._id); - activePlaylistSignal = signal(xtreamPlaylist); - - playlistContext = { - playlists: playlistsSignal, - allPlaylistsLoaded: signal(true), - resolvedPlaylistId: resolvedPlaylistIdSignal, - activePlaylist: activePlaylistSignal, - selectPlaylist: jest.fn((playlist: PlaylistMeta) => { - resolvedPlaylistIdSignal.set(playlist._id); - activePlaylistSignal.set(playlist); - }), - }; - portalStatusService = { - checkPortalStatus: jest - .fn() - .mockResolvedValueOnce('active') - .mockResolvedValueOnce('inactive'), - getCachedStatus: jest.fn().mockReturnValue(null), - getStatusClass: jest.fn((status: string) => `status-${status}`), - }; - refreshActionService = { - canRefresh: jest.fn((playlist: PlaylistMeta) => - Boolean(playlist.serverUrl || playlist.url || playlist.filePath) - ), - refresh: jest.fn(), - }; - dialog = { - open: jest.fn(), - }; - dialogService = { - openConfirmDialog: jest.fn(), - }; - playlistDeleteAction = { - deletePlaylist: jest.fn().mockResolvedValue(true), - }; - snackBar = { - open: jest.fn(), - }; - store = { - dispatch: jest.fn(), - }; }); - afterEach(() => { - localStorage.clear(); - Object.defineProperty(window, 'electron', { - configurable: true, - writable: true, - value: originalElectron, - }); - jest.restoreAllMocks(); - }); + afterEach(() => fixture.destroy()); - it('labels each playlist row with translated text', async () => { - await createComponent(); - const translate = TestBed.inject(TranslateService); - translate.setTranslation('en', { - HOME: { PLAYLISTS: { CHANNELS_COUNT: '{{count}} channels' } }, - WORKSPACE: { - SHELL: { - STALKER_PORTAL: 'Stalker Portal', - XTREAM_CODE: 'Xtream Code', - }, - }, - }); - translate.use('en'); - - expect(component.getPlaylistMetaLabel(m3uPlaylist)).toBe('24 channels'); - expect(component.getPlaylistMetaLabel(stalkerPlaylist)).toBe( - 'Stalker Portal' - ); - expect(component.getPlaylistMetaLabel(xtreamPlaylist)).toBe( - 'Xtream Code' - ); - }); - - it('reads persisted search and type filters on startup and filters playlists accordingly', async () => { - localStorage.setItem(SEARCH_QUERY_STORAGE_KEY, 'room'); - localStorage.setItem( - PLAYLIST_TYPE_FILTER_STORAGE_KEY, - JSON.stringify({ - m3u: false, - stalker: true, - xtream: true, - }) - ); - - await createComponent(); - - expect(component.searchQuery()).toBe('room'); - expect(component.isTypeFilterSelected('m3u')).toBe(false); - expect(component.filteredPlaylists()).toEqual([stalkerPlaylist]); - }); - - it('sorts playlists by import date descending and prevents clearing the last enabled type filter', async () => { - await createComponent(); - - expect( - component.filteredPlaylists().map((playlist) => playlist._id) - ).toEqual([xtreamPlaylist._id, stalkerPlaylist._id, m3uPlaylist._id]); - - component.togglePlaylistTypeFilter('m3u'); - component.togglePlaylistTypeFilter('stalker'); - expect(component.playlistTypeFilters()).toEqual({ - m3u: false, - stalker: false, - xtream: true, - }); - - component.togglePlaylistTypeFilter('xtream'); - expect(component.playlistTypeFilters()).toEqual({ - m3u: false, - stalker: false, - xtream: true, - }); - }); - - it('selects a playlist, closes the menu, and emits the selected id', async () => { - await createComponent(); - - const closeMenuSpy = jest.spyOn(component.menuTrigger(), 'closeMenu'); - const selectedIds: string[] = []; - component.playlistSelected.subscribe((playlistId) => { - selectedIds.push(playlistId); - }); - - component.selectPlaylist(stalkerPlaylist); - fixture.detectChanges(); - - expect(closeMenuSpy).toHaveBeenCalledTimes(1); - expect(playlistContext.selectPlaylist).toHaveBeenCalledWith( - stalkerPlaylist - ); - expect(selectedIds).toEqual([stalkerPlaylist._id]); - expect(component.isSelected(stalkerPlaylist)).toBe(true); - }); - - it('emits playlist and account info requests through the context actions', async () => { - await createComponent(); - - const closeMenuSpy = jest.spyOn(component.menuTrigger(), 'closeMenu'); - const playlistInfoSpy = jest.fn(); - const accountInfoSpy = jest.fn(); - component.playlistInfoRequested.subscribe(playlistInfoSpy); - component.accountInfoRequested.subscribe(accountInfoSpy); - - component.requestPlaylistInfo(); - component.requestAccountInfo(); - - expect(closeMenuSpy).toHaveBeenCalledTimes(2); - expect(playlistInfoSpy).toHaveBeenCalledTimes(1); - expect(accountInfoSpy).toHaveBeenCalledTimes(1); - }); - - it('renders playlist info, account info, and add playlist context actions in the menu', async () => { - await createComponent(); - fixture.componentRef.setInput('showPlaylistInfo', true); - fixture.componentRef.setInput('showAccountInfo', true); - fixture.componentRef.setInput('showAddPlaylist', true); - fixture.detectChanges(); - - const playlistInfoSpy = jest.fn(); - component.playlistInfoRequested.subscribe(playlistInfoSpy); - - component.menuTrigger().openMenu(); - fixture.detectChanges(); - - const actionButtons = Array.from( - document.querySelectorAll( - '.context-actions-section .context-action-item' - ) - ); - const labels = actionButtons.map((button) => - button.textContent?.trim() - ); - - expect(labels).toEqual([ - expect.stringContaining('PLAYLIST_INFO'), - expect.stringContaining('ACCOUNT_INFO'), - expect.stringContaining('ADD_PLAYLIST'), - ]); - - actionButtons[0].click(); - expect(playlistInfoSpy).toHaveBeenCalledTimes(1); - }); - - it('offers per-playlist account info for portal playlists only', async () => { - await createComponent(); - - const requestedPlaylists: PlaylistMeta[] = []; - component.accountInfoForPlaylistRequested.subscribe((playlist) => - requestedPlaylists.push(playlist) - ); - - expect(component.hasAccountInfo(m3uPlaylist)).toBe(false); - expect(component.hasAccountInfo(stalkerPlaylist)).toBe(true); - expect(component.hasAccountInfo(xtreamPlaylist)).toBe(true); - - component.requestAccountInfoFor(stalkerPlaylist); - expect(requestedPlaylists).toEqual([stalkerPlaylist]); - }); - - it('opens the menu, syncs overlay width, and checks portal statuses for Xtream playlists', fakeAsync(async () => { - await createComponent(); - - const triggerElement = component.triggerElement().nativeElement; - jest.spyOn(triggerElement, 'getBoundingClientRect').mockReturnValue({ - bottom: 0, - height: 0, - left: 0, - right: 320, - toJSON: () => ({}), - top: 0, - width: 320, - x: 0, - y: 0, - }); - - component.onMenuOpened(); - flushMicrotasks(); - tick(); - - expect(component.isMenuOpen()).toBe(true); - expect( - document.documentElement.style.getPropertyValue( - '--playlist-switcher-overlay-width' - ) - ).toBe('400px'); - expect(portalStatusService.checkPortalStatus).toHaveBeenCalledTimes(1); - expect(portalStatusService.checkPortalStatus).toHaveBeenCalledWith( - xtreamPlaylist.serverUrl, - xtreamPlaylist.username, - xtreamPlaylist.password - ); - expect(component.portalStatuses().get(xtreamPlaylist._id)).toBe( - 'active' - ); - expect(component.getStatusClass(xtreamPlaylist._id)).toBe( - 'status-active' - ); - - component.onMenuClosed(); - expect(component.isMenuOpen()).toBe(false); - expect( - document.documentElement.style.getPropertyValue( - '--playlist-switcher-overlay-width' - ) - ).toBe(''); - })); - - it('falls back to the active playlist title when no explicit current title is provided', async () => { - await createComponent(); - - expect(component.displayTitle()).toBe('Cinema Xtream'); - - const translate = TestBed.inject(TranslateService); - translate.setTranslation('en', { - HOME: { PLAYLISTS: { SELECT_PLAYLIST: 'Pick a source' } }, - }); - translate.use('en'); - resolvedPlaylistIdSignal.set(null); - activePlaylistSignal.set(null); - fixture.detectChanges(); - - expect(component.displayTitle()).toBe('Pick a source'); - }); - - it('delegates playlist deletion and removes the source after confirmation', async () => { - await createComponent(); - - component.removePlaylistFor(xtreamPlaylist); - const confirm = dialogService.openConfirmDialog.mock.calls[0][0] - .onConfirm as () => Promise; - await confirm(); - - expect(playlistDeleteAction.deletePlaylist).toHaveBeenCalledWith( - xtreamPlaylist - ); - expect(store.dispatch).toHaveBeenCalledWith( - PlaylistActions.playlistRemovalCommitted({ - playlistId: xtreamPlaylist._id, - }) - ); - }); - - it('names the active source by its host, never by a credential-bearing URL', async () => { - const urlNamed = createPlaylist({ - _id: 'url-named', - count: 3220, - title: 'get.php?username=joe&password=hunter2&type=m3u_plus', - url: 'http://biggie.test:8080/get.php?username=joe&password=hunter2&type=m3u_plus', - }); - resolvedPlaylistIdSignal.set(urlNamed._id); - activePlaylistSignal.set(urlNamed); - await createComponent(); - - const trigger = (fixture.nativeElement as HTMLElement).querySelector( - '[data-test-id="playlist-switcher-trigger"]' - ) as HTMLElement; - expect(trigger.querySelector('.name')?.textContent?.trim()).toBe( + it('names the active source by its host, never by a credential URL', () => { + expect(trigger().querySelector('.name')?.textContent?.trim()).toBe( 'biggie.test:8080' ); - expect(trigger.querySelector('.channels-count')?.textContent).toContain( - 'M3U · HOME.PLAYLISTS.CHANNELS_COUNT' - ); - expect(trigger.textContent).not.toMatch(/hunter2|password=/); - expect(trigger.getAttribute('aria-keyshortcuts')).toBe( + expect( + trigger().querySelector('.channels-count')?.textContent + ).toContain('M3U · HOME.PLAYLISTS.CHANNELS_COUNT'); + expect(trigger().textContent).not.toMatch(/hunter2|password=/); + expect(trigger().querySelector('.source-tile__dot')).not.toBeNull(); + expect(trigger().getAttribute('aria-keyshortcuts')).toBe( 'Meta+P Control+P' ); }); - it('opens on a ⌘P / Ctrl+P request while it is on screen', async () => { - await createComponent(); + it('shows the source count when no source is active', () => { + active.set(null); + fixture.detectChanges(); + expect(trigger().querySelector('.channels-count')?.textContent).toBe( + '3 sources' + ); + }); + + it('opens the popover in an overlay and closes it on Escape, back on the trigger', () => { + trigger().click(); + fixture.detectChanges(); + expect(panel()).not.toBeNull(); + expect(trigger().getAttribute('aria-expanded')).toBe('true'); + expect(trigger().classList).toContain('is-open'); + + document.body.dispatchEvent( + new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }) + ); + fixture.detectChanges(); + expect(panel()).toBeNull(); + expect(document.activeElement).toBe(trigger()); + }); + + it('opens on a ⌘P / Ctrl+P request while it is on screen', () => { const switcherOpen = TestBed.inject(PlaylistSwitcherOpenService); expect(switcherOpen.available).toBe(true); - switcherOpen.request(); fixture.detectChanges(); - expect(component.isMenuOpen()).toBe(true); + expect(panel()).not.toBeNull(); fixture.destroy(); expect(switcherOpen.available).toBe(false); diff --git a/libs/playlist/shared/ui/src/lib/playlist-switcher/playlist-switcher.component.ts b/libs/playlist/shared/ui/src/lib/playlist-switcher/playlist-switcher.component.ts index ad06a4447..d6c1c3080 100644 --- a/libs/playlist/shared/ui/src/lib/playlist-switcher/playlist-switcher.component.ts +++ b/libs/playlist/shared/ui/src/lib/playlist-switcher/playlist-switcher.component.ts @@ -1,17 +1,8 @@ -import { Injector } from '@angular/core'; import { - RuntimeCapabilitiesService, - injectTranslationTick, -} from '@iptvnator/services'; -import { - SourceHealthService, - SourceStatusService, -} from '@iptvnator/portal/shared/data-access'; -import { foldSearchText, sourceHealthType } from '@iptvnator/shared/interfaces'; -import { SourceHealthIndicatorComponent } from '../source-health/source-health-indicator.component'; -import { SourceTileComponent } from '../source-row/source-tile.component'; -import { PlaylistSwitcherOpenService } from './playlist-switcher-open.service'; -import { DatePipe, DOCUMENT } from '@angular/common'; + CdkConnectedOverlay, + CdkOverlayOrigin, + ConnectedPosition, +} from '@angular/cdk/overlay'; import { ChangeDetectionStrategy, Component, @@ -24,142 +15,81 @@ import { signal, viewChild, } from '@angular/core'; -import { FormsModule } from '@angular/forms'; -import { MatIconButton } from '@angular/material/button'; -import { MatRippleModule } from '@angular/material/core'; -import { MatDialog } from '@angular/material/dialog'; -import { MatDivider } from '@angular/material/divider'; import { MatIcon } from '@angular/material/icon'; -import { MatInputModule } from '@angular/material/input'; -import { MatMenu, MatMenuModule, MatMenuTrigger } from '@angular/material/menu'; -import { MatSnackBar } from '@angular/material/snack-bar'; -import { MatTooltip } from '@angular/material/tooltip'; -import { Store } from '@ngrx/store'; +import { TranslateService } from '@ngx-translate/core'; import { normalizeDateLocale } from '@iptvnator/pipes'; -import { TranslatePipe, TranslateService } from '@ngx-translate/core'; -import { DialogService } from '@iptvnator/ui/components'; -import { PlaylistActions } from '@iptvnator/m3u-state'; +import { SourceStatusService } from '@iptvnator/portal/shared/data-access'; import { PlaylistContextFacade, sourceDisplayName, sourceMetaParts, } from '@iptvnator/playlist/shared/util'; -import { - PlaylistDeleteActionService, - PortalStatus, - PortalStatusService, -} from '@iptvnator/services'; -import { - getPlaylistSourceIcon, - isPortalAccountPlaylist, - PlaylistMeta, -} from '@iptvnator/shared/interfaces'; -import { PlaylistRefreshActionService } from '../playlist-refresh-action.service'; -import { PlaylistInfoComponent } from '../recent-playlists/playlist-info/playlist-info.component'; +import { injectTranslationTick } from '@iptvnator/services'; +import { PlaylistMeta } from '@iptvnator/shared/interfaces'; +import { SourceTileComponent } from '../source-row/source-tile.component'; +import { PlaylistSwitcherOpenService } from './playlist-switcher-open.service'; +import { PlaylistSwitcherPanelComponent } from './playlist-switcher-panel.component'; -type PlaylistFilterType = 'm3u' | 'stalker' | 'xtream'; - -const SEARCH_QUERY_STORAGE_KEY = 'playlist-switcher:search-query'; -const PLAYLIST_TYPE_FILTER_STORAGE_KEY = 'playlist-switcher:type-filters'; -const DEFAULT_PLAYLIST_TYPE_FILTERS: Record = { - m3u: true, - stalker: true, - xtream: true, -}; +/** Below the trigger's left edge, 8px down; above it when there is no room. */ +const POSITIONS: ConnectedPosition[] = [ + { + originX: 'start', + originY: 'bottom', + overlayX: 'start', + overlayY: 'top', + offsetY: 8, + }, + { + originX: 'start', + originY: 'top', + overlayX: 'start', + overlayY: 'bottom', + offsetY: -8, + }, +]; +/** The popover keeps this far from the window's bottom edge. */ +const VIEWPORT_MARGIN = 12; +/** + * The toolbar playlist switcher: a trigger showing the active source and a + * popover (`app-playlist-switcher-panel`) in a CDK overlay, so row menus + * open above it rather than inside its scrolling list. Opens on click and + * on ⌘P / Ctrl+P; Escape closes it and returns focus to the trigger. + */ @Component({ selector: 'app-playlist-switcher', + imports: [ + CdkConnectedOverlay, + CdkOverlayOrigin, + MatIcon, + PlaylistSwitcherPanelComponent, + SourceTileComponent, + ], templateUrl: './playlist-switcher.component.html', styleUrls: ['./playlist-switcher.component.scss'], changeDetection: ChangeDetectionStrategy.OnPush, - imports: [ - SourceHealthIndicatorComponent, - SourceTileComponent, - DatePipe, - FormsModule, - MatDivider, - MatIcon, - MatIconButton, - MatInputModule, - MatMenuModule, - MatRippleModule, - MatTooltip, - TranslatePipe, - ], }) export class PlaylistSwitcherComponent { - private readonly document = inject(DOCUMENT); - private readonly destroyRef = inject(DestroyRef); private readonly playlistContext = inject(PlaylistContextFacade); - private readonly portalStatusService = inject(PortalStatusService); - private readonly translate = inject(TranslateService); - private readonly refreshAction = inject(PlaylistRefreshActionService); - private readonly dialog = inject(MatDialog); - private readonly dialogService = inject(DialogService); - private readonly playlistDeleteAction = inject(PlaylistDeleteActionService); - private readonly snackBar = inject(MatSnackBar); - private readonly store = inject(Store); - private focusSearchTimeoutId: ReturnType | null = null; - private readonly languageTick = injectTranslationTick(); private readonly statusService = inject(SourceStatusService); - - readonly runtime = inject(RuntimeCapabilitiesService); - private readonly healthInjector = inject(Injector); - readonly sourceHealthType = sourceHealthType; - recheckSource(playlist: PlaylistMeta): void { - void this.healthInjector.get(SourceHealthService).recheck(playlist); - } + private readonly translate = inject(TranslateService); + private readonly languageTick = injectTranslationTick(); + private readonly trigger = + viewChild.required>('trigger'); readonly currentTitle = input.required(); + /** Shown under the name while no source is active ("3 sources"). */ readonly subtitle = input(''); - readonly showPlaylistInfo = input(false); - readonly showAccountInfo = input(false); - readonly showAddPlaylist = input(false); readonly playlistSelected = output(); - readonly playlistInfoRequested = output(); - readonly accountInfoRequested = output(); readonly accountInfoForPlaylistRequested = output(); readonly addPlaylistRequested = output(); - readonly menuTrigger = viewChild.required('menuTrigger'); - readonly playlistMenu = viewChild.required('playlistMenu'); - readonly triggerElement = - viewChild.required>('triggerElement'); - readonly searchInput = - viewChild>('searchInput'); - - readonly isMenuOpen = signal(false); - readonly searchQuery = signal(this.readPersistedSearchQuery()); - readonly playlistTypeFilters = signal(this.readPersistedTypeFilters()); - readonly searchExpanded = signal(false); - private static readonly SEARCH_AUTO_VISIBLE_THRESHOLD = 5; - - readonly playlists = this.playlistContext.playlists; - readonly allPlaylistsLoaded = this.playlistContext.allPlaylistsLoaded; + readonly positions = POSITIONS; + readonly isOpen = signal(false); + readonly maxHeight = signal(null); readonly activePlaylistId = this.playlistContext.resolvedPlaylistId; readonly activePlaylist = this.playlistContext.activePlaylist; - readonly filteredPlaylists = computed(() => { - const query = foldSearchText(this.searchQuery()).trim(); - const filters = this.playlistTypeFilters(); - const allPlaylists = this.playlists(); - const filteredByType = allPlaylists.filter((playlist) => { - const playlistType = this.getPlaylistFilterType(playlist); - return filters[playlistType]; - }); - const filtered = query - ? filteredByType.filter( - (playlist) => - foldSearchText(playlist.title ?? '').includes(query) || - foldSearchText(playlist.filename ?? '').includes(query) - ) - : filteredByType; - return [...filtered].sort( - (a, b) => - new Date(b.importDate ?? 0).getTime() - - new Date(a.importDate ?? 0).getTime() - ); - }); /** The active source's name; never its URL or a credential. */ readonly displayTitle = computed(() => { this.languageTick(); @@ -183,7 +113,9 @@ export class PlaylistSwitcherComponent { active, { translate: (key, params) => this.translate.instant(key, params), - locale: this.currentLocale(), + locale: normalizeDateLocale( + this.translate.currentLang || this.translate.defaultLang + ), now: Date.now(), }, true @@ -197,419 +129,46 @@ export class PlaylistSwitcherComponent { return active ? this.statusService.statusOf(active) : null; }); - readonly availablePlaylistTypes = computed(() => { - const types = new Set(); - for (const playlist of this.playlists()) { - types.add(this.getPlaylistFilterType(playlist)); - } - return types; - }); - readonly showTypeFilters = computed( - () => this.availablePlaylistTypes().size > 1 - ); - readonly hasSearchToggle = computed( - () => - this.playlists().length <= - PlaylistSwitcherComponent.SEARCH_AUTO_VISIBLE_THRESHOLD - ); - readonly showSearchField = computed( - () => !this.hasSearchToggle() || this.searchExpanded() - ); - - readonly portalStatuses = signal>(new Map()); - - /** - * Tracks the in-flight check round so we can cancel pending writes when - * the menu closes (avoiding zombie writes from a slow portal landing - * after the user has moved on). Status caching itself lives in - * PortalStatusService so it's shared across components. - */ - private portalStatusAbortController: AbortController | null = null; - readonly currentLocale = computed(() => { - this.languageTick(); - return normalizeDateLocale( - this.translate.currentLang || this.translate.defaultLang - ); - }); - constructor() { const openService = inject(PlaylistSwitcherOpenService); const unregister = openService.register(); const requests = openService.openRequests.subscribe(() => this.open()); - this.destroyRef.onDestroy(() => { + inject(DestroyRef).onDestroy(() => { unregister(); requests.unsubscribe(); }); - this.destroyRef.onDestroy(() => { - this.clearMenuOverlayWidth(); - this.clearSearchFocusTimeout(); - this.cancelPortalStatusChecks(); - }); } - /** Opens the switcher (⌘P / Ctrl+P). */ + toggle(): void { + if (this.isOpen()) { + this.close(); + } else { + this.open(); + } + } + open(): void { - this.menuTrigger().openMenu(); + const bottom = + this.trigger().nativeElement.getBoundingClientRect().bottom; + this.maxHeight.set( + `${Math.max(240, window.innerHeight - bottom - 8 - VIEWPORT_MARGIN)}px` + ); + this.isOpen.set(true); } - onMenuOpened(): void { - this.isMenuOpen.set(true); - this.syncMenuOverlayWidthToTrigger(); - if (this.showSearchField()) { - this.focusSearchField(); - } - if (!this.runtime.supportsSourceHealth) - void this.checkPortalStatuses(this.playlists()); - } - - onMenuClosed(): void { - this.isMenuOpen.set(false); - this.clearMenuOverlayWidth(); - this.clearSearchFocusTimeout(); - this.cancelPortalStatusChecks(); - if (this.hasSearchToggle()) { - this.searchExpanded.set(false); - } - } - - toggleSearchField(event?: Event): void { - event?.stopPropagation(); - const next = !this.searchExpanded(); - this.searchExpanded.set(next); - if (next) { - this.focusSearchField(); - } - } - - setSearchQuery(value: string): void { - this.searchQuery.set(value); - this.writePersistedSearchQuery(value); - } - - togglePlaylistTypeFilter(type: PlaylistFilterType, event?: Event): void { - event?.stopPropagation(); - const current = this.playlistTypeFilters(); - const next = { - ...current, - [type]: !current[type], - }; - - if (!next.m3u && !next.stalker && !next.xtream) { + /** Closes the popover; focus goes back to the trigger. */ + close(): void { + if (!this.isOpen()) { return; } - - this.playlistTypeFilters.set(next); - this.writePersistedTypeFilters(next); + this.isOpen.set(false); + this.trigger().nativeElement.focus(); } - isTypeFilterSelected(type: PlaylistFilterType): boolean { - return this.playlistTypeFilters()[type]; - } - - selectPlaylist(playlist: PlaylistMeta): void { - this.menuTrigger().closeMenu(); - this.playlistContext.selectPlaylist(playlist); - this.playlistSelected.emit(playlist._id); - } - - requestPlaylistInfo(): void { - this.menuTrigger().closeMenu(); - this.playlistInfoRequested.emit(); - } - - requestAccountInfo(): void { - this.menuTrigger().closeMenu(); - this.accountInfoRequested.emit(); - } - - requestAddPlaylist(): void { - this.menuTrigger().closeMenu(); - this.addPlaylistRequested.emit(); - } - - hasType(type: PlaylistFilterType): boolean { - return this.availablePlaylistTypes().has(type); - } - - canRefreshPlaylist(playlist: PlaylistMeta): boolean { - return this.refreshAction.canRefresh(playlist); - } - - openPlaylistInfoFor(playlist: PlaylistMeta, event?: Event): void { - event?.stopPropagation(); - this.menuTrigger().closeMenu(); - this.dialog.open(PlaylistInfoComponent, { data: playlist }); - } - - hasAccountInfo(playlist: PlaylistMeta): boolean { - return isPortalAccountPlaylist(playlist); - } - - requestAccountInfoFor(playlist: PlaylistMeta, event?: Event): void { - event?.stopPropagation(); - this.menuTrigger().closeMenu(); - this.accountInfoForPlaylistRequested.emit(playlist); - } - - refreshPlaylistFor(playlist: PlaylistMeta, event?: Event): void { - event?.stopPropagation(); - this.menuTrigger().closeMenu(); - this.refreshAction.refresh(playlist); - } - - removePlaylistFor(playlist: PlaylistMeta, event?: Event): void { - event?.stopPropagation(); - this.menuTrigger().closeMenu(); - this.dialogService.openConfirmDialog({ - title: this.translate.instant('HOME.PLAYLISTS.REMOVE_DIALOG.TITLE'), - message: this.translate.instant( - 'HOME.PLAYLISTS.REMOVE_DIALOG.MESSAGE' - ), - confirmLabel: this.translate.instant('HOME.PLAYLISTS.REMOVE'), - tone: 'destructive', - onConfirm: () => this.removePlaylistConfirmed(playlist), - }); - } - - private async removePlaylistConfirmed( - playlist: PlaylistMeta - ): Promise { - const deleted = - await this.playlistDeleteAction.deletePlaylist(playlist); - - if (!deleted) { - return; - } - - this.store.dispatch( - PlaylistActions.playlistRemovalCommitted({ - playlistId: playlist._id, - }) - ); - this.snackBar.open( - this.translate.instant('HOME.PLAYLISTS.REMOVE_DIALOG.SUCCESS'), - undefined, - { duration: 2000 } - ); - } - - getPlaylistIcon(playlist: PlaylistMeta): string { - return getPlaylistSourceIcon(playlist); - } - - getPlaylistMetaLabel(playlist: PlaylistMeta): string { - if (playlist.macAddress) { - return this.translate.instant('WORKSPACE.SHELL.STALKER_PORTAL'); - } - if (playlist.serverUrl) { - return this.translate.instant('WORKSPACE.SHELL.XTREAM_CODE'); - } - return this.translate.instant('HOME.PLAYLISTS.CHANNELS_COUNT', { - count: playlist.count ?? 0, - }); - } - - getStatusClass(playlistId: string): string { - const status = this.portalStatuses().get(playlistId); - // No entry yet means we haven't checked (or are mid-flight without a - // 'checking' write). Render no status class instead of misleading the - // user with the red 'unavailable' dot. - if (!status) { - return ''; - } - return this.portalStatusService.getStatusClass(status); - } - - isSelected(playlist: PlaylistMeta): boolean { - return this.activePlaylistId() === playlist._id; - } - - private getPlaylistFilterType(playlist: PlaylistMeta): PlaylistFilterType { - if (playlist.macAddress) { - return 'stalker'; - } - if (playlist.serverUrl) { - return 'xtream'; - } - return 'm3u'; - } - - private async checkPortalStatuses(playlists: PlaylistMeta[]) { - // Cancel any prior in-flight round so its results can't overwrite the - // new round (e.g. user closes + reopens the menu rapidly). - this.cancelPortalStatusChecks(); - const controller = new AbortController(); - this.portalStatusAbortController = controller; - - const xtreamPlaylists = playlists.filter( - ( - playlist - ): playlist is PlaylistMeta & { - serverUrl: string; - username: string; - password: string; - } => - Boolean( - playlist.serverUrl && playlist.username && playlist.password - ) - ); - if (xtreamPlaylists.length === 0) { - return; - } - - // Hydrate from the shared service cache + mark uncached portals as - // 'checking' in a single signal write so the UI flips from blank → - // cached/checking dots in one render, not one per playlist. - const next = new Map(this.portalStatuses()); - const toFetch: (PlaylistMeta & { - serverUrl: string; - username: string; - password: string; - })[] = []; - for (const playlist of xtreamPlaylists) { - const cached = this.portalStatusService.getCachedStatus( - playlist.serverUrl, - playlist.username, - playlist.password - ); - if (cached !== null) { - next.set(playlist._id, cached); - } else { - next.set(playlist._id, 'checking'); - toFetch.push(playlist); - } - } - this.portalStatuses.set(next); - - if (toFetch.length === 0) { - return; - } - - // Stream results: each portal's dot updates as soon as ITS request - // resolves, independent of the slowest one. Replaces the prior - // Promise.all that blocked all dots on the tail latency. - await Promise.all( - toFetch.map(async (playlist) => { - let status: PortalStatus; - try { - status = await this.portalStatusService.checkPortalStatus( - playlist.serverUrl, - playlist.username, - playlist.password - ); - } catch { - status = 'unavailable'; - } - - if (controller.signal.aborted) { - return; - } - - this.portalStatuses.update((current) => { - const updated = new Map(current); - updated.set(playlist._id, status); - return updated; - }); - }) - ); - } - - private cancelPortalStatusChecks(): void { - if (this.portalStatusAbortController) { - this.portalStatusAbortController.abort(); - this.portalStatusAbortController = null; - } - } - - private syncMenuOverlayWidthToTrigger(): void { - const triggerWidth = Math.round( - this.triggerElement().nativeElement.getBoundingClientRect().width - ); - const overlayWidth = Math.max(triggerWidth, 400); - this.document.documentElement.style.setProperty( - '--playlist-switcher-overlay-width', - `${overlayWidth}px` - ); - } - - private clearMenuOverlayWidth(): void { - this.document.documentElement.style.removeProperty( - '--playlist-switcher-overlay-width' - ); - } - - private focusSearchField(): void { - this.clearSearchFocusTimeout(); - this.focusSearchTimeoutId = setTimeout(() => { - const input = this.searchInput()?.nativeElement; - if (!input) { - return; - } - input.focus(); - input.select(); - this.focusSearchTimeoutId = null; - }, 0); - } - - private clearSearchFocusTimeout(): void { - if (this.focusSearchTimeoutId !== null) { - clearTimeout(this.focusSearchTimeoutId); - this.focusSearchTimeoutId = null; - } - } - - private readPersistedSearchQuery(): string { - try { - return localStorage.getItem(SEARCH_QUERY_STORAGE_KEY) ?? ''; - } catch { - return ''; - } - } - - private writePersistedSearchQuery(value: string): void { - try { - localStorage.setItem(SEARCH_QUERY_STORAGE_KEY, value); - } catch { - // Ignore storage write failures. - } - } - - private readPersistedTypeFilters(): Record { - try { - const raw = localStorage.getItem(PLAYLIST_TYPE_FILTER_STORAGE_KEY); - if (!raw) { - return { ...DEFAULT_PLAYLIST_TYPE_FILTERS }; - } - - const parsed = JSON.parse(raw) as Partial< - Record - >; - const next = { - m3u: parsed.m3u !== false, - stalker: parsed.stalker !== false, - xtream: parsed.xtream !== false, - }; - - if (!next.m3u && !next.stalker && !next.xtream) { - return { ...DEFAULT_PLAYLIST_TYPE_FILTERS }; - } - - return next; - } catch { - return { ...DEFAULT_PLAYLIST_TYPE_FILTERS }; - } - } - - private writePersistedTypeFilters( - filters: Record - ): void { - try { - localStorage.setItem( - PLAYLIST_TYPE_FILTER_STORAGE_KEY, - JSON.stringify(filters) - ); - } catch { - // Ignore storage write failures. + onOverlayKeydown(event: KeyboardEvent): void { + if (event.key === 'Escape') { + event.preventDefault(); + this.close(); } } } diff --git a/libs/playlist/shared/ui/src/lib/source-removal/source-removal-toast.component.spec.ts b/libs/playlist/shared/ui/src/lib/source-removal/source-removal-toast.component.spec.ts new file mode 100644 index 000000000..827a0cb28 --- /dev/null +++ b/libs/playlist/shared/ui/src/lib/source-removal/source-removal-toast.component.spec.ts @@ -0,0 +1,83 @@ +import { TestBed } from '@angular/core/testing'; +import { + MAT_SNACK_BAR_DATA, + MatSnackBarRef, +} from '@angular/material/snack-bar'; +import { Subject } from 'rxjs'; +import { SourceRemovalToastComponent } from './source-removal-toast.component'; + +describe('SourceRemovalToastComponent', () => { + let opened: Subject; + let ref: { + afterOpened: () => Subject; + dismiss: jest.Mock; + dismissWithAction: jest.Mock; + }; + + const render = () => { + const fixture = TestBed.createComponent(SourceRemovalToastComponent); + fixture.detectChanges(); + document.body.appendChild(fixture.nativeElement); + return fixture.nativeElement as HTMLElement; + }; + + beforeEach(() => { + opened = new Subject(); + ref = { + afterOpened: () => opened, + dismiss: jest.fn(), + dismissWithAction: jest.fn(), + }; + TestBed.configureTestingModule({ + imports: [SourceRemovalToastComponent], + providers: [ + { + provide: MAT_SNACK_BAR_DATA, + useValue: { + message: 'Removed “Alpha”', + undoLabel: 'Undo', + closeLabel: 'Close', + }, + }, + { provide: MatSnackBarRef, useValue: ref }, + ], + }); + }); + + it('undoes, or commits at once from the close button', () => { + const element = render(); + expect(element.textContent).toContain('Removed “Alpha”'); + element + .querySelector('.source-removal-toast__undo') + ?.click(); + expect(ref.dismissWithAction).toHaveBeenCalled(); + element + .querySelector('.source-removal-toast__close') + ?.click(); + expect(ref.dismiss).toHaveBeenCalled(); + expect( + element + .querySelector('.source-removal-toast__close') + ?.getAttribute('aria-label') + ).toBe('Close'); + }); + + it('takes the focus when the delete left it nowhere', () => { + const element = render(); + (document.activeElement as HTMLElement | null)?.blur(); + opened.next(); + expect(document.activeElement).toBe( + element.querySelector('.source-removal-toast__undo') + ); + }); + + it('leaves the focus where the user put it', () => { + render(); + const elsewhere = document.createElement('button'); + document.body.appendChild(elsewhere); + elsewhere.focus(); + opened.next(); + expect(document.activeElement).toBe(elsewhere); + elsewhere.remove(); + }); +}); diff --git a/libs/playlist/shared/ui/src/lib/source-removal/source-removal-toast.component.ts b/libs/playlist/shared/ui/src/lib/source-removal/source-removal-toast.component.ts index 21c230170..08cfc9d05 100644 --- a/libs/playlist/shared/ui/src/lib/source-removal/source-removal-toast.component.ts +++ b/libs/playlist/shared/ui/src/lib/source-removal/source-removal-toast.component.ts @@ -1,4 +1,11 @@ -import { ChangeDetectionStrategy, Component, inject } from '@angular/core'; +import { DOCUMENT } from '@angular/common'; +import { + ChangeDetectionStrategy, + Component, + ElementRef, + inject, + viewChild, +} from '@angular/core'; import { MatButton, MatIconButton } from '@angular/material/button'; import { MatIcon } from '@angular/material/icon'; import { @@ -17,7 +24,9 @@ export interface SourceRemovalToastData { /** * "Removed …" with Undo, and a close button that commits the delete at - * once instead of waiting out the toast. + * once instead of waiting out the toast. A delete from a row's menu takes + * the row, and with it the focus, off the page: Undo then takes the focus, + * so the keyboard can reach it. */ @Component({ selector: 'app-source-removal-toast', @@ -33,6 +42,7 @@ export interface SourceRemovalToastData { {{ data.message }}