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:
2026-09-26 08:51:04 -04:00
parent 4bdcd393fe
commit 2db1f2deee
5 changed files with 948 additions and 335 deletions
+453 -315
View File
@@ -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>
+193 -7
View File
@@ -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)) ||