import { KeyValuePipe } from '@angular/common'; import { AfterViewInit, Component, computed, effect, inject, Inject, Optional, signal, viewChild, } from '@angular/core'; import { FormsModule } from '@angular/forms'; import { MatIconButton } from '@angular/material/button'; import { MatCheckboxModule } from '@angular/material/checkbox'; import { MAT_DIALOG_DATA, MatDialogModule, MatDialogRef, } from '@angular/material/dialog'; import { MatIcon } from '@angular/material/icon'; import { ActivatedRoute, Router } from '@angular/router'; import { TranslatePipe } from '@ngx-translate/core'; import { DatabaseService } from 'services'; import { ContentCardComponent } from '@iptvnator/portal/shared/ui'; import { SearchLayoutComponent } from '@iptvnator/portal/shared/ui'; import { buildXtreamNavigationTarget, isWorkspaceLayoutRoute, queryParamSignal, } from '@iptvnator/portal/shared/util'; import { createLogger } from '@iptvnator/portal/shared/util'; import { XtreamContentItem } from '@iptvnator/portal/xtream/data-access'; import { SearchFilters } from '@iptvnator/portal/xtream/data-access'; import { XtreamStore } from '@iptvnator/portal/xtream/data-access'; import { ContentType } from '@iptvnator/portal/xtream/data-access'; interface SearchResultsData { isGlobalSearch: boolean; initialQuery?: string; } function groupResultsByPlaylistName( items: XtreamContentItem[] ): Record { return items.reduce>( (groups, item) => { const key = String(item.playlist_name); if (!groups[key]) { groups[key] = []; } groups[key].push(item); return groups; }, {} ); } @Component({ selector: 'app-search-results', imports: [ ContentCardComponent, FormsModule, KeyValuePipe, MatCheckboxModule, MatDialogModule, MatIcon, MatIconButton, SearchLayoutComponent, TranslatePipe, ], providers: [], templateUrl: './search-results.component.html', styleUrls: ['./search-results.component.scss'], }) export class SearchResultsComponent implements AfterViewInit { readonly searchLayoutComponent = viewChild(SearchLayoutComponent); readonly xtreamStore = inject(XtreamStore); readonly router = inject(Router); readonly activatedRoute = inject(ActivatedRoute); readonly databaseService = inject(DatabaseService); private readonly logger = createLogger('XtreamSearchResults'); readonly isWorkspaceLayout = isWorkspaceLayoutRoute(this.activatedRoute); readonly routeSearchTerm = queryParamSignal( this.activatedRoute, 'q', (value) => (value ?? '').trim() ); /** Search term from store */ readonly searchTerm = this.xtreamStore.searchTerm; /** Search filters from store */ readonly filters = this.xtreamStore.searchFilters; private globalSearchRequestVersion = 0; private static readonly GROUP_BY_STORAGE_KEY = 'global-search-group-by-playlist'; private static readonly EXCLUDE_HIDDEN_STORAGE_KEY = 'xtream-search-exclude-hidden'; private static readonly TYPE_FILTERS_STORAGE_KEY = 'global-search-type-filters'; isGlobalSearch = false; /** Whether to group global search results by playlist */ readonly groupByPlaylist = signal( localStorage.getItem(SearchResultsComponent.GROUP_BY_STORAGE_KEY) !== 'false' ); /** Whether to exclude content from hidden categories */ readonly excludeHidden = signal( localStorage.getItem( SearchResultsComponent.EXCLUDE_HIDDEN_STORAGE_KEY ) === 'true' ); readonly filterConfig = [ { key: 'live' as keyof SearchFilters, label: 'Live TV', translationKey: 'PORTALS.SIDEBAR.LIVE_TV', }, { key: 'movie' as keyof SearchFilters, label: 'Movies', translationKey: 'PORTALS.SIDEBAR.MOVIES', }, { key: 'series' as keyof SearchFilters, label: 'Series', translationKey: 'PORTALS.SIDEBAR.SERIES', }, ]; /** Grouped results computed once per result change (avoids recalculating on every CD cycle) */ readonly groupedResults = computed(() => { const results = this.xtreamStore.searchResults(); if (!this.isGlobalSearch) return { default: results }; return groupResultsByPlaylistName(results); }); constructor( @Optional() @Inject(MAT_DIALOG_DATA) data: SearchResultsData, @Optional() public dialogRef: MatDialogRef ) { this.isGlobalSearch = data?.isGlobalSearch || false; const initialQuery = (data?.initialQuery ?? '').trim(); if (this.isGlobalSearch) { const savedFilters = localStorage.getItem( SearchResultsComponent.TYPE_FILTERS_STORAGE_KEY ); if (savedFilters) { try { const parsed = JSON.parse( savedFilters ) as Partial; this.xtreamStore.setSearchFilters({ live: parsed.live !== false, movie: parsed.movie !== false, series: parsed.series !== false, }); } catch { // Ignore malformed storage value and keep defaults. } } if (initialQuery) { this.xtreamStore.setSearchTerm(initialQuery); } } effect((onCleanup) => { const term = this.searchTerm(); if (term.length >= 3) { const timeout = setTimeout(() => this.executeSearch(), 300); onCleanup(() => clearTimeout(timeout)); } else if (term.length === 0) { this.clearResultsOnly(); } }); effect(() => { if (this.isGlobalSearch || !this.isWorkspaceLayout) { return; } const queryTerm = this.routeSearchTerm(); if (!queryTerm || queryTerm === this.searchTerm()) { return; } this.xtreamStore.setSearchTerm(queryTerm); }); } ngAfterViewInit() { this.xtreamStore.setSelectedContentType(undefined); setTimeout(() => { this.searchLayoutComponent()?.focusSearchInput(); }); } executeSearch() { const filters = this.filters(); const types = Object.entries(filters) .filter(([, enabled]) => enabled) .map(([type]) => type); const excludeHidden = this.excludeHidden(); if (this.isGlobalSearch) { this.searchGlobal(this.searchTerm(), types, excludeHidden); } else { this.xtreamStore.searchContent({ term: this.searchTerm(), types, excludeHidden, }); } } /** * Update search term in the store */ updateSearchTerm(term: string) { this.xtreamStore.setSearchTerm(term); } /** * Update a single filter in the store */ updateFilter(key: keyof SearchFilters, value: boolean) { this.xtreamStore.updateSearchFilter(key, value); if (this.isGlobalSearch) { localStorage.setItem( SearchResultsComponent.TYPE_FILTERS_STORAGE_KEY, JSON.stringify(this.xtreamStore.searchFilters()) ); } if (this.searchTerm().length >= 3) { this.executeSearch(); } } /** * Clear only the results, not the search term/filters */ private clearResultsOnly() { this.globalSearchRequestVersion++; this.xtreamStore.setIsSearching(false); this.xtreamStore.setGlobalSearchResults([]); } async searchGlobal(term: string, types: string[], excludeHidden?: boolean) { const requestVersion = ++this.globalSearchRequestVersion; this.xtreamStore.setIsSearching(true); try { const results = await this.databaseService.globalSearchContent( term, types, excludeHidden ); if (requestVersion !== this.globalSearchRequestVersion) { return; } if (results && Array.isArray(results)) { this.xtreamStore.setGlobalSearchResults(results); } else { this.xtreamStore.setIsSearching(false); } } catch (error) { if (requestVersion !== this.globalSearchRequestVersion) { return; } this.logger.error('Error in global search', error); this.xtreamStore.resetSearchResults(); } } selectItem(item: XtreamContentItem) { const playlistId = item.playlist_id ?? this.xtreamStore.playlistId(); if (!playlistId) { return; } const type = (item.type === 'movie' ? 'vod' : item.type) as ContentType; const navigationType = item.type === 'movie' ? 'movie' : item.type === 'series' ? 'series' : 'live'; this.xtreamStore.setSelectedContentType(type); const navigation = buildXtreamNavigationTarget({ playlistId, type: navigationType, categoryId: item.category_id, itemId: item.xtream_id, title: item.title, imageUrl: item.poster_url, }); if (this.isGlobalSearch) { this.dialogRef?.close(); } void this.router.navigate(navigation.link, { state: navigation.state, }); } onCloseDialog() { this.dialogRef?.close(); } toggleGroupByPlaylist(value: boolean) { this.groupByPlaylist.set(value); localStorage.setItem( SearchResultsComponent.GROUP_BY_STORAGE_KEY, String(value) ); } toggleExcludeHidden(value: boolean) { this.excludeHidden.set(value); localStorage.setItem( SearchResultsComponent.EXCLUDE_HIDDEN_STORAGE_KEY, String(value) ); if (this.searchTerm().length >= 3) { this.executeSearch(); } } getGroupedResults() { return this.groupedResults(); } get showInlineSearchInput(): boolean { return !this.isWorkspaceLayout || this.isGlobalSearch; } }