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 = Object.fromEntries( Object.entries(SHORT_TO_LONG).map(([shortId, longId]) => [longId, shortId]), ) as Record; /** 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 = { 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 = { 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(null); items = signal([]); index = signal(0); nextCursor = signal(null); busyMore = signal(false); /** Mode "Pour toi" : flux perso multi-fournisseurs (voir loadForYou). */ forYou = signal(false); topics = signal([]); topicsLoading = signal(false); // ---- Expérience façon YouTube Shorts ---- /** Cadre 9:16 (plein écran + focus). */ frameRef = viewChild>('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/ (comme /watch). Les IDs de liste approximatifs * ("Video not found") sont ainsi corrigés ou retirés du flux. */ resolvedRumbleEmbed = signal>({}); private rumblePending = new Set(); private rumbleInflight = new Set(); private rumbleEmbedFailed = new Set(); 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(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(); /** 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(); /** * 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(null); /** Clés déjà tentées pour la résolution paresseuse d'avatar (anti-boucle). */ private avatarResolved = new Set(); // 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 => { 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