style: enhance playlist switcher with search functionality and UI improvements

Entire-Checkpoint: 640047bcbeb2
This commit is contained in:
4gray committed 2026-04-12 22:48:20 +02:00
1 parent 156c12c51a
commit 1fca406486
4 files changed
+182 -21

No files matched your search

@@ -31,10 +31,27 @@
yPosition="below"
>
<div class="menu-header">
<mat-icon>playlist_play</mat-icon>
<span>{{ 'HOME.PLAYLISTS.MY_PLAYLISTS' | translate }}</span>
<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
@@ -55,6 +72,7 @@
</button>
}
</div>
}
@if (showTypeFilters()) {
<div class="type-filters" (click)="$event.stopPropagation()">
@@ -165,7 +183,6 @@
</div>
@if (showAddPlaylist() || showAccountInfo()) {
<mat-divider />
<div class="context-actions-section">
@if (showAddPlaylist()) {
<button
@@ -103,15 +103,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, 360px),
var(--playlist-switcher-overlay-width, 400px),
calc(100vw - 24px)
) !important;
min-width: min(
var(--playlist-switcher-overlay-width, 360px),
var(--playlist-switcher-overlay-width, 400px),
calc(100vw - 24px)
) !important;
max-width: min(
var(--playlist-switcher-overlay-width, 360px),
var(--playlist-switcher-overlay-width, 400px),
calc(100vw - 24px)
) !important;
margin-top: -1px !important;
@@ -131,18 +131,51 @@
display: flex;
align-items: center;
gap: 10px;
padding: 14px 16px 10px;
padding: 8px 12px 6px;
font-size: 0.85em;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.5px;
opacity: 0.7;
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;
&:hover,
&.is-active {
opacity: 1;
color: var(--mat-sys-on-surface);
}
mat-icon {
font-size: 18px;
width: 18px;
height: 18px;
}
}
}
.search-container {
@@ -258,7 +291,12 @@
.menu-content {
max-height: 360px;
overflow-y: auto;
padding: 8px 0;
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 {
@@ -266,11 +304,16 @@
flex-direction: row;
align-items: center;
gap: 10px;
padding: 6px 12px;
min-height: 52px;
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 {
@@ -280,8 +323,8 @@
content: '';
position: absolute;
left: 0;
top: 8px;
bottom: 8px;
top: 6px;
bottom: 6px;
width: 3px;
border-radius: 0 3px 3px 0;
background: var(--app-selection-color, #a78bfa);
@@ -328,7 +371,7 @@
.playlist-item:hover:not(.selected) {
background: color-mix(
in srgb,
var(--app-on-surface) 8%,
var(--app-on-surface) 10%,
transparent
);
}
@@ -456,6 +499,7 @@
.context-actions-section {
padding: 6px 8px 8px;
background: var(--mat-menu-container-color);
.context-action-item {
display: flex;
@@ -6,10 +6,17 @@ import {
flushMicrotasks,
tick,
} from '@angular/core/testing';
import { MatDialog } from '@angular/material/dialog';
import { MatSnackBar } from '@angular/material/snack-bar';
import { Store } from '@ngrx/store';
import { NoopAnimationsModule } from '@angular/platform-browser/animations';
import { TranslateModule } from '@ngx-translate/core';
import { PlaylistContextFacade } from '@iptvnator/playlist/shared/util';
import { PortalStatusService } from 'services';
import {
PlaylistContextFacade,
PlaylistRefreshActionService,
} from '@iptvnator/playlist/shared/util';
import { DialogService } from 'components';
import { DatabaseService, PortalStatusService } from 'services';
import { PlaylistMeta } from 'shared-interfaces';
import { PlaylistSwitcherComponent } from './playlist-switcher.component';
@@ -48,6 +55,26 @@ describe('PlaylistSwitcherComponent', () => {
checkPortalStatus: jest.Mock;
getStatusClass: jest.Mock;
};
let refreshActionService: {
canRefresh: jest.Mock;
refresh: jest.Mock;
};
let dialog: {
open: jest.Mock;
};
let dialogService: {
openConfirmDialog: jest.Mock;
};
let databaseService: {
createOperationId: jest.Mock;
deletePlaylist: jest.Mock;
};
let snackBar: {
open: jest.Mock;
};
let store: {
dispatch: jest.Mock;
};
const m3uPlaylist = createPlaylist({
_id: 'm3u-1',
@@ -89,6 +116,30 @@ describe('PlaylistSwitcherComponent', () => {
provide: PortalStatusService,
useValue: portalStatusService,
},
{
provide: PlaylistRefreshActionService,
useValue: refreshActionService,
},
{
provide: MatDialog,
useValue: dialog,
},
{
provide: DialogService,
useValue: dialogService,
},
{
provide: DatabaseService,
useValue: databaseService,
},
{
provide: MatSnackBar,
useValue: snackBar,
},
{
provide: Store,
useValue: store,
},
],
}).compileComponents();
@@ -123,6 +174,31 @@ describe('PlaylistSwitcherComponent', () => {
.mockResolvedValueOnce('inactive'),
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(),
};
databaseService = {
createOperationId: jest.fn(),
deletePlaylist: jest.fn(),
};
snackBar = {
open: jest.fn(),
};
store = {
dispatch: jest.fn(),
};
});
afterEach(() => {
@@ -235,7 +311,7 @@ describe('PlaylistSwitcherComponent', () => {
document.documentElement.style.getPropertyValue(
'--playlist-switcher-overlay-width'
)
).toBe('320px');
).toBe('400px');
expect(portalStatusService.checkPortalStatus).toHaveBeenCalledTimes(1);
expect(portalStatusService.checkPortalStatus).toHaveBeenCalledWith(
xtreamPlaylist.serverUrl,
@@ -102,6 +102,8 @@ export class PlaylistSwitcherComponent {
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;
@@ -154,6 +156,14 @@ export class PlaylistSwitcherComponent {
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());
readonly currentLocale = computed(() => {
@@ -173,7 +183,9 @@ export class PlaylistSwitcherComponent {
onMenuOpened(): void {
this.isMenuOpen.set(true);
this.syncMenuOverlayWidthToTrigger();
this.focusSearchField();
if (this.showSearchField()) {
this.focusSearchField();
}
void this.checkPortalStatuses(this.playlists());
}
@@ -181,6 +193,18 @@ export class PlaylistSwitcherComponent {
this.isMenuOpen.set(false);
this.clearMenuOverlayWidth();
this.clearSearchFocusTimeout();
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 {
@@ -323,10 +347,10 @@ export class PlaylistSwitcherComponent {
: fallback;
if (playlist.macAddress) {
return `Stalker · ${countLabel}`;
return 'Stalker Portal';
}
if (playlist.serverUrl) {
return `Xtream · ${countLabel}`;
return 'Xtream Code';
}
return countLabel;
}
@@ -384,7 +408,7 @@ export class PlaylistSwitcherComponent {
const triggerWidth = Math.round(
this.triggerElement().nativeElement.getBoundingClientRect().width
);
const overlayWidth = Math.max(triggerWidth, 360);
const overlayWidth = Math.max(triggerWidth, 400);
this.document.documentElement.style.setProperty(
'--playlist-switcher-overlay-width',
`${overlayWidth}px`