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

The switcher's mat-menu becomes a CDK connected overlay
(app-playlist-switcher-panel), 440px wide, with a dimmed, blurred
backdrop; only its list scrolls and row menus open above it.

- Header with the source count, Add playlist and All sources.
- Search by name or host, autofocused; the type filter is one segmented
  control (All / M3U / Xtream / Stalker with counts) instead of three
  checkboxes, remembered between openings (the old checkbox setting is
  migrated). The search is no longer kept between openings.
- "Recent" (the three most recently opened, kept across restarts) above
  "All" in the Sources page order, as compact source rows: the current
  one tinted with a check. Each row's ⋯ menu holds Refresh, Playlist
  info, Account info, Check again and Remove (with Undo); the footer's
  info, account and add actions moved there and to the header.
- Keyboard: a listbox driven by aria-activedescendant from the search
  field. Arrow keys move and wrap, Enter opens, Shift+F10 opens the
  row's menu, Escape closes and returns focus to the trigger. Hints in
  the footer.
- Deleting from a row menu moves focus to the toast's Undo, since the
  row (and its focus) left the page.

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

No files matched your search

@@ -245,44 +245,41 @@ test.describe('Electron Playlist Switcher', () => {
await waitForM3uCatalog(app.mainWindow);
await openPlaylistFavorites(app.mainWindow);
await expect(channelItemByTitle(app.mainWindow, localAChannelName)).toHaveCount(
1
);
await expect(channelItemByTitle(app.mainWindow, localBChannelName)).toHaveCount(
0
);
await expect(
channelItemByTitle(app.mainWindow, localAChannelName)
).toHaveCount(1);
await expect(
channelItemByTitle(app.mainWindow, localBChannelName)
).toHaveCount(0);
await switchPlaylistFromHeader(app.mainWindow, localBDisplayName);
await expectPathname(
app.mainWindow,
/\/workspace\/global-favorites$/
);
await expect(channelItemByTitle(app.mainWindow, localBChannelName)).toHaveCount(
1
);
await expect(channelItemByTitle(app.mainWindow, localAChannelName)).toHaveCount(
0
);
await expect(
channelItemByTitle(app.mainWindow, localBChannelName)
).toHaveCount(1);
await expect(
channelItemByTitle(app.mainWindow, localAChannelName)
).toHaveCount(0);
await openPlaylistRecent(app.mainWindow);
await expect(channelItemByTitle(app.mainWindow, localBChannelName)).toHaveCount(
1
);
await expect(channelItemByTitle(app.mainWindow, localAChannelName)).toHaveCount(
0
);
await expect(
channelItemByTitle(app.mainWindow, localBChannelName)
).toHaveCount(1);
await expect(
channelItemByTitle(app.mainWindow, localAChannelName)
).toHaveCount(0);
await switchPlaylistFromHeader(app.mainWindow, localADisplayName);
await expectPathname(
app.mainWindow,
/\/workspace\/global-recent$/
);
await expect(channelItemByTitle(app.mainWindow, localAChannelName)).toHaveCount(
1
);
await expect(channelItemByTitle(app.mainWindow, localBChannelName)).toHaveCount(
0
);
await expectPathname(app.mainWindow, /\/workspace\/global-recent$/);
await expect(
channelItemByTitle(app.mainWindow, localAChannelName)
).toHaveCount(1);
await expect(
channelItemByTitle(app.mainWindow, localBChannelName)
).toHaveCount(0);
} finally {
await closeElectronApp(app);
}
@@ -319,7 +316,9 @@ test.describe('Electron Playlist Switcher', () => {
username: xtreamStressUsername,
});
const overlay = app.mainWindow.locator('.workspace-loading-overlay');
const overlay = app.mainWindow.locator(
'.workspace-loading-overlay'
);
await expect(overlay).toBeVisible({ timeout: 15000 });
await waitForDbOperationEvent(app.mainWindow, {
operation: 'save-content',
@@ -428,17 +427,116 @@ test.describe('Electron Playlist Switcher', () => {
await switchPlaylistFromHeader(app.mainWindow, offlineXtreamName);
await waitForXtreamCatalog(app.mainWindow);
await expectXtreamUnavailableWarning(app.mainWindow);
await expect(app.mainWindow.locator('.xtream-content-gate')).toHaveCount(
0
);
await expect(
app.mainWindow.locator('.xtream-content-gate')
).toHaveCount(0);
app = await restartElectronApp(app, dataDir);
await waitForXtreamCatalog(app.mainWindow);
await expectXtreamUnavailableWarning(app.mainWindow);
await expect(app.mainWindow.locator('.xtream-content-gate')).toHaveCount(
0
await expect(
app.mainWindow.locator('.xtream-content-gate')
).toHaveCount(0);
} finally {
await closeElectronApp(app);
}
});
test('runs from the keyboard: Cmd/Ctrl+P, filter, arrows, Enter and Escape', async ({
dataDir,
request,
}) => {
await resetMockServers(request, ['xtream']);
const localAPath = writeTemporaryM3uFile(dataDir, localAFileName, [
{
groupTitle: 'News',
name: 'Switcher Local A',
url: 'https://streams.example.test/switcher-local-a.m3u8',
},
]);
const localBPath = writeTemporaryM3uFile(dataDir, localBFileName, [
{
groupTitle: 'Sports',
name: 'Switcher Local B',
url: 'https://streams.example.test/switcher-local-b.m3u8',
},
]);
const app = await launchElectronApp(dataDir);
const page = app.mainWindow;
try {
await importM3uPlaylistFromNativeDialog(app, localAPath);
await importM3uPlaylistFromNativeDialog(app, localBPath);
await addXtreamPortal(page, { name: xtreamAName });
await waitForXtreamCatalog(page);
const trigger = page.getByTestId('playlist-switcher-trigger');
const panel = page.locator('app-playlist-switcher-panel');
const search = panel.locator('input[role="combobox"]');
await expect(playlistSwitcherTitle(page)).toContainText(
xtreamAName
);
await page.keyboard.press('ControlOrMeta+p');
await expect(search).toBeFocused();
// No list shows a credential: the Xtream row names the account
// on purpose, never its password.
await expect(panel).not.toContainText(defaultXtreamPassword);
await expect(panel).not.toContainText('password=');
await page.keyboard.type('switcher-local');
await expect(switcherOptions(page)).toHaveCount(2);
// A search highlights its first match; ↓ moves on, ↑ wraps.
const options = switcherOptions(page);
const firstId = (await options.nth(0).getAttribute('id')) ?? '';
const secondId = (await options.nth(1).getAttribute('id')) ?? '';
await expect(search).toHaveAttribute(
'aria-activedescendant',
firstId
);
await page.keyboard.press('ArrowDown');
await expect(search).toHaveAttribute(
'aria-activedescendant',
secondId
);
await page.keyboard.press('ArrowDown');
await expect(search).toHaveAttribute(
'aria-activedescendant',
firstId
);
await page.keyboard.press('ArrowUp');
await expect(search).toHaveAttribute(
'aria-activedescendant',
secondId
);
const targetName =
(
await options
.nth(1)
.locator('.source-row__name')
.textContent()
)?.trim() ?? '';
await page.keyboard.press('Enter');
await expect(panel).toHaveCount(0);
await waitForM3uCatalog(page);
await expect(playlistSwitcherTitle(page)).toContainText(targetName);
// Escape closes and puts focus back on the trigger.
await page.keyboard.press('ControlOrMeta+p');
await expect(search).toBeFocused();
await page.keyboard.press('Escape');
await expect(panel).toHaveCount(0);
await expect(trigger).toBeFocused();
await openSources(page);
await expect(
page.locator('app-workspace-sources app-source-row')
).toHaveCount(3);
await expect(
page.locator('app-workspace-sources')
).not.toContainText(defaultXtreamPassword);
} finally {
await closeElectronApp(app);
}
@@ -446,12 +544,12 @@ test.describe('Electron Playlist Switcher', () => {
});
async function openPlaylistSwitcher(page: Page): Promise<void> {
await page
.locator('app-playlist-switcher .playlist-switcher-trigger')
.click();
await expect(
page.locator('.cdk-overlay-pane .playlist-item').first()
).toBeVisible();
await page.getByTestId('playlist-switcher-trigger').click();
await expect(switcherOptions(page).first()).toBeVisible();
}
function switcherOptions(page: Page) {
return page.locator('app-playlist-switcher-panel [role="option"]');
}
async function switchPlaylistFromHeader(
@@ -459,16 +557,11 @@ async function switchPlaylistFromHeader(
title: string
): Promise<void> {
await openPlaylistSwitcher(page);
const item = page
.locator('.cdk-overlay-pane .playlist-item')
.filter({ hasText: title })
.first();
const item = switcherOptions(page).filter({ hasText: title }).first();
await expect(item).toBeVisible();
await item.click();
await expect(page.locator('.cdk-overlay-pane .playlist-item')).toHaveCount(
0
);
await expect(page.locator('app-playlist-switcher-panel')).toHaveCount(0);
}
async function expectPathname(page: Page, pattern: RegExp): Promise<void> {
@@ -503,16 +596,21 @@ async function selectSettingsOption(
}
async function expectXtreamUnavailableWarning(page: Page): Promise<void> {
await expect(
page.getByTestId('xtream-offline-warning')
).toBeVisible({ timeout: 20000 });
await expect(page.getByTestId('xtream-offline-warning')).toBeVisible({
timeout: 20000,
});
}
async function toggleFavoriteForChannel(page: Page, title: string): Promise<void> {
async function toggleFavoriteForChannel(
page: Page,
title: string
): Promise<void> {
const item = channelItemByTitle(page, title).first();
await expect(item).toBeVisible({ timeout: 20000 });
await item.hover();
await item.locator('.favorite-button').first().click();
await expect(item.locator('.favorite-button mat-icon').first()).toHaveText(/star/);
await expect(item.locator('.favorite-button mat-icon').first()).toHaveText(
/star/
);
}
@@ -175,4 +175,69 @@ test.describe('Electron Sources keyboard', () => {
await closeElectronApp(app);
}
});
test('@m3u @electron deletes from the row menu and undoes from the toast, keyboard only', async ({
dataDir,
}) => {
const alpha = writeTemporaryM3uFile(dataDir, 'undo-alpha.m3u', [
{
groupTitle: 'News',
name: 'Undo Alpha News',
url: 'https://streams.example.test/undo-alpha.m3u8',
},
]);
const bravo = writeTemporaryM3uFile(dataDir, 'undo-bravo.m3u', [
{
groupTitle: 'Sports',
name: 'Undo Bravo Sports',
url: 'https://streams.example.test/undo-bravo.m3u8',
},
]);
const app = await launchElectronApp(dataDir, {
appArgs: [alpha, bravo],
});
const page = app.mainWindow;
const rows = page.locator('app-workspace-sources app-source-row');
try {
await page.waitForURL(/\/workspace\/playlists\/.+/, {
timeout: 30_000,
});
await openSources(page);
await expect(rows).toHaveCount(2, { timeout: 30_000 });
const before = await rows
.locator('.source-row__name')
.allTextContents();
// Focus the first row's ⋯ and open its menu from the keyboard.
const row = rows.first();
await row.locator('.source-row__more').focus();
await page.keyboard.press('Enter');
const remove = page.getByRole('menuitem', {
name: 'Remove playlist',
});
await expect(remove).toBeVisible();
await page.keyboard.press('End');
await expect(remove).toBeFocused();
await page.keyboard.press('Enter');
// The row leaves at once; Undo takes the focus the row had.
await expect(rows).toHaveCount(1);
const undo = page.locator(
'mat-snack-bar-container.app-toast .source-removal-toast__undo'
);
await expect(undo).toBeFocused();
await page.keyboard.press('Enter');
await expect(rows).toHaveCount(2);
expect(
await rows.locator('.source-row__name').allTextContents()
).toEqual(before);
await expect(
page.locator('mat-snack-bar-container.app-toast')
).toHaveCount(0);
} finally {
await closeElectronApp(app);
}
});
});
+12 -3
View File
@@ -785,11 +785,20 @@ https://streams.example.test/original-url.m3u8
try {
await dropM3uPlaylistOntoWorkspace(app.mainWindow, localFilePath);
await waitForM3uCatalog(app.mainWindow);
// Refresh for the active source lives in its switcher row menu.
await app.mainWindow.getByTestId('playlist-switcher-trigger').click();
const currentRow = app.mainWindow.locator(
'app-playlist-switcher-panel [role="option"][aria-current="true"]'
);
await currentRow.locator('.source-row__more').click();
await expect(
app.mainWindow.locator(
'app-playlist-switcher .trigger-refresh-button'
)
app.mainWindow.getByRole('menuitem', { name: 'Refresh playlist' })
).toBeVisible();
await app.mainWindow.keyboard.press('Escape');
await app.mainWindow.keyboard.press('Escape');
await expect(
app.mainWindow.locator('app-playlist-switcher-panel')
).toHaveCount(0);
await goToDashboard(app.mainWindow);
const sourceCard = app.mainWindow