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]="{
|
||||
'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>
|
||||
}
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user