feat(switcher): rebuild the playlist switcher as an overlay popover

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 <noreply@anthropic.com>
This commit is contained in:
4grayandClaude Opus 5.5 committed 2026-10-12 22:34:22 +02:00
1 parent 106414c118
commit 9feab4f83b
49 files changed
+1987 -1813

No files matched your search

@@ -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<void> {
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<void> {
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<void> {
@@ -503,16 +596,21 @@ async function selectSettingsOption(
}
async function expectXtreamUnavailableWarning(page: Page): Promise<void> {
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<void> {
async function toggleFavoriteForChannel(
page: Page,
title: string
): Promise<void> {
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/
);
}
@@ -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);
}
});
});
+12 -3
View File
@@ -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
@@ -80,7 +80,7 @@ export async function importPlaylist(page: Page): Promise<string> {
.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();
}
+7 -4
View File
@@ -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();
+4 -7
View File
@@ -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();
}
+13 -2
View File
@@ -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": "جارٍ التحميل",
+13 -2
View File
@@ -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": "كيتشارجا",
+13 -2
View File
@@ -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": "Загрузка",
+13 -2
View File
@@ -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",
+13 -2
View File
@@ -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": "Φόρτωση",
+13 -2
View File
@@ -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",
+13 -2
View File
@@ -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",
+13 -2
View File
@@ -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",
+13 -2
View File
@@ -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",
+13 -2
View File
@@ -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",
+13 -2
View File
@@ -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": "読み込み中",
+13 -2
View File
@@ -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": "로드 중",
+13 -2
View File
@@ -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",
+13 -2
View File
@@ -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",
+13 -2
View File
@@ -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",
+13 -2
View File
@@ -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": "Загрузка",
+13 -2
View File
@@ -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",
+13 -2
View File
@@ -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": "加载中",
+13 -2
View File
@@ -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": "載入中",
@@ -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<PlaylistMeta> = {}) =>
({
_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');
});
});
@@ -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<string, boolean> | 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;
}
@@ -0,0 +1,167 @@
<div
class="switcher"
role="dialog"
aria-modal="true"
aria-labelledby="playlist-switcher-title"
cdkTrapFocus
>
<header class="switcher__header">
<h2 id="playlist-switcher-title" class="switcher__title">
{{ 'HOME.PLAYLISTS.MY_PLAYLISTS' | translate }}
</h2>
<span class="switcher__count">{{ playlists().length }}</span>
<span class="switcher__spacer"></span>
<button
mat-icon-button
type="button"
class="switcher__action"
data-test-id="playlist-switcher-add"
(click)="addPlaylist()"
[matTooltip]="'WORKSPACE.SHELL.ADD_PLAYLIST' | translate"
[attr.aria-label]="'WORKSPACE.SHELL.ADD_PLAYLIST' | translate"
>
<mat-icon>add</mat-icon>
</button>
<button
mat-icon-button
type="button"
class="switcher__action"
(click)="openAllSources()"
[matTooltip]="'WORKSPACE.SWITCHER.ALL_SOURCES' | translate"
[attr.aria-label]="'WORKSPACE.SWITCHER.ALL_SOURCES' | translate"
>
<mat-icon>open_in_new</mat-icon>
</button>
</header>
<label class="switcher__search">
<mat-icon aria-hidden="true">search</mat-icon>
<input
#searchInput
type="text"
role="combobox"
autocomplete="off"
spellcheck="false"
aria-autocomplete="list"
aria-expanded="true"
aria-keyshortcuts="Shift+F10"
[attr.aria-controls]="listId"
[attr.aria-activedescendant]="activeOptionId()"
[attr.aria-label]="'WORKSPACE.SWITCHER.SEARCH' | translate"
[placeholder]="'WORKSPACE.SWITCHER.SEARCH' | translate"
[value]="query()"
(input)="setQuery(searchInput.value)"
(keydown)="onSearchKeydown($event)"
/>
<kbd aria-hidden="true">↑↓ ↵</kbd>
</label>
@if (showTypeFilter()) {
<mat-button-toggle-group
class="switcher__types"
hideSingleSelectionIndicator
[value]="type()"
(change)="setType($event.value)"
[attr.aria-label]="'WORKSPACE.SOURCES.TYPE' | translate"
>
<mat-button-toggle value="all">{{
'WORKSPACE.SOURCES.ALL' | translate
}}</mat-button-toggle>
@for (kind of kinds; track kind) {
@if (typeCounts()[kind] > 0) {
<mat-button-toggle [value]="kind"
>{{ kindLabels[kind] }}
<span class="switcher__type-count">{{
typeCounts()[kind]
}}</span></mat-button-toggle
>
}
}
</mat-button-toggle-group>
}
<div
#list
class="switcher__list"
role="listbox"
[id]="listId"
[attr.aria-label]="'HOME.PLAYLISTS.MY_PLAYLISTS' | translate"
>
@if (groups().recent.length) {
<div role="group" aria-labelledby="playlist-switcher-recent">
<div id="playlist-switcher-recent" class="switcher__group">
{{ 'WORKSPACE.SWITCHER.RECENT' | translate }}
</div>
@for (option of groups().recent; track option.id) {
<ng-container
*ngTemplateOutlet="
row;
context: { $implicit: option, index: $index }
"
/>
}
</div>
}
<div role="group" aria-labelledby="playlist-switcher-all">
@if (groups().recent.length) {
<div id="playlist-switcher-all" class="switcher__group">
{{ 'WORKSPACE.SOURCES.ALL' | translate }}
</div>
}
@for (option of groups().all; track option.id) {
<ng-container
*ngTemplateOutlet="
row;
context: {
$implicit: option,
index: groups().recent.length + $index,
}
"
/>
}
</div>
</div>
@if (!options().length) {
<p class="switcher__empty" role="status">
{{ 'WORKSPACE.SWITCHER.NO_MATCHES' | translate }}
</p>
}
<footer class="switcher__footer">
<span class="switcher__hints" aria-hidden="true">
<kbd>↑↓</kbd>{{ 'WORKSPACE.SWITCHER.HINT_SELECT' | translate }}
<kbd>↵</kbd>{{ 'WORKSPACE.SWITCHER.HINT_OPEN' | translate }}
<kbd>esc</kbd>{{ 'WORKSPACE.SWITCHER.HINT_CLOSE' | translate }}
</span>
<span class="switcher__spacer"></span>
<button
mat-button
type="button"
class="switcher__all"
(click)="openAllSources()"
>
{{ 'WORKSPACE.SWITCHER.ALL_SOURCES' | translate }}
<mat-icon iconPositionEnd>arrow_forward</mat-icon>
</button>
</footer>
</div>
<ng-template #row let-option let-index="index">
<app-source-row
size="compact"
role="option"
[attr.id]="option.id"
[attr.aria-selected]="index === highlight()"
[attr.aria-current]="option.playlist._id === currentId() || null"
[playlist]="option.playlist"
[current]="option.playlist._id === currentId()"
[highlighted]="index === highlight()"
[canRefresh]="canRefresh(option.playlist)"
(activated)="select(option.playlist)"
(refreshRequested)="refresh(option.playlist)"
(playlistInfoRequested)="openInfo(option.playlist)"
(accountInfoRequested)="openAccountInfo(option.playlist)"
(removeRequested)="remove(option.playlist)"
(menuClosed)="searchInput.focus()"
/>
</ng-template>
@@ -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;
}
}
@@ -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<PlaylistMeta> = {}) =>
({
_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<PlaylistSwitcherPanelComponent>;
let component: PlaylistSwitcherPanelComponent;
let playlists: ReturnType<typeof signal<PlaylistMeta[]>>;
let pending: ReturnType<typeof signal<ReadonlySet<string>>>;
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<ReadonlySet<string>>(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<SortOptions>({
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']);
});
});
@@ -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<ElementRef<HTMLElement>>('list');
private readonly searchInput =
viewChild.required<ElementRef<HTMLInputElement>>('searchInput');
private readonly rows = viewChildren(SourceRowComponent);
private readonly rowElements = viewChildren(SourceRowComponent, {
read: ElementRef,
});
readonly closeRequested = output<void>();
readonly playlistSelected = output<string>();
readonly addPlaylistRequested = output<void>();
readonly accountInfoRequested = output<PlaylistMeta>();
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<SourcesTypeFilter>(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<SwitcherOption[]>(() => [
...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<HTMLElement>(`#${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;
}
}
}
@@ -1,14 +1,15 @@
<button
#trigger
type="button"
class="playlist-switcher-trigger"
data-test-id="playlist-switcher-trigger"
[class.is-open]="isMenuOpen()"
[matMenuTriggerFor]="playlistMenu"
#menuTrigger="matMenuTrigger"
#triggerElement
cdkOverlayOrigin
#origin="cdkOverlayOrigin"
aria-haspopup="dialog"
aria-keyshortcuts="Meta+P Control+P"
(menuOpened)="onMenuOpened()"
(menuClosed)="onMenuClosed()"
[attr.aria-expanded]="isOpen()"
[class.is-open]="isOpen()"
(click)="toggle()"
>
@if (activePlaylist(); as active) {
<app-source-tile
@@ -25,260 +26,30 @@
<span class="name">{{ displayTitle() }}</span>
<span class="channels-count">{{ triggerSubtitle() }}</span>
</span>
<mat-icon class="dropdown-icon" [class.rotated]="isMenuOpen()"
<mat-icon class="dropdown-icon" [class.rotated]="isOpen()"
>expand_more</mat-icon
>
</button>
<mat-menu
#playlistMenu="matMenu"
panelClass="playlist-switcher-menu"
hasBackdrop
backdropClass="playlist-switcher-menu-backdrop"
overlapTrigger="false"
xPosition="after"
yPosition="below"
<ng-template
cdkConnectedOverlay
[cdkConnectedOverlayOrigin]="origin"
[cdkConnectedOverlayOpen]="isOpen()"
[cdkConnectedOverlayPositions]="positions"
[cdkConnectedOverlayHasBackdrop]="true"
[cdkConnectedOverlayPush]="true"
[cdkConnectedOverlayViewportMargin]="8"
cdkConnectedOverlayBackdropClass="playlist-switcher-backdrop"
cdkConnectedOverlayPanelClass="playlist-switcher-pane"
(backdropClick)="close()"
(detach)="close()"
(overlayKeydown)="onOverlayKeydown($event)"
>
<div class="menu-header">
<mat-icon class="menu-header-icon">playlist_play</mat-icon>
<span class="menu-header-title">{{
'HOME.PLAYLISTS.MY_PLAYLISTS' | translate
}}</span>
@if (hasSearchToggle()) {
<button
mat-icon-button
type="button"
class="search-toggle-btn"
[class.is-active]="searchExpanded()"
(click)="toggleSearchField($event)"
[attr.aria-label]="
'HOME.PLAYLISTS.SEARCH_PLAYLISTS' | translate
"
>
<mat-icon>search</mat-icon>
</button>
}
</div>
@if (showSearchField()) {
<div class="search-container" (click)="$event.stopPropagation()">
<mat-icon class="search-icon">search</mat-icon>
<input
#searchInput
type="text"
class="search-input"
[placeholder]="'HOME.PLAYLISTS.SEARCH_PLAYLISTS' | translate"
[ngModel]="searchQuery()"
(ngModelChange)="setSearchQuery($event)"
/>
@if (searchQuery()) {
<button
mat-icon-button
type="button"
class="clear-btn"
[attr.aria-label]="'CLEAR_SEARCH' | translate"
(click)="setSearchQuery('')"
>
<mat-icon>close</mat-icon>
</button>
}
</div>
}
@if (showTypeFilters()) {
<div class="type-filters" (click)="$event.stopPropagation()">
@if (hasType('m3u')) {
<button
type="button"
class="type-filter-chip"
[class.selected]="isTypeFilterSelected('m3u')"
(click)="togglePlaylistTypeFilter('m3u', $event)"
>
M3U
</button>
}
@if (hasType('stalker')) {
<button
type="button"
class="type-filter-chip"
[class.selected]="isTypeFilterSelected('stalker')"
(click)="togglePlaylistTypeFilter('stalker', $event)"
>
Stalker
</button>
}
@if (hasType('xtream')) {
<button
type="button"
class="type-filter-chip"
[class.selected]="isTypeFilterSelected('xtream')"
(click)="togglePlaylistTypeFilter('xtream', $event)"
>
Xtream
</button>
}
</div>
}
<div class="menu-content">
@for (playlist of filteredPlaylists(); track playlist._id) {
<div
class="playlist-item"
[class.selected]="isSelected(playlist)"
(click)="selectPlaylist(playlist)"
>
<!-- @if (isSelected(playlist)) {
<mat-icon class="selected-indicator">check</mat-icon>
} @else {
<div class="selected-indicator-placeholder"></div>
} -->
<div class="playlist-icon-container">
<mat-icon class="playlist-type-icon">
{{ getPlaylistIcon(playlist) }}
</mat-icon>
@if (runtime.supportsSourceHealth) {
<app-source-health-indicator
[playlist]="playlist"
[enabled]="isMenuOpen()"
/>
} @else if (playlist.serverUrl) {
<span
class="status-dot"
[class]="getStatusClass(playlist._id)"
></span>
}
@if (
playlist.autoRefresh &&
!playlist.serverUrl &&
!playlist.macAddress
) {
<mat-icon class="auto-refresh-indicator"
>autorenew</mat-icon
>
}
</div>
<div class="playlist-details">
<span class="playlist-title">
{{ playlist.title || playlist.filename }}
</span>
<span class="playlist-meta">
{{ getPlaylistMetaLabel(playlist) }}
@if (playlist.updateDate) {
<span class="separator">·</span>
{{
playlist.updateDate
| date
: 'MMM d, HH:mm'
: undefined
: currentLocale()
}}
}
</span>
</div>
<button
type="button"
class="item-action-trigger"
mat-icon-button
[matMenuTriggerFor]="itemMenu"
[matMenuTriggerData]="{ playlist: playlist }"
(click)="$event.stopPropagation()"
[attr.aria-label]="
'HOME.PLAYLISTS.PLAYLIST_ACTIONS' | translate
"
>
<mat-icon>more_vert</mat-icon>
</button>
</div>
} @empty {
<div class="empty-state">
<mat-icon>playlist_add</mat-icon>
<span>{{ 'HOME.PLAYLISTS.NO_PLAYLISTS' | translate }}</span>
</div>
}
</div>
@if (showAddPlaylist() || showAccountInfo() || showPlaylistInfo()) {
<div class="context-actions-section">
@if (showPlaylistInfo()) {
<button
class="context-action-item"
(click)="requestPlaylistInfo()"
>
<mat-icon>info</mat-icon>
<span>{{
'WORKSPACE.SHELL.PLAYLIST_INFO' | translate
}}</span>
</button>
}
@if (showAccountInfo()) {
<button
class="context-action-item"
(click)="requestAccountInfo()"
>
<mat-icon>person</mat-icon>
<span>{{
'WORKSPACE.SHELL.ACCOUNT_INFO' | translate
}}</span>
</button>
}
@if (showAddPlaylist()) {
<button
class="context-action-item"
(click)="requestAddPlaylist()"
>
<mat-icon>add</mat-icon>
<span>{{
'WORKSPACE.SHELL.ADD_PLAYLIST' | translate
}}</span>
</button>
}
</div>
}
</mat-menu>
<mat-menu #itemMenu="matMenu" class="playlist-item-action-menu">
<ng-template matMenuContent let-playlist="playlist">
<button mat-menu-item (click)="openPlaylistInfoFor(playlist, $event)">
<mat-icon>info</mat-icon>
<span>{{ 'WORKSPACE.SHELL.PLAYLIST_INFO' | translate }}</span>
</button>
@if (hasAccountInfo(playlist)) {
<button
mat-menu-item
(click)="requestAccountInfoFor(playlist, $event)"
>
<mat-icon>person</mat-icon>
<span>{{ 'WORKSPACE.SHELL.ACCOUNT_INFO' | translate }}</span>
</button>
}
@if (runtime.supportsSourceHealth && sourceHealthType(playlist)) {
<button mat-menu-item (click)="recheckSource(playlist)">
<mat-icon>network_check</mat-icon
><span>{{ 'SOURCE_HEALTH.RECHECK' | translate }}</span>
</button>
}
@if (canRefreshPlaylist(playlist)) {
<button
mat-menu-item
(click)="refreshPlaylistFor(playlist, $event)"
>
<mat-icon>sync</mat-icon>
<span>{{
'WORKSPACE.SHELL.REFRESH_PLAYLIST' | translate
}}</span>
</button>
}
<mat-divider />
<button
mat-menu-item
class="danger"
(click)="removePlaylistFor(playlist, $event)"
>
<mat-icon>delete</mat-icon>
<span>{{ 'HOME.PLAYLISTS.REMOVE_DIALOG.TITLE' | translate }}</span>
</button>
</ng-template>
</mat-menu>
<app-playlist-switcher-panel
[style.--playlist-switcher-max-height]="maxHeight()"
(closeRequested)="close()"
(playlistSelected)="playlistSelected.emit($event)"
(addPlaylistRequested)="addPlaylistRequested.emit()"
(accountInfoRequested)="accountInfoForPlaylistRequested.emit($event)"
/>
</ng-template>
@@ -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;
}
}
@@ -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<PlaylistMeta> & { _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: '<input class="stub-search" />',
changeDetection: ChangeDetectionStrategy.OnPush,
})
class StubPanelComponent {
readonly closeRequested = output<void>();
readonly playlistSelected = output<string>();
readonly addPlaylistRequested = output<void>();
readonly accountInfoRequested = output<PlaylistMeta>();
}
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<PlaylistSwitcherComponent>;
let component: PlaylistSwitcherComponent;
let playlistsSignal: ReturnType<typeof signal<PlaylistMeta[]>>;
let resolvedPlaylistIdSignal: ReturnType<typeof signal<string | null>>;
let activePlaylistSignal: ReturnType<typeof signal<PlaylistMeta | null>>;
let playlistContext: {
playlists: typeof playlistsSignal;
allPlaylistsLoaded: ReturnType<typeof signal<boolean>>;
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<typeof signal<PlaylistMeta | null>>;
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<void> {
await TestBed.configureTestingModule({
beforeEach(() => {
active = signal<PlaylistMeta | null>(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<string | null>(xtreamPlaylist._id);
activePlaylistSignal = signal<PlaylistMeta | null>(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<HTMLButtonElement>(
'.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<void>;
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);
@@ -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<PlaylistFilterType, boolean> = {
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<typeof setTimeout> | 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<ElementRef<HTMLButtonElement>>('trigger');
readonly currentTitle = input.required<string>();
/** Shown under the name while no source is active ("3 sources"). */
readonly subtitle = input<string>('');
readonly showPlaylistInfo = input(false);
readonly showAccountInfo = input(false);
readonly showAddPlaylist = input(false);
readonly playlistSelected = output<string>();
readonly playlistInfoRequested = output<void>();
readonly accountInfoRequested = output<void>();
readonly accountInfoForPlaylistRequested = output<PlaylistMeta>();
readonly addPlaylistRequested = output<void>();
readonly menuTrigger = viewChild.required<MatMenuTrigger>('menuTrigger');
readonly playlistMenu = viewChild.required<MatMenu>('playlistMenu');
readonly triggerElement =
viewChild.required<ElementRef<HTMLElement>>('triggerElement');
readonly searchInput =
viewChild<ElementRef<HTMLInputElement>>('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<string | null>(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<PlaylistFilterType>();
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<Map<string, PortalStatus>>(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<void> {
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<PlaylistFilterType, boolean> {
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<PlaylistFilterType, boolean>
>;
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<PlaylistFilterType, boolean>
): 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();
}
}
}
@@ -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<void>;
let ref: {
afterOpened: () => Subject<void>;
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<void>();
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<HTMLButtonElement>('.source-removal-toast__undo')
?.click();
expect(ref.dismissWithAction).toHaveBeenCalled();
element
.querySelector<HTMLButtonElement>('.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();
});
});
@@ -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 {
<span matSnackBarLabel>{{ data.message }}</span>
<span matSnackBarActions>
<button
#undo
mat-button
matSnackBarAction
type="button"
@@ -59,4 +69,17 @@ export class SourceRemovalToastComponent {
readonly data = inject<SourceRemovalToastData>(MAT_SNACK_BAR_DATA);
readonly ref =
inject<MatSnackBarRef<SourceRemovalToastComponent>>(MatSnackBarRef);
private readonly undo = viewChild.required('undo', { read: ElementRef });
constructor() {
const document = inject(DOCUMENT);
// After the opening animation: by then a closing menu has tried, and
// failed, to give focus back to the removed row's ⋯ button.
this.ref.afterOpened().subscribe(() => {
const active = document.activeElement;
if (!active || active === document.body) {
(this.undo().nativeElement as HTMLElement).focus();
}
});
}
}
+1
View File
@@ -1,4 +1,5 @@
export * from './lib/playlist-context.facade';
export * from './lib/playlist-recent-history.service';
export * from './lib/playlist-file-import.service';
export * from './lib/source-display/source-display';
export * from './lib/source-display/source-format';
@@ -22,6 +22,7 @@ import {
PortalRailSection,
} from '@iptvnator/portal/shared/util';
import { RuntimeCapabilitiesService } from '@iptvnator/services';
import { PlaylistRecentHistoryService } from './playlist-recent-history.service';
export interface PlaylistRouteContext {
inWorkspace: boolean;
@@ -73,6 +74,7 @@ export class PlaylistContextFacade {
private readonly runtime = inject(RuntimeCapabilitiesService);
private readonly router = inject(Router);
private readonly store = inject(Store);
private readonly recentHistory = inject(PlaylistRecentHistoryService);
readonly playlists = this.store.selectSignal(selectAllPlaylistsMeta);
readonly allPlaylistsLoaded = this.store.selectSignal(
@@ -539,6 +541,7 @@ export class PlaylistContextFacade {
}
private writePersistedActivePlaylistId(playlistId: string): void {
this.recentHistory.record(playlistId);
try {
localStorage.setItem(LAST_ACTIVE_PLAYLIST_STORAGE_KEY, playlistId);
} catch {
@@ -0,0 +1,35 @@
import { TestBed } from '@angular/core/testing';
import {
PLAYLIST_RECENT_HISTORY_STORAGE_KEY,
PlaylistRecentHistoryService,
} from './playlist-recent-history.service';
describe('PlaylistRecentHistoryService', () => {
const create = () => {
TestBed.configureTestingModule({
providers: [PlaylistRecentHistoryService],
});
return TestBed.inject(PlaylistRecentHistoryService);
};
beforeEach(() => localStorage.clear());
afterEach(() => TestBed.resetTestingModule());
it('keeps the three most recently opened, newest first', () => {
const history = create();
for (const id of ['a', 'b', 'c', 'b', 'd']) {
history.record(id);
}
expect(history.ids()).toEqual(['d', 'b', 'c']);
});
it('survives a restart and ignores unreadable storage', () => {
create().record('a');
TestBed.resetTestingModule();
expect(create().ids()).toEqual(['a']);
TestBed.resetTestingModule();
localStorage.setItem(PLAYLIST_RECENT_HISTORY_STORAGE_KEY, '{oops');
expect(create().ids()).toEqual([]);
});
});
@@ -0,0 +1,51 @@
import { Injectable, signal } from '@angular/core';
export const PLAYLIST_RECENT_HISTORY_STORAGE_KEY =
'playlist-switcher:recent:v1';
/** The switcher's "Recent" group shows this many. */
export const PLAYLIST_RECENT_HISTORY_SIZE = 3;
function readIds(): string[] {
try {
const parsed: unknown = JSON.parse(
localStorage.getItem(PLAYLIST_RECENT_HISTORY_STORAGE_KEY) ?? '[]'
);
return Array.isArray(parsed)
? parsed
.filter((id): id is string => typeof id === 'string' && !!id)
.slice(0, PLAYLIST_RECENT_HISTORY_SIZE)
: [];
} catch {
return [];
}
}
/**
* The playlists opened most recently, newest first, kept across restarts.
* IDs of deleted playlists stay until pushed out; readers skip the ones that
* no longer exist.
*/
@Injectable({ providedIn: 'root' })
export class PlaylistRecentHistoryService {
private readonly state = signal<readonly string[]>(readIds());
readonly ids = this.state.asReadonly();
record(playlistId: string): void {
if (!playlistId || this.state()[0] === playlistId) {
return;
}
const next = [
playlistId,
...this.state().filter((id) => id !== playlistId),
].slice(0, PLAYLIST_RECENT_HISTORY_SIZE);
this.state.set(next);
try {
localStorage.setItem(
PLAYLIST_RECENT_HISTORY_STORAGE_KEY,
JSON.stringify(next)
);
} catch {
// The list still works for this session.
}
}
}
@@ -97,6 +97,12 @@ describe('sourceMetaParts', () => {
expect(parts[4].text).toBe('https://cdn.tv/…/list.m3u');
});
it('counts a single channel in the singular', () => {
expect(sourceMetaParts({ ...m3u, count: 1 }, context())[1].text).toBe(
'WORKSPACE.SOURCES.ROW.CHANNELS_ONE {"count":"1"}'
);
});
it('leaves out empty parts and zero channels', () => {
const parts = sourceMetaParts(
{ title: 'x', count: 0, importDate: '' },
@@ -142,7 +142,12 @@ export function sourceMetaParts(
const count = new Intl.NumberFormat(locale).format(playlist.count ?? 0);
parts.push({
kind: 'channels',
text: translate('HOME.PLAYLISTS.CHANNELS_COUNT', { count }),
text: translate(
playlist.count === 1
? 'WORKSPACE.SOURCES.ROW.CHANNELS_ONE'
: 'HOME.PLAYLISTS.CHANNELS_COUNT',
{ count }
),
});
}
const added = compact
@@ -59,11 +59,6 @@
class="playlist-switcher"
[currentTitle]="playlistTitle()"
[subtitle]="playlistSubtitle()"
[showPlaylistInfo]="canOpenPlaylistInfo()"
[showAccountInfo]="canOpenAccountInfo()"
[showAddPlaylist]="true"
(playlistInfoRequested)="onPlaylistInfoRequested()"
(accountInfoRequested)="onAccountInfoRequested()"
(accountInfoForPlaylistRequested)="
onAccountInfoForPlaylistRequested($event)
"
@@ -31,11 +31,6 @@ import { LayoutSlideDirective } from '../../layout-slide.directive';
class MockPlaylistSwitcherComponent {
readonly currentTitle = input.required<string>();
readonly subtitle = input('');
readonly showPlaylistInfo = input(false);
readonly showAccountInfo = input(false);
readonly showAddPlaylist = input(false);
readonly playlistInfoRequested = output<void>();
readonly accountInfoRequested = output<void>();
readonly addPlaylistRequested = output<void>();
}
@@ -52,8 +52,6 @@ export class WorkspaceShellHeaderComponent {
readonly backTarget = input<WorkspaceBackTarget | null>(null);
readonly playlistTitle = input('');
readonly playlistSubtitle = input('');
readonly canOpenPlaylistInfo = input(false);
readonly canOpenAccountInfo = input(false);
readonly searchQuery = input('');
readonly canUseSearch = input(false);
readonly searchPlaceholder = input('');
@@ -143,8 +141,6 @@ export class WorkspaceShellHeaderComponent {
readonly headerSidebarToggleRequested = output<void>();
readonly parentalLockToggleRequested = output<void>();
readonly downloadsRequested = output<void>();
readonly playlistInfoRequested = output<void>();
readonly accountInfoRequested = output<void>();
readonly contextDrawerToggleRequested = output<void>();
readonly accountInfoForPlaylistRequested = output<PlaylistMeta>();
@@ -196,14 +192,6 @@ export class WorkspaceShellHeaderComponent {
this.backRequested.emit();
}
onPlaylistInfoRequested(): void {
this.playlistInfoRequested.emit();
}
onAccountInfoRequested(): void {
this.accountInfoRequested.emit();
}
onAccountInfoForPlaylistRequested(playlist: PlaylistMeta): void {
this.accountInfoForPlaylistRequested.emit(playlist);
}
@@ -33,8 +33,6 @@
[backTarget]="facade.backTarget()"
[playlistTitle]="facade.playlistTitle()"
[playlistSubtitle]="facade.playlistSubtitle()"
[canOpenPlaylistInfo]="facade.canOpenPlaylistInfo()"
[canOpenAccountInfo]="facade.canOpenAccountInfo()"
[searchQuery]="facade.searchQuery()"
[canUseSearch]="facade.canUseSearch()"
[searchPlaceholder]="facade.searchPlaceholder()"
@@ -63,8 +61,6 @@
(headerSidebarToggleRequested)="facade.toggleLiveSidebar()"
(parentalLockToggleRequested)="facade.toggleParentalLock()"
(downloadsRequested)="facade.openDownloadsShortcut()"
(playlistInfoRequested)="facade.openPlaylistInfo()"
(accountInfoRequested)="facade.openAccountInfo()"
(accountInfoForPlaylistRequested)="facade.openAccountInfoFor($event)"
/>
@@ -50,8 +50,6 @@ class MockWorkspaceShellHeaderComponent {
readonly backTarget = input<unknown>(null);
readonly playlistTitle = input('');
readonly playlistSubtitle = input('');
readonly canOpenPlaylistInfo = input(false);
readonly canOpenAccountInfo = input(false);
readonly searchQuery = input('');
readonly canUseSearch = input(false);
readonly searchPlaceholder = input('');
@@ -79,8 +77,6 @@ class MockWorkspaceShellHeaderComponent {
readonly downloadsRequested = output<void>();
readonly headerSidebarToggleRequested = output<void>();
readonly parentalLockToggleRequested = output<void>();
readonly playlistInfoRequested = output<void>();
readonly accountInfoRequested = output<void>();
readonly contextDrawerToggleRequested = output<void>();
focusSearchInput = jest.fn();
@@ -21,8 +21,8 @@
class="side-list__action"
data-test-id="sources-add-playlist"
(click)="addPlaylist()"
[matTooltip]="'WORKSPACE.SHELL.ADD_PLAYLIST' | translate"
[attr.aria-label]="'WORKSPACE.SHELL.ADD_PLAYLIST' | translate"
[matTooltip]="'WORKSPACE.SOURCES.ADD_SOURCE' | translate"
[attr.aria-label]="'WORKSPACE.SOURCES.ADD_SOURCE' | translate"
>
<mat-icon>add</mat-icon>
</button>