diff --git a/src/components/shorts/watch-short.component.html b/src/components/shorts/watch-short.component.html
index e097783..2458639 100644
--- a/src/components/shorts/watch-short.component.html
+++ b/src/components/shorts/watch-short.component.html
@@ -156,36 +156,6 @@
@if (busyMore()) {
Chargement…
}
-
-
-
-
- @if (items().length > 0) {
-
- {{ index() + 1 }} / {{ items().length }}
-
- } @else {
-
- }
-
-
-
- @if (forYou() && (topics().length > 0 || topicsLoading())) {
-
- @if (topicsLoading()) {
- Analyse de vos goûts…
- } @else {
- Sujets : {{ topics().join(' · ') }}
- }
-
- }
- 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 5672930..69a62b6 100644
--- a/src/components/shorts/watch-short.component.ts
+++ b/src/components/shorts/watch-short.component.ts
@@ -3,7 +3,7 @@ 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 { firstValueFrom, forkJoin, map, of } from 'rxjs';
import { catchError } from 'rxjs/operators';
import { YoutubeApiService } from '../../services/youtube-api.service';
import { InstanceService, Provider } from '../../services/instance.service';
@@ -86,10 +86,16 @@ export class WatchShortComponent implements OnDestroy {
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). */
+ /**
+ * Embed Rumble autoritaire : id demandé -> vrai id scrapé via
+ * /api/rumble/video/ (comme /watch). Les IDs de liste approximatifs
+ * ("Video not found") sont ainsi corrigés ou retirés du flux.
+ */
resolvedRumbleEmbed = signal>({});
- private rumbleResolving = new Set();
+ private rumblePending = new Set();
+ private rumbleInflight = new Set();
private rumbleEmbedFailed = new Set();
+ private readonly RUMBLE_RESOLVE_CONCURRENCY = 3;
/** J'aime (état + busy). */
liked = signal(false);
private likedCheckedFor = '';
@@ -242,16 +248,13 @@ export class WatchShortComponent implements OnDestroy {
}
}
if (p === 'rumble' && (v.videoId || v.url)) {
- // Même base que /watch : /embed//?pub=4&autoplay=1.
- // 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).
+ // Même base que /watch. L'id autoritaire résolu via
+ // /api/rumble/video/ prime (les IDs de liste approximatifs
+ // donnent "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);
+ const clean = (id && this.resolvedRumbleEmbed()[id]) || id;
+ if (clean) {
+ return this.sanitizer.bypassSecurityTrustResourceUrl(this.rumbleEmbedUrl(clean));
}
}
} catch {}
@@ -284,6 +287,15 @@ export class WatchShortComponent implements OnDestroy {
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 {
@@ -411,7 +423,7 @@ export class WatchShortComponent implements OnDestroy {
if (this.subs.items().length === 0) this.subs.list().subscribe();
this.subs.listGroups().subscribe();
} catch {}
- // Résolution Rumble autoritaire (courant + suivant) + reset progression.
+ // Pré-validation Rumble (courant + suivant) + reset progression.
effect(() => {
const v = this.current();
const i = this.index();
@@ -419,9 +431,8 @@ export class WatchShortComponent implements OnDestroy {
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);
+ this.queueRumbleResolves([v, nx]);
// État J'aime du short courant (connecté uniquement).
try {
if (this.isLoggedIn() && v.videoId) {
@@ -450,30 +461,57 @@ export class WatchShortComponent implements OnDestroy {
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);
+ /**
+ * 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(`/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();
}
- },
- error: () => {
- this.rumbleResolving.delete(id);
- this.rumbleEmbedFailed.add(id);
- }
- });
+ });
+ }
}
// ---- Actions façon YouTube Shorts ----
@@ -580,7 +618,7 @@ export class WatchShortComponent implements OnDestroy {
});
}
- /** Progression YouTube via postMessage (enablejsapi=1) : barre rouge façon Shorts. */
+ /** Progression + erreurs YouTube via postMessage (enablejsapi=1). */
private handleShortMessage = (ev: MessageEvent): void => {
try {
const origin = String(ev?.origin || '');
@@ -589,6 +627,12 @@ export class WatchShortComponent implements OnDestroy {
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;
+ }
if (data.event === 'infoDelivery' && data.info && typeof data.info === 'object') {
const ct = Number(data.info.currentTime);
let dur = Number(data.info.duration);
@@ -791,6 +835,8 @@ export class WatchShortComponent implements OnDestroy {
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);
},
@@ -811,21 +857,39 @@ export class WatchShortComponent implements OnDestroy {
* 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();
- const i = this.index();
- if (!list.length) return;
- const next = list.slice(0, i).concat(list.slice(i + 1));
+ 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 (next.length === 0) {
- if (this.nextCursor() || this.hasMoreQueries()) {
+ 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();
}
- return;
+ } else if (!this.forYou() && this.index() >= Math.max(0, next.length - 3)) {
+ this.fetchNextPage(false);
}
- this.index.set(Math.min(i, next.length - 1));
- if (this.index() >= 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 {
@@ -854,18 +918,24 @@ export class WatchShortComponent implements OnDestroy {
this.api.getTwitchClipsFeed()?.subscribe({
next: (res: { items: Video[]; nextCursor?: string | null }) => {
const raw: Video[] = (res.items || []).filter((v: Video) => !!v.videoId);
- const list: Video[] = this.dedup(raw.filter((v: Video) => this.isShort(v)));
- 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);
- }
+ // Double sécurité anti "time machine" sur les clips non validés.
+ this.withoutDeadClips(raw.filter((v: Video) => this.isShort(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();
@@ -888,33 +958,44 @@ export class WatchShortComponent implements OnDestroy {
this.api.searchTwitchClipsPage(q)?.subscribe({
next: (res) => {
const raw = (res?.items || []).filter(v => !!v.videoId);
- const list = this.dedup(raw.filter(v => this.isShort(v)));
- if (list.length > 0) {
- this.items.set(list);
- this.index.set(0);
- this.nextCursor.set(res?.nextCursor || null);
- 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); }
- });
- }
+ const shorts = raw.filter(v => this.isShort(v));
+ // Les clips de recherche ne sont pas validés côté service.
+ this.withoutDeadClips(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);
@@ -926,6 +1007,8 @@ export class WatchShortComponent implements OnDestroy {
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) {
@@ -1022,6 +1105,22 @@ export class WatchShortComponent implements OnDestroy {
return isShortVideo({ ...(v as object), provider: p } as Parameters[0]);
}
+ /**
+ * 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