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
152 lines
5.0 KiB
TypeScript
152 lines
5.0 KiB
TypeScript
import { ChangeDetectionStrategy, Component, ElementRef, QueryList, ViewChild, ViewChildren, inject, signal, effect, OnInit, OnDestroy } from '@angular/core';
|
|
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, TranslatePipe],
|
|
templateUrl: './themes-nav.component.html'
|
|
})
|
|
export class ThemesNavComponent implements OnDestroy {
|
|
private router = inject(Router);
|
|
private route = inject(ActivatedRoute);
|
|
themesSvc = inject(ThemesService);
|
|
|
|
themes = () => this.themesSvc.themes();
|
|
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>;
|
|
|
|
// Signal to force re-evaluation when data-theme changes
|
|
private themeChanged = signal<number>(0);
|
|
|
|
constructor() {
|
|
// Reflect current URL into active slug
|
|
this.router.events.subscribe((e) => {
|
|
if (e instanceof NavigationEnd) this.syncFromUrl();
|
|
});
|
|
// Also on init
|
|
setTimeout(() => this.syncFromUrl(), 0);
|
|
|
|
// Effect to listen for theme changes on document.documentElement
|
|
effect(() => {
|
|
const currentTheme = document.documentElement.getAttribute('data-theme') || 'system';
|
|
// Trigger re-evaluation when theme changes
|
|
this.themeChanged.update(n => n + 1);
|
|
});
|
|
}
|
|
|
|
ngOnDestroy() {
|
|
// Nothing to cleanup
|
|
}
|
|
|
|
private syncFromUrl() {
|
|
const url = this.router.url || '';
|
|
const m = url.match(/#?\/?(?:p\/[^/]+\/)?t\/([^/?#]+)/i);
|
|
if (m && m[1]) this.activeSlug.set(m[1]);
|
|
}
|
|
|
|
goToTheme(slug: string) {
|
|
this.activeSlug.set(slug);
|
|
this.router.navigate(['/t', slug]);
|
|
}
|
|
|
|
onKeydown(ev: KeyboardEvent, index: number) {
|
|
const list = this.pills?.toArray() || [];
|
|
if (!list.length) return;
|
|
if (ev.key === 'ArrowRight') {
|
|
ev.preventDefault();
|
|
const next = list[(index + 1) % list.length];
|
|
next?.nativeElement?.focus();
|
|
next?.nativeElement?.scrollIntoView({ inline: 'center', behavior: 'smooth', block: 'nearest' });
|
|
} else if (ev.key === 'ArrowLeft') {
|
|
ev.preventDefault();
|
|
const prev = list[(index - 1 + list.length) % list.length];
|
|
prev?.nativeElement?.focus();
|
|
prev?.nativeElement?.scrollIntoView({ inline: 'center', behavior: 'smooth', block: 'nearest' });
|
|
}
|
|
}
|
|
|
|
scrollBy(px: number) {
|
|
const el = this.scrollRef?.nativeElement;
|
|
if (!el) return;
|
|
el.scrollBy({ left: px, behavior: 'smooth' });
|
|
}
|
|
|
|
onWheel(ev: WheelEvent) {
|
|
const el = this.scrollRef?.nativeElement;
|
|
if (!el) return;
|
|
// Horizontal scroll with mouse wheel while hovering the bar
|
|
if (Math.abs(ev.deltaY) > Math.abs(ev.deltaX)) {
|
|
ev.preventDefault();
|
|
el.scrollLeft += ev.deltaY;
|
|
}
|
|
}
|
|
|
|
// Theme helpers (read from document attribute/localStorage)
|
|
private getCurrentTheme(): 'light' | 'dark' | 'black' | 'blue' | 'system' {
|
|
try {
|
|
const fromAttr = document.documentElement.getAttribute('data-theme');
|
|
if (fromAttr) return fromAttr as any;
|
|
const fromStorage = localStorage.getItem('newtube.theme');
|
|
return (fromStorage as any) || 'system';
|
|
} catch {
|
|
return 'system';
|
|
}
|
|
}
|
|
|
|
private resolveThemeSlug(): 'light' | 'dark' | 'black' | 'blue' {
|
|
const cur = this.getCurrentTheme();
|
|
if (cur === 'system') {
|
|
const prefersDark = typeof window !== 'undefined' && window.matchMedia?.('(prefers-color-scheme: dark)').matches;
|
|
return prefersDark ? 'dark' : 'light';
|
|
}
|
|
return cur as any;
|
|
}
|
|
|
|
isLightTheme(): boolean {
|
|
this.themeChanged(); // Trigger re-evaluation
|
|
return this.resolveThemeSlug() === 'light';
|
|
}
|
|
isDarkTheme(): boolean {
|
|
this.themeChanged(); // Trigger re-evaluation
|
|
return this.resolveThemeSlug() === 'dark';
|
|
}
|
|
isBlackTheme(): boolean {
|
|
this.themeChanged(); // Trigger re-evaluation
|
|
return this.resolveThemeSlug() === 'black';
|
|
}
|
|
isBlueTheme(): boolean {
|
|
this.themeChanged(); // Trigger re-evaluation
|
|
return this.resolveThemeSlug() === 'blue';
|
|
}
|
|
}
|