946 lines
39 KiB
TypeScript
946 lines
39 KiB
TypeScript
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;
|
||
}
|
||
}
|