feat(watch,themes): transcript en sidebar, seek fiable, player instantane, bande themes repliable
CI / build-and-test (push) Failing after 8m19s
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:
@@ -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>
|
||||||
|
}
|
||||||
|
|||||||
@@ -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>;
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
Reference in New Issue
Block a user