mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-12 20:16:15 -08:00
feat(playlist-switcher): add playlist type filters and enhance search functionality
This commit is contained in:
1 parent
853ba51c25
commit
e1b41832a9
3 files changed
+286
-61
No files matched your search
@@ -43,19 +43,46 @@
|
||||
class="search-input"
|
||||
[placeholder]="'HOME.PLAYLISTS.SEARCH_PLAYLISTS' | translate"
|
||||
[ngModel]="searchQuery()"
|
||||
(ngModelChange)="searchQuery.set($event)"
|
||||
(ngModelChange)="setSearchQuery($event)"
|
||||
/>
|
||||
@if (searchQuery()) {
|
||||
<button
|
||||
mat-icon-button
|
||||
class="clear-btn"
|
||||
(click)="searchQuery.set('')"
|
||||
(click)="setSearchQuery('')"
|
||||
>
|
||||
<mat-icon>close</mat-icon>
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="type-filters" (click)="$event.stopPropagation()">
|
||||
<button
|
||||
type="button"
|
||||
class="type-filter-chip"
|
||||
[class.selected]="isTypeFilterSelected('m3u')"
|
||||
(click)="togglePlaylistTypeFilter('m3u', $event)"
|
||||
>
|
||||
M3U
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="type-filter-chip"
|
||||
[class.selected]="isTypeFilterSelected('stalker')"
|
||||
(click)="togglePlaylistTypeFilter('stalker', $event)"
|
||||
>
|
||||
Stalker
|
||||
</button>
|
||||
<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
|
||||
|
||||
@@ -170,6 +170,59 @@
|
||||
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 {
|
||||
outline: 2px solid var(--app-selection-border, var(--mat-sys-primary));
|
||||
outline-offset: 1px;
|
||||
}
|
||||
}
|
||||
|
||||
.menu-content {
|
||||
max-height: 360px;
|
||||
overflow-y: auto;
|
||||
|
||||
@@ -33,6 +33,7 @@ import { PlaylistMeta } from 'shared-interfaces';
|
||||
|
||||
type PlaylistRouteProvider = 'playlists' | 'xtreams' | 'stalker';
|
||||
type PlaylistSectionProvider = Exclude<PlaylistRouteProvider, 'playlists'>;
|
||||
type PlaylistFilterType = 'm3u' | 'stalker' | 'xtream';
|
||||
|
||||
interface PlaylistRouteContext {
|
||||
inWorkspace: boolean;
|
||||
@@ -43,7 +44,15 @@ interface PlaylistRouteContext {
|
||||
}
|
||||
|
||||
const LAST_SECTION_STORAGE_KEY = 'playlist-switcher:last-sections';
|
||||
const LAST_ACTIVE_PLAYLIST_STORAGE_KEY = 'playlist-switcher:last-active-playlist-id';
|
||||
const LAST_ACTIVE_PLAYLIST_STORAGE_KEY =
|
||||
'playlist-switcher:last-active-playlist-id';
|
||||
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,
|
||||
};
|
||||
interface PlaylistSectionMemory {
|
||||
providers: Partial<Record<PlaylistSectionProvider, string>>;
|
||||
playlists: Record<
|
||||
@@ -107,32 +116,40 @@ export class PlaylistSwitcherComponent {
|
||||
|
||||
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 triggerElement =
|
||||
viewChild.required<ElementRef<HTMLElement>>('triggerElement');
|
||||
readonly searchInput =
|
||||
viewChild<ElementRef<HTMLInputElement>>('searchInput');
|
||||
|
||||
/** Signal for tracking menu open state */
|
||||
readonly isMenuOpen = signal(false);
|
||||
|
||||
/** Search query for filtering playlists */
|
||||
readonly searchQuery = signal('');
|
||||
readonly searchQuery = signal(this.readPersistedSearchQuery());
|
||||
readonly playlistTypeFilters = signal(this.readPersistedTypeFilters());
|
||||
|
||||
/** All playlists from store */
|
||||
readonly playlists = this.store.selectSignal(selectAllPlaylistsMeta);
|
||||
readonly allPlaylistsLoaded = this.store.selectSignal(selectPlaylistsLoadingFlag);
|
||||
readonly allPlaylistsLoaded = this.store.selectSignal(
|
||||
selectPlaylistsLoadingFlag
|
||||
);
|
||||
|
||||
/** Filtered playlists based on search query, sorted by importDate (newest first) */
|
||||
readonly filteredPlaylists = computed(() => {
|
||||
const query = this.searchQuery().toLowerCase().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
|
||||
? allPlaylists.filter(
|
||||
? filteredByType.filter(
|
||||
(p) =>
|
||||
p.title?.toLowerCase().includes(query) ||
|
||||
p.filename?.toLowerCase().includes(query)
|
||||
)
|
||||
: allPlaylists;
|
||||
: filteredByType;
|
||||
|
||||
return [...filtered].sort(
|
||||
(a, b) =>
|
||||
@@ -143,7 +160,9 @@ export class PlaylistSwitcherComponent {
|
||||
|
||||
/** Current active playlist ID from route */
|
||||
readonly routePlaylistId = signal<string | null>(null);
|
||||
readonly selectedPlaylistId = this.store.selectSignal(selectActivePlaylistId);
|
||||
readonly selectedPlaylistId = this.store.selectSignal(
|
||||
selectActivePlaylistId
|
||||
);
|
||||
readonly activePlaylistId = computed(() => {
|
||||
return this.routePlaylistId() ?? this.selectedPlaylistId() ?? null;
|
||||
});
|
||||
@@ -153,7 +172,10 @@ export class PlaylistSwitcherComponent {
|
||||
return null;
|
||||
}
|
||||
|
||||
return this.playlists().find((playlist) => playlist._id === playlistId) ?? null;
|
||||
return (
|
||||
this.playlists().find((playlist) => playlist._id === playlistId) ??
|
||||
null
|
||||
);
|
||||
});
|
||||
readonly displayTitle = computed(() => {
|
||||
if (!this.activePlaylistId()) {
|
||||
@@ -181,12 +203,16 @@ export class PlaylistSwitcherComponent {
|
||||
if (!this.allPlaylistsLoaded()) {
|
||||
return;
|
||||
}
|
||||
const playlistIds = this.playlists().map((playlist) => playlist._id);
|
||||
const playlistIds = this.playlists().map(
|
||||
(playlist) => playlist._id
|
||||
);
|
||||
this.removeDeletedPlaylistsFromSectionMemory(playlistIds);
|
||||
});
|
||||
effect(() => {
|
||||
const allPlaylistsLoaded = this.allPlaylistsLoaded();
|
||||
const playlistIds = this.playlists().map((playlist) => playlist._id);
|
||||
const playlistIds = this.playlists().map(
|
||||
(playlist) => playlist._id
|
||||
);
|
||||
const playlistIdSet = new Set(playlistIds);
|
||||
const selectedId = this.selectedPlaylistId();
|
||||
const routeId = this.routePlaylistId();
|
||||
@@ -219,7 +245,12 @@ export class PlaylistSwitcherComponent {
|
||||
|
||||
// On routes without playlist in URL (for example dashboard),
|
||||
// restore last known active playlist when store has none.
|
||||
if (!routeId && !selectedId && storedId && playlistIdSet.has(storedId)) {
|
||||
if (
|
||||
!routeId &&
|
||||
!selectedId &&
|
||||
storedId &&
|
||||
playlistIdSet.has(storedId)
|
||||
) {
|
||||
this.store.dispatch(
|
||||
PlaylistActions.setActivePlaylist({ playlistId: storedId })
|
||||
);
|
||||
@@ -251,7 +282,7 @@ export class PlaylistSwitcherComponent {
|
||||
|
||||
private updateActivePlaylistFromRoute(url: string) {
|
||||
// Match routes like /playlists/:id, /xtreams/:id, /stalker/:id
|
||||
const match = url.match(/\/(playlists|xtreams|stalker)\/([^\/\?]+)/);
|
||||
const match = url.match(/\/(playlists|xtreams|stalker)\/([^/?]+)/);
|
||||
if (match) {
|
||||
this.routePlaylistId.set(match[2]);
|
||||
} else {
|
||||
@@ -328,7 +359,31 @@ export class PlaylistSwitcherComponent {
|
||||
this.isMenuOpen.set(false);
|
||||
this.clearMenuOverlayWidth();
|
||||
this.clearSearchFocusTimeout();
|
||||
this.searchQuery.set('');
|
||||
}
|
||||
|
||||
setSearchQuery(value: string) {
|
||||
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) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.playlistTypeFilters.set(next);
|
||||
this.writePersistedTypeFilters(next);
|
||||
}
|
||||
|
||||
isTypeFilterSelected(type: PlaylistFilterType): boolean {
|
||||
return this.playlistTypeFilters()[type];
|
||||
}
|
||||
|
||||
private syncMenuOverlayWidthToTrigger() {
|
||||
@@ -393,7 +448,9 @@ export class PlaylistSwitcherComponent {
|
||||
return /^\/workspace(?:\/|$)/.test(url);
|
||||
}
|
||||
|
||||
private getProviderForPlaylist(playlist: PlaylistMeta): PlaylistRouteProvider {
|
||||
private getProviderForPlaylist(
|
||||
playlist: PlaylistMeta
|
||||
): PlaylistRouteProvider {
|
||||
if (playlist.serverUrl) {
|
||||
return 'xtreams';
|
||||
}
|
||||
@@ -403,12 +460,22 @@ export class PlaylistSwitcherComponent {
|
||||
return 'playlists';
|
||||
}
|
||||
|
||||
private getPlaylistFilterType(playlist: PlaylistMeta): PlaylistFilterType {
|
||||
if (playlist.macAddress) {
|
||||
return 'stalker';
|
||||
}
|
||||
if (playlist.serverUrl) {
|
||||
return 'xtream';
|
||||
}
|
||||
return 'm3u';
|
||||
}
|
||||
|
||||
private getRouteContext(url: string): PlaylistRouteContext {
|
||||
const inWorkspace = this.isWorkspaceRoute(url);
|
||||
const isWorkspaceDashboard =
|
||||
/^\/workspace(?:\/dashboard)?(?:\/)?(?:\?.*)?$/.test(url);
|
||||
const match = url.match(
|
||||
/^\/(?:workspace\/)?(playlists|xtreams|stalker)\/([^\/\?]+)(?:\/([^\/\?]+))?/
|
||||
/^\/(?:workspace\/)?(playlists|xtreams|stalker)\/([^/?]+)(?:\/([^/?]+))?/
|
||||
);
|
||||
|
||||
return {
|
||||
@@ -455,7 +522,10 @@ export class PlaylistSwitcherComponent {
|
||||
currentSection: string | null,
|
||||
targetPlaylistId: string
|
||||
): string {
|
||||
const fromCurrent = this.normalizeSectionForProvider(currentSection, provider);
|
||||
const fromCurrent = this.normalizeSectionForProvider(
|
||||
currentSection,
|
||||
provider
|
||||
);
|
||||
if (fromCurrent) {
|
||||
return fromCurrent;
|
||||
}
|
||||
@@ -484,7 +554,9 @@ export class PlaylistSwitcherComponent {
|
||||
return 'vod';
|
||||
}
|
||||
|
||||
private supportsSectionNavigation(provider: PlaylistSectionProvider): boolean {
|
||||
private supportsSectionNavigation(
|
||||
provider: PlaylistSectionProvider
|
||||
): boolean {
|
||||
if (provider === 'xtreams') {
|
||||
return Boolean(window.electron);
|
||||
}
|
||||
@@ -503,7 +575,9 @@ export class PlaylistSwitcherComponent {
|
||||
if (section === 'itv') {
|
||||
return 'live';
|
||||
}
|
||||
return XTREAM_SECTIONS.includes(section as (typeof XTREAM_SECTIONS)[number])
|
||||
return XTREAM_SECTIONS.includes(
|
||||
section as (typeof XTREAM_SECTIONS)[number]
|
||||
)
|
||||
? section
|
||||
: null;
|
||||
}
|
||||
@@ -514,7 +588,9 @@ export class PlaylistSwitcherComponent {
|
||||
if (section === 'recently-added') {
|
||||
return 'recent';
|
||||
}
|
||||
return STALKER_SECTIONS.includes(section as (typeof STALKER_SECTIONS)[number])
|
||||
return STALKER_SECTIONS.includes(
|
||||
section as (typeof STALKER_SECTIONS)[number]
|
||||
)
|
||||
? section
|
||||
: null;
|
||||
}
|
||||
@@ -532,11 +608,17 @@ export class PlaylistSwitcherComponent {
|
||||
return empty;
|
||||
}
|
||||
|
||||
const providers: Partial<Record<PlaylistSectionProvider, string>> = {};
|
||||
const providers: Partial<Record<PlaylistSectionProvider, string>> =
|
||||
{};
|
||||
const playlists: PlaylistSectionMemory['playlists'] = {};
|
||||
|
||||
const parsedProviders = (parsed as Record<string, unknown>)['providers'];
|
||||
if (typeof parsedProviders === 'object' && parsedProviders !== null) {
|
||||
const parsedProviders = (parsed as Record<string, unknown>)[
|
||||
'providers'
|
||||
];
|
||||
if (
|
||||
typeof parsedProviders === 'object' &&
|
||||
parsedProviders !== null
|
||||
) {
|
||||
const candidate = parsedProviders as Record<string, unknown>;
|
||||
if (typeof candidate['xtreams'] === 'string') {
|
||||
providers.xtreams = candidate['xtreams'];
|
||||
@@ -556,38 +638,39 @@ export class PlaylistSwitcherComponent {
|
||||
}
|
||||
}
|
||||
|
||||
const parsedPlaylists = (parsed as Record<string, unknown>)['playlists'];
|
||||
if (typeof parsedPlaylists === 'object' && parsedPlaylists !== null) {
|
||||
Object.entries(parsedPlaylists as Record<string, unknown>).forEach(
|
||||
([playlistId, value]) => {
|
||||
if (
|
||||
!value ||
|
||||
typeof value !== 'object' ||
|
||||
!playlistId
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const row = value as Record<string, unknown>;
|
||||
const provider = row['provider'];
|
||||
const section = row['section'];
|
||||
const updatedAt = row['updatedAt'];
|
||||
const isProviderValid =
|
||||
provider === 'xtreams' || provider === 'stalker';
|
||||
if (!isProviderValid || typeof section !== 'string') {
|
||||
return;
|
||||
}
|
||||
|
||||
playlists[playlistId] = {
|
||||
provider,
|
||||
section,
|
||||
updatedAt:
|
||||
typeof updatedAt === 'number'
|
||||
? updatedAt
|
||||
: Date.now(),
|
||||
};
|
||||
const parsedPlaylists = (parsed as Record<string, unknown>)[
|
||||
'playlists'
|
||||
];
|
||||
if (
|
||||
typeof parsedPlaylists === 'object' &&
|
||||
parsedPlaylists !== null
|
||||
) {
|
||||
Object.entries(
|
||||
parsedPlaylists as Record<string, unknown>
|
||||
).forEach(([playlistId, value]) => {
|
||||
if (!value || typeof value !== 'object' || !playlistId) {
|
||||
return;
|
||||
}
|
||||
);
|
||||
|
||||
const row = value as Record<string, unknown>;
|
||||
const provider = row['provider'];
|
||||
const section = row['section'];
|
||||
const updatedAt = row['updatedAt'];
|
||||
const isProviderValid =
|
||||
provider === 'xtreams' || provider === 'stalker';
|
||||
if (!isProviderValid || typeof section !== 'string') {
|
||||
return;
|
||||
}
|
||||
|
||||
playlists[playlistId] = {
|
||||
provider,
|
||||
section,
|
||||
updatedAt:
|
||||
typeof updatedAt === 'number'
|
||||
? updatedAt
|
||||
: Date.now(),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
return { providers, playlists };
|
||||
@@ -598,7 +681,10 @@ export class PlaylistSwitcherComponent {
|
||||
|
||||
private writeSectionMemory(memory: PlaylistSectionMemory): void {
|
||||
try {
|
||||
localStorage.setItem(LAST_SECTION_STORAGE_KEY, JSON.stringify(memory));
|
||||
localStorage.setItem(
|
||||
LAST_SECTION_STORAGE_KEY,
|
||||
JSON.stringify(memory)
|
||||
);
|
||||
} catch {
|
||||
// Ignore storage write failures (private mode/storage quota).
|
||||
}
|
||||
@@ -606,7 +692,9 @@ export class PlaylistSwitcherComponent {
|
||||
|
||||
private readPersistedActivePlaylistId(): string | null {
|
||||
try {
|
||||
const value = localStorage.getItem(LAST_ACTIVE_PLAYLIST_STORAGE_KEY);
|
||||
const value = localStorage.getItem(
|
||||
LAST_ACTIVE_PLAYLIST_STORAGE_KEY
|
||||
);
|
||||
return value && value.trim().length > 0 ? value : null;
|
||||
} catch {
|
||||
return null;
|
||||
@@ -629,7 +717,64 @@ export class PlaylistSwitcherComponent {
|
||||
}
|
||||
}
|
||||
|
||||
private removeDeletedPlaylistsFromSectionMemory(existingIds: string[]): void {
|
||||
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 mode/storage quota).
|
||||
}
|
||||
}
|
||||
|
||||
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 (private mode/storage quota).
|
||||
}
|
||||
}
|
||||
|
||||
private removeDeletedPlaylistsFromSectionMemory(
|
||||
existingIds: string[]
|
||||
): void {
|
||||
const existingIdSet = new Set(existingIds);
|
||||
const memory = this.readSectionMemory();
|
||||
const currentEntries = Object.entries(memory.playlists);
|
||||
|
||||
Reference in new issue
Block a user