fix(twitch,rumble,shorts): replis serveur, embed Rumble autoritaire, refonte UX Shorts
CI / build-and-test (push) Successful in 14m15s
CI / build-and-test (push) Successful in 14m15s
This commit is contained in:
@@ -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 à
|
||||
|
||||
Reference in New Issue
Block a user