feat(channel): page details chaine multi-providers avec onglets et infinite scroll
CI / build-and-test (push) Successful in 13m34s

- Strategy Pattern front: ChannelProviderInterface + 6 providers (yt/dm/tw/pt/od/ru) + factory (ids longs/courts)
- ChannelContentService: cache par onglet/tri/recherche (TTL 5min), pagination nextPage
- ChannelPage: header (banniere/avatar/subs/description ...plus), tabs dynamiques par capabilities, grilles videos/shorts 9:16/playlists/live, recherche debounced, tri recent/populaire, infinite scroll IntersectionObserver, skeletons/empty/error
- Backend: GET /api/channels/:provider/:id/content (videos/shorts/playlists/live) natif par provider
This commit is contained in:
2026-09-25 10:15:12 -04:00
parent fe35340948
commit 97e5de74f5
147 changed files with 7954 additions and 77497 deletions
@@ -0,0 +1,40 @@
import { ChangeDetectionStrategy, Component, Input } from '@angular/core';
import { CommonModule, DatePipe } from '@angular/common';
import type { ChannelDetail } from '../../../app/shared/models/channel-detail.model';
@Component({
selector: 'app-channel-about',
standalone: true,
imports: [CommonModule, DatePipe],
template: `
<div *ngIf="channel" class="grid gap-4 rounded-xl border border-zinc-800 bg-zinc-900/50 p-5 text-sm text-zinc-300 lg:grid-cols-2">
<div>
<h3 class="mb-2 text-base font-semibold text-white">Description</h3>
<p class="whitespace-pre-line">{{ channel.description || 'Aucune description disponible.' }}</p>
</div>
<div class="space-y-2">
<h3 class="text-base font-semibold text-white">Informations</h3>
<p *ngIf="channel.handle"><span class="text-zinc-500">Handle : </span>{{ channel.handle }}</p>
<p><span class="text-zinc-500">ID externe : </span><span class="font-mono text-xs">{{ channel.externalId }}</span></p>
<p *ngIf="channel.viewCount != null"><span class="text-zinc-500">Vues totales : </span>{{ channel.viewCount | number }}</p>
<p *ngIf="channel.createdAt"><span class="text-zinc-500">Créée le : </span>{{ channel.createdAt | date:'longDate' }}</p>
<p *ngIf="channel.lastRefreshedAt"><span class="text-zinc-500">Dernière synchro : </span>{{ channel.lastRefreshedAt | date:'medium' }}</p>
<div *ngIf="channel.links?.length">
<p class="text-zinc-500">Liens :</p>
<ul class="mt-1 space-y-1">
<li *ngFor="let link of channel.links">
<a [href]="link.url" target="_blank" rel="noopener" class="text-red-300 hover:text-red-200 hover:underline">{{ link.label }}</a>
</li>
</ul>
</div>
<a *ngIf="channel.url" [href]="channel.url" target="_blank" rel="noopener" class="inline-block rounded-md border border-zinc-700 px-3 py-1.5 text-zinc-200 hover:border-zinc-500 hover:text-white">
Ouvrir la chaîne officielle
</a>
</div>
</div>
`,
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class ChannelAboutComponent {
@Input() channel: ChannelDetail | null = null;
}
@@ -0,0 +1,83 @@
<div *ngIf="loading" class="animate-pulse">
<div class="h-36 w-full rounded-xl bg-zinc-800 sm:h-48"></div>
<div class="mt-4 flex items-center gap-4 px-1">
<div class="h-24 w-24 rounded-full bg-zinc-800"></div>
<div class="flex-1 space-y-2">
<div class="h-6 w-1/3 rounded bg-zinc-800"></div>
<div class="h-4 w-1/4 rounded bg-zinc-800"></div>
</div>
</div>
</div>
<div *ngIf="!loading && channel" class="overflow-hidden rounded-xl border border-zinc-800 bg-zinc-950">
<!-- Bannière (masquée proprement si absente) -->
<div *ngIf="channel.bannerUrl" class="h-36 w-full overflow-hidden bg-zinc-900 sm:h-48">
<img [src]="channel.bannerUrl" [alt]="" class="h-full w-full object-cover" loading="lazy" />
</div>
<div class="flex flex-col gap-4 p-4 sm:p-6">
<div class="flex flex-col gap-4 sm:flex-row sm:items-start">
<div class="relative -mt-2 shrink-0 sm:-mt-10">
<img
[src]="channel.avatarUrl || 'https://placehold.co/160x160/png'"
[alt]="channel.title || channel.handle || 'Avatar'"
class="h-24 w-24 rounded-full border-4 border-zinc-950 bg-zinc-800 object-cover sm:h-28 sm:w-28"
/>
</div>
<div class="min-w-0 flex-1">
<h1 class="truncate text-2xl font-bold text-white sm:text-3xl">
{{ channel.title || channel.handle || 'Chaîne inconnue' }}
</h1>
<div class="mt-1 flex flex-wrap items-center gap-x-3 gap-y-1 text-sm text-zinc-400">
<app-provider-badge [provider]="longProvider"></app-provider-badge>
<span *ngIf="channel.handle" class="truncate">{{ channel.handle }}</span>
<span *ngIf="channel.subsCount != null">
<app-compact-number [value]="channel.subsCount"></app-compact-number> abonnés
</span>
<span *ngIf="channel.videoCount != null">
<app-compact-number [value]="channel.videoCount"></app-compact-number> vidéos
</span>
<span *ngIf="channel.verified" class="font-medium text-emerald-300">✓ Vérifiée</span>
</div>
<!-- Description tronquée + ...plus -->
<div *ngIf="channel.description" class="mt-2 max-w-3xl text-sm text-zinc-300">
<p [class.line-clamp-2]="!expanded()">{{ channel.description }}</p>
<button type="button" (click)="toggleExpand()" class="mt-1 font-medium text-zinc-100 hover:text-red-300">
{{ expanded() ? '...moins' : '...plus' }}
</button>
</div>
</div>
</div>
<div class="flex flex-wrap items-center gap-2">
<app-subscribe-button
[provider]="channel.provider"
[externalId]="channel.externalId"
[channelMeta]="channel"
></app-subscribe-button>
<button
type="button"
(click)="share()"
class="rounded-md border border-zinc-700 px-3 py-1.5 text-sm font-medium text-zinc-200 transition hover:border-zinc-500 hover:text-white"
>
Partager
</button>
<a
*ngIf="channel.url"
[href]="channel.url"
target="_blank"
rel="noopener"
class="rounded-md border border-zinc-700 px-3 py-1.5 text-sm font-medium text-zinc-200 transition hover:border-zinc-500 hover:text-white"
>
Ouvrir la chaîne officielle
</a>
<button
type="button"
(click)="refresh.emit()"
class="rounded-md border border-zinc-700 px-3 py-1.5 text-sm font-medium text-zinc-200 transition hover:border-zinc-500 hover:text-white"
>
Rafraîchir
</button>
</div>
</div>
</div>
@@ -0,0 +1,42 @@
import { ChangeDetectionStrategy, Component, EventEmitter, Input, Output, signal } from '@angular/core';
import { CommonModule } from '@angular/common';
import type { ChannelDetail } from '../../../app/shared/models/channel-detail.model';
import { ProviderBadgeComponent } from '../../../app/shared/components/provider-badge/provider-badge.component';
import { SubscribeButtonComponent } from '../../../app/shared/components/subscribe-button/subscribe-button.component';
import { CompactNumberComponent } from '../../../app/shared/components/compact-number/compact-number.component';
@Component({
selector: 'app-channel-header',
standalone: true,
imports: [CommonModule, ProviderBadgeComponent, SubscribeButtonComponent, CompactNumberComponent],
templateUrl: './channel-header.component.html',
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class ChannelHeaderComponent {
@Input() channel: ChannelDetail | null = null;
@Input() loading = false;
@Output() refresh = new EventEmitter<void>();
readonly expanded = signal(false);
toggleExpand(): void {
this.expanded.update((v) => !v);
}
share(): void {
const url = window.location.href;
const title = this.channel?.title || this.channel?.handle || 'Chaîne';
if (navigator.share) {
navigator.share({ title, url }).catch(() => {});
} else {
navigator.clipboard?.writeText(url).catch(() => {});
}
}
get longProvider(): string | null {
const map: Record<string, string> = {
yt: 'youtube', dm: 'dailymotion', tw: 'twitch', pt: 'peertube', od: 'odysee', ru: 'rumble',
};
return this.channel ? (map[this.channel.provider] ?? null) : null;
}
}
@@ -0,0 +1,97 @@
<div class="container mx-auto max-w-7xl px-4 py-6">
<!-- Erreur header (params invalides / chaîne introuvable) -->
<div *ngIf="headerError() && !headerLoading()" class="rounded-xl border border-red-500/40 bg-red-500/10 p-8 text-center">
<h2 class="mb-2 text-xl font-semibold text-red-100">Impossible de charger la chaîne</h2>
<p class="text-sm text-red-200/80">{{ headerError() }}</p>
<button type="button" (click)="refreshAll(true)" class="mt-4 rounded-md border border-red-500/60 px-4 py-1.5 text-sm text-red-100 hover:bg-red-500/20">
Réessayer
</button>
</div>
<div *ngIf="!headerError() || headerLoading()">
<app-channel-header [channel]="detail()" [loading]="headerLoading()" (refresh)="refreshAll(true)"></app-channel-header>
<div *ngIf="!headerLoading() && detail()" class="mt-4">
<app-channel-tabs [capabilities]="capabilities()" [active]="activeTab()" (select)="onTabSelect($event)"></app-channel-tabs>
<!-- Barre d'outils : tri + recherche locale (masquée sur À propos) -->
<div *ngIf="activeTab() !== 'about'" class="flex flex-col gap-2 py-4 sm:flex-row sm:items-center">
<div *ngIf="activeTab() === 'videos' || activeTab() === 'shorts'" class="flex items-center gap-2 text-sm">
<label for="channel-sort" class="text-zinc-400">Trier :</label>
<select
id="channel-sort"
[value]="sort()"
(change)="onSortChange($any($event.target).value)"
class="rounded-md border border-zinc-700 bg-zinc-900 px-2 py-1.5 text-sm text-zinc-100"
>
<option value="recent">Plus récentes</option>
<option value="popular">Plus populaires</option>
</select>
</div>
<div class="relative flex-1 sm:max-w-md sm:ml-auto">
<input
type="search"
placeholder="Rechercher dans cette chaîne…"
[value]="searchQuery()"
(input)="onSearchInput($any($event.target).value)"
class="w-full rounded-lg border border-zinc-700 bg-zinc-900 px-3 py-2 pr-9 text-sm text-zinc-100 placeholder:text-zinc-500 focus:border-red-500 focus:outline-none"
/>
<button *ngIf="searchQuery()" type="button" (click)="clearSearch()" aria-label="Effacer"
class="absolute right-2 top-1/2 -translate-y-1/2 rounded px-1 text-zinc-400 hover:text-white">✕</button>
</div>
</div>
<!-- Onglets contenu -->
<div class="pb-10">
<app-channel-video-grid
*ngIf="activeTab() === 'videos'"
[videos]="videos()"
[loading]="listLoading()"
[loadingMore]="listLoadingMore()"
[hasMore]="hasMore()"
[error]="listError()"
layout="video"
emptyMessage="Aucune vidéo trouvée pour cette chaîne."
(loadMore)="onLoadMore()"
(retry)="onRetryTab()"
></app-channel-video-grid>
<app-channel-video-grid
*ngIf="activeTab() === 'shorts'"
[videos]="shorts()"
[loading]="listLoading()"
[loadingMore]="listLoadingMore()"
[hasMore]="hasMore()"
[error]="listError()"
layout="short"
emptyMessage="Aucun short trouvé pour cette chaîne."
(loadMore)="onLoadMore()"
(retry)="onRetryTab()"
></app-channel-video-grid>
<app-channel-playlists
*ngIf="activeTab() === 'playlists'"
[playlists]="playlists()"
[loading]="listLoading()"
[error]="listError()"
(retry)="onRetryTab()"
></app-channel-playlists>
<app-channel-video-grid
*ngIf="activeTab() === 'live'"
[videos]="streams()"
[loading]="listLoading()"
[loadingMore]="listLoadingMore()"
[hasMore]="hasMore()"
[error]="listError()"
layout="video"
emptyMessage="Aucun stream en direct ou replay pour le moment."
(loadMore)="onLoadMore()"
(retry)="onRetryTab()"
></app-channel-video-grid>
<app-channel-about *ngIf="activeTab() === 'about'" [channel]="detail()"></app-channel-about>
</div>
</div>
</div>
</div>
@@ -0,0 +1,296 @@
import { CommonModule } from '@angular/common';
import { ChangeDetectionStrategy, Component, computed, inject, signal } from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { ActivatedRoute, Router } from '@angular/router';
import { Subject, debounceTime, distinctUntilChanged, map, of, switchMap, take } from 'rxjs';
import type { ProviderId } from '../../app/shared/models/channel.model';
import type {
ChannelDetail,
ChannelPlaylist,
ChannelSort,
ChannelTabId,
} from '../../app/shared/models/channel-detail.model';
import { buildChannelDetail } from '../../app/shared/models/channel-detail.model';
import type { VideoItem } from '../../app/shared/models/video-item.model';
import { normalizeChannelProvider, ChannelProviderFactory } from '../../app/core/providers/channel/channel-provider.factory';
import { ChannelsService } from '../../services/channels.service';
import { ChannelContentService } from '../../services/channel-content.service';
import { SubscriptionsService } from '../../services/subscriptions.service';
import { ChannelHeaderComponent } from './channel-header/channel-header.component';
import { ChannelTabsComponent } from './channel-tabs/channel-tabs.component';
import { ChannelVideoGridComponent } from './channel-video-grid/channel-video-grid.component';
import { ChannelPlaylistsComponent } from './channel-playlists/channel-playlists.component';
import { ChannelAboutComponent } from './channel-about/channel-about.component';
@Component({
selector: 'app-channel-page',
standalone: true,
imports: [
CommonModule,
ChannelHeaderComponent,
ChannelTabsComponent,
ChannelVideoGridComponent,
ChannelPlaylistsComponent,
ChannelAboutComponent,
],
templateUrl: './channel-page.component.html',
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class ChannelPageComponent {
private readonly route = inject(ActivatedRoute);
private readonly router = inject(Router);
private readonly channels = inject(ChannelsService);
private readonly content = inject(ChannelContentService);
private readonly providers = inject(ChannelProviderFactory);
private readonly subs = inject(SubscriptionsService);
private readonly searchInput$ = new Subject<string>();
readonly provider = signal<ProviderId | null>(null);
readonly externalId = signal<string | null>(null);
readonly detail = signal<ChannelDetail | null>(null);
readonly headerLoading = signal(true);
readonly headerError = signal<string | null>(null);
readonly activeTab = signal<ChannelTabId>('videos');
readonly sort = signal<ChannelSort>('recent');
readonly searchQuery = signal('');
readonly videos = signal<VideoItem[]>([]);
readonly shorts = signal<VideoItem[]>([]);
readonly streams = signal<VideoItem[]>([]);
readonly playlists = signal<ChannelPlaylist[]>([]);
readonly listLoading = signal(false);
readonly listLoadingMore = signal(false);
readonly listError = signal<string | null>(null);
readonly hasMore = signal(false);
readonly capabilities = computed(() => this.detail()?.capabilities ?? null);
constructor() {
if (this.subs.items().length === 0) {
this.subs.list().pipe(takeUntilDestroyed()).subscribe();
}
// Recherche locale débouncée -> refetch via searchInChannel (ou filtre)
this.searchInput$
.pipe(debounceTime(400), distinctUntilChanged(), takeUntilDestroyed())
.subscribe((q) => {
this.searchQuery.set(q);
this.syncQueryParams();
this.reloadTab(true);
});
this.route.paramMap
.pipe(
map((params) => ({
provider: normalizeChannelProvider(params.get('provider')),
externalId: params.get('externalId')?.trim() || null,
})),
switchMap(({ provider, externalId }) => {
this.provider.set(provider);
this.externalId.set(externalId);
// Onglet / tri / recherche depuis l'URL (?tab=&sort=&q=)
const qp = this.route.snapshot.queryParamMap;
const tab = qp.get('tab') as ChannelTabId | null;
if (tab && ['videos', 'shorts', 'playlists', 'live', 'about'].includes(tab)) this.activeTab.set(tab);
const sort = qp.get('sort') as ChannelSort | null;
if (sort === 'popular' || sort === 'recent') this.sort.set(sort);
this.searchQuery.set(qp.get('q') ?? '');
if (!provider || !externalId) {
this.headerLoading.set(false);
this.headerError.set('channel_invalid_params');
this.detail.set(null);
return of(null);
}
this.headerLoading.set(true);
this.headerError.set(null);
const refresh = qp.get('refresh') === '1' || qp.get('refresh') === 'true';
// Si ?refresh=1, vide aussi le cache des listes
if (refresh) this.content.clear(provider, externalId);
return this.channels.resolve(provider, externalId, { refresh });
}),
takeUntilDestroyed(),
)
.subscribe((meta) => {
this.headerLoading.set(false);
if (!meta) {
this.headerError.set('channel_unavailable');
this.detail.set(null);
return;
}
// Enrichit la meta (cache 6h) en ChannelDetail avec capabilities du provider
const providerApi = this.providers.get(meta.provider);
this.detail.set(
buildChannelDetail(meta, {
capabilities: {
videos: true,
shorts: providerApi.supports('shorts'),
playlists: providerApi.supports('playlists'),
live: providerApi.supports('live'),
about: true,
searchInChannel: providerApi.supports('searchInChannel'),
},
}),
);
this.headerError.set(null);
// Si l'onglet actif n'est pas supporté, retomber sur videos
const capsNow = this.detail()?.capabilities;
if (capsNow && !(capsNow as unknown as Record<string, boolean>)[this.activeTab()]) {
this.activeTab.set('videos');
}
this.reloadTab();
});
}
onTabSelect(tab: ChannelTabId): void {
if (tab === this.activeTab()) return;
this.activeTab.set(tab);
this.syncQueryParams();
this.reloadTab();
}
onSortChange(value: string): void {
this.sort.set(value === 'popular' ? 'popular' : 'recent');
this.syncQueryParams();
this.reloadTab(true);
}
onSearchInput(value: string): void {
this.searchInput$.next(value);
}
clearSearch(): void {
this.searchQuery.set('');
this.syncQueryParams();
this.reloadTab(true);
}
onLoadMore(): void {
const p = this.provider();
const id = this.externalId();
if (!p || !id || this.listLoadingMore() || this.listLoading() || !this.hasMore()) return;
this.listLoadingMore.set(true);
const type = this.tabToContentType();
if (!type) return;
this.content
.loadMore(p, id, type, { sort: this.sort(), q: this.searchQuery() })
.pipe(take(1))
.subscribe((ok) => {
this.listLoadingMore.set(false);
if (ok) this.pullState();
});
}
onRetryTab(): void {
this.reloadTab(true);
}
refreshAll(force = true): void {
const p = this.provider();
const id = this.externalId();
if (!p || !id) return;
this.headerLoading.set(true);
if (force) this.content.clear(p, id);
this.channels
.resolve(p, id, { refresh: force })
.pipe(take(1))
.subscribe({
next: (meta) => {
if (meta) {
const api = this.providers.get(meta.provider);
this.detail.set(
buildChannelDetail(meta, {
description: this.detail()?.description ?? null,
capabilities: {
videos: true,
shorts: api.supports('shorts'),
playlists: api.supports('playlists'),
live: api.supports('live'),
about: true,
searchInChannel: api.supports('searchInChannel'),
},
}),
);
this.headerError.set(null);
} else {
this.headerError.set('channel_unavailable');
}
},
error: () => this.headerError.set('channel_unavailable'),
complete: () => {
this.headerLoading.set(false);
this.reloadTab(true);
},
});
}
private tabToContentType(): 'videos' | 'shorts' | 'playlists' | 'live' | null {
switch (this.activeTab()) {
case 'videos': return 'videos';
case 'shorts': return 'shorts';
case 'playlists': return 'playlists';
case 'live': return 'live';
default: return null;
}
}
private reloadTab(force = false): void {
const p = this.provider();
const id = this.externalId();
const tab = this.activeTab();
if (!p || !id || tab === 'about') return;
this.listLoading.set(true);
this.listError.set(null);
const sort = this.sort();
const q = this.searchQuery();
const done = (ok: boolean) => {
this.listLoading.set(false);
if (!ok) this.listError.set('channel_content_failed');
this.pullState();
};
if (tab === 'videos') {
this.content.loadVideos(p, id, { sort, q, refresh: force }).pipe(take(1)).subscribe({ next: () => done(true), error: () => done(false) });
} else if (tab === 'shorts') {
this.content.loadShorts(p, id, { sort, q, refresh: force }).pipe(take(1)).subscribe({ next: () => done(true), error: () => done(false) });
} else if (tab === 'live') {
this.content.loadStreams(p, id, { q, refresh: force }).pipe(take(1)).subscribe({ next: () => done(true), error: () => done(false) });
} else if (tab === 'playlists') {
this.content.loadPlaylists(p, id, { q, refresh: force }).pipe(take(1)).subscribe({ next: () => done(true), error: () => done(false) });
}
}
private pullState(): void {
const p = this.provider();
const id = this.externalId();
if (!p || !id) return;
const sort = this.sort();
const q = this.searchQuery();
const tab = this.activeTab();
if (tab === 'videos') {
const s = this.content.snapshot<VideoItem>(p, id, 'videos', sort, q);
this.videos.set(s.items); this.listError.set(s.error); this.hasMore.set(s.nextPage != null);
} else if (tab === 'shorts') {
const s = this.content.snapshot<VideoItem>(p, id, 'shorts', sort, q);
this.shorts.set(s.items); this.listError.set(s.error); this.hasMore.set(s.nextPage != null);
} else if (tab === 'live') {
const s = this.content.snapshot<VideoItem>(p, id, 'live', 'recent', q);
this.streams.set(s.items); this.listError.set(s.error); this.hasMore.set(s.nextPage != null);
} else if (tab === 'playlists') {
const s = this.content.snapshot<ChannelPlaylist>(p, id, 'playlists', 'recent', q);
this.playlists.set(s.items); this.listError.set(s.error); this.hasMore.set(s.nextPage != null);
}
}
private syncQueryParams(): void {
const p = this.provider();
const id = this.externalId();
if (!p || !id) return;
this.router.navigate(['/channel', p, id], {
queryParams: { tab: this.activeTab(), sort: this.sort(), q: this.searchQuery() || null },
queryParamsHandling: 'merge',
replaceUrl: true,
}).catch(() => {});
}
}
@@ -0,0 +1,46 @@
import { ChangeDetectionStrategy, Component, EventEmitter, Input, Output } from '@angular/core';
import { CommonModule } from '@angular/common';
import type { ChannelPlaylist } from '../../../app/shared/models/channel-detail.model';
import { VideoCardSkeletonComponent } from '../../../app/shared/components/video-card-skeleton/video-card-skeleton.component';
@Component({
selector: 'app-channel-playlists',
standalone: true,
imports: [CommonModule, VideoCardSkeletonComponent],
template: `
<div *ngIf="loading" class="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
<app-video-card-skeleton *ngFor="let _ of [0,1,2,3,4,5]"></app-video-card-skeleton>
</div>
<div *ngIf="!loading && error" class="flex flex-col items-center gap-3 rounded-xl border border-red-500/40 bg-red-500/10 p-8 text-center">
<h3 class="text-lg font-semibold text-red-100">Impossible de charger les playlists</h3>
<button type="button" (click)="retry.emit()" class="rounded-md border border-red-500/60 px-4 py-1.5 text-sm text-red-100 hover:bg-red-500/20">Réessayer</button>
</div>
<div *ngIf="!loading && !error && playlists.length === 0" class="flex flex-col items-center gap-2 rounded-xl border border-zinc-800 bg-zinc-900/50 p-10 text-center">
<h3 class="text-lg font-semibold text-white">Aucune playlist trouvée</h3>
<p class="text-sm text-zinc-400">Cette chaîne ne propose pas encore de playlists.</p>
</div>
<div *ngIf="!loading && !error && playlists.length > 0" class="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
<div *ngFor="let pl of playlists" class="group overflow-hidden rounded-xl border border-zinc-800 bg-zinc-900 transition hover:border-zinc-600">
<div class="relative aspect-video w-full overflow-hidden bg-zinc-800">
<img *ngIf="pl.thumbnailUrl; else noThumb" [src]="pl.thumbnailUrl!" [alt]="pl.title" loading="lazy" class="h-full w-full object-cover" />
<ng-template #noThumb><div class="flex h-full w-full items-center justify-center text-4xl text-zinc-600">▤</div></ng-template>
<div class="absolute inset-y-0 right-0 flex w-24 flex-col items-center justify-center gap-1 bg-black/70 text-white">
<span class="text-xl font-bold">{{ pl.videoCount ?? '–' }}</span>
<span class="text-xs">vidéos</span>
</div>
</div>
<div class="p-3">
<h3 class="line-clamp-2 text-sm font-semibold text-zinc-100">{{ pl.title }}</h3>
<p *ngIf="pl.updatedAt" class="mt-1 text-xs text-zinc-400">Mis à jour le {{ pl.updatedAt | date:'shortDate' }}</p>
</div>
</div>
</div>
`,
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class ChannelPlaylistsComponent {
@Input() playlists: ChannelPlaylist[] = [];
@Input() loading = false;
@Input() error: string | null = null;
@Output() retry = new EventEmitter<void>();
}
@@ -0,0 +1,53 @@
import { ChangeDetectionStrategy, Component, EventEmitter, Input, Output } from '@angular/core';
import { CommonModule } from '@angular/common';
import type { ChannelCapabilities, ChannelTabId } from '../../../app/shared/models/channel-detail.model';
export interface ChannelTabDef {
id: ChannelTabId;
label: string;
}
const ALL_TABS: ChannelTabDef[] = [
{ id: 'videos', label: 'Vidéos' },
{ id: 'shorts', label: 'Shorts' },
{ id: 'playlists', label: 'Playlists' },
{ id: 'live', label: 'En direct' },
{ id: 'about', label: 'À propos' },
];
@Component({
selector: 'app-channel-tabs',
standalone: true,
imports: [CommonModule],
template: `
<nav class="sticky top-0 z-10 -mx-4 border-b border-zinc-800 bg-zinc-950/95 px-4 backdrop-blur" aria-label="Onglets chaîne">
<div class="flex gap-1 overflow-x-auto py-2" role="tablist">
<button
*ngFor="let tab of visibleTabs"
type="button"
role="tab"
[attr.aria-selected]="tab.id === active"
(click)="select.emit(tab.id)"
class="whitespace-nowrap rounded-lg px-4 py-2 text-sm font-medium transition"
[class.bg-red-500/20]="tab.id === active"
[class.text-red-100]="tab.id === active"
[class.text-zinc-400]="tab.id !== active"
[class.hover:text-white]="tab.id !== active"
>
{{ tab.label }}
</button>
</div>
</nav>
`,
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class ChannelTabsComponent {
@Input() capabilities: ChannelCapabilities | null = null;
@Input() active: ChannelTabId = 'videos';
@Output() select = new EventEmitter<ChannelTabId>();
get visibleTabs(): ChannelTabDef[] {
if (!this.capabilities) return [{ id: 'videos', label: 'Vidéos' }];
return ALL_TABS.filter((t) => (this.capabilities as unknown as Record<string, boolean>)[t.id]);
}
}
@@ -0,0 +1,46 @@
<!-- Loading initial : squelettes -->
<div *ngIf="loading" class="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
<app-video-card-skeleton *ngFor="let _ of [0,1,2,3,4,5,6,7]"></app-video-card-skeleton>
</div>
<!-- Erreur -->
<div *ngIf="!loading && error" class="flex flex-col items-center gap-3 rounded-xl border border-red-500/40 bg-red-500/10 p-8 text-center">
<h3 class="text-lg font-semibold text-red-100">Impossible de charger le contenu</h3>
<p class="text-sm text-red-200/80">{{ error }}</p>
<button type="button" (click)="retry.emit()" class="rounded-md border border-red-500/60 px-4 py-1.5 text-sm font-medium text-red-100 hover:bg-red-500/20">
Réessayer
</button>
</div>
<!-- Vide -->
<div *ngIf="!loading && !error && videos.length === 0" class="flex flex-col items-center gap-2 rounded-xl border border-zinc-800 bg-zinc-900/50 p-10 text-center">
<h3 class="text-lg font-semibold text-white">{{ emptyMessage }}</h3>
<p class="text-sm text-zinc-400">Essayez un autre onglet ou revenez plus tard.</p>
</div>
<!-- Grille vidéos classiques -->
<div *ngIf="!loading && !error && videos.length > 0 && layout === 'video'" class="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
<app-video-card *ngFor="let video of videos" [video]="video"></app-video-card>
</div>
<!-- Grille shorts verticale 9:16 -->
<div *ngIf="!loading && !error && videos.length > 0 && layout === 'short'" class="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-6">
<a *ngFor="let video of videos" [routerLink]="['/watch', video.id]" [queryParams]="{ p: video.provider }"
class="group overflow-hidden rounded-xl border border-zinc-800 bg-zinc-900">
<div class="aspect-[9/16] w-full overflow-hidden bg-zinc-800">
<img [src]="video.thumbnailUrl" [alt]="video.title" loading="lazy" class="h-full w-full object-cover transition-transform group-hover:scale-105" />
</div>
<div class="p-2">
<h3 class="line-clamp-2 text-sm font-medium text-zinc-100">{{ video.title }}</h3>
<p *ngIf="video.viewCount" class="mt-1 text-xs text-zinc-400">{{ video.viewCount | number }} vues</p>
</div>
</a>
</div>
<!-- Sentinelle infinite scroll -->
<div *ngIf="hasMore && !loading && !error" #sentinel class="flex justify-center py-6" aria-hidden="true">
<div *ngIf="loadingMore" class="flex items-center gap-2 text-sm text-zinc-400">
<span class="h-5 w-5 animate-spin rounded-full border-2 border-zinc-700 border-t-red-500"></span>
Chargement…
</div>
</div>
@@ -0,0 +1,69 @@
import {
AfterViewInit,
ChangeDetectionStrategy,
Component,
ElementRef,
EventEmitter,
Input,
OnChanges,
OnDestroy,
Output,
SimpleChanges,
ViewChild,
} from '@angular/core';
import { CommonModule } from '@angular/common';
import { RouterLink } from '@angular/router';
import type { VideoItem } from '../../../app/shared/models/video-item.model';
import { VideoCardComponent } from '../../../app/shared/components/video-card/video-card.component';
import { VideoCardSkeletonComponent } from '../../../app/shared/components/video-card-skeleton/video-card-skeleton.component';
@Component({
selector: 'app-channel-video-grid',
standalone: true,
imports: [CommonModule, RouterLink, VideoCardComponent, VideoCardSkeletonComponent],
templateUrl: './channel-video-grid.component.html',
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class ChannelVideoGridComponent implements AfterViewInit, OnChanges, OnDestroy {
@Input() videos: VideoItem[] = [];
@Input() loading = false;
@Input() loadingMore = false;
@Input() hasMore = false;
@Input() error: string | null = null;
/** 'video' (16:9) ou 'short' (9:16) */
@Input() layout: 'video' | 'short' = 'video';
@Input() emptyMessage = 'Aucune vidéo trouvée.';
@Output() loadMore = new EventEmitter<void>();
@Output() retry = new EventEmitter<void>();
@ViewChild('sentinel', { static: false }) sentinel?: ElementRef<HTMLDivElement>;
private observer: IntersectionObserver | null = null;
ngAfterViewInit(): void {
this.observe();
}
ngOnChanges(changes: SimpleChanges): void {
if (changes['hasMore'] || changes['videos']) {
// Ré-observe après chaque rendu (sentinelle recréée par *ngIf)
queueMicrotask(() => this.observe());
}
}
ngOnDestroy(): void {
this.observer?.disconnect();
}
private observe(): void {
this.observer?.disconnect();
const el = this.sentinel?.nativeElement;
if (!el) return;
this.observer = new IntersectionObserver(
(entries) => {
if (entries.some((e) => e.isIntersecting)) this.loadMore.emit();
},
{ rootMargin: '600px' },
);
this.observer.observe(el);
}
}
@@ -0,0 +1,50 @@
<section class="mx-auto max-w-4xl px-4 py-6">
<a routerLink="/library/downloads"
class="inline-flex items-center gap-2 text-sm text-slate-300 hover:text-white transition">
<span aria-hidden="true">←</span> {{ 'downloads.back' | t }}
</a>
@if (loading()) {
<div class="mt-6 text-sm text-slate-400">{{ 'loading.more' | t }}</div>
} @else if (error(); as errKey) {
<div class="mt-6 rounded-lg border border-red-700/60 bg-red-900/30 px-4 py-3 text-sm text-red-200">
{{ errKey | t }}
</div>
} @else if (job(); as j) {
<div class="mt-4 flex flex-wrap items-center gap-2">
<app-provider-badge [provider]="j.provider" />
<h1 class="min-w-0 flex-1 truncate text-xl font-bold text-slate-100" [title]="j.title || j.videoId">
{{ j.title || j.videoId }}
</h1>
</div>
<div class="mt-1 flex flex-wrap items-center gap-2 text-xs text-slate-400">
@if (j.fileSize) {
<span>{{ formatBytes(j.fileSize) }}</span>
}
@if (j.audioOnly) {
<span>{{ 'downloads.audioOnly' | t }}</span>
}
</div>
@if (mediaUrl(); as src) {
<div class="mt-4 overflow-hidden rounded-xl border border-slate-700/60 bg-black">
@if (isAudio()) {
<audio [src]="src" controls preload="metadata" class="w-full"></audio>
} @else {
<video [src]="src" controls preload="metadata" playsinline class="max-h-[70vh] w-full"></video>
}
</div>
}
<div class="mt-4 flex flex-wrap gap-2">
<a [routerLink]="['/watch', j.provider, j.videoId]" queryParamsHandling="merge"
class="rounded-lg border border-slate-600 px-3 py-1.5 text-xs text-slate-200 hover:bg-slate-700 transition">
Watch
</a>
<button type="button" (click)="downloadCopy()" [disabled]="downloading()"
class="rounded-lg bg-emerald-600 px-3 py-1.5 text-xs font-semibold text-white hover:bg-emerald-500 disabled:opacity-50 transition">
{{ 'downloads.getFile' | t }}
</button>
</div>
}
</section>
@@ -0,0 +1,119 @@
import { ChangeDetectionStrategy, Component, OnDestroy, inject, signal } from '@angular/core';
import { CommonModule } from '@angular/common';
import { ActivatedRoute, RouterLink } from '@angular/router';
import { DownloadService, type DownloadJob } from '../../../services/download.service';
import { AuthService } from '../../../services/auth.service';
import { TranslatePipe } from '../../../pipes/translate.pipe';
import { ProviderBadgeComponent } from '../../../app/shared/components/provider-badge/provider-badge.component';
const AUDIO_EXTS = new Set(['mp3', 'm4a', 'aac', 'opus', 'ogg', 'oga', 'wav', 'flac', 'weba']);
@Component({
selector: 'app-download-play',
standalone: true,
imports: [CommonModule, RouterLink, TranslatePipe, ProviderBadgeComponent],
templateUrl: './download-play.component.html',
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class DownloadPlayComponent implements OnDestroy {
private route = inject(ActivatedRoute);
private downloads = inject(DownloadService);
private auth = inject(AuthService);
readonly job = signal<DownloadJob | null>(null);
readonly loading = signal<boolean>(true);
readonly error = signal<string | null>(null);
readonly mediaUrl = signal<string | null>(null);
readonly isAudio = signal<boolean>(false);
readonly downloading = signal<boolean>(false);
private objectUrl: string | null = null;
constructor() {
const id = (this.route.snapshot.paramMap.get('id') || '').trim();
if (!id) {
this.error.set('downloads.loadError');
this.loading.set(false);
return;
}
if (!this.auth.currentUser()) {
this.error.set('downloads.loadError');
this.loading.set(false);
return;
}
this.downloads.getJob(id).subscribe({
next: (job) => {
if (!job || job.state !== 'completed') {
this.error.set('downloads.notReady');
this.loading.set(false);
return;
}
this.job.set(job);
const ext = String((job as any)?.fileExt || '').toLowerCase();
this.isAudio.set(Boolean((job as any)?.audioOnly) || AUDIO_EXTS.has(ext));
this.downloads.downloadFileBlob(job.id).subscribe({
next: ({ blob }) => {
try {
if (this.objectUrl) URL.revokeObjectURL(this.objectUrl);
this.objectUrl = URL.createObjectURL(blob);
this.mediaUrl.set(this.objectUrl);
} catch {
this.error.set('downloads.loadError');
}
this.loading.set(false);
},
error: (err) => {
this.error.set(err?.status === 401 ? 'downloads.fileError' : 'downloads.loadError');
this.loading.set(false);
},
});
},
error: (err) => {
this.error.set(err?.status === 401 ? 'downloads.fileError' : 'downloads.loadError');
this.loading.set(false);
},
});
}
ngOnDestroy(): void {
try {
if (this.objectUrl) URL.revokeObjectURL(this.objectUrl);
} catch {}
this.objectUrl = null;
}
downloadCopy(): void {
const job = this.job();
if (!job || this.downloading()) return;
this.downloading.set(true);
this.downloads.downloadFileBlob(job.id).subscribe({
next: ({ blob, filename }) => {
try {
const a = document.createElement('a');
const url = URL.createObjectURL(blob);
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
setTimeout(() => {
document.body.removeChild(a);
URL.revokeObjectURL(url);
}, 0);
} catch {}
this.downloading.set(false);
},
error: () => {
this.error.set('downloads.fileError');
this.downloading.set(false);
},
});
}
formatBytes(n?: number | null): string {
const x = typeof n === 'number' ? n : 0;
if (x <= 0) return '—';
const units = ['B', 'KB', 'MB', 'GB', 'TB'];
const i = Math.min(units.length - 1, Math.floor(Math.log(x) / Math.log(1024)));
return `${(x / Math.pow(1024, i)).toFixed(1)} ${units[i]}`;
}
}
@@ -0,0 +1,138 @@
<section class="mx-auto max-w-5xl px-4 py-6">
<div class="flex flex-wrap items-center justify-between gap-3">
<h1 class="text-2xl font-bold text-slate-100">{{ 'downloads.title' | t }}</h1>
<button
type="button"
(click)="reload()"
class="rounded-lg border border-slate-700 px-3 py-1.5 text-sm text-slate-200 hover:bg-slate-800 transition">
{{ 'downloads.refresh' | t }}
</button>
</div>
<!-- Storage quota -->
@if (quota(); as q) {
<div class="mt-4 rounded-xl border border-slate-700/60 bg-slate-800/40 p-4">
<div class="flex items-center justify-between text-sm text-slate-300">
<span>{{ 'downloads.quota' | t }}</span>
<span>{{ formatBytes(q.usedBytes) }} / {{ formatBytes(q.quotaBytes) }}</span>
</div>
@if (quotaPct(); as pct) {
<div class="mt-2 h-2 w-full overflow-hidden rounded-full bg-slate-700">
<div class="h-full rounded-full bg-sky-500 transition-all" [style.width.%]="pct"></div>
</div>
}
</div>
}
<!-- State filters -->
<div class="mt-4 flex flex-wrap gap-2">
<button type="button" (click)="setFilter('all')"
class="rounded-full px-3 py-1 text-sm transition"
[ngClass]="stateFilter() === 'all' ? 'bg-sky-600 text-white' : 'bg-slate-800 text-slate-300 hover:bg-slate-700'">
{{ 'downloads.filter.all' | t }}
</button>
<button type="button" (click)="setFilter('active')"
class="rounded-full px-3 py-1 text-sm transition"
[ngClass]="stateFilter() === 'active' ? 'bg-sky-600 text-white' : 'bg-slate-800 text-slate-300 hover:bg-slate-700'">
{{ 'downloads.filter.active' | t }} ({{ activeCount() }})
</button>
<button type="button" (click)="setFilter('completed')"
class="rounded-full px-3 py-1 text-sm transition"
[ngClass]="stateFilter() === 'completed' ? 'bg-sky-600 text-white' : 'bg-slate-800 text-slate-300 hover:bg-slate-700'">
{{ 'downloads.filter.completed' | t }}
</button>
<button type="button" (click)="setFilter('failed')"
class="rounded-full px-3 py-1 text-sm transition"
[ngClass]="stateFilter() === 'failed' ? 'bg-sky-600 text-white' : 'bg-slate-800 text-slate-300 hover:bg-slate-700'">
{{ 'downloads.filter.failed' | t }}
</button>
</div>
<!-- Error -->
@if (error(); as err) {
<div class="mt-4 rounded-lg border border-red-700/60 bg-red-900/30 px-4 py-3 text-sm text-red-200">{{ err }}</div>
}
@if (fileError(); as ferr) {
<div class="mt-4 rounded-lg border border-red-700/60 bg-red-900/30 px-4 py-3 text-sm text-red-200">{{ ferr }}</div>
}
<!-- Loading -->
@if (loading()) {
<div class="mt-6 text-sm text-slate-400">{{ 'loading.more' | t }}</div>
}
<!-- Empty state -->
@if (!loading() && filteredItems().length === 0) {
<div class="mt-6 rounded-xl border border-dashed border-slate-700 p-8 text-center text-sm text-slate-400">
{{ 'downloads.empty' | t }}
</div>
}
<!-- Job list -->
<ul class="mt-4 space-y-3">
@for (job of filteredItems(); track job.id) {
<li class="rounded-xl border border-slate-700/60 bg-slate-800/40 p-4">
<div class="flex flex-wrap items-start justify-between gap-3">
<div class="min-w-0 flex-1">
<div class="flex items-center gap-2">
<app-provider-badge [provider]="job.provider" />
<a [routerLink]="watchLink(job)" queryParamsHandling="merge"
class="truncate text-sm font-semibold text-slate-100 hover:text-sky-400"
[title]="job.title || job.videoId">
{{ job.title || job.videoId }}
</a>
</div>
<div class="mt-1 flex flex-wrap items-center gap-2 text-xs text-slate-400">
<span class="rounded px-1.5 py-0.5 font-medium"
[ngClass]="{
'bg-sky-900/60 text-sky-300': isActive(job),
'bg-emerald-900/60 text-emerald-300': job.state === 'completed',
'bg-red-900/60 text-red-300': job.state === 'failed',
'bg-amber-900/60 text-amber-300': job.state === 'interrupted'
}">
{{ 'downloads.state.' + job.state | t }}
</span>
@if (job.audioOnly) {
<span>{{ 'downloads.audioOnly' | t }}</span>
}
@if (job.fileSize) {
<span>{{ formatBytes(job.fileSize) }}</span>
}
@if (job.error) {
<span class="truncate text-red-300" [title]="job.error">{{ job.error }}</span>
}
</div>
@if (isActive(job)) {
<div class="mt-2 h-1.5 w-full overflow-hidden rounded-full bg-slate-700">
<div class="h-full rounded-full bg-sky-500 transition-all" [style.width.%]="job.progress"></div>
</div>
}
</div>
<div class="flex flex-shrink-0 items-center gap-2">
@if (job.state === 'completed') {
<a [routerLink]="playLink(job)"
class="rounded-lg bg-sky-600 px-3 py-1.5 text-xs font-semibold text-white hover:bg-sky-500 transition">
{{ 'downloads.play' | t }}
</a>
<button type="button" (click)="getFile(job)" [disabled]="fetchingFileIds().has(job.id)"
class="rounded-lg bg-emerald-600 px-3 py-1.5 text-xs font-semibold text-white hover:bg-emerald-500 disabled:opacity-50 transition">
{{ 'downloads.getFile' | t }}
</button>
}
@if (job.state === 'failed' || job.state === 'interrupted') {
<button type="button" (click)="retry(job)" [disabled]="busyIds().has(job.id)"
class="rounded-lg bg-sky-600 px-3 py-1.5 text-xs font-semibold text-white hover:bg-sky-500 disabled:opacity-50 transition">
{{ 'downloads.retry' | t }}
</button>
}
<button type="button" (click)="cancel(job)" [disabled]="busyIds().has(job.id)"
class="rounded-lg border border-slate-600 px-3 py-1.5 text-xs text-slate-300 hover:bg-slate-700 disabled:opacity-50 transition">
{{ 'btn.remove' | t }}
</button>
</div>
</div>
</li>
}
</ul>
</section>
@@ -0,0 +1,208 @@
import { ChangeDetectionStrategy, Component, computed, inject, signal } from '@angular/core';
import { CommonModule } from '@angular/common';
import { RouterLink } from '@angular/router';
import { DownloadService, type DownloadJob, type DownloadQuota } from '../../../services/download.service';
import { AuthService } from '../../../services/auth.service';
import { TranslatePipe } from '../../../pipes/translate.pipe';
import { ProviderBadgeComponent } from '../../../app/shared/components/provider-badge/provider-badge.component';
type StateFilter = 'all' | 'active' | 'completed' | 'failed';
const ACTIVE_STATES = new Set(['queued', 'running', 'merging']);
@Component({
selector: 'app-library-downloads',
standalone: true,
imports: [CommonModule, RouterLink, TranslatePipe, ProviderBadgeComponent],
templateUrl: './downloads.component.html',
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class DownloadsComponent {
private downloads = inject(DownloadService);
private auth = inject(AuthService);
readonly loading = signal<boolean>(true);
readonly error = signal<string | null>(null);
readonly items = signal<DownloadJob[]>([]);
readonly quota = signal<DownloadQuota | null>(null);
readonly stateFilter = signal<StateFilter>('all');
readonly busyIds = signal<Set<string>>(new Set());
readonly fileError = signal<string | null>(null);
readonly fetchingFileIds = signal<Set<string>>(new Set());
readonly filteredItems = computed(() => {
const filter = this.stateFilter();
const list = this.items();
if (filter === 'all') return list;
if (filter === 'active') return list.filter((j) => ACTIVE_STATES.has(j.state));
return list.filter((j) => j.state === filter);
});
readonly activeCount = computed(() => this.items().filter((j) => ACTIVE_STATES.has(j.state)).length);
readonly quotaPct = computed(() => {
const q = this.quota();
if (!q || !q.quotaBytes) return null;
return Math.min(100, Math.round((q.usedBytes / q.quotaBytes) * 100));
});
// Poller keeps active job progress fresh while the page is open
private poller: any = null;
constructor() {
this.reload();
}
isLoggedIn(): boolean {
return this.auth.currentUser() !== null;
}
reload(): void {
this.loading.set(true);
this.error.set(null);
this.downloads.listJobs({ limit: 100 }).subscribe({
next: (res) => {
this.items.set(res.items || []);
this.quota.set(res.quota ?? null);
this.loading.set(false);
this.syncPoller();
},
error: (err) => {
if (err?.status === 401) {
this.error.set('Connectez-vous pour voir vos téléchargements.');
} else {
this.error.set('Impossible de charger la file de téléchargement.');
}
this.loading.set(false);
},
});
}
setFilter(f: StateFilter): void {
this.stateFilter.set(f);
}
isActive(job: DownloadJob): boolean {
return ACTIVE_STATES.has(job.state);
}
retry(job: DownloadJob): void {
if (this.busyIds().has(job.id)) return;
this.markBusy(job.id, true);
this.downloads.retryJob(job.id).subscribe({
next: () => {
this.markBusy(job.id, false);
this.reload();
},
error: () => {
this.markBusy(job.id, false);
this.error.set('Nouvel essai impossible pour le moment.');
},
});
}
cancel(job: DownloadJob): void {
if (this.busyIds().has(job.id)) return;
this.markBusy(job.id, true);
this.downloads.cancelJob(job.id).subscribe({
next: () => {
this.markBusy(job.id, false);
this.items.update((list) => list.filter((j) => j.id !== job.id));
},
error: () => {
this.markBusy(job.id, false);
this.error.set('Suppression impossible pour le moment.');
},
});
}
// Deep link back to the watch page for a job's video
watchLink(job: DownloadJob): any[] {
return ['/watch', job.provider, job.videoId];
}
// Deep link to the local playback page for a completed job
playLink(job: DownloadJob): any[] {
return ['/library/downloads', job.id];
}
/**
* "Obtenir le fichier" via requête authentifiée (Bearer) puis blob :
* ne dépend pas des cookies de session (liens <a href> directs -> 401).
*/
getFile(job: DownloadJob): void {
if (this.fetchingFileIds().has(job.id)) return;
this.fileError.set(null);
this.fetchingFileIds.update((set) => new Set(set).add(job.id));
this.downloads.downloadFileBlob(job.id).subscribe({
next: ({ blob, filename }) => {
try {
const a = document.createElement('a');
const objUrl = URL.createObjectURL(blob);
a.href = objUrl;
a.download = filename;
document.body.appendChild(a);
a.click();
setTimeout(() => {
document.body.removeChild(a);
URL.revokeObjectURL(objUrl);
}, 0);
} catch {}
this.fetchingFileIds.update((set) => {
const next = new Set(set);
next.delete(job.id);
return next;
});
},
error: (err) => {
this.fetchingFileIds.update((set) => {
const next = new Set(set);
next.delete(job.id);
return next;
});
this.fileError.set(
err?.status === 401
? 'Session expirée. Reconnectez-vous puis réessayez.'
: 'Fichier inaccessible pour le moment.'
);
},
});
}
formatBytes(n?: number | null): string {
const x = typeof n === 'number' ? n : 0;
if (x <= 0) return '—';
const units = ['B', 'KB', 'MB', 'GB', 'TB'];
const i = Math.min(units.length - 1, Math.floor(Math.log(x) / Math.log(1024)));
return `${(x / Math.pow(1024, i)).toFixed(1)} ${units[i]}`;
}
private markBusy(id: string, busy: boolean): void {
this.busyIds.update((set) => {
const next = new Set(set);
if (busy) next.add(id);
else next.delete(id);
return next;
});
}
private syncPoller(): void {
const hasActive = this.items().some((j) => ACTIVE_STATES.has(j.state));
if (hasActive && !this.poller) {
this.poller = setInterval(() => this.refreshActive(), 3000);
} else if (!hasActive && this.poller) {
clearInterval(this.poller);
this.poller = null;
}
}
private refreshActive(): void {
this.downloads.listJobs({ limit: 100 }).subscribe({
next: (res) => {
this.items.set(res.items || []);
this.quota.set(res.quota ?? null);
this.syncPoller();
},
error: () => {},
});
}
}
@@ -136,6 +136,10 @@ export class PlaylistsComponent {
savePlaylist() {
if (!this.formTitle().trim()) return;
if (!this.currentUser()) {
this.error.set('Connectez-vous pour créer une playlist.');
return;
}
this.busySave.set(true);
const body = {
title: this.formTitle().trim(),
@@ -149,7 +153,13 @@ export class PlaylistsComponent {
: this.http.post(`${this.apiBase()}/playlists`, body, { withCredentials: true });
req.subscribe({
next: () => { this.busySave.set(false); this.modalOpen.set(false); this.reload(this.searchQuery().trim() || undefined); },
error: (err) => { this.error.set(err?.error?.error || err?.message || 'Échec de l’enregistrement'); this.busySave.set(false); }
error: (err) => {
this.error.set(
err?.status === 401 ? 'Session expirée. Reconnectez-vous puis réessayez.'
: (err?.error?.error || err?.message || 'Échec de l’enregistrement')
);
this.busySave.set(false);
}
});
}
@@ -1,6 +1,146 @@
<div class="container mx-auto p-4 sm:p-6">
<h2 class="text-3xl font-bold mb-6 text-slate-100 border-l-4 border-red-500 pl-4">Abonnements</h2>
<div class="bg-slate-800/50 rounded-lg p-6 border border-slate-700">
<p class="text-slate-300">Section pour suivre vos abonnements (à implémenter). Vous verrez ici les dernières vidéos des chaînes suivies sur chaque provider.</p>
<div class="mb-6 flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
<div>
<h2 class="text-3xl font-bold text-slate-100">Abonnements</h2>
<p class="text-sm text-slate-400">Gérez vos chaînes suivies multi-fournisseurs</p>
</div>
<div class="flex flex-wrap gap-2">
<button
class="rounded-md border border-slate-700 bg-slate-800 px-3 py-1.5 text-sm text-slate-200 transition hover:border-slate-500 hover:text-white"
(click)="refresh()"
type="button"
>
Rafraîchir
</button>
</div>
</div>
<div class="grid gap-4 lg:grid-cols-[minmax(0,1fr)_280px]">
<div class="bg-slate-900/60 rounded-xl border border-slate-800 p-4 shadow-lg shadow-black/30">
<div class="flex flex-wrap items-center gap-2 border-b border-slate-800 pb-4">
<span class="text-xs uppercase tracking-wide text-slate-400">Filtres</span>
<div class="flex flex-wrap gap-2">
<button
*ngFor="let filter of filters"
type="button"
(click)="setFilter(filter.id)"
class="rounded-full border px-3 py-1 text-xs font-semibold transition"
[class.bg-red-500/20]="activeFilter() === filter.id"
[class.text-red-200]="activeFilter() === filter.id"
[class.border-red-500/40]="activeFilter() === filter.id"
[class.border-slate-700]="activeFilter() !== filter.id"
[class.text-slate-300]="activeFilter() !== filter.id"
[class.bg-slate-800/70]="activeFilter() !== filter.id"
>
{{ filter.label }}
</button>
</div>
</div>
<div class="flex items-center justify-between py-4">
<div class="flex items-center gap-2 text-sm text-slate-400">
<span>Trier par :</span>
<button
*ngFor="let sort of sorts"
type="button"
(click)="setSort(sort.key)"
class="rounded-md px-3 py-1 text-xs transition"
[class.bg-emerald-500/20]="activeSort() === sort.key"
[class.text-emerald-200]="activeSort() === sort.key"
[class.bg-slate-800/60]="activeSort() !== sort.key"
[class.text-slate-300]="activeSort() !== sort.key"
>
{{ sort.label }}
</button>
</div>
</div>
<ng-container *ngIf="filteredItems().length > 0; else emptyState">
<div class="divide-y divide-slate-800">
<div
*ngFor="let item of filteredItems()"
class="flex flex-col gap-4 py-4 md:flex-row md:items-center md:justify-between"
>
<ng-container *ngIf="item.channel as channel; else subscriptionFallback">
<div class="flex items-center gap-4">
<img
[src]="channel.avatarUrl || 'https://placehold.co/80x80/png'"
[alt]="channel.title || channel.handle || 'Avatar'"
class="h-16 w-16 rounded-full border border-slate-800 object-cover"
/>
<div class="space-y-1">
<div class="flex flex-wrap items-center gap-2">
<h3 class="text-lg font-semibold text-slate-100">
{{ channel.title || channel.handle || 'Chaîne inconnue' }}
</h3>
<app-provider-badge [provider]="channel.provider"></app-provider-badge>
<span *ngIf="channel.verified" class="text-xs text-emerald-300">✓ Vérifiée</span>
</div>
<div class="flex flex-wrap gap-3 text-sm text-slate-400">
<span *ngIf="channel.handle">{{ channel.handle }}</span>
<span *ngIf="channel.subsCount != null">
{{ channel.subsCount | number }} abonnés
</span>
<span class="text-slate-500">
Abonné depuis {{ item.createdAt | date:'mediumDate' }}
</span>
</div>
<a
*ngIf="channel.url"
class="text-xs text-blue-300 hover:text-blue-200"
[href]="channel.url"
target="_blank"
rel="noopener"
>
Voir la chaîne externe
</a>
</div>
</div>
<div class="flex flex-col items-stretch gap-2 text-sm md:text-right">
<app-subscribe-button
[provider]="channel.provider"
[externalId]="channel.externalId"
[channelMeta]="channel"
(unsubscribed)="refresh()"
(subscribed)="refresh()"
></app-subscribe-button>
<button
class="rounded-md border border-slate-700 bg-slate-800/60 px-3 py-1 text-sm text-slate-300 transition hover:border-slate-500 hover:text-white"
type="button"
(click)="openChannel(item)"
>
Voir la chaîne
</button>
</div>
</ng-container>
<ng-template #subscriptionFallback>
<div class="flex-1 text-sm text-slate-400">
<p class="font-medium text-slate-200">Chaîne non disponible</p>
<p class="text-xs">Essayez de rafraîchir l'abonnement plus tard.</p>
</div>
</ng-template>
</div>
</div>
</ng-container>
<ng-template #emptyState>
<div class="flex flex-col items-center justify-center gap-3 rounded-lg border border-dashed border-slate-700 bg-slate-900/60 p-10 text-center">
<h3 class="text-lg font-semibold text-slate-100">Aucune chaîne suivie</h3>
<p class="text-sm text-slate-400">Explorez la page Trending ou effectuez une recherche pour commencer à vous abonner.</p>
</div>
</ng-template>
</div>
<aside class="rounded-xl border border-slate-800 bg-slate-900/50 p-4 shadow-lg shadow-black/30">
<h3 class="text-lg font-semibold text-slate-100">Astuce</h3>
<p class="mt-2 text-sm text-slate-400">
Utilisez le raccourci <code class="rounded bg-slate-800 px-2 py-0.5 text-xs">S</code> sur une carte vidéo pour activer
rapidement le bouton d'abonnement. Les abonnements sont synchronisés sur tous vos appareils.
</p>
<p class="mt-4 text-sm text-slate-500">
Cache TTL: {{ hasCache() ? '6h' : '—' }}.
</p>
</aside>
</div>
</div>
@@ -1,11 +1,101 @@
import { ChangeDetectionStrategy, Component } from '@angular/core';
import { ChangeDetectionStrategy, Component, computed, inject, signal } from '@angular/core';
import { CommonModule } from '@angular/common';
import { DatePipe, DecimalPipe } from '@angular/common';
import { Router } from '@angular/router';
import { SubscriptionsService, type SubscriptionItem } from '../../../services/subscriptions.service';
import type { ProviderId } from '../../../app/shared/models/channel.model';
import { ProviderBadgeComponent } from '../../../app/shared/components/provider-badge/provider-badge.component';
import { SubscribeButtonComponent } from '../../../app/shared/components/subscribe-button/subscribe-button.component';
interface FilterChip {
id: 'all' | ProviderId;
label: string;
}
type SortKey = 'alpha' | 'recent' | 'subs';
@Component({
selector: 'app-library-subscriptions',
standalone: true,
imports: [CommonModule],
imports: [CommonModule, DatePipe, DecimalPipe, ProviderBadgeComponent, SubscribeButtonComponent],
templateUrl: './subscriptions.component.html',
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class SubscriptionsComponent { }
export class SubscriptionsComponent {
private subs = inject(SubscriptionsService);
private router = inject(Router);
filters: FilterChip[] = [
{ id: 'all', label: 'Tous' },
{ id: 'yt', label: 'YouTube' },
{ id: 'dm', label: 'Dailymotion' },
{ id: 'tw', label: 'Twitch' },
{ id: 'pt', label: 'PeerTube' },
{ id: 'od', label: 'Odysee' },
{ id: 'ru', label: 'Rumble' },
];
sorts: { key: SortKey; label: string }[] = [
{ key: 'alpha', label: 'A → Z' },
{ key: 'recent', label: 'Récents' },
{ key: 'subs', label: 'Abonnés' },
];
activeFilter = signal<FilterChip['id']>('all');
activeSort = signal<SortKey>('alpha');
readonly items = computed(() => this.subs.items());
readonly filteredItems = computed(() => this.applyFilters(this.items()));
readonly hasCache = computed(() => this.items().length > 0);
constructor() {
this.refresh();
}
refresh(): void {
this.subs.list().subscribe();
}
setFilter(id: FilterChip['id']): void {
this.activeFilter.set(id);
}
setSort(key: SortKey): void {
this.activeSort.set(key);
}
openChannel(item: SubscriptionItem): void {
if (!item?.channel?.provider || !item?.channel?.externalId) {
return;
}
this.router.navigate(['/channel', item.channel.provider, item.channel.externalId]);
}
private applyFilters(list: SubscriptionItem[]): SubscriptionItem[] {
const filter = this.activeFilter();
const sort = this.activeSort();
let filtered = list;
if (filter !== 'all') {
filtered = filtered.filter((item) => item.channel?.provider === filter);
}
switch (sort) {
case 'recent':
filtered = [...filtered].sort((a, b) => (b.createdAt || 0) - (a.createdAt || 0));
break;
case 'subs':
filtered = [...filtered].sort(
(a, b) => (b.channel?.subsCount ?? 0) - (a.channel?.subsCount ?? 0)
);
break;
case 'alpha':
default:
filtered = [...filtered].sort((a, b) => {
const ta = (a.channel?.title || a.channel?.handle || '').toLowerCase();
const tb = (b.channel?.title || b.channel?.handle || '').toLowerCase();
return ta.localeCompare(tb);
});
break;
}
return filtered;
}
}
@@ -1,22 +1,22 @@
<div class="fixed inset-0 z-[9999] bg-black/60 flex justify-center items-center" (click)="close.emit()">
<div class="fixed inset-0 z-[9999] bg-black/60" (click)="close.emit()">
<div class="fixed inset-0 grid place-items-center">
<div #dialog
class="w-[90%] max-w-lg bg-slate-900 rounded-xl shadow-2xl border border-slate-700"
role="dialog"
aria-modal="true"
aria-labelledby="providersTitle"
(click)="$event.stopPropagation()"
tabindex="0"
<div #dialog
class="w-[90%] max-w-lg bg-slate-900 rounded-xl shadow-2xl border border-slate-700"
role="dialog"
aria-modal="true"
aria-labelledby="providersTitle"
(click)="$event.stopPropagation()"
tabindex="0"
(keydown)="onDialogKeydown($event)">
<div class="p-4 border-b border-slate-700 flex items-center justify-between">
<h2 id="providersTitle" class="text-slate-100 font-semibold">Providers</h2>
<button (click)="close.emit()" class="px-2 py-1 rounded bg-slate-700 hover:bg-slate-600 text-slate-100">Esc</button>
<button type="button" (click)="close.emit()" class="px-2 py-1 rounded bg-slate-700 hover:bg-slate-600 text-slate-100" aria-label="Fermer (Esc)">Esc</button>
</div>
<div class="p-4 space-y-3">
<input type="text" [ngModel]="filter()" (ngModelChange)="filter.set($event)" placeholder="filter providers…" class="w-full px-3 py-2 rounded bg-slate-800 text-slate-100 placeholder-slate-400 border border-slate-700"/>
<input type="text" [ngModel]="filter()" (ngModelChange)="filter.set($event)" placeholder="filter providers…" aria-label="Filtrer les fournisseurs" class="w-full px-3 py-2 rounded bg-slate-800 text-slate-100 placeholder-slate-400 border border-slate-700"/>
<div class="flex items-center gap-2">
<label class="inline-flex items-center gap-2 text-slate-200">
<input type="checkbox" [checked]="all()" (change)="toggleAll()"/>
<input type="checkbox" [checked]="allMode()" (change)="toggleAllMode()"/>
<span>All</span>
</label>
</div>
@@ -33,8 +33,9 @@
</label>
</div>
<div class="p-4 border-t border-slate-700 flex items-center justify-end gap-2">
<button (click)="close.emit()" class="px-3 py-2 rounded bg-slate-700 hover:bg-slate-600 text-slate-100">Cancel</button>
<button (click)="onApply()" class="px-3 py-2 rounded bg-red-600 hover:bg-red-500 text-white">Apply filters</button>
<button type="button" (click)="close.emit()" class="px-3 py-2 rounded bg-slate-700 hover:bg-slate-600 text-slate-100">Cancel</button>
<button type="button" (click)="onApply()" class="px-3 py-2 rounded bg-red-600 hover:bg-red-500 text-white">Apply filters</button>
</div>
</div>
</div>
</div>
@@ -1,7 +1,7 @@
import { Component, EventEmitter, Output, Input, signal, computed, ElementRef, ViewChild, AfterViewInit } from '@angular/core';
import { Component, EventEmitter, Output, Input, signal, computed, ElementRef, ViewChild, AfterViewInit, OnDestroy } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { ProviderId, PROVIDERS } from '../../app/core/providers/provider-registry';
import { ProviderId, PROVIDERS, PROVIDER_MAP } from '../../app/core/providers/provider-registry';
@Component({
selector: 'app-provider-picker',
@@ -9,45 +9,66 @@ import { ProviderId, PROVIDERS } from '../../app/core/providers/provider-registr
imports: [CommonModule, FormsModule],
templateUrl: './provider-picker.component.html'
})
export class ProviderPickerComponent implements AfterViewInit {
export class ProviderPickerComponent implements AfterViewInit, OnDestroy {
@Input() open = false;
@Input() selected: ProviderId[] | 'all' = 'all';
// Incoming selection: 'all' or a list of provider ids
@Input() set selected(value: ProviderId[] | 'all') {
// Reset the draft each time the picker opens with a new incoming selection
this._draft.set(value === 'all' ? [] : Array.isArray(value) ? [...value] : []);
this._allMode.set(value === 'all');
}
@Output() close = new EventEmitter<void>();
@Output() apply = new EventEmitter<{ selection: ProviderId[] | 'all'; rememberDefault: boolean }>();
filter = signal('');
rememberDefault = signal(false);
// Draft state: working copy while the modal is open
private _draft = signal<ProviderId[]>([]);
private _allMode = signal(true);
readonly draft = computed(() => this._draft());
readonly allMode = computed(() => this._allMode() && this._draft().length === 0);
@ViewChild('dialog', { static: false }) dialogRef?: ElementRef<HTMLDivElement>;
readonly all = computed(() => this.selected === 'all');
readonly providers = computed(() => {
const q = this.filter().toLowerCase();
return PROVIDERS.filter(p => !q || p.displayName.toLowerCase().includes(q) || p.shortLabel.toLowerCase().includes(q));
});
toggleAll() {
this.apply.emit({ selection: 'all', rememberDefault: this.rememberDefault() });
toggleAllMode() {
this._allMode.set(true);
this._draft.set([]);
}
isChecked(id: ProviderId): boolean {
if (this.selected === 'all') return true;
return Array.isArray(this.selected) && this.selected.includes(id);
if (this.allMode()) return true;
return this._draft().includes(id);
}
toggle(id: ProviderId) {
if (this.selected === 'all') {
this.apply.emit({ selection: [id], rememberDefault: this.rememberDefault() });
if (this.allMode()) {
// Leaving All mode by unchecking one provider: start from every provider
// then remove the unchecked one ("all except this one")
const set = new Set(PROVIDERS.map(p => p.id));
set.delete(id);
this._draft.set(Array.from(set));
this._allMode.set(false);
return;
}
const set = new Set(this.selected || []);
if (set.has(id)) set.delete(id); else set.add(id);
const next = Array.from(set) as ProviderId[];
this.selected = next;
const set = new Set(this._draft());
if (set.has(id)) {
set.delete(id);
} else {
set.add(id);
}
this._draft.set(Array.from(set));
this._allMode.set(false);
}
onApply() {
this.apply.emit({ selection: this.selected, rememberDefault: this.rememberDefault() });
const selection: ProviderId[] | 'all' = this.allMode() ? 'all' : this._draft();
this.apply.emit({ selection, rememberDefault: this.rememberDefault() });
}
ngAfterViewInit() {
@@ -57,8 +78,17 @@ export class ProviderPickerComponent implements AfterViewInit {
});
}
// Basic focus trap within the dialog
ngOnDestroy() {
try { document.body.style.overflow = ''; } catch {}
}
// Focus trap within the dialog (Tab / Shift+Tab)
onDialogKeydown(ev: KeyboardEvent) {
if (ev.key === 'Escape') {
ev.preventDefault();
this.close.emit();
return;
}
if (ev.key !== 'Tab') return;
const root = this.dialogRef?.nativeElement;
if (!root) return;
@@ -71,7 +101,7 @@ export class ProviderPickerComponent implements AfterViewInit {
if (!ev.shiftKey && active === last) {
ev.preventDefault();
first.focus();
} else if (ev.shiftKey && active === first) {
} else if (ev.shiftKey && (active === first || active === root)) {
ev.preventDefault();
last.focus();
}
+21 -12
View File
@@ -1,4 +1,4 @@
<form class="w-full relative" novalidate (ngSubmit)="onSubmit($event)" (submit)="onSubmit($event)" (keydown)="handleKeydown($event)" role="search" aria-label="Recherche">
<form class="w-full relative" novalidate (submit)="onSubmit($event)" role="search" aria-label="Recherche">
<!-- Input -->
<div class="flex items-center gap-2 rounded-lg ring-1 ring-slate-600/50 bg-slate-800/70 px-3 py-2">
<!-- Provider chips -->
@@ -9,6 +9,7 @@
'bg-slate-200 text-slate-800 border-slate-300': providerMode()==='all',
'bg-slate-700 text-slate-100 border-slate-600': providerMode()!=='all'
}"
[attr.aria-pressed]="providerMode()==='all'"
title="Tous les fournisseurs">
All
</button>
@@ -19,20 +20,25 @@
'bg-slate-700 text-slate-100 border-slate-600': !(selected().includes(c.id)),
'bg-slate-200 text-slate-800 border-slate-300': selected().includes(c.id)
}"
[title]="'Inclure ' + c.label">
[attr.aria-pressed]="selected().includes(c.id)"
[title]="'Inclure ' + c.label + ' (Alt+' + (i + 1) + ')'">
<span [class]="c.colorClass">{{ c.label }}</span>
</button>
</ng-container>
<!-- Text input -->
<!-- Text input: combobox pattern for the inline '@' autocomplete -->
<input #qInput type="search"
class="flex-1 bg-transparent outline-none text-slate-100 placeholder-slate-400 px-2"
[placeholder]="placeholder"
[ngModel]="query()" (ngModelChange)="queryUpdate($event)" (keydown)="handleKeydown($event)" (keydown.enter)="onSubmit($event)" name="q"
autocomplete="off" aria-label="Rechercher des vidéos"/>
autocomplete="off" aria-label="Rechercher des vidéos"
role="combobox" aria-expanded="false" aria-autocomplete="list"
[attr.aria-expanded]="atOpen()"
[attr.aria-controls]="atOpen() ? 'atOptionsList' : null"
[attr.aria-activedescendant]="atOpen() && atOptions().length > 0 ? 'atOption-' + atIndex() : null"/>
<!-- Actions -->
<button type="button" (click)="openPicker()" class="text-xs px-2 py-1 rounded bg-slate-700 hover:bg-slate-600" aria-label="Choisir des fournisseurs">@</button>
<button type="button" (click)="openPicker()" class="text-xs px-2 py-1 rounded bg-slate-700 hover:bg-slate-600" aria-label="Choisir des fournisseurs (@)">@</button>
<button type="submit" class="ml-1 px-3 py-1 rounded bg-red-600 hover:bg-red-500 text-white"
(click)="onSubmit($event)"
[attr.title]="(query() || '').trim().length < 2 ? 'Tapez au moins 2 caractères' : null">
@@ -51,10 +57,13 @@
<div *ngIf="atOpen() && atOptions().length > 0"
class="absolute z-50 mt-1 left-3 right-3 rounded-lg shadow-xl overflow-hidden"
style="top: 100%">
<ul class="max-h-64 overflow-y-auto"
<ul id="atOptionsList" role="listbox" aria-label="Fournisseurs"
class="max-h-64 overflow-y-auto"
[ngClass]="{ 'bg-slate-900 border border-slate-700 text-slate-200': true }">
<li *ngFor="let opt of atOptions(); let i = index">
<button type="button" (click)="toggleChip(opt.id); atOpen.set(false)"
<li *ngFor="let opt of atOptions(); let i = index" role="option"
[attr.aria-selected]="i === atIndex()"
[id]="'atOption-' + i">
<button type="button" (click)="applyAtOption(opt.id)"
class="w-full text-left px-3 py-2 text-sm"
[ngClass]="{
'bg-slate-800': i === atIndex(),
@@ -67,11 +76,11 @@
</div>
<!-- Quick Menu (Ctrl/Cmd+K) -->
<div *ngIf="quickOpen()" style="position: fixed; top: 0; left: 0; right: 0; bottom: 0; z-index: 9999; background-color: rgba(0, 0, 0, 0.6);" role="dialog" aria-modal="true" (click)="closeQuickMenu()">
<div style="position: absolute; top: 50%; left: 50%; transform: translate(-50%, -0%); width: 90%; max-width: 56rem; max-height: 85vh; border-radius: 0.75rem; box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.25); overflow: hidden; background-color: #0f172a; border: 1px solid #334155; display: flex; flex-direction: column;" role="document" aria-labelledby="quickMenuTitle" (click)="$event.stopPropagation()" tabindex="0">
<div *ngIf="quickOpen()" style="position: fixed; top: 0; left: 0; right: 0; bottom: 0; z-index: 9999; background-color: rgba(0, 0, 0, 0.6);" role="presentation" (click)="closeQuickMenu()">
<div #quickDialog style="position: absolute; top: 50%; left: 50%; transform: translate(-50%, -0%); width: 90%; max-width: 56rem; max-height: 85vh; border-radius: 0.75rem; box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.25); overflow: hidden; background-color: #0f172a; border: 1px solid #334155; display: flex; flex-direction: column;" role="dialog" aria-modal="true" aria-labelledby="quickMenuTitle" (click)="$event.stopPropagation()" (keydown)="onQuickMenuKeydown($event)">
<div class="p-3 border-b border-slate-700 flex items-center justify-between">
<h2 id="quickMenuTitle" class="text-slate-100 font-semibold">Quick Menu</h2>
<button (click)="closeQuickMenu()" class="px-2 py-1 rounded bg-slate-700 hover:bg-slate-600 text-slate-100" autofocus>Esc</button>
<button type="button" (click)="closeQuickMenu()" class="px-2 py-1 rounded bg-slate-700 hover:bg-slate-600 text-slate-100" aria-label="Fermer le menu (Esc)">Esc</button>
</div>
<div class="grid grid-cols-2 gap-0 overflow-y-auto">
<!-- History column -->
@@ -80,7 +89,7 @@
<ul class="space-y-1 max-h-80 overflow-y-auto">
<li *ngFor="let it of recentSearches()">
<button type="button" class="w-full text-left px-2 py-1 rounded hover:bg-slate-800 text-slate-200"
(click)="queryUpdate(it.query || '')">{{ it.query }}</button>
(click)="queryUpdate(it.query || ''); closeQuickMenu()">{{ it.query }}</button>
</li>
<li *ngIf="(recentSearches()?.length || 0) === 0" class="text-slate-500 text-sm">No recent searches.</li>
</ul>
+129 -83
View File
@@ -7,17 +7,20 @@ import { ProviderPickerComponent } from './provider-picker.component';
import { SearchService } from '../../app/search/search.service';
import { UserService, type UserPreferences } from '../../services/user.service';
import { HistoryService, type SearchHistoryItem } from '../../services/history.service';
import { TelemetryService } from '../../services/telemetry.service';
import { Subject } from 'rxjs';
import { debounceTime, distinctUntilChanged } from 'rxjs/operators';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
// No overlays; we render fixed-position modals with high z-index
const AT_QUERY_RE = /(^|\s)@([a-z]{1,24})$/i;
@Component({
selector: 'app-search-box',
standalone: true,
imports: [
CommonModule,
FormsModule,
CommonModule,
FormsModule,
ProviderPickerComponent,
HttpClientModule // Nécessaire pour SearchService
],
@@ -32,7 +35,9 @@ export class SearchBoxComponent {
private search = inject(SearchService);
private users = inject(UserService);
private history = inject(HistoryService);
private telemetry = inject(TelemetryService);
@ViewChild('qInput', { static: false }) qInputRef?: ElementRef<HTMLInputElement>;
@ViewChild('quickDialog', { static: false }) quickDialogRef?: ElementRef<HTMLDivElement>;
// Input/Output bindings
@Input() placeholder: string = 'Search videos…';
@@ -46,25 +51,24 @@ export class SearchBoxComponent {
selected = signal<ProviderId[]>([]);
pickerOpen = signal(false);
suggestionsOpen = signal(false);
// Inline @autocomplete state
// Inline @autocomplete state (atOpen is derived from the query, atOptions is pure)
atOpen = signal(false);
atIndex = signal(0);
atOptions = computed(() => {
const q = this.query();
const m = /(^|\s)@([a-z]{1,24})$/i.exec(q);
const m = AT_QUERY_RE.exec(this.query());
if (!m) return [] as { id: ProviderId; label: string }[];
const term = (m[2] || '').toLowerCase();
const opts = PROVIDERS.filter(p =>
p.shortLabel.toLowerCase().includes(term) || p.displayName.toLowerCase().includes(term)
).map(p => ({ id: p.id, label: p.displayName }));
this.atOpen.set(opts.length > 0);
if (this.atIndex() >= opts.length) this.atIndex.set(0);
return opts as any;
return PROVIDERS
.filter(p => p.shortLabel.toLowerCase().includes(term) || p.displayName.toLowerCase().includes(term))
.map(p => ({ id: p.id, label: p.displayName }));
});
// Quick menu modal (Ctrl/Cmd+K)
quickOpen = signal(false);
recentSearches = signal<SearchHistoryItem[]>([]);
// Element to restore focus to when a modal closes (a11y)
private lastFocused: HTMLElement | null = null;
readonly chips = PROVIDERS.map(p => ({ id: p.id, label: p.shortLabel, colorClass: p.colorClass }));
// Derived state
@@ -100,39 +104,68 @@ export class SearchBoxComponent {
hydratedFromService = true;
});
// Apply default providers from user preferences if none explicitly set
// Apply default providers from user preferences if none explicitly set (Step 9 client hook)
effect(() => {
const prefs: UserPreferences | null = this.users.preferences();
if (!prefs) return;
const list = (prefs as any).defaultProviders as string[] | undefined;
if (Array.isArray(list) && list.length > 0) {
// Keep only known provider ids
const valid = list.filter(id => PROVIDERS.some(p => p.id === (id as ProviderId))) as ProviderId[];
if (valid.length === 0) return;
// Only apply if current mode is 'all' and nothing selected
if (this.providerMode() === 'all' && this.selected().length === 0) {
// Cast to ProviderId[] while trusting server-provided list
this.providerMode.set('custom');
this.selected.set(list as any);
this.selected.set(valid);
}
}
});
// Focus the first focusable element of the quick menu dialog when it opens (a11y)
effect(() => {
if (this.quickOpen()) {
setTimeout(() => {
try {
const root = this.quickDialogRef?.nativeElement;
const first = root?.querySelector<HTMLElement>('button');
(first || root)?.focus();
} catch {}
}, 0);
}
});
// Lock body scroll when a modal is open (a11y UX)
effect(() => {
const open = this.quickOpen() || this.pickerOpen();
try {
document.body.style.overflow = open ? 'hidden' : '';
} catch {}
});
}
// Update query and handle inline '@provider' prefix to open filtered picker
// Update query and refresh inline '@provider' autocomplete state
queryUpdate(value: string) {
try { console.log('[SearchBox] queryUpdate', { value }); } catch {}
this.query.set(value);
// Keep SearchService in sync with current text to avoid stale overwrite
try { this.search.q$.next(value); } catch {}
this._qChanges.next(value);
const m = /(^|\s)@([a-z]{1,12})$/i.exec(value);
const hasOptions = this.atOptions().length > 0;
this.atOpen.set(AT_QUERY_RE.test(value) && hasOptions);
if (this.atIndex() >= this.atOptions().length) this.atIndex.set(0);
}
// Pick an option from the inline '@' popover: strip the '@term' and toggle the chip
applyAtOption(id: ProviderId) {
const q = this.query();
const m = AT_QUERY_RE.exec(q);
if (m) {
const term = m[2] || '';
this.pickerOpen.set(true);
const lower = term.toLowerCase();
const matched = PROVIDERS.filter(p => p.shortLabel.toLowerCase().startsWith(lower) || p.displayName.toLowerCase().includes(lower)).map(p => p.id);
if (matched.length === 1) {
this.toggleChip(matched[0]);
}
const start = (m.index || 0) + (m[1] || '').length;
this.queryUpdate(q.slice(0, start).trimEnd());
}
this.atOpen.set(false);
this.toggleChip(id);
this.telemetry.track('at_autocomplete_use', { providers: [id] });
try { this.qInputRef?.nativeElement?.focus(); } catch {}
}
// Toggle All chip
@@ -152,43 +185,37 @@ export class SearchBoxComponent {
if (this.providerMode() === 'all') this.providerMode.set('custom');
const cur = new Set(this.selected());
if (cur.has(id)) cur.delete(id); else cur.add(id);
const next = Array.from(cur);
this.selected.set(next);
if (next.length === 0) {
// Empty selection warning UX could be handled by parent; keep custom mode
}
this.selected.set(Array.from(cur));
}
// Keyboard shortcuts: Alt+1..6 map to chips by order
// Single keyboard handler for the whole form (input events bubble here)
handleKeydown(ev: KeyboardEvent) {
if (ev.key === '@') {
ev.preventDefault();
this.pickerOpen.set(true);
return;
}
// Inline @autocomplete navigation
// Inline @autocomplete navigation (highest priority)
if (this.atOpen()) {
const opts = this.atOptions();
if (ev.key === 'ArrowDown') { ev.preventDefault(); this.atIndex.set(Math.min(opts.length - 1, this.atIndex() + 1)); return; }
if (ev.key === 'ArrowUp') { ev.preventDefault(); this.atIndex.set(Math.max(0, this.atIndex() - 1)); return; }
if (ev.key === 'Enter') {
// Prevent the sibling (keydown.enter) submit handler from also firing
ev.stopImmediatePropagation();
ev.preventDefault();
const pick = opts[this.atIndex()];
if (pick) this.toggleChip(pick.id);
this.atOpen.set(false);
if (pick) this.applyAtOption(pick.id);
return;
}
if (ev.key === 'Escape') { this.atOpen.set(false); }
if (ev.key === 'Escape') { this.atOpen.set(false); return; }
}
// Alt+1..6 map to chips by registry order
if (ev.altKey) {
const map: Record<string, ProviderId | undefined> = { '1': 'yt', '2': 'dm', '3': 'tw', '4': 'pt', '5': 'od', '6': 'ru' };
const pid = map[ev.key];
if (pid) { ev.preventDefault(); this.toggleChip(pid); }
return;
}
if (ev.key === 'Escape') {
// Escape clears transient UI; if nothing is open, also clear the query
// Escape closes transient UI; if nothing is open, clear the query
const hadAny = this.pickerOpen() || this.suggestionsOpen() || this.atOpen() || this.quickOpen();
this.pickerOpen.set(false);
this.closePicker();
this.suggestionsOpen.set(false);
this.atOpen.set(false);
if (!hadAny && (this.query() || '').length > 0) {
@@ -197,19 +224,13 @@ export class SearchBoxComponent {
}
}
// Global shortcut Ctrl/Cmd+K to open the Providers picker
// Global shortcut Ctrl/Cmd+K to open the Providers quick menu
@HostListener('document:keydown', ['$event'])
onGlobalKey(ev: KeyboardEvent) {
if ((ev.key.toLowerCase() === 'k') && (ev.ctrlKey || ev.metaKey)) {
ev.preventDefault();
this.quickOpen.set(true);
// Load last 15 searches
try { this.history.getSearchHistory(15).subscribe({ next: (items) => this.recentSearches.set(items || []), error: () => {} }); } catch {}
}
// Also support Ctrl+Shift+K explicitly
if ((ev.key.toLowerCase() === 'k') && ev.ctrlKey && ev.shiftKey) {
ev.preventDefault();
this.quickOpen.set(true);
this.openQuickMenu();
return;
}
if (ev.key === 'Escape') {
if (this.quickOpen()) {
@@ -234,35 +255,68 @@ export class SearchBoxComponent {
try {
const raw = this.qInputRef.nativeElement.value || '';
q = String(raw).trim();
console.log('[SearchBox] fallback native value used', { q, qLength: q.length });
} catch {}
}
// Debug: minimal console trace to confirm handler runs
try {
console.log('[SearchBox] onSubmit called', {
q,
qLength: q.length,
hasParentHandler: !!this.submitted.observers.length
});
} catch(e) {
console.error('Error in SearchBox onSubmit:', e);
}
if (q.length < 2) {
console.log('[SearchBox] Query too short, not submitting');
return;
}
if (q.length < 2) return;
const prov = this.providersForRequest();
console.log('[SearchBox] Emitting submit event', { q, providers: prov });
this.telemetry.track('search_submit', { providers: Array.isArray(prov) ? prov : ['all'], queryLength: q.length });
// Emit payload to parent (Header/Search page)
this.submitted.emit({ q, providers: prov });
console.log('[SearchBox] Submit event emitted');
}
openPicker() { this.pickerOpen.set(true); }
closePicker() { this.pickerOpen.set(false); }
openPicker() {
this.saveFocus();
this.pickerOpen.set(true);
this.telemetry.track('provider_picker_open');
}
closePicker() {
if (!this.pickerOpen()) return;
this.pickerOpen.set(false);
this.restoreFocus();
}
// Quick menu open/close with focus management (a11y)
openQuickMenu() {
this.saveFocus();
this.quickOpen.set(true);
this.telemetry.track('quick_menu_open');
// Load last 15 searches
try { this.history.getSearchHistory(15).subscribe({ next: (items) => this.recentSearches.set(items || []), error: () => {} }); } catch {}
}
closeQuickMenu() {
if (!this.quickOpen()) return;
this.quickOpen.set(false);
this.restoreFocus();
}
private saveFocus() {
try { this.lastFocused = document.activeElement as HTMLElement | null; } catch { this.lastFocused = null; }
}
private restoreFocus() {
try { this.lastFocused?.focus(); } catch {}
this.lastFocused = null;
}
// Generic focus trap used by the quick menu dialog (Tab / Shift+Tab)
trapFocus(ev: KeyboardEvent, root: HTMLElement | null | undefined) {
if (ev.key !== 'Tab' || !root) return;
const focusables = Array.from(root.querySelectorAll<HTMLElement>('button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'))
.filter(el => !el.hasAttribute('disabled'));
if (focusables.length === 0) return;
const first = focusables[0];
const last = focusables[focusables.length - 1];
const active = document.activeElement as HTMLElement | null;
if (!ev.shiftKey && active === last) {
ev.preventDefault();
first.focus();
} else if (ev.shiftKey && (active === first || active === root)) {
ev.preventDefault();
last.focus();
}
}
onQuickMenuKeydown(ev: KeyboardEvent) {
this.trapFocus(ev, this.quickDialogRef?.nativeElement);
}
// Accept both legacy payload (array|'all') and new object payload; use any to satisfy strict template binder
applyPicker(payload: any) {
@@ -272,20 +326,20 @@ export class SearchBoxComponent {
if (prov === 'all') {
this.providerMode.set('all');
this.selected.set([]);
} else {
} else if (Array.isArray(prov)) {
this.providerMode.set('custom');
this.selected.set(prov);
}
// Persist default selection if requested and user is logged-in
// Persist default selection if requested and user is logged-in (Step 9)
if (rememberDefault) {
const body: any = { defaultProviders: (prov === 'all') ? [] : prov };
try { this.users.updatePreferences(body).subscribe({ next: () => {}, error: () => {} }); } catch {}
}
this.telemetry.track('provider_apply', { providers: Array.isArray(prov) ? prov : ['all'], rememberDefault });
this.closePicker();
}
// Quick menu close/apply (re-using same payload shape as picker)
closeQuickMenu() { this.quickOpen.set(false); }
applyQuickMenu(payload: any) {
this.applyPicker(payload);
this.closeQuickMenu();
@@ -299,14 +353,6 @@ export class SearchBoxComponent {
this.selected.set(Array.from(set));
}
// Lock body scroll when a modal is open (a11y UX)
private _scrollLock = effect(() => {
const open = this.quickOpen() || this.pickerOpen();
try {
document.body.style.overflow = open ? 'hidden' : '';
} catch {}
});
// Internal subject for debouncing query changes
private _qChanges = new Subject<string>();
}
@@ -1,28 +1,43 @@
<div class="mt-3 space-y-4">
<!-- Grouped suggestion sections by provider -->
<div *ngFor="let entry of (groups | keyvalue)" class="border border-slate-700 rounded-lg overflow-hidden">
<div class="px-3 py-2 bg-slate-800 text-slate-200 text-sm font-semibold">
{{ entry.key | uppercase }}
</div>
<ul class="divide-y divide-slate-700">
<li *ngFor="let s of (entry.value | slice:0:3); let i = index" class="p-3 flex items-start gap-3">
<img *ngIf="s.thumbnail" [src]="s.thumbnail" class="h-12 w-20 object-cover rounded" alt=""/>
<div class="min-w-0">
<div class="text-slate-100 truncate">{{ s.title }}</div>
<div class="text-slate-400 text-xs">{{ s.uploaderName }}</div>
<div class="mt-3 space-y-4" role="region" aria-label="Suggestions par fournisseur">
@for (s of sections(); track s.id) {
<section class="border border-slate-700 rounded-lg overflow-hidden" [attr.aria-label]="'Résultats ' + s.name">
<header class="px-3 py-2 bg-slate-800 text-slate-200 text-sm font-semibold flex items-center gap-2">
<span [class]="s.colorClass">{{ s.shortLabel }}</span>
<span>{{ s.name }}</span>
</header>
@if (!s.supported) {
<!-- Explicit unsupported-provider row (Step 6) -->
<div class="p-3 text-slate-500 text-sm italic flex items-center gap-2">
<span aria-hidden="true">⚠</span>
{{ s.name }} ne supporte pas la recherche pour le moment.
</div>
</li>
<li *ngIf="(entry.value?.length || 0) === 0" class="p-3 text-slate-500 text-sm">
Aucun résultat pour ce fournisseur.
</li>
</ul>
<div class="px-3 py-2 bg-slate-900 text-right">
<a class="text-xs text-red-300 hover:text-red-200"
[routerLink]="['/search']"
[queryParams]="{ q: query, providers: entry.key }"
[attr.aria-label]="'View all results on ' + (entry.key | uppercase)">
View all on {{ entry.key | uppercase }}
</a>
</div>
</div>
} @else {
<ul class="divide-y divide-slate-700">
@for (item of s.items | slice:0:3; track item.id) {
<li class="p-3 flex items-start gap-3">
@if (item.thumbnail) {
<img [src]="item.thumbnail" class="h-12 w-20 object-cover rounded" alt=""/>
}
<div class="min-w-0">
<div class="text-slate-100 truncate">{{ item.title }}</div>
<div class="text-slate-400 text-xs">{{ item.uploaderName }}</div>
</div>
</li>
}
@empty {
<li class="p-3 text-slate-500 text-sm">Aucun résultat pour ce fournisseur.</li>
}
</ul>
<div class="px-3 py-2 bg-slate-900 text-right">
<a class="text-xs text-red-300 hover:text-red-200"
[routerLink]="['/search']"
[queryParams]="{ q: query, providers: s.deepLinkProviders }"
[attr.aria-label]="'Voir tous les résultats sur ' + s.name">
View all on {{ s.name }}
</a>
</div>
}
</section>
}
</div>
@@ -1,9 +1,19 @@
import { Component, Input } from '@angular/core';
import { Component, Input, computed } from '@angular/core';
import { CommonModule } from '@angular/common';
import { RouterLink } from '@angular/router';
import type { ProviderId } from '../../app/core/providers/provider-registry';
import { PROVIDERS, PROVIDER_MAP, type ProviderId } from '../../app/core/providers/provider-registry';
import type { SuggestionItem } from '../../app/search/search.service';
interface SuggestionSection {
id: ProviderId;
name: string;
shortLabel: string;
colorClass: string;
items: SuggestionItem[];
supported: boolean;
deepLinkProviders: string;
}
@Component({
selector: 'app-search-suggestions',
standalone: true,
@@ -15,4 +25,26 @@ export class SearchSuggestionsComponent {
yt: [], dm: [], tw: [], pt: [], od: [], ru: []
} as any;
@Input() query: string = '';
// Providers that were actually requested; drives which sections render and
// which unsupported providers get an explicit row (Step 6)
@Input() activeProviders: ProviderId[] | 'all' = 'all';
readonly sections = computed<SuggestionSection[]>(() => {
const groups = this.groups || ({} as Record<ProviderId, SuggestionItem[]>);
const active = this.activeProviders === 'all'
? PROVIDERS.map(p => p.id)
: (this.activeProviders || []).filter(id => PROVIDER_MAP.has(id));
return active.map(id => {
const spec = PROVIDER_MAP.get(id)!;
return {
id,
name: spec.displayName,
shortLabel: spec.shortLabel,
colorClass: spec.colorClass,
items: Array.isArray(groups[id]) ? groups[id] : [],
supported: !!spec.supports?.search,
deepLinkProviders: id,
} as SuggestionSection;
});
});
}
+21 -3
View File
@@ -11,7 +11,8 @@ import { TranslatePipe } from '../../pipes/translate.pipe';
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 { UserService, type UserPreferences } from '../../services/user.service';
import { PROVIDERS, type ProviderId } from '../../app/core/providers/provider-registry';
import { Observable, Subscription } from 'rxjs';
@Component({
@@ -29,6 +30,7 @@ export class SearchComponent implements AfterViewInit {
private history = inject(HistoryService);
private title = inject(Title);
private unified = inject(SearchService);
private users = inject(UserService);
q = signal<string>('');
loading = signal<boolean>(false);
@@ -36,6 +38,8 @@ export class SearchComponent implements AfterViewInit {
providerParam = signal<Provider | null>(null);
themeParam = signal<string | null>(null);
providersListParam = signal<ProviderId[] | 'all' | null>(null);
// Step 7: default providers loaded from user preferences (server), applied when URL has none
private defaultProvidersPref = signal<ProviderId[] | null>(null);
pageParam = signal<number>(1);
sortParam = signal<'relevance' | 'date' | 'views'>('relevance');
@@ -258,6 +262,20 @@ export class SearchComponent implements AfterViewInit {
this.title.setTitle(this.pageHeading());
});
// Step 7: load default providers preference once (server) for deep-link fallback
try {
this.users.loadPreferences().subscribe({
next: (prefs: UserPreferences) => {
const list = (prefs as any)?.defaultProviders;
if (Array.isArray(list)) {
const valid = list.filter((id: string) => PROVIDERS.some(p => p.id === id)) as ProviderId[];
if (valid.length > 0) this.defaultProvidersPref.set(valid);
}
},
error: () => {} // anonymous users: no preference
});
} catch {}
// Subscribe once to unified multi-provider results and reflect to UI
const subscription: Subscription = this.unified.request$
.pipe(takeUntilDestroyed())
@@ -330,8 +348,8 @@ export class SearchComponent implements AfterViewInit {
const provider = prov || this.instances.selectedProvider();
this.filterTag.set(provider === 'twitch' ? 'twitch_all' : 'all');
// Use unified search in all cases
const providersToUse = providersList || [provider as ProviderId];
// Use unified search in all cases (URL providers > user default preference > single provider)
const providersToUse = providersList || this.defaultProvidersPref() || [provider as ProviderId];
this.unified.setQuery(q);
this.unified.setProviders(providersToUse);
@@ -1,10 +1,22 @@
<div class="h-[calc(100vh-4rem)] w-full flex items-center justify-center p-2 sm:p-4"
(wheel)="onWheel($event)" (touchstart)="onTouchStart($event)" (touchend)="onTouchEnd($event)">
<div class="w-full flex flex-col items-center gap-3 p-2 sm:p-4">
<!-- Sélecteur de fournisseur : la page n'est plus bloquée sur un seul provider -->
<div class="flex flex-wrap items-center justify-center gap-2" role="tablist" aria-label="Fournisseur des Shorts">
<button *ngFor="let p of providersList()"
(click)="selectProvider(p.id)"
[attr.aria-pressed]="provider() === p.id"
class="px-3 py-1.5 rounded-full text-sm border transition-colors"
[class]="provider() === p.id ? 'bg-red-600 border-red-500 text-white' : 'bg-slate-800 border-slate-700 text-slate-300 hover:bg-slate-700'">
{{ p.label }}
</button>
</div>
<div class="h-[calc(100vh-8rem)] w-full flex items-center justify-center"
(wheel)="onWheel($event)" (touchstart)="onTouchStart($event)" (touchend)="onTouchEnd($event)">
<!-- Center column -->
<div class="relative">
<!-- Video frame (9:16) -->
<div class="rounded-xl overflow-hidden shadow-2xl ring-1 ring-slate-700/50 bg-black/90"
style="height: calc(100vh - 6rem); max-height: calc(100vh - 6rem); aspect-ratio: 9 / 16; width: auto; max-width: 92vw;">
style="height: calc(100vh - 10rem); max-height: calc(100vh - 10rem); aspect-ratio: 9 / 16; width: auto; max-width: 92vw;">
@if (embedUrl(); as url) {
<iframe [src]="url" class="w-full h-full" frameborder="0"
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"
@@ -12,8 +24,9 @@
} @else if (loading()) {
<div class="w-full h-full animate-pulse bg-slate-800"></div>
} @else if (error()) {
<div class="w-full h-full flex items-center justify-center text-slate-300">
{{ error() }}
<div class="w-full h-full flex flex-col gap-3 items-center justify-center text-slate-300 p-6 text-center">
<span>{{ error() }}</span>
<button (click)="retry()" class="px-4 py-2 rounded bg-slate-700 hover:bg-slate-600 text-white text-sm">Réessayer</button>
</div>
}
@@ -22,10 +35,12 @@
<div class="absolute left-0 right-0 bottom-0 p-3 sm:p-4 bg-gradient-to-t from-black/60 to-transparent text-white">
<div class="flex items-center gap-3 text-sm">
<img [src]="v.uploaderAvatar" [alt]="v.uploaderName" class="h-8 w-8 rounded-full object-cover">
<div class="flex-1">
<div class="flex-1 min-w-0">
<div class="font-semibold line-clamp-1">{{ v.title }}</div>
<div class="opacity-80 line-clamp-1">@{{ v.uploaderName }}</div>
</div>
<a *ngIf="watchLink()" [routerLink]="watchLink()" [queryParams]="watchQueryParams()"
class="shrink-0 px-2 py-1 rounded bg-white/15 hover:bg-white/25 text-xs">Ouvrir</a>
</div>
</div>
}
@@ -43,10 +58,19 @@
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" viewBox="0 0 24 24" fill="currentColor"><path d="M7 10l5 5 5-5H7z"/></svg>
</button>
<!-- Position indicator -->
@if (items().length > 0) {
<div class="absolute -left-14 top-6 hidden md:flex items-center justify-center px-2 py-1 rounded-full bg-slate-800/70 text-white text-xs ring-1 ring-slate-700/60">
{{ index() + 1 }} / {{ items().length }}
</div>
}
<!-- Mobile next/prev (bottom) -->
<div class="flex md:hidden items-center justify-between mt-3">
<button (click)="prev()" [disabled]="!canPrev()" class="px-3 py-2 rounded bg-slate-800/70 text-white disabled:opacity-40">Prev</button>
<span *ngIf="items().length > 0" class="text-slate-400 text-sm">{{ index() + 1 }} / {{ items().length }}</span>
<button (click)="next()" [disabled]="!canNext()" class="px-3 py-2 rounded bg-slate-800/70 text-white disabled:opacity-40">Next</button>
</div>
</div>
</div>
</div>
+121 -70
View File
@@ -1,16 +1,20 @@
import { ChangeDetectionStrategy, Component, computed, inject, signal, HostListener } from '@angular/core';
import { ChangeDetectionStrategy, Component, computed, effect, inject, signal, HostListener } from '@angular/core';
import { CommonModule } from '@angular/common';
import { RouterLink } from '@angular/router';
import { DomSanitizer, SafeResourceUrl } from '@angular/platform-browser';
import { firstValueFrom } from 'rxjs';
import { YoutubeApiService } from '../../services/youtube-api.service';
import { InstanceService } from '../../services/instance.service';
import { InstanceService, Provider } from '../../services/instance.service';
import { Video } from '../../models/video.model';
import { isShortVideo } from '../../app/shared/utils/video-kind';
const SHORTS_QUERY = 'shorts';
@Component({
selector: 'app-watch-short',
standalone: true,
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [CommonModule],
imports: [CommonModule, RouterLink],
templateUrl: './watch-short.component.html'
})
export class WatchShortComponent {
@@ -29,8 +33,10 @@ export class WatchShortComponent {
private wheelAccum = 0;
private lastSwipeY: number | null = null;
private lastScrollTs = 0;
private lastProvider: string | null = null;
readonly provider = computed(() => this.instances.selectedProvider());
readonly providersList = computed(() => this.instances.providers());
readonly current = computed<Video | null>(() => {
const list = this.items();
const i = this.index();
@@ -38,7 +44,7 @@ export class WatchShortComponent {
});
readonly canPrev = computed(() => this.index() > 0);
readonly canNext = computed(() => this.index() < this.items().length - 1);
readonly canNext = computed(() => this.index() < this.items().length - 1 || !!this.nextCursor());
readonly embedUrl = computed<SafeResourceUrl | null>(() => {
const v = this.current();
@@ -47,8 +53,18 @@ export class WatchShortComponent {
try {
const host = (location && location.hostname) ? location.hostname : 'localhost';
if (p === 'youtube' && v.videoId) {
const qs = 'autoplay=1&mute=1&rel=0&modestbranding=1&playsinline=1&controls=1';
const u = `https://www.youtube.com/embed/${encodeURIComponent(v.videoId)}?${qs}`;
// Même base que la page /watch : youtube-nocookie + origin/enablejsapi.
// L'embed youtube.com sans origin provoque l'erreur 153
// ("Video configuration error") sur certaines vidéos.
let origin = '';
let ref = '';
try {
origin = window.location.origin || '';
ref = window.location.href || '';
} catch {}
let u = `https://www.youtube-nocookie.com/embed/${encodeURIComponent(v.videoId)}?autoplay=1&mute=1&rel=0&modestbranding=1&playsinline=1&controls=1&enablejsapi=1`;
if (origin) u += `&origin=${encodeURIComponent(origin)}`;
if (ref) u += `&widget_referrer=${encodeURIComponent(ref)}`;
return this.sanitizer.bypassSecurityTrustResourceUrl(u);
}
if (p === 'dailymotion' && v.videoId) {
@@ -88,23 +104,35 @@ export class WatchShortComponent {
}
}
if (p === 'rumble' && v.videoId) {
const u = `https://rumble.com/embed/${encodeURIComponent(v.videoId)}/?autoplay=2&muted=1`;
// L'embed officiel attend l'ID court (vXXXX) ; le videoId peut contenir le slug complet.
const m = /^v[0-9A-Za-z]+/.exec(String(v.videoId));
const id = m ? m[0] : v.videoId;
const u = `https://rumble.com/embed/${encodeURIComponent(id)}/?autoplay=2&muted=1`;
return this.sanitizer.bypassSecurityTrustResourceUrl(u);
}
} catch {}
return null;
});
/** Lien "voir en page Watch" pour le short courant (secours si l'embed échoue). */
readonly watchLink = computed<any[] | null>(() => {
const v = this.current();
if (!v?.videoId) return null;
return ['/watch', v.videoId];
});
readonly watchQueryParams = computed<Record<string, any> | null>(() => {
const v = this.current();
const p = this.provider();
if (!v) return null;
const qp: any = { p };
if (p === 'twitch' && v.type === 'channel' && v.videoId) qp.channel = v.videoId;
return qp;
});
// Called when a query returns no usable items; falls back to Trending
private afterNoResults(): void {
// For Shorts page, do NOT fallback to trending; display an appropriate message instead.
const p = this.provider();
const unsupported = this.isProviderUnsupportedForShorts(p);
if (unsupported) {
this.error.set('Ce fournisseur ne supporte pas les shorts');
} else {
this.error.set('Aucun Short trouvé pour ce fournisseur.');
}
this.error.set('Aucun Short trouvé pour ce fournisseur. Essayez un autre fournisseur ou réessayez.');
}
// Filter YouTube results to real Shorts using the Videos API to get durations.
@@ -123,13 +151,32 @@ export class WatchShortComponent {
}
constructor() {
// Initialize feed for Shorts according to current provider
// Recharger le flux quand le provider global change (ex. sélecteur du header).
effect(() => {
const p = this.provider();
if (this.lastProvider !== p) {
this.lastProvider = p;
this.loadFeed();
}
});
}
selectProvider(p: Provider): void {
if (p === this.provider()) return;
this.instances.setSelectedProvider(p);
// L'effect ci-dessus déclenche loadFeed().
}
retry(): void {
this.loadFeed();
}
loadFeed(): void {
this.loading.set(true);
this.error.set(null);
this.items.set([]);
this.index.set(0);
this.nextCursor.set(null);
const ready = this.instances.getProviderReadiness(this.provider());
if (!ready.ready) {
this.loading.set(false);
@@ -137,10 +184,10 @@ export class WatchShortComponent {
return;
}
const provider = this.provider();
// Use provider-specific source for Shorts
// Twitch : les Clips sont l'équivalent Shorts. La recherche Helix rejette
// les requêtes vides → on part des top streams (getTwitchClipsFeed).
if (provider === 'twitch') {
// Use Twitch Clips as Shorts
this.api.searchTwitchClipsPage('')?.subscribe({
this.api.getTwitchClipsFeed()?.subscribe({
next: (res: { items: Video[]; nextCursor?: string | null }) => {
const raw: Video[] = (res.items || []).filter((v: Video) => !!v.videoId);
const list: Video[] = raw.filter((v: Video) => this.isShort(v));
@@ -157,72 +204,76 @@ export class WatchShortComponent {
});
return;
}
// Default: search for shorts keywords via generic search
this.api.searchVideosPage('shorts OR #shorts').subscribe({
// Default: search for shorts keywords via generic search, then filter.
// Si la recherche ne donne aucun Short, repli sur le trending filtré
// (utile pour Rumble/Odysee/PeerTube/Dailymotion dont la recherche
// plein-texte "shorts" est approximative).
this.api.searchVideosPage(SHORTS_QUERY).subscribe({
next: (res) => {
const raw = (res.items || []).filter(v => !!v.videoId);
this.nextCursor.set(res.nextCursor || null);
const p = this.provider();
if (p === 'youtube' && raw.length) {
this.filterYouTubeShorts(raw).then(list => {
this.items.set(list.filter(v => this.isShort(v)));
this.index.set(0);
if (list.length === 0) this.afterNoResults();
else this.error.set(null);
this.loading.set(false);
});
const shorts = list.filter(v => this.isShort(v));
if (shorts.length > 0) {
this.items.set(shorts);
this.index.set(0);
this.error.set(null);
this.loading.set(false);
} else {
this.loadTrendingFallback();
}
}).catch(() => this.loadTrendingFallback());
return;
}
// Non-YouTube: apply provider-specific minimal filtering
const list = raw.filter(v => this.isShort(v));
this.items.set(list);
this.index.set(0);
if (list.length === 0) this.afterNoResults(); else this.error.set(null);
this.loading.set(false);
if (list.length > 0) {
this.items.set(list);
this.index.set(0);
this.error.set(null);
this.loading.set(false);
} else {
this.loadTrendingFallback();
}
},
error: () => {
this.error.set('Failed to load Shorts.');
this.loadTrendingFallback();
}
});
}
/** Repli : trending du provider filtré sur les Shorts (jamais de contenu long). */
private loadTrendingFallback(): void {
this.api.getTrendingPage(null).subscribe({
next: (res) => {
const raw = (res.items || []).filter(v => !!v.videoId);
const p = this.provider();
const apply = (list: Video[]) => {
this.items.set(list);
this.index.set(0);
this.nextCursor.set(null);
if (list.length === 0) this.afterNoResults(); else this.error.set(null);
this.loading.set(false);
};
if (p === 'youtube' && raw.length) {
this.filterYouTubeShorts(raw).then(list => apply(list.filter(v => this.isShort(v)))).catch(() => apply([]));
} else {
apply(raw.filter(v => this.isShort(v)));
}
},
error: () => {
this.afterNoResults();
this.loading.set(false);
}
});
}
// Trending fallback removed for Shorts page to avoid showing non-short content
private loadFallbackTrending(_hint?: string): void { this.error.set('Ce fournisseur ne supporte pas les shorts'); this.loading.set(false); }
// Generic Shorts check based on provider
// Generic Shorts check — classification partagée (une durée 0/absente
// ne qualifie JAMAIS un Short : les scrapers renvoient 0 quand inconnue).
private isShort(v: Video): boolean {
const p = (v as any).provider || this.provider();
const url = (v as any).url || '';
switch (p) {
case 'youtube':
return (v as any).isShort === true || (typeof url === 'string' && url.includes('/shorts/')) || ((v.duration ?? 0) > 0 && (v.duration ?? 0) <= 70);
case 'dailymotion':
return (v as any).isShort === true || (((v.duration ?? 0) <= 60) && ((v as any).isVertical === true));
case 'twitch':
return (v as any).kind === 'clip';
case 'rumble':
return (v as any).isShort === true;
case 'odysee':
// Odysee: use duration <= 60s as shorts indicator if native isShort unavailable
return (v as any).isShort === true || ((v.duration ?? 0) <= 60);
case 'peertube':
return (v as any).isShort === true || (((v.duration ?? 0) <= 60) && ((v as any).isVertical === true));
default:
return false;
}
}
private isProviderUnsupportedForShorts(p: string | undefined | null): boolean {
switch (p) {
case 'rumble':
return true; // Rumble requires native isShort indicator
case 'twitch':
case 'youtube':
return false;
default:
return false;
}
return isShortVideo({ ...(v as object), provider: p } as Parameters<typeof isShortVideo>[0]);
}
private fetchNextPage(autoAdvance = false) {
@@ -250,14 +301,14 @@ export class WatchShortComponent {
};
if (provider === 'twitch') {
this.api.searchTwitchClipsPage('', cursor || undefined)?.subscribe({
this.api.getTwitchClipsFeed(cursor || undefined)?.subscribe({
next: handle,
error: () => { this.busyMore.set(false); }
});
return;
}
this.api.searchVideosPage('shorts OR #shorts', cursor || undefined).subscribe({
this.api.searchVideosPage(SHORTS_QUERY, cursor || undefined).subscribe({
next: (res) => {
handle(res);
},
@@ -268,7 +319,7 @@ export class WatchShortComponent {
}
next(): void {
if (this.canNext()) {
if (this.canNext() && this.index() < this.items().length - 1) {
this.index.update(i => i + 1);
} else if (this.nextCursor()) {
this.fetchNextPage(true);
@@ -53,6 +53,12 @@
<span [class.hidden]="collapsed">{{ 'nav.liked' | t }}</span>
</a>
</li>
<li>
<a routerLink="/library/downloads" routerLinkActive="bg-slate-800" class="flex items-center px-3 py-2 rounded hover:bg-slate-800 transition" [ngClass]="{ 'gap-0 justify-center': collapsed, 'gap-3': !collapsed }" [attr.title]="collapsed ? ('nav.downloads' | t) : null">
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" viewBox="0 0 24 24" fill="currentColor"><path d="M12 3v10.17l3.59-3.58L17 11l-5 5-5-5 1.41-1.41L12 13.17V3h0zm-7 15h14v2H5v-2z"/></svg>
<span [class.hidden]="collapsed">{{ 'nav.downloads' | t }}</span>
</a>
</li>
</ul>
</div>
}
@@ -24,6 +24,25 @@
</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">
<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'">
Tout
</button>
<button (click)="setContentFilter('videos')" [attr.aria-pressed]="contentFilter() === 'videos'"
class="px-3 py-1.5 rounded-full text-sm border transition-colors"
[class]="contentFilter() === 'videos' ? 'bg-red-600 border-red-500 text-white' : 'bg-slate-800 border-slate-700 text-slate-300 hover:bg-slate-700'">
Vidéos
</button>
<button (click)="setContentFilter('shorts')" [attr.aria-pressed]="contentFilter() === 'shorts'"
class="px-3 py-1.5 rounded-full text-sm border transition-colors"
[class]="contentFilter() === 'shorts' ? 'bg-red-600 border-red-500 text-white' : 'bg-slate-800 border-slate-700 text-slate-300 hover:bg-slate-700'">
Shorts<span *ngIf="allVideos().length"> ({{ shortsCount() }})</span>
</button>
</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">
@@ -107,6 +126,10 @@
<div class="absolute top-2 right-2">
<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>
</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>
@@ -10,7 +10,9 @@ import { TranslatePipe } from '../../pipes/translate.pipe';
import { LikeButtonComponent } from '../shared/components/like-button/like-button.component';
import { DurationPipe } from '../../app/shared/pipes/duration.pipe';
import { ProviderBadgeComponent } from '../../app/shared/components/provider-badge/provider-badge.component';
import { map, of, switchMap } from 'rxjs';
import { classifyVideo, isShortVideo } from '../../app/shared/utils/video-kind';
import { map, of, switchMap, catchError, Observable } from 'rxjs';
import { PagedResult } from '../../services/youtube-api.service';
const TWITCH_THEME_KEYWORDS: Record<string, string[]> = {
trending: ['just chatting', 'highlights'],
@@ -141,18 +143,32 @@ export class ProviderThemePageComponent implements OnDestroy {
const themeSlug = this.theme();
const isLiveTheme = themeSlug === 'live';
let apiCall;
let apiCall: Observable<PagedResult<Video>>;
if (provider === 'twitch') {
if (isLiveTheme) {
apiCall = this.api.searchTwitchChannelsPage(query || 'live', cursor || undefined);
// 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 || [])] }))
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))
);
})
);
})
);
@@ -164,11 +180,11 @@ export class ProviderThemePageComponent implements OnDestroy {
}
apiCall.subscribe({
next: (res) => {
next: (res: PagedResult<Video>) => {
if (snapshotVersion !== this.version) return;
const existingIds = new Set(this.allVideos().map(v => v.videoId));
const newVideos = (res.items || []).filter(v => !existingIds.has(v.videoId));
const existingIds = new Set(this.allVideos().map((v: Video) => v.videoId));
const newVideos = (res.items || []).filter((v: Video) => !existingIds.has(v.videoId));
this.allVideos.update(current => [...current, ...newVideos]);
this.nextCursor.set(res.nextCursor || null);
@@ -199,11 +215,14 @@ export class ProviderThemePageComponent implements OnDestroy {
const typeFilter = this.type();
if (typeFilter !== 'any') {
const p = this.provider();
arr = arr.filter(v => {
if (typeFilter === 'video') return v.type === 'video';
if (typeFilter === 'live') return v.type === 'live' || v.type === 'channel';
if (typeFilter === 'recorded') return v.type === 'recorded';
if (typeFilter === 'short') return v.type === 'short' || v.duration < 60;
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);
return true;
});
}
@@ -252,6 +271,27 @@ export class ProviderThemePageComponent implements OnDestroy {
onSortChange(val: any) { this.sort.set(val as any); }
onDurationChange(val: any) { this.duration.set(val as any); }
onTypeChange(val: any) { this.type.set(val as any); }
// Raccourci Vidéos / Shorts synchronisé avec le filtre "type" existant.
contentFilter = computed<'all' | 'videos' | 'shorts'>(() => {
const t = this.type();
if (t === 'short') return 'shorts';
if (t === 'video') return 'videos';
return 'all';
});
setContentFilter(v: 'all' | 'videos' | 'shorts'): void {
this.type.set(v === 'shorts' ? 'short' : v === 'videos' ? 'video' : 'any');
}
kindOf(v: Video): string {
const p = this.provider();
return classifyVideo({ ...v, provider: (v.provider as string) || p });
}
shortsCount(): number {
const p = this.provider();
return this.allVideos().filter(v => isShortVideo({ ...v, provider: (v.provider as string) || p })).length;
}
onLanguageChange(val: any) { this.language.set(val as any); }
onDateChange(val: any) { this.date.set(val as any); }
@@ -1,8 +1,27 @@
<div class="container mx-auto p-4 sm:p-6">
<h2 class="text-3xl font-bold mb-6 text-slate-100 border-l-4 border-red-500 pl-4 flex items-center gap-2">
<h2 class="text-3xl font-bold mb-4 text-slate-100 border-l-4 border-red-500 pl-4 flex items-center gap-2">
<span>{{ themeLabel() }}</span>
</h2>
<!-- Filtre Vidéos / Shorts : les deux formats ne sont plus mélangés sans contrôle -->
<div class="flex items-center gap-2 mb-6" 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'">
Tout
</button>
<button (click)="setContentFilter('videos')" [attr.aria-pressed]="contentFilter() === 'videos'"
class="px-3 py-1.5 rounded-full text-sm border transition-colors"
[class]="contentFilter() === 'videos' ? 'bg-red-600 border-red-500 text-white' : 'bg-slate-800 border-slate-700 text-slate-300 hover:bg-slate-700'">
Vidéos
</button>
<button (click)="setContentFilter('shorts')" [attr.aria-pressed]="contentFilter() === 'shorts'"
class="px-3 py-1.5 rounded-full text-sm border transition-colors"
[class]="contentFilter() === 'shorts' ? 'bg-red-600 border-red-500 text-white' : 'bg-slate-800 border-slate-700 text-slate-300 hover:bg-slate-700'">
Shorts
</button>
</div>
<div class="space-y-10">
<section *ngFor="let b of blocks()">
<div class="flex items-center justify-between mb-3">
@@ -18,10 +37,13 @@
<!-- 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 && filteredItems(b).length === 0" class="text-slate-400">
Aucun {{ contentFilter() === 'shorts' ? 'Short' : 'vidéo' }} pour ce fournisseur — essayez l'autre filtre.
</div>
<!-- Grid -->
<div *ngIf="!b.loading && !b.error && b.items.length" class="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-5 gap-4">
<a *ngFor="let v of b.items"
<div *ngIf="!b.loading && !b.error && filteredItems(b).length" class="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-5 gap-4">
<a *ngFor="let v of filteredItems(b)"
[routerLink]="['/watch', v.videoId]"
[queryParams]="watchQueryParams(b.provider, v)"
[state]="{ video: v }"
@@ -31,6 +53,10 @@
<div class="absolute top-2 right-2">
<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>
</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>
@@ -9,6 +9,7 @@ import { TranslatePipe } from '../../pipes/translate.pipe';
import { LikeButtonComponent } from '../shared/components/like-button/like-button.component';
import { ProviderBadgeComponent } from '../../app/shared/components/provider-badge/provider-badge.component';
import { DurationPipe } from '../../app/shared/pipes/duration.pipe';
import { classifyVideo, isShortVideo } from '../../app/shared/utils/video-kind';
interface ProviderBlock {
provider: Provider;
@@ -39,6 +40,30 @@ export class ThemePageComponent {
blocks = signal<ProviderBlock[]>([]);
// Filtre d'affichage Vidéos / Shorts (les Shorts ne sont plus mélangés sans contrôle).
readonly contentFilter = signal<'all' | 'videos' | 'shorts'>('all');
setContentFilter(v: 'all' | 'videos' | 'shorts'): void {
this.contentFilter.set(v);
}
/** Items du bloc après application du filtre Vidéos/Shorts. */
filteredItems(b: ProviderBlock): Video[] {
const f = this.contentFilter();
if (f === 'all') return b.items;
return b.items.filter(v =>
f === 'shorts' ? isShortVideo({ ...v, provider: (v.provider as string) || b.provider }) : !isShortVideo({ ...v, provider: (v.provider as string) || b.provider })
);
}
kindOf(v: Video, provider: Provider): string {
return classifyVideo({ ...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;
}
constructor() {
this.route.paramMap.subscribe(pm => {
const slug = pm.get('theme') || pm.get('slug') || 'trending';
@@ -115,6 +140,35 @@ 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({
next: res => {
block.items = (res.items || []).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' });
this.blocks.update(a => [...a]);
},
error: () => { block.error = 'Erreur de chargement'; block.loading = false; this.blocks.update(a => [...a]); }
});
return;
}
// Twitch hors live/trending : VODs + clips + fallback top streams.
if (block.provider === 'twitch') {
const q = tokens.join(' ');
const call = q ? this.api.searchTwitchThemePage(q, null) : this.api.getTwitchTopStreamsPage(null);
call.subscribe({
next: res => {
block.items = (res.items || []).slice(0, max);
block.loading = false;
if (!block.items.length) console.info('[ThemePage] Empty block page', { provider: block.provider, theme: slug, query: q });
this.blocks.update(a => [...a]);
},
error: () => { block.error = 'Erreur de chargement'; block.loading = false; this.blocks.update(a => [...a]); }
});
return;
}
const q = tokens.join(' ');
if (!q) {
// fallback to trending
+35 -4
View File
@@ -59,14 +59,45 @@
{{ formatViewsFr(v.views) }} • {{ formatDateAbsFr(v.uploadedDate) }}
</div>
<div class="flex flex-col sm:flex-row sm:items-center sm:justify-between mt-3 text-slate-400">
<div class="flex flex-col sm:flex-row sm:items-center sm:justify-between mt-3 text-slate-400 gap-3">
<div class="flex items-center space-x-3">
<img [src]="v.uploaderAvatar" [alt]="v.uploaderName" class="w-10 h-10 rounded-full">
@if (channelAvatarUrl(); as avatar) {
<img [src]="avatar" [alt]="v.uploaderName" (error)="onAvatarError()"
class="w-10 h-10 rounded-full object-cover bg-slate-700" loading="lazy" referrerpolicy="no-referrer">
} @else {
<div class="w-10 h-10 rounded-full bg-slate-700 flex items-center justify-center text-slate-200 font-bold"
[attr.aria-label]="v.uploaderName">{{ channelInitial() }}</div>
}
<div>
<div class="font-semibold text-slate-200">{{ v.uploaderName }}</div>
<div>{{ formatNumber(v.subscribers) }} subscribers</div>
<div class="font-semibold text-slate-200">{{ v.uploaderName || channelMeta()?.title || 'Chaîne inconnue' }}</div>
@if (channelSubsLabel(); as subs) {
<div>{{ subs }} abonnés</div>
}
</div>
@if (canSubscribe() && isLoggedIn()) {
<app-subscribe-button
[provider]="subscribeProvider()!"
[externalId]="subscribeExternalId()!"
[channelMeta]="channelMeta()"
(subscribed)="onSubscribedFromWatch($event)"
(unsubscribed)="onUnsubscribedFromWatch()">
</app-subscribe-button>
} @else if (canSubscribe() && !isLoggedIn()) {
<a routerLink="/auth/login" title="Connectez-vous pour vous abonner"
class="inline-flex items-center gap-2 rounded-md border border-slate-600 bg-slate-800/70 px-3 py-1.5 text-sm font-semibold text-slate-200">
S'abonner
</a>
}
</div>
@if (subscribeError(); as err) {
<div class="text-sm text-red-300">{{ err }}</div>
}
@if (subServiceError(); as serr) {
<div class="text-sm text-red-300">{{ serr }}</div>
}
@if (canSubscribe() && isLoggedIn()) {
<div class="text-xs text-slate-500" title="Raccourci clavier">Astuce : touche « s » pour (dé)s'abonner</div>
}
<div class="flex items-center space-x-4 mt-3 sm:mt-0">
<a [href]="openOnProviderUrl() || '#'" target="_blank" rel="noopener noreferrer"
class="flex items-center space-x-2 bg-slate-700 hover:bg-slate-600 px-4 py-2 rounded-full transition"
+178 -9
View File
@@ -1,4 +1,4 @@
import { ChangeDetectionStrategy, Component, inject, signal, computed, OnDestroy, AfterViewInit, ViewChild, ElementRef } from '@angular/core';
import { ChangeDetectionStrategy, Component, inject, signal, computed, OnDestroy, AfterViewInit, ViewChild, ElementRef, HostListener } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { CommonModule } from '@angular/common';
import { ActivatedRoute, Router, RouterLink } from '@angular/router';
@@ -13,6 +13,10 @@ import { HistoryService } from '../../services/history.service';
import { InstanceService, Provider } from '../../services/instance.service';
import { DownloadService, DownloadFormat, DownloadJob } from '../../services/download.service';
import { AuthService } from '../../services/auth.service';
import { ChannelsService } from '../../services/channels.service';
import { SubscriptionsService } from '../../services/subscriptions.service';
import type { ChannelMeta, ProviderId } from '../../app/shared/models/channel.model';
import { SubscribeButtonComponent } from '../../app/shared/components/subscribe-button/subscribe-button.component';
import { DomSanitizer, SafeResourceUrl } from '@angular/platform-browser';
import { IframeProgressService } from '../../services/iframe-progress.service';
import { formatAbsoluteFr, formatNumberFr } from '../../utils/date.util';
@@ -24,7 +28,7 @@ import { DurationPipe } from '../../app/shared/pipes/duration.pipe';
templateUrl: './watch.component.html',
standalone: true,
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [CommonModule, VideoPlayerComponent, RouterLink, AddToPlaylistComponent, ProviderBadgeComponent, DurationPipe]
imports: [CommonModule, VideoPlayerComponent, RouterLink, AddToPlaylistComponent, ProviderBadgeComponent, DurationPipe, SubscribeButtonComponent]
})
export class WatchComponent implements OnDestroy, AfterViewInit {
private route = inject(ActivatedRoute);
@@ -39,10 +43,69 @@ export class WatchComponent implements OnDestroy, AfterViewInit {
private auth = inject(AuthService);
private http = inject(HttpClient);
private iframeProgress = inject(IframeProgressService);
private channels = inject(ChannelsService);
private subs = inject(SubscriptionsService);
private routeSubscription!: Subscription;
@ViewChild('embedFrame', { static: false }) embedFrame?: ElementRef<HTMLIFrameElement>;
private attachTryCount = 0;
// --- Chaîne / abonnement ---
channelMeta = signal<ChannelMeta | null>(null);
channelAvatarBroken = signal(false);
subscribeError = signal<string | null>(null);
subscribeBusy = signal(false);
/** Mappe le provider d'affichage ('youtube') vers l'id canal ('yt'). */
subscribeProvider = computed<ProviderId | null>(() => {
const map: Record<string, ProviderId> = {
youtube: 'yt', dailymotion: 'dm', twitch: 'tw',
peertube: 'pt', odysee: 'od', rumble: 'ru',
};
return map[this.provider()] || null;
});
/** ExternalId de la chaîne : meta résolue > détails vidéo > néant. */
subscribeExternalId = computed<string | null>(() => {
const meta = this.channelMeta();
if (meta?.externalId) return meta.externalId;
const v: any = this.video();
const ext = v?.channelExternalId || v?.channelId || null;
return ext ? String(ext) : null;
});
canSubscribe = computed(() => !!this.subscribeProvider() && !!this.subscribeExternalId());
/** Erreur remontée par le bouton S'abonner (ex. session expirée). */
subServiceError = computed(() => this.subs.lastError());
/** Avatar à afficher : meta chaîne > détails vidéo > néant (initiales). */
channelAvatarUrl = computed<string | null>(() => {
if (this.channelAvatarBroken()) return null;
const metaAvatar = this.channelMeta()?.avatarUrl;
if (metaAvatar) return metaAvatar;
const v: any = this.video();
const direct = v?.channelAvatarUrl || v?.uploaderAvatar;
if (direct && /^https?:\/\//i.test(String(direct))) {
// Évite d'afficher la miniature vidéo comme logo (cas YouTube historique).
if (v?.thumbnail && String(direct) === String(v.thumbnail)) return null;
return String(direct);
}
return null;
});
channelInitial = computed(() => {
const name = (this.video()?.uploaderName || this.channelMeta()?.title || this.channelMeta()?.handle || 'C').trim();
return name ? name.charAt(0).toUpperCase() : 'C';
});
channelSubsLabel = computed(() => {
const fromMeta = this.channelMeta()?.subsCount;
if (typeof fromMeta === 'number') return this.formatNumber(fromMeta);
const v = this.video()?.subscribers;
if (typeof v === 'number' && v > 0) return this.formatNumber(v);
return null;
});
// Choose correct API base: use dev proxy when UI runs on a different port than the backend
private apiBase(): string {
try {
@@ -264,8 +327,11 @@ export class WatchComponent implements OnDestroy, AfterViewInit {
const parentsSet = new Set<string>([host, 'localhost', '127.0.0.1']);
const parentParams = Array.from(parentsSet).map(h => `parent=${encodeURIComponent(h)}`).join('&');
const base = 'https://player.twitch.tv/';
const qs = ch
? `?channel=${encodeURIComponent(ch)}&${parentParams}&autoplay=false`
// ?video= exige un id de VOD numérique ; un login (non numérique) sans
// ?channel= doit être traité comme une chaîne (résultats de recherche).
const chan = ch || (!/^\d+$/.test(String(id || '')) && id ? id : null);
const qs = chan
? `?channel=${encodeURIComponent(chan)}&${parentParams}&autoplay=false`
: `?video=${encodeURIComponent(id)}&${parentParams}&autoplay=false`;
const u = base + qs;
return this.sanitizer.bypassSecurityTrustResourceUrl(u);
@@ -316,7 +382,78 @@ export class WatchComponent implements OnDestroy, AfterViewInit {
return null;
});
@HostListener('document:keydown', ['$event'])
onGlobalKey(ev: KeyboardEvent): void {
try {
const target = ev.target as HTMLElement | null;
if (target?.closest?.('input, textarea, select, [contenteditable="true"]')) return;
if (ev.ctrlKey || ev.metaKey || ev.altKey) return;
if (ev.key !== 's' && ev.key !== 'S') return;
if (!this.canSubscribe() || !this.isLoggedIn() || this.subscribeBusy()) return;
ev.preventDefault();
this.toggleSubscribeShortcut();
} catch {}
}
onAvatarError(): void {
this.channelAvatarBroken.set(true);
}
onSubscribedFromWatch(meta: ChannelMeta | null): void {
this.subscribeError.set(null);
if (meta) this.channelMeta.set(meta);
}
onUnsubscribedFromWatch(): void {
this.subscribeError.set(null);
}
/** Fallback quand le bouton n'est pas utilisable : même logique que SubscribeButton. */
toggleSubscribeShortcut(): void {
const provider = this.subscribeProvider();
const externalId = this.subscribeExternalId();
if (!provider || !externalId || this.subscribeBusy()) return;
this.subscribeBusy.set(true);
this.subscribeError.set(null);
const isSub = this.subs.isSubscribed(provider, externalId);
if (isSub) {
const current = this.subs.items().find((i) => i.channel.provider === provider && i.channel.externalId === externalId);
if (!current) { this.subscribeBusy.set(false); return; }
this.subs.unsubscribe(current.subscriptionId).subscribe({
next: (ok) => { if (!ok) this.subscribeError.set("Désabonnement impossible. Réessayez."); },
error: () => this.subscribeError.set("Désabonnement impossible. Réessayez."),
complete: () => this.subscribeBusy.set(false),
});
} else {
this.subs.subscribe(provider, externalId, { refresh: true }).subscribe({
next: (item) => {
if (item?.channel) this.channelMeta.set(item.channel);
else this.subscribeError.set("Abonnement impossible. Réessayez.");
},
error: () => this.subscribeError.set("Abonnement impossible. Réessayez."),
complete: () => this.subscribeBusy.set(false),
});
}
}
private resolveChannelMeta(): void {
const provider = this.subscribeProvider();
// subscribeExternalId dépend de video(), donc on le lit après chaque merge.
const externalId = this.subscribeExternalId();
if (!provider || !externalId) return;
try {
this.channels.get(provider, externalId).subscribe({
next: (meta) => { if (meta) this.channelMeta.set(meta); },
error: () => {},
});
} catch {}
}
constructor() {
// Charge le cache d'abonnements pour que isSubscribed() soit fiable sur /watch.
try {
if (this.subs.items().length === 0) this.subs.list().subscribe();
} catch {}
this.routeSubscription = this.route.paramMap.subscribe(params => {
const id = params.get('id');
const providerFromPath = params.get('provider');
@@ -398,7 +535,14 @@ export class WatchComponent implements OnDestroy, AfterViewInit {
// Clear any previous Rumble embed URL to avoid stale iframes during fast navigation
this.rumbleEmbed.set(null);
// Prepare placeholder from router state if available for richer UI
const stateVideo = (history.state && (history.state as any).video) as Video | undefined;
const stateVideo = (history.state && (history.state as any).video) as (Video & { channelAvatarUrl?: string }) | undefined;
const placeholderAvatar = ((): string => {
const a = stateVideo?.uploaderAvatar || '';
const thumb = stateVideo?.thumbnail || '';
// N'affiche jamais la miniature vidéo comme logo chaîne.
if (a && thumb && a === thumb) return '';
return a;
})();
this.video.set({
url: stateVideo?.url || '',
type: stateVideo?.type || 'video',
@@ -406,7 +550,10 @@ export class WatchComponent implements OnDestroy, AfterViewInit {
thumbnail: stateVideo?.thumbnail || '',
uploaderName: stateVideo?.uploaderName || '',
uploaderUrl: stateVideo?.uploaderUrl || '',
uploaderAvatar: stateVideo?.uploaderAvatar || '',
uploaderAvatar: placeholderAvatar,
channelId: (stateVideo as any)?.channelId,
channelExternalId: (stateVideo as any)?.channelExternalId,
channelAvatarUrl: (stateVideo as any)?.channelAvatarUrl,
uploadedDate: stateVideo?.uploadedDate || '',
shortDescription: stateVideo?.shortDescription || '',
duration: stateVideo?.duration || 0,
@@ -421,7 +568,14 @@ export class WatchComponent implements OnDestroy, AfterViewInit {
audioStreams: [],
relatedStreams: [],
comments: []
});
} as VideoDetail);
// Reset état chaîne à chaque navigation.
this.channelMeta.set(null);
this.channelAvatarBroken.set(false);
this.subscribeError.set(null);
this.subscribeBusy.set(false);
// Si le state contient déjà un channelId, pré-résout l'avatar sans attendre /details.
this.resolveChannelMeta();
this.showFullDescription.set(false);
this.summary.set(null);
this.isSummarizing.set(false);
@@ -462,13 +616,20 @@ export class WatchComponent implements OnDestroy, AfterViewInit {
title: data?.title || v.title,
thumbnail: data?.thumbnail || v.thumbnail,
uploaderName: data?.uploaderName || v.uploaderName,
uploaderUrl: (data as any)?.uploaderUrl || (v as any).uploaderUrl,
uploaderAvatar: data?.uploaderAvatar || v.uploaderAvatar,
channelId: (data as any)?.channelId || (v as any)?.channelId,
channelExternalId: (data as any)?.channelExternalId || (v as any)?.channelExternalId,
channelAvatarUrl: (data as any)?.uploaderAvatar || (v as any)?.channelAvatarUrl,
uploadedDate: data?.uploadedDate || v.uploadedDate,
views: typeof data?.views === 'number' ? data.views : v.views,
subscribers: typeof (data as any)?.subscribers === 'number' && (data as any).subscribers > 0 ? (data as any).subscribers : v.subscribers,
duration: typeof data?.duration === 'number' ? data.duration : v.duration,
description: data?.description || v.description,
url: data?.url || v.url,
} : v);
this.channelAvatarBroken.set(false);
this.resolveChannelMeta();
this.loading.set(false);
this.loadRelatedSuggestions();
// Update watch history with enriched title/thumbnail/duration
@@ -523,12 +684,20 @@ export class WatchComponent implements OnDestroy, AfterViewInit {
title: data?.title || v.title,
thumbnail: data?.thumbnail || v.thumbnail,
uploaderName: data?.uploaderName || v.uploaderName,
uploaderUrl: (data as any)?.uploaderUrl || (v as any).uploaderUrl,
uploaderAvatar: (data as any)?.uploaderAvatar || v.uploaderAvatar,
channelId: (data as any)?.channelId || (v as any)?.channelId,
channelExternalId: (data as any)?.channelExternalId || (v as any)?.channelExternalId,
channelAvatarUrl: (data as any)?.uploaderAvatar || (v as any)?.channelAvatarUrl,
subscribers: typeof (data as any)?.subscribers === 'number' && (data as any).subscribers > 0 ? (data as any).subscribers : v.subscribers,
uploadedDate: data?.uploadedDate || v.uploadedDate,
views: typeof data?.views === 'number' ? data.views : v.views,
duration: typeof data?.duration === 'number' ? data.duration : v.duration,
description: (data?.description || v.description || v.shortDescription || ''),
url: data?.url || v.url,
} : v);
this.channelAvatarBroken.set(false);
this.resolveChannelMeta();
this.loading.set(false);
this.loadRelatedSuggestions();
// Update watch history with enriched title/thumbnail/duration
@@ -883,9 +1052,9 @@ export class WatchComponent implements OnDestroy, AfterViewInit {
if (slug.startsWith('/')) slug = slug.slice(1);
qp.slug = slug;
}
if (p === 'twitch' && v.type === 'channel') {
if (p === 'twitch' && (v.type === 'channel' || (v as any).type === 'live')) {
// Prefer explicit videoId as channel login; fallback to parsing URL
if (v.videoId) {
if (v.videoId && !/^\d+$/.test(String(v.videoId))) {
qp.channel = v.videoId;
} else {
const url = v.uploaderUrl || v.url || '';