Files
NewTube/src/components/shorts/watch-short.component.ts
T
2026-09-29 09:38:13 -04:00

946 lines
39 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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, forkJoin, of } from 'rxjs';
import { catchError } from 'rxjs/operators';
import { YoutubeApiService } from '../../services/youtube-api.service';
import { InstanceService, Provider } from '../../services/instance.service';
import { HistoryService } from '../../services/history.service';
import { LikesService } from '../../services/likes.service';
import { DownloadService } from '../../services/download.service';
import { SubscriptionsService, GROUP_ICON_KEYWORDS } from '../../services/subscriptions.service';
import { AuthService } from '../../services/auth.service';
import { Video } from '../../models/video.model';
import { isShortVideo, YOUTUBE_SHORT_MAX_SECONDS } from '../../app/shared/utils/video-kind';
/** Durée max d'un Short Rumble natif (rumble.com/shorts : vertical ≤ 90 s). */
const RUMBLE_SHORT_MAX_SECONDS = 90;
/** Mots vides FR/EN + termes vidéo génériques (extraction de sujets "Pour toi"). */
const TOPIC_STOPWORDS = new Set([
'les', 'des', 'une', 'dans', 'avec', 'pour', 'sur', 'par', 'pas', 'plus', 'tout', 'tous', 'toute', 'toutes',
'cette', 'ces', 'son', 'sa', 'ses', 'mon', 'ma', 'mes', 'ton', 'ta', 'tes', 'notre', 'votre', 'leurs',
'qui', 'que', 'quoi', 'dont', 'est', 'sont', 'été', 'être', 'avoir', 'faire', 'fait', 'faites', 'vous',
'nous', 'ils', 'elles', 'mais', 'comme', 'aussi', 'entre', 'sans', 'sous', 'chez', 'vers', 'très',
'the', 'and', 'for', 'with', 'from', 'that', 'this', 'these', 'those', 'have', 'has', 'had', 'will',
'would', 'can', 'all', 'your', 'you', 'our', 'their', 'they', 'them', 'she', 'her', 'his', 'him',
'are', 'was', 'were', 'been', 'being', 'what', 'when', 'where', 'which', 'how', 'why', 'not', 'but',
'video', 'vidéo', 'videos', 'vidéos', 'official', 'officiel', 'officielle', 'clip', 'clips', 'live',
'short', 'shorts', 'bande', 'annonce', 'trailer', 'episode', 'épisode', 'part', 'partie', 'full',
'entier', 'entière', 'complete', 'complet', 'complète', 'best', 'top', 'nouveau', 'nouvelle', 'bestof',
]);
/**
* Rotation de requêtes par provider pour un défilement infini façon YouTube
* Shorts : quand une page/un curseur est épuisé, on passe à la requête
* suivante au lieu d'afficher une impasse. Les requêtes restent génériques
* pour maximiser le nombre de résultats courts.
*/
const SHORTS_QUERIES: Record<string, string[]> = {
youtube: ['shorts', '#shorts', 'vertical video', 'short film', 'funny shorts'],
dailymotion: ['shorts', '#shorts', 'vertical', 'funny', 'music'],
peertube: ['shorts', 'vertical', 'funny', 'music', 'clip'],
rumble: ['shorts', 'funny', 'viral', 'music', 'sports'],
odysee: ['shorts', 'funny', 'crypto', 'music', 'gaming'],
twitch: ['Just Chatting', 'gaming', 'music', 'sports', 'food'],
};
@Component({
selector: 'app-watch-short',
standalone: true,
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [CommonModule, RouterLink],
templateUrl: './watch-short.component.html'
})
export class WatchShortComponent {
private api = inject(YoutubeApiService);
private instances = inject(InstanceService);
private sanitizer = inject(DomSanitizer);
private history = inject(HistoryService);
private likes = inject(LikesService);
private downloads = inject(DownloadService);
private subs = inject(SubscriptionsService);
private auth = inject(AuthService);
loading = signal(true);
error = signal<string | null>(null);
items = signal<Video[]>([]);
index = signal(0);
nextCursor = signal<string | null>(null);
busyMore = signal(false);
/** Mode "Pour toi" : flux perso multi-fournisseurs (voir loadForYou). */
forYou = signal(false);
topics = signal<string[]>([]);
topicsLoading = signal(false);
/** IDs déjà vus (dedup inter-pages pour l'infini). */
private seenIds = new Set<string>();
/** Position dans la rotation SHORTS_QUERIES pour le provider courant. */
private queryIndex = 0;
/** Requêtes déjà épuisées (curseur null) pour ce chargement. */
private exhaustedQueries = new Set<string>();
// scroll/swipe helpers
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();
return (i >= 0 && i < list.length) ? list[i] : null;
});
readonly canPrev = computed(() => this.index() > 0);
readonly canNext = computed(() =>
this.index() < this.items().length - 1 || (!this.forYou() && (!!this.nextCursor() || this.hasMoreQueries()))
);
/** Provider effectif d'une vidéo : en mode "Pour toi", chaque item garde son fournisseur. */
private providerOf(v: Video | null): Provider {
if (this.forYou() && v) {
const p = String((v as any).provider || '').toLowerCase();
if (p === 'youtube' || p === 'dailymotion' || p === 'twitch' || p === 'peertube' || p === 'rumble' || p === 'odysee') {
return p as Provider;
}
}
return this.provider();
}
private queriesFor(p: string): string[] {
return SHORTS_QUERIES[p] || SHORTS_QUERIES['youtube'];
}
private currentQuery(): string {
const list = this.queriesFor(this.provider());
return list[this.queryIndex % list.length];
}
private hasMoreQueries(): boolean {
const list = this.queriesFor(this.provider());
return this.exhaustedQueries.size < list.length;
}
readonly embedUrl = computed<SafeResourceUrl | null>(() => {
const v = this.current();
if (!v) return null;
const p = this.providerOf(v);
try {
const host = (location && location.hostname) ? location.hostname : 'localhost';
if (p === 'youtube' && v.videoId) {
// Strictement la même base que /watch (youtube-nocookie + origin +
// widget_referrer + enablejsapi). Le flag videoEmbeddable + le filtre
// des vidéos non-embeddables (durée -1) évitent l'erreur 153 ; les
// params loop/playlist ont été retirés car ils déclenchaient 153
// 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&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) {
// Nouveau player obligatoire depuis fév. 2026 (l'ancien /embed est
// coupé et répond "Forbidden"). Seuls mute/startTime/loop passent
// en query sans Player ID ; autoplay est tenté au mieux.
const u = `https://geo.dailymotion.com/player.html?video=${encodeURIComponent(v.videoId)}&mute=1&autoplay=1`;
return this.sanitizer.bypassSecurityTrustResourceUrl(u);
}
if (p === 'twitch' && v.videoId) {
const parentsSet = new Set<string>([host, 'localhost', '127.0.0.1']);
const parentParams = Array.from(parentsSet).map(h => `parent=${encodeURIComponent(h)}`).join('&');
// Les Shorts Twitch = clips uniquement (jamais de live/channel).
const clipId = (v as any).kind === 'clip' ? v.videoId : null;
if (clipId) {
const u = `https://clips.twitch.tv/embed?clip=${encodeURIComponent(clipId)}&${parentParams}&autoplay=true`;
return this.sanitizer.bypassSecurityTrustResourceUrl(u);
}
// Repli : player channel/VOD (ne devrait pas arriver, les lives
// sont exclus du flux Shorts).
const base = 'https://player.twitch.tv/';
const chan = !/^\d+$/.test(String(v.videoId)) ? v.videoId : null;
const qs = chan
? `?channel=${encodeURIComponent(chan)}&${parentParams}&autoplay=true&muted=true`
: `?video=${encodeURIComponent(v.videoId)}&${parentParams}&autoplay=true&muted=true`;
return this.sanitizer.bypassSecurityTrustResourceUrl(base + qs);
}
if (p === 'peertube' && v.videoId) {
// L'instance peut différer de l'instance active (navigation, fédération)
// : on la dérive de l'URL vidéo, avec repli sur l'instance active.
// "Sorry this video does not exist" venait d'un mauvais host.
const inst = this.peerTubeHostOf(v) || this.instances.activePeerTubeInstance();
const u = `https://${inst}/videos/embed/${encodeURIComponent(v.videoId)}?autoplay=1&api=1`;
return this.sanitizer.bypassSecurityTrustResourceUrl(u);
}
if (p === 'odysee') {
const slug = this.odyseeSlugOf(v);
if (slug) {
const u = `https://odysee.com/$/embed/${slug}?autoplay=1`;
return this.sanitizer.bypassSecurityTrustResourceUrl(u);
}
}
if (p === 'rumble' && (v.videoId || v.url)) {
// Même base que /watch : /embed/<vId>/?pub=4&autoplay=1.
// autoplay=2/muted=1 n'existent pas côté Rumble ("Video not found").
const id = this.rumbleIdOf(v);
if (id) {
const u = `https://rumble.com/embed/${id}/?pub=4&autoplay=1`;
return this.sanitizer.bypassSecurityTrustResourceUrl(u);
}
}
} catch {}
return null;
});
/** Host PeerTube déduit de l'URL vidéo (fédération), sinon null. */
private peerTubeHostOf(v: Video): string | null {
try {
const raw = String((v as any)?.url || '');
if (/^https?:\/\//i.test(raw)) {
const host = new URL(raw).hostname;
if (host && host.includes('.')) return host;
}
} catch {}
return null;
}
/** Slug Odysee "canal/video" (non encodé : @ : / doivent rester bruts). */
private odyseeSlugOf(v: Video): string | null {
try {
const raw = String((v as any)?.url || '').split(/[?#]/)[0];
if (/^https?:\/\//i.test(raw)) {
const u = new URL(raw);
let path = (u.pathname || '').replace(/^\/+\$?\/+embed\/+/, '').replace(/^\/+/, '').replace(/\/+$/, '');
if (path && path !== '/' && !/^embed$/i.test(path)) return path;
}
} catch {}
const id = String((v as any)?.videoId || '').trim();
return id || null;
}
/** ID court Rumble (vXXXX…), sans encodage (l'embed le veut brut). */
private rumbleIdOf(v: Video): string | null {
try {
const url = String((v as any)?.url || '');
const mUrl = /\/(v[\w-]+?)(?:\.html|\/|$)/i.exec(url);
if (mUrl?.[1]) return mUrl[1];
} catch {}
try {
const raw = String((v as any)?.videoId || '').trim();
const m = /^(v[0-9A-Za-z]+)/.exec(raw);
if (m?.[1]) return m[1];
if (raw) return raw.replace(/\.html$/i, '').split('/').filter(Boolean).pop() || raw;
} 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();
if (!v) return null;
const p = this.providerOf(v);
const qp: any = { p };
if (p === 'twitch' && (v as any).kind === 'clip' && v.videoId) qp.clip = v.videoId;
else if (p === 'twitch' && v.type === 'channel' && v.videoId) qp.channel = v.videoId;
if (p === 'odysee') {
const slug = this.odyseeSlugOf(v);
if (slug) qp.slug = slug;
}
return qp;
});
/** URL "ouvrir chez le fournisseur" (youtube.com/shorts/…, etc.). */
readonly providerUrl = computed<string | null>(() => {
const v = this.current();
if (!v) return null;
const p = this.providerOf(v);
try {
if (p === 'youtube' && v.videoId) return `https://www.youtube.com/shorts/${encodeURIComponent(v.videoId)}`;
if (p === 'dailymotion' && v.videoId) return `https://www.dailymotion.com/video/${encodeURIComponent(v.videoId)}`;
if (p === 'twitch' && v.videoId) {
if ((v as any).kind === 'clip') return `https://clips.twitch.tv/${encodeURIComponent(v.videoId)}`;
return `https://www.twitch.tv/${encodeURIComponent(v.videoId)}`;
}
if (p === 'peertube' && v.videoId) {
const inst = this.peerTubeHostOf(v) || this.instances.activePeerTubeInstance();
return `https://${inst}/w/${encodeURIComponent(v.videoId)}`;
}
if (p === 'odysee' && v.url && /^https?:\/\//i.test(v.url)) return v.url;
if (p === 'rumble') {
if (v.url && /^https?:\/\//i.test(v.url)) return v.url;
const id = this.rumbleIdOf(v);
if (id) return `https://rumble.com/${id}`;
}
} catch {}
return null;
});
// Called when a query returns no usable items; falls back to Trending
private afterNoResults(): void {
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.
private async filterYouTubeShorts(list: Video[]): Promise<Video[]> {
const ids = Array.from(new Set(list.map(v => v.videoId))).slice(0, 50);
try {
const durations = await firstValueFrom(this.api.getYouTubeDurations(ids));
return list.filter(v => {
const d = durations?.[v.videoId] ?? 0;
return d > 0 && d <= YOUTUBE_SHORT_MAX_SECONDS;
});
} catch {
return [];
}
}
/**
* Filtre Shorts YouTube en préférant les durées déjà enrichies par
* searchShortsPage (0 appel API supplémentaire). Ne refait un appel
* videos.list que si aucune durée n'est connue (économie de quota).
* Les durées -1 (= non-embeddable, erreur 153) sont toujours exclues.
*/
private async pickYouTubeShorts(list: Video[]): Promise<Video[]> {
const shorts = list.filter(v =>
typeof v.duration === 'number' && v.duration > 0 && v.duration <= YOUTUBE_SHORT_MAX_SECONDS
);
const allKnown = list.every(v => typeof v.duration === 'number' && v.duration !== 0);
if (shorts.length > 0 || allKnown) return shorts;
// Certaines durées manquent : on les récupère au lieu de jeter les vidéos.
const missing = list.filter(v => typeof v.duration !== 'number' || v.duration === 0);
const fetched = await this.filterYouTubeShorts(missing);
return [...shorts, ...fetched.filter(v => this.isShort(v))];
}
constructor() {
// 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();
}
});
// Pré-chargement façon YouTube Shorts : dès qu'on s'approche de la fin,
// on charge la page suivante pour un défilement sans impasse.
effect(() => {
const i = this.index();
const len = this.items().length;
const cursor = this.nextCursor();
const busy = this.busyMore();
const loading = this.loading();
const fy = this.forYou();
if (!fy && !loading && !busy && len > 0 && i >= len - 3 && (cursor || this.hasMoreQueries())) {
this.fetchNextPage(false);
}
});
}
/**
* Répartiteur du flux Shorts par provider :
* - PeerTube : vidéos locales uniquement (les distantes échouent à
* l'embed : "follow constraints" / "does not exist") ;
* - Rumble : flux natif /shorts (vertical ≤ 90 s) + recherche ;
* - autres : recherche filtrée durée côté serveur quand possible.
*/
private shortsPage(query: string, cursor?: string | null) {
const p = this.provider();
if (p === 'peertube') return this.api.searchPeerTubeShortsPage(query, cursor);
if (p === 'rumble') return this.api.searchRumbleShortsPage(query, cursor);
return this.api.searchShortsPage(query, cursor);
}
selectProvider(p: Provider): void {
if (this.forYou()) this.forYou.set(false);
if (p === this.provider()) {
if (this.items().length === 0) this.loadFeed();
return;
}
this.instances.setSelectedProvider(p);
// L'effect ci-dessus déclenche loadFeed().
}
// ------------------------- Mode "Pour toi" -------------------------
// Flux personnalisé multi-fournisseurs : les sujets sont extraits des
// abonnements (+ groupes), de l'historique de recherche et de visionnage,
// des likes et des téléchargements, puis chaque sujet est cherché en
// Shorts sur tous les fournisseurs et les résultats sont entrelacés.
toggleForYou(): void {
if (this.forYou()) {
this.forYou.set(false);
this.loadFeed();
return;
}
this.forYou.set(true);
this.loadForYou();
}
private loadForYou(): void {
this.loading.set(true);
this.error.set(null);
this.items.set([]);
this.index.set(0);
this.nextCursor.set(null);
this.seenIds = new Set<string>();
this.topicsLoading.set(true);
// Hydrate les abonnements/groupes (signaux) s'ils ne sont pas chargés.
try { if (this.subs.items().length === 0) this.subs.list().subscribe(); } catch {}
try { this.subs.listGroups().subscribe(); } catch {}
const loggedIn = (() => { try { return !!this.auth.currentUser(); } catch { return false; } })();
const search$ = loggedIn ? this.history.getSearchHistory(30).pipe(catchError(() => of([]))) : of([]);
const watch$ = loggedIn ? this.history.getWatchHistory(30).pipe(catchError(() => of([]))) : of([]);
const likes$ = loggedIn ? this.likes.list(100).pipe(catchError(() => of([]))) : of([]);
const dl$ = loggedIn ? this.downloads.listJobs({ limit: 50 }).pipe(catchError(() => of({ items: [] }))) : of({ items: [] });
forkJoin([search$, watch$, likes$, dl$]).subscribe({
next: ([searches, watches, liked, jobs]: any[]) => {
const topics = this.extractTopics(searches || [], watches || [], liked || [], (jobs as any)?.items || []);
this.topics.set(topics);
this.topicsLoading.set(false);
if (!topics.length) {
// Aucun signal : repli sur des sujets génériques populaires.
this.topics.set(['music', 'gaming', 'funny', 'sports']);
}
this.fetchForYou(this.topics());
},
error: () => {
this.topicsLoading.set(false);
this.topics.set(['music', 'gaming', 'funny', 'sports']);
this.fetchForYou(this.topics());
}
});
}
/** Extrait des sujets pondérés (abos ×3, recherches ×2, reste ×1). */
private extractTopics(searches: any[], watches: any[], liked: any[], jobs: any[]): string[] {
const scores = new Map<string, number>();
const fullQueries: Array<{ q: string; w: number }> = [];
const addText = (text: string, weight: number) => {
const clean = String(text || '').toLowerCase().replace(/[#@]/g, ' ').replace(/[^a-zà-ÿ0-9\s'-]/gi, ' ');
for (const tok of clean.split(/[\s'’-]+/)) {
const t = tok.trim().replace(/^-+|-+$/g, '');
if (t.length < 3 || TOPIC_STOPWORDS.has(t) || /^\d+$/.test(t)) continue;
scores.set(t, (scores.get(t) || 0) + weight);
}
};
try {
for (const s of this.subs.items() || []) {
const name = String((s as any)?.channel?.title || (s as any)?.channel?.handle || '');
if (name) addText(name, 3);
}
for (const g of this.subs.subscriptionGroups() || []) {
if ((g as any)?.name) addText(String((g as any).name), 3);
const kw = GROUP_ICON_KEYWORDS[String((g as any)?.icon || '')];
if (kw) addText(kw, 2);
}
} catch {}
for (const h of searches || []) {
const q = String((h as any)?.query || '').trim().slice(0, 40);
if (q) {
fullQueries.push({ q, w: 4 });
addText(q, 2);
}
}
for (const w of watches || []) {
const t = String((w as any)?.title || '');
if (t) addText(t, 1.5);
}
for (const l of liked || []) {
const t = String((l as any)?.title || '');
if (t) addText(t, 1.5);
}
for (const j of jobs || []) {
const t = String((j as any)?.title || (j as any)?.fileName || '');
if (t) addText(t, 1);
}
const ranked = Array.from(scores.entries()).sort((a, b) => b[1] - a[1]).map(([t]) => t);
const out: string[] = [];
const seen = new Set<string>();
for (const fq of fullQueries.slice(0, 3)) {
const key = fq.q.toLowerCase();
if (!seen.has(key)) { seen.add(key); out.push(fq.q); }
}
for (const t of ranked) {
if (out.length >= 8) break;
if (!seen.has(t)) { seen.add(t); out.push(t); }
}
return out;
}
/** Cherche chaque sujet en Shorts sur tous les fournisseurs et entrelace. */
private fetchForYou(topics: string[]): void {
const picks = topics.slice(0, 4);
if (!picks.length) {
this.afterNoResults();
this.loading.set(false);
return;
}
const defs: Array<{ provider: Provider; call: any }> = [];
const ready = (p: Provider) => {
try { return this.instances.getProviderReadiness(p).ready; } catch { return true; }
};
for (const t of picks) {
if (ready('youtube')) defs.push({ provider: 'youtube', call: this.api.searchShortsPage(t, null, 'youtube' as Provider) });
if (ready('dailymotion')) defs.push({ provider: 'dailymotion', call: this.api.searchShortsPage(t, null, 'dailymotion' as Provider) });
if (ready('peertube')) defs.push({ provider: 'peertube', call: this.api.searchPeerTubeShortsPage(t, null) });
if (ready('rumble')) defs.push({ provider: 'rumble', call: this.api.searchRumbleShortsPage(t, null) });
if (ready('odysee')) defs.push({ provider: 'odysee', call: this.api.searchShortsPage(t, null, 'odysee' as Provider) });
}
if (ready('twitch')) {
for (const t of picks.slice(0, 2)) {
defs.push({ provider: 'twitch', call: this.api.searchTwitchClipsPage(t).pipe(catchError(() => of({ items: [], nextCursor: null }))) });
}
}
if (!defs.length) {
this.afterNoResults();
this.loading.set(false);
return;
}
forkJoin(defs.map(d => d.call.pipe(catchError(() => of({ items: [], nextCursor: null }))))).subscribe({
next: (pages: any[]) => {
// Regroupe par provider puis entrelace (tourniquet) pour mixer les sources.
const byProvider: Video[][] = defs.map((d, i) => {
const raw = ((pages[i]?.items || []) as Video[]).filter(v => !!v?.videoId);
const list = raw.filter(v => this.isShort({ ...v, provider: d.provider } as Video));
// Mélange léger par sujet pour varier l'ordre à chaque visite.
for (let k = list.length - 1; k > 0; k--) {
const j = Math.floor(Math.random() * (k + 1));
[list[k], list[j]] = [list[j], list[k]];
}
return list;
});
const merged: Video[] = [];
let added = true;
while (added && merged.length < 80) {
added = false;
for (const arr of byProvider) {
const v = arr.shift();
if (!v) continue;
const key = `${String((v as any).provider || '')}:${String(v.videoId)}`;
if (this.seenIds.has(key)) continue;
this.seenIds.add(key);
merged.push(v);
added = true;
if (merged.length >= 80) break;
}
}
this.items.set(merged);
this.index.set(0);
this.nextCursor.set(null);
if (!merged.length) this.afterNoResults(); else this.error.set(null);
this.loading.set(false);
},
error: () => {
this.afterNoResults();
this.loading.set(false);
}
});
}
retry(): void {
this.loadFeed();
}
/**
* La vidéo courante est illisible (erreur 153 YouTube, Forbidden
* Dailymotion, "Video not found"…): on la retire du flux et on avance,
* façon YouTube Shorts qui saute les contenus indisponibles.
*/
skipUnplayable(): void {
const list = this.items();
const i = this.index();
if (!list.length) return;
const next = list.slice(0, i).concat(list.slice(i + 1));
this.items.set(next);
if (next.length === 0) {
if (this.nextCursor() || this.hasMoreQueries()) {
this.fetchNextPage(true);
} else {
this.afterNoResults();
}
return;
}
this.index.set(Math.min(i, next.length - 1));
if (this.index() >= next.length - 3) this.fetchNextPage(false);
}
loadFeed(): void {
if (this.forYou()) {
this.loadForYou();
return;
}
this.loading.set(true);
this.error.set(null);
this.items.set([]);
this.index.set(0);
this.nextCursor.set(null);
this.seenIds = new Set<string>();
this.queryIndex = 0;
this.exhaustedQueries = new Set<string>();
const ready = this.instances.getProviderReadiness(this.provider());
if (!ready.ready) {
this.loading.set(false);
this.error.set(ready.reason || 'Provider not ready.');
return;
}
const provider = this.provider();
// Twitch : les Clips sont l'équivalent Shorts. La recherche Helix rejette
// les requêtes vides → on part des top streams (getTwitchClipsFeed).
if (provider === 'twitch') {
this.api.getTwitchClipsFeed()?.subscribe({
next: (res: { items: Video[]; nextCursor?: string | null }) => {
const raw: Video[] = (res.items || []).filter((v: Video) => !!v.videoId);
const list: Video[] = this.dedup(raw.filter((v: Video) => this.isShort(v)));
this.items.set(list);
this.index.set(0);
this.nextCursor.set(res.nextCursor || null);
if (list.length === 0) {
// Repli : clips thématiques puis trending, pour ne jamais rester vide.
this.loadTwitchThemeFallback();
} else {
this.error.set(null);
this.loading.set(false);
if (list.length <= 4) this.fetchNextPage(false);
}
},
error: () => {
this.loadTwitchThemeFallback();
}
});
return;
}
// Shorts via le flux dédié (filtre durée côté serveur quand possible).
// Rotation : requête courante → suivante → trending filtré (infini).
this.shortsPage(this.currentQuery()).subscribe({
next: (res) => this.handleShortsResponse(res, true),
error: () => this.tryNextShortsQueryOrTrending(),
});
}
/** Repli Twitch : clips sur requêtes génériques, puis top streams. */
private loadTwitchThemeFallback(): void {
const queries = this.queriesFor('twitch');
const q = queries[this.queryIndex % queries.length] || 'gaming';
this.api.searchTwitchClipsPage(q)?.subscribe({
next: (res) => {
const raw = (res?.items || []).filter(v => !!v.videoId);
const list = this.dedup(raw.filter(v => this.isShort(v)));
if (list.length > 0) {
this.items.set(list);
this.index.set(0);
this.nextCursor.set(res?.nextCursor || null);
this.error.set(null);
this.loading.set(false);
if (list.length <= 4) this.fetchNextPage(false);
} else {
this.api.getTwitchTopStreamsPage()?.subscribe({
next: (top) => {
const clips = (top?.items || []).filter(v => !!v.videoId);
const mapped = this.dedup(clips.map(v => ({ ...v, kind: 'clip' as const })));
this.items.set(mapped);
this.index.set(0);
this.nextCursor.set(top?.nextCursor || null);
if (!mapped.length) this.afterNoResults(); else this.error.set(null);
this.loading.set(false);
},
error: () => { this.afterNoResults(); this.loading.set(false); }
});
}
},
error: () => { this.afterNoResults(); this.loading.set(false); }
});
}
/** Applique le filtre Shorts à une page de résultats (1er passage ou requête de secours). */
private handleShortsResponse(res: { items: Video[]; nextCursor?: string | null }, allowAltQuery: boolean): void {
const raw = (res.items || []).filter(v => !!v.videoId);
this.nextCursor.set(res.nextCursor || null);
if (!res.nextCursor) this.exhaustedQueries.add(this.currentQuery());
const show = (list: Video[]) => {
const deduped = this.dedup(list);
this.items.set(deduped);
this.index.set(0);
this.error.set(null);
this.loading.set(false);
if (deduped.length > 0 && deduped.length <= 4) this.fetchNextPage(false);
};
if (this.provider() === 'youtube' && raw.length) {
this.pickYouTubeShorts(raw).then(list => {
const shorts = this.dedup(list.filter(v => this.isShort(v)));
if (shorts.length > 0) show(shorts);
else if (allowAltQuery) this.tryNextShortsQueryOrTrending();
else this.loadTrendingFallback();
}).catch(() => {
if (allowAltQuery) this.tryNextShortsQueryOrTrending();
else this.loadTrendingFallback();
});
return;
}
const list = this.dedup(raw.filter(v => this.isShort(v)));
if (list.length > 0) show(list);
else if (allowAltQuery) this.tryNextShortsQueryOrTrending();
else this.loadTrendingFallback();
}
/** Passe à la requête suivante de la rotation avant le repli trending. */
private tryNextShortsQueryOrTrending(): void {
const list = this.queriesFor(this.provider());
this.exhaustedQueries.add(this.currentQuery());
if (this.queryIndex < list.length - 1) {
this.queryIndex++;
this.shortsPage(this.currentQuery()).subscribe({
next: (res) => this.handleShortsResponse(res, true),
error: () => this.tryNextShortsQueryOrTrending(),
});
return;
}
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[]) => {
const deduped = this.dedup(list);
this.items.set(deduped);
this.index.set(0);
this.nextCursor.set(null);
if (deduped.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);
}
});
}
// Filtre Shorts — classification partagée, avec tolérance pour les
// providers dont les durées sont souvent inconnues (Rumble/Odysee : le
// scraper renvoie 0) afin de ne jamais afficher une page vide.
private isShort(v: Video): boolean {
const p = (v as any).provider || this.provider();
// Dailymotion : embed désactivé par le propriétaire (allow_embed=false)
// => le player répond "Forbidden" : on exclut avant même l'embed.
if (String(p) === 'dailymotion' && (v as any)?.raw?.allowEmbed === false) return false;
if (String(p) === 'twitch') {
const kind = String((v as any).kind || '').toLowerCase();
if (kind === 'clip') return true;
// Les lives/channels ne sont jamais des Shorts.
const type = String((v as any).type || '').toLowerCase();
if (type === 'live' || type === 'channel' || type === 'stream') return false;
}
const d = Number((v as any).duration || 0);
if (String(p) === 'rumble') {
// Shorts natifs Rumble : vertical ≤ 90 s. Durée inconnue : on garde
// (le flux /shorts natif ne contient que du vertical).
if ((v as any).isShort === true && (d <= 0 || d <= RUMBLE_SHORT_MAX_SECONDS)) return true;
if (!(d > 0)) return true;
return d <= RUMBLE_SHORT_MAX_SECONDS;
}
if ((v as any).isShort === true) {
if (d > 0 && d > 75) return false;
return true;
}
if (String(p) === 'odysee' && !(d > 0)) {
// Durée inconnue : on garde la vidéo (flux infini) plutôt qu'un vide.
return true;
}
return isShortVideo({ ...(v as object), provider: p } as Parameters<typeof isShortVideo>[0]);
}
/** Dedup inter-pages (clé videoId + provider). */
private dedup(list: Video[]): Video[] {
const out: Video[] = [];
for (const v of list) {
const key = `${String((v as any).provider || this.provider())}:${String(v.videoId)}`;
if (!v.videoId || this.seenIds.has(key)) continue;
this.seenIds.add(key);
out.push(v);
}
return out;
}
private fetchNextPage(autoAdvance = false) {
if (this.busyMore() || this.forYou()) return;
const provider = this.provider();
// Cas Twitch : pagination du flux de clips (curseur streams) ou rotation.
if (provider === 'twitch') {
if (!this.nextCursor() && !this.hasMoreQueries() && this.items().length > 0) {
// Boucle infinie : on repart du début quand tout est épuisé.
this.nextCursor.set(null);
}
if (!this.nextCursor()) {
this.rotateTwitchQuery();
return;
}
this.busyMore.set(true);
const cursor = this.nextCursor();
this.api.getTwitchClipsFeed(cursor || undefined)?.subscribe({
next: (res: any) => this.handleMore(res, autoAdvance, () => this.rotateTwitchQuery(autoAdvance)),
error: () => { this.busyMore.set(false); }
});
return;
}
const cursor = this.nextCursor();
if (!cursor) {
// Curseur épuisé → requête suivante de la rotation (infini).
const list = this.queriesFor(provider);
this.exhaustedQueries.add(this.currentQuery());
if (this.queryIndex < list.length - 1) {
this.queryIndex++;
this.busyMore.set(true);
this.shortsPage(this.currentQuery()).subscribe({
next: (res) => this.handleMore(res, autoAdvance),
error: () => { this.busyMore.set(false); }
});
} else if (this.items().length > 0) {
// Tout est épuisé : on boucle sur la première requête (jamais de fin).
this.queryIndex = 0;
this.exhaustedQueries = new Set<string>();
this.busyMore.set(true);
this.shortsPage(this.currentQuery()).subscribe({
next: (res) => this.handleMore(res, autoAdvance),
error: () => { this.busyMore.set(false); }
});
}
return;
}
this.busyMore.set(true);
this.shortsPage(this.currentQuery(), cursor || undefined).subscribe({
next: (res) => {
this.handleMore(res, autoAdvance);
},
error: () => {
this.busyMore.set(false);
}
});
}
/** Passe à la requête Twitch suivante (clips thématiques). */
private rotateTwitchQuery(autoAdvance = false): void {
const list = this.queriesFor('twitch');
if (this.queryIndex < list.length - 1) {
this.queryIndex++;
this.busyMore.set(true);
this.api.searchTwitchClipsPage(this.currentQuery())?.subscribe({
next: (res: any) => this.handleMore(res, autoAdvance),
error: () => { this.busyMore.set(false); }
});
} else if (this.items().length > 0) {
this.queryIndex = 0;
this.busyMore.set(true);
this.api.searchTwitchClipsPage(this.currentQuery())?.subscribe({
next: (res: any) => this.handleMore(res, autoAdvance),
error: () => { this.busyMore.set(false); }
});
} else {
this.busyMore.set(false);
}
}
/** Fusionne une page supplémentaire (dedup + filtre Shorts + avance auto). */
private handleMore(res: any, autoAdvance: boolean, onEmpty?: () => void): void {
const raw: Video[] = ((res.items || []) as Video[]).filter((v: Video) => !!v.videoId);
const p = this.provider();
const apply = (more: Video[]) => {
const deduped = this.dedup(more);
const merged = this.items().concat(deduped);
this.items.set(merged);
this.nextCursor.set(res.nextCursor || null);
if (!res.nextCursor) this.exhaustedQueries.add(this.currentQuery());
if (autoAdvance && deduped.length > 0) {
this.index.update(i => Math.min(i + 1, merged.length - 1));
} else if (deduped.length === 0 && onEmpty) {
onEmpty();
return;
} else if (deduped.length === 0 && !res.nextCursor && this.hasMoreQueries() && p !== 'twitch') {
this.busyMore.set(false);
this.fetchNextPage(autoAdvance);
return;
}
this.busyMore.set(false);
};
if (p === 'youtube' && raw.length) {
this.pickYouTubeShorts(raw).then((list: Video[]) => apply(list.filter((v: Video) => this.isShort(v)))).catch(() => { apply([]); });
} else {
apply(raw.filter((v: Video) => this.isShort(v)));
}
}
next(): void {
if (this.canNext() && this.index() < this.items().length - 1) {
this.index.update(i => i + 1);
} else if (this.nextCursor() || this.hasMoreQueries()) {
this.fetchNextPage(true);
}
}
prev(): void { if (this.canPrev()) this.index.update(i => i - 1); }
@HostListener('document:keydown', ['$event'])
onKey(ev: KeyboardEvent) {
if (this.items().length === 0) return;
if (ev.key === 'ArrowDown') { ev.preventDefault(); this.next(); }
if (ev.key === 'ArrowUp') { ev.preventDefault(); this.prev(); }
}
// Also listen on window to catch events when the cursor is over the iframe
@HostListener('window:wheel', ['$event'])
onWindowWheel(ev: WheelEvent) { this.onWheel(ev); }
@HostListener('window:touchstart', ['$event'])
onWindowTouchStart(ev: TouchEvent) { this.onTouchStart(ev); }
@HostListener('window:touchend', ['$event'])
onWindowTouchEnd(ev: TouchEvent) { this.onTouchEnd(ev); }
// Handle mouse wheel on container (bound in template to allow preventDefault)
onWheel(ev: WheelEvent) {
if (this.items().length === 0) return;
try { ev.preventDefault(); ev.stopPropagation(); } catch {}
const now = Date.now();
if (now - this.lastScrollTs < 250) return; // cooldown to avoid rapid fire
this.wheelAccum += ev.deltaY;
const threshold = 40; // more sensitive for trackpads
if (this.wheelAccum > threshold) { this.wheelAccum = 0; this.lastScrollTs = now; this.next(); }
else if (this.wheelAccum < -threshold) { this.wheelAccum = 0; this.lastScrollTs = now; this.prev(); }
}
onTouchStart(ev: TouchEvent) {
if (this.items().length === 0) return;
this.lastSwipeY = (ev.changedTouches?.[0]?.clientY ?? null);
}
onTouchEnd(ev: TouchEvent) {
if (this.items().length === 0) return;
const y = ev.changedTouches?.[0]?.clientY;
if (this.lastSwipeY == null || y == null) return;
const dy = y - this.lastSwipeY;
const threshold = 50;
if (dy < -threshold) this.next();
else if (dy > threshold) this.prev();
this.lastSwipeY = null;
}
}