fix(shorts): flux dedie avec filtre duree serveur + loop + replis
This commit is contained in:
@@ -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">
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user