+
+
-
-
+
-
-
-
-
- @if (embedUrl(); as url) {
-
- } @else if (loading()) {
-
-
-
Chargement des Shorts…
-
- } @else if (error()) {
-
- {{ error() }}
- Réessayer
-
- }
-
-
- @if (current(); as v) {
-
-
-
![]()
-
-
{{ v.title }}
-
@{{ v.uploaderName }}
-
-
Ouvrir
-
-
- }
-
-
-
-
-
- @if (busyMore()) {
-
Chargement…
- }
-
-
-
-
-
-
+
-
- Préc.
+
+
+
+
+
+
+
+ @if (embedUrl(); as url) {
+
+ } @else if (loading()) {
+
+
+
Chargement des Shorts…
+
+ } @else if (error()) {
+
+ {{ error() }}
+ Réessayer
+
+ }
+
+ @if (current(); as v) {
+
+
+
+ @if (isFullscreen()) {
+
+ } @else {
+
+ }
+
+
+
+
+
+
+
+
+ @if (menuOpen()) {
+
+ }
+
+
+
+
+
+
+ @if (muted()) {
+
+ } @else {
+
+ }
+
+
+
+
+
+
+
![]()
+
+
{{ v.title }}
+
@{{ v.uploaderName }}
+
+ @if (isLoggedIn() && currentChannelKey()) {
+
+ {{ isSubscribed() ? 'Abonné' : "S'abonner" }}
+
+ }
+
+
+ @if (ytProgress() != null) {
+
+ }
+
+ }
+
+ @if (shareCopied()) {
+
Lien copié ✓
+ }
+
+
+
+
+
+
+
+
+
+
+ Partager
+
+
+
+
+
+
+
+
+
+
+ @if (busyMore()) {
+
Chargement…
+ }
+
+
+
+
+ ↑ Préc.
@if (items().length > 0) {
@@ -97,12 +172,12 @@
}
- Suiv.
-
+ Suiv. ↓
+
@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