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 = {
id: 'tw',
@@ -12,17 +12,36 @@ const handler = {
async search(q, opts) {
const { limit = 10 } = opts;
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(
`https://api.twitch.tv/kraken/search/streams?` +
`https://api.twitch.tv/helix/search/channels?` +
new URLSearchParams({
query: q,
limit: Math.min(limit, 25).toString()
first: Math.min(limit, 100).toString()
}),
{
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();
return (data.streams || []).map(item => ({
title: item.channel.status || item.channel.display_name,
id: item.channel.name, // Channel name as ID
url: `https://www.twitch.tv/${item.channel.name}`,
thumbnail: item.preview?.medium || item.channel.logo,
uploaderName: item.channel.display_name,
return data.data?.map(item => ({
title: item.title || item.display_name,
id: item.id,
url: `https://www.twitch.tv/${item.broadcaster_login}`,
thumbnail: item.thumbnail_url?.replace('{width}x{height}', '440x248'),
uploaderName: item.display_name,
type: 'stream',
isLive: true
}));
isLive: item.is_live || item.started_at
})) || [];
} catch (error) {
console.error('Twitch search error:', error);
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 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;
label = 'Dailymotion';
constructor(private http: HttpClient) {}
async search(params: ProviderSearchParams, _signal: AbortSignal): Promise<SearchResult> {
const res = await firstValueFrom(this.http.get<any>(`/api/search`, { params: { q: params.q, providers: 'dm' } }));
const items = (res?.groups?.dm || []).map((it: any) => ({
async search(params: ProviderSearchParams, _signal: AbortSignal): Promise<SearchResult<VideoItem>> {
let httpParams = new HttpParams().set('q', params.q).set('providers', 'dm');
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,
provider: this.key,
provider: 'dailymotion',
title: it.title,
channel: it.uploaderName,
channelName: it.uploaderName,
durationSec: it.duration,
thumbUrl: it.thumbnail,
watchUrl: it.url || '',
views: undefined,
publishedAt: undefined,
isLive: it.type === 'live',
isShort: it.isShort === true
thumbnailUrl: it.thumbnail,
viewCount: undefined, // Dailymotion API does not provide view count in search results
publishedAt: undefined, // Dailymotion API does not provide published date in search results
}));
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 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;
label = 'Odysee';
constructor(private http: HttpClient) {}
async search(params: ProviderSearchParams, _signal: AbortSignal): Promise<SearchResult> {
const res = await firstValueFrom(this.http.get<any>(`/api/search`, { params: { q: params.q, providers: 'od' } }));
const items = (res?.groups?.od || []).map((it: any) => ({
id: it.id,
provider: this.key,
title: it.title,
channel: it.uploaderName,
durationSec: it.duration,
thumbUrl: it.thumbnail,
watchUrl: it.url || '',
views: undefined,
publishedAt: undefined,
isLive: false,
isShort: it.isShort === true
}));
async search(params: ProviderSearchParams, _signal: AbortSignal): Promise<SearchResult<VideoItem>> {
let httpParams = new HttpParams().set('q', params.q).set('providers', 'od');
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,
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 };
}
}
+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 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;
label = 'PeerTube';
constructor(private http: HttpClient) {}
async search(params: ProviderSearchParams, _signal: AbortSignal): Promise<SearchResult> {
const res = await firstValueFrom(this.http.get<any>(`/api/search`, { params: { q: params.q, providers: 'pt' } }));
const items = (res?.groups?.pt || []).map((it: any) => ({
async search(params: ProviderSearchParams, _signal: AbortSignal): Promise<SearchResult<VideoItem>> {
let httpParams = new HttpParams().set('q', params.q).set('providers', 'pt');
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,
provider: this.key,
provider: 'peertube',
title: it.title,
channel: it.uploaderName,
channelName: it.uploaderName,
durationSec: it.duration,
thumbUrl: it.thumbnail,
watchUrl: it.url || '',
views: undefined,
thumbnailUrl: it.thumbnail,
viewCount: undefined,
publishedAt: undefined,
isLive: false,
isShort: false
}));
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 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;
label = 'Rumble';
constructor(private http: HttpClient) {}
async search(params: ProviderSearchParams, _signal: AbortSignal): Promise<SearchResult> {
const res = await firstValueFrom(this.http.get<any>(`/api/search`, { params: { q: params.q, providers: 'ru' } }));
const items = (res?.groups?.ru || []).map((it: any) => ({
async search(params: ProviderSearchParams, _signal: AbortSignal): Promise<SearchResult<VideoItem>> {
let httpParams = new HttpParams().set('q', params.q).set('providers', 'ru');
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,
provider: this.key,
provider: 'rumble',
title: it.title,
channel: it.uploaderName,
channelName: it.uploaderName,
durationSec: it.duration,
thumbUrl: it.thumbnail,
watchUrl: it.url || '',
views: undefined,
publishedAt: undefined,
isLive: false,
isShort: false
thumbnailUrl: it.thumbnail,
viewCount: typeof it.views === 'number' ? it.views : undefined,
publishedAt: it.publishedAt,
}));
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 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;
label = 'Twitch';
constructor(private http: HttpClient) {}
async search(params: ProviderSearchParams, _signal: AbortSignal): Promise<SearchResult> {
const res = await firstValueFrom(this.http.get<any>(`/api/search`, { params: { q: params.q, providers: 'tw' } }));
const items = (res?.groups?.tw || []).map((it: any) => ({
id: it.id,
provider: this.key,
title: it.title,
channel: it.uploaderName,
durationSec: it.duration,
thumbUrl: it.thumbnail,
watchUrl: it.url || '',
views: undefined,
publishedAt: undefined,
isLive: it.type === 'live',
isShort: false
}));
async search(params: ProviderSearchParams, _signal: AbortSignal): Promise<SearchResult<VideoItem>> {
let httpParams = new HttpParams().set('q', params.q).set('providers', 'tw');
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,
provider: 'twitch',
title: it.title,
channelName: it.uploaderName,
durationSec: it.duration,
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 };
}
}
+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 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;
label = 'YouTube';
constructor(private http: HttpClient) {}
async search(params: ProviderSearchParams, _signal: AbortSignal): Promise<SearchResult> {
const res = await firstValueFrom(this.http.get<any>(`/api/search`, { params: { q: params.q, providers: 'yt' } }));
const items = (res?.groups?.yt || []).map((it: any) => ({
async search(params: ProviderSearchParams, _signal: AbortSignal): Promise<SearchResult<VideoItem>> {
let httpParams = new HttpParams().set('q', params.q).set('providers', 'yt');
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,
provider: this.key,
provider: 'youtube',
title: it.title,
channel: it.uploaderName,
channelName: it.uploaderName,
durationSec: it.duration,
thumbUrl: it.thumbnail,
watchUrl: it.url || '',
views: undefined,
publishedAt: undefined,
isLive: it.type === 'live',
isShort: it.isShort === true
thumbnailUrl: it.thumbnail,
viewCount: typeof it.views === 'number' ? it.views : (typeof it.viewCount === 'number' ? it.viewCount : undefined),
publishedAt: it.publishedAt,
}));
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
}
export interface SearchResult {
items: SearchItem[];
export interface SearchResult<T = SearchItem> {
items: T[];
total?: number;
nextPageToken?: string; // or page: string|number
}
@@ -29,8 +29,8 @@ export interface ProviderSearchParams {
type?: 'video'|'live'|'shorts'|'all';
}
export interface ProviderAdapter {
export interface ProviderAdapter<T = SearchItem> {
key: ProviderId;
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 { RuAdapter } from './adapters/ru';
import type { ProviderAdapter, ProviderSearchParams, SearchResult } from './models';
import { VideoItem } from 'src/app/shared/models/video-item.model';
export type SuggestionItem = SuggestionItemV1;
export type SearchResponse = SearchResponseV1;
type CacheKey = string;
interface CacheEntry { t: number; data: SearchResult; }
interface CacheEntry<T> { t: number; data: SearchResult<T>; }
@Injectable({ providedIn: 'root' })
export class SearchService {
@@ -29,11 +30,11 @@ export class SearchService {
readonly sort$ = new BehaviorSubject<'relevance' | 'date' | 'views' | 'duration'>('relevance');
// 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;
// Adapters registry
private adapters: Record<ProviderId, ProviderAdapter> = {
private adapters: Record<ProviderId, ProviderAdapter<VideoItem>> = {
yt: new YtAdapter(this.http),
dm: new DmAdapter(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
}, prov)))
,
// Map normalized items to legacy "groups" response expected by UI
// Map provider results to the format expected by the UI
map((byProvider) => {
const groups = Object.entries(byProvider).reduce((acc, [pid, result]) => {
const arr: SuggestionItemV1[] = (result?.items || []).map(it => ({
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;
acc[pid as ProviderId] = result.items;
return acc;
}, {} as Record<ProviderId, SuggestionItemV1[]>);
}, {} as Record<ProviderId, VideoItem[]>);
const providers = Object.keys(byProvider) as ProviderId[];
const resp: SearchResponse = { q: this.q$.value, providers, groups };
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.
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 controller = new AbortController();
const signal = controller.signal;
const tasks = active.map(async (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 now = Date.now();
const cached = this.cache.get(key);
@@ -99,9 +91,9 @@ export class SearchService {
const tid = setTimeout(() => reject(new Error('timeout')), perProviderTimeout);
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 {
const res = await attempt().catch(async (e) => {
const res: SearchResult<VideoItem> = await attempt().catch(async (e) => {
// retry once on network-like errors
if (e && (e.name === 'AbortError' || String(e.message || '').includes('abort'))) throw e;
try { return await attempt(); } catch (err) { throw err; }
@@ -110,11 +102,11 @@ export class SearchService {
return [pid, res] as const;
} catch {
// 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);
return Object.fromEntries(results) as Record<ProviderId, SearchResult>;
return Object.fromEntries(results) as Record<ProviderId, SearchResult<VideoItem>>;
}
// 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">
<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">
<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">
<div class="flex items-start gap-2">
<span class="text-slate-400 mt-0.5">
@@ -114,7 +114,7 @@
<ul *ngIf="searchHistory().length > 0" class="space-y-3">
<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">
<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">
<div class="flex items-start gap-2">
<span class="text-slate-400 mt-0.5">
@@ -230,6 +230,17 @@ getSearchProvider(item: SearchHistoryItem): { name: string; id: string; provider
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
private formatProviderName(providerId: 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">
<header class="mb-4">
<h1 class="text-xl sm:text-2xl font-semibold text-slate-100">{{ pageHeading() }}</h1>
<p class="text-sm text-white/60 mt-1">{{ activeProvidersDisplay() }}</p>
<header class="mb-6">
<h1 class="text-2xl sm:text-3xl font-bold text-white mb-2">{{ pageHeading() }}</h1>
<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>
@if (notice()) {
@@ -30,38 +41,6 @@
</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()) {
<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">
@@ -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>
</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()) {
<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 }}
@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>
}
} @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>
+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 { ActivatedRoute, Router, RouterLink } from '@angular/router';
import { ActivatedRoute, Router, ParamMap } from '@angular/router';
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 { InstanceService, Provider } from '../../services/instance.service';
import { HistoryService } from '../../services/history.service';
import { Title } from '@angular/platform-browser';
import { TranslatePipe } from '../../pipes/translate.pipe';
import { LikeButtonComponent } from '../shared/components/like-button/like-button.component';
import { AddToPlaylistComponent } from '../shared/components/add-to-playlist/add-to-playlist.component';
import { SearchResultGridComponent } from 'src/app/shared/components/search-result-grid/search-result-grid.component';
import { VideoItem } from 'src/app/shared/models/video-item.model';
import { SearchService, type SearchResponse } from '../../app/search/search.service';
import type { ProviderId } from '../../app/core/providers/provider-registry';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { Observable, Subscription } from 'rxjs';
@Component({
selector: 'app-search',
standalone: true,
templateUrl: './search.component.html',
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [CommonModule, RouterLink, InfiniteAnchorComponent, TranslatePipe, LikeButtonComponent, AddToPlaylistComponent]
imports: [CommonModule, TranslatePipe, SearchResultGridComponent]
})
export class SearchComponent {
private route = inject(ActivatedRoute);
@@ -33,16 +32,6 @@ export class SearchComponent {
q = signal<string>('');
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);
providerParam = signal<Provider | null>(null);
themeParam = signal<string | null>(null);
@@ -51,7 +40,7 @@ export class SearchComponent {
sortParam = signal<'relevance' | 'date' | 'views'>('relevance');
// Unified multi-provider response (grouped suggestions)
groups = signal<Record<ProviderId, any[]>>({} as any);
groups = signal<Record<string, any[]>>({});
showUnified = signal<boolean>(false);
error = signal<string | null>(null);
@@ -79,20 +68,52 @@ export class SearchComponent {
return ['yt','dm','tw','pt','od','ru'];
});
// Subtitle display: codes separated by middot
activeProvidersDisplay = computed(() => this.activeProviders().join(' · '));
// Map between Provider and ProviderId
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
providerDisplay(p: ProviderId): string {
try {
const found = (this.instances.providers() || []).find(x => (x.id as any) === p);
return found?.label || p.toUpperCase();
} catch { return p.toUpperCase(); }
// Get active providers with full info
activeProvidersInfo = computed(() => {
const activeIds = this.activeProviders();
return this.instances.providers().filter(p =>
activeIds.some(id => this.providerIdMap[id] === p.id)
);
});
// 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(() => {
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`
selectedProviderForView = computed(() => this.providerParam() || this.instances.selectedProvider());
@@ -111,46 +132,19 @@ export class SearchComponent {
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(() => {
const tag = this.filterTag();
const list = this.allVideos();
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 (tag === 'all') return list;
@@ -162,84 +156,76 @@ export class SearchComponent {
// Duration filters
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;
});
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;
});
if (tag === 'long') return list.filter(v => {
const d = Number(v.duration || 0);
const d = Number(v.durationSec || 0);
return d >= 20 * 60;
});
// Date filters
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;
});
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;
});
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;
});
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 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)
availableTags = computed(() => {
const provider = this.selectedProviderForView();
const list = this.allVideos();
if (list.length === 0) return [];
const tags: { key: string; label: string; show: boolean }[] = [];
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
const oneDay = 24 * 60 * 60 * 1000;
const sevenDays = 7 * oneDay;
@@ -250,8 +236,8 @@ export class SearchComponent {
hasToday = false, hasThisWeek = false, hasThisMonth = false, hasThisYear = false;
for (const v of list) {
const d = Number(v.duration || 0);
const uploadTime = typeof v.uploaded === 'number' ? v.uploaded : 0;
const d = Number(v.durationSec || 0);
const uploadTime = v.publishedAt ? new Date(v.publishedAt).getTime() : 0;
const timeDiff = now - uploadTime;
// Duration filters
@@ -290,21 +276,32 @@ export class SearchComponent {
});
// Subscribe once to unified multi-provider results and reflect to UI
this.unified.request$.pipe(takeUntilDestroyed()).subscribe({
next: (resp: SearchResponse) => {
this.groups.set(resp.groups as any);
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.');
}
});
const subscription: Subscription = this.unified.request$
.pipe(takeUntilDestroyed())
.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.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)
this.route.queryParamMap.pipe(takeUntilDestroyed()).subscribe((pm) => {
const paramSubscription: Subscription = this.route.queryParamMap
.pipe(takeUntilDestroyed())
.subscribe((pm: ParamMap) => {
const q = (pm.get('q') || '').trim();
this.q.set(q);
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.error.set(null);
} else {
this.results.set([]);
this.nextCursor.set(null);
this.loading.set(false);
this.groups.set({} as any);
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 {
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);
}
// Build query params for Watch page (provider + optional odysee slug)
watchQueryParams(v: Video): Record<string, any> | null {
const p = this.providerParam() || this.instances.selectedProvider();
const qp: any = { p };
if (p === 'odysee' && v.url?.startsWith('https://odysee.com/')) {
let slug = v.url.substring('https://odysee.com/'.length);
if (slug.startsWith('/')) slug = slug.slice(1);
qp.slug = slug;
// Méthode utilitaire pour nettoyer les paramètres de l'URL
private cleanUrlParams(params: any): any {
const cleanParams = { ...params };
// Supprimer explicitement le paramètre theme
if ('theme' in cleanParams) {
delete cleanParams.theme;
}
if (p === 'twitch' && v.type === 'channel') {
// Extract channel login from uploaderUrl if available
const url = v.uploaderUrl || v.url || '';
try {
const u = new URL(url);
const parts = u.pathname.split('/').filter(Boolean);
if (parts.length > 0) qp.channel = parts[0];
} catch {}
}
return qp;
// Nettoyer les valeurs null/undefined
Object.keys(cleanParams).forEach(key => {
if (cleanParams[key] === null || cleanParams[key] === undefined) {
delete cleanParams[key];
}
});
return cleanParams;
}
// Submit handler from the embedded SearchBox: keep URL as source of truth
onSearchBarSubmit(evt: { q: string; providers: ProviderId[] | 'all' }) {
const q = (evt?.q || '').trim();
if (!q) return;
const provider = this.providerParam() || this.instances.selectedProvider();
const qp: any = { q, p: provider, provider: null };
if (Array.isArray(evt.providers)) qp.providers = evt.providers.join(',');
else if (evt.providers === 'all') qp.providers = 'yt,dm,tw,pt,od,ru';
qp.page = 1; // reset pagination on new search
this.router.navigate([], { relativeTo: this.route, queryParams: qp, queryParamsHandling: 'merge' });
// Créer un nouvel objet de paramètres de requête
const queryParams: any = {
q,
p: this.providerParam() || this.instances.selectedProvider(),
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
@@ -574,18 +454,47 @@ export class SearchComponent {
// Pagination controls
nextPage() {
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() {
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
onSortChange(event: Event) {
const value = (event.target as HTMLSelectElement)?.value as 'relevance' | 'date' | 'views' | '';
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
@@ -593,6 +502,12 @@ export class SearchComponent {
const q = (this.q() || '').trim();
const qp: any = { provider: p, p, providers: p, page: 1 };
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'
part: 'snippet',
// Optimize for quota usage: reduce initial results
maxResults: '12',
maxResults: '25',
q: String(query),
regionCode: String(region),
// Add safeSearch for better content filtering