fix(shorts): flux dedie avec filtre duree serveur + loop + replis

This commit is contained in:
2026-09-27 16:53:01 -04:00
parent ebfb39fec2
commit d0d1c07535
3 changed files with 214 additions and 44 deletions
@@ -18,11 +18,14 @@
<div class="rounded-xl overflow-hidden shadow-2xl ring-1 ring-slate-700/50 bg-black/90"
style="height: calc(100vh - 10rem); max-height: calc(100vh - 10rem); aspect-ratio: 9 / 16; width: auto; max-width: 92vw;">
@if (embedUrl(); as url) {
<iframe [src]="url" class="w-full h-full" frameborder="0"
<iframe [src]="url" [title]="current()?.title || 'Short video'" class="w-full h-full" frameborder="0"
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"
allowfullscreen></iframe>
} @else if (loading()) {
<div class="w-full h-full animate-pulse bg-slate-800"></div>
<div class="w-full h-full flex flex-col gap-3 items-center justify-center bg-slate-800 text-slate-400 text-sm">
<div class="h-8 w-8 rounded-full border-2 border-slate-600 border-t-red-500 animate-spin" aria-hidden="true"></div>
<span role="status">Chargement des Shorts…</span>
</div>
} @else if (error()) {
<div class="w-full h-full flex flex-col gap-3 items-center justify-center text-slate-300 p-6 text-center">
<span>{{ error() }}</span>
@@ -47,6 +50,9 @@
</div>
<!-- Navigation buttons -->
@if (busyMore()) {
<div class="absolute top-2 left-1/2 -translate-x-1/2 px-2 py-1 rounded-full bg-black/60 text-white text-xs" role="status">Chargement…</div>
}
<button (click)="prev()" [disabled]="!canPrev()"
class="absolute -right-14 top-6 hidden md:flex items-center justify-center h-10 w-10 rounded-full bg-slate-800/70 hover:bg-slate-700 text-white ring-1 ring-slate-700/60 disabled:opacity-40"
aria-label="Previous short">
+75 -42
View File
@@ -6,9 +6,11 @@ import { firstValueFrom } from 'rxjs';
import { YoutubeApiService } from '../../services/youtube-api.service';
import { InstanceService, Provider } from '../../services/instance.service';
import { Video } from '../../models/video.model';
import { isShortVideo } from '../../app/shared/utils/video-kind';
import { isShortVideo, YOUTUBE_SHORT_MAX_SECONDS } from '../../app/shared/utils/video-kind';
const SHORTS_QUERY = 'shorts';
/** Requête de secours quand la première ne donne aucun Short. */
const SHORTS_ALT_QUERY = '#shorts';
@Component({
selector: 'app-watch-short',
@@ -34,6 +36,8 @@ export class WatchShortComponent {
private lastSwipeY: number | null = null;
private lastScrollTs = 0;
private lastProvider: string | null = null;
/** true quand la requête de secours (#shorts) a déjà été tentée pour ce chargement. */
private altQueryTried = false;
readonly provider = computed(() => this.instances.selectedProvider());
readonly providersList = computed(() => this.instances.providers());
@@ -62,7 +66,7 @@ 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&controls=1&enablejsapi=1`;
let u = `https://www.youtube-nocookie.com/embed/${encodeURIComponent(v.videoId)}?autoplay=1&mute=1&rel=0&modestbranding=1&playsinline=1&controls=1&enablejsapi=1&loop=1&playlist=${encodeURIComponent(v.videoId)}`;
if (origin) u += `&origin=${encodeURIComponent(origin)}`;
if (ref) u += `&widget_referrer=${encodeURIComponent(ref)}`;
return this.sanitizer.bypassSecurityTrustResourceUrl(u);
@@ -140,16 +144,33 @@ export class WatchShortComponent {
const ids = Array.from(new Set(list.map(v => v.videoId))).slice(0, 50);
try {
const durations = await firstValueFrom(this.api.getYouTubeDurations(ids));
const maxShort = 70; // seconds (include some margin)
return list.filter(v => {
const d = durations?.[v.videoId] ?? 0;
return d > 0 && d <= maxShort;
return d > 0 && d <= YOUTUBE_SHORT_MAX_SECONDS;
});
} catch {
return [];
}
}
/**
* Filtre Shorts YouTube en préférant les durées déjà enrichies par
* searchShortsPage (0 appel API supplémentaire). Ne refait un appel
* videos.list que si aucune durée n'est connue (économie de quota).
* Les durées -1 (= non-embeddable, erreur 153) sont toujours exclues.
*/
private async pickYouTubeShorts(list: Video[]): Promise<Video[]> {
const shorts = list.filter(v =>
typeof v.duration === 'number' && v.duration > 0 && v.duration <= YOUTUBE_SHORT_MAX_SECONDS
);
const allKnown = list.every(v => typeof v.duration === 'number' && v.duration !== 0);
if (shorts.length > 0 || allKnown) return shorts;
// Certaines durées manquent : on les récupère au lieu de jeter les vidéos.
const missing = list.filter(v => typeof v.duration !== 'number' || v.duration === 0);
const fetched = await this.filterYouTubeShorts(missing);
return [...shorts, ...fetched.filter(v => this.isShort(v))];
}
constructor() {
// Recharger le flux quand le provider global change (ex. sélecteur du header).
effect(() => {
@@ -177,6 +198,7 @@ export class WatchShortComponent {
this.items.set([]);
this.index.set(0);
this.nextCursor.set(null);
this.altQueryTried = false;
const ready = this.instances.getProviderReadiness(this.provider());
if (!ready.ready) {
this.loading.set(false);
@@ -204,42 +226,53 @@ export class WatchShortComponent {
});
return;
}
// Default: search for shorts keywords via generic search, then filter.
// Si la recherche ne donne aucun Short, repli sur le trending filtré
// (utile pour Rumble/Odysee/PeerTube/Dailymotion dont la recherche
// plein-texte "shorts" est approximative).
this.api.searchVideosPage(SHORTS_QUERY).subscribe({
next: (res) => {
const raw = (res.items || []).filter(v => !!v.videoId);
this.nextCursor.set(res.nextCursor || null);
const p = this.provider();
if (p === 'youtube' && raw.length) {
this.filterYouTubeShorts(raw).then(list => {
const shorts = list.filter(v => this.isShort(v));
if (shorts.length > 0) {
this.items.set(shorts);
this.index.set(0);
this.error.set(null);
this.loading.set(false);
} else {
this.loadTrendingFallback();
}
}).catch(() => this.loadTrendingFallback());
return;
}
const list = raw.filter(v => this.isShort(v));
if (list.length > 0) {
this.items.set(list);
this.index.set(0);
this.error.set(null);
this.loading.set(false);
} else {
this.loadTrendingFallback();
}
},
error: () => {
this.loadTrendingFallback();
}
// Shorts via le flux dédié (filtre durée côté serveur quand possible).
// Chaîne de replis : recherche "shorts" → recherche "#shorts" → trending filtré
// (utile pour Rumble/Odysee dont la recherche plein-texte reste approximative).
this.api.searchShortsPage(SHORTS_QUERY).subscribe({
next: (res) => this.handleShortsResponse(res, true),
error: () => this.loadShortsAltQueryOrTrending(),
});
}
/** 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);
this.nextCursor.set(res.nextCursor || null);
const show = (list: Video[]) => {
this.items.set(list);
this.index.set(0);
this.error.set(null);
this.loading.set(false);
};
if (this.provider() === 'youtube' && raw.length) {
this.pickYouTubeShorts(raw).then(list => {
const shorts = list.filter(v => this.isShort(v));
if (shorts.length > 0) show(shorts);
else if (allowAltQuery) this.loadShortsAltQueryOrTrending();
else this.loadTrendingFallback();
}).catch(() => {
if (allowAltQuery) this.loadShortsAltQueryOrTrending();
else this.loadTrendingFallback();
});
return;
}
const list = raw.filter(v => this.isShort(v));
if (list.length > 0) show(list);
else if (allowAltQuery) this.loadShortsAltQueryOrTrending();
else this.loadTrendingFallback();
}
/** 2e chance avec une requête alternative avant le repli trending. */
private loadShortsAltQueryOrTrending(): void {
if (this.altQueryTried) {
this.loadTrendingFallback();
return;
}
this.altQueryTried = true;
this.api.searchShortsPage(SHORTS_ALT_QUERY).subscribe({
next: (res) => this.handleShortsResponse(res, false),
error: () => this.loadTrendingFallback(),
});
}
@@ -294,7 +327,7 @@ export class WatchShortComponent {
this.busyMore.set(false);
};
if (p === 'youtube' && raw.length) {
this.filterYouTubeShorts(raw).then((list: Video[]) => apply(list.filter((v: Video) => this.isShort(v)))).catch(() => { apply([]); });
this.pickYouTubeShorts(raw).then((list: Video[]) => apply(list.filter((v: Video) => this.isShort(v)))).catch(() => { apply([]); });
} else {
apply(raw.filter((v: Video) => this.isShort(v)));
}
@@ -308,7 +341,7 @@ export class WatchShortComponent {
return;
}
this.api.searchVideosPage(SHORTS_QUERY, cursor || undefined).subscribe({
this.api.searchShortsPage(SHORTS_QUERY, cursor || undefined).subscribe({
next: (res) => {
handle(res);
},