Files
NewTube/src/components/shorts/watch-short.component.ts
T
bruno 01e0671de9
CI / build-and-test (push) Successful in 14m48s
feat(lecteur): menu FAB partage /watch et /shorts
- PlayerMenuComponent : FAB circulaire translucide (haut-gauche) + panneau
  deroulant, commun aux deux pages (position, style, open/close, clic exterieur)
- /shorts : consolidation de tous les boutons (fournisseurs, langue, actions)
  dans le menu FAB ; suppression du rail a droite et de la rangee du haut
- /watch : actions (cinema, qualite, j'aime, playlist, transcription,
  telechargement, partage, ouvrir) deplacees dans le menu FAB
- AddToPlaylistComponent : variante menuItem pour s'integrer au menu
- styles .pm-item/.pm-sep/.pm-section-label dans index.css
2026-09-30 22:50:46 -04:00

1960 lines
81 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, ElementRef, inject, signal, viewChild, HostListener, OnDestroy } from '@angular/core';
import { CommonModule } from '@angular/common';
import { RouterLink } from '@angular/router';
import { HttpClient } from '@angular/common/http';
import { DomSanitizer, SafeResourceUrl } from '@angular/platform-browser';
import { firstValueFrom, forkJoin, from, map, of } from 'rxjs';
import { catchError, switchMap } 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 { shortsFeedAccepts, isPlayable as isPlayablePolicy, type PlayableContext } from '../../app/shared/utils/section-policy';
import { SHORT_TO_LONG, type ProviderId } from '../../app/shared/providers/provider-ids';
import { dailymotionEmbedBase } from '../../app/shared/providers/dailymotion-player';
import { PlayerMenuComponent } from '../../app/shared/components/player-menu/player-menu.component';
import { SearchService } from '../../app/search/search.service';
import { ChannelProviderFactory } from '../../app/core/providers/channel/channel-provider.factory';
import {
catalogShortToVideo,
formatShortMeta,
formatCapturedAgo,
normalizeShortsLangMode,
resolveShortsLangs,
SHORTS_LANG_MODES,
type ShortsLangMode,
SHORTS_CATALOG_PAGE_SIZE,
SHORTS_CATALOG_MIN_ITEMS,
} from '../../app/search/shorts-catalog';
/** Provider long (youtube…) -> id court d'abonnement ('yt'…). */
const SUB_PROVIDER_OF: Record<string, ProviderId> = Object.fromEntries(
Object.entries(SHORT_TO_LONG).map(([shortId, longId]) => [longId, shortId]),
) as Record<string, ProviderId>;
/** Clé de persistance du filtre de langues (invité compris, sans backend). */
const LS_SHORTS_LANG_KEY = 'newtube.shorts.langFilter';
type ShortsGlyph = { kind: 'play' } | { kind: 'chat' } | { kind: 'letter'; letter: string };
/**
* Pastilles fournisseurs : disque aux couleurs de la marque (mêmes dégradés
* que `ProviderBadgeComponent`) + glyphe blanc — play YouTube, bulle Twitch,
* initiale sinon. Reconnaissable à 24 px sans charger d'assets.
*/
const SHORTS_PROVIDER_STYLE: Record<string, { disc: string; glyph: ShortsGlyph }> = {
youtube: { disc: 'bg-gradient-to-br from-red-600 to-red-500', glyph: { kind: 'play' } },
dailymotion: { disc: 'bg-gradient-to-br from-sky-600 to-sky-500', glyph: { kind: 'letter', letter: 'd' } },
twitch: { disc: 'bg-gradient-to-br from-violet-600 to-purple-500', glyph: { kind: 'chat' } },
peertube: { disc: 'bg-gradient-to-br from-amber-500 to-yellow-400', glyph: { kind: 'letter', letter: 'P' } },
rumble: { disc: 'bg-gradient-to-br from-emerald-600 to-green-500', glyph: { kind: 'letter', letter: 'R' } },
odysee: { disc: 'bg-gradient-to-br from-pink-500 to-fuchsia-500', glyph: { kind: 'letter', letter: 'O' } },
};
/** 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, PlayerMenuComponent],
templateUrl: './watch-short.component.html'
})
export class WatchShortComponent implements OnDestroy {
private api = inject(YoutubeApiService);
private instances = inject(InstanceService);
private sanitizer = inject(DomSanitizer);
private http = inject(HttpClient);
private history = inject(HistoryService);
private likes = inject(LikesService);
private downloads = inject(DownloadService);
private subs = inject(SubscriptionsService);
private auth = inject(AuthService);
/**
* Phase Shorts-catalogue — le flux est alimenté par le catalogue unifié
* (`/api/search?type=shorts` : cache, provenance, feature-flags, capacités),
* le chemin historique direct restant en repli (Twitch clips, panne serveur,
* provider sans équivalent Shorts catalogue).
*/
private catalog = inject(SearchService);
private channelStrategies = inject(ChannelProviderFactory);
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);
// ---- Expérience façon YouTube Shorts ----
/** Cadre 9:16 (plein écran + focus). */
frameRef = viewChild<ElementRef<HTMLElement>>('shortsFrame');
/**
* Son ACTIVÉ par défaut : les embeds YouTube / Odysee / Dailymotion /
* PeerTube démarrent avec le son (`mute=0`). C'est une tentative — le
* navigateur peut bloquer l'autoplay sonore sans interaction préalable,
* auquel cas l'utilisateur tape une fois sur la vidéo (geste qui débloque
* les suivantes).
*
* Exceptions (muet forcé, autoplay garanti) :
* - Twitch clips : l'embed `clips.twitch.tv` n'expose AUCUNE API de son —
* `muted=true` est la seule combinaison qui démarre sans geste ;
* - Rumble : `autoplay=1` (son) par défaut, `autoplay=2` (muet) si
* l'utilisateur coupe le son.
*/
muted = signal(false);
/**
* Embed Rumble autoritaire : id demandé -> vrai id scrapé via
* /api/rumble/video/<id> (comme /watch). Les IDs de liste approximatifs
* ("Video not found") sont ainsi corrigés ou retirés du flux.
*/
resolvedRumbleEmbed = signal<Record<string, string>>({});
private rumblePending = new Set<string>();
private rumbleInflight = new Set<string>();
private rumbleEmbedFailed = new Set<string>();
private readonly RUMBLE_RESOLVE_CONCURRENCY = 3;
/** J'aime (état + busy). */
liked = signal(false);
private likedCheckedFor = '';
likeBusy = signal(false);
/** Abonnement à la chaîne du short courant. */
subBusy = signal(false);
/** Partage : "copié" éphémère. */
shareCopied = signal(false);
private shareCopiedTimer: any = null;
/** Menu ⋯. */
menuOpen = signal(false);
/** Progression YouTube (0..1), null si inconnue / non-YouTube. */
ytProgress = signal<number | null>(null);
/** Plein écran actif. */
isFullscreen = signal(false);
readonly isLoggedIn = computed(() => {
try { return !!this.auth.currentUser(); } catch { return false; }
});
/** Id court d'abonnement + id externe de la chaîne courante (null si inconnu). */
readonly currentChannelKey = computed<{ short: 'yt' | 'dm' | 'tw' | 'pt' | 'od' | 'ru'; externalId: string } | null>(() => {
const v = this.current();
if (!v) return null;
const p = this.providerOf(v);
const short = SUB_PROVIDER_OF[p];
const externalId = String((v as any)?.channelExternalId || (v as any)?.channelId || (v as any)?.channelHandle || '').trim();
if (!short || !externalId) return null;
return { short, externalId };
});
readonly isSubscribed = computed(() => {
const key = this.currentChannelKey();
if (!key) return false;
try { return this.subs.isSubscribed(key.short, key.externalId); } catch { return 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>();
/**
* Alimentation catalogue active : la pagination suit alors les pages
* `/api/search` (`catalogPage`), pas les curseurs du chemin historique.
* Toute bascule vers le repli historique remet ce drapeau à `false`.
*/
private catalogActive = false;
private catalogPage = 1;
/** Erreur non bloquante du catalogue (« source dégradée », esprit 7.4). */
readonly catalogError = signal<string | null>(null);
/** Clés déjà tentées pour la résolution paresseuse d'avatar (anti-boucle). */
private avatarResolved = 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());
/** Pastilles fournisseurs enrichies du style de disque + glyphe (logos). */
readonly providerButtons = computed(() => this.providersList().map((p) => {
const style = SHORTS_PROVIDER_STYLE[String(p?.id || '')] || {
disc: 'bg-slate-700',
glyph: { kind: 'letter', letter: String(p?.label || '?').trim().charAt(0).toUpperCase() } as ShortsGlyph,
};
return { ...p, disc: style.disc, glyph: style.glyph };
}));
/** Lettre du glyphe (`@switch` ne rétrécit pas toujours l'union en template). */
glyphLetter(p: { glyph?: ShortsGlyph } | null | undefined): string {
const g = p?.glyph;
return g && g.kind === 'letter' ? g.letter : '';
}
/**
* Filtre de langues préférées de la page (persisté, invité compris) :
* `profile` = langues du profil utilisateur (défaut), sinon restriction
* explicite. Chaque bouton (chaque provider + « Pour toi ») passe par
* `effectiveShortsLangs()` — un seul filtre pour tout le flux.
*/
readonly langFilter = signal<ShortsLangMode>(((): ShortsLangMode => {
try { return normalizeShortsLangMode(localStorage.getItem(LS_SHORTS_LANG_KEY)); } catch { return 'profile'; }
})());
setLangFilter(mode: ShortsLangMode): void {
const next = normalizeShortsLangMode(mode);
if (next === this.langFilter()) return;
this.langFilter.set(next);
try { localStorage.setItem(LS_SHORTS_LANG_KEY, next); } catch {}
// Le flux courant est filtré avec l'ancien jeu : on recharge pour que le
// changement soit immédiat (même requête, nouveau filtre).
try { this.loadFeed(); } catch {}
}
/**
* Globe cyclique (bouton icon-only) : Profil → Français → Anglais →
* FR+EN → Toutes → Profil… Le filtre actif se lit dans le tooltip et
* l'anneau ambré ; aucun texte affiché.
*/
cycleLangFilter(): void {
const order = [...SHORTS_LANG_MODES];
const at = order.indexOf(this.langFilter());
this.setLangFilter(order[(at + 1) % order.length]);
}
/** Langues effectives : `[]` = aucune restriction (tout passe). */
private effectiveShortsLangs(): string[] {
try {
const profile = this.api.getShortsLanguages();
return resolveShortsLangs(this.langFilter(), profile);
} catch {
return resolveShortsLangs(this.langFilter(), ['en', 'fr']);
}
}
/** Libellé du filtre actif pour le `title` du sélecteur. */
readonly langFilterHint = computed(() => {
try {
const eff = this.effectiveShortsLangs();
const base = eff.length ? eff.join(', ') : 'toutes langues';
return this.langFilter() === 'profile' ? `Filtre actif : ${base} (profil)` : `Filtre actif : ${base}`;
} catch { return 'Filtre de langues'; }
});
/** Libellé court du mode de langue pour le menu FAB (« Français »…). */
readonly langFilterShort = computed(() => {
switch (this.langFilter()) {
case 'profile': return 'Profil';
case 'fr': return 'Français';
case 'en': return 'Anglais';
case 'fren': return 'FR + EN';
case 'all': return 'Toutes';
default: return this.langFilter();
}
});
readonly current = computed<Video | null>(() => {
const list = this.items();
const i = this.index();
return (i >= 0 && i < list.length) ? list[i] : null;
});
/**
* Méta façon YouTube sous le titre (« 12 k vues • il y a 2 j ») : vient du
* catalogue (vues + date réelles), chaque morceau inconnu est omis.
*/
readonly currentMeta = computed(() => {
const v = this.current();
if (!v) return '';
try { return formatShortMeta((v as Video).views, (v as Video).uploadedDate); } catch { return ''; }
});
/** Fraîcheur de collecte catalogue (« capturé il y a 4 min », '' si inconnue). */
readonly currentCaptured = computed(() => {
const v = this.current();
if (!v) return '';
try {
const raw = (v as unknown as { raw?: { capturedAt?: number } })?.raw;
return formatCapturedAgo(raw?.capturedAt);
} catch { return ''; }
});
/** Voie de collecte catalogue (`api`, `innertube`, `cache`… — '' si inconnue). */
readonly currentSource = computed(() => {
const v = this.current();
if (!v) return '';
try {
const raw = (v as unknown as { raw?: { source?: unknown } })?.raw;
const s = String(raw?.source || '').trim();
return s || '';
} catch { return ''; }
});
/** Libellé du fournisseur courant (pastille façon YouTube). */
readonly providerLabel = computed(() => {
try {
const list = this.providersList() as Array<{ id?: unknown; label?: unknown }>;
const hit = (list || []).find((p) => String(p?.id || '') === String(this.provider()));
const label = String(hit?.label || '').trim();
return label || String(this.provider());
} catch { return String(this.provider()); }
});
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 {}
const mute = this.muted() ? 1 : 0;
let u = `https://www.youtube-nocookie.com/embed/${encodeURIComponent(v.videoId)}?autoplay=1&mute=${mute}&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 + Player ID obligatoire depuis fév. 2026 (player.html
// sans Player ID répond "Forbidden"). Démarrage MUET (comme /watch),
// son uniquement sur action explicite du bouton Son.
const withSound = !this.muted() && this.soundTouched();
const u = `${dailymotionEmbedBase(v.videoId)}&mute=${withSound ? 0 : 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) {
// `muted=true` FORCÉ (indépendant du bouton Son) : l'embed clips
// n'expose aucune API de volume — muet est la seule combinaison
// qui démarre sans geste. Le son reste disponible via « Ouvrir
// chez le fournisseur » / Watch.
const u = `https://clips.twitch.tv/embed?clip=${encodeURIComponent(clipId)}&${parentParams}&autoplay=true&muted=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();
// `muted=1` est requis pour que l'autoplay ne soit pas bloqué.
const u = `https://${inst}/videos/embed/${encodeURIComponent(v.videoId)}?autoplay=1&muted=${this.muted() ? 1 : 0}&api=1`;
return this.sanitizer.bypassSecurityTrustResourceUrl(u);
}
if (p === 'odysee') {
const slug = this.odyseeSlugOf(v);
if (slug) {
// Le player Odysee bloque l'autoplay sonore sans geste préalable et
// affiche un rectangle « tap to unmute » à la place de la vidéo :
// on démarre donc MUET (lecture immédiate, pas d'overlay) et on ne
// passe au son QUE sur action explicite du bouton Son — le clic EST
// le geste qui autorise l'autoplay sonore, donc pas de rectangle.
const withSound = !this.muted() && this.soundTouched();
const u = `https://odysee.com/$/embed/${slug}?autoplay=1&muted=${withSound ? 0 : 1}`;
return this.sanitizer.bypassSecurityTrustResourceUrl(u);
}
}
if (p === 'rumble' && (v.videoId || v.url)) {
// Même base que /watch. L'id autoritaire résolu via
// /api/rumble/video/<id> prime (les IDs de liste approximatifs
// donnent "Video not found" alors que Watch fonctionne).
const id = this.rumbleIdOf(v);
const clean = (id && this.resolvedRumbleEmbed()[id]) || id;
if (clean) {
return this.sanitizer.bypassSecurityTrustResourceUrl(this.rumbleEmbedUrl(clean));
}
}
} 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;
}
/**
* URL d'embed Rumble : autoplay=2 = lecture auto (mode muet, compatible
* avec le blocage autoplay des navigateurs) ; autoplay=1 + son sinon.
*/
private rumbleEmbedUrl(id: string): string {
const ap = this.muted() ? 2 : 1;
return `https://rumble.com/embed/${id}/?pub=4&autoplay=${ap}`;
}
/** 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));
// Durée issue de videos.list appliquée à l'item, puis classification centrale.
return list.filter(v => shortsFeedAccepts(
{ ...(v as object), duration: durations?.[v.videoId] ?? 0, provider: 'youtube' } as Video,
'youtube',
));
} 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 => shortsFeedAccepts({ ...(v as object), provider: 'youtube' } as Video, 'youtube'));
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.accepts(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);
}
});
// Hydrate abonnements (état du bouton S'abonner) une fois.
try {
if (this.subs.items().length === 0) this.subs.list().subscribe();
this.subs.listGroups().subscribe();
} catch {}
// Pré-validation Rumble (courant + suivant) + reset progression.
effect(() => {
const v = this.current();
const i = this.index();
const list = this.items();
this.ytProgress.set(null);
this.ytPlaying.set(true);
this.menuOpen.set(false);
if (!v) return;
const nx = (i >= 0 && i + 1 < list.length) ? list[i + 1] : null;
this.queueRumbleResolves([v, nx]);
// Avatar paresseux via le catalogue (`channelMeta`) : le flux n'affiche
// plus une image cassée quand la recherche ne fournit pas d'avatar.
this.ensureAvatar(v);
// Avance auto en fin de vidéo (minuteur générique ; YouTube a en plus
// son événement temps réel, voir handleShortMessage).
this.scheduleAutoAdvance(v);
// Nudge YouTube : si l'autoplay a calé (politique navigateur), une
// commande `playVideo` explicite relance la lecture dès qu'une
// activation utilisateur existe. Sans effet sinon (jamais d'erreur).
this.nudgeYouTubePlay(v);
// État J'aime du short courant (connecté uniquement).
try {
if (this.isLoggedIn() && v.videoId) {
const key = `${String((v as any).provider || '')}:${v.videoId}`;
if (key !== this.likedCheckedFor) {
this.likedCheckedFor = key;
this.liked.set(false);
this.likes.isLiked(this.providerOf(v), v.videoId).subscribe({
next: (r) => { if (this.likedCheckedFor === key) this.liked.set(!!(r as any)?.liked); },
error: () => {},
});
}
} else {
this.liked.set(false);
this.likedCheckedFor = '';
}
} catch {}
});
try { window.addEventListener('message', this.handleShortMessage); } catch {}
try { document.addEventListener('fullscreenchange', this.handleFullscreenChange); } catch {}
}
ngOnDestroy(): void {
try { window.removeEventListener('message', this.handleShortMessage); } catch {}
try { document.removeEventListener('fullscreenchange', this.handleFullscreenChange); } catch {}
try { if (this.autoAdvanceTimer) clearTimeout(this.autoAdvanceTimer); } catch {}
this.autoAdvanceTimer = null;
try { if (this.chromeIdleTimer) clearTimeout(this.chromeIdleTimer); } catch {}
this.chromeIdleTimer = null;
try { if (this.passthroughTimer) clearTimeout(this.passthroughTimer); } catch {}
this.passthroughTimer = null;
if (this.shareCopiedTimer) { try { clearTimeout(this.shareCopiedTimer); } catch {} }
}
/**
* Résout l'avatar de la chaîne du short courant via le catalogue
* (`GET /channels/:provider/:externalId`, une seule requête par vidéo grâce
* au garde `avatarResolved`). Le lot est patché immuablement pour rester
* compatible OnPush. Silence total en échec : pas d'avatar vaut mieux qu'un
* spinner ou une erreur dans un flux infini.
*/
private ensureAvatar(v: Video | null): void {
try {
if (!v?.videoId || v.uploaderAvatar) return;
const extId = String((v as unknown as { channelExternalId?: unknown })?.channelExternalId || '').trim();
if (!extId) return;
const p = this.providerOf(v);
const short = SUB_PROVIDER_OF[p];
if (!short) return;
const key = `${p}:${String(v.videoId)}`;
if (this.avatarResolved.has(key)) return;
this.avatarResolved.add(key);
this.channelStrategies.get(short).getChannelInfo(extId).subscribe({
next: (detail) => {
const avatar = String((detail as unknown as { avatarUrl?: unknown })?.avatarUrl || '').trim();
if (!avatar) return;
const at = this.items().findIndex((x) =>
String(x.videoId) === String(v.videoId) &&
String((x as unknown as { provider?: unknown })?.provider || '') === String((v as unknown as { provider?: unknown })?.provider || ''));
if (at < 0 || this.items()[at]?.uploaderAvatar) return;
const next = this.items().slice();
next[at] = { ...next[at], uploaderAvatar: avatar };
this.items.set(next);
},
error: () => {},
});
} catch {}
}
/** Timer d'avance auto en fin de vidéo (repli générique). */
private autoAdvanceTimer: any = null;
private autoAdvanceKey = '';
/** État de lecture YouTube (via `playerState`, pour le toggle tap). */
private ytPlaying = signal(true);
/** Chrome (boutons/méta) visible — auto-hide sur desktop au repos. */
readonly chromeVisible = signal(true);
private chromeIdleTimer: any = null;
/** Accès direct au lecteur (overlay effacé 6 s, tap non-YouTube). */
readonly passthrough = signal(false);
readonly passthroughHint = signal(false);
private passthroughTimer: any = null;
/**
* Durée effective pour l'avance auto quand la durée réelle est inconnue.
* - clips Twitch : 60 s (plafond plateforme, par construction) ;
* - autres : 90 s (`VERTICAL_SHORT_MAX_SECONDS`, plafond d'un Short).
*
* Compromis assumé : sans signal de fin du player, on ne peut pas savoir
* quand une vidéo sans durée se termine. Le plafond 90 s borne le pire cas
* (écran de fin), et une vidéo plus longue égarée dans le flux est coupée
* à 90 s — acceptable pour un flux Shorts, la navigation manuelle restant
* toujours disponible. Dès que la durée est connue, elle prime.
*/
private estimatedDuration(v: Video): number {
const kind = String((v as unknown as { kind?: unknown })?.kind || '').toLowerCase();
const p = String((v as unknown as { provider?: unknown })?.provider || '').toLowerCase();
if (kind === 'clip' || p === 'twitch') return 60;
return 90;
}
/**
* Avance auto en fin de lecture :
* - YouTube : `playerState` + `infoDelivery` temps réel (voir
* handleShortMessage : 0 = fin → suivant, 2 = pause → minuteur désarmé,
* 1 = reprise → minuteur réarmé) ;
* - tous : minuteur basé sur la durée connue (ou estimée, voir
* `estimatedDuration`) + 2,5 s de grâce pour le buffering. Gardé par
* (index, videoId) : si l'utilisateur a déjà navigué, le timer ne fait
* rien. Ignoré si l'onglet est caché (on ne fait pas défiler un flux
* invisible).
*/
private scheduleAutoAdvance(v: Video | null): void {
try {
if (this.autoAdvanceTimer) { clearTimeout(this.autoAdvanceTimer); this.autoAdvanceTimer = null; }
} catch {}
this.autoAdvanceKey = '';
if (!v?.videoId) return;
const known = Number((v as Video).duration || 0);
const secs = (Number.isFinite(known) && known > 0) ? known : this.estimatedDuration(v);
if (!Number.isFinite(secs) || secs <= 0) return;
const atIndex = this.index();
const id = String(v.videoId);
const key = `${atIndex}:${id}`;
this.autoAdvanceKey = key;
this.autoAdvanceTimer = setTimeout(() => {
this.autoAdvanceTimer = null;
try {
if (typeof document !== 'undefined' && document.hidden) return;
if (this.autoAdvanceKey !== key) return;
const cur = this.current();
if (this.index() === atIndex && cur && String(cur.videoId) === id) this.advanceWithRetry();
} catch {}
}, Math.min(600000, (secs + 2.5) * 1000));
}
/**
* Avance avec réessais bornés : `next()` peut être absorbé sans effet quand
* un pré-chargement est en cours (`busyMore`) ou que la page suivante n'est
* pas encore arrivée — sans réessai, la vidéo reste bloquée sur son écran
* de fin. 2 réessais à 2,5 s puis abandon (jamais de boucle).
*/
private advanceWithRetry(attempt = 0): void {
try {
const before = this.index();
if (!this.items().length || this.loading()) return;
this.next();
if (this.index() !== before) return;
if (attempt < 2) {
setTimeout(() => {
try {
if (this.index() === before && this.items().length > 0 && !this.loading()) {
this.advanceWithRetry(attempt + 1);
}
} catch {}
}, 2500);
}
} catch {}
}
/** Fin de lecture vue par un player (garde anti-double). */
private advanceFromPlayer(id: string): void {
const key = `end:${id}`;
if (this.autoAdvanceKey === key) return;
this.autoAdvanceKey = key;
this.advanceWithRetry();
}
/**
* Relance la lecture YouTube si l'autoplay a calé : commande `playVideo`
* via l'API iframe (`enablejsapi=1` déjà présent dans l'URL d'embed).
* N'agit que sur le short courant (gardé par videoId au moment du tir).
*/
private nudgeYouTubePlay(v: Video | null): void {
try {
if (!v?.videoId || this.providerOf(v) !== 'youtube') return;
const id = String(v.videoId);
const atIndex = this.index();
// Handshake SANS LEQUEL le player YouTube n'émet RIEN (ni infoDelivery
// ni onStateChange) : c'est ce qui rendait l'avance auto et la barre de
// progression inopérantes. Envoyé tout de suite + après chargement.
this.postToPlayer({ event: 'listening' });
setTimeout(() => {
try {
const cur = this.current();
if (this.index() !== atIndex || !cur || String(cur.videoId) !== id) return;
this.postToPlayer({ event: 'listening' });
if (this.ytPlaying()) this.postToPlayer({ event: 'command', func: 'playVideo', args: '' });
} catch {}
}, 1500);
} catch {}
}
/** Post un message à l'iframe du short courant (API YouTube, sans effet sinon). */
private postToPlayer(msg: object): void {
try {
const frame = this.frameRef()?.nativeElement;
const iframe = frame?.querySelector('iframe') as HTMLIFrameElement | null;
iframe?.contentWindow?.postMessage(JSON.stringify(msg), '*');
} catch {}
}
/**
* File de résolution Rumble (concurrence bornée) : chaque id est vérifié
* via le backend (comme /watch). Succès => l'embed bascule sur le vrai
* id ; 404 (page absente, premium…) => la vidéo est retirée du flux
* AVANT d'afficher "Video not found". Autres erreurs réseau => on garde
* l'URL construite (le doute profite à la vidéo).
*/
private queueRumbleResolves(videos: (Video | null)[]): void {
let added = false;
for (const v of videos) {
if (!v || this.providerOf(v) !== 'rumble') continue;
const id = this.rumbleIdOf(v);
if (!id || this.resolvedRumbleEmbed()[id] || this.rumbleEmbedFailed.has(id) ||
this.rumblePending.has(id) || this.rumbleInflight.has(id)) continue;
this.rumblePending.add(id);
added = true;
}
if (added) this.pumpRumbleQueue();
}
private pumpRumbleQueue(): void {
while (this.rumbleInflight.size < this.RUMBLE_RESOLVE_CONCURRENCY) {
const next = [...this.rumblePending][0];
if (!next) return;
this.rumblePending.delete(next);
if (this.resolvedRumbleEmbed()[next] || this.rumbleEmbedFailed.has(next)) continue;
this.rumbleInflight.add(next);
this.http.get<any>(`/api/rumble/video/${encodeURIComponent(next)}`).subscribe({
next: (data) => {
this.rumbleInflight.delete(next);
const eurl = String(data?.embedUrl || '');
const m = /\/embed\/(v[0-9A-Za-z]+)/.exec(eurl);
if (eurl && m?.[1]) {
this.resolvedRumbleEmbed.update(r => ({ ...r, [next]: m[1] }));
} else {
this.rumbleEmbedFailed.add(next);
this.dropRumbleId(next);
}
this.pumpRumbleQueue();
},
error: (err) => {
this.rumbleInflight.delete(next);
// 404 = introuvable de façon certaine => retrait du flux.
if (err?.status === 404) {
this.rumbleEmbedFailed.add(next);
this.dropRumbleId(next);
}
this.pumpRumbleQueue();
}
});
}
}
// ---- Actions façon YouTube Shorts ----
/**
* Geste sonore EXPLICITE (clic sur le bouton Son). Certains players
* (Odysee) refusent le son sans geste : ce drapeau distingue « son par
* défaut » (tentative, peut être bloquée) de « son demandé » (le clic EST
* l'activation qui autorise l'autoplay sonore).
*/
private soundTouched = signal(false);
toggleMute(): void {
this.soundTouched.set(true);
this.muted.update(m => !m);
}
/**
* Chrome auto-hide (desktop) : visible au survol / à l'interaction, masqué
* après 2,5 s d'immobilité — façon YouTube. Sur tactile, `mousemove` ne
* tire presque jamais : le chrome reste visible en permanence.
*/
pokeChrome(): void {
try {
this.chromeVisible.set(true);
if (this.chromeIdleTimer) clearTimeout(this.chromeIdleTimer);
this.chromeIdleTimer = setTimeout(() => {
try { this.chromeVisible.set(false); } catch {}
}, 2500);
} catch {}
}
/**
* Tap sur la vidéo (mobile : l'overlay capte le toucher, l'iframe ne le
* voit pas — voir `.shorts-gesture`) :
* - YouTube : play/pause via l'API iframe (état suivi par `playerState`) ;
* - autres (pas d'API) : 6 s d'accès DIRECT au lecteur — l'overlay
* s'efface et les boutons du fournisseur deviennent touchables, puis
* l'overlay revient seul. Discret et réversible.
*/
private onVideoTap(): void {
try {
const v = this.current();
if (!v) return;
this.pokeChrome();
if (this.providerOf(v) === 'youtube') {
const play = !this.ytPlaying();
this.postToPlayer({ event: 'command', func: play ? 'playVideo' : 'pauseVideo', args: '' });
this.ytPlaying.set(play);
return;
}
this.enterPassthrough();
} catch {}
}
private enterPassthrough(): void {
try {
this.passthrough.set(true);
this.passthroughHint.set(true);
if (this.passthroughTimer) clearTimeout(this.passthroughTimer);
this.passthroughTimer = setTimeout(() => {
try { this.passthrough.set(false); this.passthroughHint.set(false); } catch {}
}, 6000);
} catch {}
}
toggleFullscreen(): void {
try {
if (document.fullscreenElement) {
document.exitFullscreen();
return;
}
const el = this.frameRef()?.nativeElement;
if (el?.requestFullscreen) el.requestFullscreen();
} catch {}
}
private handleFullscreenChange = (): void => {
try { this.isFullscreen.set(!!document.fullscreenElement); } catch {}
};
closeMenu(): void {
this.menuOpen.set(false);
}
copyLink(): void {
const url = this.providerUrl() || (typeof window !== 'undefined' ? window.location.href : '');
if (!url) return;
const done = () => {
this.shareCopied.set(true);
this.menuOpen.set(false);
if (this.shareCopiedTimer) { try { clearTimeout(this.shareCopiedTimer); } catch {} }
this.shareCopiedTimer = setTimeout(() => this.shareCopied.set(false), 2000);
};
try {
if (navigator?.clipboard?.writeText) {
navigator.clipboard.writeText(url).then(done).catch(() => {});
return;
}
} catch {}
}
share(): void {
const v = this.current();
const url = this.providerUrl() || '';
this.menuOpen.set(false);
try {
const nav: any = navigator as any;
if (nav?.share && url) {
nav.share({ title: v?.title || 'Short', url }).catch(() => {});
return;
}
} catch {}
this.copyLink();
}
toggleLike(): void {
const v = this.current();
if (!v?.videoId || !this.isLoggedIn() || this.likeBusy()) return;
const p = this.providerOf(v);
this.likeBusy.set(true);
const done = (liked: boolean) => {
this.liked.set(liked);
this.likeBusy.set(false);
};
if (this.liked()) {
this.likes.unlike(p, v.videoId).subscribe({
next: () => done(false),
error: () => this.likeBusy.set(false),
});
} else {
this.likes.like(p, v.videoId, v.title, v.thumbnail).subscribe({
next: () => done(true),
error: () => this.likeBusy.set(false),
});
}
}
toggleSubscribe(): void {
const key = this.currentChannelKey();
if (!key || !this.isLoggedIn() || this.subBusy()) return;
this.subBusy.set(true);
if (this.isSubscribed()) {
try {
const item = (this.subs.items() || []).find(
s => (s as any)?.channel?.provider === key.short && (s as any)?.channel?.externalId === key.externalId
);
const id = (item as any)?.subscriptionId;
if (id == null) { this.subBusy.set(false); return; }
this.subs.unsubscribe(Number(id)).subscribe({
next: () => this.subBusy.set(false),
error: () => this.subBusy.set(false),
});
} catch { this.subBusy.set(false); }
return;
}
this.subs.subscribe(key.short, key.externalId).subscribe({
next: () => this.subBusy.set(false),
error: () => this.subBusy.set(false),
});
}
/** Progression + erreurs YouTube via postMessage (enablejsapi=1). */
private handleShortMessage = (ev: MessageEvent): void => {
try {
const origin = String(ev?.origin || '');
if (!origin.includes('youtube')) return;
const v = this.current();
if (!v || this.providerOf(v) !== 'youtube') return;
const data = typeof ev?.data === 'string' ? JSON.parse(ev.data) : ev?.data;
if (!data || typeof data !== 'object') return;
// Erreur player (153/150/101…) : on saute automatiquement, comme l'app
// native qui ne montre jamais une erreur à la place d'un Short.
if (data.event === 'onError') {
try { this.skipUnplayable(); } catch {}
return;
}
// Fin/début/pause canoniques (`onStateChange`, plus fiable que le temps :
// pas de marge). Requiert le handshake `listening` (voir nudgeYouTubePlay).
if (data.event === 'onStateChange') {
const st = Number(data.info);
const cur = this.current();
if (!cur || String(cur.videoId) !== String(v.videoId) || !Number.isFinite(st)) return;
const id = String(v.videoId);
if (st === 0) {
this.advanceFromPlayer(id);
} else if (st === 2) {
// Pause utilisateur : état + minuteur désarmé (on n'avance pas une
// vidéo mise en pause).
this.ytPlaying.set(false);
this.autoAdvanceKey = `paused:${id}`;
} else if (st === 1) {
this.ytPlaying.set(true);
if (this.autoAdvanceKey.startsWith('paused:')) this.scheduleAutoAdvance(v);
}
return;
}
if (data.event === 'infoDelivery' && data.info && typeof data.info === 'object') {
const ct = Number(data.info.currentTime);
let dur = Number(data.info.duration);
if (!Number.isFinite(dur) || dur <= 0) dur = Number((v as any)?.duration || 0);
// `playerState` YouTube : 0 = fin, 1 = lecture, 2 = pause.
const state = Number(data.info.playerState);
const cur = this.current();
const same = !!cur && String(cur.videoId) === String(v.videoId);
if (Number.isFinite(state) && same) {
const id = String(v.videoId);
if (state === 0) {
// Fin certaine (plus fiable que le temps : pas de marge) → suivant.
this.advanceFromPlayer(id);
return;
}
if (state === 2) {
// Pause utilisateur : on désarme le minuteur générique pour ne
// pas faire avancer une vidéo mise en pause.
this.ytPlaying.set(false);
this.autoAdvanceKey = `paused:${id}`;
return;
}
if (state === 1) {
// Reprise après pause : on réarme le minuteur (repli, la fin
// temps réel prime de toute façon).
this.ytPlaying.set(true);
if (this.autoAdvanceKey.startsWith('paused:')) this.scheduleAutoAdvance(v);
return;
}
}
if (Number.isFinite(ct) && ct >= 0 && dur > 0) {
this.ytProgress.set(Math.max(0, Math.min(1, ct / dur)));
// Fin de lecture (marge 0,75 s : l'événement arrive par salves) →
// short suivant, façon YouTube. Gardé par videoId : l'événement ne
// doit jamais faire avancer une vidéo déjà quittée. La clé écrase
// aussi le minuteur générique (même garde).
if (ct >= dur - 0.75) {
const cur = this.current();
if (cur && String(cur.videoId) === String(v.videoId)) {
this.advanceFromPlayer(String(v.videoId));
}
}
}
}
} catch {}
};
/**
* 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;
}
/**
* Voie catalogue avec repli historique (mode « Pour toi ») : le catalogue
* est tenté d'abord (cache 60 s, provenance) ; s'il rend moins de
* `SHORTS_CATALOG_MIN_ITEMS` items — ou en erreur — on rejoue l'appel
* historique fourni. Le reste du pipeline (filtre `accepts`, mélange,
* entrelacement) est inchangé.
*/
private catalogOrLegacy(p: Provider, topic: string, legacy: any): any {
const attempt = this.catalog.fetchCatalogShorts(p, topic, { page: 1, pageSize: SHORTS_CATALOG_PAGE_SIZE }).then(
(r) => {
const videos = (r.items || [])
.map((it) => catalogShortToVideo(it))
.filter((v): v is Video => !!v?.videoId);
return videos.length >= SHORTS_CATALOG_MIN_ITEMS ? { items: videos, nextCursor: null } : null;
},
() => null,
).catch(() => null);
return from(attempt).pipe(
switchMap((hit) => hit
? of(hit)
: (legacy as any).pipe(catchError(() => of({ items: [], nextCursor: null })))),
);
}
/** 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.catalogOrLegacy('youtube', t, this.api.searchShortsPage(t, null, 'youtube' as Provider)) });
if (ready('dailymotion')) defs.push({ provider: 'dailymotion', call: this.catalogOrLegacy('dailymotion', t, this.api.searchShortsPage(t, null, 'dailymotion' as Provider)) });
if (ready('peertube')) defs.push({ provider: 'peertube', call: this.catalogOrLegacy('peertube', t, this.api.searchPeerTubeShortsPage(t, null)) });
if (ready('rumble')) defs.push({ provider: 'rumble', call: this.catalogOrLegacy('rumble', t, this.api.searchRumbleShortsPage(t, null)) });
if (ready('odysee')) defs.push({ provider: 'odysee', call: this.catalogOrLegacy('odysee', t, 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.accepts({ ...v, provider: d.provider } as Video) &&
(d.provider !== 'twitch' || this.shortsLangOk({ ...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);
// Pré-validation Rumble (retire les "Video not found").
this.queueRumbleResolves(merged);
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 {
this.dropAt(this.index());
}
/**
* Retire silencieusement l'item à `idx` (pré-validation échouée, erreur
* player…) en gardant la position de lecture cohérente, puis relance le
* flux si la liste se vide (infini).
*/
private dropAt(idx: number): void {
const list = this.items();
if (idx < 0 || idx >= list.length) return;
const cur = this.index();
const next = list.slice(0, idx).concat(list.slice(idx + 1));
this.items.set(next);
if (idx < cur) this.index.set(cur - 1);
else if (cur >= next.length) this.index.set(Math.max(0, next.length - 1));
if (next.length === 0 && !this.loading()) {
if (this.forYou()) {
this.afterNoResults();
} else if (this.nextCursor() || this.hasMoreQueries() || this.provider() === 'rumble' || this.provider() === 'twitch') {
this.fetchNextPage(true);
} else {
this.afterNoResults();
}
} else if (!this.forYou() && this.index() >= Math.max(0, next.length - 3)) {
this.fetchNextPage(false);
}
}
private dropRumbleId(id: string): void {
const list = this.items();
const idx = list.findIndex(v => this.providerOf(v) === 'rumble' && this.rumbleIdOf(v) === id);
if (idx >= 0) this.dropAt(idx);
}
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>();
this.catalogActive = false;
this.catalogError.set(null);
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();
// Shorts via le CATALOGUE d'abord (`/api/search?type=shorts` : cache,
// provenance, feature-flags). Twitch garde son flux de clips historique
// (pas d'équivalent catalogue : la recherche serveur renvoie des VOD).
if (provider === 'twitch') {
this.catalogActive = false;
this.api.getTwitchClipsFeed()?.subscribe({
next: (res: { items: Video[]; nextCursor?: string | null }) => {
const raw: Video[] = (res.items || []).filter((v: Video) => !!v.videoId);
// Langue utilisateur + double sécurité anti "time machine".
this.twitchShortsReady(raw.filter((v: Video) => this.accepts(v))).subscribe({
next: (filtered) => {
const list: Video[] = this.dedup(filtered);
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(),
});
},
error: () => {
this.loadTwitchThemeFallback();
}
});
return;
}
this.loadCatalogFeed();
}
/**
* Voie catalogue : une page `/api/search?type=shorts` pour la requête
* courante, mappée vers le modèle du flux puis soumise aux mêmes portes que
* l'historique (`accepts()` : classification centrale + jouabilité +
* langue, enrichissement des durées YouTube inclus). Sous le seuil
* `SHORTS_CATALOG_MIN_ITEMS` — ou en erreur — on rejoue le chemin
* historique direct : le flux ne reste jamais vide pour une raison
* d'alimentation.
*/
private loadCatalogFeed(): void {
this.catalogActive = true;
this.catalogPage = 1;
this.catalogError.set(null);
const query = this.currentQuery();
this.catalog.fetchCatalogShorts(this.provider(), query, { page: 1, pageSize: SHORTS_CATALOG_PAGE_SIZE }).then(
(res) => {
if (!this.catalogActive) return; // un rechargement a eu lieu entre-temps
if (res.providerError) this.catalogError.set(res.providerError);
const raw = (res.items || [])
.map((it) => catalogShortToVideo(it))
.filter((v): v is Video => !!v?.videoId);
if (this.provider() === 'youtube' && raw.length) {
this.pickYouTubeShorts(raw).then(
(list) => this.showCatalogList(list.filter((v) => this.accepts(v)), query),
() => this.loadLegacyFeed(),
);
return;
}
this.showCatalogList(raw.filter((v) => this.accepts(v)), query);
},
() => this.loadLegacyFeed(),
).catch(() => this.loadLegacyFeed());
}
/** Affiche un lot catalogue (mêmes invariants que la voie historique). */
private showCatalogList(list: Video[], query: string): void {
if (!this.catalogActive) return;
if (list.length >= SHORTS_CATALOG_MIN_ITEMS) {
const deduped = this.dedup(list);
this.items.set(deduped);
this.index.set(0);
this.nextCursor.set(null);
this.error.set(null);
this.loading.set(false);
this.queueRumbleResolves(deduped);
this.ensureAvatar(deduped[0] || null);
if (deduped.length > 0 && deduped.length <= 4) this.fetchNextPage(false);
return;
}
// Lot trop maigre : la requête est marquée épuisée côté catalogue et on
// rejoue l'historique (qui a ses propres requêtes de secours + trending).
this.catalogActive = false;
this.loadLegacyFeed();
}
/**
* Chemin historique direct (requêtes `SHORTS_QUERIES` vers les API
* fournisseurs) : inchangé, il sert de repli au catalogue et reste la voie
* des cas sans équivalent catalogue.
*/
private loadLegacyFeed(): void {
this.catalogActive = false;
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 shorts = raw.filter(v => this.accepts(v));
// Langue utilisateur + validation (recherche non validée côté service).
this.twitchShortsReady(shorts).subscribe({
next: (filtered) => {
const list = this.dedup(filtered);
this.showTwitchFallbackList(list, res?.nextCursor || null);
},
error: () => this.showTwitchFallbackList(this.dedup(shorts), res?.nextCursor || null),
});
},
error: () => { this.afterNoResults(); this.loading.set(false); }
});
}
private showTwitchFallbackList(list: Video[], nextCursor: string | null): void {
if (list.length > 0) {
this.items.set(list);
this.index.set(0);
this.nextCursor.set(nextCursor);
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); }
});
}
}
/** 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);
// Pré-validation Rumble de tout le lot (retire les "Video not found").
this.queueRumbleResolves(deduped);
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.accepts(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.accepts(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.accepts(v)))).catch(() => apply([]));
} else {
apply(raw.filter(v => this.accepts(v)));
}
},
error: () => {
this.afterNoResults();
this.loading.set(false);
}
});
}
/**
* Le feed accepte-t-il cet item ? Séparation stricte :
* - classification : `shortsFeedAccepts` (centre video-kind/section-policy,
* tolérance « durée inconnue » Rumble/Odysee incluse) ;
* - jouabilité : `isPlayable` (allowEmbed, lives/chaînes Twitch, Rumble 404).
*/
private accepts(v: Video): boolean {
const p = String((v as any).provider || this.provider()).toLowerCase();
const withProvider = { ...(v as object), provider: p } as Video;
return shortsFeedAccepts(withProvider, p) && this.isPlayable(withProvider) && this.shortsLangOk(withProvider);
}
/** Jouabilité : l'embed répondra-t-il ? (indépendant du type de contenu) */
private isPlayable(v: Video): boolean {
const p = String((v as any).provider || this.provider()).toLowerCase();
const ctx: PlayableContext = { provider: p };
if (p === 'rumble') {
const id = this.rumbleIdOf(v);
if (id) {
ctx.rumbleResolved = !!this.resolvedRumbleEmbed()[id];
ctx.rumbleFailed = this.rumbleEmbedFailed.has(id);
}
}
return isPlayablePolicy(v, ctx);
}
/**
* La vidéo respecte-t-elle les langues préférées (filtre de la page :
* profil utilisateur par défaut, restriction explicite sinon) ?
* On lit `language` (mappé pour DM/PeerTube/Odysee) ou `raw.language`
* (Twitch). Sans métadonnée, le doute profite à la vidéo : YouTube et
* PeerTube sont de toute façon filtrés côté serveur (relevanceLanguage,
* languageOneOf) et Rumble n'expose pas la langue.
*/
private shortsLangOk(v: Video): boolean {
const lang = String((v as any)?.language || (v as any)?.raw?.language || '')
.toLowerCase().split('-')[0];
if (!lang || lang === 'other') return true;
const accepted = this.effectiveShortsLangs();
// `[]` = mode « toutes langues » : tout passe.
if (!accepted.length) return true;
return accepted.includes(lang);
}
/** Shorts Twitch : langue utilisateur + clips morts exclus avant affichage. */
private twitchShortsReady(list: Video[]): import('rxjs').Observable<Video[]> {
const inLang = list.filter(v => this.shortsLangOk(v));
return this.withoutDeadClips(inLang);
}
/**
* Exclut les clips Twitch morts ("time machine") avant affichage.
* Les flux game/broadcaster sont déjà validés côté service ; ce garde-fou
* couvre la recherche de clips thématique (repli).
*/
private withoutDeadClips(list: Video[]): import('rxjs').Observable<Video[]> {
const clips = list.filter(v => String((v as any).kind || '').toLowerCase() === 'clip' && !!v.videoId);
if (!clips.length) return of(list);
return this.api.validateTwitchClipIds(clips.map(v => v.videoId)).pipe(
map(valid => list.filter(v =>
String((v as any).kind || '').toLowerCase() !== 'clip' || !v.videoId || valid.has(v.videoId)
)),
catchError(() => of(list))
);
}
/** 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();
// Voie catalogue : pages `/api/search` successives de la requête courante,
// puis requête suivante de la rotation. Contrairement au chemin historique
// (curseurs opaques), la pagination est un simple compteur de pages.
if (this.catalogActive && provider !== 'twitch') {
this.busyMore.set(true);
this.fetchCatalogMore(this.currentQuery(), this.catalogPage + 1, autoAdvance);
return;
}
// 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);
}
});
}
/**
* Page catalogue suivante (ajout au flux, jamais de remplacement).
* Page vide (tout est déjà vu) → requête suivante de la rotation ; rotation
* épuisée sans rien de neuf → on s'arrête proprement (pas de boucle de
* re-fetch : le cache 60 s resservirait les mêmes items, tous dédupliqués).
*/
private fetchCatalogMore(query: string, page: number, autoAdvance: boolean): void {
const provider = this.provider();
this.catalog.fetchCatalogShorts(provider, query, { page, pageSize: SHORTS_CATALOG_PAGE_SIZE }).then(
(res) => {
if (!this.catalogActive) { this.busyMore.set(false); return; }
const raw = (res.items || [])
.map((it) => catalogShortToVideo(it))
.filter((v): v is Video => !!v?.videoId);
const apply = (list: Video[]) => {
const deduped = this.dedup(list.filter((v) => this.accepts(v)));
if (deduped.length > 0) {
this.catalogPage = page;
const merged = this.items().concat(deduped);
this.items.set(merged);
this.queueRumbleResolves(deduped);
if (autoAdvance) this.index.update((i) => Math.min(i + 1, merged.length - 1));
this.busyMore.set(false);
return;
}
const rotation = this.queriesFor(provider);
if (this.queryIndex < rotation.length - 1) {
this.queryIndex++;
this.catalogPage = 1;
this.fetchCatalogMore(this.currentQuery(), 1, autoAdvance);
} else {
// Rotation complète sans rien de neuf : fin du pré-chargement,
// le flux existant reste consultable (jamais de spinner infini).
this.queryIndex = 0;
this.catalogPage = 1;
this.busyMore.set(false);
}
};
if (provider === 'youtube' && raw.length) {
this.pickYouTubeShorts(raw).then(
(list) => apply(list),
() => { this.busyMore.set(false); },
);
} else {
apply(raw);
}
},
() => { this.busyMore.set(false); },
).catch(() => { 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);
// Pré-validation Rumble du nouveau lot.
this.queueRumbleResolves(deduped);
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.accepts(v)))).catch(() => { apply([]); });
} else if (p === 'twitch' && raw.length) {
// Langue utilisateur + clips paginés non tous validés côté service.
const shorts = raw.filter((v: Video) => this.accepts(v));
this.twitchShortsReady(shorts).subscribe({
next: (clean) => apply(clean),
error: () => apply(shorts),
});
} else {
apply(raw.filter((v: Video) => this.accepts(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();
else if (Math.abs(dy) < 12) this.onVideoTap();
this.lastSwipeY = null;
}
}