feat(watch): refonte pro page lecture + dedup transcript
- Supprime le hint debug 'touche s' (raccourci conserve, tooltip discret) - server/transcript.mjs: dedupeTranscriptLines collapse les auto-captions a fenetre glissante (12 lignes -> 3 sur l'exemple signale), applique dans parseJson3/parseVtt/parseXmlCaptions + filet avant cache API - watch: layout pro (grille 1fr/360px, carte chaine+actions, labels FR, panneaus description/download, sidebar A suivre sticky, a11y/focus) - transcript interactif: recherche, clic timestamp -> seek, suivi auto + surlignage ligne active, copie, retry - tests: 4 nouveaux cas dedupe (23/23 pass), build prod OK
This commit is contained in:
@@ -1,100 +1,115 @@
|
||||
<div class="container mx-auto p-4 lg:p-6 lg:max-w-7xl">
|
||||
<div class="mx-auto w-full max-w-7xl px-4 py-4 sm:px-6 lg:px-8 lg:py-6">
|
||||
@if (loading()) {
|
||||
<div class="animate-pulse flex flex-col lg:flex-row gap-6">
|
||||
<div class="lg:w-2/3">
|
||||
<div class="aspect-video bg-slate-700 rounded-lg"></div>
|
||||
<div class="animate-pulse grid gap-6 lg:grid-cols-[minmax(0,1fr)_360px]">
|
||||
<div>
|
||||
<div class="aspect-video rounded-2xl bg-slate-800"></div>
|
||||
<div class="mt-4 space-y-3">
|
||||
<div class="h-6 bg-slate-700 rounded w-3/4"></div>
|
||||
<div class="h-4 bg-slate-700 rounded w-1/2"></div>
|
||||
<div class="h-6 w-3/4 rounded bg-slate-800"></div>
|
||||
<div class="h-4 w-1/2 rounded bg-slate-800"></div>
|
||||
<div class="flex items-center gap-3 pt-2">
|
||||
<div class="h-10 w-10 rounded-full bg-slate-800"></div>
|
||||
<div class="h-4 w-40 rounded bg-slate-800"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
</div>
|
||||
<div class="lg:w-1/3 space-y-4">
|
||||
@for (item of [1,2,3,4]; track item) {
|
||||
<div class="flex items-start space-x-3">
|
||||
<div class="w-40 h-24 bg-slate-700 rounded"></div>
|
||||
<div class="flex-1 space-y-2">
|
||||
<div class="h-4 bg-slate-700 rounded"></div>
|
||||
<div class="h-4 bg-slate-700 rounded w-2/3"></div>
|
||||
<div class="space-y-4">
|
||||
@for (item of [1,2,3,4,5]; track item) {
|
||||
<div class="flex items-start gap-3">
|
||||
<div class="h-24 w-40 shrink-0 rounded-xl bg-slate-800"></div>
|
||||
<div class="flex-1 space-y-2 pt-1">
|
||||
<div class="h-4 rounded bg-slate-800"></div>
|
||||
<div class="h-4 w-2/3 rounded bg-slate-800"></div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
} @else if (video(); as v) {
|
||||
<div class="flex flex-col lg:flex-row gap-6">
|
||||
<!-- Main Content: Player and Details -->
|
||||
<div class="lg:w-2/3">
|
||||
<div class="aspect-video rounded-lg overflow-hidden shadow-2xl bg-black">
|
||||
@if (embedUrl(); as src) {
|
||||
<iframe #embedFrame class="w-full h-full" [src]="src" frameborder="0"
|
||||
referrerpolicy="origin"
|
||||
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"
|
||||
allowfullscreen></iframe>
|
||||
} @else {
|
||||
<app-video-player
|
||||
[videoSource]="videoSource()"
|
||||
[startPositionSeconds]="startPositionSeconds()"
|
||||
[watchHistoryId]="watchHistoryId()"
|
||||
></app-video-player>
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="mt-4">
|
||||
@if (availableQualities().length > 0) {
|
||||
<div class="flex items-center gap-3 text-sm text-slate-300 mb-3">
|
||||
<label class="text-slate-400">Quality</label>
|
||||
<select #qsel class="bg-slate-800 text-slate-200 rounded px-2 py-1"
|
||||
(change)="onSelectQuality(qsel.value)">
|
||||
<option value="">Auto</option>
|
||||
@for (q of availableQualities(); track q) {
|
||||
<option [value]="q">{{ q }}</option>
|
||||
}
|
||||
</select>
|
||||
</div>
|
||||
}
|
||||
<h1 class="text-2xl font-bold text-white">{{ v.title }}</h1>
|
||||
<div class="text-sm text-slate-400 mt-1">
|
||||
{{ formatViewsFr(v.views) }} • {{ formatDateAbsFr(v.uploadedDate) }}
|
||||
<div class="grid gap-6 lg:grid-cols-[minmax(0,1fr)_360px] xl:gap-8">
|
||||
<!-- ============ Colonne principale ============ -->
|
||||
<div class="min-w-0">
|
||||
<!-- Lecteur -->
|
||||
<section aria-label="Lecteur vidéo" class="overflow-hidden rounded-2xl bg-black shadow-2xl ring-1 ring-white/10">
|
||||
<div class="aspect-video">
|
||||
@if (embedUrl(); as src) {
|
||||
<iframe #embedFrame class="h-full w-full" [src]="src" frameborder="0"
|
||||
referrerpolicy="origin"
|
||||
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"
|
||||
allowfullscreen title="Lecteur vidéo"></iframe>
|
||||
} @else {
|
||||
<app-video-player
|
||||
[videoSource]="videoSource()"
|
||||
[startPositionSeconds]="startPositionSeconds()"
|
||||
[watchHistoryId]="watchHistoryId()"
|
||||
></app-video-player>
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col sm:flex-row sm:items-center sm:justify-between mt-3 text-slate-400 gap-3">
|
||||
<div class="flex items-center space-x-3">
|
||||
<a *ngIf="canSubscribe(); else plainIdentity"
|
||||
[routerLink]="['/channel', subscribeProvider(), subscribeExternalId()]"
|
||||
class="flex items-center space-x-3 rounded-lg"
|
||||
title="Ouvrir la page de la chaîne">
|
||||
</section>
|
||||
|
||||
@if (availableQualities().length > 0) {
|
||||
<div class="mt-3 flex items-center gap-2 text-sm text-slate-400">
|
||||
<label for="qualitySel" class="font-medium">Qualité</label>
|
||||
<select id="qualitySel" #qsel class="rounded-lg border border-slate-700 bg-slate-800 px-2 py-1 text-sm text-slate-200 focus:outline-none focus-visible:ring-2 focus-visible:ring-red-400"
|
||||
(change)="onSelectQuality(qsel.value)">
|
||||
<option value="">Auto</option>
|
||||
@for (q of availableQualities(); track q) {
|
||||
<option [value]="q">{{ q }}</option>
|
||||
}
|
||||
</select>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- Titre + métadonnées -->
|
||||
<section class="mt-4" aria-label="Informations vidéo">
|
||||
<h1 class="text-xl font-bold leading-snug text-white sm:text-2xl">{{ v.title || 'Vidéo sans titre' }}</h1>
|
||||
<p class="mt-1.5 text-sm text-slate-400">
|
||||
{{ formatViewsFr(v.views) }} <span aria-hidden="true" class="mx-1">•</span> {{ formatDateAbsFr(v.uploadedDate) }}
|
||||
@if (v.duration > 0) {
|
||||
<span aria-hidden="true" class="mx-1">•</span> {{ v.duration | duration }}
|
||||
}
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<!-- Chaîne + actions -->
|
||||
<section class="mt-4 rounded-2xl border border-slate-700/60 bg-slate-800/50 p-3 sm:p-4" aria-label="Chaîne et actions">
|
||||
<div class="flex flex-wrap items-center gap-3">
|
||||
<a *ngIf="canSubscribe(); else plainIdentity"
|
||||
[routerLink]="['/channel', subscribeProvider(), subscribeExternalId()]"
|
||||
class="flex min-w-0 flex-1 items-center gap-3 rounded-xl p-1 transition hover:bg-slate-700/40 focus:outline-none focus-visible:ring-2 focus-visible:ring-red-400"
|
||||
title="Ouvrir la page de la chaîne">
|
||||
@if (channelAvatarUrl(); as avatar) {
|
||||
<img [src]="avatar" [alt]="v.uploaderName" (error)="onAvatarError()"
|
||||
class="h-11 w-11 shrink-0 rounded-full bg-slate-700 object-cover" loading="lazy" referrerpolicy="no-referrer">
|
||||
} @else {
|
||||
<div class="flex h-11 w-11 shrink-0 items-center justify-center rounded-full bg-slate-700 text-lg font-bold text-slate-200"
|
||||
[attr.aria-label]="v.uploaderName">{{ channelInitial() }}</div>
|
||||
}
|
||||
<div class="min-w-0">
|
||||
<div class="truncate font-semibold text-slate-100">{{ v.uploaderName || channelMeta()?.title || 'Chaîne inconnue' }}</div>
|
||||
@if (channelSubsLabel(); as subs) {
|
||||
<div class="text-sm text-slate-400">{{ subs }} abonnés</div>
|
||||
}
|
||||
</div>
|
||||
</a>
|
||||
<ng-template #plainIdentity>
|
||||
<div class="flex min-w-0 flex-1 items-center gap-3">
|
||||
@if (channelAvatarUrl(); as avatar) {
|
||||
<img [src]="avatar" [alt]="v.uploaderName" (error)="onAvatarError()"
|
||||
class="w-10 h-10 rounded-full object-cover bg-slate-700 hover:ring-2 hover:ring-red-500/60" loading="lazy" referrerpolicy="no-referrer">
|
||||
class="h-11 w-11 shrink-0 rounded-full bg-slate-700 object-cover" loading="lazy" referrerpolicy="no-referrer">
|
||||
} @else {
|
||||
<div class="w-10 h-10 rounded-full bg-slate-700 flex items-center justify-center text-slate-200 font-bold hover:ring-2 hover:ring-red-500/60"
|
||||
<div class="flex h-11 w-11 shrink-0 items-center justify-center rounded-full bg-slate-700 text-lg font-bold text-slate-200"
|
||||
[attr.aria-label]="v.uploaderName">{{ channelInitial() }}</div>
|
||||
}
|
||||
<div>
|
||||
<div class="font-semibold text-slate-200 hover:text-red-300">{{ v.uploaderName || channelMeta()?.title || 'Chaîne inconnue' }}</div>
|
||||
<div class="min-w-0">
|
||||
<div class="truncate font-semibold text-slate-100">{{ v.uploaderName || channelMeta()?.title || 'Chaîne inconnue' }}</div>
|
||||
@if (channelSubsLabel(); as subs) {
|
||||
<div>{{ subs }} abonnés</div>
|
||||
<div class="text-sm text-slate-400">{{ subs }} abonnés</div>
|
||||
}
|
||||
</div>
|
||||
</a>
|
||||
<ng-template #plainIdentity>
|
||||
@if (channelAvatarUrl(); as avatar) {
|
||||
<img [src]="avatar" [alt]="v.uploaderName" (error)="onAvatarError()"
|
||||
class="w-10 h-10 rounded-full object-cover bg-slate-700" loading="lazy" referrerpolicy="no-referrer">
|
||||
} @else {
|
||||
<div class="w-10 h-10 rounded-full bg-slate-700 flex items-center justify-center text-slate-200 font-bold"
|
||||
[attr.aria-label]="v.uploaderName">{{ channelInitial() }}</div>
|
||||
}
|
||||
<div>
|
||||
<div class="font-semibold text-slate-200">{{ v.uploaderName || channelMeta()?.title || 'Chaîne inconnue' }}</div>
|
||||
@if (channelSubsLabel(); as subs) {
|
||||
<div>{{ subs }} abonnés</div>
|
||||
}
|
||||
</div>
|
||||
</ng-template>
|
||||
@if (canSubscribe() && isLoggedIn()) {
|
||||
</div>
|
||||
</ng-template>
|
||||
@if (canSubscribe() && isLoggedIn()) {
|
||||
<span title="Raccourci clavier : S">
|
||||
<app-subscribe-button
|
||||
[provider]="subscribeProvider()!"
|
||||
[externalId]="subscribeExternalId()!"
|
||||
@@ -102,276 +117,399 @@
|
||||
(subscribed)="onSubscribedFromWatch($event)"
|
||||
(unsubscribed)="onUnsubscribedFromWatch()">
|
||||
</app-subscribe-button>
|
||||
} @else if (canSubscribe() && !isLoggedIn()) {
|
||||
<a routerLink="/auth/login" title="Connectez-vous pour vous abonner"
|
||||
class="inline-flex items-center gap-2 rounded-md border border-slate-600 bg-slate-800/70 px-3 py-1.5 text-sm font-semibold text-slate-200">
|
||||
S'abonner
|
||||
</a>
|
||||
}
|
||||
</div>
|
||||
@if (subscribeError(); as err) {
|
||||
<div class="text-sm text-red-300">{{ err }}</div>
|
||||
}
|
||||
@if (subServiceError(); as serr) {
|
||||
<div class="text-sm text-red-300">{{ serr }}</div>
|
||||
}
|
||||
@if (canSubscribe() && isLoggedIn()) {
|
||||
<div class="text-xs text-slate-500" title="Raccourci clavier">Astuce : touche « s » pour (dé)s'abonner</div>
|
||||
}
|
||||
<div class="flex items-center space-x-4 mt-3 sm:mt-0">
|
||||
<a [href]="openOnProviderUrl() || '#'" target="_blank" rel="noopener noreferrer"
|
||||
class="flex items-center space-x-2 bg-slate-700 hover:bg-slate-600 px-4 py-2 rounded-full transition"
|
||||
[class.pointer-events-none]="!openOnProviderUrl()" [class.opacity-60]="!openOnProviderUrl()"
|
||||
title="Ouvrir sur le site du fournisseur">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path d="M10 3a1 1 0 011-1h6a1 1 0 011 1v6a1 1 0 11-2 0V5.414l-7.293 7.293a1 1 0 01-1.414-1.414L14.586 4H11a1 1 0 01-1-1z"/>
|
||||
<path d="M5 4a1 1 0 011-1h3a1 1 0 110 2H7v8h8v-2a1 1 0 112 0v3a1 1 0 01-1 1H6a1 1 0 01-1-1V4z"/>
|
||||
</svg>
|
||||
<span class="hidden sm:inline"></span>
|
||||
</span>
|
||||
} @else if (canSubscribe() && !isLoggedIn()) {
|
||||
<a routerLink="/auth/login" title="Connectez-vous pour vous abonner (raccourci : S une fois connecté)"
|
||||
class="inline-flex items-center gap-2 rounded-lg border border-slate-600 bg-slate-800/70 px-3 py-1.5 text-sm font-semibold text-slate-200 transition hover:bg-slate-700 focus:outline-none focus-visible:ring-2 focus-visible:ring-red-400">
|
||||
S'abonner
|
||||
</a>
|
||||
<button class="flex items-center space-x-2 bg-slate-700 hover:bg-slate-600 px-4 py-2 rounded-full transition">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" viewBox="0 0 20 20" fill="currentColor"><path d="M2 10.5a1.5 1.5 0 113 0v6a1.5 1.5 0 01-3 0v-6zM6 10.333v5.43a2 2 0 001.106 1.787l.09.044a2 2 0 002.253-.334l.09-.092l3.535-3.536a2 2 0 00.586-1.414V5a2 2 0 00-2-2H9a2 2 0 00-2 2v5.333z" /></svg>
|
||||
<span>{{ formatNumber(v.likes) }}</span>
|
||||
</button>
|
||||
@if (isLoggedIn()) {
|
||||
<button (click)="toggleLike()"
|
||||
[disabled]="likeBusy()"
|
||||
[class.bg-red-600]="liked()"
|
||||
[class.hover\:bg-red-500]="liked()"
|
||||
title="{{ liked() ? 'Liked' : 'Like' }}"
|
||||
class="flex items-center justify-center bg-slate-700 hover:bg-slate-600 disabled:opacity-60 disabled:cursor-not-allowed p-2 rounded-full transition">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" viewBox="0 0 24 24" [attr.fill]="liked() ? 'currentColor' : 'none'" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M12 21s-6.716-4.248-9.193-6.725A6 6 0 0112 5.414 6 6 0 0121.193 14.275C18.716 16.752 12 21 12 21z" />
|
||||
</svg>
|
||||
</button>
|
||||
}
|
||||
@if (isLoggedIn()) {
|
||||
<app-add-to-playlist [provider]="provider()" [videoId]="v.videoId" [title]="v.title" [thumbnail]="v.thumbnail"></app-add-to-playlist>
|
||||
}
|
||||
<button (click)="copyLink()" title="Copy link"
|
||||
class="flex items-center space-x-2 bg-slate-700 hover:bg-slate-600 px-4 py-2 rounded-full transition">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M16 1a3 3 0 013 3v10a3 3 0 01-3 3H6a3 3 0 01-3-3V4a3 3 0 013-3h10zm0 2H6a1 1 0 00-1 1v10a1 1 0 001 1h10a1 1 0 001-1V4a1 1 0 00-1-1z"/>
|
||||
<path d="M18 7h2a3 3 0 013 3v10a3 3 0 01-3 3H10a3 3 0 01-3-3v-2h2v2a1 1 0 001 1h10a1 1 0 001-1V10a1 1 0 00-1-1h-2V7z"/>
|
||||
</svg>
|
||||
<span class="hidden sm:inline"></span>
|
||||
</button>
|
||||
@if (isLoggedIn()) {
|
||||
<button (click)="openDownloadPanel()"
|
||||
class="flex items-center space-x-2 bg-red-600 hover:bg-red-500 px-4 py-2 rounded-full transition font-semibold">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
|
||||
<!-- Arrow down -->
|
||||
<path d="M10 3a1 1 0 011 1v7l2.293-2.293a1 1 0 111.414 1.414l-4 4a1 1 0 01-1.414 0l-4-4a1 1 0 111.414-1.414L9 11V4a1 1 0 011-1z" />
|
||||
<!-- Tray -->
|
||||
<path d="M4 17a1 1 0 011-1h10a1 1 0 110 2H5a1 1 0 01-1-1z" />
|
||||
</svg>
|
||||
<span></span>
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-4 bg-slate-800/70 p-4 rounded-lg">
|
||||
<p class="whitespace-pre-wrap text-slate-300" [class.line-clamp-3]="!showFullDescription()">
|
||||
{{ hasDescription() ? descriptionText() : 'Aucune description disponible.' }}
|
||||
</p>
|
||||
@if (showMoreAvailable()) {
|
||||
<button (click)="toggleDescription()" class="text-red-400 hover:text-red-300 font-semibold mt-2">
|
||||
{{ showFullDescription() ? 'Show less' : 'Show more' }}
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
@if (subscribeError(); as err) {
|
||||
<div class="mt-2 text-sm text-red-300" role="alert">{{ err }}</div>
|
||||
}
|
||||
@if (subServiceError(); as serr) {
|
||||
<div class="mt-2 text-sm text-red-300" role="alert">{{ serr }}</div>
|
||||
}
|
||||
|
||||
<!-- Download Panel (shown only when logged in) -->
|
||||
@if (isLoggedIn() && downloadOpen()) {
|
||||
<div class="mt-4 bg-slate-800/70 p-4 rounded-lg border border-slate-700">
|
||||
<div class="flex items-start gap-4 flex-wrap">
|
||||
<div class="flex-1 min-w-[240px]">
|
||||
<label class="block text-sm text-slate-400 mb-1">Format / Qualité</label>
|
||||
@if (formatsLoading()) {
|
||||
<div class="text-slate-400 text-sm">Chargement des formats...</div>
|
||||
} @else if (downloadFormats().length > 0) {
|
||||
<select #fmtSel class="w-full bg-slate-900 text-slate-200 border border-slate-700 rounded px-2 py-2"
|
||||
(change)="selectedFormatId.set(fmtSel.value || null)">
|
||||
<option value="">Auto (best)</option>
|
||||
@for (f of downloadFormats(); track f.id) {
|
||||
<option [value]="f.id">{{ f.label }}</option>
|
||||
}
|
||||
</select>
|
||||
} @else if (downloadError()) {
|
||||
<div class="text-red-300 text-sm">{{ downloadError() }}</div>
|
||||
} @else {
|
||||
<div class="text-slate-400 text-sm">Aucun format disponible.</div>
|
||||
}
|
||||
<!-- Barre d'actions -->
|
||||
<div class="mt-3 flex flex-wrap items-center gap-2 border-t border-slate-700/60 pt-3" role="toolbar" aria-label="Actions vidéo">
|
||||
<span class="inline-flex items-center gap-1.5 rounded-full bg-slate-700/70 px-3 py-1.5 text-sm text-slate-200" title="Mentions J'aime">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true"><path d="M2 10.5a1.5 1.5 0 113 0v6a1.5 1.5 0 01-3 0v-6zM6 10.333v5.43a2 2 0 001.106 1.787l.09.044a2 2 0 002.253-.334l.09-.092l3.535-3.536a2 2 0 00.586-1.414V5a2 2 0 00-2-2H9a2 2 0 00-2 2v5.333z" /></svg>
|
||||
<span class="tabular-nums">{{ formatNumber(v.likes) }}</span>
|
||||
</span>
|
||||
@if (isLoggedIn()) {
|
||||
<button (click)="toggleLike()" [disabled]="likeBusy()"
|
||||
[attr.aria-pressed]="liked()" [title]="liked() ? 'Ne plus aimer' : 'J\'aime'"
|
||||
class="inline-flex items-center gap-1.5 rounded-full px-3 py-1.5 text-sm font-semibold transition focus:outline-none focus-visible:ring-2 focus-visible:ring-red-400 disabled:cursor-not-allowed disabled:opacity-60"
|
||||
[class.bg-red-600]="liked()" [class.text-white]="liked()"
|
||||
[class.bg-slate-700]="!liked()" [class.text-slate-200]="!liked()" [class.hover:bg-slate-600]="!liked()">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" viewBox="0 0 24 24" [attr.fill]="liked() ? 'currentColor' : 'none'" stroke="currentColor" stroke-width="2" aria-hidden="true">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M12 21s-6.716-4.248-9.193-6.725A6 6 0 0112 5.414 6 6 0 0121.193 14.275C18.716 16.752 12 21 12 21z" />
|
||||
</svg>
|
||||
<span>{{ liked() ? 'Aimée' : 'J\'aime' }}</span>
|
||||
</button>
|
||||
}
|
||||
@if (isLoggedIn()) {
|
||||
<app-add-to-playlist [provider]="provider()" [videoId]="v.videoId" [title]="v.title" [thumbnail]="v.thumbnail"></app-add-to-playlist>
|
||||
}
|
||||
<button (click)="copyLink()" title="Copier le lien de la vidéo"
|
||||
class="inline-flex items-center gap-1.5 rounded-full bg-slate-700 px-3 py-1.5 text-sm font-semibold text-slate-200 transition hover:bg-slate-600 focus:outline-none focus-visible:ring-2 focus-visible:ring-red-400">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
|
||||
<path d="M16 1a3 3 0 013 3v10a3 3 0 01-3 3H6a3 3 0 01-3-3V4a3 3 0 013-3h10zm0 2H6a1 1 0 00-1 1v10a1 1 0 001 1h10a1 1 0 001-1V4a1 1 0 00-1-1z"/>
|
||||
<path d="M18 7h2a3 3 0 013 3v10a3 3 0 01-3 3H10a3 3 0 01-3-3v-2h2v2a1 1 0 001 1h10a1 1 0 001-1V10a1 1 0 00-1-1h-2V7z"/>
|
||||
</svg>
|
||||
<span>{{ linkCopied() ? 'Lien copié !' : 'Partager' }}</span>
|
||||
</button>
|
||||
<button (click)="toggleTranscript()" [attr.aria-expanded]="transcriptOpen()" aria-controls="transcript-panel"
|
||||
[title]="transcriptOpen() ? 'Masquer la transcription' : 'Afficher la transcription'"
|
||||
class="inline-flex items-center gap-1.5 rounded-full px-3 py-1.5 text-sm font-semibold transition focus:outline-none focus-visible:ring-2 focus-visible:ring-red-400"
|
||||
[class.bg-red-600]="transcriptOpen()" [class.text-white]="transcriptOpen()"
|
||||
[class.bg-slate-700]="!transcriptOpen()" [class.text-slate-200]="!transcriptOpen()" [class.hover:bg-slate-600]="!transcriptOpen()">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
|
||||
<path fill-rule="evenodd" d="M4 4a2 2 0 012-2h8a2 2 0 012 2v12a2 2 0 01-2 2H6a2 2 0 01-2-2V4zm2 0v12h8V4H6zm1 2a1 1 0 011-1h4a1 1 0 110 2H8a1 1 0 01-1-1zm0 3a1 1 0 011-1h4a1 1 0 110 2H8a1 1 0 01-1-1zm0 3a1 1 0 011-1h2a1 1 0 110 2H8a1 1 0 01-1-1z" clip-rule="evenodd" />
|
||||
</svg>
|
||||
<span>Transcription</span>
|
||||
</button>
|
||||
@if (isLoggedIn()) {
|
||||
<button (click)="openDownloadPanel()" title="Télécharger la vidéo"
|
||||
class="inline-flex items-center gap-1.5 rounded-full bg-slate-700 px-3 py-1.5 text-sm font-semibold text-slate-200 transition hover:bg-slate-600 focus:outline-none focus-visible:ring-2 focus-visible:ring-red-400">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
|
||||
<path d="M10 3a1 1 0 011 1v7l2.293-2.293a1 1 0 111.414 1.414l-4 4a1 1 0 01-1.414 0l-4-4a1 1 0 111.414-1.414L9 11V4a1 1 0 011-1z" />
|
||||
<path d="M4 17a1 1 0 011-1h10a1 1 0 110 2H5a1 1 0 01-1-1z" />
|
||||
</svg>
|
||||
<span>Télécharger</span>
|
||||
</button>
|
||||
}
|
||||
<a [href]="openOnProviderUrl() || '#'" target="_blank" rel="noopener noreferrer"
|
||||
class="inline-flex items-center gap-1.5 rounded-full bg-slate-700 px-3 py-1.5 text-sm font-semibold text-slate-200 transition hover:bg-slate-600 focus:outline-none focus-visible:ring-2 focus-visible:ring-red-400"
|
||||
[class.pointer-events-none]="!openOnProviderUrl()" [class.opacity-60]="!openOnProviderUrl()"
|
||||
title="Ouvrir sur le site du fournisseur">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
|
||||
<path d="M10 3a1 1 0 011-1h6a1 1 0 011 1v6a1 1 0 11-2 0V5.414l-7.293 7.293a1 1 0 01-1.414-1.414L14.586 4H11a1 1 0 01-1-1z"/>
|
||||
<path d="M5 4a1 1 0 011-1h3a1 1 0 110 2H7v8h8v-2a1 1 0 112 0v3a1 1 0 01-1 1H6a1 1 0 01-1-1V4z"/>
|
||||
</svg>
|
||||
<span>Ouvrir</span>
|
||||
</a>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Description -->
|
||||
<section class="mt-4 rounded-2xl border border-slate-700/60 bg-slate-800/50 p-4" aria-label="Description">
|
||||
<h2 class="text-sm font-semibold uppercase tracking-wide text-slate-400">Description</h2>
|
||||
<p class="mt-2 whitespace-pre-wrap text-sm leading-relaxed text-slate-200" [class.line-clamp-3]="!showFullDescription()">
|
||||
{{ hasDescription() ? descriptionText() : 'Aucune description disponible.' }}
|
||||
</p>
|
||||
@if (showMoreAvailable()) {
|
||||
<button (click)="toggleDescription()" class="mt-2 text-sm font-semibold text-red-400 transition hover:text-red-300 focus:outline-none focus-visible:ring-2 focus-visible:ring-red-400 rounded">
|
||||
{{ showFullDescription() ? 'Réduire' : 'Afficher plus' }}
|
||||
</button>
|
||||
}
|
||||
</section>
|
||||
|
||||
<!-- Téléchargement -->
|
||||
@if (isLoggedIn() && downloadOpen()) {
|
||||
<section class="mt-4 rounded-2xl border border-slate-700 bg-slate-800/70 p-4" aria-label="Téléchargement">
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<h2 class="font-semibold text-slate-100">Téléchargement</h2>
|
||||
<button (click)="closeDownloadPanel()" title="Fermer le panneau de téléchargement"
|
||||
class="rounded-full p-1.5 text-slate-400 transition hover:bg-slate-700 hover:text-slate-200 focus:outline-none focus-visible:ring-2 focus-visible:ring-red-400" aria-label="Fermer">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true"><path fill-rule="evenodd" d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z" clip-rule="evenodd" /></svg>
|
||||
</button>
|
||||
</div>
|
||||
<div class="mt-3 flex flex-wrap items-end gap-4">
|
||||
<div class="min-w-[240px] flex-1">
|
||||
<label for="fmtSel" class="mb-1 block text-sm text-slate-400">Format / Qualité</label>
|
||||
@if (formatsLoading()) {
|
||||
<div class="text-sm text-slate-400">Chargement des formats…</div>
|
||||
} @else if (downloadFormats().length > 0) {
|
||||
<select id="fmtSel" #fmtSel class="w-full rounded-lg border border-slate-700 bg-slate-900 px-2 py-2 text-slate-200 focus:outline-none focus-visible:ring-2 focus-visible:ring-red-400"
|
||||
(change)="selectedFormatId.set(fmtSel.value || null)">
|
||||
<option value="">Auto (meilleur)</option>
|
||||
@for (f of downloadFormats(); track f.id) {
|
||||
<option [value]="f.id">{{ f.label }}</option>
|
||||
}
|
||||
</select>
|
||||
} @else if (downloadError()) {
|
||||
<div class="text-sm text-red-300">{{ downloadError() }}</div>
|
||||
} @else {
|
||||
<div class="text-sm text-slate-400">Aucun format disponible.</div>
|
||||
}
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<input id="audioOnly" #audioChk type="checkbox" class="h-4 w-4 accent-red-500"
|
||||
(change)="audioOnly.set(audioChk.checked)" />
|
||||
<label for="audioOnly" class="text-sm text-slate-300">Audio uniquement</label>
|
||||
</div>
|
||||
<div class="ml-auto">
|
||||
@if (!jobId()) {
|
||||
<button (click)="startDownload()" class="rounded-lg bg-red-600 px-4 py-2 font-semibold text-white transition hover:bg-red-500 focus:outline-none focus-visible:ring-2 focus-visible:ring-red-400">
|
||||
Démarrer
|
||||
</button>
|
||||
} @else {
|
||||
<button (click)="cancelDownload()" class="rounded-lg bg-slate-700 px-4 py-2 text-white transition hover:bg-slate-600 focus:outline-none focus-visible:ring-2 focus-visible:ring-red-400">
|
||||
Annuler
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
@if (jobId()) {
|
||||
<div class="mt-4">
|
||||
<div class="mb-1 flex items-center gap-2 text-sm text-slate-300">
|
||||
<span>Statut :</span>
|
||||
<span class="font-semibold">{{ jobState() || 'en attente' }}</span>
|
||||
<span aria-hidden="true">•</span>
|
||||
<span class="tabular-nums">Progression : {{ jobProgress() }} %</span>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-2 mt-2">
|
||||
<input id="audioOnly" #audioChk type="checkbox" class="accent-red-500"
|
||||
(change)="audioOnly.set(audioChk.checked)" />
|
||||
<label for="audioOnly" class="text-sm text-slate-300">Audio uniquement</label>
|
||||
</div>
|
||||
|
||||
<div class="ml-auto flex items-center gap-3 mt-2">
|
||||
@if (!jobId()) {
|
||||
<button (click)="startDownload()" class="bg-red-600 hover:bg-red-500 text-white font-semibold px-4 py-2 rounded">
|
||||
Démarrer
|
||||
</button>
|
||||
} @else {
|
||||
<button (click)="cancelDownload()" class="bg-slate-700 hover:bg-slate-600 text-white px-4 py-2 rounded">
|
||||
Annuler
|
||||
</button>
|
||||
}
|
||||
<div class="h-2.5 w-full rounded-full bg-slate-900" role="progressbar" [attr.aria-valuenow]="jobProgress()" aria-valuemin="0" aria-valuemax="100">
|
||||
<div class="h-2.5 rounded-full bg-red-600 transition-all" [style.width.%]="jobProgress()"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (jobId()) {
|
||||
<div class="mt-4">
|
||||
<div class="text-sm text-slate-300 mb-1 flex items-center gap-2">
|
||||
<span>Statut:</span>
|
||||
<span class="font-semibold">{{ jobState() || 'en attente' }}</span>
|
||||
<span>•</span>
|
||||
<span>Progression: {{ jobProgress() }}%</span>
|
||||
</div>
|
||||
<div class="w-full h-3 bg-slate-900 rounded">
|
||||
<div class="h-3 bg-red-600 rounded" [style.width.%]="jobProgress()"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (jobFileReady()) {
|
||||
<div class="mt-4 flex items-center gap-3">
|
||||
<button (click)="downloadFile()" class="bg-green-600 hover:bg-green-500 text-white font-semibold px-4 py-2 rounded">
|
||||
Télécharger le fichier
|
||||
</button>
|
||||
@if (jobFileSize(); as size) {
|
||||
<span class="text-sm text-slate-400">{{ formatBytes(size) }}</span>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
@if (downloadError() && !formatsLoading()) {
|
||||
<div class="mt-3 text-red-300 text-sm">{{ downloadError() }}</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
<!-- Comments Section -->
|
||||
<div class="mt-8">
|
||||
<div class="flex items-center justify-between flex-wrap gap-4 mb-4">
|
||||
<h2 class="text-xl font-bold">{{ v.comments.length }} Comments</h2>
|
||||
@if (v.comments.length > 0) {
|
||||
<button (click)="onSummarizeComments()"
|
||||
[disabled]="isSummarizing() || !geminiReady"
|
||||
class="flex items-center space-x-2 bg-slate-700 hover:bg-slate-600 disabled:bg-slate-800 disabled:text-slate-400 disabled:cursor-not-allowed px-3 py-2 rounded-full transition text-sm font-semibold">
|
||||
@if (isSummarizing()) {
|
||||
<svg class="animate-spin h-5 w-5" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
</svg>
|
||||
<span>Summarizing...</span>
|
||||
} @else {
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 text-yellow-300" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path fill-rule="evenodd" d="M10 3a1 1 0 011 1v2.586l1.707-1.707a1 1 0 111.414 1.414l-1.707 1.707H15a1 1 0 110 2h-2.586l1.707 1.707a1 1 0 11-1.414 1.414L11 12.414V15a1 1 0 11-2 0v-2.586l-1.707 1.707a1 1 0 11-1.414-1.414L7.586 11H5a1 1 0 110-2h2.586L5.793 7.293a1 1 0 111.414-1.414L9 7.586V5a1 1 0 011-1z" clip-rule="evenodd" />
|
||||
</svg>
|
||||
<span>Summarize Comments</span>
|
||||
}
|
||||
</button>
|
||||
@if (!geminiReady && geminiReason) {
|
||||
<div class="text-xs text-amber-300 ml-2">
|
||||
{{ geminiReason }}
|
||||
@if (jobFileReady()) {
|
||||
<div class="mt-4 flex items-center gap-3">
|
||||
<button (click)="downloadFile()" class="rounded-lg bg-green-600 px-4 py-2 font-semibold text-white transition hover:bg-green-500 focus:outline-none focus-visible:ring-2 focus-visible:ring-green-400">
|
||||
Télécharger le fichier
|
||||
</button>
|
||||
@if (jobFileSize(); as size) {
|
||||
<span class="text-sm text-slate-400">{{ formatBytes(size) }}</span>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
@if (downloadError() && !formatsLoading()) {
|
||||
<div class="mt-3 text-sm text-red-300" role="alert">{{ downloadError() }}</div>
|
||||
}
|
||||
</section>
|
||||
}
|
||||
|
||||
<!-- Summary/Error/Loading Display -->
|
||||
<!-- Transcription -->
|
||||
@if (transcriptOpen()) {
|
||||
<section id="transcript-panel" class="mt-4 rounded-2xl border border-slate-700 bg-slate-800/70 p-4" role="region" aria-label="Transcription">
|
||||
<div class="flex flex-wrap items-center gap-3">
|
||||
<h2 class="font-semibold text-slate-100">Transcription</h2>
|
||||
@if (transcriptAvailable() && transcriptLines().length > 0) {
|
||||
<span class="rounded-full bg-slate-700/70 px-2.5 py-0.5 text-xs tabular-nums text-slate-300">{{ transcriptCountLabel() }}</span>
|
||||
}
|
||||
<div class="ml-auto flex flex-wrap items-center gap-2">
|
||||
@if (transcriptLanguages().length > 0) {
|
||||
<label class="flex items-center gap-2 text-sm text-slate-400">
|
||||
Langue
|
||||
<select #langSel class="rounded-lg border border-slate-700 bg-slate-900 px-2 py-1 text-sm text-slate-200 focus:outline-none focus-visible:ring-2 focus-visible:ring-red-400"
|
||||
[value]="selectedTranscriptLang()"
|
||||
(change)="onTranscriptLangChange(langSel.value)">
|
||||
@for (lang of transcriptLanguages(); track lang) {
|
||||
<option [value]="lang" [selected]="lang === selectedTranscriptLang()">{{ lang }}</option>
|
||||
}
|
||||
</select>
|
||||
</label>
|
||||
}
|
||||
@if (transcriptAvailable() && transcriptLines().length > 0) {
|
||||
<button (click)="toggleTranscriptAutoScroll()" [attr.aria-pressed]="transcriptAutoScroll()" title="Suivre la lecture automatiquement"
|
||||
class="rounded-full px-2.5 py-1 text-xs font-semibold transition focus:outline-none focus-visible:ring-2 focus-visible:ring-red-400"
|
||||
[class.bg-red-600]="transcriptAutoScroll()" [class.text-white]="transcriptAutoScroll()"
|
||||
[class.bg-slate-700]="!transcriptAutoScroll()" [class.text-slate-300]="!transcriptAutoScroll()">
|
||||
Suivi {{ transcriptAutoScroll() ? 'activé' : 'coupé' }}
|
||||
</button>
|
||||
<button (click)="copyTranscript()" title="Copier la transcription filtrée"
|
||||
class="rounded-full bg-slate-700 px-2.5 py-1 text-xs font-semibold text-slate-200 transition hover:bg-slate-600 focus:outline-none focus-visible:ring-2 focus-visible:ring-red-400">
|
||||
{{ transcriptCopied() ? 'Copié !' : 'Copier' }}
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
@if (transcriptAvailable() && transcriptLines().length > 0) {
|
||||
<div class="relative mt-3">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-slate-500" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true"><path fill-rule="evenodd" d="M8 4a4 4 0 100 8 4 4 0 000-8zM2 8a6 6 0 1110.89 3.476l4.817 4.817a1 1 0 01-1.414 1.414l-4.816-4.816A6 6 0 012 8z" clip-rule="evenodd" /></svg>
|
||||
<input #tsearch type="search" placeholder="Rechercher dans la transcription…" [value]="transcriptSearch()"
|
||||
(input)="onTranscriptSearch(tsearch.value)" aria-label="Rechercher dans la transcription"
|
||||
class="w-full rounded-xl border border-slate-700 bg-slate-900 py-2 pl-9 pr-9 text-sm text-slate-200 placeholder:text-slate-500 focus:outline-none focus-visible:ring-2 focus-visible:ring-red-400" />
|
||||
@if (transcriptSearch()) {
|
||||
<button (click)="clearTranscriptSearch()" title="Effacer la recherche" aria-label="Effacer la recherche"
|
||||
class="absolute right-2 top-1/2 -translate-y-1/2 rounded-full p-1 text-slate-400 transition hover:bg-slate-700 hover:text-slate-200">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true"><path fill-rule="evenodd" d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z" clip-rule="evenodd" /></svg>
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
@if (transcriptLoading()) {
|
||||
<div class="mt-4 space-y-2" aria-live="polite" aria-busy="true">
|
||||
@for (sk of [1,2,3,4,5]; track sk) {
|
||||
<div class="flex gap-3">
|
||||
<div class="h-4 w-12 shrink-0 rounded bg-slate-700"></div>
|
||||
<div class="h-4 flex-1 rounded bg-slate-700"></div>
|
||||
</div>
|
||||
}
|
||||
<p class="pt-1 text-sm text-slate-400">Chargement de la transcription…</p>
|
||||
</div>
|
||||
} @else if (transcriptAvailable() && transcriptLines().length > 0) {
|
||||
@if (filteredTranscriptLines().length > 0) {
|
||||
<p class="mt-2 text-xs text-slate-500">Cliquez sur un horodatage pour sauter à ce passage.</p>
|
||||
<div class="mt-2 max-h-96 space-y-1 overflow-y-auto rounded-xl bg-slate-900/50 p-2" (scroll)="onTranscriptScroll($event)" tabindex="0" aria-label="Lignes de transcription">
|
||||
@for (line of filteredTranscriptLines(); track trackTranscriptLine($index, line); let i = $index) {
|
||||
<div [id]="'tr-line-' + i"
|
||||
class="flex items-baseline gap-3 rounded-lg px-2 py-1.5 text-sm transition"
|
||||
[class.bg-red-500/15]="i === transcriptActiveIndex()"
|
||||
[class.ring-1]="i === transcriptActiveIndex()" [class.ring-red-500/40]="i === transcriptActiveIndex()">
|
||||
<button (click)="seekTranscript(line.t)" [title]="'Aller à ' + formatTimestamp(line.t)"
|
||||
class="shrink-0 rounded px-1 font-mono text-xs tabular-nums text-red-300 transition hover:bg-red-500/20 hover:text-red-200 focus:outline-none focus-visible:ring-2 focus-visible:ring-red-400">
|
||||
{{ formatTimestamp(line.t) }}
|
||||
</button>
|
||||
<span class="min-w-0 flex-1 leading-relaxed" [class.text-white]="i === transcriptActiveIndex()" [class.text-slate-300]="i !== transcriptActiveIndex()">{{ line.text }}</span>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
} @else {
|
||||
<div class="mt-3 rounded-xl bg-slate-900/50 p-4 text-center">
|
||||
<p class="text-sm text-slate-300">Aucun passage ne correspond à « {{ transcriptSearch() }} ».</p>
|
||||
<button (click)="clearTranscriptSearch()" class="mt-2 text-sm font-semibold text-red-400 hover:text-red-300">Effacer la recherche</button>
|
||||
</div>
|
||||
}
|
||||
} @else {
|
||||
@if (transcriptReason() === 'no_subtitles' || (!transcriptError() && transcriptLanguages().length === 0)) {
|
||||
<div class="mt-3 rounded-xl bg-slate-900/50 p-4 text-center">
|
||||
<p class="text-sm text-slate-400">Aucun sous-titre disponible pour cette vidéo.</p>
|
||||
</div>
|
||||
} @else {
|
||||
<div class="mt-3 rounded-xl bg-slate-900/50 p-4 text-center">
|
||||
<p class="text-sm text-slate-400">{{ transcriptError() || 'Aucun sous-titre disponible pour cette vidéo.' }}</p>
|
||||
@if (transcriptRetryable()) {
|
||||
<button (click)="retryTranscript()"
|
||||
class="mt-3 inline-flex items-center gap-2 rounded-full bg-slate-700 px-3 py-1.5 text-sm font-semibold text-slate-100 transition hover:bg-slate-600 focus:outline-none focus-visible:ring-2 focus-visible:ring-red-400">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
|
||||
<path fill-rule="evenodd" d="M4 2a1 1 0 011 1v2.101a7.002 7.002 0 0111.601 2.566 1 1 0 11-1.885.666A5.002 5.002 0 005.999 7H9a1 1 0 010 2H4a1 1 0 01-1-1V3a1 1 0 011-1zm.008 9.057a1 1 0 011.276.61A5.002 5.002 0 0014.001 13H11a1 1 0 110-2h5a1 1 0 011 1v5a1 1 0 11-2 0v-2.101a7.002 7.002 0 01-11.601-2.566 1 1 0 01-.61-1.276z" clip-rule="evenodd" />
|
||||
</svg>
|
||||
Réessayer
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
}
|
||||
</section>
|
||||
}
|
||||
|
||||
<!-- Commentaires -->
|
||||
<section class="mt-8" aria-label="Commentaires">
|
||||
<div class="mb-4 flex flex-wrap items-center justify-between gap-3">
|
||||
<h2 class="text-xl font-bold text-white">{{ v.comments.length }} commentaire{{ v.comments.length > 1 ? 's' : '' }}</h2>
|
||||
@if (v.comments.length > 0) {
|
||||
<div class="flex items-center gap-2">
|
||||
<button (click)="onSummarizeComments()"
|
||||
[disabled]="isSummarizing() || !geminiReady"
|
||||
class="flex items-center gap-2 rounded-full bg-slate-700 px-3 py-2 text-sm font-semibold text-slate-100 transition hover:bg-slate-600 focus:outline-none focus-visible:ring-2 focus-visible:ring-red-400 disabled:cursor-not-allowed disabled:bg-slate-800 disabled:text-slate-400">
|
||||
@if (isSummarizing()) {
|
||||
<svg class="h-5 w-5 animate-spin" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" aria-hidden="true">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
</svg>
|
||||
<span>Résumé en cours…</span>
|
||||
} @else {
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 text-yellow-300" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
|
||||
<path fill-rule="evenodd" d="M10 3a1 1 0 011 1v2.586l1.707-1.707a1 1 0 111.414 1.414l-1.707 1.707H15a1 1 0 110 2h-2.586l1.707 1.707a1 1 0 11-1.414 1.414L11 12.414V15a1 1 0 11-2 0v-2.586l-1.707 1.707a1 1 0 11-1.414-1.414L7.586 11H5a1 1 0 110-2h2.586L5.793 7.293a1 1 0 111.414-1.414L9 7.586V5a1 1 0 011-1z" clip-rule="evenodd" />
|
||||
</svg>
|
||||
<span>Résumer les commentaires</span>
|
||||
}
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
@if (!geminiReady && geminiReason && v.comments.length > 0) {
|
||||
<div class="mb-4 text-xs text-amber-300">{{ geminiReason }}</div>
|
||||
}
|
||||
@if (isSummarizing() || summary() || summaryError()) {
|
||||
<div class="mb-6">
|
||||
@if (isSummarizing() && !summary() && !summaryError()) {
|
||||
<div class="bg-slate-800 p-4 rounded-lg animate-pulse">
|
||||
<div class="h-4 bg-slate-700 rounded w-1/4 mb-3"></div>
|
||||
<div class="space-y-2">
|
||||
<div class="h-4 bg-slate-700 rounded"></div>
|
||||
<div class="h-4 bg-slate-700 rounded w-5/6"></div>
|
||||
<div class="h-4 bg-slate-700 rounded w-3/4"></div>
|
||||
</div>
|
||||
<div class="animate-pulse rounded-xl bg-slate-800 p-4">
|
||||
<div class="mb-3 h-4 w-1/4 rounded bg-slate-700"></div>
|
||||
<div class="space-y-2">
|
||||
<div class="h-4 rounded bg-slate-700"></div>
|
||||
<div class="h-4 w-5/6 rounded bg-slate-700"></div>
|
||||
<div class="h-4 w-3/4 rounded bg-slate-700"></div>
|
||||
</div>
|
||||
</div>
|
||||
} @else if (summary(); as summaryText) {
|
||||
<div class="bg-gradient-to-br from-slate-800 to-slate-800/50 p-4 rounded-lg border border-slate-700">
|
||||
<h3 class="text-lg font-bold text-yellow-300 flex items-center space-x-2">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" viewBox="0 0 20 20" fill="currentColor">
|
||||
<div class="rounded-xl border border-slate-700 bg-gradient-to-br from-slate-800 to-slate-800/50 p-4">
|
||||
<h3 class="flex items-center gap-2 text-lg font-bold text-yellow-300">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
|
||||
<path fill-rule="evenodd" d="M10 3a1 1 0 011 1v2.586l1.707-1.707a1 1 0 111.414 1.414l-1.707 1.707H15a1 1 0 110 2h-2.586l1.707 1.707a1 1 0 11-1.414 1.414L11 12.414V15a1 1 0 11-2 0v-2.586l-1.707 1.707a1 1 0 11-1.414-1.414L7.586 11H5a1 1 0 110-2h2.586L5.793 7.293a1 1 0 111.414-1.414L9 7.586V5a1 1 0 011-1z" clip-rule="evenodd" />
|
||||
</svg>
|
||||
<span>AI Summary</span>
|
||||
<span>Résumé IA</span>
|
||||
</h3>
|
||||
<p class="mt-2 text-slate-300 whitespace-pre-wrap">{{ summaryText }}</p>
|
||||
<p class="mt-2 whitespace-pre-wrap text-slate-300">{{ summaryText }}</p>
|
||||
</div>
|
||||
} @else if (summaryError(); as errorText) {
|
||||
<div class="bg-red-900/50 border border-red-700 text-red-300 p-4 rounded-lg">
|
||||
<div class="rounded-xl border border-red-700 bg-red-900/50 p-4 text-red-300" role="alert">
|
||||
<p>{{ errorText }}</p>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
<div class="space-y-6">
|
||||
@for (comment of v.comments; track comment.author + comment.commentedTime) {
|
||||
<div class="flex items-start space-x-4">
|
||||
<img [src]="comment.thumbnail" alt="" class="w-10 h-10 rounded-full">
|
||||
<div class="flex-1">
|
||||
<div class="flex items-center space-x-2 text-sm">
|
||||
<span class="font-semibold text-slate-200">{{ comment.author }}</span>
|
||||
<span class="text-slate-400">{{ comment.commentedTime }}</span>
|
||||
@if (v.comments.length > 0) {
|
||||
<div class="space-y-5">
|
||||
@for (comment of v.comments; track comment.author + comment.commentedTime) {
|
||||
<article class="flex items-start gap-3">
|
||||
<img [src]="comment.thumbnail" alt="" class="h-10 w-10 shrink-0 rounded-full bg-slate-700" loading="lazy">
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="flex flex-wrap items-center gap-x-2 text-sm">
|
||||
<span class="font-semibold text-slate-200">{{ comment.author }}</span>
|
||||
<span class="text-slate-500">{{ comment.commentedTime }}</span>
|
||||
</div>
|
||||
<p class="mt-1 break-words text-sm leading-relaxed text-slate-300">{{ comment.commentText }}</p>
|
||||
</div>
|
||||
<p class="mt-1 text-slate-300">{{ comment.commentText }}</p>
|
||||
</article>
|
||||
}
|
||||
</div>
|
||||
} @else {
|
||||
<p class="rounded-xl bg-slate-800/50 p-4 text-center text-sm text-slate-400">Aucun commentaire pour cette vidéo.</p>
|
||||
}
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<!-- ============ Sidebar ============ -->
|
||||
<aside class="min-w-0" aria-label="Vidéos suivantes">
|
||||
<div class="lg:sticky lg:top-4">
|
||||
<h2 class="mb-3 text-lg font-bold text-white">À suivre</h2>
|
||||
<div class="space-y-2">
|
||||
@for (related of v.relatedStreams; track related.videoId) {
|
||||
<div class="group rounded-xl p-2 transition hover:bg-slate-800/60 focus-within:bg-slate-800/60">
|
||||
<a [routerLink]="['/watch', related.videoId]" [queryParams]="watchQueryParams(related)" [state]="{ video: related }" class="flex items-start gap-3 rounded-lg focus:outline-none focus-visible:ring-2 focus-visible:ring-red-400">
|
||||
<div class="relative w-40 shrink-0">
|
||||
<img [src]="related.thumbnail" [alt]="related.title" class="h-24 w-full rounded-xl object-cover" loading="lazy">
|
||||
<div class="absolute right-1.5 top-1.5">
|
||||
<app-provider-badge [provider]="related.provider"></app-provider-badge>
|
||||
</div>
|
||||
@if (related.duration && related.duration > 0) {
|
||||
<div class="absolute bottom-1.5 right-1.5 rounded bg-black/75 px-1.5 py-0.5 text-[10px] tabular-nums text-white">
|
||||
{{ related.duration | duration }}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
<div class="min-w-0 flex-1">
|
||||
<h3 class="line-clamp-2 text-sm font-semibold leading-snug text-slate-100 group-hover:text-red-300">{{ related.title }}</h3>
|
||||
<div class="mt-1 text-xs tabular-nums text-slate-400">{{ formatNumber(related.views) }} vues</div>
|
||||
</div>
|
||||
</a>
|
||||
<div class="mt-1.5 pl-1">
|
||||
<app-channel-identity
|
||||
size="sm"
|
||||
[provider]="related.provider"
|
||||
[externalId]="related.channelExternalId ?? null"
|
||||
[avatarUrl]="related.channelAvatarUrl || related.uploaderAvatar || null"
|
||||
[name]="related.uploaderName || null"
|
||||
></app-channel-identity>
|
||||
</div>
|
||||
</div>
|
||||
} @empty {
|
||||
<p class="rounded-xl bg-slate-800/50 p-4 text-center text-sm text-slate-400">Aucune suggestion pour le moment.</p>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Sidebar: Related Videos -->
|
||||
<div class="lg:w-1/3">
|
||||
<h2 class="text-xl font-bold mb-4">Up next</h2>
|
||||
<div class="space-y-4">
|
||||
@for (related of v.relatedStreams; track related.videoId) {
|
||||
<div class="group hover:bg-slate-800/50 p-2 rounded-lg transition">
|
||||
<a [routerLink]="['/watch', related.videoId]" [queryParams]="watchQueryParams(related)" [state]="{ video: related }" class="flex items-start space-x-3">
|
||||
<div class="relative w-40 flex-shrink-0">
|
||||
<img [src]="related.thumbnail" [alt]="related.title" class="w-full h-24 object-cover rounded">
|
||||
<div class="absolute top-1.5 right-1.5">
|
||||
<app-provider-badge [provider]="related.provider"></app-provider-badge>
|
||||
</div>
|
||||
<div *ngIf="related.duration && related.duration > 0" class="absolute bottom-1.5 right-1.5 bg-black/75 text-white text-[10px] px-1.5 py-0.5 rounded">
|
||||
{{ related.duration | duration }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex-1 min-w-0">
|
||||
<h3 class="font-semibold text-sm line-clamp-2 group-hover:text-red-400">{{ related.title }}</h3>
|
||||
<div class="text-xs text-slate-400">{{ formatNumber(related.views) }} views</div>
|
||||
</div>
|
||||
</a>
|
||||
<div class="mt-1 pl-1">
|
||||
<app-channel-identity
|
||||
size="sm"
|
||||
[provider]="related.provider"
|
||||
[externalId]="related.channelExternalId ?? null"
|
||||
[avatarUrl]="related.channelAvatarUrl || related.uploaderAvatar || null"
|
||||
[name]="related.uploaderName || null"
|
||||
></app-channel-identity>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
} @else {
|
||||
<div class="text-center py-20">
|
||||
<h2 class="text-2xl font-bold text-slate-300">Video not found</h2>
|
||||
<p class="text-slate-400 mt-2">The requested video could not be loaded.</p>
|
||||
<a routerLink="/" class="mt-6 inline-block bg-red-600 hover:bg-red-700 text-white font-bold py-2 px-4 rounded-full transition">Go to Home</a>
|
||||
<div class="py-20 text-center">
|
||||
<h2 class="text-2xl font-bold text-slate-300">Vidéo introuvable</h2>
|
||||
<p class="mt-2 text-slate-400">La vidéo demandée n'a pas pu être chargée.</p>
|
||||
<a routerLink="/" class="mt-6 inline-block rounded-full bg-red-600 px-5 py-2.5 font-bold text-white transition hover:bg-red-500 focus:outline-none focus-visible:ring-2 focus-visible:ring-red-400">Retour à l'accueil</a>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -290,11 +290,29 @@ export class WatchComponent implements OnDestroy, AfterViewInit {
|
||||
liked = signal<boolean>(false);
|
||||
likeBusy = signal<boolean>(false);
|
||||
|
||||
// --- Transcript state (Step 16, Phase 1: display + language selector, no seek) ---
|
||||
// --- Transcript state (Phase 2: display + search + click-to-seek + auto-scroll) ---
|
||||
transcriptOpen = signal(false);
|
||||
transcriptLoading = signal(false);
|
||||
transcriptAvailable = signal(false);
|
||||
transcriptError = signal<string | null>(null);
|
||||
transcriptSearch = signal('');
|
||||
transcriptAutoScroll = signal(true);
|
||||
transcriptCopied = signal(false);
|
||||
transcriptActiveIndex = signal(-1);
|
||||
linkCopied = signal(false);
|
||||
filteredTranscriptLines = computed(() => {
|
||||
const q = this.transcriptSearch().trim().toLowerCase();
|
||||
const lines = this.transcriptLines();
|
||||
if (!q) return lines;
|
||||
return lines.filter((l) => l.text.toLowerCase().includes(q));
|
||||
});
|
||||
transcriptCountLabel = computed(() => {
|
||||
const total = this.transcriptLines().length;
|
||||
const shown = this.filteredTranscriptLines().length;
|
||||
return shown !== total ? `${shown} / ${total} passages` : `${total} passages`;
|
||||
});
|
||||
private transcriptClock: any = null;
|
||||
@ViewChild(VideoPlayerComponent, { static: false }) nativePlayer?: VideoPlayerComponent;
|
||||
// Machine-readable unavailability reason from the API: 'no_subtitles' |
|
||||
// 'provider_unsupported' | 'temporarily_unavailable' | null.
|
||||
transcriptReason = signal<string | null>(null);
|
||||
@@ -523,6 +541,7 @@ export class WatchComponent implements OnDestroy, AfterViewInit {
|
||||
|
||||
ngOnDestroy(): void {
|
||||
this.routeSubscription.unsubscribe();
|
||||
this.stopTranscriptClock();
|
||||
}
|
||||
|
||||
// Toggle like status for the current video
|
||||
@@ -837,11 +856,15 @@ export class WatchComponent implements OnDestroy, AfterViewInit {
|
||||
|
||||
// Copy current page link to clipboard
|
||||
async copyLink(): Promise<void> {
|
||||
const done = () => {
|
||||
this.linkCopied.set(true);
|
||||
setTimeout(() => this.linkCopied.set(false), 2000);
|
||||
};
|
||||
try {
|
||||
const href = typeof window !== 'undefined' && window.location ? window.location.href : '';
|
||||
if (!href) return;
|
||||
await navigator.clipboard.writeText(href);
|
||||
console.log('[Watch] Link copied to clipboard');
|
||||
done();
|
||||
} catch (e) {
|
||||
try {
|
||||
// Fallback if Clipboard API not available
|
||||
@@ -853,7 +876,7 @@ export class WatchComponent implements OnDestroy, AfterViewInit {
|
||||
ta.select();
|
||||
document.execCommand('copy');
|
||||
document.body.removeChild(ta);
|
||||
console.log('[Watch] Link copied to clipboard (fallback)');
|
||||
done();
|
||||
} catch {}
|
||||
}
|
||||
}
|
||||
@@ -890,7 +913,11 @@ export class WatchComponent implements OnDestroy, AfterViewInit {
|
||||
this.selectedQuality.set(value || null);
|
||||
}
|
||||
|
||||
// --- Transcript logic (Step 16) ---
|
||||
// Langues déjà tentées pour la vidéo courante (auto-fallback "n'importe
|
||||
// quelle langue" : on ne réessaie jamais deux fois la même).
|
||||
private transcriptTriedLangs = new Set<string>();
|
||||
|
||||
// --- Transcript logic (Phase 2) ---
|
||||
private resetTranscriptUi() {
|
||||
this.transcriptOpen.set(false);
|
||||
this.transcriptLoading.set(false);
|
||||
@@ -900,6 +927,12 @@ export class WatchComponent implements OnDestroy, AfterViewInit {
|
||||
this.transcriptRetryable.set(false);
|
||||
this.transcriptLanguages.set([]);
|
||||
this.transcriptLines.set([]);
|
||||
this.transcriptSearch.set('');
|
||||
this.transcriptCopied.set(false);
|
||||
this.transcriptActiveIndex.set(-1);
|
||||
this.transcriptAutoScroll.set(true);
|
||||
this.stopTranscriptClock();
|
||||
this.transcriptTriedLangs.clear();
|
||||
try {
|
||||
const prefLang = String(this.users.preferences()?.language || '').trim().toLowerCase().slice(0, 12);
|
||||
this.selectedTranscriptLang.set(prefLang || 'fr');
|
||||
@@ -911,8 +944,13 @@ export class WatchComponent implements OnDestroy, AfterViewInit {
|
||||
toggleTranscript() {
|
||||
const next = !this.transcriptOpen();
|
||||
this.transcriptOpen.set(next);
|
||||
if (next && this.transcriptLines().length === 0 && !this.transcriptLoading()) {
|
||||
this.loadTranscript();
|
||||
if (next) {
|
||||
this.startTranscriptClock();
|
||||
if (this.transcriptLines().length === 0 && !this.transcriptLoading()) {
|
||||
this.loadTranscript();
|
||||
}
|
||||
} else {
|
||||
this.stopTranscriptClock();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -996,9 +1034,147 @@ export class WatchComponent implements OnDestroy, AfterViewInit {
|
||||
retryTranscript() {
|
||||
if (this.transcriptLoading()) return;
|
||||
this.transcriptLines.set([]);
|
||||
this.transcriptActiveIndex.set(-1);
|
||||
// Nouveau round manuel : on peut re-tenter toutes les langues.
|
||||
this.transcriptTriedLangs.clear();
|
||||
this.loadTranscript();
|
||||
}
|
||||
|
||||
/** Clic sur un timestamp : saute à ce moment (natif direct, iframe via ?start=). */
|
||||
seekTranscript(seconds: number): void {
|
||||
const t = Math.max(0, Math.floor(Number(seconds) || 0));
|
||||
try {
|
||||
const native = this.nativePlayer?.el;
|
||||
if (native && Number.isFinite(native.duration)) {
|
||||
native.currentTime = t;
|
||||
try { native.play?.(); } catch {}
|
||||
this.startPositionSeconds.set(t);
|
||||
this.updateTranscriptActive(t);
|
||||
this.scrollTranscriptTo(t);
|
||||
return;
|
||||
}
|
||||
} catch {}
|
||||
// Embeds cross-origin (YouTube/Dailymotion/PeerTube/Odysee) : on
|
||||
// reconstruit l'URL d'embed avec le offset (embedUrl dépend du signal).
|
||||
this.startPositionSeconds.set(t);
|
||||
this.updateTranscriptActive(t);
|
||||
this.scrollTranscriptTo(t);
|
||||
}
|
||||
|
||||
onTranscriptSearch(value: string): void {
|
||||
this.transcriptSearch.set(String(value ?? ''));
|
||||
}
|
||||
|
||||
clearTranscriptSearch(): void {
|
||||
this.transcriptSearch.set('');
|
||||
}
|
||||
|
||||
toggleTranscriptAutoScroll(): void {
|
||||
this.transcriptAutoScroll.update((v) => !v);
|
||||
}
|
||||
|
||||
/** L'utilisateur scrolle manuellement : on coupe l'auto-scroll pour ne pas le gêner. */
|
||||
onTranscriptScroll(ev: Event): void {
|
||||
if (!this.transcriptAutoScroll()) return;
|
||||
try {
|
||||
const el = ev.target as HTMLElement | null;
|
||||
if (!el) return;
|
||||
const nearBottom = el.scrollHeight - el.scrollTop - el.clientHeight < 120;
|
||||
if (!nearBottom) this.transcriptAutoScroll.set(false);
|
||||
} catch {}
|
||||
}
|
||||
|
||||
async copyTranscript(): Promise<void> {
|
||||
const text = this.filteredTranscriptLines()
|
||||
.map((l) => `${this.formatTimestamp(l.t)} ${l.text}`)
|
||||
.join('\n');
|
||||
if (!text) return;
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
} catch {
|
||||
try {
|
||||
const ta = document.createElement('textarea');
|
||||
ta.value = text;
|
||||
document.body.appendChild(ta);
|
||||
ta.select();
|
||||
document.execCommand('copy');
|
||||
document.body.removeChild(ta);
|
||||
} catch {}
|
||||
}
|
||||
this.transcriptCopied.set(true);
|
||||
setTimeout(() => this.transcriptCopied.set(false), 2000);
|
||||
}
|
||||
|
||||
trackTranscriptLine(i: number, line: { t: number; text: string }): string {
|
||||
return `${Math.round(line.t * 10)}-${line.text.slice(0, 24)}-${i}`;
|
||||
}
|
||||
|
||||
formatTimestamp(t: number): string {
|
||||
const s = Math.max(0, Math.floor(Number(t) || 0));
|
||||
const h = Math.floor(s / 3600);
|
||||
const m = Math.floor((s % 3600) / 60);
|
||||
const sec = s % 60;
|
||||
const pad = (n: number) => String(n).padStart(2, '0');
|
||||
return h > 0 ? `${h}:${pad(m)}:${pad(sec)}` : `${m}:${pad(sec)}`;
|
||||
}
|
||||
|
||||
private updateTranscriptActive(currentSec: number): void {
|
||||
const lines = this.transcriptLines();
|
||||
if (!lines.length) return;
|
||||
let idx = -1;
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
if (lines[i].t <= currentSec + 0.25) idx = i;
|
||||
else break;
|
||||
}
|
||||
if (idx !== this.transcriptActiveIndex()) {
|
||||
this.transcriptActiveIndex.set(idx);
|
||||
if (this.transcriptAutoScroll() && idx >= 0) {
|
||||
try {
|
||||
document.getElementById(`tr-line-${idx}`)?.scrollIntoView({ block: 'nearest', behavior: 'smooth' });
|
||||
} catch {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private scrollTranscriptTo(seconds: number): void {
|
||||
this.updateTranscriptActive(seconds);
|
||||
}
|
||||
|
||||
private startTranscriptClock(): void {
|
||||
this.stopTranscriptClock();
|
||||
// Native <video> uniquement (les iframes cross-origin n'exposent pas currentTime).
|
||||
this.transcriptClock = setInterval(() => {
|
||||
try {
|
||||
const v = this.nativePlayer?.el;
|
||||
if (v && !v.paused && Number.isFinite(v.currentTime)) this.updateTranscriptActive(v.currentTime);
|
||||
} catch {}
|
||||
}, 1000);
|
||||
}
|
||||
|
||||
private stopTranscriptClock(): void {
|
||||
if (this.transcriptClock) {
|
||||
clearInterval(this.transcriptClock);
|
||||
this.transcriptClock = null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Auto-fallback "n'importe quelle langue" : si la langue demandée échoue
|
||||
* mais que le backend signale d'autres pistes, on relance automatiquement
|
||||
* avec la première non encore tentée. Retourne true si un retry est parti.
|
||||
*/
|
||||
private autoFallbackTranscriptLang(languages: unknown): boolean {
|
||||
if (!Array.isArray(languages)) return false;
|
||||
const next = (languages as unknown[])
|
||||
.map((l) => String(l || '').trim().slice(0, 12))
|
||||
.find((l) => !!l && !this.transcriptTriedLangs.has(l.toLowerCase()));
|
||||
if (!next) return false;
|
||||
this.selectedTranscriptLang.set(next);
|
||||
// loadTranscript() ré-enregistre dans triedLangs et relance la requête.
|
||||
this.loadTranscript();
|
||||
return true;
|
||||
}
|
||||
|
||||
loadTranscript() {
|
||||
const p = this.provider();
|
||||
const id = this.videoId();
|
||||
@@ -1014,8 +1190,10 @@ export class WatchComponent implements OnDestroy, AfterViewInit {
|
||||
this.transcriptError.set(null);
|
||||
this.transcriptReason.set(null);
|
||||
this.transcriptRetryable.set(false);
|
||||
const requestedLang = this.selectedTranscriptLang();
|
||||
this.transcriptTriedLangs.add(String(requestedLang || '').toLowerCase());
|
||||
const opts = this.buildProviderOpts() as Record<string, string>;
|
||||
const params = new URLSearchParams({ lang: this.selectedTranscriptLang() });
|
||||
const params = new URLSearchParams({ lang: requestedLang });
|
||||
if (opts['instance']) params.set('instance', opts['instance']);
|
||||
if (opts['slug']) params.set('slug', opts['slug']);
|
||||
const url = `${this.apiBase()}/transcript/${encodeURIComponent(p)}/${encodeURIComponent(id)}?${params.toString()}`;
|
||||
@@ -1029,6 +1207,9 @@ export class WatchComponent implements OnDestroy, AfterViewInit {
|
||||
this.selectedTranscriptLang.set(data.lang);
|
||||
}
|
||||
if (!data?.available) {
|
||||
// La langue demandée n'a rien donné mais d'autres pistes existent :
|
||||
// on bascule automatiquement dessus au lieu d'afficher une erreur.
|
||||
if (this.autoFallbackTranscriptLang(data?.languages)) return;
|
||||
const st = this.transcriptStatusFor(data);
|
||||
this.transcriptReason.set(st.reason);
|
||||
this.transcriptRetryable.set(st.retryable);
|
||||
@@ -1037,6 +1218,8 @@ export class WatchComponent implements OnDestroy, AfterViewInit {
|
||||
this.transcriptReason.set(null);
|
||||
this.transcriptRetryable.set(false);
|
||||
this.transcriptError.set(null);
|
||||
this.transcriptActiveIndex.set(-1);
|
||||
this.startTranscriptClock();
|
||||
}
|
||||
},
|
||||
error: (err) => {
|
||||
@@ -1046,6 +1229,9 @@ export class WatchComponent implements OnDestroy, AfterViewInit {
|
||||
const body = err?.error;
|
||||
if (body && typeof body === 'object' && Array.isArray(body.languages)) {
|
||||
this.transcriptLanguages.set(body.languages);
|
||||
// Erreur réseau/502 mais d'autres pistes connues : on essaie la
|
||||
// suivante automatiquement (une fois chacune, garde anti-boucle).
|
||||
if (this.autoFallbackTranscriptLang(body.languages)) return;
|
||||
}
|
||||
const raw =
|
||||
(body && typeof body === 'object' && (body.error || body.details || body.reason)) ||
|
||||
|
||||
Reference in New Issue
Block a user