mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-14 21:26:15 -08:00
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:
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);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
@@ -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": "جارٍ التحميل",
|
||||
|
||||
@@ -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": "كيتشارجا",
|
||||
|
||||
@@ -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": "Загрузка",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "Φόρτωση",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "読み込み中",
|
||||
|
||||
@@ -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": "로드 중",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "Загрузка",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "加载中",
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
+167
@@ -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>
|
||||
+185
@@ -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;
|
||||
}
|
||||
}
|
||||
+238
@@ -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']);
|
||||
});
|
||||
});
|
||||
+281
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
+29
-258
@@ -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>
|
||||
+9
-451
@@ -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;
|
||||
}
|
||||
}
|
||||
+84
-445
@@ -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,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
|
||||
|
||||
-5
@@ -59,11 +59,6 @@
|
||||
class="playlist-switcher"
|
||||
[currentTitle]="playlistTitle()"
|
||||
[subtitle]="playlistSubtitle()"
|
||||
[showPlaylistInfo]="canOpenPlaylistInfo()"
|
||||
[showAccountInfo]="canOpenAccountInfo()"
|
||||
[showAddPlaylist]="true"
|
||||
(playlistInfoRequested)="onPlaylistInfoRequested()"
|
||||
(accountInfoRequested)="onAccountInfoRequested()"
|
||||
(accountInfoForPlaylistRequested)="
|
||||
onAccountInfoForPlaylistRequested($event)
|
||||
"
|
||||
|
||||
-5
@@ -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>();
|
||||
}
|
||||
|
||||
|
||||
-12
@@ -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();
|
||||
|
||||
+2
-2
@@ -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>
|
||||
|
||||
Reference in new issue
Block a user