CI / build-and-test (push) Successful in 14m48s
- 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
1960 lines
81 KiB
TypeScript
1960 lines
81 KiB
TypeScript
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;
|
||
}
|
||
}
|