diff --git a/server/index.mjs b/server/index.mjs index 83ce3d2..10f038a 100644 --- a/server/index.mjs +++ b/server/index.mjs @@ -3014,6 +3014,19 @@ app.get('/api/twitch-token', async (_req, res) => { } }); +// Top streams Twitch via le token serveur (repli fiable pour Home/Explore +// quand les appels Helix directs du navigateur échouent). +app.get('/api/twitch/top-streams', async (req, res) => { + try { + const first = Math.min(50, Math.max(1, parseInt(String(req.query.first || '24'), 10) || 24)); + const { default: twitch } = await import('./providers/twitch.mjs'); + const items = await twitch.topStreams(first); + return res.json({ items }); + } catch (e) { + return res.status(502).json({ items: [], error: e?.message || 'twitch_top_streams_failed' }); + } +}); + // -------------------- Gemini summarize (server-side) -------------------- // Keeps GEMINI_API_KEY on the server. Client calls POST /api/ai/summarize. app.get('/api/ai/status', (_req, res) => { diff --git a/server/providers/twitch.mjs b/server/providers/twitch.mjs index 2f82345..f4b3cac 100644 --- a/server/providers/twitch.mjs +++ b/server/providers/twitch.mjs @@ -302,6 +302,24 @@ async function searchChannelsMulti(queries, first, auth, after = '') { const handler = { id: 'tw', label: 'Twitch', + /** + * Top streams via le token serveur (retries inclus). + * Utilisé par la route /api/twitch/top-streams : repli fiable quand les + * appels Helix directs du navigateur échouent (proxy, CORS, 429...). + * @param {number} limit + */ + async topStreams(limit = 24) { + try { + const appToken = await getAppAccessToken(); + const auth = { token: appToken.token, clientId: appToken.clientId }; + const first = String(Math.min(100, Math.max(1, Number(limit) || 24))); + const data = await helixGet('/streams', new URLSearchParams({ first }), auth, { retries: 2 }); + return Array.isArray(data?.data) ? data.data.map(mapStream) : []; + } catch (e) { + console.error('Twitch topStreams error:', e?.message || e); + return []; + } + }, /** * Recherche mixte : lives + VODs + clips + chaînes. * @param {string} q diff --git a/src/components/shorts/watch-short.component.html b/src/components/shorts/watch-short.component.html index 7740797..e097783 100644 --- a/src/components/shorts/watch-short.component.html +++ b/src/components/shorts/watch-short.component.html @@ -1,6 +1,6 @@ -
- -
+
+ +
- -
+
- -
- -
- @if (embedUrl(); as url) { - - } @else if (loading()) { -
- - Chargement des Shorts… -
- } @else if (error()) { -
- {{ error() }} - -
- } - - - @if (current(); as v) { -
-
- -
-
{{ v.title }}
-
@{{ v.uploaderName }}
-
- Ouvrir -
-
- } -
- - -
- - - - -
- - @if (busyMore()) { -
Chargement…
- } -
-
- - -
- + + +
+ +
+ @if (embedUrl(); as url) { + + } @else if (loading()) { +
+ + Chargement des Shorts… +
+ } @else if (error()) { +
+ {{ error() }} + +
+ } + + @if (current(); as v) { + +
+ + +
+ + @if (menuOpen()) { + + } +
+
+ + +
+ +
+ + +
+
+ +
+
{{ v.title }}
+
@{{ v.uploaderName }}
+
+ @if (isLoggedIn() && currentChannelKey()) { + + } +
+ + @if (ytProgress() != null) { + + } +
+ } + + @if (shareCopied()) { +
Lien copié ✓
+ } +
+ + +
+
+ + J'aime +
+
+ + + + Commentaires +
+
+ + Partager +
+
+
+ + + +
+ + @if (busyMore()) { +
Chargement…
+ } + + +
+ @if (items().length > 0) { @@ -97,12 +172,12 @@ }
+ @if (forYou() && (topics().length > 0 || topicsLoading())) {

@if (topicsLoading()) { @@ -112,5 +187,5 @@ }

} -

Si une vidéo affiche une erreur (153, Forbidden, introuvable…), utilisez ⏭ Passer : elle est retirée du flux.

+

Molette, flèches ↑ ↓ ou balayage pour zapper. Son coupé par défaut (🔇) : l'activer reprend la vidéo au début. Vidéo illisible ? Menu ⋯ → Passer.

diff --git a/src/components/shorts/watch-short.component.ts b/src/components/shorts/watch-short.component.ts index 680ada2..5672930 100644 --- a/src/components/shorts/watch-short.component.ts +++ b/src/components/shorts/watch-short.component.ts @@ -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 = { + 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 = { 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([]); topicsLoading = signal(false); + + // ---- Expérience façon YouTube Shorts ---- + /** Cadre 9:16 (plein écran + focus). */ + frameRef = viewChild>('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/ (comme /watch). */ + resolvedRumbleEmbed = signal>({}); + private rumbleResolving = new Set(); + private rumbleEmbedFailed = new Set(); + /** J'aime (état + busy). */ + liked = signal(false); + private likedCheckedFor = ''; + likeBusy = signal(false); + /** Abonnement à la chaîne du short courant. */ + subBusy = signal(false); + /** Partage : "copié" éphémère. */ + shareCopied = signal(false); + private shareCopiedTimer: any = null; + /** Menu ⋯. */ + menuOpen = signal(false); + /** Progression YouTube (0..1), null si inconnue / non-YouTube. */ + ytProgress = signal(null); + /** Plein écran actif. */ + isFullscreen = signal(false); + + readonly isLoggedIn = computed(() => { + try { return !!this.auth.currentUser(); } catch { return false; } + }); + + /** Id court d'abonnement + id externe de la chaîne courante (null si inconnu). */ + readonly currentChannelKey = computed<{ short: 'yt' | 'dm' | 'tw' | 'pt' | 'od' | 'ru'; externalId: string } | null>(() => { + const v = this.current(); + if (!v) return null; + const p = this.providerOf(v); + const short = SUB_PROVIDER_OF[p]; + const externalId = String((v as any)?.channelExternalId || (v as any)?.channelId || (v as any)?.channelHandle || '').trim(); + if (!short || !externalId) return null; + return { short, externalId }; + }); + + readonly isSubscribed = computed(() => { + const key = this.currentChannelKey(); + if (!key) return false; + try { return this.subs.isSubscribed(key.short, key.externalId); } catch { return false; } + }); /** IDs déjà vus (dedup inter-pages pour l'infini). */ private seenIds = new Set(); /** Position dans la rotation SHORTS_QUERIES pour le provider courant. */ @@ -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//?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/ (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(`/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 à diff --git a/src/components/themes/provider-theme-page.component.ts b/src/components/themes/provider-theme-page.component.ts index 08d0c54..36d0afd 100644 --- a/src/components/themes/provider-theme-page.component.ts +++ b/src/components/themes/provider-theme-page.component.ts @@ -353,11 +353,23 @@ export class ProviderThemePageComponent implements OnDestroy { if (r['clip']) this.twitchCursors.clip = r['clip'].nextCursor || null; if (r['channel']) this.twitchCursors.channel = r['channel'].nextCursor || null; const items = Object.values(r).flatMap(x => x?.items || []); - // Filet de sécurité : si VODs+clips vides et aucun live, compléter par top streams. + // Filets de sécurité : si VODs+clips vides et aucun live, compléter + // par top streams (repli serveur inclus), puis par la recherche + // serveur unifiée (/api/search?providers=tw). const hasContent = items.length > 0; if (!hasContent && firstPage) { this.api.getTwitchTopStreamsPage(null).subscribe({ - next: (top) => finish([...items, ...(top.items || [])], top.nextCursor || null), + next: (top) => { + const all = [...items, ...(top.items || [])]; + if (all.length) { + finish(all, top.nextCursor || null); + return; + } + this.api.searchTwitchViaServer(query, 24).subscribe({ + next: (srv) => finish([...all, ...(srv.items || [])], srv.nextCursor || null), + error: () => finish(all, null), + }); + }, error: () => finish(items, null), }); return; diff --git a/src/services/youtube-api.service.ts b/src/services/youtube-api.service.ts index 40124a8..46875ad 100644 --- a/src/services/youtube-api.service.ts +++ b/src/services/youtube-api.service.ts @@ -1544,7 +1544,7 @@ export class YoutubeApiService { public getTwitchTopStreamsPage(cursor?: string | null, limit = 24): Observable> { return this.getTwitchAuthToken().pipe(timeout(30000), switchMap(token => { - if (!token) return of({ items: [], nextCursor: null }); + if (!token) return this.getTwitchServerTopStreams(limit); const headers = new HttpHeaders({ 'Client-ID': this.resolveTwitchClientId()!, 'Authorization': `Bearer ${token}` }); const after = cursor ? `&after=${encodeURIComponent(cursor)}` : ''; const primary = `/api/twitch-api/helix/streams?first=${limit}${after}`; @@ -1555,12 +1555,84 @@ export class YoutubeApiService { items: (res.data || []).map((i: any) => this.mapTwitchStreamToVideo(i)), nextCursor: res.pagination?.cursor || null, } as PagedResult