feat(preferences): section langues a telecharger (defaut fr+en), transcripts filtres cote serveur et watch
CI / build-and-test (push) Successful in 13m50s
CI / build-and-test (push) Successful in 13m50s
This commit is contained in:
@@ -13,6 +13,7 @@ export class DmAdapter implements ProviderAdapter<VideoItem> {
|
||||
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 providerError = typeof res?.errors?.dm?.message === 'string' ? res.errors.dm.message : undefined;
|
||||
const items: VideoItem[] = (res?.groups?.dm || []).map((it: any) => ({
|
||||
id: it.id,
|
||||
provider: 'dailymotion',
|
||||
@@ -25,6 +26,6 @@ export class DmAdapter implements ProviderAdapter<VideoItem> {
|
||||
channelExternalId: it.channelId || undefined,
|
||||
channelAvatarUrl: it.uploaderAvatar || undefined,
|
||||
}));
|
||||
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, providerError };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ export class OdAdapter implements ProviderAdapter<VideoItem> {
|
||||
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 providerError = typeof res?.errors?.od?.message === 'string' ? res.errors.od.message : undefined;
|
||||
const items: VideoItem[] = (res?.groups?.od || []).map((it: any) => {
|
||||
let slug: string | undefined;
|
||||
try {
|
||||
@@ -35,6 +36,6 @@ export class OdAdapter implements ProviderAdapter<VideoItem> {
|
||||
channelExternalId: it.uploaderName || undefined,
|
||||
} 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, providerError };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ export class PtAdapter implements ProviderAdapter<VideoItem> {
|
||||
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 providerError = typeof res?.errors?.pt?.message === 'string' ? res.errors.pt.message : undefined;
|
||||
const items: VideoItem[] = (res?.groups?.pt || []).map((it: any) => {
|
||||
// PeerTube identifie une chaîne par `instance|channel` : l'instance est
|
||||
// dérivée de l'URL vidéo, le nom de chaîne de `channelId`.
|
||||
@@ -32,6 +33,6 @@ export class PtAdapter implements ProviderAdapter<VideoItem> {
|
||||
channelExternalId,
|
||||
};
|
||||
});
|
||||
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, providerError };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ export class RuAdapter implements ProviderAdapter<VideoItem> {
|
||||
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 providerError = typeof res?.errors?.ru?.message === 'string' ? res.errors.ru.message : undefined;
|
||||
const items: VideoItem[] = (res?.groups?.ru || []).map((it: any) => ({
|
||||
id: it.id,
|
||||
provider: 'rumble',
|
||||
@@ -22,6 +23,6 @@ export class RuAdapter implements ProviderAdapter<VideoItem> {
|
||||
viewCount: typeof it.views === 'number' ? it.views : undefined,
|
||||
publishedAt: it.publishedAt,
|
||||
}));
|
||||
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, providerError };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,29 +12,38 @@ export class TwAdapter implements ProviderAdapter<VideoItem> {
|
||||
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 providerError = typeof res?.errors?.tw?.message === 'string' ? res.errors.tw.message : undefined;
|
||||
const items: VideoItem[] = (res?.groups?.tw || []).map((it: any) => {
|
||||
// /api/search (tw) ne retourne que des chaînes : le player exige le login
|
||||
// (?channel=<login>), jamais l'id numérique. Sans `channel`, /watch tente
|
||||
// ?video=<id> et Twitch répond par une erreur.
|
||||
// /api/search (tw) renvoie un mixte façon directory : lives, VODs, clips, chaînes.
|
||||
// Seuls les items 'live' / 'channel' utilisent l'embed ?channel=<login> ;
|
||||
// VODs (id numérique) et clips (slug) ont leur propre lecture.
|
||||
const login: string | undefined =
|
||||
it.channelExternalId || it.channelHandle || undefined;
|
||||
const kind = String(it?.type || '').toLowerCase();
|
||||
const isChannel = kind === 'channel' || kind === 'live' || Boolean(login);
|
||||
const clipKind = String(it?.kind || '').toLowerCase();
|
||||
const isClip = clipKind === 'clip';
|
||||
const isChannelLike = kind === 'channel' || kind === 'live';
|
||||
return {
|
||||
id: it.id,
|
||||
provider: 'twitch',
|
||||
title: it.title,
|
||||
channelName: it.uploaderName,
|
||||
durationSec: it.duration,
|
||||
durationSec: typeof it.duration === 'number' ? it.duration : undefined,
|
||||
thumbnailUrl: it.thumbnail,
|
||||
viewCount: typeof it.views === 'number' ? it.views : undefined,
|
||||
publishedAt: it.publishedAt,
|
||||
channelExternalId: login,
|
||||
channelAvatarUrl: it.uploaderAvatar || undefined,
|
||||
// Login pour l'embed ?channel=... (video-card -> watchQueryParams).
|
||||
channel: isChannel ? login : undefined,
|
||||
// Login pour l'embed ?channel=... (video-card -> watchQueryParams),
|
||||
// uniquement pour les chaînes / lives — jamais pour VODs ni clips.
|
||||
channel: isChannelLike && !isClip ? login : undefined,
|
||||
// Statut conservé pour le filtre "Type" (live / vidéo / clip / chaîne).
|
||||
type: typeof it.type === 'string' ? it.type : undefined,
|
||||
isLive: typeof it.isLive === 'boolean' ? it.isLive : undefined,
|
||||
kind: typeof it.kind === 'string' ? it.kind : undefined,
|
||||
game: typeof it.game === 'string' ? it.game : undefined,
|
||||
} as VideoItem;
|
||||
});
|
||||
return { items, total: Array.isArray(res?.groups?.tw) ? res.groups.tw.length : 0 };
|
||||
}).filter((v: VideoItem) => v.id != null && String(v.id).length > 0 && (v.thumbnailUrl || v.channelAvatarUrl));
|
||||
return { items, total: items.length, providerError };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ export class YtAdapter implements ProviderAdapter<VideoItem> {
|
||||
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 providerError = typeof res?.errors?.yt?.message === 'string' ? res.errors.yt.message : undefined;
|
||||
const items: VideoItem[] = (res?.groups?.yt || []).map((it: any) => ({
|
||||
id: it.id,
|
||||
provider: 'youtube',
|
||||
@@ -25,6 +26,6 @@ export class YtAdapter implements ProviderAdapter<VideoItem> {
|
||||
// (l'avatar est résolu via /api/channels, l'API search n'en fournit pas).
|
||||
channelExternalId: it.channelExternalId || it.channelId || undefined,
|
||||
}));
|
||||
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, providerError };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,4 +18,6 @@ export interface SearchResponseV1 {
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
sort?: 'relevance' | 'date' | 'views';
|
||||
/** Per-provider backend failures, keyed by provider id. */
|
||||
errors?: Partial<Record<ProviderId, string>>;
|
||||
}
|
||||
|
||||
@@ -18,6 +18,8 @@ export interface SearchResult<T = SearchItem> {
|
||||
items: T[];
|
||||
total?: number;
|
||||
nextPageToken?: string; // or page: string|number
|
||||
/** Backend per-provider failure (e.g. quota), surfaced for UI notice. */
|
||||
providerError?: string;
|
||||
}
|
||||
|
||||
export interface ProviderSearchParams {
|
||||
@@ -26,7 +28,7 @@ export interface ProviderSearchParams {
|
||||
sort?: 'relevance'|'date'|'views'|'duration';
|
||||
time?: 'today'|'7d'|'30d'|'all';
|
||||
length?: 'short'|'medium'|'long'|'all';
|
||||
type?: 'video'|'live'|'shorts'|'all';
|
||||
type?: 'video'|'live'|'shorts'|'short'|'recorded'|'channel'|'all';
|
||||
}
|
||||
|
||||
export interface ProviderAdapter<T = SearchItem> {
|
||||
|
||||
@@ -64,9 +64,17 @@ export class SearchService {
|
||||
acc[pid as ProviderId] = result.items;
|
||||
return acc;
|
||||
}, {} as Record<ProviderId, VideoItem[]>);
|
||||
// Surface per-provider backend failures (e.g. YouTube quota) for UI notice.
|
||||
const errors: Partial<Record<ProviderId, string>> = {};
|
||||
for (const [pid, result] of Object.entries(byProvider)) {
|
||||
if (result?.providerError) errors[pid as ProviderId] = result.providerError;
|
||||
}
|
||||
|
||||
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,
|
||||
...(Object.keys(errors).length > 0 ? { errors } : {}),
|
||||
};
|
||||
return resp;
|
||||
}),
|
||||
shareReplay(1)
|
||||
|
||||
@@ -1,14 +1,17 @@
|
||||
<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>
|
||||
<!-- Videos already loaded: keep them mounted during continuation loads (no scroll jump). -->
|
||||
<div *ngIf="videos.length > 0" class="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4">
|
||||
<app-video-card *ngFor="let video of videos; trackBy: trackById" [video]="video"></app-video-card>
|
||||
<ng-container *ngIf="loadingMore">
|
||||
<app-video-card-skeleton *ngFor="let _ of moreSkeletonItems"></app-video-card-skeleton>
|
||||
</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">
|
||||
<!-- Initial load only: skeleton grid while the first page arrives. -->
|
||||
<div *ngIf="videos.length === 0 && loading" class="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4">
|
||||
<app-video-card-skeleton *ngFor="let _ of skeletonItems"></app-video-card-skeleton>
|
||||
</div>
|
||||
|
||||
<div *ngIf="!loading && !loadingMore && 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>
|
||||
|
||||
@@ -13,10 +13,21 @@ import { VideoCardSkeletonComponent } from '../video-card-skeleton/video-card-sk
|
||||
})
|
||||
export class SearchResultGridComponent {
|
||||
@Input() videos: VideoItem[] = [];
|
||||
// Initial load (no videos yet) -> full skeleton grid.
|
||||
@Input() loading = false;
|
||||
// Continuation load (videos already shown) -> keep grid, append trailing skeletons.
|
||||
@Input() loadingMore = false;
|
||||
@Input() skeletons = 12;
|
||||
|
||||
get skeletonItems() {
|
||||
return new Array(this.skeletons);
|
||||
}
|
||||
|
||||
get moreSkeletonItems() {
|
||||
return new Array(4);
|
||||
}
|
||||
|
||||
trackById(_index: number, v: VideoItem): string {
|
||||
return String((v && (v.id || (v as any).videoId || (v as any).url)) ?? _index);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,21 +1,31 @@
|
||||
<div class="flex flex-col gap-2">
|
||||
<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" />
|
||||
<img [src]="video.thumbnailUrl" [alt]="video.title" loading="lazy" decoding="async" class="h-full w-full object-cover transition-transform group-hover:scale-105" />
|
||||
<div class="absolute top-1 right-1">
|
||||
<app-provider-badge [provider]="video.provider"></app-provider-badge>
|
||||
</div>
|
||||
<span *ngIf="video.durationSec !== undefined" class="absolute bottom-1 right-1 rounded bg-black/80 px-1.5 py-0.5 text-xs text-white">
|
||||
<div class="absolute top-1 left-1 flex gap-1">
|
||||
<span *ngIf="isTwitchLive" class="rounded bg-purple-600 px-1.5 py-0.5 text-[11px] font-bold text-white">LIVE</span>
|
||||
<span *ngIf="isTwitchClip" class="rounded bg-red-600 px-1.5 py-0.5 text-[11px] font-bold text-white">CLIP</span>
|
||||
<span *ngIf="isTwitchChannel" class="rounded bg-slate-700/90 px-1.5 py-0.5 text-[11px] font-bold text-slate-200 ring-1 ring-slate-500/50">CHAÎNE</span>
|
||||
</div>
|
||||
<span *ngIf="showDuration" class="absolute bottom-1 right-1 rounded bg-black/80 px-1.5 py-0.5 text-xs text-white">
|
||||
{{ video.durationSec | duration }}
|
||||
</span>
|
||||
<span *ngIf="isTwitchLive && video.viewCount" class="absolute bottom-1 left-1 rounded bg-black/80 px-1.5 py-0.5 text-xs text-white">
|
||||
{{ video.viewCount | number }} spectateurs
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-1">
|
||||
<h3 class="text-base font-semibold text-zinc-100 line-clamp-2" [title]="video.title">
|
||||
{{ video.title }}
|
||||
</h3>
|
||||
<div class="text-xs text-zinc-400 flex gap-2">
|
||||
<span *ngIf="video.viewCount">{{ video.viewCount | number }} vues</span>
|
||||
<div class="text-xs text-zinc-400 flex flex-wrap gap-x-2 gap-y-0.5">
|
||||
<span *ngIf="video.viewCount && !isTwitchLive">{{ video.viewCount | number }} vues</span>
|
||||
<span *ngIf="isTwitchChannel">Chaîne Twitch</span>
|
||||
<span *ngIf="$any(video).game">{{ $any(video).game }}</span>
|
||||
<span *ngIf="video.publishedAt">{{ video.publishedAt | date:'shortDate' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -21,10 +21,40 @@ export class VideoCardComponent {
|
||||
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;
|
||||
// Twitch : chaînes/lives via ?channel=<login>, clips via ?clip=<slug>.
|
||||
if (v.provider === 'twitch') {
|
||||
const kind = String((v as any)?.type || '').toLowerCase();
|
||||
const clipKind = String((v as any)?.kind || '').toLowerCase();
|
||||
if (clipKind === 'clip') qp.clip = v.id;
|
||||
else if ((kind === 'live' || kind === 'channel') && v.channel) qp.channel = v.channel;
|
||||
}
|
||||
return qp;
|
||||
}
|
||||
|
||||
get isTwitchLive(): boolean {
|
||||
const v: any = this.video;
|
||||
return v?.provider === 'twitch' && (v?.isLive === true || String(v?.type || '').toLowerCase() === 'live');
|
||||
}
|
||||
|
||||
get isTwitchChannel(): boolean {
|
||||
const v: any = this.video;
|
||||
return v?.provider === 'twitch' && String(v?.type || '').toLowerCase() === 'channel' && v?.isLive !== true;
|
||||
}
|
||||
|
||||
get isTwitchClip(): boolean {
|
||||
const v: any = this.video;
|
||||
return v?.provider === 'twitch' && String(v?.kind || '').toLowerCase() === 'clip';
|
||||
}
|
||||
|
||||
get showDuration(): boolean {
|
||||
const d = Number((this.video as any)?.durationSec ?? NaN);
|
||||
return Number.isFinite(d) && d > 0;
|
||||
}
|
||||
|
||||
get providerId(): string {
|
||||
return String((this.video as any)?.provider || '');
|
||||
}
|
||||
|
||||
get channelProviderId(): ProviderId | null {
|
||||
const map: Record<VideoItem['provider'], ProviderId> = {
|
||||
youtube: 'yt',
|
||||
|
||||
@@ -15,4 +15,9 @@ export interface VideoItem {
|
||||
// Provider-specific routing hints
|
||||
slug?: string; // Odysee slug when available
|
||||
channel?: string; // Twitch channel when item represents a channel/live
|
||||
// Live status (only providers exposing it, e.g. Twitch, set these)
|
||||
type?: string; // e.g. 'live' | 'channel' | 'video'
|
||||
isLive?: boolean; // true when actually broadcasting now
|
||||
kind?: string; // e.g. Twitch 'vod' | 'clip'
|
||||
game?: string; // e.g. Twitch game/category name
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user