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:
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user