feat(switcher): restyle the toolbar trigger and open it with Cmd/Ctrl+P

- The trigger is a 40px secondary button: the active source's tile with
  its status dot, its name and "type · channels" (or the source count),
  and a chevron that turns while open. The name comes from
  sourceDisplayName, so a URL-named playlist no longer shows its URL or
  credentials in the toolbar.
- Cmd+P / Ctrl+P opens the switcher wherever it is shown (Cmd+K stays
  the command palette) and is listed in the keyboard shortcuts dialog.
- The trigger's inline refresh button is gone; refresh stays in the
  playlist's ⋯ menu and the command palette.

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 401295c092
commit 106414c118
34 files changed
+317 -251

No files matched your search

+2 -1
View File
@@ -1996,7 +1996,8 @@
"COMMAND_PALETTE_NAVIGATION": "نقل تحديد لوحة الأوامر",
"COMMAND_PALETTE_RUN": "تشغيل عنصر لوحة الأوامر المحدد",
"CLOSE_DIALOGS": "إغلاق مربع الحوار أو إخفاء التراكب",
"DOWNLOADS_OPEN_ITEM": "فتح عنصر التنزيل المحدد في المكتبة"
"DOWNLOADS_OPEN_ITEM": "فتح عنصر التنزيل المحدد في المكتبة",
"OPEN_PLAYLIST_SWITCHER": "تبديل قائمة التشغيل"
}
},
"GLOBAL_FAVORITES": {
+2 -1
View File
@@ -1996,7 +1996,8 @@
"COMMAND_PALETTE_NAVIGATION": "حرك الاختيار فلوحة الأوامر",
"COMMAND_PALETTE_RUN": "شغل العنصر المختار فلوحة الأوامر",
"CLOSE_DIALOGS": "سد النافذة ولا خبي الطبقة",
"DOWNLOADS_OPEN_ITEM": "حل عنصر التحميل المحدد فالمكتبة"
"DOWNLOADS_OPEN_ITEM": "حل عنصر التحميل المحدد فالمكتبة",
"OPEN_PLAYLIST_SWITCHER": "بدّل الليستة"
}
},
"GLOBAL_FAVORITES": {
+2 -1
View File
@@ -1996,7 +1996,8 @@
"COMMAND_PALETTE_NAVIGATION": "Перамясціць выбар у палітры каманд",
"COMMAND_PALETTE_RUN": "Выканаць выбраны элемент палітры каманд",
"CLOSE_DIALOGS": "Закрыць дыялог або схаваць накладку",
"DOWNLOADS_OPEN_ITEM": "Адкрыць сфакусаваную спампоўку ў бібліятэцы"
"DOWNLOADS_OPEN_ITEM": "Адкрыць сфакусаваную спампоўку ў бібліятэцы",
"OPEN_PLAYLIST_SWITCHER": "Пераключыць плэйліст"
}
},
"GLOBAL_FAVORITES": {
+2 -1
View File
@@ -1996,7 +1996,8 @@
"COMMAND_PALETTE_NAVIGATION": "Auswahl in der Befehlspalette verschieben",
"COMMAND_PALETTE_RUN": "Ausgewählten Eintrag der Befehlspalette ausführen",
"CLOSE_DIALOGS": "Dialog schließen oder Overlay ausblenden",
"DOWNLOADS_OPEN_ITEM": "Fokussierten Download-Eintrag in der Bibliothek öffnen"
"DOWNLOADS_OPEN_ITEM": "Fokussierten Download-Eintrag in der Bibliothek öffnen",
"OPEN_PLAYLIST_SWITCHER": "Playlist wechseln"
}
},
"GLOBAL_FAVORITES": {
+2 -1
View File
@@ -1996,7 +1996,8 @@
"COMMAND_PALETTE_NAVIGATION": "Μετακίνηση επιλογής παλέτας εντολών",
"COMMAND_PALETTE_RUN": "Εκτέλεση επιλεγμένου στοιχείου παλέτας εντολών",
"CLOSE_DIALOGS": "Κλείσιμο διαλόγου ή απόκρυψη overlay",
"DOWNLOADS_OPEN_ITEM": "Άνοιγμα εστιασμένου στοιχείου λήψης στη βιβλιοθήκη"
"DOWNLOADS_OPEN_ITEM": "Άνοιγμα εστιασμένου στοιχείου λήψης στη βιβλιοθήκη",
"OPEN_PLAYLIST_SWITCHER": "Εναλλαγή λίστας"
}
},
"GLOBAL_FAVORITES": {
+2 -1
View File
@@ -1996,7 +1996,8 @@
"COMMAND_PALETTE_NAVIGATION": "Move command palette selection",
"COMMAND_PALETTE_RUN": "Run selected command palette item",
"CLOSE_DIALOGS": "Close dialog or dismiss overlay",
"DOWNLOADS_OPEN_ITEM": "Open focused download item in the library"
"DOWNLOADS_OPEN_ITEM": "Open focused download item in the library",
"OPEN_PLAYLIST_SWITCHER": "Switch playlist"
}
},
"GLOBAL_FAVORITES": {
+2 -1
View File
@@ -1996,7 +1996,8 @@
"COMMAND_PALETTE_NAVIGATION": "Mover la selección de la paleta de comandos",
"COMMAND_PALETTE_RUN": "Ejecutar el elemento seleccionado de la paleta de comandos",
"CLOSE_DIALOGS": "Cerrar diálogo o descartar superposición",
"DOWNLOADS_OPEN_ITEM": "Abrir en la biblioteca el elemento de descarga enfocado"
"DOWNLOADS_OPEN_ITEM": "Abrir en la biblioteca el elemento de descarga enfocado",
"OPEN_PLAYLIST_SWITCHER": "Cambiar de lista"
}
},
"GLOBAL_FAVORITES": {
+2 -1
View File
@@ -1996,7 +1996,8 @@
"COMMAND_PALETTE_NAVIGATION": "Déplacer la sélection dans la palette de commandes",
"COMMAND_PALETTE_RUN": "Exécuter l’élément sélectionné dans la palette de commandes",
"CLOSE_DIALOGS": "Fermer une boîte de dialogue ou masquer une superposition",
"DOWNLOADS_OPEN_ITEM": "Ouvrir l’élément de téléchargement ciblé dans la bibliothèque"
"DOWNLOADS_OPEN_ITEM": "Ouvrir l’élément de téléchargement ciblé dans la bibliothèque",
"OPEN_PLAYLIST_SWITCHER": "Changer de playlist"
}
},
"GLOBAL_FAVORITES": {
+2 -1
View File
@@ -1996,7 +1996,8 @@
"COMMAND_PALETTE_NAVIGATION": "Kijelölés mozgatása a parancspalettán",
"COMMAND_PALETTE_RUN": "A parancspalettán kijelölt művelet végrehajtása",
"CLOSE_DIALOGS": "Párbeszédpanel bezárása vagy fedőréteg eltüntetése",
"DOWNLOADS_OPEN_ITEM": "A fókuszban lévő letöltés megnyitása a médiatárban"
"DOWNLOADS_OPEN_ITEM": "A fókuszban lévő letöltés megnyitása a médiatárban",
"OPEN_PLAYLIST_SWITCHER": "Lejátszási lista váltása"
}
},
"GLOBAL_FAVORITES": {
+2 -1
View File
@@ -1996,7 +1996,8 @@
"COMMAND_PALETTE_NAVIGATION": "Sposta selezione della palette comandi",
"COMMAND_PALETTE_RUN": "Esegui elemento selezionato della palette comandi",
"CLOSE_DIALOGS": "Chiudi dialogo o nascondi overlay",
"DOWNLOADS_OPEN_ITEM": "Apri nella libreria l’elemento download selezionato"
"DOWNLOADS_OPEN_ITEM": "Apri nella libreria l’elemento download selezionato",
"OPEN_PLAYLIST_SWITCHER": "Cambia playlist"
}
},
"GLOBAL_FAVORITES": {
+2 -1
View File
@@ -1996,7 +1996,8 @@
"COMMAND_PALETTE_NAVIGATION": "コマンドパレットの選択を移動",
"COMMAND_PALETTE_RUN": "選択中のコマンドパレット項目を実行",
"CLOSE_DIALOGS": "ダイアログを閉じる、またはオーバーレイを解除",
"DOWNLOADS_OPEN_ITEM": "フォーカス中のダウンロード項目をライブラリで開く"
"DOWNLOADS_OPEN_ITEM": "フォーカス中のダウンロード項目をライブラリで開く",
"OPEN_PLAYLIST_SWITCHER": "プレイリストを切り替え"
}
},
"GLOBAL_FAVORITES": {
+2 -1
View File
@@ -1996,7 +1996,8 @@
"COMMAND_PALETTE_NAVIGATION": "명령 팔레트 선택 이동",
"COMMAND_PALETTE_RUN": "선택한 명령 팔레트 항목 실행",
"CLOSE_DIALOGS": "대화상자 닫기 또는 오버레이 해제",
"DOWNLOADS_OPEN_ITEM": "포커스된 다운로드 항목을 라이브러리에서 열기"
"DOWNLOADS_OPEN_ITEM": "포커스된 다운로드 항목을 라이브러리에서 열기",
"OPEN_PLAYLIST_SWITCHER": "재생목록 전환"
}
},
"GLOBAL_FAVORITES": {
+2 -1
View File
@@ -1996,7 +1996,8 @@
"COMMAND_PALETTE_NAVIGATION": "Selectie in opdrachtpalet verplaatsen",
"COMMAND_PALETTE_RUN": "Geselecteerd item in opdrachtpalet uitvoeren",
"CLOSE_DIALOGS": "Dialoogvenster sluiten of overlay verbergen",
"DOWNLOADS_OPEN_ITEM": "Gefocust downloaditem in de bibliotheek openen"
"DOWNLOADS_OPEN_ITEM": "Gefocust downloaditem in de bibliotheek openen",
"OPEN_PLAYLIST_SWITCHER": "Afspeellijst wisselen"
}
},
"GLOBAL_FAVORITES": {
+2 -1
View File
@@ -1996,7 +1996,8 @@
"COMMAND_PALETTE_NAVIGATION": "Przenieś zaznaczenie w palecie poleceń",
"COMMAND_PALETTE_RUN": "Uruchom zaznaczoną pozycję palety poleceń",
"CLOSE_DIALOGS": "Zamknij okno dialogowe lub ukryj nakładkę",
"DOWNLOADS_OPEN_ITEM": "Otwórz aktywne pobranie w bibliotece"
"DOWNLOADS_OPEN_ITEM": "Otwórz aktywne pobranie w bibliotece",
"OPEN_PLAYLIST_SWITCHER": "Przełącz playlistę"
}
},
"GLOBAL_FAVORITES": {
+2 -1
View File
@@ -1996,7 +1996,8 @@
"COMMAND_PALETTE_NAVIGATION": "Mover seleção da paleta de comandos",
"COMMAND_PALETTE_RUN": "Executar item selecionado da paleta de comandos",
"CLOSE_DIALOGS": "Fechar diálogo ou dispensar sobreposição",
"DOWNLOADS_OPEN_ITEM": "Abrir o download em foco na biblioteca"
"DOWNLOADS_OPEN_ITEM": "Abrir o download em foco na biblioteca",
"OPEN_PLAYLIST_SWITCHER": "Mudar de lista"
}
},
"GLOBAL_FAVORITES": {
+2 -1
View File
@@ -1996,7 +1996,8 @@
"COMMAND_PALETTE_NAVIGATION": "Переместить выбор в палитре команд",
"COMMAND_PALETTE_RUN": "Выполнить выбранный пункт палитры команд",
"CLOSE_DIALOGS": "Закрыть диалог или скрыть оверлей",
"DOWNLOADS_OPEN_ITEM": "Открыть выбранную загрузку в библиотеке"
"DOWNLOADS_OPEN_ITEM": "Открыть выбранную загрузку в библиотеке",
"OPEN_PLAYLIST_SWITCHER": "Переключить плейлист"
}
},
"GLOBAL_FAVORITES": {
+2 -1
View File
@@ -1996,7 +1996,8 @@
"COMMAND_PALETTE_NAVIGATION": "Komut paleti seçimini taşı",
"COMMAND_PALETTE_RUN": "Seçili komut paleti öğesini çalıştır",
"CLOSE_DIALOGS": "İletişim kutusunu kapat veya katmanı gizle",
"DOWNLOADS_OPEN_ITEM": "Odaktaki indirme öğesini kitaplıkta aç"
"DOWNLOADS_OPEN_ITEM": "Odaktaki indirme öğesini kitaplıkta aç",
"OPEN_PLAYLIST_SWITCHER": "Oynatma listesini değiştir"
}
},
"GLOBAL_FAVORITES": {
+2 -1
View File
@@ -1996,7 +1996,8 @@
"COMMAND_PALETTE_NAVIGATION": "移动命令面板选中项",
"COMMAND_PALETTE_RUN": "运行选中的命令面板项目",
"CLOSE_DIALOGS": "关闭对话框或取消覆盖层",
"DOWNLOADS_OPEN_ITEM": "在媒体库中打开聚焦的下载项"
"DOWNLOADS_OPEN_ITEM": "在媒体库中打开聚焦的下载项",
"OPEN_PLAYLIST_SWITCHER": "切换播放列表"
}
},
"GLOBAL_FAVORITES": {
+2 -1
View File
@@ -1996,7 +1996,8 @@
"COMMAND_PALETTE_NAVIGATION": "移動命令面板選取項",
"COMMAND_PALETTE_RUN": "執行選取的命令面板項目",
"CLOSE_DIALOGS": "關閉對話框或取消覆蓋層",
"DOWNLOADS_OPEN_ITEM": "在媒體庫中開啟聚焦的下載項目"
"DOWNLOADS_OPEN_ITEM": "在媒體庫中開啟聚焦的下載項目",
"OPEN_PLAYLIST_SWITCHER": "切換播放清單"
}
},
"GLOBAL_FAVORITES": {
+1
View File
@@ -1,5 +1,6 @@
export * from './lib/add-playlist-menu/playlist-type';
export * from './lib/playlist-switcher/playlist-switcher.component';
export * from './lib/playlist-switcher/playlist-switcher-open.service';
export * from './lib/recent-playlists/playlist-info/playlist-info.component';
export * from './lib/recent-playlists/playlist-info/stalker-playlist-connection-editor.token';
export * from './lib/recent-playlists/recent-playlists.component';
@@ -0,0 +1,31 @@
import { Injectable } from '@angular/core';
import { Subject } from 'rxjs';
/**
* Opens the toolbar playlist switcher from outside it: the ⌘P / Ctrl+P
* shortcut. The switcher subscribes while it is on screen (it is hidden on
* Settings), so a request with no switcher does nothing.
*/
@Injectable({ providedIn: 'root' })
export class PlaylistSwitcherOpenService {
private readonly requests = new Subject<void>();
readonly openRequests = this.requests.asObservable();
private listeners = 0;
/** Whether a switcher is on screen to take the request. */
get available(): boolean {
return this.listeners > 0;
}
request(): void {
this.requests.next();
}
/** Called by the switcher; the returned function unregisters it. */
register(): () => void {
this.listeners += 1;
return () => {
this.listeners -= 1;
};
}
}
@@ -1,43 +1,34 @@
<div
<button
type="button"
class="playlist-switcher-trigger"
data-test-id="playlist-switcher-trigger"
[class.is-open]="isMenuOpen()"
matRipple
[matRippleDisabled]="false"
[matMenuTriggerFor]="playlistMenu"
#menuTrigger="matMenuTrigger"
#triggerElement
aria-keyshortcuts="Meta+P Control+P"
(menuOpened)="onMenuOpened()"
(menuClosed)="onMenuClosed()"
>
<div class="trigger-icon-container">
<mat-icon class="trigger-playlist-icon">playlist_play</mat-icon>
</div>
<div class="playlist-info">
<div class="name">{{ displayTitle() }}</div>
<div class="channels-count">{{ subtitle() }}</div>
</div>
@if (canRefreshActivePlaylist()) {
<button
type="button"
mat-icon-button
class="trigger-refresh-button"
[disabled]="isRefreshingActivePlaylist()"
(click)="onRefreshClick($event)"
(mousedown)="$event.stopPropagation()"
[matTooltip]="'WORKSPACE.SHELL.REFRESH_PLAYLIST' | translate"
[attr.aria-label]="'WORKSPACE.SHELL.REFRESH_PLAYLIST' | translate"
>
<mat-icon
class="trigger-refresh-icon"
[class.is-spinning]="isRefreshingActivePlaylist()"
>sync</mat-icon
>
</button>
@if (activePlaylist(); as active) {
<app-source-tile
size="sm"
[playlist]="active"
[status]="activeStatus()"
/>
} @else {
<span class="trigger-placeholder-tile" aria-hidden="true">
<mat-icon>playlist_play</mat-icon>
</span>
}
<mat-icon class="dropdown-icon" [class.rotated]="isMenuOpen()">
expand_more
</mat-icon>
</div>
<span class="playlist-info">
<span class="name">{{ displayTitle() }}</span>
<span class="channels-count">{{ triggerSubtitle() }}</span>
</span>
<mat-icon class="dropdown-icon" [class.rotated]="isMenuOpen()"
>expand_more</mat-icon
>
</button>
<mat-menu
#playlistMenu="matMenu"
@@ -5,186 +5,125 @@
width: 100%;
}
// The trigger is a secondary button (UI kit): 40px, 10px radius, a 10%
// fill, the source's tile, two lines and a chevron that turns when open.
.playlist-switcher-trigger {
display: flex;
align-items: center;
gap: 8px;
gap: 10px;
width: 100%;
height: 40px;
padding: 0 10px 0 6px;
box-sizing: border-box;
padding: 4px 8px 4px 4px;
border: 0;
border-radius: 10px;
background: color-mix(in srgb, var(--app-on-surface) 10%, transparent);
color: var(--app-heading-color);
font: inherit;
text-align: start;
cursor: pointer;
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
background: color-mix(in srgb, var(--app-on-surface) 5%, transparent);
border: 1px solid
color-mix(in srgb, var(--mat-sys-outline-variant) 36%, transparent);
app-region: no-drag;
transition: background 0.12s ease;
&:hover {
background: color-mix(
in srgb,
var(--app-on-surface) 12%,
transparent
);
border-color: color-mix(
in srgb,
var(--mat-sys-outline-variant) 64%,
transparent
);
background: color-mix(in srgb, var(--app-on-surface) 16%, transparent);
}
// Open: one step up, with a 2px inset ring in the selection colour.
&.is-open {
background: color-mix(
in srgb,
var(--app-on-surface) 12%,
transparent
);
border-color: color-mix(
in srgb,
var(--mat-sys-outline-variant) 72%,
transparent
);
background: color-mix(in srgb, var(--app-on-surface) 14%, transparent);
box-shadow: inset 0 0 0 2px var(--app-selection-color);
}
&:active {
transform: scale(0.98);
&:focus-visible {
@include focus-ring.focus-ring-declarations;
}
app-source-tile {
--source-tile-bg: color-mix(
in srgb,
var(--app-on-surface) 10%,
var(--app-header-bg)
);
--source-tile-ring: var(--app-header-bg);
}
}
.trigger-icon-container {
width: 30px;
height: 30px;
border-radius: 8px;
display: inline-flex;
align-items: center;
justify-content: center;
background: color-mix(
in srgb,
var(--app-on-surface) 8%,
transparent
);
flex-shrink: 0;
}
.trigger-placeholder-tile {
flex: none;
display: grid;
place-items: center;
width: 28px;
height: 28px;
border-radius: 7px;
background: color-mix(in srgb, var(--app-on-surface) 10%, transparent);
color: var(--app-body-color);
.trigger-playlist-icon {
font-size: 18px;
width: 18px;
height: 18px;
color: var(--mat-sys-on-surface-variant);
.mat-icon {
width: 14px;
height: 14px;
font-size: 14px;
line-height: 14px;
}
}
.playlist-info {
display: flex;
flex: 1;
flex-direction: column;
min-width: 0;
.name {
font-size: 0.95em;
font-weight: 600;
.name,
.channels-count {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
line-height: 1.25;
letter-spacing: 0.01em;
}
.name {
font-size: 13px;
font-weight: 600;
line-height: 1.3;
}
.channels-count {
font-size: 0.74em;
font-size: 11px;
line-height: 1.3;
letter-spacing: 0.02em;
color: var(--mat-sys-on-surface-variant);
// A wrapping subtitle pushes the trigger past the 56px header row.
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
}
// On a phone the header row has no width to spare, and the shell allows this
// trigger to shrink to 88px there. The decorative type icon goes so that the
// trigger's fixed chrome (refresh, chevron, gaps, padding) actually fits that
// floor with the name ellipsizing; the subtitle goes because the name is the
// only part worth the room.
@media (max-width: 640px) {
.trigger-icon-container {
display: none;
}
.playlist-info .channels-count {
display: none;
}
}
.trigger-refresh-button {
margin-left: auto;
width: 32px;
height: 32px;
min-width: 32px;
min-height: 32px;
padding: 0;
line-height: 32px;
flex-shrink: 0;
display: inline-flex;
align-items: center;
justify-content: center;
color: var(--mat-sys-on-surface-variant);
transition:
color 0.15s ease,
background 0.15s ease;
&:hover:not(:disabled) {
color: var(--mat-sys-on-surface);
background: var(--mat-sys-surface-container);
}
::ng-deep .mat-mdc-button-touch-target {
width: 32px;
height: 32px;
}
::ng-deep .mat-mdc-button-persistent-ripple,
::ng-deep .mat-mdc-focus-indicator {
border-radius: 50%;
}
}
.trigger-refresh-icon {
font-size: 18px;
width: 18px;
height: 18px;
line-height: 18px;
margin: 0;
padding: 0;
display: inline-flex;
align-items: center;
justify-content: center;
&.is-spinning {
animation: switcher-refresh-spin 1s linear infinite;
}
}
@keyframes switcher-refresh-spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
color: var(--app-body-color);
}
}
.dropdown-icon {
font-size: 20px;
width: 20px;
height: 20px;
color: var(--mat-sys-on-surface-variant);
transition: transform 0.25s cubic-bezier(0.4, 0, 0.2, 1);
margin-left: 4px;
flex: none;
width: 12px;
height: 12px;
font-size: 12px;
line-height: 12px;
color: var(--app-body-color);
transition: transform 0.15s ease;
&.rotated {
transform: rotate(180deg);
}
}
@media (prefers-reduced-motion: reduce) {
.playlist-switcher-trigger,
.dropdown-icon {
transition: none;
}
}
// On a phone the header row has no width to spare and the trigger may
// shrink to 88px: the tile and the subtitle go, the name ellipsizes.
@media (max-width: 640px) {
.playlist-switcher-trigger app-source-tile,
.trigger-placeholder-tile,
.playlist-info .channels-count {
display: none;
}
}
::ng-deep .playlist-switcher-menu.mat-mdc-menu-panel {
--mat-menu-container-color: var(--mat-sys-surface-container-high);
width: min(
@@ -270,11 +209,7 @@
gap: 8px;
padding: 8px 12px;
margin: 0 8px 4px;
background: color-mix(
in srgb,
var(--app-on-surface) 6%,
transparent
);
background: color-mix(in srgb, var(--app-on-surface) 6%, transparent);
border-radius: 8px;
border: 1px solid transparent;
transition: all 0.2s ease;
@@ -429,7 +364,8 @@
height: 32px;
line-height: 32px;
flex-shrink: 0;
color: var(--mat-sys-on-surface-variant);padding: 0;
color: var(--mat-sys-on-surface-variant);
padding: 0;
mat-icon {
font-size: 18px;
@@ -454,11 +390,7 @@
}
.playlist-item:hover:not(.selected) {
background: color-mix(
in srgb,
var(--app-on-surface) 10%,
transparent
);
background: color-mix(in srgb, var(--app-on-surface) 10%, transparent);
}
.playlist-icon-container {
@@ -469,11 +401,7 @@
width: 36px;
height: 36px;
border-radius: 8px;
background: color-mix(
in srgb,
var(--app-on-surface) 6%,
transparent
);
background: color-mix(in srgb, var(--app-on-surface) 6%, transparent);
flex-shrink: 0;
}
@@ -21,6 +21,7 @@ import {
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';
const SEARCH_QUERY_STORAGE_KEY = 'playlist-switcher:search-query';
const PLAYLIST_TYPE_FILTER_STORAGE_KEY = 'playlist-switcher:type-filters';
@@ -447,4 +448,43 @@ describe('PlaylistSwitcherComponent', () => {
})
);
});
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(
'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(
'Meta+P Control+P'
);
});
it('opens on a ⌘P / Ctrl+P request while it is on screen', async () => {
await createComponent();
const switcherOpen = TestBed.inject(PlaylistSwitcherOpenService);
expect(switcherOpen.available).toBe(true);
switcherOpen.request();
fixture.detectChanges();
expect(component.isMenuOpen()).toBe(true);
fixture.destroy();
expect(switcherOpen.available).toBe(false);
});
});
@@ -3,9 +3,14 @@ import {
RuntimeCapabilitiesService,
injectTranslationTick,
} from '@iptvnator/services';
import { SourceHealthService } from '@iptvnator/portal/shared/data-access';
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';
import {
ChangeDetectionStrategy,
@@ -34,7 +39,11 @@ 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 { PlaylistContextFacade } from '@iptvnator/playlist/shared/util';
import {
PlaylistContextFacade,
sourceDisplayName,
sourceMetaParts,
} from '@iptvnator/playlist/shared/util';
import {
PlaylistDeleteActionService,
PortalStatus,
@@ -65,6 +74,7 @@ const DEFAULT_PLAYLIST_TYPE_FILTERS: Record<PlaylistFilterType, boolean> = {
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [
SourceHealthIndicatorComponent,
SourceTileComponent,
DatePipe,
FormsModule,
MatDivider,
@@ -91,6 +101,7 @@ export class PlaylistSwitcherComponent {
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);
@@ -104,20 +115,11 @@ export class PlaylistSwitcherComponent {
readonly showPlaylistInfo = input(false);
readonly showAccountInfo = input(false);
readonly showAddPlaylist = input(false);
readonly canRefreshActivePlaylist = input(false);
readonly isRefreshingActivePlaylist = input(false);
readonly playlistSelected = output<string>();
readonly playlistInfoRequested = output<void>();
readonly accountInfoRequested = output<void>();
readonly accountInfoForPlaylistRequested = output<PlaylistMeta>();
readonly addPlaylistRequested = output<void>();
readonly refreshPlaylistRequested = output<void>();
onRefreshClick(event: Event): void {
event.stopPropagation();
event.preventDefault();
this.refreshPlaylistRequested.emit();
}
readonly menuTrigger = viewChild.required<MatMenuTrigger>('menuTrigger');
readonly playlistMenu = viewChild.required<MatMenu>('playlistMenu');
@@ -158,21 +160,42 @@ export class PlaylistSwitcherComponent {
new Date(a.importDate ?? 0).getTime()
);
});
/** The active source's name; never its URL or a credential. */
readonly displayTitle = computed(() => {
this.languageTick();
if (!this.activePlaylistId()) {
return this.translate.instant('HOME.PLAYLISTS.SELECT_PLAYLIST');
}
const active = this.activePlaylist();
return (
this.currentTitle() ||
this.activePlaylist()?.title ||
this.activePlaylist()?.filename ||
this.activePlaylist()?.url ||
this.activePlaylist()?.portalUrl ||
(active ? sourceDisplayName(active) : this.currentTitle()) ||
this.translate.instant('HOME.PLAYLISTS.UNTITLED_PLAYLIST')
);
});
/** "Stalker", "M3U · 3,220 channels", or the header's source count. */
readonly triggerSubtitle = computed(() => {
this.languageTick();
const active = this.activePlaylist();
if (!active) {
return this.subtitle();
}
return sourceMetaParts(
active,
{
translate: (key, params) => this.translate.instant(key, params),
locale: this.currentLocale(),
now: Date.now(),
},
true
)
.filter((part) => part.kind === 'type' || part.kind === 'channels')
.map((part) => part.text)
.join(' · ');
});
readonly activeStatus = computed(() => {
const active = this.activePlaylist();
return active ? this.statusService.statusOf(active) : null;
});
readonly availablePlaylistTypes = computed(() => {
const types = new Set<PlaylistFilterType>();
@@ -210,6 +233,13 @@ export class PlaylistSwitcherComponent {
});
constructor() {
const openService = inject(PlaylistSwitcherOpenService);
const unregister = openService.register();
const requests = openService.openRequests.subscribe(() => this.open());
this.destroyRef.onDestroy(() => {
unregister();
requests.unsubscribe();
});
this.destroyRef.onDestroy(() => {
this.clearMenuOverlayWidth();
this.clearSearchFocusTimeout();
@@ -217,6 +247,11 @@ export class PlaylistSwitcherComponent {
});
}
/** Opens the switcher (⌘P / Ctrl+P). */
open(): void {
this.menuTrigger().openMenu();
}
onMenuOpened(): void {
this.isMenuOpen.set(true);
this.syncMenuOverlayWidthToTrigger();
@@ -1,8 +1,5 @@
export type KeyboardShortcutGroupId =
| 'global'
| 'navigation'
| 'playback'
| 'dialogs';
'global' | 'navigation' | 'playback' | 'dialogs';
export interface PlatformShortcutChord {
mac: readonly string[];
@@ -10,9 +7,7 @@ export interface PlatformShortcutChord {
}
export type KeyboardShortcutChord =
| string
| readonly string[]
| PlatformShortcutChord;
string | readonly string[] | PlatformShortcutChord;
export interface KeyboardShortcutDefinition {
id: string;
@@ -73,6 +68,14 @@ export const APP_KEYBOARD_SHORTCUTS: readonly KeyboardShortcutDefinition[] = [
keys: [commandChord('K')],
order: 10,
},
{
id: 'open-playlist-switcher',
group: 'global',
labelKey: 'WORKSPACE.SHORTCUTS.ITEMS.OPEN_PLAYLIST_SWITCHER',
icon: 'playlist_play',
keys: [commandChord('P')],
order: 15,
},
{
id: 'open-global-search',
group: 'global',
@@ -62,15 +62,12 @@
[showPlaylistInfo]="canOpenPlaylistInfo()"
[showAccountInfo]="canOpenAccountInfo()"
[showAddPlaylist]="true"
[canRefreshActivePlaylist]="canRefreshPlaylist()"
[isRefreshingActivePlaylist]="isRefreshingPlaylist()"
(playlistInfoRequested)="onPlaylistInfoRequested()"
(accountInfoRequested)="onAccountInfoRequested()"
(accountInfoForPlaylistRequested)="
onAccountInfoForPlaylistRequested($event)
"
(addPlaylistRequested)="onAddPlaylistRequested()"
(refreshPlaylistRequested)="onRefreshPlaylistRequested()"
/>
}
@@ -34,12 +34,9 @@ class MockPlaylistSwitcherComponent {
readonly showPlaylistInfo = input(false);
readonly showAccountInfo = input(false);
readonly showAddPlaylist = input(false);
readonly canRefreshActivePlaylist = input(false);
readonly isRefreshingActivePlaylist = input(false);
readonly playlistInfoRequested = output<void>();
readonly accountInfoRequested = output<void>();
readonly addPlaylistRequested = output<void>();
readonly refreshPlaylistRequested = output<void>();
}
describe('WorkspaceShellHeaderComponent', () => {
@@ -63,8 +63,6 @@ export class WorkspaceShellHeaderComponent {
readonly headerSidebarToggle = input<WorkspaceHeaderSidebarToggle | null>(
null
);
readonly canRefreshPlaylist = input(false);
readonly isRefreshingPlaylist = input(false);
readonly isElectron = input(false);
readonly hasNoPlaylists = input(false);
readonly isDownloadsView = input(false);
@@ -144,7 +142,6 @@ export class WorkspaceShellHeaderComponent {
readonly headerShortcutRequested = output<void>();
readonly headerSidebarToggleRequested = output<void>();
readonly parentalLockToggleRequested = output<void>();
readonly refreshPlaylistRequested = output<void>();
readonly downloadsRequested = output<void>();
readonly playlistInfoRequested = output<void>();
readonly accountInfoRequested = output<void>();
@@ -231,10 +228,6 @@ export class WorkspaceShellHeaderComponent {
this.headerSidebarToggleRequested.emit();
}
onRefreshPlaylistRequested(): void {
this.refreshPlaylistRequested.emit();
}
onDownloadsRequested(): void {
this.downloadsRequested.emit();
}
@@ -111,7 +111,6 @@ export class WorkspaceShellHeaderService {
this.routeState.activePlaylist()
)
);
readonly isRefreshingPlaylist = this.playlistRefreshAction.isRefreshing;
readonly playlistSubtitle = computed(() => {
this.languageTick();
@@ -1,3 +1,4 @@
import { PlaylistSwitcherOpenService } from '@iptvnator/playlist/shared/ui';
import { signal } from '@angular/core';
import { TestBed } from '@angular/core/testing';
import { MatDialog } from '@angular/material/dialog';
@@ -1111,6 +1112,32 @@ describe('WorkspaceShellFacade', () => {
expect(dialog.open).toHaveBeenCalledTimes(1);
});
it('opens the playlist switcher on Ctrl/Cmd+P when one is on screen', () => {
const switcher = TestBed.inject(PlaylistSwitcherOpenService);
const opened = jest.fn();
const subscription = switcher.openRequests.subscribe(opened);
const press = () => {
const event = new KeyboardEvent('keydown', {
key: 'p',
ctrlKey: true,
cancelable: true,
});
document.dispatchEvent(event);
return event;
};
// Settings hides the switcher: the key keeps its default there.
expect(press().defaultPrevented).toBe(false);
expect(opened).not.toHaveBeenCalled();
const unregister = switcher.register();
expect(press().defaultPrevented).toBe(true);
expect(opened).toHaveBeenCalledTimes(1);
unregister();
subscription.unsubscribe();
});
it('records the executed command id after the palette closes with a selection', () => {
const dialog = TestBed.inject(MatDialog) as unknown as {
open: jest.Mock;
@@ -26,6 +26,7 @@ import { WorkspaceShellRouteStateService } from './workspace-shell-route-state.s
import { WorkspaceShellSearchService } from './workspace-shell-search.service';
import { WorkspaceShellXtreamImportService } from './workspace-shell-xtream-import.service';
import { WorkspaceShellContextDrawerService } from '@iptvnator/workspace/shell/util';
import { PlaylistSwitcherOpenService } from '@iptvnator/playlist/shared/ui';
@Injectable()
export class WorkspaceShellFacade {
@@ -56,6 +57,7 @@ export class WorkspaceShellFacade {
optional: true,
}
);
private readonly playlistSwitcher = inject(PlaylistSwitcherOpenService);
private readonly onDocumentKeydown = (event: KeyboardEvent): void => {
if (!(event.ctrlKey || event.metaKey)) {
return;
@@ -65,9 +67,20 @@ export class WorkspaceShellFacade {
return;
}
if (event.key.toLowerCase() === 'k') {
const key = event.key.toLowerCase();
if (key === 'k') {
event.preventDefault();
this.openCommandPalette();
} else if (
key === 'p' &&
!event.shiftKey &&
!event.altKey &&
this.playlistSwitcher.available
) {
// ⌘K stays the command palette; ⌘P is the playlist switcher (and
// no longer prints the page in the browser build).
event.preventDefault();
this.playlistSwitcher.request();
}
};
@@ -127,7 +140,6 @@ export class WorkspaceShellFacade {
readonly canOpenPlaylistInfo = this.header.canOpenPlaylistInfo;
readonly canOpenAccountInfo = this.header.canOpenAccountInfo;
readonly canRefreshPlaylist = this.header.canRefreshPlaylist;
readonly isRefreshingPlaylist = this.header.isRefreshingPlaylist;
readonly headerSidebarToggle = this.header.headerSidebarToggle;
readonly playlistSubtitle = this.header.playlistSubtitle;
readonly activeDownloadsCount = computed(() =>
@@ -42,8 +42,6 @@
[searchStatusLabel]="facade.searchStatusLabel()"
[headerShortcut]="facade.headerShortcut()"
[headerSidebarToggle]="facade.headerSidebarToggle()"
[canRefreshPlaylist]="facade.canRefreshPlaylist()"
[isRefreshingPlaylist]="facade.isRefreshingPlaylist()"
[isElectron]="facade.isElectron"
[hasNoPlaylists]="facade.hasNoPlaylists()"
[isDownloadsView]="facade.isDownloadsView()"
@@ -56,7 +54,6 @@
[contextDrawerTooltipKey]="facade.contextDrawerLabelKeys().tooltip"
(backRequested)="facade.goBack()"
(contextDrawerToggleRequested)="contextDrawer.toggle()"
(refreshPlaylistRequested)="facade.refreshCurrentPlaylist()"
(searchChanged)="facade.onSearchInput($event)"
(searchSubmitted)="facade.onSearchEnter($event)"
(commandPaletteRequested)="facade.openCommandPalette()"
@@ -58,8 +58,6 @@ class MockWorkspaceShellHeaderComponent {
readonly searchScopeLabel = input('');
readonly searchStatusLabel = input('');
readonly headerShortcut = input<unknown>(null);
readonly canRefreshPlaylist = input(false);
readonly isRefreshingPlaylist = input(false);
readonly isElectron = input(false);
readonly hasNoPlaylists = input(false);
readonly isDownloadsView = input(false);
@@ -78,7 +76,6 @@ class MockWorkspaceShellHeaderComponent {
readonly shortcutsRequested = output<void>();
readonly addPlaylistRequested = output<void>();
readonly headerShortcutRequested = output<void>();
readonly refreshPlaylistRequested = output<void>();
readonly downloadsRequested = output<void>();
readonly headerSidebarToggleRequested = output<void>();
readonly parentalLockToggleRequested = output<void>();
@@ -169,7 +166,6 @@ class MockWorkspaceShellFacade {
readonly searchStatusLabel = signal('');
readonly headerShortcut = signal(null);
readonly canRefreshPlaylist = signal(false);
readonly isRefreshingPlaylist = signal(false);
readonly hasNoPlaylists = signal(false);
readonly isDownloadsView = signal(false);
readonly activeDownloadsCount = signal(3);