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

@@ -59,11 +59,6 @@
class="playlist-switcher"
[currentTitle]="playlistTitle()"
[subtitle]="playlistSubtitle()"
[showPlaylistInfo]="canOpenPlaylistInfo()"
[showAccountInfo]="canOpenAccountInfo()"
[showAddPlaylist]="true"
(playlistInfoRequested)="onPlaylistInfoRequested()"
(accountInfoRequested)="onAccountInfoRequested()"
(accountInfoForPlaylistRequested)="
onAccountInfoForPlaylistRequested($event)
"
@@ -31,11 +31,6 @@ import { LayoutSlideDirective } from '../../layout-slide.directive';
class MockPlaylistSwitcherComponent {
readonly currentTitle = input.required<string>();
readonly subtitle = input('');
readonly showPlaylistInfo = input(false);
readonly showAccountInfo = input(false);
readonly showAddPlaylist = input(false);
readonly playlistInfoRequested = output<void>();
readonly accountInfoRequested = output<void>();
readonly addPlaylistRequested = output<void>();
}
@@ -52,8 +52,6 @@ export class WorkspaceShellHeaderComponent {
readonly backTarget = input<WorkspaceBackTarget | null>(null);
readonly playlistTitle = input('');
readonly playlistSubtitle = input('');
readonly canOpenPlaylistInfo = input(false);
readonly canOpenAccountInfo = input(false);
readonly searchQuery = input('');
readonly canUseSearch = input(false);
readonly searchPlaceholder = input('');
@@ -143,8 +141,6 @@ export class WorkspaceShellHeaderComponent {
readonly headerSidebarToggleRequested = output<void>();
readonly parentalLockToggleRequested = output<void>();
readonly downloadsRequested = output<void>();
readonly playlistInfoRequested = output<void>();
readonly accountInfoRequested = output<void>();
readonly contextDrawerToggleRequested = output<void>();
readonly accountInfoForPlaylistRequested = output<PlaylistMeta>();
@@ -196,14 +192,6 @@ export class WorkspaceShellHeaderComponent {
this.backRequested.emit();
}
onPlaylistInfoRequested(): void {
this.playlistInfoRequested.emit();
}
onAccountInfoRequested(): void {
this.accountInfoRequested.emit();
}
onAccountInfoForPlaylistRequested(playlist: PlaylistMeta): void {
this.accountInfoForPlaylistRequested.emit(playlist);
}
@@ -33,8 +33,6 @@
[backTarget]="facade.backTarget()"
[playlistTitle]="facade.playlistTitle()"
[playlistSubtitle]="facade.playlistSubtitle()"
[canOpenPlaylistInfo]="facade.canOpenPlaylistInfo()"
[canOpenAccountInfo]="facade.canOpenAccountInfo()"
[searchQuery]="facade.searchQuery()"
[canUseSearch]="facade.canUseSearch()"
[searchPlaceholder]="facade.searchPlaceholder()"
@@ -63,8 +61,6 @@
(headerSidebarToggleRequested)="facade.toggleLiveSidebar()"
(parentalLockToggleRequested)="facade.toggleParentalLock()"
(downloadsRequested)="facade.openDownloadsShortcut()"
(playlistInfoRequested)="facade.openPlaylistInfo()"
(accountInfoRequested)="facade.openAccountInfo()"
(accountInfoForPlaylistRequested)="facade.openAccountInfoFor($event)"
/>
@@ -50,8 +50,6 @@ class MockWorkspaceShellHeaderComponent {
readonly backTarget = input<unknown>(null);
readonly playlistTitle = input('');
readonly playlistSubtitle = input('');
readonly canOpenPlaylistInfo = input(false);
readonly canOpenAccountInfo = input(false);
readonly searchQuery = input('');
readonly canUseSearch = input(false);
readonly searchPlaceholder = input('');
@@ -79,8 +77,6 @@ class MockWorkspaceShellHeaderComponent {
readonly downloadsRequested = output<void>();
readonly headerSidebarToggleRequested = output<void>();
readonly parentalLockToggleRequested = output<void>();
readonly playlistInfoRequested = output<void>();
readonly accountInfoRequested = output<void>();
readonly contextDrawerToggleRequested = output<void>();
focusSearchInput = jest.fn();
@@ -21,8 +21,8 @@
class="side-list__action"
data-test-id="sources-add-playlist"
(click)="addPlaylist()"
[matTooltip]="'WORKSPACE.SHELL.ADD_PLAYLIST' | translate"
[attr.aria-label]="'WORKSPACE.SHELL.ADD_PLAYLIST' | translate"
[matTooltip]="'WORKSPACE.SOURCES.ADD_SOURCE' | translate"
[attr.aria-label]="'WORKSPACE.SOURCES.ADD_SOURCE' | translate"
>
<mat-icon>add</mat-icon>
</button>