feat(channel): page details chaine multi-providers avec onglets et infinite scroll
CI / build-and-test (push) Successful in 13m34s
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:
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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 || '';
|
||||
|
||||
Reference in New Issue
Block a user