import { ChangeDetectionStrategy, Component, computed, inject, signal, Output, EventEmitter, HostListener, ViewChild, ElementRef } from '@angular/core'; import { CommonModule } from '@angular/common'; import { Router, RouterLink, NavigationEnd } from '@angular/router'; import { InstanceService, Provider } from '../../services/instance.service'; import { FormsModule } from '@angular/forms'; import { AuthService } from '../../services/auth.service'; import { UserService } from '../../services/user.service'; import { firstValueFrom } from 'rxjs'; import { TranslatePipe } from '../../pipes/translate.pipe'; import { I18nService } from '../../services/i18n.service'; import { ThemesService } from '../../services/themes.service'; import { HistoryService, SearchHistoryItem } from '../../services/history.service'; @Component({ selector: 'app-header', templateUrl: './header.component.html', standalone: true, changeDetection: ChangeDetectionStrategy.OnPush, imports: [ RouterLink, CommonModule, FormsModule, TranslatePipe ] }) export class HeaderComponent { private router = inject(Router); instances = inject(InstanceService); private auth = inject(AuthService); private userService = inject(UserService); private i18n = inject(I18nService); private themes = inject(ThemesService); private history = inject(HistoryService); @ViewChild('searchInput', { static: false }) searchInputRef?: ElementRef; @ViewChild('userMenuContainer', { static: false }) userMenuContainerRef?: ElementRef; searchQuery = ''; editingPeerTubeInstances = signal(false); peerTubeInstancesInput = ''; // Search suggestions dropdown state suggestionsOpen = signal(false); recentSearches = signal([]); highlightedIndex = signal(-1); // Suggestion item shape: history vs generated readonly suggestionItems = computed<{ text: string; source: 'history' | 'generated' }[]>(() => { type Item = { text: string; source: 'history' | 'generated' }; const q = (this.searchQuery || '').trim(); const provider = this.selectedProvider(); const fromHistory = (this.recentSearches() || []) .map(it => (it.query || '').trim()) .filter(Boolean); // 1) Base list from history let historyList: string[]; if (!q) { historyList = fromHistory.slice(0, 15); } else { const lower = q.toLowerCase(); historyList = fromHistory.filter(txt => txt.toLowerCase().includes(lower)).slice(0, 15); } const items: Item[] = historyList.map(text => ({ text, source: 'history' })); // 2) If we have fewer than 15, fill with generated suggestions if (items.length < 15) { const need = 15 - items.length; const generated = this.generateQuerySuggestions(q, provider); // Avoid duplicates with history const existing = new Set(items.map(i => i.text.toLowerCase())); for (const g of generated) { const t = (g || '').trim(); if (!t) continue; if (existing.has(t.toLowerCase())) continue; items.push({ text: t, source: 'generated' }); if (items.length >= 15) break; } } // Reset highlight if list size changes or becomes empty const prev = this.highlightedIndex(); if (prev >= items.length) this.highlightedIndex.set(items.length - 1); if (items.length === 0) this.highlightedIndex.set(-1); return items; }); // Generate query suggestions based on the current text and provider private generateQuerySuggestions(q: string, provider: string | null): string[] { const base = (q || '').trim(); const suggestions: string[] = []; // If nothing typed yet, propose popular generic queries per provider context if (!base) { // Lightweight provider-tailored seeds const common = [ 'live', 'news', 'best of', 'highlights', 'playlist', 'remix', 'cover', 'tutorial', 'review' ]; if (provider === 'youtube') { suggestions.push('trending', 'documentary', 'mix', 'lyrics', 'official video'); } else if (provider === 'peertube') { suggestions.push('open source', 'conference', 'self-hosted'); } suggestions.push(...common); return suggestions; } // Heuristics: variants that typically help discovery const quoted = `"${base}"`; const year = new Date().getFullYear(); const tokens = [ quoted, `${base} official`, `${base} live`, `${base} lyrics`, `${base} remix`, `${base} cover`, `${base} tutorial`, `${base} review`, `${base} full album`, `${base} best of`, `${base} ${year}`, `${base} ${year - 1}` ]; if (provider === 'youtube') { tokens.push(`${base} playlist`, `${base} 4k`, `${base} short`); } if (provider === 'rumble') { tokens.push(`${base} highlights`, `${base} podcast`); } // Keep unique and meaningful const seen = new Set(); for (const t of tokens) { const key = t.toLowerCase(); if (!seen.has(key)) { seen.add(key); suggestions.push(t); } if (suggestions.length >= 20) break; } return suggestions; } readonly providers = computed(() => this.instances.providers()); readonly selectedProvider = computed(() => this.instances.selectedProvider()); readonly user = computed(() => this.auth.currentUser()); // Provider context read from current URL query param (?provider=...) providerContext = signal(null); providerContextLabel = computed(() => { const ctx = this.providerContext(); if (!ctx) return null; const p = this.providers().find(x => x.id === ctx); return p?.label || ctx; }); // Theme management (global/local) themeOptions = ['system', 'light', 'dark', 'black', 'blue']; currentTheme = signal((() => { try { return localStorage.getItem('newtube.theme') || 'system'; } catch { return 'system'; } })()); // Login/Register modals state loginOpen = signal(false); registerOpen = signal(false); loginUsername = signal(''); loginPassword = signal(''); rememberMe = signal(true); registerUsername = signal(''); registerPassword = signal(''); registerEmail = signal(''); authError = signal(null); userMenuOpen = signal(false); // Sidebar toggle (emits to parent AppComponent) @Output() menuToggle = new EventEmitter(); onSubmitSearch(ev: Event, input: HTMLInputElement) { ev.preventDefault(); const q = input.value.trim(); if (!q) return; const provider = this.selectedProvider(); const theme = this.themes.activeSlug(); const qp: any = { q, provider }; if (theme) qp.theme = theme; this.router.navigate(['/search'], { queryParams: qp }); } // Open suggestions and load last 15 searches when focusing the input onSearchFocus() { this.suggestionsOpen.set(true); this.loadRecentSearches(); this.highlightedIndex.set(-1); } // Keep suggestions open and update searchQuery as user types onSearchInput(input: HTMLInputElement) { this.searchQuery = input.value; if (!this.suggestionsOpen()) { this.suggestionsOpen.set(true); } // Force refresh of suggestions by updating the signal this.recentSearches.update(searches => [...searches]); this.highlightedIndex.set(-1); } // Delay closing to allow click on a suggestion onSearchBlur() { setTimeout(() => this.suggestionsOpen.set(false), 150); } // Pick a suggestion: fill input and submit navigation pickSuggestion(text: string, input: HTMLInputElement) { input.value = text; this.searchQuery = text; // Submit same as pressing Enter const provider = this.selectedProvider(); const theme = this.themes.activeSlug(); const qp: any = { q: text, provider }; if (theme) qp.theme = theme; this.router.navigate(['/search'], { queryParams: qp }); this.suggestionsOpen.set(false); } // Keyboard navigation for suggestions onSearchKeydown(ev: KeyboardEvent, input: HTMLInputElement) { const items = this.suggestionItems(); if (!items || items.length === 0) return; const max = items.length - 1; const current = this.highlightedIndex(); if (ev.key === 'ArrowDown') { ev.preventDefault(); const next = Math.min(max, current + 1); this.highlightedIndex.set(next < 0 ? 0 : next); } else if (ev.key === 'ArrowUp') { ev.preventDefault(); const next = Math.max(-1, current - 1); this.highlightedIndex.set(next); } else if (ev.key === 'Enter') { if (current >= 0 && current <= max) { ev.preventDefault(); const chosen = items[current]?.text; if (chosen) this.pickSuggestion(chosen, input); } } else if (ev.key === 'Escape') { this.suggestionsOpen.set(false); } } // Theme helpers for styling based on current theme getCurrentTheme(): string { return this.currentTheme() || 'system'; } isLightTheme(): boolean { const t = this.getCurrentTheme(); return t === 'light' || t === 'system'; } isDarkTheme(): boolean { const t = this.getCurrentTheme(); return t === 'dark'; } isBlackTheme(): boolean { const t = this.getCurrentTheme(); return t === 'black'; } isBlueTheme(): boolean { const t = this.getCurrentTheme(); return t === 'blue'; } getThemeClasses(): { [key: string]: boolean } { const theme = this.getCurrentTheme(); return { 'theme-light': theme === 'light' || theme === 'system', 'theme-dark': theme === 'dark', 'theme-black': theme === 'black', 'theme-blue': theme === 'blue' }; } // Brand colors for provider badge (inline styles to match History badges) getProviderColors(providerId: string | null | undefined): { [key: string]: string } { const id = (providerId || '').toLowerCase(); switch (id) { case 'youtube': return { backgroundColor: 'rgba(220, 38, 38, 0.15)', color: 'rgb(248, 113, 113)', borderColor: 'rgba(239, 68, 68, 0.3)' }; case 'vimeo': return { backgroundColor: 'rgba(2, 132, 199, 0.15)', color: 'rgb(125, 211, 252)', borderColor: 'rgba(14, 165, 233, 0.3)' }; case 'dailymotion': return { backgroundColor: 'rgba(37, 99, 235, 0.15)', color: 'rgb(147, 197, 253)', borderColor: 'rgba(59, 130, 246, 0.3)' }; case 'peertube': return { backgroundColor: 'rgba(245, 158, 11, 0.15)', color: 'rgb(252, 211, 77)', borderColor: 'rgba(245, 158, 11, 0.3)' }; case 'rumble': return { backgroundColor: 'rgba(22, 163, 74, 0.15)', color: 'rgb(134, 239, 172)', borderColor: 'rgba(34, 197, 94, 0.3)' }; case 'twitch': return { backgroundColor: 'rgba(168, 85, 247, 0.15)', color: 'rgb(216, 180, 254)', borderColor: 'rgba(168, 85, 247, 0.3)' }; case 'odysee': return { backgroundColor: 'rgba(236, 72, 153, 0.15)', color: 'rgb(251, 207, 232)', borderColor: 'rgba(236, 72, 153, 0.3)' }; default: return { backgroundColor: 'rgba(30, 41, 59, 0.8)', color: 'rgb(203, 213, 225)', borderColor: 'rgb(51, 65, 85)' }; } } // Helper to show the divider before the first generated item isFirstGenerated(index: number): boolean { const arr = this.suggestionItems(); if (!arr || index < 0 || index >= arr.length) return false; if (arr[index]?.source !== 'generated') return false; return index === 0 || arr[index - 1]?.source !== 'generated'; } private loadRecentSearches() { try { this.history.getSearchHistory(15).subscribe({ next: (items) => { // API likely returns newest first; ensure we keep order and dedupe by query this.recentSearches.set(items || []); }, error: () => {} }); } catch {} } private updateProviderContext(provider: Provider) { if (this.instances.providers().some(p => p.id === provider)) { this.providerContext.set(provider); } } ngOnInit() { // Initialize and keep provider context in sync with URL this.updateProviderContextFromUrl(this.router.url); // Écouter les mises à jour de provider depuis d'autres composants document.addEventListener('updateProviderContext', (event: Event) => { const customEvent = event as CustomEvent; if (customEvent.detail) { this.updateProviderContext(customEvent.detail); } }); this.router.events.subscribe(evt => { if (evt instanceof NavigationEnd) { this.updateProviderContextFromUrl(evt.urlAfterRedirects || evt.url); } }); } private updateProviderContextFromUrl(url: string) { try { // Normalize to portion after '#', if present (hash routing) const hashIdx = url.indexOf('#'); const afterHash = hashIdx >= 0 ? url.substring(hashIdx + 1) : url; // Prefer query param ?provider=... const qIdx = afterHash.indexOf('?'); const query = qIdx >= 0 ? afterHash.substring(qIdx + 1) : ''; const params = new URLSearchParams(query); // Support both ?provider= and legacy/alt ?p= let provider = ((params.get('provider') || params.get('p') || '').trim()) as Provider; // If not provided via query, try path format /p/:provider/... (supports hash and non-hash routing) if (!provider) { // Normalize to path without query/hash prefix symbols const pathOnly = (qIdx >= 0 ? afterHash.substring(0, qIdx) : afterHash) || ''; const noHash = pathOnly; // already removed above const segs = noHash.split('/').filter(s => s.length > 0); // Look for pattern ['p', ':provider', ...] const pIdx = segs.indexOf('p'); if (pIdx >= 0 && segs.length > pIdx + 1) { provider = segs[pIdx + 1] as Provider; } } // Validate against known providers list; if none and we're on Shorts/Watch, fall back to selected provider const known = this.providers().map(p => p.id); if (provider && known.includes(provider)) { this.providerContext.set(provider); } else { // If current path looks like '/shorts' (hash routing already normalized above) const pathOnly = (qIdx >= 0 ? afterHash.substring(0, qIdx) : afterHash) || ''; const segs = pathOnly.split('/').filter(s => s.length > 0); if (segs.length > 0 && segs[0] === 'shorts') { const fallback = this.selectedProvider(); if (fallback && known.includes(fallback)) { this.providerContext.set(fallback); return; } } // If current path looks like '/watch', also fall back to selected provider to show a consistent badge if (segs.length > 0 && segs[0] === 'watch') { const fallback = this.selectedProvider(); if (fallback && known.includes(fallback)) { this.providerContext.set(fallback); return; } } this.providerContext.set(null); } } catch { this.providerContext.set(null); } } // When the native clear (X) on a search input is clicked, browsers fire a 'search' event. // If the field becomes empty, navigate back to Home. onSearchCleared(input: HTMLInputElement) { const q = (input?.value || '').trim(); if (!q) { this.router.navigate(['/']); } } focusSearch() { try { const el = this.searchInputRef?.nativeElement; if (el) { el.focus(); el.select(); } } catch {} } @HostListener('document:keydown', ['$event']) handleGlobalKeydown(ev: KeyboardEvent) { const isInput = (ev.target as HTMLElement)?.closest('input, textarea, [contenteditable="true"]'); // Ctrl/Cmd+K focuses search if ((ev.key === 'k' || ev.key === 'K') && (ev.ctrlKey || ev.metaKey)) { ev.preventDefault(); this.focusSearch(); return; } // '/' focuses search when not typing in another input if (ev.key === '/' && !isInput) { ev.preventDefault(); this.focusSearch(); return; } // Escape blurs search if (ev.key === 'Escape') { const el = this.searchInputRef?.nativeElement; if (el && document.activeElement === el) { (document.activeElement as HTMLElement)?.blur(); } } } onProviderChange(event: Event) { const selectedProvider = (event.target as HTMLSelectElement).value as Provider; this.instances.setSelectedProvider(selectedProvider); } toggleUserMenu() { this.userMenuOpen.update(v => !v); } @HostListener('document:click', ['$event']) onDocumentClick(ev: MouseEvent) { if (!this.userMenuOpen()) return; const container = this.userMenuContainerRef?.nativeElement; const target = ev.target as Node | null; if (container && target && !container.contains(target)) { this.userMenuOpen.set(false); } } onPeerTubeInstanceChange(event: Event) { const selectedInstance = (event.target as HTMLSelectElement).value; this.instances.setActivePeerTubeInstance(selectedInstance); } editPeerTubeInstances() { this.peerTubeInstancesInput = this.instances.peerTubeInstances().join('\n'); this.editingPeerTubeInstances.set(true); } savePeerTubeInstances() { const instances = this.peerTubeInstancesInput.split('\n').map(i => i.trim()).filter(i => i.length > 0); this.instances.setPeerTubeInstances(instances); if (!instances.includes(this.instances.activePeerTubeInstance())) { this.instances.setActivePeerTubeInstance(instances[0] || ''); } this.editingPeerTubeInstances.set(false); } onThemeChange(event: Event) { const value = (event.target as HTMLSelectElement | null)?.value || 'system'; this.setTheme(value); } setTheme(value: string) { this.currentTheme.set(value); try { document.documentElement.setAttribute('data-theme', value); localStorage.setItem('newtube.theme', value); } catch {} // If logged in, persist to backend preferences (fire-and-forget) if (this.user()) { try { this.userService.updatePreferences({ theme: value }).subscribe({ next: () => {}, error: () => {} }); } catch {} } } cycleTheme() { const order = this.themeOptions; const cur = this.currentTheme(); const idx = Math.max(0, order.indexOf(cur)); const nextVal = order[(idx + 1) % order.length]; this.setTheme(nextVal); } // Auth actions openLogin() { this.clearAuthForms(); this.loginOpen.set(true); } openRegister() { this.clearAuthForms(); this.registerOpen.set(true); } closeModals() { this.loginOpen.set(false); this.registerOpen.set(false); this.authError.set(null); } private clearAuthForms() { this.loginUsername.set(''); this.loginPassword.set(''); this.rememberMe.set(true); this.registerUsername.set(''); this.registerPassword.set(''); this.registerEmail.set(''); this.authError.set(null); } async onSubmitLogin(ev: Event, u?: HTMLInputElement, p?: HTMLInputElement, rm?: HTMLInputElement) { ev.preventDefault(); this.authError.set(null); try { const username = (u?.value ?? this.loginUsername()).trim(); const password = p?.value ?? this.loginPassword(); const remember = (rm?.checked != null) ? rm.checked : this.rememberMe(); await firstValueFrom(this.auth.login(username, password, remember)); // Load preferences and apply immediately const prefs = await firstValueFrom(this.userService.loadPreferences()); if (prefs) { if (prefs.defaultProvider) this.instances.setSelectedProvider(prefs.defaultProvider as any); if (prefs.region) this.instances.setRegion(prefs.region); try { const t = prefs.theme || 'system'; document.documentElement.setAttribute('data-theme', t); this.currentTheme.set(t); localStorage.setItem('newtube.theme', t); } catch {} if (prefs.language) this.i18n.setLanguage(prefs.language); } this.closeModals(); } catch (e: any) { const status = (e && typeof e.status === 'number') ? e.status : 0; const serverMsg = (e && e.error && (e.error.error || e.error.message)) || null; if (status === 401) this.authError.set(serverMsg || 'Invalid credentials.'); else if (status === 429) this.authError.set('Too many attempts. Please wait a minute and try again.'); else this.authError.set(serverMsg || 'Login failed.'); } } async onSubmitRegister(ev: Event, ru?: HTMLInputElement, re?: HTMLInputElement, rp?: HTMLInputElement) { ev.preventDefault(); this.authError.set(null); try { const usernameRaw = (ru?.value ?? this.registerUsername()).trim(); const emailRaw = (re?.value ?? this.registerEmail()).trim(); const password = (rp?.value ?? this.registerPassword()); const username = usernameRaw || emailRaw; // fallback to email when username empty if (!username || !password) { this.authError.set('Username (or Email) and password are required.'); return; } await firstValueFrom(this.auth.register(username, password, emailRaw || undefined)); const prefs = await firstValueFrom(this.userService.loadPreferences()); if (prefs) { if (prefs.defaultProvider) this.instances.setSelectedProvider(prefs.defaultProvider as any); if (prefs.region) this.instances.setRegion(prefs.region); try { const t = prefs.theme || 'system'; document.documentElement.setAttribute('data-theme', t); this.currentTheme.set(t); localStorage.setItem('newtube.theme', t); } catch {} } this.closeModals(); } catch (e: any) { const status = (e && typeof e.status === 'number') ? e.status : 0; const serverMsg = (e && e.error && (e.error.error || e.error.message)) || null; if (status === 409) this.authError.set(serverMsg || 'Username already exists. Choose another.'); else if (status === 400) this.authError.set(serverMsg || 'Username and password are required.'); else if (status === 429) this.authError.set('Too many attempts. Please wait a minute and try again.'); else this.authError.set(serverMsg || 'Registration failed.'); } } async onLogout() { try { await firstValueFrom(this.auth.logout()); } catch {} } }