Proyecto App Linux
This commit is contained in:
@@ -0,0 +1,350 @@
|
||||
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<string, XtreamContentItem[]> {
|
||||
return items.reduce<Record<string, XtreamContentItem[]>>(
|
||||
(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<SearchResultsComponent>
|
||||
) {
|
||||
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<SearchFilters>;
|
||||
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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user