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]="{
'bg-slate-800 border-b border-slate-700': isDarkTheme(),
'bg-slate-900 border-b border-slate-800': isBlackTheme(),
@@ -24,6 +39,18 @@
»
</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)">
<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">
@@ -45,6 +72,13 @@
<span>{{ themesSvc.i18nLabel(t) }}</span>
</button>
</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>
</div>
</nav>
}
+24 -1
View File
@@ -2,12 +2,15 @@ import { ChangeDetectionStrategy, Component, ElementRef, QueryList, ViewChild, V
import { CommonModule } from '@angular/common';
import { Router, NavigationEnd, ActivatedRoute } from '@angular/router';
import { ThemesService } from '../../services/themes.service';
import { TranslatePipe } from '../../pipes/translate.pipe';
const THEMES_BAR_COLLAPSED_KEY = 'newtube.themesBarCollapsed';
@Component({
selector: 'app-themes-nav',
standalone: true,
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [CommonModule],
imports: [CommonModule, TranslatePipe],
templateUrl: './themes-nav.component.html'
})
export class ThemesNavComponent implements OnDestroy {
@@ -19,6 +22,26 @@ export class ThemesNavComponent implements OnDestroy {
activeSlug = this.themesSvc.activeSlug;
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>>;
@ViewChild('scroll', { static: false }) scrollRef?: ElementRef<HTMLDivElement>;
File diff suppressed because it is too large Load Diff
+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
// ?channel= doit être traité comme une chaîne (résultats de recherche).
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
? `?channel=${encodeURIComponent(chan)}&${parentParams}&autoplay=false`
: `?video=${encodeURIComponent(id)}&${parentParams}&autoplay=false`;
: `?video=${encodeURIComponent(id)}&${parentParams}&autoplay=false${timeParam}`;
const u = base + qs;
return this.sanitizer.bypassSecurityTrustResourceUrl(u);
}
@@ -391,13 +393,15 @@ export class WatchComponent implements OnDestroy, AfterViewInit {
return this.sanitizer.bypassSecurityTrustResourceUrl(u);
}
if (p === 'odysee') {
// t=secondes : repris au chargement (seek transcript / reprise).
const tParam = start > 0 ? `&t=${start}` : '';
const slug = this.odyseeSlug();
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);
}
// 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);
}
if (p === 'rumble') {
@@ -418,9 +422,12 @@ export class WatchComponent implements OnDestroy, AfterViewInit {
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) {
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);
}
}
@@ -965,6 +972,11 @@ export class WatchComponent implements OnDestroy, AfterViewInit {
if (this.transcriptLines().length === 0 && !this.transcriptLoading()) {
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 {
this.stopTranscriptClock();
}
@@ -1056,27 +1068,58 @@ export class WatchComponent implements OnDestroy, AfterViewInit {
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 {
const t = Math.max(0, Math.floor(Number(seconds) || 0));
const p = this.provider();
// 1) Lecteur natif : seek direct sur <video>.
try {
const native = this.nativePlayer?.el;
if (native && Number.isFinite(native.duration)) {
native.currentTime = t;
try { native.play?.(); } catch {}
if (native && Number.isFinite(native.duration || NaN)) {
try { native.currentTime = t; } catch {}
try { native.play?.()?.catch?.(() => {}); } 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).
// 2) YouTube : commande seekTo via postMessage (enablejsapi=1) : pas de
// 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.updateTranscriptActive(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 {
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)}`;
}
/** 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 {
const lines = this.transcriptLines();
if (!lines.length) return;
+42
View File
@@ -71,6 +71,9 @@ const EN: Record<string, string> = {
'themes.categories': 'Categories',
'themes.backToThemes': 'Back to themes',
'themes.changeProvider': 'Change provider',
'themes.title': 'Themes',
'themes.show': 'Show',
'themes.hide': 'Hide',
// Common
'common.error': 'An error occurred.',
'empty.noItems': 'No items.',
@@ -81,13 +84,31 @@ const EN: Record<string, string> = {
'filter.sort.recent': 'Most recent',
'filter.sort.viewed': 'Most viewed',
'filter.sort.longest': 'Longest',
'filter.sort.relevance': 'Relevance',
'filter.sort.date': 'Date',
'filter.sort.views': 'Views',
'filter.duration': 'Duration',
'filter.duration.any': 'Any',
'filter.duration.short': 'Short (<10m)',
'filter.duration.medium': 'Medium (10–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.any': 'Any',
'filter.language.en': 'English',
'filter.language.fr': 'French',
'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
'theme.finance-crypto': 'Finance & Crypto',
'theme.gaming': 'Gaming',
@@ -186,6 +207,9 @@ const FR: Record<string, string> = {
'themes.categories': 'Catégories',
'themes.backToThemes': 'Retour aux thèmes',
'themes.changeProvider': 'Changer de provider',
'themes.title': 'Thèmes',
'themes.show': 'Afficher',
'themes.hide': 'Masquer',
// Common
'common.error': 'Une erreur est survenue.',
'empty.noItems': 'Aucun élément.',
@@ -196,13 +220,31 @@ const FR: Record<string, string> = {
'filter.sort.recent': 'Plus récents',
'filter.sort.viewed': 'Plus vus',
'filter.sort.longest': 'Plus longs',
'filter.sort.relevance': 'Pertinence',
'filter.sort.date': 'Date',
'filter.sort.views': 'Vues',
'filter.duration': 'Durée',
'filter.duration.any': 'Toute',
'filter.duration.short': 'Courte (<10m)',
'filter.duration.medium': 'Moyenne (10–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.any': 'Toutes',
'filter.language.en': 'Anglais',
'filter.language.fr': 'Français',
'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
'theme.finance-crypto': 'Finance & Crypto',
'theme.gaming': 'Jeux vidéo',