fix(twitch,rumble,shorts): replis serveur, embed Rumble autoritaire, refonte UX Shorts
CI / build-and-test (push) Successful in 14m15s

This commit is contained in:
2026-09-29 10:39:03 -04:00
parent 3fe05b3c30
commit f76a3795c2
6 changed files with 530 additions and 90 deletions
+257 -7
View File
@@ -1,6 +1,7 @@
import { ChangeDetectionStrategy, Component, computed, effect, inject, signal, HostListener } from '@angular/core';
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, of } from 'rxjs';
import { catchError } from 'rxjs/operators';
@@ -17,6 +18,11 @@ import { isShortVideo, YOUTUBE_SHORT_MAX_SECONDS } from '../../app/shared/utils/
/** Durée max d'un Short Rumble natif (rumble.com/shorts : vertical ≤ 90 s). */
const RUMBLE_SHORT_MAX_SECONDS = 90;
/** Provider long (youtube…) -> id court d'abonnement ('yt'…). */
const SUB_PROVIDER_OF: Record<string, 'yt' | 'dm' | 'tw' | 'pt' | 'od' | 'ru'> = {
youtube: 'yt', dailymotion: 'dm', twitch: 'tw', peertube: 'pt', odysee: 'od', rumble: 'ru',
};
/** 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',
@@ -53,10 +59,11 @@ const SHORTS_QUERIES: Record<string, string[]> = {
imports: [CommonModule, RouterLink],
templateUrl: './watch-short.component.html'
})
export class WatchShortComponent {
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);
@@ -73,6 +80,52 @@ export class WatchShortComponent {
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 coupé (autoplay) : rebascule l'URL d'embed (reprend au début). */
muted = signal(true);
/** Embed Rumble autoritaire résolu via /api/rumble/video/<id> (comme /watch). */
resolvedRumbleEmbed = signal<Record<string, string>>({});
private rumbleResolving = new Set<string>();
private rumbleEmbedFailed = new Set<string>();
/** 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. */
@@ -142,7 +195,8 @@ export class WatchShortComponent {
origin = window.location.origin || '';
ref = window.location.href || '';
} catch {}
let u = `https://www.youtube-nocookie.com/embed/${encodeURIComponent(v.videoId)}?autoplay=1&mute=1&rel=0&modestbranding=1&playsinline=1&enablejsapi=1`;
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);
@@ -151,7 +205,7 @@ export class WatchShortComponent {
// Nouveau player obligatoire depuis fév. 2026 (l'ancien /embed est
// coupé et répond "Forbidden"). Seuls mute/startTime/loop passent
// en query sans Player ID ; autoplay est tenté au mieux.
const u = `https://geo.dailymotion.com/player.html?video=${encodeURIComponent(v.videoId)}&mute=1&autoplay=1`;
const u = `https://geo.dailymotion.com/player.html?video=${encodeURIComponent(v.videoId)}&mute=${this.muted() ? 1 : 0}&autoplay=1`;
return this.sanitizer.bypassSecurityTrustResourceUrl(u);
}
if (p === 'twitch' && v.videoId) {
@@ -160,7 +214,7 @@ export class WatchShortComponent {
// Les Shorts Twitch = clips uniquement (jamais de live/channel).
const clipId = (v as any).kind === 'clip' ? v.videoId : null;
if (clipId) {
const u = `https://clips.twitch.tv/embed?clip=${encodeURIComponent(clipId)}&${parentParams}&autoplay=true`;
const u = `https://clips.twitch.tv/embed?clip=${encodeURIComponent(clipId)}&${parentParams}&autoplay=true&muted=${this.muted()}`;
return this.sanitizer.bypassSecurityTrustResourceUrl(u);
}
// Repli : player channel/VOD (ne devrait pas arriver, les lives
@@ -183,14 +237,18 @@ export class WatchShortComponent {
if (p === 'odysee') {
const slug = this.odyseeSlugOf(v);
if (slug) {
const u = `https://odysee.com/$/embed/${slug}?autoplay=1`;
const u = `https://odysee.com/$/embed/${slug}?autoplay=1&muted=${this.muted() ? 1 : 0}`;
return this.sanitizer.bypassSecurityTrustResourceUrl(u);
}
}
if (p === 'rumble' && (v.videoId || v.url)) {
// Même base que /watch : /embed/<vId>/?pub=4&autoplay=1.
// autoplay=2/muted=1 n'existent pas côté Rumble ("Video not found").
// L'URL autoritaire résolue via /api/rumble/video/<id> (scrape de
// la page, comme /watch) prime : les IDs de liste sont parfois
// approximatifs ("Video not found" alors que Watch fonctionne).
const id = this.rumbleIdOf(v);
const resolved = (id && this.resolvedRumbleEmbed()[id]) || null;
if (resolved) return this.sanitizer.bypassSecurityTrustResourceUrl(resolved);
if (id) {
const u = `https://rumble.com/embed/${id}/?pub=4&autoplay=1`;
return this.sanitizer.bypassSecurityTrustResourceUrl(u);
@@ -348,8 +406,200 @@ export class WatchShortComponent {
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 {}
// Résolution Rumble autoritaire (courant + suivant) + reset progression.
effect(() => {
const v = this.current();
const i = this.index();
const list = this.items();
this.ytProgress.set(null);
this.menuOpen.set(false);
if (!v) return;
if (this.providerOf(v) === 'rumble') this.ensureRumbleEmbed(v);
const nx = (i >= 0 && i + 1 < list.length) ? list[i + 1] : null;
if (nx && this.providerOf(nx) === 'rumble') this.ensureRumbleEmbed(nx);
// É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 {}
if (this.shareCopiedTimer) { try { clearTimeout(this.shareCopiedTimer); } catch {} }
}
/** Résout l'embed Rumble autoritaire via le backend (comme /watch). */
private ensureRumbleEmbed(v: Video): void {
const id = this.rumbleIdOf(v);
if (!id || this.resolvedRumbleEmbed()[id] || this.rumbleResolving.has(id) || this.rumbleEmbedFailed.has(id)) return;
this.rumbleResolving.add(id);
this.http.get<any>(`/api/rumble/video/${encodeURIComponent(id)}`).pipe(
catchError(() => of(null))
).subscribe({
next: (data) => {
this.rumbleResolving.delete(id);
const eurl = String(data?.embedUrl || '');
const m = /\/embed\/(v[0-9A-Za-z]+)/.exec(eurl);
if (eurl && m?.[1]) {
const clean = `https://rumble.com/embed/${m[1]}/?pub=4&autoplay=1`;
this.resolvedRumbleEmbed.update(r => ({ ...r, [id]: clean }));
} else {
this.rumbleEmbedFailed.add(id);
}
},
error: () => {
this.rumbleResolving.delete(id);
this.rumbleEmbedFailed.add(id);
}
});
}
// ---- Actions façon YouTube Shorts ----
toggleMute(): void {
this.muted.update(m => !m);
}
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 {}
};
toggleMenu(): void {
this.menuOpen.update(o => !o);
}
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 YouTube via postMessage (enablejsapi=1) : barre rouge façon Shorts. */
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;
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);
if (Number.isFinite(ct) && ct >= 0 && dur > 0) {
this.ytProgress.set(Math.max(0, Math.min(1, ct / dur)));
}
}
} catch {}
};
/**
* Répartiteur du flux Shorts par provider :
* - PeerTube : vidéos locales uniquement (les distantes échouent à