feat: update Angular cache with latest TypeScript definitions

This commit is contained in:
Bruno Charest 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

Binary file not shown.

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,15 +52,15 @@ 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 [];

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 };
} }

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);
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?.od || []).map((it: any) => {
let slug: string | undefined;
try {
const url: string | undefined = it?.url;
if (url && /^https?:\/\/odysee\.com\//i.test(url)) {
slug = url.replace(/^https?:\/\/odysee\.com\//i, '').replace(/^\//, '');
}
} catch {}
return {
id: it.id, id: it.id,
provider: this.key, provider: 'odysee',
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, slug,
isShort: it.isShort === true } 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 };
} }
} }

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 };
} }

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 };
} }

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);
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?.tw || []).map((it: any) => {
const isChannel = String(it?.type || '').toLowerCase() === 'channel';
return {
id: it.id, id: it.id,
provider: this.key, provider: 'twitch',
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, channel: isChannel ? it.id : undefined,
isLive: it.type === 'live', } as VideoItem;
isShort: false });
}));
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 };
} }
} }

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 };
} }

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>>;
} }

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

View File

@ -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>

View File

@ -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);
}
}

View File

@ -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>

View File

@ -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 {}

View File

@ -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>

View File

@ -0,0 +1,7 @@
.line-clamp-2 {
display: -webkit-box;
-webkit-line-clamp: 2;
line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}

View File

@ -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;
}
}

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
}

View File

@ -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">

View File

@ -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 } = {

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,11 +59,6 @@
<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()) {
<p class="text-slate-400">Aucun résultat pour « {{ q() }} » avec les fournisseurs sélectionnés.</p>
}
@if (error()) { @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"> <div class="mb-4 bg-red-900/40 border border-red-600 text-red-200 p-3 rounded flex items-center justify-between">
@ -93,137 +67,6 @@
</div> </div>
} }
@if (loading()) { <app-search-result-grid [videos]="filteredResults()" [loading]="loading()"></app-search-result-grid>
<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>
}
} @else if (hasQuery()) {
<p class="text-slate-400">{{ 'search.noResults' | t }}</p>
} }
</div> </div>

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,9 +276,18 @@ 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({
next: (resp: any) => {
// Convertir la réponse en un format compatible avec notre modèle
const groups: Record<string, any[]> = {};
if (resp.groups) {
Object.entries(resp.groups).forEach(([key, value]) => {
groups[key] = Array.isArray(value) ? value : [];
});
}
this.groups.set(groups);
this.loading.set(false); this.loading.set(false);
this.error.set(null); this.error.set(null);
}, },
@ -304,7 +299,9 @@ export class SearchComponent {
}); });
// 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];
} catch {}
} }
return qp; });
return cleanParams;
} }
// 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';
} }
@ViewChild('searchInput') searchInput?: ElementRef<HTMLInputElement>; // 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', { 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
});
} }
} }

View File

@ -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>

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