fix(shorts): orientation autoritaire, langues YouTube et autoplay fiable
CI / build-and-test (push) Successful in 14m18s
CI / build-and-test (push) Successful in 14m18s
- format: des que largeur/hauteur sont connues, l'orientation fait autorite (horizontale/carree => jamais short, meme <75 s). Repli duree reserve aux providers muets - dimensions mappees: Dailymotion (width,height), PeerTube (aspectRatio), Odysee (value.video.width/height) => validation verticale reelle - YouTube: langue via snippet.defaultAudioLanguage (filtre FR/EN) - autoplay: demarrage muet, suppression du deblocage automatique au scroll/selection (reload d'embed hors geste => ecran noir); son via le bouton dedie - PeerTube embed: muted=1 pour l'autoplay - server/search-filters.mjs: miroir de la regle d'orientation + tests mis a jour
This commit is contained in:
@@ -197,6 +197,8 @@ export function isShortItem(item, providerId) {
|
||||
}
|
||||
|
||||
// 2) + 3) Orientation (width/height quand le provider les expose).
|
||||
// Dimensions connues ⇒ l'orientation fait AUTORITÉ : une horizontale/carrée
|
||||
// n'est jamais un short, même courte.
|
||||
const w = numOrZero(item.width);
|
||||
const h = numOrZero(item.height);
|
||||
if (w > 0 && h > 0) {
|
||||
@@ -205,9 +207,10 @@ export function isShortItem(item, providerId) {
|
||||
if (!hasKnownDuration) return false; // règle 3 : pas de durée ⇒ pas short
|
||||
return d <= VERTICAL_SHORT_MAX_SECONDS; // règle 2
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// 4) Repli durée seule (comportement historique inchangé).
|
||||
// 4) Repli durée seule, uniquement quand l'orientation est inconnue.
|
||||
// NOTE : l'ancien code excluait les titres contenant « short »
|
||||
// (`&& !/short/i.test(title)`), ce qui est l'inverse de l'intuition : un
|
||||
// vrai Short titré « … #shorts » était EXCLU du filtre shorts. Les tests
|
||||
|
||||
@@ -209,15 +209,21 @@ expect(
|
||||
!isShortItem({ title: 'clip carré', duration: 80, width: 1080, height: 1080, type: 'video' }),
|
||||
'square 80s is not a short (orientation alone is not enough)',
|
||||
);
|
||||
// …mais à 30 s, la règle 4 (durée seule) le garde : non-régression historique.
|
||||
// Dimensions connues non verticales ⇒ JAMAIS short, même à 30 s
|
||||
// (l'orientation fait autorité ; plus de repli durée quand w/h sont connus).
|
||||
expect(
|
||||
matchesFilters({ id: 'a', title: 'clip carré', duration: 30, width: 1080, height: 1080, type: 'video' }, { type: 'shorts' }),
|
||||
'square 30s still matches shorts via duration fallback',
|
||||
!matchesFilters({ id: 'a', title: 'clip carré', duration: 30, width: 1080, height: 1080, type: 'video' }, { type: 'shorts' }),
|
||||
'square 30s is not a short (orientation authoritative)',
|
||||
);
|
||||
// Règle 4 : horizontale 50 s ⇒ short (non-régression durée seule).
|
||||
// Horizontale 50 s ⇒ PAS short (dimensions connues, non verticale).
|
||||
expect(
|
||||
isShortItem({ title: 'paysage', duration: 50, width: 1280, height: 720, type: 'video' }),
|
||||
'horizontal 50s is a short (duration fallback)',
|
||||
!isShortItem({ title: 'paysage', duration: 50, width: 1280, height: 720, type: 'video' }),
|
||||
'horizontal 50s is not a short (orientation authoritative)',
|
||||
);
|
||||
// Repli durée seule réservé aux items SANS dimensions connues.
|
||||
expect(
|
||||
isShortItem({ title: 'courte sans dims', duration: 50, type: 'video' }),
|
||||
'50s without dimensions is a short (duration fallback)',
|
||||
);
|
||||
// Règle 3 : verticale SANS durée connue ⇒ PAS short.
|
||||
expect(
|
||||
|
||||
@@ -63,9 +63,9 @@ assertEqual(
|
||||
false,
|
||||
'verticale durée 0 ⇒ pas short',
|
||||
);
|
||||
// Règle 4 : repli durée seule (non-régression).
|
||||
assertEqual(isShortVideo({ duration: 50, width: 1280, height: 720 }), true, 'horizontale 50 s ⇒ short');
|
||||
assertEqual(isShortVideo({ duration: 50 }), true, '50 s sans dimensions ⇒ short');
|
||||
// Dimensions connues non verticales ⇒ jamais short (orientation autoritaire).
|
||||
assertEqual(isShortVideo({ duration: 50, width: 1280, height: 720 }), false, 'horizontale 50 s (dims connues) ⇒ pas short');
|
||||
assertEqual(isShortVideo({ duration: 50 }), true, '50 s sans dimensions ⇒ short (repli durée)');
|
||||
assertEqual(isShortVideo({ duration: 72 }), true, '72 s non-YouTube ⇒ short (≤ 75)');
|
||||
assertEqual(isShortVideo({ duration: 72, provider: 'youtube' }), false, '72 s YouTube ⇒ pas short (> 70)');
|
||||
assertEqual(isShortVideo({ duration: 0 }), false, 'durée 0 ⇒ jamais short');
|
||||
|
||||
@@ -6,11 +6,12 @@
|
||||
* construction ; une durée aberrante est du bruit, pas une preuve).
|
||||
* 2. Flag natif (`isShort`, `type === 'short'`, URL `/shorts/`) ⇒ Short,
|
||||
* sauf durée connue > SHORT_MAX_SECONDS.
|
||||
* 3. Orientation verticale stricte (`height > width`, ratio ≤ 0.8) ET durée
|
||||
* connue ≤ VERTICAL_SHORT_MAX_SECONDS (90 s) ⇒ Short.
|
||||
* 4. Verticale SANS durée connue ⇒ PAS un Short (même doctrine que
|
||||
* « durée 0 ne qualifie jamais »).
|
||||
* 5. Repli durée seule : 1..70 s (YouTube), 1..75 s (autres).
|
||||
* 3. Dimensions connues : l'orientation fait autorité.
|
||||
* - verticale (`height > width`, ratio ≤ 0.8) ET durée connue ≤ 90 s ⇒ Short ;
|
||||
* - verticale sans durée connue ⇒ PAS un Short ;
|
||||
* - horizontale/carrée ⇒ JAMAIS un Short (même si ≤ 70/75 s).
|
||||
* 4. Dimensions inconnues (YouTube Data API, Dailymotion, PeerTube, Rumble…) :
|
||||
* repli durée seule : 1..70 s (YouTube), 1..75 s (autres).
|
||||
*/
|
||||
export type VideoKind = 'short' | 'live' | 'video';
|
||||
|
||||
@@ -79,17 +80,22 @@ export function isShortVideo(v: ClassifiableVideo | null | undefined): boolean {
|
||||
}
|
||||
|
||||
// 3) + 4) Orientation (width/height quand le provider les expose).
|
||||
// DÈS QUE les dimensions sont connues, l'orientation fait AUTORITÉ : une
|
||||
// vidéo horizontale/carrée n'est jamais un short, même si elle est courte.
|
||||
const w = numOrZero((v as { width?: unknown }).width);
|
||||
const h = numOrZero((v as { height?: unknown }).height);
|
||||
if (w > 0 && h > 0) {
|
||||
const vertical = h > w && w / h <= VERTICAL_MAX_RATIO;
|
||||
if (vertical) {
|
||||
// Verticale sans durée connue ⇒ PAS un short (doctrine historique).
|
||||
if (!hasKnownDuration) return false;
|
||||
return duration <= VERTICAL_SHORT_MAX_SECONDS;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// 5) Repli durée seule (comportement historique inchangé).
|
||||
// 5) Repli durée seule, UNIQUEMENT quand le provider n'expose pas
|
||||
// l'orientation (YouTube Data API, Dailymotion, PeerTube, Rumble…).
|
||||
if (!hasKnownDuration) return false;
|
||||
if (provider === 'youtube') return duration <= YOUTUBE_SHORT_MAX_SECONDS;
|
||||
return duration <= SHORT_MAX_SECONDS;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<div class="shorts-stage flex w-full flex-col items-center gap-3 rounded-2xl bg-black/60 p-2 sm:p-4" (click)="unlockSound(); closeMenu()">
|
||||
<div class="shorts-stage flex w-full flex-col items-center gap-3 rounded-2xl bg-black/60 p-2 sm:p-4" (click)="closeMenu()">
|
||||
<!-- Sélecteur : Pour toi + fournisseurs -->
|
||||
<div class="scroll-rail no-scrollbar flex max-w-full items-center justify-start gap-2 sm:justify-center" role="tablist" aria-label="Fournisseur des Shorts" (click)="$event.stopPropagation()">
|
||||
<button (click)="unlockSound(); toggleForYou()"
|
||||
<button (click)="toggleForYou()"
|
||||
[attr.aria-pressed]="forYou()"
|
||||
class="chip shrink-0 px-3 py-2 rounded-full text-sm border transition-colors"
|
||||
[class]="forYou() ? 'bg-violet-600 border-violet-500 text-white' : 'bg-slate-800 border-slate-700 text-slate-300 hover:bg-slate-700'"
|
||||
@@ -9,7 +9,7 @@
|
||||
✨ Pour toi
|
||||
</button>
|
||||
<button *ngFor="let p of providersList()"
|
||||
(click)="unlockSound(); selectProvider(p.id)"
|
||||
(click)="selectProvider(p.id)"
|
||||
[attr.aria-pressed]="!forYou() && provider() === p.id"
|
||||
class="chip shrink-0 px-3 py-2 rounded-full text-sm border transition-colors"
|
||||
[class]="!forYou() && provider() === p.id ? 'bg-red-600 border-red-500 text-white' : 'bg-slate-800 border-slate-700 text-slate-300 hover:bg-slate-700'">
|
||||
@@ -19,7 +19,7 @@
|
||||
|
||||
<!-- Scène immersive : flèches + cadre 9:16 + rail d'actions -->
|
||||
<div class="flex w-full min-h-0 items-center justify-center gap-1 sm:gap-3"
|
||||
(wheel)="unlockSound(); onWheel($event)" (touchstart)="unlockSound(); onTouchStart($event)" (touchend)="onTouchEnd($event)">
|
||||
(wheel)="onWheel($event)" (touchstart)="onTouchStart($event)" (touchend)="onTouchEnd($event)">
|
||||
<!-- Flèche précédent (bureau, façon YouTube) -->
|
||||
<button type="button" (click)="prev(); $event.stopPropagation()" [disabled]="!canPrev()"
|
||||
class="chip hidden sm:flex h-12 w-12 shrink-0 items-center justify-center rounded-full bg-slate-800/80 text-white hover:bg-slate-700 disabled:opacity-30"
|
||||
|
||||
@@ -83,15 +83,14 @@ export class WatchShortComponent implements OnDestroy {
|
||||
frameRef = viewChild<ElementRef<HTMLElement>>('shortsFrame');
|
||||
/**
|
||||
* Son coupé ou non. Démarrer MUET est la seule façon d'obtenir l'autoplay
|
||||
* garanti des iframes (politique navigateur) : c'est aussi ce que fait la
|
||||
* page Watch (mute=1). Le son est rétabli automatiquement à la première
|
||||
* interaction (clic/molette/tactile) via `unlockSound()`, et l'utilisateur
|
||||
* peut couper/réactiver via le bouton. Rebasculer recharge l'embed.
|
||||
* garanti des iframes (politique navigateur) — c'est aussi ce que fait la
|
||||
* page Watch (mute=1). L'utilisateur active le son via le bouton dédié :
|
||||
* un clic sur ce bouton recharge l'embed AVEC son tout en restant dans un
|
||||
* contexte de geste utilisateur (autoplay autorisé). On ne débloque jamais
|
||||
* le son automatiquement : recharger l'embed pendant un scroll relançait un
|
||||
* embed non lisible (écran noir).
|
||||
*/
|
||||
muted = signal(true);
|
||||
/** L'utilisateur a-t-il touché au bouton son ? (sinon on débloque en auto). */
|
||||
private muteTouched = false;
|
||||
private soundUnlocked = false;
|
||||
/**
|
||||
* Embed Rumble autoritaire : id demandé -> vrai id scrapé via
|
||||
* /api/rumble/video/<id> (comme /watch). Les IDs de liste approximatifs
|
||||
@@ -243,7 +242,8 @@ export class WatchShortComponent implements OnDestroy {
|
||||
// : on la dérive de l'URL vidéo, avec repli sur l'instance active.
|
||||
// "Sorry this video does not exist" venait d'un mauvais host.
|
||||
const inst = this.peerTubeHostOf(v) || this.instances.activePeerTubeInstance();
|
||||
const u = `https://${inst}/videos/embed/${encodeURIComponent(v.videoId)}?autoplay=1&api=1`;
|
||||
// `muted=1` est requis pour que l'autoplay ne soit pas bloqué.
|
||||
const u = `https://${inst}/videos/embed/${encodeURIComponent(v.videoId)}?autoplay=1&muted=${this.muted() ? 1 : 0}&api=1`;
|
||||
return this.sanitizer.bypassSecurityTrustResourceUrl(u);
|
||||
}
|
||||
if (p === 'odysee') {
|
||||
@@ -521,21 +521,9 @@ export class WatchShortComponent implements OnDestroy {
|
||||
|
||||
// ---- Actions façon YouTube Shorts ----
|
||||
toggleMute(): void {
|
||||
this.muteTouched = true;
|
||||
this.muted.update(m => !m);
|
||||
}
|
||||
|
||||
/**
|
||||
* Première interaction de l'utilisateur : le navigateur autorise alors un
|
||||
* embed avec son. On débloque le son une seule fois, sauf si l'utilisateur
|
||||
* a déjà choisi explicitement (mute activé manuellement).
|
||||
*/
|
||||
unlockSound(): void {
|
||||
if (this.soundUnlocked || this.muteTouched || !this.muted()) return;
|
||||
this.soundUnlocked = true;
|
||||
this.muted.set(false);
|
||||
}
|
||||
|
||||
toggleFullscreen(): void {
|
||||
try {
|
||||
if (document.fullscreenElement) {
|
||||
|
||||
@@ -594,7 +594,7 @@ export class YoutubeApiService {
|
||||
limit: String(limit),
|
||||
page: String(page),
|
||||
country: String(region).toLowerCase(),
|
||||
fields: 'id,title,thumbnail_url,thumbnail_480_url,thumbnail_720_url,duration,views_total,language,allow_embed,owner.id,owner.screenname,owner.avatar_80_url,created_time',
|
||||
fields: 'id,title,thumbnail_url,thumbnail_480_url,thumbnail_720_url,duration,width,height,views_total,language,allow_embed,owner.id,owner.screenname,owner.avatar_80_url,created_time',
|
||||
});
|
||||
return this.fetchDailymotion('/videos', params).pipe(
|
||||
map(res => {
|
||||
@@ -707,7 +707,7 @@ export class YoutubeApiService {
|
||||
// duration -1 = non-embeddable (erreur 153) : on garde la
|
||||
// vidéo en recherche (avec durée 0) mais les Shorts la filtrent.
|
||||
const duration = typeof s.duration === 'number' && s.duration >= 0 ? s.duration : 0;
|
||||
return { ...v, duration, views: s.views };
|
||||
return { ...v, duration, views: s.views, language: s.language };
|
||||
});
|
||||
const next = { ...out, items: enriched } as PagedResult<Video>;
|
||||
this.searchCache.set(cacheKey, { ts: now, data: next });
|
||||
@@ -1076,7 +1076,7 @@ export class YoutubeApiService {
|
||||
// duration -1 = non-embeddable (erreur 153) : on garde la
|
||||
// vidéo en recherche (avec durée 0) mais les Shorts la filtrent.
|
||||
const duration = typeof s.duration === 'number' && s.duration >= 0 ? s.duration : 0;
|
||||
return { ...v, duration, views: s.views };
|
||||
return { ...v, duration, views: s.views, language: s.language };
|
||||
});
|
||||
const next = { ...out, items: enriched } as PagedResult<Video>;
|
||||
this.searchCache.set(cacheKey, { ts: now, data: next });
|
||||
@@ -1111,7 +1111,8 @@ export class YoutubeApiService {
|
||||
country: String(region).toLowerCase(),
|
||||
// `allow_embed` indispensable : sans lui, les vidéos non-embeddables
|
||||
// passent le filtre et le player répond "Forbidden".
|
||||
fields: 'id,title,thumbnail_url,thumbnail_480_url,thumbnail_720_url,duration,views_total,language,allow_embed,owner.screenname,owner.avatar_80_url,created_time',
|
||||
// `width,height` : validation verticale stricte des Shorts.
|
||||
fields: 'id,title,thumbnail_url,thumbnail_480_url,thumbnail_720_url,duration,width,height,views_total,language,allow_embed,owner.screenname,owner.avatar_80_url,created_time',
|
||||
search: q,
|
||||
});
|
||||
return this.fetchDailymotion('/videos', params).pipe(
|
||||
@@ -1249,6 +1250,8 @@ export class YoutubeApiService {
|
||||
views: Number(i.views_total || 0),
|
||||
uploaded: i.created_time ? i.created_time * 1000 : 0,
|
||||
language: i?.language ? String(i.language).toLowerCase().split('-')[0] : undefined,
|
||||
width: Number(i?.width) > 0 ? Number(i.width) : undefined,
|
||||
height: Number(i?.height) > 0 ? Number(i.height) : undefined,
|
||||
raw: { allowEmbed: i?.allow_embed !== false },
|
||||
});
|
||||
|
||||
@@ -1263,6 +1266,11 @@ export class YoutubeApiService {
|
||||
const uploaderAvatar = abs(i.account?.avatar?.path) || abs(i.account?.avatars?.[0]?.path) || abs(i.channel?.avatar?.path) || '';
|
||||
const uploaderName = i.account?.displayName || i.account?.name || i.channel?.displayName || '';
|
||||
const uploaderUrl = i.account?.url || i.channel?.url || '';
|
||||
// PeerTube expose `aspectRatio` (largeur/hauteur) : on le convertit en
|
||||
// width/height fictifs pour la validation verticale centrale.
|
||||
const aspectRatio = Number(i?.aspectRatio) > 0 ? Number(i.aspectRatio) : 0;
|
||||
const width = aspectRatio > 0 ? Math.round(aspectRatio * 1000) : undefined;
|
||||
const height = aspectRatio > 0 ? 1000 : undefined;
|
||||
|
||||
return {
|
||||
url: `https://${instance}/w/${i.uuid}`,
|
||||
@@ -1283,6 +1291,8 @@ export class YoutubeApiService {
|
||||
uploaded: i.publishedAt ? Date.parse(i.publishedAt) : 0,
|
||||
// PeerTube expose `language` sous forme { id, label } ou chaîne ISO.
|
||||
language: (i?.language && String(i.language.id || i.language).toLowerCase().split('-')[0]) || undefined,
|
||||
width,
|
||||
height,
|
||||
} as Video;
|
||||
};
|
||||
|
||||
@@ -1302,6 +1312,10 @@ export class YoutubeApiService {
|
||||
?? i?.meta?.duration
|
||||
?? (typeof i?.value?.duration !== 'undefined' ? i.value.duration : undefined);
|
||||
const duration = this.normalizeDuration(rawDuration);
|
||||
// Orientation LBRY/Odysee : `value.video.width/height` — indispensable
|
||||
// pour la validation verticale stricte des Shorts.
|
||||
const width = Number(val?.video?.width) > 0 ? Number(val.video.width) : undefined;
|
||||
const height = Number(val?.video?.height) > 0 ? Number(val.video.height) : undefined;
|
||||
const views = (i?.meta?.view_count != null) ? Number(i.meta.view_count) : 0;
|
||||
// Odysee : `value.languages` (tableau ISO) ou `value.language` (chaîne).
|
||||
const langRaw = Array.isArray(val?.languages) ? val.languages[0] : (val?.language ?? i?.language);
|
||||
@@ -1330,6 +1344,8 @@ export class YoutubeApiService {
|
||||
views: views,
|
||||
uploaded: publishedAt ? Date.parse(publishedAt) : 0,
|
||||
language,
|
||||
width,
|
||||
height,
|
||||
} as Video;
|
||||
};
|
||||
|
||||
@@ -2157,12 +2173,14 @@ export class YoutubeApiService {
|
||||
* Fetch minimal details (duration seconds + viewCount) for a list of YouTube IDs.
|
||||
* Non-embeddable entries get duration -1 so callers can drop them (error 153).
|
||||
*/
|
||||
private getYouTubeContentAndStats(ids: string[]): Observable<Record<string, { duration: number; views: number }>> {
|
||||
private getYouTubeContentAndStats(ids: string[]): Observable<Record<string, { duration: number; views: number; language?: string }>> {
|
||||
const apiKey = this.getActiveYouTubeKey();
|
||||
if (!apiKey || !ids.length) return of({});
|
||||
const params = new URLSearchParams({
|
||||
key: apiKey,
|
||||
part: 'contentDetails,statistics,status',
|
||||
// `snippet` fournit `defaultAudioLanguage` : seul signal de langue
|
||||
// exploitable pour filtrer les Shorts FR/EN.
|
||||
part: 'contentDetails,statistics,status,snippet',
|
||||
id: ids.join(','),
|
||||
maxResults: String(Math.min(50, ids.length)),
|
||||
// Add safeSearch for consistency
|
||||
@@ -2171,21 +2189,23 @@ export class YoutubeApiService {
|
||||
return this.fetchYouTube('/youtube/v3/videos', params, apiKey).pipe(
|
||||
map((res: any) => Array.isArray(res?.items) ? res.items : []),
|
||||
map((items: any[]) => {
|
||||
const out: Record<string, { duration: number; views: number }> = {};
|
||||
const out: Record<string, { duration: number; views: number; language?: string }> = {};
|
||||
for (const it of items) {
|
||||
const id = String(it?.id || '');
|
||||
if (!id) continue;
|
||||
const langRaw = it?.snippet?.defaultAudioLanguage || it?.snippet?.defaultLanguage || '';
|
||||
const language = langRaw ? String(langRaw).toLowerCase().split('-')[0] : undefined;
|
||||
if (it?.status && it.status.embeddable === false) {
|
||||
out[id] = { duration: -1, views: Number(it?.statistics?.viewCount || 0) };
|
||||
out[id] = { duration: -1, views: Number(it?.statistics?.viewCount || 0), language };
|
||||
continue;
|
||||
}
|
||||
const duration = this.parseISODurationToSeconds(it?.contentDetails?.duration || 'PT0S');
|
||||
const views = Number(it?.statistics?.viewCount || 0);
|
||||
out[id] = { duration, views };
|
||||
out[id] = { duration, views, language };
|
||||
}
|
||||
return out;
|
||||
}),
|
||||
catchError(() => of({} as Record<string, { duration: number; views: number }>))
|
||||
catchError(() => of({} as Record<string, { duration: number; views: number; language?: string }>))
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user