feat(watch,themes): transcript en sidebar, seek fiable, player instantane, bande themes repliable
CI / build-and-test (push) Failing after 8m19s

- watch: panneau transcription deplace en tete de sidebar au-dessus de
  A suivre (+ scroll auto a l'ouverture sur mobile)
- seek transcript: seekTo postMessage YouTube (sans rechargement),
  time= Twitch VOD, t= Odysee, start= Rumble (start/dm/pt existants)
- watch: lecteur monte des les parametres de route (embed natif),
  squelettes partiels pendant /details au lieu du plein ecran
- themes-nav: bouton replier/deplier persistant (localStorage),
  barre fine avec theme actif quand repliee, cles i18n FR/EN
This commit is contained in:
2026-09-26 09:56:04 -04:00
parent d5ceab7115
commit a70d7b9797
5 changed files with 694 additions and 508 deletions
@@ -1,4 +1,19 @@
<nav class="sticky top-16 left-0 right-0 z-40 w-full relative" @if (collapsed()) {
<!-- Barre repliée : fine, rouvrable à tout moment -->
<div class="sticky top-16 left-0 right-0 z-40 flex w-full items-center gap-2 border-b border-slate-700/60 bg-slate-800/60 px-3 py-1 backdrop-blur-sm">
<span class="text-xs font-medium text-slate-400">{{ 'themes.title' | t : 'Thèmes' }}</span>
@if (activeThemeLabel(); as label) {
<span class="rounded-full border border-red-500/60 bg-slate-100/10 px-2 py-0.5 text-xs text-white">{{ label }}</span>
}
<button type="button" (click)="toggleCollapsed()" [attr.aria-expanded]="false" aria-controls="themes-bar"
[title]="'themes.show' | t : 'Afficher la bande des thèmes'"
class="ml-auto inline-flex items-center gap-1 rounded-full px-2 py-1 text-xs font-semibold text-slate-300 transition hover:bg-slate-700 hover:text-white focus:outline-none focus-visible:ring-2 focus-visible:ring-red-500">
<span>{{ 'themes.show' | t : 'Afficher' }}</span>
<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="M5.293 7.293a1 1 0 011.414 0L10 10.586l3.293-3.293a1 1 0 111.414 1.414l-4 4a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414z" clip-rule="evenodd" /></svg>
</button>
</div>
} @else {
<nav id="themes-bar" class="sticky top-16 left-0 right-0 z-40 w-full relative"
[ngClass]="{ [ngClass]="{
'bg-slate-800 border-b border-slate-700': isDarkTheme(), 'bg-slate-800 border-b border-slate-700': isDarkTheme(),
'bg-slate-900 border-b border-slate-800': isBlackTheme(), 'bg-slate-900 border-b border-slate-800': isBlackTheme(),
@@ -24,6 +39,18 @@
» »
</button> </button>
<!-- Replier la bande -->
<button type="button" (click)="toggleCollapsed()" [attr.aria-expanded]="true" aria-controls="themes-bar"
[title]="'themes.hide' | t : 'Masquer la bande des thèmes'"
class="absolute right-12 top-1/2 z-10 hidden h-9 w-9 -translate-y-1/2 items-center justify-center rounded-full shadow transition focus:outline-none focus-visible:ring-2 focus-visible:ring-red-500 md:flex"
[ngClass]="{
'bg-white/90 border border-slate-300 text-slate-700 hover:bg-slate-100': isLightTheme(),
'bg-slate-800/90 border border-slate-700 text-slate-200 hover:bg-slate-700': isDarkTheme() || isBlackTheme() || isBlueTheme()
}"
aria-label="Masquer la bande des thèmes">
<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="M14.707 12.707a1 1 0 01-1.414 0L10 9.414l-3.293 3.293a1 1 0 01-1.414-1.414l4-4a1 1 0 011.414 0l4 4a1 1 0 010 1.414z" clip-rule="evenodd" /></svg>
</button>
<div class="w-full overflow-x-auto no-scrollbar" #scroll (wheel)="onWheel($event)"> <div class="w-full overflow-x-auto no-scrollbar" #scroll (wheel)="onWheel($event)">
<ul role="tablist" aria-label="Themes" class="flex gap-2 px-12 py-2 min-w-max items-center"> <ul role="tablist" aria-label="Themes" class="flex gap-2 px-12 py-2 min-w-max items-center">
<li *ngFor="let t of displayedThemes(); let i = index" class="shrink-0"> <li *ngFor="let t of displayedThemes(); let i = index" class="shrink-0">
@@ -45,6 +72,13 @@
<span>{{ themesSvc.i18nLabel(t) }}</span> <span>{{ themesSvc.i18nLabel(t) }}</span>
</button> </button>
</li> </li>
<li class="shrink-0 md:hidden">
<button type="button" (click)="toggleCollapsed()"
class="px-3 py-1.5 rounded-full text-xs font-semibold text-slate-400 border border-slate-700 whitespace-nowrap">
{{ 'themes.hide' | t : 'Masquer' }}
</button>
</li>
</ul> </ul>
</div> </div>
</nav> </nav>
}
+24 -1
View File
@@ -2,12 +2,15 @@ import { ChangeDetectionStrategy, Component, ElementRef, QueryList, ViewChild, V
import { CommonModule } from '@angular/common'; import { CommonModule } from '@angular/common';
import { Router, NavigationEnd, ActivatedRoute } from '@angular/router'; import { Router, NavigationEnd, ActivatedRoute } from '@angular/router';
import { ThemesService } from '../../services/themes.service'; import { ThemesService } from '../../services/themes.service';
import { TranslatePipe } from '../../pipes/translate.pipe';
const THEMES_BAR_COLLAPSED_KEY = 'newtube.themesBarCollapsed';
@Component({ @Component({
selector: 'app-themes-nav', selector: 'app-themes-nav',
standalone: true, standalone: true,
changeDetection: ChangeDetectionStrategy.OnPush, changeDetection: ChangeDetectionStrategy.OnPush,
imports: [CommonModule], imports: [CommonModule, TranslatePipe],
templateUrl: './themes-nav.component.html' templateUrl: './themes-nav.component.html'
}) })
export class ThemesNavComponent implements OnDestroy { export class ThemesNavComponent implements OnDestroy {
@@ -19,6 +22,26 @@ export class ThemesNavComponent implements OnDestroy {
activeSlug = this.themesSvc.activeSlug; activeSlug = this.themesSvc.activeSlug;
displayedThemes = () => this.themes(); displayedThemes = () => this.themes();
/** Bande repliée/dépliée (persistée) : la barre reste masquable en permanence. */
collapsed = signal<boolean>((() => {
try { return localStorage.getItem(THEMES_BAR_COLLAPSED_KEY) === '1'; } catch { return false; }
})());
toggleCollapsed(): void {
const next = !this.collapsed();
this.collapsed.set(next);
try { localStorage.setItem(THEMES_BAR_COLLAPSED_KEY, next ? '1' : '0'); } catch {}
}
activeThemeLabel(): string {
try {
const slug = this.activeSlug();
const found = this.themes().find((t) => t.slug === slug);
if (found) return this.themesSvc.i18nLabel(found);
} catch {}
return '';
}
@ViewChildren('pill', { read: ElementRef }) pills!: QueryList<ElementRef<HTMLButtonElement>>; @ViewChildren('pill', { read: ElementRef }) pills!: QueryList<ElementRef<HTMLButtonElement>>;
@ViewChild('scroll', { static: false }) scrollRef?: ElementRef<HTMLDivElement>; @ViewChild('scroll', { static: false }) scrollRef?: ElementRef<HTMLDivElement>;
+144 -112
View File
@@ -1,34 +1,10 @@
<div class="mx-auto w-full max-w-7xl px-4 py-4 sm:px-6 lg:px-8 lg:py-6"> <div class="mx-auto w-full max-w-7xl px-4 py-4 sm:px-6 lg:px-8 lg:py-6">
@if (loading()) { @if (video(); as v) {
<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 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="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="grid gap-6 lg:grid-cols-[minmax(0,1fr)_360px] xl:gap-8"> <div class="grid gap-6 lg:grid-cols-[minmax(0,1fr)_360px] xl:gap-8">
<!-- ============ Colonne principale ============ --> <!-- ============ Colonne principale ============ -->
<div class="min-w-0"> <div class="min-w-0">
<!-- Lecteur --> <!-- Lecteur : monté dès que l'URL d'embed est calculable (route),
sans attendre /details, suggestions ou historique. -->
<section aria-label="Lecteur vidéo" class="overflow-hidden rounded-2xl bg-black shadow-2xl ring-1 ring-white/10"> <section aria-label="Lecteur vidéo" class="overflow-hidden rounded-2xl bg-black shadow-2xl ring-1 ring-white/10">
<div class="aspect-video"> <div class="aspect-video">
@if (embedUrl(); as src) { @if (embedUrl(); as src) {
@@ -36,16 +12,35 @@
referrerpolicy="origin" referrerpolicy="origin"
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"
allowfullscreen title="Lecteur vidéo"></iframe> allowfullscreen title="Lecteur vidéo"></iframe>
} @else { } @else if (videoSource()) {
<app-video-player <app-video-player
[videoSource]="videoSource()" [videoSource]="videoSource()"
[startPositionSeconds]="startPositionSeconds()" [startPositionSeconds]="startPositionSeconds()"
[watchHistoryId]="watchHistoryId()" [watchHistoryId]="watchHistoryId()"
></app-video-player> ></app-video-player>
} @else {
<div class="flex h-full w-full items-center justify-center gap-3 text-slate-400" aria-label="Chargement du lecteur">
<svg class="h-8 w-8 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 class="text-sm">Chargement du lecteur…</span>
</div>
} }
</div> </div>
</section> </section>
@if (loading()) {
<!-- Squelette des métadonnées pendant /details (le player joue déjà) -->
<div class="mt-4 animate-pulse space-y-3" aria-hidden="true">
<div class="h-6 w-3/4 rounded bg-slate-800"></div>
<div class="h-4 w-1/3 rounded bg-slate-800"></div>
<div class="flex items-center gap-3 rounded-2xl border border-slate-700/60 bg-slate-800/50 p-4">
<div class="h-11 w-11 rounded-full bg-slate-800"></div>
<div class="h-4 w-40 rounded bg-slate-800"></div>
</div>
</div>
} @else {
@if (availableQualities().length > 0) { @if (availableQualities().length > 0) {
<div class="mt-3 flex items-center gap-2 text-sm text-slate-400"> <div class="mt-3 flex items-center gap-2 text-sm text-slate-400">
<label for="qualitySel" class="font-medium">Qualité</label> <label for="qualitySel" class="font-medium">Qualité</label>
@@ -204,7 +199,7 @@
{{ hasDescription() ? descriptionText() : 'Aucune description disponible.' }} {{ hasDescription() ? descriptionText() : 'Aucune description disponible.' }}
</p> </p>
@if (showMoreAvailable()) { @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"> <button (click)="toggleDescription()" class="mt-2 rounded text-sm font-semibold text-red-400 transition hover:text-red-300 focus:outline-none focus-visible:ring-2 focus-visible:ring-red-400">
{{ showFullDescription() ? 'Réduire' : 'Afficher plus' }} {{ showFullDescription() ? 'Réduire' : 'Afficher plus' }}
</button> </button>
} }
@@ -290,9 +285,102 @@
</section> </section>
} }
<!-- Transcription --> <!-- 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="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="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>Résumé IA</span>
</h3>
<p class="mt-2 whitespace-pre-wrap text-slate-300">{{ summaryText }}</p>
</div>
} @else if (summaryError(); as errorText) {
<div class="rounded-xl border border-red-700 bg-red-900/50 p-4 text-red-300" role="alert">
<p>{{ errorText }}</p>
</div>
}
</div>
}
@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>
</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 : transcription puis suggestions ============ -->
<aside class="min-w-0" aria-label="Transcription et vidéos suivantes">
<div class="space-y-4 lg:sticky lg:top-4">
@if (loading()) {
<div class="animate-pulse space-y-4" aria-hidden="true">
@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>
} @else {
@if (transcriptOpen()) { @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"> <section id="transcript-panel" class="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"> <div class="flex flex-wrap items-center gap-3">
<h2 class="font-semibold text-slate-100">Transcription</h2> <h2 class="font-semibold text-slate-100">Transcription</h2>
@if (transcriptAvailable() && transcriptLines().length > 0) { @if (transcriptAvailable() && transcriptLines().length > 0) {
@@ -394,87 +482,7 @@
} }
</section> </section>
} }
<section aria-label="Vidéos suivantes">
<!-- 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="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="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>Résumé IA</span>
</h3>
<p class="mt-2 whitespace-pre-wrap text-slate-300">{{ summaryText }}</p>
</div>
} @else if (summaryError(); as errorText) {
<div class="rounded-xl border border-red-700 bg-red-900/50 p-4 text-red-300" role="alert">
<p>{{ errorText }}</p>
</div>
}
</div>
}
@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>
</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> <h2 class="mb-3 text-lg font-bold text-white">À suivre</h2>
<div class="space-y-2"> <div class="space-y-2">
@for (related of v.relatedStreams; track related.videoId) { @for (related of v.relatedStreams; track related.videoId) {
@@ -510,9 +518,33 @@
<p class="rounded-xl bg-slate-800/50 p-4 text-center text-sm text-slate-400">Aucune suggestion pour le moment.</p> <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>
</section>
}
</div> </div>
</aside> </aside>
</div> </div>
} @else if (loading()) {
<!-- Premier affichage, avant même le placeholder vidéo -->
<div class="grid animate-pulse gap-6 lg:grid-cols-[minmax(0,1fr)_360px]" aria-hidden="true">
<div>
<div class="aspect-video rounded-2xl bg-slate-800"></div>
<div class="mt-4 space-y-3">
<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>
</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 { } @else {
<div class="py-20 text-center"> <div class="py-20 text-center">
<h2 class="text-2xl font-bold text-slate-300">Vidéo introuvable</h2> <h2 class="text-2xl font-bold text-slate-300">Vidéo introuvable</h2>
+66 -11
View File
@@ -377,9 +377,11 @@ export class WatchComponent implements OnDestroy, AfterViewInit {
// ?video= exige un id de VOD numérique ; un login (non numérique) sans // ?video= exige un id de VOD numérique ; un login (non numérique) sans
// ?channel= doit être traité comme une chaîne (résultats de recherche). // ?channel= doit être traité comme une chaîne (résultats de recherche).
const chan = ch || (!/^\d+$/.test(String(id || '')) && id ? id : null); const chan = ch || (!/^\d+$/.test(String(id || '')) && id ? id : null);
// Les VOD acceptent time=1h2m3s (seek au chargement, ex. transcript).
const timeParam = !chan && start > 0 ? `&time=${encodeURIComponent(this.formatTwitchTime(start))}` : '';
const qs = chan const qs = chan
? `?channel=${encodeURIComponent(chan)}&${parentParams}&autoplay=false` ? `?channel=${encodeURIComponent(chan)}&${parentParams}&autoplay=false`
: `?video=${encodeURIComponent(id)}&${parentParams}&autoplay=false`; : `?video=${encodeURIComponent(id)}&${parentParams}&autoplay=false${timeParam}`;
const u = base + qs; const u = base + qs;
return this.sanitizer.bypassSecurityTrustResourceUrl(u); return this.sanitizer.bypassSecurityTrustResourceUrl(u);
} }
@@ -391,13 +393,15 @@ export class WatchComponent implements OnDestroy, AfterViewInit {
return this.sanitizer.bypassSecurityTrustResourceUrl(u); return this.sanitizer.bypassSecurityTrustResourceUrl(u);
} }
if (p === 'odysee') { if (p === 'odysee') {
// t=secondes : repris au chargement (seek transcript / reprise).
const tParam = start > 0 ? `&t=${start}` : '';
const slug = this.odyseeSlug(); const slug = this.odyseeSlug();
if (slug) { if (slug) {
const u = `https://odysee.com/$/embed/${slug}?autoplay=1&muted=1`; const u = `https://odysee.com/$/embed/${slug}?autoplay=1&muted=1${tParam}`;
return this.sanitizer.bypassSecurityTrustResourceUrl(u); return this.sanitizer.bypassSecurityTrustResourceUrl(u);
} }
// Fallback (may fail if slug absent) // Fallback (may fail if slug absent)
const u = `https://odysee.com/$/embed/${encodeURIComponent(id)}?autoplay=1&muted=1`; const u = `https://odysee.com/$/embed/${encodeURIComponent(id)}?autoplay=1&muted=1${tParam}`;
return this.sanitizer.bypassSecurityTrustResourceUrl(u); return this.sanitizer.bypassSecurityTrustResourceUrl(u);
} }
if (p === 'rumble') { if (p === 'rumble') {
@@ -418,9 +422,12 @@ export class WatchComponent implements OnDestroy, AfterViewInit {
videoId = id.replace(/^v/, ''); videoId = id.replace(/^v/, '');
} }
// Construire l'URL d'embed officielle de Rumble // Construire l'URL d'embed officielle de Rumble (start=secondes :
// repris au chargement pour seek transcript / reprise ; ignoré
// silencieusement si non supporté par le player embarqué).
if (videoId) { if (videoId) {
const embedUrl = `https://rumble.com/embed/${videoId}/?pub=4&autoplay=1`; const startParam = start > 0 ? `&start=${start}` : '';
const embedUrl = `https://rumble.com/embed/${videoId}/?pub=4&autoplay=1${startParam}`;
return this.sanitizer.bypassSecurityTrustResourceUrl(embedUrl); return this.sanitizer.bypassSecurityTrustResourceUrl(embedUrl);
} }
} }
@@ -965,6 +972,11 @@ export class WatchComponent implements OnDestroy, AfterViewInit {
if (this.transcriptLines().length === 0 && !this.transcriptLoading()) { if (this.transcriptLines().length === 0 && !this.transcriptLoading()) {
this.loadTranscript(); this.loadTranscript();
} }
// Le panneau vit dans la sidebar : sur mobile elle est sous le
// contenu principal, on l'amène à l'écran (sans saut sur desktop).
setTimeout(() => {
try { document.getElementById('transcript-panel')?.scrollIntoView({ behavior: 'smooth', block: 'nearest' }); } catch {}
}, 80);
} else { } else {
this.stopTranscriptClock(); this.stopTranscriptClock();
} }
@@ -1056,27 +1068,58 @@ export class WatchComponent implements OnDestroy, AfterViewInit {
this.loadTranscript(); this.loadTranscript();
} }
/** Clic sur un timestamp : saute à ce moment (natif direct, iframe via ?start=). */ /** Clic sur un timestamp : saute à ce moment sans recharger si possible. */
seekTranscript(seconds: number): void { seekTranscript(seconds: number): void {
const t = Math.max(0, Math.floor(Number(seconds) || 0)); const t = Math.max(0, Math.floor(Number(seconds) || 0));
const p = this.provider();
// 1) Lecteur natif : seek direct sur <video>.
try { try {
const native = this.nativePlayer?.el; const native = this.nativePlayer?.el;
if (native && Number.isFinite(native.duration)) { if (native && Number.isFinite(native.duration || NaN)) {
native.currentTime = t; try { native.currentTime = t; } catch {}
try { native.play?.(); } catch {} try { native.play?.()?.catch?.(() => {}); } catch {}
this.startPositionSeconds.set(t); this.startPositionSeconds.set(t);
this.updateTranscriptActive(t); this.updateTranscriptActive(t);
this.scrollTranscriptTo(t); this.scrollTranscriptTo(t);
return; return;
} }
} catch {} } catch {}
// Embeds cross-origin (YouTube/Dailymotion/PeerTube/Odysee) : on // 2) YouTube : commande seekTo via postMessage (enablejsapi=1) : pas de
// reconstruit l'URL d'embed avec le offset (embedUrl dépend du signal). // rechargement d'iframe, repositionnement instantané.
if (p === 'youtube' && this.seekYouTubeEmbed(t)) {
this.updateTranscriptActive(t);
this.scrollTranscriptTo(t);
return;
}
// 3) Autres embeds : l'URL est reconstruite avec le offset temporel
// (embedUrl réagit au signal startPositionSeconds) : Dailymotion et
// PeerTube (start=), Twitch VOD (time=1h2m3s), Odysee (t=), Rumble
// (start=). Sans param supporté, l'iframe recharge à l'identique.
this.startPositionSeconds.set(t); this.startPositionSeconds.set(t);
this.updateTranscriptActive(t); this.updateTranscriptActive(t);
this.scrollTranscriptTo(t); this.scrollTranscriptTo(t);
} }
/**
* Envoie seekTo au player YouTube embarqué (IFrame Player API via
* postMessage). Retourne false si l'iframe n'est pas (encore) prête.
*/
private seekYouTubeEmbed(seconds: number): boolean {
try {
const frame = this.embedFrame?.nativeElement;
const win = frame?.contentWindow;
if (!frame || !win) return false;
const msg = JSON.stringify({ event: 'command', func: 'seekTo', args: [seconds, true] });
try { win.postMessage(msg, '*'); } catch { return false; }
// Le player expose l'API JS ~1 s après chargement : on rejoue la
// commande une fois pour les seeks précoces.
setTimeout(() => { try { win.postMessage(msg, '*'); } catch {} }, 800);
return true;
} catch {
return false;
}
}
onTranscriptSearch(value: string): void { onTranscriptSearch(value: string): void {
this.transcriptSearch.set(String(value ?? '')); this.transcriptSearch.set(String(value ?? ''));
} }
@@ -1134,6 +1177,18 @@ export class WatchComponent implements OnDestroy, AfterViewInit {
return h > 0 ? `${h}:${pad(m)}:${pad(sec)}` : `${m}:${pad(sec)}`; return h > 0 ? `${h}:${pad(m)}:${pad(sec)}` : `${m}:${pad(sec)}`;
} }
/** Formatte des secondes pour le param `time=` du player Twitch (1h2m3s). */
private formatTwitchTime(totalSeconds: number): string {
const s = Math.max(0, Math.floor(Number(totalSeconds) || 0));
const h = Math.floor(s / 3600);
const m = Math.floor((s % 3600) / 60);
const sec = s % 60;
let out = `${sec}s`;
if (m > 0 || h > 0) out = `${m}m${out}`;
if (h > 0) out = `${h}h${out}`;
return out;
}
private updateTranscriptActive(currentSec: number): void { private updateTranscriptActive(currentSec: number): void {
const lines = this.transcriptLines(); const lines = this.transcriptLines();
if (!lines.length) return; if (!lines.length) return;
+42
View File
@@ -71,6 +71,9 @@ const EN: Record<string, string> = {
'themes.categories': 'Categories', 'themes.categories': 'Categories',
'themes.backToThemes': 'Back to themes', 'themes.backToThemes': 'Back to themes',
'themes.changeProvider': 'Change provider', 'themes.changeProvider': 'Change provider',
'themes.title': 'Themes',
'themes.show': 'Show',
'themes.hide': 'Hide',
// Common // Common
'common.error': 'An error occurred.', 'common.error': 'An error occurred.',
'empty.noItems': 'No items.', 'empty.noItems': 'No items.',
@@ -81,13 +84,31 @@ const EN: Record<string, string> = {
'filter.sort.recent': 'Most recent', 'filter.sort.recent': 'Most recent',
'filter.sort.viewed': 'Most viewed', 'filter.sort.viewed': 'Most viewed',
'filter.sort.longest': 'Longest', 'filter.sort.longest': 'Longest',
'filter.sort.relevance': 'Relevance',
'filter.sort.date': 'Date',
'filter.sort.views': 'Views',
'filter.duration': 'Duration', 'filter.duration': 'Duration',
'filter.duration.any': 'Any', 'filter.duration.any': 'Any',
'filter.duration.short': 'Short (<10m)', 'filter.duration.short': 'Short (<10m)',
'filter.duration.medium': 'Medium (10–30m)', 'filter.duration.medium': 'Medium (10–30m)',
'filter.duration.long': 'Long (30m+)', 'filter.duration.long': 'Long (30m+)',
'filter.type': 'Type',
'filter.type.any': 'Any',
'filter.type.video': 'Video',
'filter.type.live': 'Live',
'filter.type.recorded': 'Recorded',
'filter.type.short': 'Shorts',
'filter.type.channel': 'Channel',
'filter.language': 'Language', 'filter.language': 'Language',
'filter.language.any': 'Any',
'filter.language.en': 'English',
'filter.language.fr': 'French',
'filter.date': 'Date', 'filter.date': 'Date',
'filter.date.any': 'Any',
'filter.date.day': 'Last 24h',
'filter.date.week': 'Last week',
'filter.date.month': 'Last month',
'filter.date.year': 'Last year',
// Optional per-theme labels // Optional per-theme labels
'theme.finance-crypto': 'Finance & Crypto', 'theme.finance-crypto': 'Finance & Crypto',
'theme.gaming': 'Gaming', 'theme.gaming': 'Gaming',
@@ -186,6 +207,9 @@ const FR: Record<string, string> = {
'themes.categories': 'Catégories', 'themes.categories': 'Catégories',
'themes.backToThemes': 'Retour aux thèmes', 'themes.backToThemes': 'Retour aux thèmes',
'themes.changeProvider': 'Changer de provider', 'themes.changeProvider': 'Changer de provider',
'themes.title': 'Thèmes',
'themes.show': 'Afficher',
'themes.hide': 'Masquer',
// Common // Common
'common.error': 'Une erreur est survenue.', 'common.error': 'Une erreur est survenue.',
'empty.noItems': 'Aucun élément.', 'empty.noItems': 'Aucun élément.',
@@ -196,13 +220,31 @@ const FR: Record<string, string> = {
'filter.sort.recent': 'Plus récents', 'filter.sort.recent': 'Plus récents',
'filter.sort.viewed': 'Plus vus', 'filter.sort.viewed': 'Plus vus',
'filter.sort.longest': 'Plus longs', 'filter.sort.longest': 'Plus longs',
'filter.sort.relevance': 'Pertinence',
'filter.sort.date': 'Date',
'filter.sort.views': 'Vues',
'filter.duration': 'Durée', 'filter.duration': 'Durée',
'filter.duration.any': 'Toute', 'filter.duration.any': 'Toute',
'filter.duration.short': 'Courte (<10m)', 'filter.duration.short': 'Courte (<10m)',
'filter.duration.medium': 'Moyenne (10–30m)', 'filter.duration.medium': 'Moyenne (10–30m)',
'filter.duration.long': 'Longue (30m+)', 'filter.duration.long': 'Longue (30m+)',
'filter.type': 'Type',
'filter.type.any': 'Tous',
'filter.type.video': 'Vidéo',
'filter.type.live': 'Direct',
'filter.type.recorded': 'Rediffusion',
'filter.type.short': 'Shorts',
'filter.type.channel': 'Chaîne',
'filter.language': 'Langue', 'filter.language': 'Langue',
'filter.language.any': 'Toutes',
'filter.language.en': 'Anglais',
'filter.language.fr': 'Français',
'filter.date': 'Date', 'filter.date': 'Date',
'filter.date.any': 'Toutes',
'filter.date.day': '24 dernières h',
'filter.date.week': 'Semaine dernière',
'filter.date.month': 'Mois dernier',
'filter.date.year': 'Année dernière',
// Optional per-theme labels // Optional per-theme labels
'theme.finance-crypto': 'Finance & Crypto', 'theme.finance-crypto': 'Finance & Crypto',
'theme.gaming': 'Jeux vidéo', 'theme.gaming': 'Jeux vidéo',