feat: update Angular cache with latest TypeScript definitions

This commit is contained in:
2025-09-22 22:17:38 -04:00
parent d78afda4cd
commit d372b7d509
27 changed files with 512 additions and 646 deletions
File diff suppressed because one or more lines are too long
BIN
View File
Binary file not shown.
+34 -15
View File
@@ -1,5 +1,5 @@
/** /**
* Minimal Twitch provider handler * Twitch provider using Helix API
*/ */
const handler = { const handler = {
id: 'tw', id: 'tw',
@@ -12,17 +12,36 @@ const handler = {
async search(q, opts) { async search(q, opts) {
const { limit = 10 } = opts; const { limit = 10 } = opts;
try { try {
// Use Twitch Kraken API (older but public) // First, get OAuth token
const authResponse = await fetch('https://id.twitch.tv/oauth2/token', {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
},
body: new URLSearchParams({
client_id: process.env.TWITCH_CLIENT_ID,
client_secret: process.env.TWITCH_CLIENT_SECRET,
grant_type: 'client_credentials'
})
});
if (!authResponse.ok) {
throw new Error(`Twitch auth error: ${authResponse.status}`);
}
const { access_token } = await authResponse.json();
// Then search streams
const response = await fetch( const response = await fetch(
`https://api.twitch.tv/kraken/search/streams?` + `https://api.twitch.tv/helix/search/channels?` +
new URLSearchParams({ new URLSearchParams({
query: q, query: q,
limit: Math.min(limit, 25).toString() first: Math.min(limit, 100).toString()
}), }),
{ {
headers: { headers: {
'Accept': 'application/vnd.twitchtv.v5+json', 'Client-ID': process.env.TWITCH_CLIENT_ID,
'Client-ID': process.env.TWITCH_CLIENT_ID || '' 'Authorization': `Bearer ${access_token}`
} }
} }
); );
@@ -33,19 +52,19 @@ const handler = {
const data = await response.json(); const data = await response.json();
return (data.streams || []).map(item => ({ return data.data?.map(item => ({
title: item.channel.status || item.channel.display_name, title: item.title || item.display_name,
id: item.channel.name, // Channel name as ID id: item.id,
url: `https://www.twitch.tv/${item.channel.name}`, url: `https://www.twitch.tv/${item.broadcaster_login}`,
thumbnail: item.preview?.medium || item.channel.logo, thumbnail: item.thumbnail_url?.replace('{width}x{height}', '440x248'),
uploaderName: item.channel.display_name, uploaderName: item.display_name,
type: 'stream', type: 'stream',
isLive: true isLive: item.is_live || item.started_at
})); })) || [];
} catch (error) { } catch (error) {
console.error('Twitch search error:', error); console.error('Twitch search error:', error);
return []; return [];
} }
} }
}; };
export default handler; export default handler;
+15 -13
View File
@@ -1,25 +1,27 @@
import { HttpClient } from '@angular/common/http'; import { HttpClient, HttpParams } from '@angular/common/http';
import { firstValueFrom } from 'rxjs'; import { firstValueFrom } from 'rxjs';
import type { ProviderAdapter, ProviderSearchParams, SearchResult } from '../models'; import type { ProviderAdapter, ProviderSearchParams, SearchResult } from '../models';
import { VideoItem } from 'src/app/shared/models/video-item.model';
export class DmAdapter implements ProviderAdapter { // TODO: Make SearchResult generic
export class DmAdapter implements ProviderAdapter<VideoItem> {
key = 'dm' as const; key = 'dm' as const;
label = 'Dailymotion'; label = 'Dailymotion';
constructor(private http: HttpClient) {} constructor(private http: HttpClient) {}
async search(params: ProviderSearchParams, _signal: AbortSignal): Promise<SearchResult> { async search(params: ProviderSearchParams, _signal: AbortSignal): Promise<SearchResult<VideoItem>> {
const res = await firstValueFrom(this.http.get<any>(`/api/search`, { params: { q: params.q, providers: 'dm' } })); let httpParams = new HttpParams().set('q', params.q).set('providers', 'dm');
const items = (res?.groups?.dm || []).map((it: any) => ({ if (params.pageToken) httpParams = httpParams.set('page', params.pageToken);
if (params.sort) httpParams = httpParams.set('sort', params.sort);
const res = await firstValueFrom(this.http.get<any>(`/api/search`, { params: httpParams }));
const items: VideoItem[] = (res?.groups?.dm || []).map((it: any) => ({
id: it.id, id: it.id,
provider: this.key, provider: 'dailymotion',
title: it.title, title: it.title,
channel: it.uploaderName, channelName: it.uploaderName,
durationSec: it.duration, durationSec: it.duration,
thumbUrl: it.thumbnail, thumbnailUrl: it.thumbnail,
watchUrl: it.url || '', viewCount: undefined, // Dailymotion API does not provide view count in search results
views: undefined, publishedAt: undefined, // Dailymotion API does not provide published date in search results
publishedAt: undefined,
isLive: it.type === 'live',
isShort: it.isShort === true
})); }));
return { items, total: Array.isArray(res?.groups?.dm) ? res.groups.dm.length : 0 }; return { items, total: Array.isArray(res?.groups?.dm) ? res.groups.dm.length : 0 };
} }
+28 -17
View File
@@ -1,26 +1,37 @@
import { HttpClient } from '@angular/common/http'; import { HttpClient, HttpParams } from '@angular/common/http';
import { firstValueFrom } from 'rxjs'; import { firstValueFrom } from 'rxjs';
import type { ProviderAdapter, ProviderSearchParams, SearchResult } from '../models'; import type { ProviderAdapter, ProviderSearchParams, SearchResult } from '../models';
import { VideoItem } from 'src/app/shared/models/video-item.model';
export class OdAdapter implements ProviderAdapter { export class OdAdapter implements ProviderAdapter<VideoItem> {
key = 'od' as const; key = 'od' as const;
label = 'Odysee'; label = 'Odysee';
constructor(private http: HttpClient) {} constructor(private http: HttpClient) {}
async search(params: ProviderSearchParams, _signal: AbortSignal): Promise<SearchResult> { async search(params: ProviderSearchParams, _signal: AbortSignal): Promise<SearchResult<VideoItem>> {
const res = await firstValueFrom(this.http.get<any>(`/api/search`, { params: { q: params.q, providers: 'od' } })); let httpParams = new HttpParams().set('q', params.q).set('providers', 'od');
const items = (res?.groups?.od || []).map((it: any) => ({ if (params.pageToken) httpParams = httpParams.set('page', params.pageToken);
id: it.id, if (params.sort) httpParams = httpParams.set('sort', params.sort);
provider: this.key, const res = await firstValueFrom(this.http.get<any>(`/api/search`, { params: httpParams }));
title: it.title, const items: VideoItem[] = (res?.groups?.od || []).map((it: any) => {
channel: it.uploaderName, let slug: string | undefined;
durationSec: it.duration, try {
thumbUrl: it.thumbnail, const url: string | undefined = it?.url;
watchUrl: it.url || '', if (url && /^https?:\/\/odysee\.com\//i.test(url)) {
views: undefined, slug = url.replace(/^https?:\/\/odysee\.com\//i, '').replace(/^\//, '');
publishedAt: undefined, }
isLive: false, } catch {}
isShort: it.isShort === true return {
})); id: it.id,
provider: 'odysee',
title: it.title,
channelName: it.uploaderName,
durationSec: it.duration,
thumbnailUrl: it.thumbnail,
viewCount: undefined,
publishedAt: undefined,
slug,
} as VideoItem;
});
return { items, total: Array.isArray(res?.groups?.od) ? res.groups.od.length : 0 }; return { items, total: Array.isArray(res?.groups?.od) ? res.groups.od.length : 0 };
} }
} }
+13 -12
View File
@@ -1,25 +1,26 @@
import { HttpClient } from '@angular/common/http'; import { HttpClient, HttpParams } from '@angular/common/http';
import { firstValueFrom } from 'rxjs'; import { firstValueFrom } from 'rxjs';
import type { ProviderAdapter, ProviderSearchParams, SearchResult } from '../models'; import type { ProviderAdapter, ProviderSearchParams, SearchResult } from '../models';
import { VideoItem } from 'src/app/shared/models/video-item.model';
export class PtAdapter implements ProviderAdapter { export class PtAdapter implements ProviderAdapter<VideoItem> {
key = 'pt' as const; key = 'pt' as const;
label = 'PeerTube'; label = 'PeerTube';
constructor(private http: HttpClient) {} constructor(private http: HttpClient) {}
async search(params: ProviderSearchParams, _signal: AbortSignal): Promise<SearchResult> { async search(params: ProviderSearchParams, _signal: AbortSignal): Promise<SearchResult<VideoItem>> {
const res = await firstValueFrom(this.http.get<any>(`/api/search`, { params: { q: params.q, providers: 'pt' } })); let httpParams = new HttpParams().set('q', params.q).set('providers', 'pt');
const items = (res?.groups?.pt || []).map((it: any) => ({ if (params.pageToken) httpParams = httpParams.set('page', params.pageToken);
if (params.sort) httpParams = httpParams.set('sort', params.sort);
const res = await firstValueFrom(this.http.get<any>(`/api/search`, { params: httpParams }));
const items: VideoItem[] = (res?.groups?.pt || []).map((it: any) => ({
id: it.id, id: it.id,
provider: this.key, provider: 'peertube',
title: it.title, title: it.title,
channel: it.uploaderName, channelName: it.uploaderName,
durationSec: it.duration, durationSec: it.duration,
thumbUrl: it.thumbnail, thumbnailUrl: it.thumbnail,
watchUrl: it.url || '', viewCount: undefined,
views: undefined,
publishedAt: undefined, publishedAt: undefined,
isLive: false,
isShort: false
})); }));
return { items, total: Array.isArray(res?.groups?.pt) ? res.groups.pt.length : 0 }; return { items, total: Array.isArray(res?.groups?.pt) ? res.groups.pt.length : 0 };
} }
+14 -13
View File
@@ -1,25 +1,26 @@
import { HttpClient } from '@angular/common/http'; import { HttpClient, HttpParams } from '@angular/common/http';
import { firstValueFrom } from 'rxjs'; import { firstValueFrom } from 'rxjs';
import type { ProviderAdapter, ProviderSearchParams, SearchResult } from '../models'; import type { ProviderAdapter, ProviderSearchParams, SearchResult } from '../models';
import { VideoItem } from 'src/app/shared/models/video-item.model';
export class RuAdapter implements ProviderAdapter { export class RuAdapter implements ProviderAdapter<VideoItem> {
key = 'ru' as const; key = 'ru' as const;
label = 'Rumble'; label = 'Rumble';
constructor(private http: HttpClient) {} constructor(private http: HttpClient) {}
async search(params: ProviderSearchParams, _signal: AbortSignal): Promise<SearchResult> { async search(params: ProviderSearchParams, _signal: AbortSignal): Promise<SearchResult<VideoItem>> {
const res = await firstValueFrom(this.http.get<any>(`/api/search`, { params: { q: params.q, providers: 'ru' } })); let httpParams = new HttpParams().set('q', params.q).set('providers', 'ru');
const items = (res?.groups?.ru || []).map((it: any) => ({ if (params.pageToken) httpParams = httpParams.set('page', params.pageToken);
if (params.sort) httpParams = httpParams.set('sort', params.sort);
const res = await firstValueFrom(this.http.get<any>(`/api/search`, { params: httpParams }));
const items: VideoItem[] = (res?.groups?.ru || []).map((it: any) => ({
id: it.id, id: it.id,
provider: this.key, provider: 'rumble',
title: it.title, title: it.title,
channel: it.uploaderName, channelName: it.uploaderName,
durationSec: it.duration, durationSec: it.duration,
thumbUrl: it.thumbnail, thumbnailUrl: it.thumbnail,
watchUrl: it.url || '', viewCount: typeof it.views === 'number' ? it.views : undefined,
views: undefined, publishedAt: it.publishedAt,
publishedAt: undefined,
isLive: false,
isShort: false
})); }));
return { items, total: Array.isArray(res?.groups?.ru) ? res.groups.ru.length : 0 }; return { items, total: Array.isArray(res?.groups?.ru) ? res.groups.ru.length : 0 };
} }
+22 -17
View File
@@ -1,26 +1,31 @@
import { HttpClient } from '@angular/common/http'; import { HttpClient, HttpParams } from '@angular/common/http';
import { firstValueFrom } from 'rxjs'; import { firstValueFrom } from 'rxjs';
import type { ProviderAdapter, ProviderSearchParams, SearchResult } from '../models'; import type { ProviderAdapter, ProviderSearchParams, SearchResult } from '../models';
import { VideoItem } from 'src/app/shared/models/video-item.model';
export class TwAdapter implements ProviderAdapter { export class TwAdapter implements ProviderAdapter<VideoItem> {
key = 'tw' as const; key = 'tw' as const;
label = 'Twitch'; label = 'Twitch';
constructor(private http: HttpClient) {} constructor(private http: HttpClient) {}
async search(params: ProviderSearchParams, _signal: AbortSignal): Promise<SearchResult> { async search(params: ProviderSearchParams, _signal: AbortSignal): Promise<SearchResult<VideoItem>> {
const res = await firstValueFrom(this.http.get<any>(`/api/search`, { params: { q: params.q, providers: 'tw' } })); let httpParams = new HttpParams().set('q', params.q).set('providers', 'tw');
const items = (res?.groups?.tw || []).map((it: any) => ({ if (params.pageToken) httpParams = httpParams.set('page', params.pageToken);
id: it.id, if (params.sort) httpParams = httpParams.set('sort', params.sort);
provider: this.key, const res = await firstValueFrom(this.http.get<any>(`/api/search`, { params: httpParams }));
title: it.title, const items: VideoItem[] = (res?.groups?.tw || []).map((it: any) => {
channel: it.uploaderName, const isChannel = String(it?.type || '').toLowerCase() === 'channel';
durationSec: it.duration, return {
thumbUrl: it.thumbnail, id: it.id,
watchUrl: it.url || '', provider: 'twitch',
views: undefined, title: it.title,
publishedAt: undefined, channelName: it.uploaderName,
isLive: it.type === 'live', durationSec: it.duration,
isShort: false thumbnailUrl: it.thumbnail,
})); viewCount: typeof it.views === 'number' ? it.views : undefined,
publishedAt: it.publishedAt,
channel: isChannel ? it.id : undefined,
} as VideoItem;
});
return { items, total: Array.isArray(res?.groups?.tw) ? res.groups.tw.length : 0 }; return { items, total: Array.isArray(res?.groups?.tw) ? res.groups.tw.length : 0 };
} }
} }
+14 -13
View File
@@ -1,25 +1,26 @@
import { HttpClient } from '@angular/common/http'; import { HttpClient, HttpParams } from '@angular/common/http';
import { firstValueFrom } from 'rxjs'; import { firstValueFrom } from 'rxjs';
import type { ProviderAdapter, ProviderSearchParams, SearchResult } from '../models'; import type { ProviderAdapter, ProviderSearchParams, SearchResult } from '../models';
import { VideoItem } from 'src/app/shared/models/video-item.model';
export class YtAdapter implements ProviderAdapter { export class YtAdapter implements ProviderAdapter<VideoItem> {
key = 'yt' as const; key = 'yt' as const;
label = 'YouTube'; label = 'YouTube';
constructor(private http: HttpClient) {} constructor(private http: HttpClient) {}
async search(params: ProviderSearchParams, _signal: AbortSignal): Promise<SearchResult> { async search(params: ProviderSearchParams, _signal: AbortSignal): Promise<SearchResult<VideoItem>> {
const res = await firstValueFrom(this.http.get<any>(`/api/search`, { params: { q: params.q, providers: 'yt' } })); let httpParams = new HttpParams().set('q', params.q).set('providers', 'yt');
const items = (res?.groups?.yt || []).map((it: any) => ({ if (params.pageToken) httpParams = httpParams.set('page', params.pageToken);
if (params.sort) httpParams = httpParams.set('sort', params.sort);
const res = await firstValueFrom(this.http.get<any>(`/api/search`, { params: httpParams }));
const items: VideoItem[] = (res?.groups?.yt || []).map((it: any) => ({
id: it.id, id: it.id,
provider: this.key, provider: 'youtube',
title: it.title, title: it.title,
channel: it.uploaderName, channelName: it.uploaderName,
durationSec: it.duration, durationSec: it.duration,
thumbUrl: it.thumbnail, thumbnailUrl: it.thumbnail,
watchUrl: it.url || '', viewCount: typeof it.views === 'number' ? it.views : (typeof it.viewCount === 'number' ? it.viewCount : undefined),
views: undefined, publishedAt: it.publishedAt,
publishedAt: undefined,
isLive: it.type === 'live',
isShort: it.isShort === true
})); }));
return { items, total: Array.isArray(res?.groups?.yt) ? res.groups.yt.length : 0 }; return { items, total: Array.isArray(res?.groups?.yt) ? res.groups.yt.length : 0 };
} }
+4 -4
View File
@@ -14,8 +14,8 @@ export interface SearchItem {
watchUrl: string; // internal or external watchUrl: string; // internal or external
} }
export interface SearchResult { export interface SearchResult<T = SearchItem> {
items: SearchItem[]; items: T[];
total?: number; total?: number;
nextPageToken?: string; // or page: string|number nextPageToken?: string; // or page: string|number
} }
@@ -29,8 +29,8 @@ export interface ProviderSearchParams {
type?: 'video'|'live'|'shorts'|'all'; type?: 'video'|'live'|'shorts'|'all';
} }
export interface ProviderAdapter { export interface ProviderAdapter<T = SearchItem> {
key: ProviderId; key: ProviderId;
label: string; label: string;
search(params: ProviderSearchParams, signal: AbortSignal): Promise<SearchResult>; search(params: ProviderSearchParams, signal: AbortSignal): Promise<SearchResult<T>>;
} }
+14 -22
View File
@@ -10,12 +10,13 @@ import { PtAdapter } from './adapters/pt';
import { OdAdapter } from './adapters/od'; import { OdAdapter } from './adapters/od';
import { RuAdapter } from './adapters/ru'; import { RuAdapter } from './adapters/ru';
import type { ProviderAdapter, ProviderSearchParams, SearchResult } from './models'; import type { ProviderAdapter, ProviderSearchParams, SearchResult } from './models';
import { VideoItem } from 'src/app/shared/models/video-item.model';
export type SuggestionItem = SuggestionItemV1; export type SuggestionItem = SuggestionItemV1;
export type SearchResponse = SearchResponseV1; export type SearchResponse = SearchResponseV1;
type CacheKey = string; type CacheKey = string;
interface CacheEntry { t: number; data: SearchResult; } interface CacheEntry<T> { t: number; data: SearchResult<T>; }
@Injectable({ providedIn: 'root' }) @Injectable({ providedIn: 'root' })
export class SearchService { export class SearchService {
@@ -29,11 +30,11 @@ export class SearchService {
readonly sort$ = new BehaviorSubject<'relevance' | 'date' | 'views' | 'duration'>('relevance'); readonly sort$ = new BehaviorSubject<'relevance' | 'date' | 'views' | 'duration'>('relevance');
// In-memory cache 60s per (provider, q, params) // In-memory cache 60s per (provider, q, params)
private cache = new Map<CacheKey, CacheEntry>(); private cache = new Map<CacheKey, CacheEntry<VideoItem>>();
private cacheTtlMs = 60_000; private cacheTtlMs = 60_000;
// Adapters registry // Adapters registry
private adapters: Record<ProviderId, ProviderAdapter> = { private adapters: Record<ProviderId, ProviderAdapter<VideoItem>> = {
yt: new YtAdapter(this.http), yt: new YtAdapter(this.http),
dm: new DmAdapter(this.http), dm: new DmAdapter(this.http),
tw: new TwAdapter(this.http), tw: new TwAdapter(this.http),
@@ -57,22 +58,13 @@ export class SearchService {
// global filter knobs could be added here: time/length/type in the future // global filter knobs could be added here: time/length/type in the future
}, prov))) }, prov)))
, ,
// Map normalized items to legacy "groups" response expected by UI // Map provider results to the format expected by the UI
map((byProvider) => { map((byProvider) => {
const groups = Object.entries(byProvider).reduce((acc, [pid, result]) => { const groups = Object.entries(byProvider).reduce((acc, [pid, result]) => {
const arr: SuggestionItemV1[] = (result?.items || []).map(it => ({ acc[pid as ProviderId] = result.items;
id: it.id,
title: it.title,
duration: it.durationSec,
isShort: it.isShort,
thumbnail: it.thumbUrl,
uploaderName: it.channel,
url: it.watchUrl,
type: it.isLive ? 'live' : 'video'
}));
(acc as any)[pid as ProviderId] = arr;
return acc; return acc;
}, {} as Record<ProviderId, SuggestionItemV1[]>); }, {} as Record<ProviderId, VideoItem[]>);
const providers = Object.keys(byProvider) as ProviderId[]; const providers = Object.keys(byProvider) as ProviderId[];
const resp: SearchResponse = { q: this.q$.value, providers, groups }; const resp: SearchResponse = { q: this.q$.value, providers, groups };
return resp; return resp;
@@ -81,13 +73,13 @@ export class SearchService {
); );
// Orchestrate all active providers in parallel with timeout(8s), retry(1) and abort on param change. // Orchestrate all active providers in parallel with timeout(8s), retry(1) and abort on param change.
private async runAdapters(params: ProviderSearchParams, prov: ProviderId[] | 'all') { private async runAdapters(params: ProviderSearchParams, prov: ProviderId[] | 'all'): Promise<Record<ProviderId, SearchResult<VideoItem>>> {
const active: ProviderId[] = prov === 'all' ? ['yt','dm','tw','pt','od','ru'] : (Array.isArray(prov) ? prov : []); const active: ProviderId[] = prov === 'all' ? ['yt','dm','tw','pt','od','ru'] : (Array.isArray(prov) ? prov : []);
const controller = new AbortController(); const controller = new AbortController();
const signal = controller.signal; const signal = controller.signal;
const tasks = active.map(async (pid) => { const tasks = active.map(async (pid) => {
const adapter = this.adapters[pid]; const adapter = this.adapters[pid];
if (!adapter) return [pid, { items: [] as any[] } as SearchResult] as const; if (!adapter) return [pid, { items: [] } as SearchResult<VideoItem>] as const;
const key: CacheKey = `${pid}|${params.q}|${params.pageToken}|${params.sort}`; const key: CacheKey = `${pid}|${params.q}|${params.pageToken}|${params.sort}`;
const now = Date.now(); const now = Date.now();
const cached = this.cache.get(key); const cached = this.cache.get(key);
@@ -99,9 +91,9 @@ export class SearchService {
const tid = setTimeout(() => reject(new Error('timeout')), perProviderTimeout); const tid = setTimeout(() => reject(new Error('timeout')), perProviderTimeout);
p.then(v => { clearTimeout(tid); resolve(v); }).catch(e => { clearTimeout(tid); reject(e); }); p.then(v => { clearTimeout(tid); resolve(v); }).catch(e => { clearTimeout(tid); reject(e); });
}); });
const attempt = async (): Promise<SearchResult> => withTimeout(adapter.search(params, signal)); const attempt = async (): Promise<SearchResult<VideoItem>> => withTimeout(adapter.search(params, signal));
try { try {
const res = await attempt().catch(async (e) => { const res: SearchResult<VideoItem> = await attempt().catch(async (e) => {
// retry once on network-like errors // retry once on network-like errors
if (e && (e.name === 'AbortError' || String(e.message || '').includes('abort'))) throw e; if (e && (e.name === 'AbortError' || String(e.message || '').includes('abort'))) throw e;
try { return await attempt(); } catch (err) { throw err; } try { return await attempt(); } catch (err) { throw err; }
@@ -110,11 +102,11 @@ export class SearchService {
return [pid, res] as const; return [pid, res] as const;
} catch { } catch {
// Swallow errors per provider, return empty // Swallow errors per provider, return empty
return [pid, { items: [] }] as const; return [pid, { items: [] } as SearchResult<VideoItem>] as const;
} }
}); });
const results = await Promise.all(tasks); const results = await Promise.all(tasks);
return Object.fromEntries(results) as Record<ProviderId, SearchResult>; return Object.fromEntries(results) as Record<ProviderId, SearchResult<VideoItem>>;
} }
// Convenience setter helpers // Convenience setter helpers
@@ -0,0 +1,14 @@
<div class="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4">
<ng-container *ngIf="loading">
<app-video-card-skeleton *ngFor="let _ of skeletonItems"></app-video-card-skeleton>
</ng-container>
<ng-container *ngIf="!loading">
<app-video-card *ngFor="let video of videos" [video]="video"></app-video-card>
</ng-container>
</div>
<div *ngIf="!loading && videos.length === 0" class="flex flex-col items-center justify-center gap-4 rounded-lg border border-zinc-800 bg-zinc-900/50 p-8 text-center">
<h3 class="text-lg font-semibold text-white">Aucun résultat</h3>
<p class="text-zinc-400">Essayez une autre recherche ou sélectionnez un autre fournisseur.</p>
</div>
@@ -0,0 +1,22 @@
import { Component, Input } from '@angular/core';
import { VideoItem } from '../../models/video-item.model';
import { CommonModule } from '@angular/common';
import { VideoCardComponent } from '../video-card/video-card.component';
import { VideoCardSkeletonComponent } from '../video-card-skeleton/video-card-skeleton.component';
@Component({
selector: 'app-search-result-grid',
templateUrl: './search-result-grid.component.html',
styleUrls: ['./search-result-grid.component.scss'],
standalone: true,
imports: [CommonModule, VideoCardComponent, VideoCardSkeletonComponent],
})
export class SearchResultGridComponent {
@Input() videos: VideoItem[] = [];
@Input() loading = false;
@Input() skeletons = 12;
get skeletonItems() {
return new Array(this.skeletons);
}
}
@@ -0,0 +1,7 @@
<div class="flex flex-col gap-2 animate-pulse">
<div class="aspect-video w-full rounded-lg bg-zinc-800"></div>
<div class="flex flex-col gap-2">
<div class="h-5 w-3/4 rounded bg-zinc-800"></div>
<div class="h-4 w-1/2 rounded bg-zinc-800"></div>
</div>
</div>
@@ -0,0 +1,9 @@
import { Component } from '@angular/core';
@Component({
selector: 'app-video-card-skeleton',
templateUrl: './video-card-skeleton.component.html',
styleUrls: ['./video-card-skeleton.component.scss'],
standalone: true,
})
export class VideoCardSkeletonComponent {}
@@ -0,0 +1,21 @@
<a [routerLink]="['/watch', video.id]" [queryParams]="buildQueryParams(video)" [state]="{ video }" class="group flex flex-col gap-2">
<div class="relative aspect-video w-full overflow-hidden rounded-lg bg-zinc-800">
<img [src]="video.thumbnailUrl" [alt]="video.title" class="h-full w-full object-cover transition-transform group-hover:scale-105" />
<span *ngIf="video.durationSec" class="absolute bottom-1 right-1 rounded bg-black/80 px-1.5 py-0.5 text-xs text-white">
{{ video.durationSec | date:'mm:ss' }}
</span>
</div>
<div class="flex flex-col">
<h3 class="text-base font-semibold text-zinc-100 line-clamp-2" [title]="video.title">
{{ video.title }}
</h3>
<div class="text-sm text-zinc-400">
<p *ngIf="video.channelName" class="font-medium">{{ video.channelName }}</p>
<div class="flex gap-2">
<span *ngIf="video.viewCount">{{ video.viewCount | number }} vues</span>
<span *ngIf="video.publishedAt">{{ video.publishedAt | date:'shortDate' }}</span>
</div>
</div>
</div>
</a>
@@ -0,0 +1,7 @@
.line-clamp-2 {
display: -webkit-box;
-webkit-line-clamp: 2;
line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
@@ -0,0 +1,22 @@
import { Component, Input } from '@angular/core';
import { VideoItem } from '../../models/video-item.model';
import { CommonModule } from '@angular/common';
import { RouterLink } from '@angular/router';
@Component({
selector: 'app-video-card',
templateUrl: './video-card.component.html',
styleUrls: ['./video-card.component.scss'],
standalone: true,
imports: [CommonModule, RouterLink],
})
export class VideoCardComponent {
@Input() video!: VideoItem;
buildQueryParams(v: VideoItem): Record<string, any> {
const qp: any = { p: v.provider };
if (v.provider === 'odysee' && v.slug) qp.slug = v.slug;
if (v.provider === 'twitch' && v.channel) qp.channel = v.channel;
return qp;
}
}
+13
View File
@@ -0,0 +1,13 @@
export interface VideoItem {
id: string; // id vidéo canonicalisé
provider: 'youtube' | 'dailymotion' | 'twitch' | 'peertube' | 'odysee' | 'rumble';
title: string;
thumbnailUrl: string;
durationSec?: number;
channelName?: string;
viewCount?: number;
publishedAt?: string; // ISO
// Provider-specific routing hints
slug?: string; // Odysee slug when available
channel?: string; // Twitch channel when item represents a channel/live
}
@@ -66,7 +66,7 @@
<ul *ngIf="!isLoading() && filteredSearchHistory().length > 0" class="space-y-3"> <ul *ngIf="!isLoading() && filteredSearchHistory().length > 0" class="space-y-3">
<li *ngFor="let s of filteredSearchHistory()" <li *ngFor="let s of filteredSearchHistory()"
class="group relative bg-slate-700/50 hover:bg-slate-700/80 rounded-lg overflow-hidden transition-all duration-200"> class="group relative bg-slate-700/50 hover:bg-slate-700/80 rounded-lg overflow-hidden transition-all duration-200">
<a [routerLink]="['/search']" [queryParams]="getSearchProvider(s).providers ? { q: s.query, providers: getSearchProvider(s).providers } : { q: s.query, provider: getSearchProvider(s).id }" <a [routerLink]="['/search']" [queryParams]="getSearchQueryParams(s)"
class="block p-4 pr-16 hover:no-underline"> class="block p-4 pr-16 hover:no-underline">
<div class="flex items-start gap-2"> <div class="flex items-start gap-2">
<span class="text-slate-400 mt-0.5"> <span class="text-slate-400 mt-0.5">
@@ -114,7 +114,7 @@
<ul *ngIf="searchHistory().length > 0" class="space-y-3"> <ul *ngIf="searchHistory().length > 0" class="space-y-3">
<li *ngFor="let s of searchHistory()" <li *ngFor="let s of searchHistory()"
class="group relative bg-slate-700/50 hover:bg-slate-700/80 rounded-lg overflow-hidden transition-all duration-200"> class="group relative bg-slate-700/50 hover:bg-slate-700/80 rounded-lg overflow-hidden transition-all duration-200">
<a [routerLink]="['/search']" [queryParams]="getSearchProvider(s).providers ? { q: s.query, providers: getSearchProvider(s).providers } : { q: s.query, provider: getSearchProvider(s).id }" <a [routerLink]="['/search']" [queryParams]="getSearchQueryParams(s)"
class="block p-4 pr-16 hover:no-underline"> class="block p-4 pr-16 hover:no-underline">
<div class="flex items-start gap-2"> <div class="flex items-start gap-2">
<span class="text-slate-400 mt-0.5"> <span class="text-slate-400 mt-0.5">
@@ -230,6 +230,17 @@ getSearchProvider(item: SearchHistoryItem): { name: string; id: string; provider
return []; return [];
} }
// Get query params for search navigation
getSearchQueryParams(item: SearchHistoryItem): any {
const providerInfo = this.getSearchProvider(item);
if (providerInfo.providers && providerInfo.providers.length > 1) {
// Convert array to comma-separated string for URL
return { q: item.query, providers: providerInfo.providers.join(',') };
} else {
return { q: item.query, provider: providerInfo.id };
}
}
// Format provider ID to a display name // Format provider ID to a display name
private formatProviderName(providerId: string): string { private formatProviderName(providerId: string): string {
const providerMap: { [key: string]: string } = { const providerMap: { [key: string]: string } = {
+20 -177
View File
@@ -1,7 +1,18 @@
<div class="container mx-auto p-4 sm:p-6"> <div class="container mx-auto p-4 sm:p-6">
<header class="mb-4"> <header class="mb-6">
<h1 class="text-xl sm:text-2xl font-semibold text-slate-100">{{ pageHeading() }}</h1> <h1 class="text-2xl sm:text-3xl font-bold text-white mb-2">{{ pageHeading() }}</h1>
<p class="text-sm text-white/60 mt-1">{{ activeProvidersDisplay() }}</p>
<div class="flex flex-wrap gap-2 mt-3">
@for (provider of activeProvidersInfo(); track provider.id) {
<div class="flex items-center gap-2 rounded px-2 py-1 text-xs font-medium border"
[ngStyle]="getProviderColors(provider.id)">
<svg xmlns="http://www.w3.org/2000/svg" class="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 21V5a2 2 0 00-2-2H7a2 2 0 00-2 2v16m14 0h2m-2 0h-5m-9 0H3m2 0h5M9 7h1m-1 4h1m4-4h1m-1 4h1m-5 10v-5a1 1 0 011-1h2a1 1 0 011 1v5m-4 0h4" />
</svg>
{{ provider.label }}
</div>
}
</div>
</header> </header>
@if (notice()) { @if (notice()) {
@@ -30,38 +41,6 @@
</div> </div>
} }
<!-- Unified per-provider sections -->
@if (showUnified()) {
<section *ngFor="let p of activeProviders()">
<div class="sticky top-0 z-10 flex items-center justify-between bg-black/50 backdrop-blur px-4 py-2 border-b border-white/10">
<h2 class="text-sm font-medium">{{ providerDisplay(p) }} <span class="text-white/40" *ngIf="groups()[p]">({{ groups()[p].length || 0 }})</span></h2>
<button class="inline-flex items-center rounded-xl px-3 py-1.5 text-xs border border-white/20 hover:bg-white/5"
(click)="onViewAll(p)"
aria-label="Voir tout sur {{ providerDisplay(p) }}">
Voir tout sur {{ providerDisplay(p) }}
</button>
</div>
<div *ngIf="groups()[p]?.length; else noResTpl" class="grid gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 p-4">
<a *ngFor="let v of groups()[p]"
class="group block rounded-2xl border border-white/10 bg-white/5 hover:bg-white/10 transition overflow-hidden"
[href]="v.url || '#'" target="_blank" rel="noopener noreferrer"
[attr.aria-label]="v.title">
<div class="relative">
<img [src]="v.thumbnail" [alt]="v.title" class="w-full h-44 object-cover" loading="lazy">
</div>
<div class="p-3">
<div class="text-sm font-medium text-slate-100 line-clamp-2">{{ v.title }}</div>
<div class="mt-1 text-xs text-white/60 line-clamp-1">{{ v.uploaderName }}</div>
</div>
</a>
</div>
<ng-template #noResTpl>
<div class="px-4 py-6 text-white/50 text-sm">Aucun résultat pour ce fournisseur.</div>
</ng-template>
</section>
}
@if (hasQuery()) { @if (hasQuery()) {
<div class="my-4 flex flex-col sm:flex-row items-stretch sm:items-center justify-between gap-3"> <div class="my-4 flex flex-col sm:flex-row items-stretch sm:items-center justify-between gap-3">
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
@@ -80,150 +59,14 @@
<button class="px-3 py-1 rounded bg-slate-700 hover:bg-slate-600 text-slate-100" (click)="nextPage()">Suivant</button> <button class="px-3 py-1 rounded bg-slate-700 hover:bg-slate-600 text-slate-100" (click)="nextPage()">Suivant</button>
</div> </div>
</div> </div>
}
@if (showUnified() && !loading() && unifiedTotal() === 0 && hasQuery()) { @if (error()) {
<p class="text-slate-400">Aucun résultat pour « {{ q() }} » avec les fournisseurs sélectionnés.</p> <div class="mb-4 bg-red-900/40 border border-red-600 text-red-200 p-3 rounded flex items-center justify-between">
} <span>{{ error() }}</span>
<button class="px-3 py-1 rounded bg-red-600 hover:bg-red-500 text-white text-sm" (click)="onSearchBarSubmit({ q: q(), providers: providersListParam() || 'all' })">Réessayer</button>
@if (error()) {
<div class="mb-4 bg-red-900/40 border border-red-600 text-red-200 p-3 rounded flex items-center justify-between">
<span>{{ error() }}</span>
<button class="px-3 py-1 rounded bg-red-600 hover:bg-red-500 text-white text-sm" (click)="onSearchBarSubmit({ q: q(), providers: providersListParam() || 'all' })">Réessayer</button>
</div>
}
@if (loading()) {
<div class="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-6">
@for (item of [1,2,3,4,5,6,7,8]; track item) {
<div class="animate-pulse bg-slate-800 rounded-lg overflow-hidden">
<div class="w-full h-48 bg-slate-700"></div>
<div class="p-4 space-y-3">
<div class="h-4 bg-slate-700 rounded w-3/4"></div>
<div class="h-4 bg-slate-700 rounded w-1/2"></div>
</div>
</div>
}
</div>
} @else if (selectedProviderForView() === 'twitch') {
<!-- Twitch: two sections -->
<div class="space-y-10">
<!-- Live Channels -->
<section *ngIf="filterTag() === 'twitch_all' || filterTag() === 'twitch_live'">
<h3 class="text-xl font-semibold text-slate-200 mb-3">Chaînes en direct</h3>
@if (twitchChannels().length > 0) {
<div class="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-x-6 gap-y-8">
@for (video of twitchChannels(); track video.videoId) {
<a [routerLink]="['/watch', video.videoId]" [queryParams]="watchQueryParams(video)" [state]="{ video }" class="group flex flex-col bg-slate-800/50 rounded-lg overflow-hidden hover:bg-slate-700/50 transition-all duration-300 transform hover:-translate-y-1">
<div class="relative">
<img [src]="video.thumbnail" [alt]="video.title" class="w-full h-48 object-cover transition-transform duration-300 group-hover:scale-105">
<div class="absolute top-2 left-2 bg-black/75 text-white text-xs px-2 py-1 rounded">
{{ formatViews(video.views) }} en direct
</div>
<div class="absolute bottom-2 left-2">
<app-like-button [videoId]="video.videoId" [provider]="'twitch'" [title]="video.title" [thumbnail]="video.thumbnail"></app-like-button>
<app-add-to-playlist class="ml-2" [provider]="'twitch'" [videoId]="video.videoId" [title]="video.title" [thumbnail]="video.thumbnail"></app-add-to-playlist>
</div>
</div>
<div class="p-4 flex-grow flex flex-col">
<h4 class="font-semibold text-slate-100 group-hover:text-red-400 transition-colors duration-200 line-clamp-2">{{ video.title }}</h4>
<div class="mt-2 flex items-center space-x-3 text-sm text-slate-400">
<img [src]="video.uploaderAvatar" [alt]="video.uploaderName" class="w-8 h-8 rounded-full">
<span>{{ video.uploaderName }}</span>
</div>
</div>
</a>
}
</div>
@if (twitchCursorChannels()) {
<div class="mt-4 flex justify-center">
<button class="px-4 py-2 rounded bg-slate-700 hover:bg-slate-600 text-slate-100 disabled:opacity-50" (click)="loadMoreTwitchChannels()" [disabled]="twitchBusyChannels()">Afficher plus</button>
</div>
}
} @else {
<p class="text-slate-400">Aucune chaîne en direct trouvée.</p>
}
</section>
<!-- Past Videos (VODs) -->
<section *ngIf="filterTag() === 'twitch_all' || filterTag() === 'twitch_vod'">
<h3 class="text-xl font-semibold text-slate-200 mb-3">Vidéos (VOD)</h3>
@if (twitchVods().length > 0) {
<div class="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-x-6 gap-y-8">
@for (video of twitchVods(); track video.videoId) {
<a [routerLink]="['/watch', video.videoId]" [queryParams]="watchQueryParams(video)" [state]="{ video }" class="group flex flex-col bg-slate-800/50 rounded-lg overflow-hidden hover:bg-slate-700/50 transition-all duration-300 transform hover:-translate-y-1">
<div class="relative">
<img [src]="video.thumbnail" [alt]="video.title" class="w-full h-48 object-cover transition-transform duration-300 group-hover:scale-105">
<div class="absolute bottom-2 right-2 bg-black/75 text-white text-xs px-2 py-1 rounded">
{{ video.duration / 60 | number:'1.0-0' }}:{{ (video.duration % 60) | number:'2.0-0' }}
</div>
<div class="absolute bottom-2 left-2">
<app-like-button [videoId]="video.videoId" [provider]="'twitch'" [title]="video.title" [thumbnail]="video.thumbnail"></app-like-button>
<app-add-to-playlist class="ml-2" [provider]="'twitch'" [videoId]="video.videoId" [title]="video.title" [thumbnail]="video.thumbnail"></app-add-to-playlist>
</div>
</div>
<div class="p-4 flex-grow flex flex-col">
<h4 class="font-semibold text-slate-100 group-hover:text-red-400 transition-colors duration-200 line-clamp-2">{{ video.title }}</h4>
<div class="mt-2 flex items-center space-x-3 text-sm text-slate-400">
<img [src]="video.uploaderAvatar" [alt]="video.uploaderName" class="w-8 h-8 rounded-full">
<span>{{ video.uploaderName }}</span>
</div>
<div class="mt-auto pt-2 text-sm text-slate-400">
<span>{{ formatViews(video.views) }} visionnements</span> &bull; <span>{{ formatRelative(video.uploadedDate) }}</span>
</div>
</div>
</a>
}
</div>
@if (twitchCursorVods()) {
<div class="mt-4 flex justify-center">
<button class="px-4 py-2 rounded bg-slate-700 hover:bg-slate-600 text-slate-100 disabled:opacity-50" (click)="loadMoreTwitchVods()" [disabled]="twitchBusyVods()">Afficher plus</button>
</div>
}
} @else {
<p class="text-slate-400">Aucune vidéo VOD trouvée.</p>
}
</section>
</div>
} @else if (results().length > 0) {
<div class="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-x-6 gap-y-8">
@for (video of filteredResults(); track video.videoId) {
<a [routerLink]="['/watch', video.videoId]" [queryParams]="watchQueryParams(video)" [state]="{ video }" class="group flex flex-col bg-slate-800/50 rounded-lg overflow-hidden hover:bg-slate-700/50 transition-all duration-300 transform hover:-translate-y-1">
<div class="relative">
<img [src]="video.thumbnail" [alt]="video.title" class="w-full h-48 object-cover transition-transform duration-300 group-hover:scale-105">
<div class="absolute bottom-2 right-2 bg-black/75 text-white text-xs px-2 py-1 rounded">
{{ video.duration / 60 | number:'1.0-0' }}:{{ (video.duration % 60) | number:'2.0-0' }}
</div>
<div class="absolute bottom-2 left-2">
<app-like-button [videoId]="video.videoId" [provider]="selectedProviderForView()" [title]="video.title" [thumbnail]="video.thumbnail"></app-like-button>
<app-add-to-playlist class="ml-2" [provider]="selectedProviderForView()" [videoId]="video.videoId" [title]="video.title" [thumbnail]="video.thumbnail"></app-add-to-playlist>
</div>
</div>
<div class="p-4 flex-grow flex flex-col">
<h3 class="font-semibold text-slate-100 group-hover:text-red-400 transition-colors duration-200 line-clamp-2">{{ video.title }}</h3>
<div class="mt-2 flex items-center space-x-3 text-sm text-slate-400">
<img [src]="video.uploaderAvatar" [alt]="video.uploaderName" class="w-8 h-8 rounded-full">
<span>{{ video.uploaderName }}</span>
</div>
<div class="mt-auto pt-2 text-sm text-slate-400">
<span>{{ formatViews(video.views) }} visionnements</span> &bull; <span>{{ formatRelative(video.uploadedDate) }}</span>
</div>
</div>
</a>
}
</div>
<!-- Infinite scroll anchor -->
<app-infinite-anchor class="mt-6"
[disabled]="!nextCursor()"
[busy]="busyMore()"
(loadMore)="fetchNextPage()"></app-infinite-anchor>
@if (busyMore()) {
<div class="flex items-center justify-center py-4 text-slate-400">
<svg class="animate-spin h-5 w-5 mr-2" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle><path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path></svg>
{{ 'loading.more' | t }}
</div> </div>
} }
} @else if (hasQuery()) {
<p class="text-slate-400">{{ 'search.noResults' | t }}</p> <app-search-result-grid [videos]="filteredResults()" [loading]="loading()"></app-search-result-grid>
} }
</div> </div>
+204 -289
View File
@@ -1,26 +1,25 @@
import { ChangeDetectionStrategy, Component, computed, effect, ElementRef, inject, signal, untracked, ViewChild } from '@angular/core'; import { ChangeDetectionStrategy, Component, computed, effect, inject, signal, ViewChild, ElementRef } from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { CommonModule } from '@angular/common'; import { CommonModule } from '@angular/common';
import { ActivatedRoute, Router, RouterLink } from '@angular/router'; import { ActivatedRoute, Router, ParamMap } from '@angular/router';
import { YoutubeApiService } from '../../services/youtube-api.service'; import { YoutubeApiService } from '../../services/youtube-api.service';
import { Video } from '../../models/video.model';
import { InfiniteAnchorComponent } from '../shared/infinite-anchor/infinite-anchor.component';
import { formatRelativeFr } from '../../utils/date.util'; import { formatRelativeFr } from '../../utils/date.util';
import { InstanceService, Provider } from '../../services/instance.service'; import { InstanceService, Provider } from '../../services/instance.service';
import { HistoryService } from '../../services/history.service'; import { HistoryService } from '../../services/history.service';
import { Title } from '@angular/platform-browser'; import { Title } from '@angular/platform-browser';
import { TranslatePipe } from '../../pipes/translate.pipe'; import { TranslatePipe } from '../../pipes/translate.pipe';
import { LikeButtonComponent } from '../shared/components/like-button/like-button.component'; import { SearchResultGridComponent } from 'src/app/shared/components/search-result-grid/search-result-grid.component';
import { AddToPlaylistComponent } from '../shared/components/add-to-playlist/add-to-playlist.component'; import { VideoItem } from 'src/app/shared/models/video-item.model';
import { SearchService, type SearchResponse } from '../../app/search/search.service'; import { SearchService, type SearchResponse } from '../../app/search/search.service';
import type { ProviderId } from '../../app/core/providers/provider-registry'; import type { ProviderId } from '../../app/core/providers/provider-registry';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { Observable, Subscription } from 'rxjs';
@Component({ @Component({
selector: 'app-search', selector: 'app-search',
standalone: true, standalone: true,
templateUrl: './search.component.html', templateUrl: './search.component.html',
changeDetection: ChangeDetectionStrategy.OnPush, changeDetection: ChangeDetectionStrategy.OnPush,
imports: [CommonModule, RouterLink, InfiniteAnchorComponent, TranslatePipe, LikeButtonComponent, AddToPlaylistComponent] imports: [CommonModule, TranslatePipe, SearchResultGridComponent]
}) })
export class SearchComponent { export class SearchComponent {
private route = inject(ActivatedRoute); private route = inject(ActivatedRoute);
@@ -33,16 +32,6 @@ export class SearchComponent {
q = signal<string>(''); q = signal<string>('');
loading = signal<boolean>(false); loading = signal<boolean>(false);
results = signal<Video[]>([]);
busyMore = signal<boolean>(false);
nextCursor = signal<string | null>(null);
// Twitch-specific dual lists
twitchChannels = signal<Video[]>([]);
twitchVods = signal<Video[]>([]);
twitchCursorChannels = signal<string | null>(null);
twitchCursorVods = signal<string | null>(null);
twitchBusyChannels = signal<boolean>(false);
twitchBusyVods = signal<boolean>(false);
notice = signal<string | null>(null); notice = signal<string | null>(null);
providerParam = signal<Provider | null>(null); providerParam = signal<Provider | null>(null);
themeParam = signal<string | null>(null); themeParam = signal<string | null>(null);
@@ -51,7 +40,7 @@ export class SearchComponent {
sortParam = signal<'relevance' | 'date' | 'views'>('relevance'); sortParam = signal<'relevance' | 'date' | 'views'>('relevance');
// Unified multi-provider response (grouped suggestions) // Unified multi-provider response (grouped suggestions)
groups = signal<Record<ProviderId, any[]>>({} as any); groups = signal<Record<string, any[]>>({});
showUnified = signal<boolean>(false); showUnified = signal<boolean>(false);
error = signal<string | null>(null); error = signal<string | null>(null);
@@ -79,20 +68,52 @@ export class SearchComponent {
return ['yt','dm','tw','pt','od','ru']; return ['yt','dm','tw','pt','od','ru'];
}); });
// Subtitle display: codes separated by middot // Map between Provider and ProviderId
activeProvidersDisplay = computed(() => this.activeProviders().join(' · ')); private readonly providerIdMap: Record<string, Provider> = {
'yt': 'youtube',
'dm': 'dailymotion',
'tw': 'twitch',
'pt': 'peertube',
'od': 'odysee',
'ru': 'rumble'
};
// Helper to display provider label from registry/instances // Get active providers with full info
providerDisplay(p: ProviderId): string { activeProvidersInfo = computed(() => {
try { const activeIds = this.activeProviders();
const found = (this.instances.providers() || []).find(x => (x.id as any) === p); return this.instances.providers().filter(p =>
return found?.label || p.toUpperCase(); activeIds.some(id => this.providerIdMap[id] === p.id)
} catch { return p.toUpperCase(); } );
});
// Brand colors for provider badge (inline styles to match History badges)
getProviderColors(providerId: string | null | undefined): { [key: string]: string } {
const id = (providerId || '').toLowerCase();
switch (id) {
case 'youtube':
return { backgroundColor: 'rgba(220, 38, 38, 0.15)', color: 'rgb(248, 113, 113)', borderColor: 'rgba(239, 68, 68, 0.3)' };
case 'dailymotion':
return { backgroundColor: 'rgba(37, 99, 235, 0.15)', color: 'rgb(147, 197, 253)', borderColor: 'rgba(59, 130, 246, 0.3)' };
case 'peertube':
return { backgroundColor: 'rgba(245, 158, 11, 0.15)', color: 'rgb(252, 211, 77)', borderColor: 'rgba(245, 158, 11, 0.3)' };
case 'rumble':
return { backgroundColor: 'rgba(22, 163, 74, 0.15)', color: 'rgb(134, 239, 172)', borderColor: 'rgba(34, 197, 94, 0.3)' };
case 'twitch':
return { backgroundColor: 'rgba(168, 85, 247, 0.15)', color: 'rgb(216, 180, 254)', borderColor: 'rgba(168, 85, 247, 0.3)' };
case 'odysee':
return { backgroundColor: 'rgba(236, 72, 153, 0.15)', color: 'rgb(251, 207, 232)', borderColor: 'rgba(236, 72, 153, 0.3)' };
default:
return { backgroundColor: 'rgba(30, 41, 59, 0.8)', color: 'rgb(203, 213, 225)', borderColor: 'rgb(51, 65, 85)' };
}
} }
// Title: Search ${q ? ` — “${q}”` : ''}
// Page title with search query
pageHeading = computed(() => { pageHeading = computed(() => {
const q = this.q(); const q = this.q();
return `Search ${q ? ` — “${q}”` : ''}`; if (q) {
return `Résultats pour « ${q} »`;
}
return 'Recherche';
}); });
// Public computed used by the template to avoid referencing private `instances` // Public computed used by the template to avoid referencing private `instances`
selectedProviderForView = computed(() => this.providerParam() || this.instances.selectedProvider()); selectedProviderForView = computed(() => this.providerParam() || this.instances.selectedProvider());
@@ -111,46 +132,19 @@ export class SearchComponent {
canPrev = computed(() => (this.pageParam() || 1) > 1); canPrev = computed(() => (this.pageParam() || 1) > 1);
// Helper computed: filtered list based on active tag (non-Twitch only) // Flatten all unified results into a single list of VideoItems
allVideos = computed<any[]>(() => {
const g = this.groups();
if (!g) return [];
return Object.values(g).flat();
});
// Helper computed: filtered list based on active tag
filteredResults = computed(() => { filteredResults = computed(() => {
const tag = this.filterTag(); const tag = this.filterTag();
const list = this.allVideos();
const provider = this.selectedProviderForView(); const provider = this.selectedProviderForView();
// Use unified results if available
if (this.showUnified()) {
const groups = this.groups();
const list: Video[] = [];
// Convert unified groups to Video objects
Object.entries(groups).forEach(([providerId, items]) => {
items.forEach((item: any) => {
list.push({
videoId: item.id,
title: item.title,
thumbnail: item.thumbnail || '',
uploaderName: item.uploaderName || 'Unknown',
uploaderAvatar: '',
views: 0,
uploaded: Date.now(),
uploadedDate: new Date().toISOString(),
duration: 0,
url: item.url || '',
type: 'video',
provider: providerId as 'youtube'|'dailymotion'|'twitch'|'rumble'|'odysee'|'peertube'
});
});
});
// Apply tag filtering to unified results
if (provider === 'twitch') return list; // Not used for Twitch (separate sections)
if (tag === 'all') return list;
// For now, return all results since we don't have duration/uploaded info from unified search
return list;
}
// Fallback to legacy results
const list = this.results();
if (provider === 'twitch') return list; // Not used for Twitch (separate sections) if (provider === 'twitch') return list; // Not used for Twitch (separate sections)
if (tag === 'all') return list; if (tag === 'all') return list;
@@ -162,84 +156,76 @@ export class SearchComponent {
// Duration filters // Duration filters
if (tag === 'short') return list.filter(v => { if (tag === 'short') return list.filter(v => {
const d = Number(v.duration || 0); const d = Number(v.durationSec || 0);
return d > 0 && d < 4 * 60; return d > 0 && d < 4 * 60;
}); });
if (tag === 'medium') return list.filter(v => { if (tag === 'medium') return list.filter(v => {
const d = Number(v.duration || 0); const d = Number(v.durationSec || 0);
return d >= 4 * 60 && d < 20 * 60; return d >= 4 * 60 && d < 20 * 60;
}); });
if (tag === 'long') return list.filter(v => { if (tag === 'long') return list.filter(v => {
const d = Number(v.duration || 0); const d = Number(v.durationSec || 0);
return d >= 20 * 60; return d >= 20 * 60;
}); });
// Date filters // Date filters
if (tag === 'today') return list.filter(v => { if (tag === 'today') return list.filter(v => {
const uploadTime = typeof v.uploaded === 'number' ? v.uploaded : 0; const uploadTime = v.publishedAt ? new Date(v.publishedAt).getTime() : 0;
return uploadTime > 0 && (now - uploadTime) <= oneDay; return uploadTime > 0 && (now - uploadTime) <= oneDay;
}); });
if (tag === 'this_week') return list.filter(v => { if (tag === 'this_week') return list.filter(v => {
const uploadTime = typeof v.uploaded === 'number' ? v.uploaded : 0; const uploadTime = v.publishedAt ? new Date(v.publishedAt).getTime() : 0;
return uploadTime > 0 && (now - uploadTime) <= sevenDays; return uploadTime > 0 && (now - uploadTime) <= sevenDays;
}); });
if (tag === 'this_month') return list.filter(v => { if (tag === 'this_month') return list.filter(v => {
const uploadTime = typeof v.uploaded === 'number' ? v.uploaded : 0; const uploadTime = v.publishedAt ? new Date(v.publishedAt).getTime() : 0;
return uploadTime > 0 && (now - uploadTime) <= thirtyDays; return uploadTime > 0 && (now - uploadTime) <= thirtyDays;
}); });
if (tag === 'this_year') return list.filter(v => { if (tag === 'this_year') return list.filter(v => {
const uploadTime = typeof v.uploaded === 'number' ? v.uploaded : 0; const uploadTime = v.publishedAt ? new Date(v.publishedAt).getTime() : 0;
return uploadTime > 0 && (now - uploadTime) <= oneYear; return uploadTime > 0 && (now - uploadTime) <= oneYear;
}); });
return list; return list;
}); });
// Filter each provider group separately for sectioned rendering
filteredGroups = computed(() => {
const tag = this.filterTag();
const g = this.groups();
const result: Record<string, any[]> = {};
const now = Date.now();
const oneDay = 24 * 60 * 60 * 1000;
const sevenDays = 7 * oneDay;
const thirtyDays = 30 * oneDay;
const oneYear = 365 * oneDay;
const applyFilter = (list: any[]): any[] => {
if (tag === 'all' || this.selectedProviderForView() === 'twitch') return list;
if (tag === 'short') return list.filter(v => { const d = Number(v.durationSec || 0); return d > 0 && d < 4 * 60; });
if (tag === 'medium') return list.filter(v => { const d = Number(v.durationSec || 0); return d >= 4 * 60 && d < 20 * 60; });
if (tag === 'long') return list.filter(v => { const d = Number(v.durationSec || 0); return d >= 20 * 60; });
if (tag === 'today') return list.filter(v => { const t = v.publishedAt ? new Date(v.publishedAt).getTime() : 0; return t > 0 && (now - t) <= oneDay; });
if (tag === 'this_week') return list.filter(v => { const t = v.publishedAt ? new Date(v.publishedAt).getTime() : 0; return t > 0 && (now - t) <= sevenDays; });
if (tag === 'this_month') return list.filter(v => { const t = v.publishedAt ? new Date(v.publishedAt).getTime() : 0; return t > 0 && (now - t) <= thirtyDays; });
if (tag === 'this_year') return list.filter(v => { const t = v.publishedAt ? new Date(v.publishedAt).getTime() : 0; return t > 0 && (now - t) <= oneYear; });
return list;
};
Object.entries(g || {}).forEach(([pid, list]) => {
result[pid] = Array.isArray(list) ? applyFilter(list) : [];
});
return result;
});
// Available tags computed from current results (only show tags that can apply) // Available tags computed from current results (only show tags that can apply)
availableTags = computed(() => { availableTags = computed(() => {
const provider = this.selectedProviderForView(); const list = this.allVideos();
if (list.length === 0) return [];
const tags: { key: string; label: string; show: boolean }[] = []; const tags: { key: string; label: string; show: boolean }[] = [];
const now = Date.now(); const now = Date.now();
// Use unified results if available
const list = this.showUnified() ?
(() => {
const groups = this.groups();
const results: Video[] = [];
Object.entries(groups).forEach(([_, items]) => {
items.forEach((item: any) => {
results.push({
videoId: item.id,
title: item.title,
thumbnail: item.thumbnail || '',
uploaderName: item.uploaderName || 'Unknown',
uploaderAvatar: '',
views: 0,
uploaded: Date.now(),
uploadedDate: new Date().toISOString(),
duration: 0,
url: item.url || '',
type: 'video',
provider: 'youtube' as any
});
});
});
return results;
})() :
this.results();
if (provider === 'twitch') {
const hasLive = this.twitchChannels().length > 0;
const hasVods = this.twitchVods().length > 0;
// Show the group if at least one of the sections is available
if (hasLive || hasVods) {
tags.push({ key: 'twitch_all', label: 'Tout', show: hasLive && hasVods });
tags.push({ key: 'twitch_live', label: 'En direct', show: hasLive });
tags.push({ key: 'twitch_vod', label: 'VOD', show: hasVods });
}
return tags.filter(t => t.show);
}
// Non-Twitch providers: duration + date filters // Non-Twitch providers: duration + date filters
const oneDay = 24 * 60 * 60 * 1000; const oneDay = 24 * 60 * 60 * 1000;
const sevenDays = 7 * oneDay; const sevenDays = 7 * oneDay;
@@ -250,8 +236,8 @@ export class SearchComponent {
hasToday = false, hasThisWeek = false, hasThisMonth = false, hasThisYear = false; hasToday = false, hasThisWeek = false, hasThisMonth = false, hasThisYear = false;
for (const v of list) { for (const v of list) {
const d = Number(v.duration || 0); const d = Number(v.durationSec || 0);
const uploadTime = typeof v.uploaded === 'number' ? v.uploaded : 0; const uploadTime = v.publishedAt ? new Date(v.publishedAt).getTime() : 0;
const timeDiff = now - uploadTime; const timeDiff = now - uploadTime;
// Duration filters // Duration filters
@@ -290,21 +276,32 @@ export class SearchComponent {
}); });
// Subscribe once to unified multi-provider results and reflect to UI // Subscribe once to unified multi-provider results and reflect to UI
this.unified.request$.pipe(takeUntilDestroyed()).subscribe({ const subscription: Subscription = this.unified.request$
next: (resp: SearchResponse) => { .pipe(takeUntilDestroyed())
this.groups.set(resp.groups as any); .subscribe({
this.loading.set(false); next: (resp: any) => {
this.error.set(null); // Convertir la réponse en un format compatible avec notre modèle
}, const groups: Record<string, any[]> = {};
error: () => { if (resp.groups) {
this.groups.set({} as any); Object.entries(resp.groups).forEach(([key, value]) => {
this.loading.set(false); groups[key] = Array.isArray(value) ? value : [];
this.error.set('Le service de recherche est temporairement indisponible. Réessayer.'); });
} }
}); this.groups.set(groups);
this.loading.set(false);
this.error.set(null);
},
error: () => {
this.groups.set({} as any);
this.loading.set(false);
this.error.set('Le service de recherche est temporairement indisponible. Réessayer.');
}
});
// Listen to query param changes (so subsequent searches update) // Listen to query param changes (so subsequent searches update)
this.route.queryParamMap.pipe(takeUntilDestroyed()).subscribe((pm) => { const paramSubscription: Subscription = this.route.queryParamMap
.pipe(takeUntilDestroyed())
.subscribe((pm: ParamMap) => {
const q = (pm.get('q') || '').trim(); const q = (pm.get('q') || '').trim();
this.q.set(q); this.q.set(q);
const prov = (pm.get('provider') as Provider) || (pm.get('p') as Provider) || null; const prov = (pm.get('provider') as Provider) || (pm.get('p') as Provider) || null;
@@ -361,8 +358,6 @@ export class SearchComponent {
this.groups.set({} as any); // Clear previous results this.groups.set({} as any); // Clear previous results
this.error.set(null); this.error.set(null);
} else { } else {
this.results.set([]);
this.nextCursor.set(null);
this.loading.set(false); this.loading.set(false);
this.groups.set({} as any); this.groups.set({} as any);
this.showUnified.set(false); this.showUnified.set(false);
@@ -370,139 +365,8 @@ export class SearchComponent {
} }
}); });
// React to provider/region/PeerTube instance changes to refresh search automatically
effect(() => {
const provider = this.instances.selectedProvider();
const region = this.instances.region();
const ptInstance = this.instances.activePeerTubeInstance();
untracked(() => {
// If provider is explicitly specified in query, do not override it with global changes
if (this.providerParam()) return;
if (!this.q()) return;
this.notice.set(null);
// If unified mode is active, re-emit providers to trigger a refresh instead of legacy reload
if (this.showUnified()) {
const provParam = this.providersListParam();
const provider = this.instances.selectedProvider();
const providersToUse = (provParam && (provParam === 'all' || (Array.isArray(provParam) && provParam.length > 0)))
? (provParam as any)
: [provider as unknown as ProviderId];
this.unified.setProviders(providersToUse);
this.loading.set(true);
} else {
this.reloadSearch();
}
});
}, { allowSignalWrites: true });
} }
reloadSearch() {
const readiness = this.instances.getProviderReadiness(this.providerParam() || undefined as any);
if (!readiness.ready) {
this.notice.set(readiness.reason || 'Le provider sélectionné n\'est pas prêt.');
this.results.set([]);
this.nextCursor.set(null);
this.twitchChannels.set([]);
this.twitchVods.set([]);
this.twitchCursorChannels.set(null);
this.twitchCursorVods.set(null);
this.loading.set(false);
return;
}
this.loading.set(true);
this.results.set([]);
this.nextCursor.set(null);
this.twitchChannels.set([]);
this.twitchVods.set([]);
this.twitchCursorChannels.set(null);
this.twitchCursorVods.set(null);
const provider = this.providerParam() || this.instances.selectedProvider();
// Record search term with provider once per reload
try {
this.history.recordSearch(this.q(), { provider }).subscribe({
next: () => {},
error: (err) => console.error('Error recording search:', err)
});
} catch (err) {
console.error('Error in recordSearch:', err);
}
// Ensure default tag matches provider
this.filterTag.set(provider === 'twitch' ? 'twitch_all' : 'all');
if (provider === 'twitch') {
// Load both sections in parallel
this.twitchBusyChannels.set(true);
this.twitchBusyVods.set(true);
this.api.searchTwitchChannelsPage(this.q(), null).subscribe(res => {
this.twitchChannels.set(res.items);
this.twitchCursorChannels.set(res.nextCursor || null);
this.twitchBusyChannels.set(false);
this.loading.set(false);
});
this.api.searchTwitchVodsPage(this.q(), null).subscribe(res => {
this.twitchVods.set(res.items);
this.twitchCursorVods.set(res.nextCursor || null);
this.twitchBusyVods.set(false);
this.loading.set(false);
});
} else {
this.fetchNextPage();
}
}
fetchNextPage() {
if (this.busyMore() || !this.q()) return;
const readiness = this.instances.getProviderReadiness(this.providerParam() || undefined as any);
if (!readiness.ready) {
if (!this.notice()) this.notice.set(readiness.reason || 'Le provider sélectionné n\'est pas prêt.');
return;
}
this.busyMore.set(true);
const providerOverride = this.providerParam();
this.api.searchVideosPage(this.q(), this.nextCursor(), providerOverride as any).subscribe(res => {
const merged = [...this.results(), ...res.items];
this.results.set(merged);
this.nextCursor.set(res.nextCursor || null);
this.busyMore.set(false);
this.loading.set(false);
// Provider/instance availability notices (avoid overriding legitimate no-results unless clear)
if (merged.length === 0 && !this.notice()) {
const readiness2 = this.instances.getProviderReadiness();
if (!readiness2.ready) {
this.notice.set(readiness2.reason || 'Le provider sélectionné n\'est pas prêt.');
} else {
const provider = this.providerParam() || this.instances.selectedProvider();
if (provider === 'peertube') {
const inst = this.instances.activePeerTubeInstance();
this.notice.set(`PeerTube: les vidéos ne sont pas disponibles depuis l'instance "${inst}" pour le moment. Essayez une autre instance dans l'en-tête.`);
} else if (provider === 'rumble') {
const label = this.instances.selectedProviderLabel();
this.notice.set(`Les vidéos ne sont pas disponibles pour le provider "${label}" pour le moment. Réessayez plus tard ou choisissez un autre provider.`);
}
}
}
});
}
loadMoreTwitchChannels() {
if (this.twitchBusyChannels() || !this.twitchCursorChannels()) return;
this.twitchBusyChannels.set(true);
this.api.searchTwitchChannelsPage(this.q(), this.twitchCursorChannels()).subscribe(res => {
this.twitchChannels.set([...this.twitchChannels(), ...res.items]);
this.twitchCursorChannels.set(res.nextCursor || null);
this.twitchBusyChannels.set(false);
});
}
loadMoreTwitchVods() {
if (this.twitchBusyVods() || !this.twitchCursorVods()) return;
this.twitchBusyVods.set(true);
this.api.searchTwitchVodsPage(this.q(), this.twitchCursorVods()).subscribe(res => {
this.twitchVods.set([...this.twitchVods(), ...res.items]);
this.twitchCursorVods.set(res.nextCursor || null);
this.twitchBusyVods.set(false);
});
}
formatViews(views: number): string { formatViews(views: number): string {
if (views >= 1_000_000_000) return (views / 1_000_000_000).toFixed(1) + 'B'; if (views >= 1_000_000_000) return (views / 1_000_000_000).toFixed(1) + 'B';
@@ -516,40 +380,56 @@ export class SearchComponent {
return formatRelativeFr(dateIso); return formatRelativeFr(dateIso);
} }
// Build query params for Watch page (provider + optional odysee slug)
watchQueryParams(v: Video): Record<string, any> | null { // Méthode utilitaire pour nettoyer les paramètres de l'URL
const p = this.providerParam() || this.instances.selectedProvider(); private cleanUrlParams(params: any): any {
const qp: any = { p }; const cleanParams = { ...params };
if (p === 'odysee' && v.url?.startsWith('https://odysee.com/')) { // Supprimer explicitement le paramètre theme
let slug = v.url.substring('https://odysee.com/'.length); if ('theme' in cleanParams) {
if (slug.startsWith('/')) slug = slug.slice(1); delete cleanParams.theme;
qp.slug = slug;
} }
if (p === 'twitch' && v.type === 'channel') { // Nettoyer les valeurs null/undefined
// Extract channel login from uploaderUrl if available Object.keys(cleanParams).forEach(key => {
const url = v.uploaderUrl || v.url || ''; if (cleanParams[key] === null || cleanParams[key] === undefined) {
try { delete cleanParams[key];
const u = new URL(url); }
const parts = u.pathname.split('/').filter(Boolean); });
if (parts.length > 0) qp.channel = parts[0]; return cleanParams;
} catch {}
}
return qp;
} }
// Submit handler from the embedded SearchBox: keep URL as source of truth // Submit handler from the embedded SearchBox: keep URL as source of truth
onSearchBarSubmit(evt: { q: string; providers: ProviderId[] | 'all' }) { onSearchBarSubmit(evt: { q: string; providers: ProviderId[] | 'all' }) {
const q = (evt?.q || '').trim(); const q = (evt?.q || '').trim();
if (!q) return; if (!q) return;
const provider = this.providerParam() || this.instances.selectedProvider();
const qp: any = { q, p: provider, provider: null }; // Créer un nouvel objet de paramètres de requête
if (Array.isArray(evt.providers)) qp.providers = evt.providers.join(','); const queryParams: any = {
else if (evt.providers === 'all') qp.providers = 'yt,dm,tw,pt,od,ru'; q,
qp.page = 1; // reset pagination on new search p: this.providerParam() || this.instances.selectedProvider(),
this.router.navigate([], { relativeTo: this.route, queryParams: qp, queryParamsHandling: 'merge' }); page: 1 // reset pagination on new search
};
// Gérer les fournisseurs
if (Array.isArray(evt.providers)) {
queryParams.providers = evt.providers.join(',');
} else if (evt.providers === 'all') {
queryParams.providers = 'yt,dm,tw,pt,od,ru';
}
// Nettoyer les paramètres avant la navigation
const cleanParams = this.cleanUrlParams(queryParams);
// Utiliser replaceUrl: true pour éviter d'ajouter une entrée dans l'historique
// et forcer le rechargement avec les nouveaux paramètres
this.router.navigate([], {
relativeTo: this.route,
queryParams: cleanParams,
queryParamsHandling: '',
replaceUrl: true
});
} }
@ViewChild('searchInput') searchInput?: ElementRef<HTMLInputElement>; @ViewChild('searchInput', { static: false }) searchInput?: ElementRef<HTMLInputElement>;
// Legacy input handlers removed; SearchBox handles keyboard and submit // Legacy input handlers removed; SearchBox handles keyboard and submit
@@ -574,18 +454,47 @@ export class SearchComponent {
// Pagination controls // Pagination controls
nextPage() { nextPage() {
const next = (this.pageParam() || 1) + 1; const next = (this.pageParam() || 1) + 1;
this.router.navigate([], { relativeTo: this.route, queryParams: { page: next }, queryParamsHandling: 'merge' }); const cleanParams = this.cleanUrlParams({
...this.route.snapshot.queryParams,
page: next
});
this.router.navigate([], {
relativeTo: this.route,
queryParams: cleanParams,
queryParamsHandling: '',
replaceUrl: true
});
} }
prevPage() { prevPage() {
const prev = Math.max(1, (this.pageParam() || 1) - 1); const prev = Math.max(1, (this.pageParam() || 1) - 1);
this.router.navigate([], { relativeTo: this.route, queryParams: { page: prev }, queryParamsHandling: 'merge' }); const cleanParams = this.cleanUrlParams({
...this.route.snapshot.queryParams,
page: prev
});
this.router.navigate([], {
relativeTo: this.route,
queryParams: cleanParams,
queryParamsHandling: '',
replaceUrl: true
});
} }
// Sort control // Sort control
onSortChange(event: Event) { onSortChange(event: Event) {
const value = (event.target as HTMLSelectElement)?.value as 'relevance' | 'date' | 'views' | ''; const value = (event.target as HTMLSelectElement)?.value as 'relevance' | 'date' | 'views' | '';
const sort = (value === 'date' || value === 'views') ? value : 'relevance'; const sort = (value === 'date' || value === 'views') ? value : 'relevance';
this.router.navigate([], { relativeTo: this.route, queryParams: { sort, page: 1 }, queryParamsHandling: 'merge' }); const cleanParams = this.cleanUrlParams({
...this.route.snapshot.queryParams,
sort,
page: 1
});
this.router.navigate([], {
relativeTo: this.route,
queryParams: cleanParams,
queryParamsHandling: '',
replaceUrl: true
});
} }
// CTA handler: navigate to focus on a single provider while preserving q // CTA handler: navigate to focus on a single provider while preserving q
@@ -593,6 +502,12 @@ export class SearchComponent {
const q = (this.q() || '').trim(); const q = (this.q() || '').trim();
const qp: any = { provider: p, p, providers: p, page: 1 }; const qp: any = { provider: p, p, providers: p, page: 1 };
if (q) qp.q = q; if (q) qp.q = q;
this.router.navigate([], { relativeTo: this.route, queryParams: qp, queryParamsHandling: 'merge' }); const cleanParams = this.cleanUrlParams(qp);
this.router.navigate([], {
relativeTo: this.route,
queryParams: cleanParams,
queryParamsHandling: '',
replaceUrl: true
});
} }
} }
@@ -1,50 +0,0 @@
<nav class="sticky top-16 left-0 right-0 z-40 w-full relative"
[ngClass]="{
'bg-slate-800 border-b border-slate-700': isDarkTheme(),
'bg-slate-900 border-b border-slate-800': isBlackTheme(),
'bg-slate-700 border-b border-slate-600': isBlueTheme()
}">
<!-- Left scroll button -->
<button type="button" (click)="scrollBy(-320)" aria-label="Scroll left"
class="hidden md:flex items-center justify-center absolute left-2 top-1/2 -translate-y-1/2 h-9 w-9 rounded-full shadow z-10"
[ngClass]="{
'bg-white/90 border border-slate-300 text-slate-700 hover:bg-slate-100': isLightTheme(),
'bg-slate-800/90 border border-slate-700 text-slate-200 hover:bg-slate-700': !isLightTheme()
}">
«
</button>
<!-- Right scroll button -->
<button type="button" (click)="scrollBy(320)" aria-label="Scroll right"
class="hidden md:flex items-center justify-center absolute right-2 top-1/2 -translate-y-1/2 h-9 w-9 rounded-full shadow z-10"
[ngClass]="{
'bg-white/90 border border-slate-300 text-slate-700 hover:bg-slate-100': isLightTheme(),
'bg-slate-800/90 border border-slate-700 text-slate-200 hover:bg-slate-700': !isLightTheme()
}">
»
</button>
<div class="w-full overflow-x-auto no-scrollbar" #scroll (wheel)="onWheel($event)">
<ul role="tablist" aria-label="Themes" class="flex gap-2 px-12 py-2 min-w-max items-center">
<li *ngFor="let t of displayedThemes(); let i = index" class="shrink-0">
<button
#pill
role="tab"
type="button"
class="px-3 py-1.5 rounded-full text-sm font-medium border transition-colors whitespace-nowrap focus:outline-none focus:ring-2 focus:ring-red-500"
[class.bg-slate-100/10]="activeSlug() === t.slug"
[class.text-white]="activeSlug() === t.slug"
[class.border-red-500]="activeSlug() === t.slug"
[class.text-slate-300]="activeSlug() !== t.slug"
[class.border-slate-700]="activeSlug() !== t.slug"
[attr.aria-selected]="activeSlug() === t.slug"
(click)="goToTheme(t.slug)"
(keydown)="onKeydown($event, i)"
>
<span class="mr-1 select-none">{{ t.emoji }}</span>
<span>{{ themesSvc.i18nLabel(t) }}</span>
</button>
</li>
</ul>
</div>
</nav>
+1 -1
View File
@@ -379,7 +379,7 @@ export class YoutubeApiService {
// search.list only supports 'snippet' // search.list only supports 'snippet'
part: 'snippet', part: 'snippet',
// Optimize for quota usage: reduce initial results // Optimize for quota usage: reduce initial results
maxResults: '12', maxResults: '25',
q: String(query), q: String(query),
regionCode: String(region), regionCode: String(region),
// Add safeSearch for better content filtering // Add safeSearch for better content filtering