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:
@@ -24,8 +24,8 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Filtre rapide Vidéos / Shorts -->
|
||||
<div class="flex items-center gap-2 mb-4" role="tablist" aria-label="Filtrer vidéos ou shorts">
|
||||
<!-- Filtre rapide Vidéos / Shorts (masqué sur Twitch : onglets à sections ci-dessous) -->
|
||||
<div *ngIf="provider() !== 'twitch'" class="flex items-center gap-2 mb-4" role="tablist" aria-label="Filtrer vidéos ou shorts">
|
||||
<button (click)="setContentFilter('all')" [attr.aria-pressed]="contentFilter() === 'all'"
|
||||
class="px-3 py-1.5 rounded-full text-sm border transition-colors"
|
||||
[class]="contentFilter() === 'all' ? 'bg-red-600 border-red-500 text-white' : 'bg-slate-800 border-slate-700 text-slate-300 hover:bg-slate-700'">
|
||||
@@ -43,6 +43,43 @@
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Onglets Twitch façon directory : Directs / Vidéos / Clips / Chaînes -->
|
||||
<div *ngIf="isTwitchSections()" class="mb-4">
|
||||
<div *ngIf="twitchGame()" class="mb-3 flex items-center gap-3 rounded-xl border border-purple-500/30 bg-purple-950/30 px-4 py-2.5">
|
||||
<span class="text-2xl">🎮</span>
|
||||
<div>
|
||||
<div class="text-sm text-purple-300">Catégorie Twitch</div>
|
||||
<div class="font-semibold text-slate-100">{{ twitchGame()?.name }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-2 overflow-x-auto pb-1" role="tablist" aria-label="Sections Twitch">
|
||||
<button (click)="setTwitchTab('all')" [attr.aria-pressed]="twitchTab() === 'all'"
|
||||
class="px-3 py-1.5 rounded-full text-sm border transition-colors whitespace-nowrap"
|
||||
[class]="twitchTab() === 'all' ? 'bg-purple-600 border-purple-500 text-white' : 'bg-slate-800 border-slate-700 text-slate-300 hover:bg-slate-700'">
|
||||
Tout
|
||||
</button>
|
||||
<button (click)="setTwitchTab('live')" [attr.aria-pressed]="twitchTab() === 'live'"
|
||||
class="px-3 py-1.5 rounded-full text-sm border transition-colors whitespace-nowrap"
|
||||
[class]="twitchTab() === 'live' ? 'bg-purple-600 border-purple-500 text-white' : 'bg-slate-800 border-slate-700 text-slate-300 hover:bg-slate-700'">
|
||||
En direct<span *ngIf="twitchLives().length"> ({{ twitchLives().length }})</span>
|
||||
</button>
|
||||
<button (click)="setTwitchTab('video')" [attr.aria-pressed]="twitchTab() === 'video'"
|
||||
class="px-3 py-1.5 rounded-full text-sm border transition-colors whitespace-nowrap"
|
||||
[class]="twitchTab() === 'video' ? 'bg-purple-600 border-purple-500 text-white' : 'bg-slate-800 border-slate-700 text-slate-300 hover:bg-slate-700'">
|
||||
Vidéos<span *ngIf="twitchVods().length"> ({{ twitchVods().length }})</span>
|
||||
</button>
|
||||
<button (click)="setTwitchTab('clip')" [attr.aria-pressed]="twitchTab() === 'clip'"
|
||||
class="px-3 py-1.5 rounded-full text-sm border transition-colors whitespace-nowrap"
|
||||
[class]="twitchTab() === 'clip' ? 'bg-purple-600 border-purple-500 text-white' : 'bg-slate-800 border-slate-700 text-slate-300 hover:bg-slate-700'">
|
||||
Clips<span *ngIf="twitchClips().length"> ({{ twitchClips().length }})</span>
|
||||
</button>
|
||||
<button (click)="setTwitchTab('channel')" [attr.aria-pressed]="twitchTab() === 'channel'"
|
||||
class="px-3 py-1.5 rounded-full text-sm border transition-colors whitespace-nowrap"
|
||||
[class]="twitchTab() === 'channel' ? 'bg-purple-600 border-purple-500 text-white' : 'bg-slate-800 border-slate-700 text-slate-300 hover:bg-slate-700'">
|
||||
Chaînes<span *ngIf="twitchChannels().length"> ({{ twitchChannels().length }})</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Filters -->
|
||||
<div class="bg-slate-800/50 p-4 rounded-xl border border-slate-700 mb-8">
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-5 gap-4">
|
||||
@@ -66,29 +103,30 @@
|
||||
<div>
|
||||
<label for="type-filter" class="block text-sm font-medium text-slate-300 mb-1">{{ 'filter.type' | t }}</label>
|
||||
<select id="type-filter" [ngModel]="type()" (ngModelChange)="onTypeChange($event)" class="w-full bg-slate-700 text-white rounded-md px-3 py-2 border border-slate-600 focus:outline-none focus:ring-2 focus:ring-blue-500">
|
||||
<option value="any">Any</option>
|
||||
<option value="video">Video</option>
|
||||
<option value="live">Live</option>
|
||||
<option value="recorded">Recorded</option>
|
||||
<option value="short">Shorts</option>
|
||||
<option value="any">{{ 'filter.type.any' | t }}</option>
|
||||
<option value="video">{{ 'filter.type.video' | t }}</option>
|
||||
<option value="live">{{ 'filter.type.live' | t }}</option>
|
||||
<option value="recorded">{{ 'filter.type.recorded' | t }}</option>
|
||||
<option value="short">{{ 'filter.type.short' | t }}</option>
|
||||
<option value="channel">{{ 'filter.type.channel' | t }}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label for="language-filter" class="block text-sm font-medium text-slate-300 mb-1">{{ 'filter.language' | t }}</label>
|
||||
<select id="language-filter" [ngModel]="language()" (ngModelChange)="onLanguageChange($event)" class="w-full bg-slate-700 text-white rounded-md px-3 py-2 border border-slate-600 focus:outline-none focus:ring-2 focus:ring-blue-500">
|
||||
<option value="any">Any</option>
|
||||
<option value="en">English</option>
|
||||
<option value="fr">Français</option>
|
||||
<option value="any">{{ 'filter.language.any' | t }}</option>
|
||||
<option value="en">{{ 'filter.language.en' | t }}</option>
|
||||
<option value="fr">{{ 'filter.language.fr' | t }}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label for="date-filter" class="block text-sm font-medium text-slate-300 mb-1">{{ 'filter.date' | t }}</label>
|
||||
<select id="date-filter" [ngModel]="date()" (ngModelChange)="onDateChange($event)" class="w-full bg-slate-700 text-white rounded-md px-3 py-2 border border-slate-600 focus:outline-none focus:ring-2 focus:ring-blue-500">
|
||||
<option value="any">Any</option>
|
||||
<option value="day">Last 24h</option>
|
||||
<option value="week">Last week</option>
|
||||
<option value="month">Last month</option>
|
||||
<option value="year">Last year</option>
|
||||
<option value="any">{{ 'filter.date.any' | t }}</option>
|
||||
<option value="day">{{ 'filter.date.day' | t }}</option>
|
||||
<option value="week">{{ 'filter.date.week' | t }}</option>
|
||||
<option value="month">{{ 'filter.date.month' | t }}</option>
|
||||
<option value="year">{{ 'filter.date.year' | t }}</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
@@ -105,20 +143,22 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div *ngIf="!loading() && videos().length === 0" class="text-center py-16">
|
||||
<div *ngIf="!loading() && !error() && videos().length === 0" class="text-center py-16">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="mx-auto h-12 w-12 text-slate-500" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9.172 16.172a4 4 0 015.656 0M9 10h.01M15 10h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
<h3 class="mt-2 text-lg font-medium text-slate-300">{{ 'empty.noItems' | t }}</h3>
|
||||
<p class="mt-1 text-sm text-slate-400">Try adjusting your filters or changing the provider.</p>
|
||||
<p *ngIf="theme() === 'live'" class="mt-1 text-sm text-slate-400">Aucun direct pour le moment — réessayez plus tard.</p>
|
||||
<p *ngIf="theme() !== 'live'" class="mt-1 text-sm text-slate-400">Try adjusting your filters or changing the provider.</p>
|
||||
</div>
|
||||
|
||||
<div *ngIf="error()" class="mt-4 p-4 bg-red-900/30 border border-red-700 text-red-200 rounded-lg text-center">
|
||||
<p>{{ error() }}</p>
|
||||
<button class="mt-2 px-4 py-2 bg-red-600 hover:bg-red-700 text-white rounded-md transition-colors" (click)="retry()">{{ 'action.retry' | t }}</button>
|
||||
<button *ngIf="error() !== 'Ce fournisseur ne propose pas de directs.'" class="mt-2 px-4 py-2 bg-red-600 hover:bg-red-700 text-white rounded-md transition-colors" (click)="retry()">{{ 'action.retry' | t }}</button>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 gap-x-6 gap-y-8">
|
||||
<!-- Grille unique (tous providers sauf vue "Tout" Twitch) -->
|
||||
<div *ngIf="!showTwitchSectionsAll()" class="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 gap-x-6 gap-y-8">
|
||||
<div *ngFor="let v of videos(); trackBy: trackByVideo" class="group bg-slate-800/50 rounded-lg overflow-hidden hover:bg-slate-700/50 transition-all duration-300 transform hover:-translate-y-1 shadow-lg hover:shadow-xl">
|
||||
<a [routerLink]="['/watch', v.videoId]" [queryParams]="watchQueryParams(v)" [state]="{ video: v }" class="block">
|
||||
<div class="relative">
|
||||
@@ -127,13 +167,18 @@
|
||||
<app-provider-badge [provider]="provider()"></app-provider-badge>
|
||||
</div>
|
||||
<div class="absolute top-2 left-2 flex gap-1">
|
||||
<span *ngIf="kindOf(v) === 'short'" class="bg-red-600 text-white text-[11px] font-bold px-1.5 py-0.5 rounded">SHORT</span>
|
||||
<span *ngIf="kindOf(v) === 'live'" class="bg-purple-600 text-white text-[11px] font-bold px-1.5 py-0.5 rounded">LIVE</span>
|
||||
<span *ngIf="isLiveCard(v)" class="bg-purple-600 text-white text-[11px] font-bold px-1.5 py-0.5 rounded">LIVE</span>
|
||||
<span *ngIf="!isLiveCard(v) && isClipCard(v)" class="bg-red-600 text-white text-[11px] font-bold px-1.5 py-0.5 rounded">CLIP</span>
|
||||
<span *ngIf="!isLiveCard(v) && kindOf(v) === 'short' && !isClipCard(v)" class="bg-red-600 text-white text-[11px] font-bold px-1.5 py-0.5 rounded">SHORT</span>
|
||||
<span *ngIf="isChannelCard(v)" class="bg-slate-700/90 text-slate-200 text-[11px] font-bold px-1.5 py-0.5 rounded ring-1 ring-slate-500/50">CHAÎNE</span>
|
||||
</div>
|
||||
<div *ngIf="v.duration && v.duration > 0" class="absolute bottom-2 right-2 bg-black/75 text-white text-xs px-2 py-1 rounded-md font-mono">
|
||||
{{ v.duration | duration }}
|
||||
</div>
|
||||
<div class="absolute top-2 left-2">
|
||||
<div *ngIf="isLiveCard(v) && v.views > 0" class="absolute bottom-2 left-2 bg-black/75 text-white text-xs px-2 py-1 rounded-md">
|
||||
{{ v.views | number }} spectateurs
|
||||
</div>
|
||||
<div *ngIf="!isLiveCard(v)" class="absolute bottom-2 left-2">
|
||||
<app-like-button [videoId]="v.videoId" [provider]="provider()" [title]="v.title" [thumbnail]="v.thumbnail"></app-like-button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -150,22 +195,120 @@
|
||||
[name]="v.uploaderName || null"
|
||||
></app-channel-identity>
|
||||
<div class="mt-1.5 text-xs text-slate-400">
|
||||
<span>{{ v.views | number }} visionnements</span> • <span>{{ v.uploadedDate | date:'shortDate' }}</span>
|
||||
<span *ngIf="isChannelCard(v)">Chaîne Twitch</span>
|
||||
<span *ngIf="!isChannelCard(v)">{{ v.views | number }} visionnements</span>
|
||||
<span *ngIf="!isChannelCard(v) && v.uploadedDate"> • {{ v.uploadedDate | date:'shortDate' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div *ngIf="loading() && videos().length > 0" class="text-center py-8">
|
||||
<svg class="animate-spin h-8 w-8 text-slate-400 mx-auto" 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>
|
||||
<!-- Vue "Tout" Twitch : sections empilées façon twitch.tv/directory -->
|
||||
<div *ngIf="showTwitchSectionsAll()" class="space-y-10">
|
||||
<section *ngIf="twitchLives().length > 0">
|
||||
<div class="flex items-center gap-2 mb-3">
|
||||
<span class="bg-purple-600 text-white text-[11px] font-bold px-1.5 py-0.5 rounded">LIVE</span>
|
||||
<h3 class="text-xl font-semibold text-slate-100">En direct</h3>
|
||||
<span class="text-sm text-slate-400">({{ twitchLives().length }})</span>
|
||||
</div>
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 gap-x-6 gap-y-8">
|
||||
<div *ngFor="let v of twitchLives(); trackBy: trackByVideo" class="group bg-slate-800/50 rounded-lg overflow-hidden hover:bg-slate-700/50 transition-all duration-300 transform hover:-translate-y-1 shadow-lg hover:shadow-xl">
|
||||
<a [routerLink]="['/watch', v.videoId]" [queryParams]="watchQueryParams(v)" [state]="{ video: v }" class="block">
|
||||
<div class="relative">
|
||||
<img [src]="v.thumbnail" [alt]="v.title" loading="lazy" decoding="async" class="w-full aspect-video object-cover transition-transform duration-300 group-hover:scale-105">
|
||||
<div class="absolute top-2 right-2"><app-provider-badge [provider]="provider()"></app-provider-badge></div>
|
||||
<div class="absolute top-2 left-2"><span class="bg-purple-600 text-white text-[11px] font-bold px-1.5 py-0.5 rounded">LIVE</span></div>
|
||||
<div *ngIf="v.views > 0" class="absolute bottom-2 left-2 bg-black/75 text-white text-xs px-2 py-1 rounded-md">{{ v.views | number }} spectateurs</div>
|
||||
</div>
|
||||
<div class="p-3 pb-1"><h3 class="font-semibold text-slate-100 group-hover:text-purple-300 transition-colors duration-200 text-sm line-clamp-2 pr-2">{{ v.title }}</h3></div>
|
||||
</a>
|
||||
<div class="px-3 pb-3">
|
||||
<app-channel-identity size="sm" [provider]="v.provider || provider()" [externalId]="v.channelExternalId ?? null" [avatarUrl]="v.channelAvatarUrl || v.uploaderAvatar || null" [name]="v.uploaderName || null"></app-channel-identity>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<section *ngIf="twitchVods().length > 0">
|
||||
<div class="flex items-center gap-2 mb-3">
|
||||
<h3 class="text-xl font-semibold text-slate-100">Vidéos</h3>
|
||||
<span class="text-sm text-slate-400">({{ twitchVods().length }})</span>
|
||||
</div>
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 gap-x-6 gap-y-8">
|
||||
<div *ngFor="let v of twitchVods(); trackBy: trackByVideo" class="group bg-slate-800/50 rounded-lg overflow-hidden hover:bg-slate-700/50 transition-all duration-300 transform hover:-translate-y-1 shadow-lg hover:shadow-xl">
|
||||
<a [routerLink]="['/watch', v.videoId]" [queryParams]="watchQueryParams(v)" [state]="{ video: v }" class="block">
|
||||
<div class="relative">
|
||||
<img [src]="v.thumbnail" [alt]="v.title" loading="lazy" decoding="async" class="w-full aspect-video object-cover transition-transform duration-300 group-hover:scale-105">
|
||||
<div class="absolute top-2 right-2"><app-provider-badge [provider]="provider()"></app-provider-badge></div>
|
||||
<div *ngIf="v.duration && v.duration > 0" class="absolute bottom-2 right-2 bg-black/75 text-white text-xs px-2 py-1 rounded-md font-mono">{{ v.duration | duration }}</div>
|
||||
<div class="absolute bottom-2 left-2"><app-like-button [videoId]="v.videoId" [provider]="provider()" [title]="v.title" [thumbnail]="v.thumbnail"></app-like-button></div>
|
||||
</div>
|
||||
<div class="p-3 pb-1"><h3 class="font-semibold text-slate-100 group-hover:text-red-400 transition-colors duration-200 text-sm line-clamp-2 pr-2">{{ v.title }}</h3></div>
|
||||
</a>
|
||||
<div class="px-3 pb-3">
|
||||
<app-channel-identity size="sm" [provider]="v.provider || provider()" [externalId]="v.channelExternalId ?? null" [avatarUrl]="v.channelAvatarUrl || v.uploaderAvatar || null" [name]="v.uploaderName || null"></app-channel-identity>
|
||||
<div class="mt-1.5 text-xs text-slate-400"><span>{{ v.views | number }} visionnements</span><span *ngIf="v.uploadedDate"> • {{ v.uploadedDate | date:'shortDate' }}</span></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<section *ngIf="twitchClips().length > 0">
|
||||
<div class="flex items-center gap-2 mb-3">
|
||||
<span class="bg-red-600 text-white text-[11px] font-bold px-1.5 py-0.5 rounded">CLIP</span>
|
||||
<h3 class="text-xl font-semibold text-slate-100">Clips</h3>
|
||||
<span class="text-sm text-slate-400">({{ twitchClips().length }})</span>
|
||||
</div>
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 gap-x-6 gap-y-8">
|
||||
<div *ngFor="let v of twitchClips(); trackBy: trackByVideo" class="group bg-slate-800/50 rounded-lg overflow-hidden hover:bg-slate-700/50 transition-all duration-300 transform hover:-translate-y-1 shadow-lg hover:shadow-xl">
|
||||
<a [routerLink]="['/watch', v.videoId]" [queryParams]="watchQueryParams(v)" [state]="{ video: v }" class="block">
|
||||
<div class="relative">
|
||||
<img [src]="v.thumbnail" [alt]="v.title" loading="lazy" decoding="async" class="w-full aspect-video object-cover transition-transform duration-300 group-hover:scale-105">
|
||||
<div class="absolute top-2 right-2"><app-provider-badge [provider]="provider()"></app-provider-badge></div>
|
||||
<div class="absolute top-2 left-2"><span class="bg-red-600 text-white text-[11px] font-bold px-1.5 py-0.5 rounded">CLIP</span></div>
|
||||
<div *ngIf="v.duration && v.duration > 0" class="absolute bottom-2 right-2 bg-black/75 text-white text-xs px-2 py-1 rounded-md font-mono">{{ v.duration | duration }}</div>
|
||||
<div class="absolute bottom-2 left-2"><app-like-button [videoId]="v.videoId" [provider]="provider()" [title]="v.title" [thumbnail]="v.thumbnail"></app-like-button></div>
|
||||
</div>
|
||||
<div class="p-3 pb-1"><h3 class="font-semibold text-slate-100 group-hover:text-red-400 transition-colors duration-200 text-sm line-clamp-2 pr-2">{{ v.title }}</h3></div>
|
||||
</a>
|
||||
<div class="px-3 pb-3">
|
||||
<app-channel-identity size="sm" [provider]="v.provider || provider()" [externalId]="v.channelExternalId ?? null" [avatarUrl]="v.channelAvatarUrl || v.uploaderAvatar || null" [name]="v.uploaderName || null"></app-channel-identity>
|
||||
<div class="mt-1.5 text-xs text-slate-400"><span>{{ v.views | number }} visionnements</span><span *ngIf="v.uploadedDate"> • {{ v.uploadedDate | date:'shortDate' }}</span></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<section *ngIf="twitchChannels().length > 0">
|
||||
<div class="flex items-center gap-2 mb-3">
|
||||
<h3 class="text-xl font-semibold text-slate-100">Chaînes</h3>
|
||||
<span class="text-sm text-slate-400">({{ twitchChannels().length }})</span>
|
||||
</div>
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 gap-x-6 gap-y-8">
|
||||
<div *ngFor="let v of twitchChannels(); trackBy: trackByVideo" class="group bg-slate-800/50 rounded-lg overflow-hidden hover:bg-slate-700/50 transition-all duration-300 transform hover:-translate-y-1 shadow-lg hover:shadow-xl">
|
||||
<a [routerLink]="['/watch', v.videoId]" [queryParams]="watchQueryParams(v)" [state]="{ video: v }" class="block">
|
||||
<div class="relative flex items-center justify-center bg-slate-900 py-6">
|
||||
<img [src]="v.thumbnail" [alt]="v.title" loading="lazy" decoding="async" class="h-24 w-24 rounded-full object-cover ring-2 ring-purple-500/50">
|
||||
<div class="absolute top-2 right-2"><app-provider-badge [provider]="provider()"></app-provider-badge></div>
|
||||
<div class="absolute top-2 left-2"><span class="bg-slate-700/90 text-slate-200 text-[11px] font-bold px-1.5 py-0.5 rounded ring-1 ring-slate-500/50">CHAÎNE</span></div>
|
||||
</div>
|
||||
<div class="p-3 pb-1 text-center"><h3 class="font-semibold text-slate-100 group-hover:text-purple-300 transition-colors duration-200 text-sm line-clamp-2">{{ v.title }}</h3></div>
|
||||
</a>
|
||||
<div class="px-3 pb-3">
|
||||
<app-channel-identity size="sm" [provider]="v.provider || provider()" [externalId]="v.channelExternalId ?? null" [avatarUrl]="v.channelAvatarUrl || v.uploaderAvatar || null" [name]="v.uploaderName || null"></app-channel-identity>
|
||||
<div class="mt-1.5 text-xs text-slate-400 text-center">Chaîne Twitch</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div *ngIf="nextCursor()" class="text-center mt-8">
|
||||
<button (click)="loadMore()" [disabled]="loading()" class="px-6 py-3 bg-slate-800 hover:bg-slate-700 rounded-lg border border-slate-700 text-slate-200 font-semibold disabled:opacity-50 disabled:cursor-not-allowed transition-all">
|
||||
{{ 'loading.more' | t }}
|
||||
</button>
|
||||
<!-- Standard infinite scroll: shared sentinel + bottom loader + end state -->
|
||||
<div class="flex flex-col items-center justify-center py-6">
|
||||
<div *ngIf="loadingMore()" class="text-center">
|
||||
<svg class="animate-spin h-8 w-8 text-slate-400 mx-auto" 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>
|
||||
</div>
|
||||
<div *ngIf="!loading() && !loadingMore() && !nextCursor() && !error() && videos().length > 0" class="text-slate-500 text-xs">Fin des résultats</div>
|
||||
</div>
|
||||
<app-infinite-anchor [disabled]="!nextCursor()" [busy]="loading() || loadingMore()" (loadMore)="loadMore()"></app-infinite-anchor>
|
||||
</div>
|
||||
@@ -11,8 +11,9 @@ import { LikeButtonComponent } from '../shared/components/like-button/like-butto
|
||||
import { DurationPipe } from '../../app/shared/pipes/duration.pipe';
|
||||
import { ProviderBadgeComponent } from '../../app/shared/components/provider-badge/provider-badge.component';
|
||||
import { ChannelIdentityComponent } from '../../app/shared/components/channel-identity/channel-identity.component';
|
||||
import { classifyVideo, isShortVideo } from '../../app/shared/utils/video-kind';
|
||||
import { map, of, switchMap, catchError, Observable } from 'rxjs';
|
||||
import { InfiniteAnchorComponent } from '../shared/infinite-anchor/infinite-anchor.component';
|
||||
import { classifyVideo, isLiveVideo, isShortVideo } from '../../app/shared/utils/video-kind';
|
||||
import { map, of, switchMap, catchError, forkJoin, Observable } from 'rxjs';
|
||||
import { PagedResult } from '../../services/youtube-api.service';
|
||||
|
||||
const TWITCH_THEME_KEYWORDS: Record<string, string[]> = {
|
||||
@@ -47,7 +48,7 @@ const TWITCH_THEME_KEYWORDS: Record<string, string[]> = {
|
||||
standalone: true,
|
||||
templateUrl: './provider-theme-page.component.html',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
imports: [CommonModule, FormsModule, RouterLink, TranslatePipe, LikeButtonComponent, DurationPipe, ProviderBadgeComponent, ChannelIdentityComponent]
|
||||
imports: [CommonModule, FormsModule, RouterLink, TranslatePipe, LikeButtonComponent, DurationPipe, ProviderBadgeComponent, ChannelIdentityComponent, InfiniteAnchorComponent]
|
||||
})
|
||||
export class ProviderThemePageComponent implements OnDestroy {
|
||||
private route = inject(ActivatedRoute);
|
||||
@@ -66,20 +67,85 @@ export class ProviderThemePageComponent implements OnDestroy {
|
||||
// Filters state
|
||||
sort = signal<'recent' | 'viewed' | 'longest'>('recent');
|
||||
duration = signal<'any' | 'short' | 'medium' | 'long'>('any');
|
||||
type = signal<'any' | 'video' | 'live' | 'recorded' | 'short'>('any');
|
||||
type = signal<'any' | 'video' | 'live' | 'recorded' | 'short' | 'channel'>('any');
|
||||
language = signal<'any' | 'en' | 'fr'>('any');
|
||||
date = signal<'any' | 'day' | 'week' | 'month' | 'year'>('any');
|
||||
|
||||
// Data state
|
||||
allVideos = signal<Video[]>([]);
|
||||
nextCursor = signal<string | null>(null);
|
||||
// Curseurs par section Twitch (les paginations Helix sont indépendantes).
|
||||
private twitchCursors = { live: null as string | null, vod: null as string | null, clip: null as string | null, channel: null as string | null };
|
||||
/** Catégorie Twitch résolue (directory/category façon twitch.tv). */
|
||||
twitchGame = signal<{ id: string; name: string; boxArtUrl?: string } | null>(null);
|
||||
loading = signal(false);
|
||||
// Continuation (cursor > first page): grid stays mounted, bottom loader only.
|
||||
loadingMore = signal(false);
|
||||
error = signal<string | null>(null);
|
||||
|
||||
// Derived state for the UI
|
||||
providersList = computed(() => this.instances.providers());
|
||||
videos = computed(() => this.applyFilters(this.allVideos()));
|
||||
|
||||
/** La page courante est-elle une page Twitch à sections (hors thème live) ? */
|
||||
isTwitchSections = computed(() => this.provider() === 'twitch' && this.theme() !== 'live');
|
||||
|
||||
/** Listes Twitch par section (filtres hors-type appliqués), façon directory. */
|
||||
private twitchFilteredNoType(): Video[] {
|
||||
return this.applyFiltersWithType(this.allVideos(), 'any');
|
||||
}
|
||||
|
||||
twitchLives = computed(() => {
|
||||
const p = this.provider();
|
||||
return this.twitchFilteredNoType().filter(v =>
|
||||
isLiveVideo({ ...v, provider: (v.provider as string) || p }));
|
||||
});
|
||||
twitchVods = computed(() => {
|
||||
const p = this.provider();
|
||||
return this.twitchFilteredNoType().filter(v => {
|
||||
const w = { ...v, provider: (v.provider as string) || p };
|
||||
if (isLiveVideo(w)) return false;
|
||||
if (isShortVideo(w)) return false;
|
||||
if (String(v.type || '').toLowerCase() === 'channel') return false;
|
||||
return true;
|
||||
});
|
||||
});
|
||||
twitchClips = computed(() => {
|
||||
const p = this.provider();
|
||||
return this.twitchFilteredNoType().filter(v => {
|
||||
const w = { ...v, provider: (v.provider as string) || p };
|
||||
if (isLiveVideo(w)) return false;
|
||||
if (String(v.type || '').toLowerCase() === 'channel') return false;
|
||||
if (String((v as any).kind || '').toLowerCase() === 'clip') return true;
|
||||
return isShortVideo(w);
|
||||
});
|
||||
});
|
||||
twitchChannels = computed(() => {
|
||||
const p = this.provider();
|
||||
return this.twitchFilteredNoType().filter(v => {
|
||||
if ((v as any).isLive === true) return false;
|
||||
if (isLiveVideo({ ...v, provider: (v.provider as string) || p })) return false;
|
||||
return String(v.type || '').toLowerCase() === 'channel';
|
||||
});
|
||||
});
|
||||
|
||||
/** Onglet Twitch synchronisé avec le filtre "type" existant. */
|
||||
twitchTab = computed<'all' | 'live' | 'video' | 'clip' | 'channel'>(() => {
|
||||
const t = this.type();
|
||||
if (t === 'live') return 'live';
|
||||
if (t === 'video' || t === 'recorded') return 'video';
|
||||
if (t === 'short') return 'clip';
|
||||
if (t === 'channel') return 'channel';
|
||||
return 'all';
|
||||
});
|
||||
|
||||
setTwitchTab(v: 'all' | 'live' | 'video' | 'clip' | 'channel'): void {
|
||||
if (v === 'all') this.type.set('any');
|
||||
else if (v === 'video') this.type.set('video');
|
||||
else if (v === 'clip') this.type.set('short');
|
||||
else this.type.set(v);
|
||||
}
|
||||
|
||||
constructor() {
|
||||
this.route.paramMap.subscribe(pm => {
|
||||
const provider = (pm.get('provider') as Provider) || 'youtube';
|
||||
@@ -87,6 +153,16 @@ export class ProviderThemePageComponent implements OnDestroy {
|
||||
this.provider.set(provider);
|
||||
this.theme.set(theme);
|
||||
this.instances.setSelectedProvider(provider);
|
||||
// Thème "En direct" : activer le filtre "Live" par défaut et lever les
|
||||
// filtres qui masqueraient les lives (durée/date = any).
|
||||
if (theme === 'live') {
|
||||
this.type.set('live');
|
||||
this.duration.set('any');
|
||||
this.date.set('any');
|
||||
} else if (this.type() === 'live') {
|
||||
// En quittant le thème live, ne pas rester bloqué sur le filtre Live.
|
||||
this.type.set('any');
|
||||
}
|
||||
this.resetAndLoad();
|
||||
});
|
||||
|
||||
@@ -105,7 +181,10 @@ export class ProviderThemePageComponent implements OnDestroy {
|
||||
this.version++;
|
||||
this.allVideos.set([]);
|
||||
this.nextCursor.set(null);
|
||||
this.twitchCursors = { live: null, vod: null, clip: null, channel: null };
|
||||
this.twitchGame.set(null);
|
||||
this.error.set(null);
|
||||
this.loadingMore.set(false);
|
||||
this.loadMore();
|
||||
}
|
||||
|
||||
@@ -127,7 +206,7 @@ export class ProviderThemePageComponent implements OnDestroy {
|
||||
}
|
||||
|
||||
loadMore() {
|
||||
if (this.loading()) return;
|
||||
if (this.loading() || this.loadingMore()) return;
|
||||
|
||||
const readiness = this.instances.getProviderReadiness(this.provider());
|
||||
if (!readiness.ready) {
|
||||
@@ -135,7 +214,10 @@ export class ProviderThemePageComponent implements OnDestroy {
|
||||
return;
|
||||
}
|
||||
|
||||
this.loading.set(true);
|
||||
// First page (empty list) -> full loader; continuations -> bottom loader,
|
||||
// grid stays mounted so scroll position is preserved.
|
||||
if (this.allVideos().length > 0) this.loadingMore.set(true);
|
||||
else this.loading.set(true);
|
||||
this.error.set(null);
|
||||
const snapshotVersion = this.version;
|
||||
const provider = this.provider();
|
||||
@@ -144,36 +226,28 @@ export class ProviderThemePageComponent implements OnDestroy {
|
||||
const themeSlug = this.theme();
|
||||
const isLiveTheme = themeSlug === 'live';
|
||||
|
||||
// Fournisseurs sans aucun direct : aucune vidéo, message explicite.
|
||||
const LIVE_PROVIDERS: Provider[] = ['youtube', 'twitch'];
|
||||
if (isLiveTheme && !LIVE_PROVIDERS.includes(provider)) {
|
||||
this.error.set('Ce fournisseur ne propose pas de directs.');
|
||||
this.loading.set(false);
|
||||
this.loadingMore.set(false);
|
||||
this.cdr.markForCheck();
|
||||
return;
|
||||
}
|
||||
|
||||
let apiCall: Observable<PagedResult<Video>>;
|
||||
if (provider === 'twitch') {
|
||||
if (isLiveTheme) {
|
||||
// Thème live : top streams (jamais vide quand Twitch est configuré).
|
||||
apiCall = this.api.getTwitchTopStreamsPage(cursor || undefined);
|
||||
} else {
|
||||
// Thème classique : VODs puis clips, puis top streams en filet de sécurité.
|
||||
apiCall = this.api.searchTwitchVodsPage(query, cursor || undefined).pipe(
|
||||
switchMap((res) => {
|
||||
if ((res?.items?.length || 0) >= 8) {
|
||||
return of(res);
|
||||
}
|
||||
return this.api.searchTwitchClipsPage(query, cursor || undefined).pipe(
|
||||
map((clips) => ({ ...clips, items: [...(res?.items || []), ...(clips.items || [])] })),
|
||||
switchMap((merged: any) => {
|
||||
if (((merged?.items?.length) || 0) >= 4) return of(merged);
|
||||
return this.api.getTwitchTopStreamsPage(cursor || undefined).pipe(
|
||||
map((top) => {
|
||||
const seen = new Set((merged?.items || []).map((v: Video) => v.videoId));
|
||||
const extra = (top?.items || []).filter((v: Video) => !seen.has(v.videoId));
|
||||
return { items: [...(merged?.items || []), ...extra], nextCursor: top?.nextCursor || merged?.nextCursor || null };
|
||||
}),
|
||||
// En cas d'échec du fallback, garder au moins VODs+clips.
|
||||
catchError(() => of(merged))
|
||||
);
|
||||
})
|
||||
);
|
||||
})
|
||||
);
|
||||
}
|
||||
if (isLiveTheme && provider === 'youtube') {
|
||||
// Directs YouTube : search.list eventType=live (vrais lives uniquement).
|
||||
apiCall = this.api.searchYouTubeLivePage('', cursor || undefined);
|
||||
} else if (provider === 'twitch' && !isLiveTheme) {
|
||||
// Page Twitch à sections façon directory : lives / vidéos / clips / chaînes
|
||||
// chargés en parallèle avec leurs propres curseurs Helix.
|
||||
this.loadTwitchSections(snapshotVersion, provider, query);
|
||||
return;
|
||||
} else if (provider === 'twitch') {
|
||||
// Thème live : top streams (jamais vide quand Twitch est configuré).
|
||||
apiCall = this.api.getTwitchTopStreamsPage(cursor || undefined);
|
||||
} else {
|
||||
apiCall = query
|
||||
? this.api.searchVideosPage(query, cursor, provider)
|
||||
@@ -184,27 +258,127 @@ export class ProviderThemePageComponent implements OnDestroy {
|
||||
next: (res: PagedResult<Video>) => {
|
||||
if (snapshotVersion !== this.version) return;
|
||||
|
||||
// Thème live : garde-fou, ne jamais mélanger des VODs.
|
||||
let items = res.items || [];
|
||||
if (isLiveTheme) {
|
||||
items = items.filter((v: Video) =>
|
||||
isLiveVideo({ ...v, provider: (v.provider as string) || provider })
|
||||
);
|
||||
}
|
||||
const existingIds = new Set(this.allVideos().map((v: Video) => v.videoId));
|
||||
const newVideos = (res.items || []).filter((v: Video) => !existingIds.has(v.videoId));
|
||||
const newVideos = items.filter((v: Video) => !existingIds.has(v.videoId));
|
||||
|
||||
this.allVideos.update(current => [...current, ...newVideos]);
|
||||
this.nextCursor.set(res.nextCursor || null);
|
||||
this.loading.set(false);
|
||||
this.loadingMore.set(false);
|
||||
this.cdr.markForCheck();
|
||||
},
|
||||
error: (e) => {
|
||||
if (snapshotVersion !== this.version) return;
|
||||
this.error.set(e.message || 'Failed to load videos');
|
||||
this.loading.set(false);
|
||||
this.loadingMore.set(false);
|
||||
this.cdr.markForCheck();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private applyFilters(list: Video[]): Video[] {
|
||||
return this.applyFiltersWithType(list, this.type());
|
||||
}
|
||||
|
||||
/**
|
||||
* Chargement Twitch à sections (directory/category) : lives (+ catégorie),
|
||||
* VODs, clips et chaînes en parallèle, chacun avec son curseur Helix.
|
||||
* En continuation on ne recharge que l'onglet actif (ou tout si "Tout").
|
||||
*/
|
||||
private loadTwitchSections(snapshotVersion: number, provider: Provider, query: string): void {
|
||||
const firstPage = this.allVideos().length === 0;
|
||||
const tab = this.twitchTab();
|
||||
const wantLive = firstPage || tab === 'all' || tab === 'live';
|
||||
const wantVod = firstPage || tab === 'all' || tab === 'video';
|
||||
const wantClip = firstPage || tab === 'all' || tab === 'clip';
|
||||
const wantChannel = firstPage || tab === 'all' || tab === 'channel';
|
||||
|
||||
const finish = (items: Video[], next: string | null, err?: string) => {
|
||||
if (snapshotVersion !== this.version) return;
|
||||
if (err) this.error.set(err);
|
||||
const existing = new Set(this.allVideos().map(v => `${v.type}:${v.videoId}`));
|
||||
const fresh = items.filter(v => !existing.has(`${v.type}:${v.videoId}`));
|
||||
if (fresh.length) this.allVideos.update(cur => [...cur, ...fresh]);
|
||||
this.nextCursor.set(next);
|
||||
this.loading.set(false);
|
||||
this.loadingMore.set(false);
|
||||
this.cdr.markForCheck();
|
||||
};
|
||||
|
||||
const fetchAll = (gameId: string | null) => {
|
||||
const calls: Record<string, Observable<PagedResult<Video>>> = {};
|
||||
if (wantLive) {
|
||||
calls['live'] = gameId
|
||||
? this.api.getTwitchGameStreamsPage(gameId, this.twitchCursors.live || undefined, 12).pipe(
|
||||
catchError(() => this.api.getTwitchTopStreamsPage(this.twitchCursors.live || undefined)))
|
||||
: this.api.getTwitchTopStreamsPage(this.twitchCursors.live || undefined);
|
||||
}
|
||||
if (wantVod) calls['vod'] = this.api.searchTwitchVodsPage(query, this.twitchCursors.vod || undefined);
|
||||
if (wantClip) calls['clip'] = this.api.searchTwitchClipsPage(query, this.twitchCursors.clip || undefined);
|
||||
if (wantChannel) calls['channel'] = this.api.searchTwitchAllChannelsPage(query, this.twitchCursors.channel || undefined, 20);
|
||||
if (Object.keys(calls).length === 0) {
|
||||
this.loading.set(false);
|
||||
this.loadingMore.set(false);
|
||||
this.cdr.markForCheck();
|
||||
return;
|
||||
}
|
||||
forkJoin(calls).subscribe({
|
||||
next: (res) => {
|
||||
if (snapshotVersion !== this.version) return;
|
||||
const r = res as Record<string, PagedResult<Video>>;
|
||||
if (r['live']) this.twitchCursors.live = r['live'].nextCursor || null;
|
||||
if (r['vod']) this.twitchCursors.vod = r['vod'].nextCursor || null;
|
||||
if (r['clip']) this.twitchCursors.clip = r['clip'].nextCursor || null;
|
||||
if (r['channel']) this.twitchCursors.channel = r['channel'].nextCursor || null;
|
||||
const items = Object.values(r).flatMap(x => x?.items || []);
|
||||
// Filet de sécurité : si VODs+clips vides et aucun live, compléter par top streams.
|
||||
const hasContent = items.length > 0;
|
||||
if (!hasContent && firstPage) {
|
||||
this.api.getTwitchTopStreamsPage(null).subscribe({
|
||||
next: (top) => finish([...items, ...(top.items || [])], top.nextCursor || null),
|
||||
error: () => finish(items, null),
|
||||
});
|
||||
return;
|
||||
}
|
||||
const next = this.twitchCursors.live || this.twitchCursors.vod || this.twitchCursors.clip || this.twitchCursors.channel;
|
||||
finish(items, next);
|
||||
},
|
||||
error: (e) => finish([], null, e?.message || 'Failed to load videos'),
|
||||
});
|
||||
};
|
||||
|
||||
if (firstPage) {
|
||||
// Première page : résoudre la catégorie Twitch pour des lives thématiques.
|
||||
this.api.searchTwitchCategory(query).subscribe({
|
||||
next: (game) => {
|
||||
if (snapshotVersion !== this.version) return;
|
||||
if (game) this.twitchGame.set(game);
|
||||
fetchAll(game?.id || null);
|
||||
},
|
||||
error: () => {
|
||||
if (snapshotVersion !== this.version) return;
|
||||
fetchAll(null);
|
||||
},
|
||||
});
|
||||
} else {
|
||||
fetchAll(this.twitchGame()?.id || null);
|
||||
}
|
||||
}
|
||||
|
||||
private applyFiltersWithType(list: Video[], typeOverride: 'any' | 'video' | 'live' | 'recorded' | 'short' | 'channel'): Video[] {
|
||||
let arr = [...list];
|
||||
const isLiveTheme = this.theme() === 'live';
|
||||
const durationFilter = this.duration();
|
||||
if (durationFilter !== 'any') {
|
||||
// Les lives n'ont pas de durée connue (0) : le filtre durée ne s'applique pas sur le thème live.
|
||||
if (durationFilter !== 'any' && !isLiveTheme) {
|
||||
arr = arr.filter(v => {
|
||||
const dur = v.duration || 0;
|
||||
if (durationFilter === 'short') return dur > 0 && dur < 240; // < 4 min
|
||||
@@ -214,18 +388,33 @@ export class ProviderThemePageComponent implements OnDestroy {
|
||||
});
|
||||
}
|
||||
|
||||
const typeFilter = this.type();
|
||||
const typeFilter = typeOverride;
|
||||
if (typeFilter !== 'any') {
|
||||
const p = this.provider();
|
||||
arr = arr.filter(v => {
|
||||
const withProvider = { ...v, provider: (v.provider as string) || p };
|
||||
if (typeFilter === 'video') return !isShortVideo(withProvider) && (v.type === 'video' || v.type === 'recorded');
|
||||
if (typeFilter === 'live') return v.type === 'live' || v.type === 'channel' || v.type === 'stream' || (v as any).isLive === true;
|
||||
if (typeFilter === 'recorded') return v.type === 'recorded' || (v.type === 'video' && !isShortVideo(withProvider));
|
||||
// Shorts : classification partagée (durée 0/absente ⇒ jamais un Short).
|
||||
if (typeFilter === 'short') return isShortVideo(withProvider);
|
||||
if (typeFilter === 'video') return !isShortVideo(withProvider) && !isLiveVideo(withProvider) && String(v.type || '').toLowerCase() !== 'channel' && (v.type === 'video' || v.type === 'recorded');
|
||||
if (typeFilter === 'live') return isLiveVideo(withProvider);
|
||||
if (typeFilter === 'recorded') return !isLiveVideo(withProvider) && String(v.type || '').toLowerCase() !== 'channel' && (v.type === 'recorded' || (v.type === 'video' && !isShortVideo(withProvider)));
|
||||
// Shorts / Clips : classification partagée + kind='clip' (durée parfois absente).
|
||||
if (typeFilter === 'short') {
|
||||
if (isLiveVideo(withProvider)) return false;
|
||||
if (String(v.type || '').toLowerCase() === 'channel') return false;
|
||||
if (String((v as any).kind || '').toLowerCase() === 'clip') return true;
|
||||
return isShortVideo(withProvider);
|
||||
}
|
||||
// Chaînes : hors-ligne uniquement (les lives restent dans "Direct").
|
||||
if (typeFilter === 'channel') {
|
||||
if ((v as any).isLive === true) return false;
|
||||
if (isLiveVideo(withProvider) && String(v.type || '').toLowerCase() !== 'channel') return false;
|
||||
return String(v.type || '').toLowerCase() === 'channel';
|
||||
}
|
||||
return true;
|
||||
});
|
||||
} else if (this.theme() === 'live') {
|
||||
// Thème live même si l'utilisateur remet le filtre sur "Any" : LIVE seulement.
|
||||
const p = this.provider();
|
||||
arr = arr.filter(v => isLiveVideo({ ...v, provider: (v.provider as string) || p }));
|
||||
}
|
||||
|
||||
const langFilter = this.language();
|
||||
@@ -289,6 +478,31 @@ export class ProviderThemePageComponent implements OnDestroy {
|
||||
return classifyVideo({ ...v, provider: (v.provider as string) || p });
|
||||
}
|
||||
|
||||
/** Badges Twitch : LIVE seulement si réellement en direct (pas les chaînes hors-ligne). */
|
||||
isLiveCard(v: Video): boolean {
|
||||
if ((v as any).isLive === true) return true;
|
||||
if (String(v.type || '').toLowerCase() === 'live') return true;
|
||||
if (String(v.type || '').toLowerCase() === 'channel') return false;
|
||||
const p = this.provider();
|
||||
return isLiveVideo({ ...v, provider: (v.provider as string) || p })
|
||||
&& String(v.type || '').toLowerCase() !== 'channel';
|
||||
}
|
||||
|
||||
isChannelCard(v: Video): boolean {
|
||||
if ((v as any).isLive === true) return false;
|
||||
return String(v.type || '').toLowerCase() === 'channel';
|
||||
}
|
||||
|
||||
isClipCard(v: Video): boolean {
|
||||
if (this.isLiveCard(v) || this.isChannelCard(v)) return false;
|
||||
if (String((v as any).kind || '').toLowerCase() === 'clip') return true;
|
||||
const p = this.provider();
|
||||
return isShortVideo({ ...v, provider: (v.provider as string) || p });
|
||||
}
|
||||
|
||||
/** Affichage "Tout" Twitch : sections empilées façon directory. */
|
||||
showTwitchSectionsAll = computed(() => this.isTwitchSections() && this.twitchTab() === 'all');
|
||||
|
||||
shortsCount(): number {
|
||||
const p = this.provider();
|
||||
return this.allVideos().filter(v => isShortVideo({ ...v, provider: (v.provider as string) || p })).length;
|
||||
@@ -309,11 +523,21 @@ export class ProviderThemePageComponent implements OnDestroy {
|
||||
if (slug.startsWith('/')) slug = slug.slice(1);
|
||||
qp.slug = slug;
|
||||
}
|
||||
if (p === 'twitch' && v.type === 'channel') qp.channel = v.videoId;
|
||||
if (p === 'twitch') {
|
||||
// Clips : embed dédié ?clip=<slug> (jamais ?channel=, le slug n'est pas un login).
|
||||
if (String((v as any).kind || '').toLowerCase() === 'clip') {
|
||||
qp.clip = v.videoId;
|
||||
return qp;
|
||||
}
|
||||
if (v.type === 'channel' || v.type === 'live' || (v as any).isLive === true) {
|
||||
// ?channel= exige le login (non numérique) ; un id numérique = VOD.
|
||||
if (v.videoId && !/^\d+$/.test(String(v.videoId))) qp.channel = v.videoId;
|
||||
}
|
||||
}
|
||||
return qp;
|
||||
}
|
||||
|
||||
trackByVideo(_idx: number, v: Video) { return v.videoId || v.url; }
|
||||
trackByVideo(_idx: number, v: Video) { return `${v.type}:${v.videoId}` || v.url; }
|
||||
|
||||
ngOnDestroy(): void {
|
||||
this.version++; // Invalidate any pending requests
|
||||
|
||||
@@ -36,7 +36,8 @@
|
||||
|
||||
<!-- Error / empty -->
|
||||
<div *ngIf="!b.loading && b.error" class="text-amber-300">{{ b.error }}</div>
|
||||
<div *ngIf="!b.loading && !b.error && b.items.length === 0" class="text-slate-400">{{ 'empty.noItems' | t }}</div>
|
||||
<div *ngIf="!b.loading && !b.error && b.items.length === 0 && themeSlug() === 'live' && !isLiveProvider(b.provider)" class="text-slate-400">Ce fournisseur ne propose pas de directs.</div>
|
||||
<div *ngIf="!b.loading && !b.error && b.items.length === 0 && !(themeSlug() === 'live' && !isLiveProvider(b.provider))" class="text-slate-400">{{ 'empty.noItems' | t }}</div>
|
||||
<div *ngIf="!b.loading && !b.error && b.items.length > 0 && filteredItems(b).length === 0" class="text-slate-400">
|
||||
Aucun {{ contentFilter() === 'shorts' ? 'Short' : 'vidéo' }} pour ce fournisseur — essayez l'autre filtre.
|
||||
</div>
|
||||
@@ -55,13 +56,18 @@
|
||||
<app-provider-badge [provider]="b.provider"></app-provider-badge>
|
||||
</div>
|
||||
<div class="absolute top-2 left-2 flex gap-1">
|
||||
<span *ngIf="kindOf(v, b.provider) === 'short'" class="bg-red-600 text-white text-[11px] font-bold px-1.5 py-0.5 rounded">SHORT</span>
|
||||
<span *ngIf="kindOf(v, b.provider) === 'live'" class="bg-purple-600 text-white text-[11px] font-bold px-1.5 py-0.5 rounded">LIVE</span>
|
||||
<span *ngIf="isLiveCard(v, b.provider)" class="bg-purple-600 text-white text-[11px] font-bold px-1.5 py-0.5 rounded">LIVE</span>
|
||||
<span *ngIf="!isLiveCard(v, b.provider) && isClipCard(v, b.provider)" class="bg-red-600 text-white text-[11px] font-bold px-1.5 py-0.5 rounded">CLIP</span>
|
||||
<span *ngIf="!isLiveCard(v, b.provider) && !isClipCard(v, b.provider) && kindOf(v, b.provider) === 'short'" class="bg-red-600 text-white text-[11px] font-bold px-1.5 py-0.5 rounded">SHORT</span>
|
||||
<span *ngIf="isChannelCard(v)" class="bg-slate-700/90 text-slate-200 text-[11px] font-bold px-1.5 py-0.5 rounded ring-1 ring-slate-500/50">CHAÎNE</span>
|
||||
</div>
|
||||
<div class="absolute bottom-2 right-2 bg-black/70 text-white text-xs px-2 py-0.5 rounded" *ngIf="v.duration && v.duration > 0">
|
||||
{{ v.duration | duration }}
|
||||
</div>
|
||||
<div class="absolute bottom-2 left-2">
|
||||
<div *ngIf="isLiveCard(v, b.provider) && v.views > 0" class="absolute bottom-2 left-2 bg-black/70 text-white text-xs px-2 py-0.5 rounded">
|
||||
{{ v.views | number }} spectateurs
|
||||
</div>
|
||||
<div class="absolute bottom-2 left-2" *ngIf="!isLiveCard(v, b.provider)">
|
||||
<app-like-button [videoId]="v.videoId" [provider]="b.provider" [title]="v.title" [thumbnail]="v.thumbnail"></app-like-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -10,7 +10,7 @@ import { LikeButtonComponent } from '../shared/components/like-button/like-butto
|
||||
import { ProviderBadgeComponent } from '../../app/shared/components/provider-badge/provider-badge.component';
|
||||
import { ChannelIdentityComponent } from '../../app/shared/components/channel-identity/channel-identity.component';
|
||||
import { DurationPipe } from '../../app/shared/pipes/duration.pipe';
|
||||
import { classifyVideo, isShortVideo } from '../../app/shared/utils/video-kind';
|
||||
import { classifyVideo, isLiveVideo, isShortVideo } from '../../app/shared/utils/video-kind';
|
||||
|
||||
interface ProviderBlock {
|
||||
provider: Provider;
|
||||
@@ -48,11 +48,27 @@ export class ThemePageComponent {
|
||||
this.contentFilter.set(v);
|
||||
}
|
||||
|
||||
/** Items du bloc après application du filtre Vidéos/Shorts. */
|
||||
/** Fournisseurs capables de fournir de vrais directs. */
|
||||
private static readonly LIVE_PROVIDERS: Provider[] = ['youtube', 'twitch'];
|
||||
|
||||
isLiveTheme(): boolean {
|
||||
return this.themeSlug() === 'live';
|
||||
}
|
||||
|
||||
isLiveProvider(p: Provider): boolean {
|
||||
return (ThemePageComponent.LIVE_PROVIDERS as Provider[]).includes(p);
|
||||
}
|
||||
|
||||
/** Items du bloc après application du filtre Vidéos/Shorts (+ LIVE seulement sur thème live). */
|
||||
filteredItems(b: ProviderBlock): Video[] {
|
||||
let items = b.items;
|
||||
// Thème "En direct" : ne jamais mélanger avec des VODs (garde-fou si une API renvoie du non-live).
|
||||
if (this.themeSlug() === 'live') {
|
||||
items = items.filter(v => isLiveVideo({ ...v, provider: (v.provider as string) || b.provider }));
|
||||
}
|
||||
const f = this.contentFilter();
|
||||
if (f === 'all') return b.items;
|
||||
return b.items.filter(v =>
|
||||
if (f === 'all') return items;
|
||||
return items.filter(v =>
|
||||
f === 'shorts' ? isShortVideo({ ...v, provider: (v.provider as string) || b.provider }) : !isShortVideo({ ...v, provider: (v.provider as string) || b.provider })
|
||||
);
|
||||
}
|
||||
@@ -61,6 +77,26 @@ export class ThemePageComponent {
|
||||
return classifyVideo({ ...v, provider: (v.provider as string) || provider });
|
||||
}
|
||||
|
||||
/** LIVE seulement si réellement en direct (les chaînes hors-ligne ne le sont pas). */
|
||||
isLiveCard(v: Video, provider: Provider): boolean {
|
||||
if ((v as any).isLive === true) return true;
|
||||
if (String(v.type || '').toLowerCase() === 'live') return true;
|
||||
if (String(v.type || '').toLowerCase() === 'channel') return false;
|
||||
return isLiveVideo({ ...v, provider: (v.provider as string) || provider })
|
||||
&& String(v.type || '').toLowerCase() !== 'channel';
|
||||
}
|
||||
|
||||
isChannelCard(v: Video): boolean {
|
||||
if ((v as any).isLive === true) return false;
|
||||
return String(v.type || '').toLowerCase() === 'channel';
|
||||
}
|
||||
|
||||
isClipCard(v: Video, provider: Provider): boolean {
|
||||
if (this.isLiveCard(v, provider) || this.isChannelCard(v)) return false;
|
||||
if (String((v as any).kind || '').toLowerCase() === 'clip') return true;
|
||||
return isShortVideo({ ...v, provider: (v.provider as string) || provider });
|
||||
}
|
||||
|
||||
shortsCount(b: ProviderBlock): number {
|
||||
return b.items.filter(v => isShortVideo({ ...v, provider: (v.provider as string) || b.provider })).length;
|
||||
}
|
||||
@@ -100,6 +136,14 @@ export class ThemePageComponent {
|
||||
}
|
||||
|
||||
private fetchForProvider(block: ProviderBlock, slug: string) {
|
||||
// Thème "En direct" : les fournisseurs sans live n'affichent aucune vidéo.
|
||||
if (slug === 'live' && !this.isLiveProvider(block.provider)) {
|
||||
block.loading = false;
|
||||
block.error = null;
|
||||
block.items = [];
|
||||
this.blocks.update(a => [...a]);
|
||||
return;
|
||||
}
|
||||
// Provider readiness (e.g., YouTube key, Twitch creds). If not ready, surface reason and skip network calls.
|
||||
const ready = this.instances.getProviderReadiness(block.provider);
|
||||
if (!ready.ready) {
|
||||
@@ -141,17 +185,32 @@ export class ThemePageComponent {
|
||||
});
|
||||
return;
|
||||
}
|
||||
// Twitch / live : les top streams live garantissent un bloc rempli
|
||||
// (la recherche de chaînes "live" est trop restrictive).
|
||||
if (block.provider === 'twitch' && slug === 'live') {
|
||||
this.api.getTwitchTopStreamsPage(null).subscribe({
|
||||
// Thème "En direct" : vrais lives uniquement.
|
||||
// - YouTube : search.list eventType=live (pas une recherche textuelle "live").
|
||||
// - Twitch : top streams Helix (la recherche de chaînes "live" est trop restrictive).
|
||||
if (slug === 'live') {
|
||||
const liveCall = block.provider === 'youtube'
|
||||
? this.api.searchYouTubeLivePage('', null)
|
||||
: this.api.getTwitchTopStreamsPage(null);
|
||||
const mode = block.provider === 'youtube' ? 'youtube_eventType_live' : 'twitch_top_streams';
|
||||
liveCall.subscribe({
|
||||
next: res => {
|
||||
block.items = (res.items || []).slice(0, max);
|
||||
block.items = (res.items || [])
|
||||
.filter(v => isLiveVideo({ ...v, provider: (v.provider as string) || block.provider }))
|
||||
.slice(0, max);
|
||||
block.loading = false;
|
||||
if (!block.items.length) console.info('[ThemePage] Empty block page', { provider: block.provider, theme: slug, mode: 'twitch_top_streams' });
|
||||
if (!block.items.length) console.info('[ThemePage] Empty block page', { provider: block.provider, theme: slug, mode });
|
||||
this.blocks.update(a => [...a]);
|
||||
},
|
||||
error: () => { block.error = 'Erreur de chargement'; block.loading = false; this.blocks.update(a => [...a]); }
|
||||
error: (e) => {
|
||||
if (block.provider === 'youtube' && e?.status === 403) {
|
||||
this.api.quotaExceeded$.next(true);
|
||||
block.error = 'YouTube quota exceeded. Try again later or switch provider.';
|
||||
} else {
|
||||
block.error = 'Erreur de chargement';
|
||||
}
|
||||
block.loading = false; this.blocks.update(a => [...a]);
|
||||
}
|
||||
});
|
||||
return;
|
||||
}
|
||||
@@ -220,9 +279,15 @@ export class ThemePageComponent {
|
||||
qp.slug = slug;
|
||||
} catch {}
|
||||
}
|
||||
if (provider === 'twitch' && v.type === 'channel') {
|
||||
// Try to use channel name
|
||||
if (v.videoId) qp.channel = v.videoId;
|
||||
if (provider === 'twitch') {
|
||||
if (String((v as any).kind || '').toLowerCase() === 'clip') {
|
||||
qp.clip = v.videoId;
|
||||
return qp;
|
||||
}
|
||||
if (v.type === 'channel' || v.type === 'live' || (v as any).isLive === true) {
|
||||
// ?channel= exige le login ; un id VOD numérique ne doit jamais y passer.
|
||||
if (v.videoId && !/^\d+$/.test(String(v.videoId))) qp.channel = v.videoId;
|
||||
}
|
||||
}
|
||||
return qp;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user