first commit

This commit is contained in:
2025-09-14 23:05:30 -04:00
commit 2866d74b32
122 changed files with 95474 additions and 0 deletions
+29
View File
@@ -0,0 +1,29 @@
<div class="min-h-screen flex flex-col">
<app-header (menuToggle)="toggleSidebar()"></app-header>
<div class="pt-16">
<!-- Global Themes bar: shown on Home and Theme pages, sticky under header -->
@if (showThemesBar()) {
<app-themes-nav></app-themes-nav>
}
<div class="flex flex-1 relative">
<!-- Desktop sidebar -->
<aside class="hidden md:block border-r border-slate-800 sticky top-16 self-start h-[calc(100vh-4rem)] transition-[width] duration-200"
[class.w-64]="!sidebarCollapsed()" [class.w-20]="sidebarCollapsed()">
<app-sidebar [collapsed]="sidebarCollapsed()"></app-sidebar>
</aside>
<!-- Mobile drawer sidebar -->
@if (sidebarOpen()) {
<div class="fixed inset-0 bg-black/60 z-40 md:hidden" (click)="closeSidebar()"></div>
}
<div class="fixed inset-y-0 left-0 w-72 z-40 bg-slate-900 transform transition-transform duration-200 md:hidden"
[class.-translate-x-full]="!sidebarOpen()" [class.translate-x-0]="sidebarOpen()">
<app-sidebar></app-sidebar>
</div>
<main class="flex-1 min-w-0 overflow-x-hidden">
<router-outlet></router-outlet>
</main>
</div>
</div>
</div>
+64
View File
@@ -0,0 +1,64 @@
import { ChangeDetectionStrategy, Component, signal, inject } from '@angular/core';
import { Router, RouterOutlet, NavigationEnd } from '@angular/router';
import { HeaderComponent } from './components/header/header.component';
import { SidebarComponent } from './components/sidebar/sidebar.component';
import { ThemesNavComponent } from './components/themes/themes-nav.component';
import { I18nService } from './services/i18n.service';
@Component({
selector: 'app-root',
templateUrl: './app.component.html',
standalone: true,
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [RouterOutlet, HeaderComponent, SidebarComponent, ThemesNavComponent]
})
export class AppComponent {
private i18n = inject(I18nService);
private router = inject(Router);
sidebarOpen = signal(false);
sidebarCollapsed = signal(false);
private _showThemesBar = signal(false);
toggleSidebar() {
// On mobile (< md), open/close drawer. On desktop, collapse/expand sidebar width.
try {
const isMobile = typeof window !== 'undefined' && window.innerWidth < 768; // Tailwind md breakpoint
if (isMobile) {
this.sidebarOpen.update(v => !v);
} else {
this.sidebarCollapsed.update(v => !v);
}
} catch {
this.sidebarOpen.update(v => !v);
}
}
closeSidebar() {
this.sidebarOpen.set(false);
}
constructor() {
// Initialize and react to route changes to show/hide the global themes bar
try {
this.updateShowThemesBar();
this.router.events.subscribe(e => {
if (e instanceof NavigationEnd) this.updateShowThemesBar();
});
} catch {}
}
private updateShowThemesBar() {
try {
const url = this.router.url || '/';
const isHome = url === '/' || url.startsWith('/?');
const isTheme = url.startsWith('/t/') || /^\/p\/[^/]+\/t\//.test(url);
this._showThemesBar.set(isHome || isTheme);
} catch {
this._showThemesBar.set(false);
}
}
// Template helpers
showThemesBar() { return this._showThemesBar(); }
}
+94
View File
@@ -0,0 +1,94 @@
import { Routes } from '@angular/router';
export const APP_ROUTES: Routes = [
{ path: '', pathMatch: 'full', redirectTo: 't/trending' },
// Theme pages
{
path: 't/:theme',
loadComponent: () => import('./components/themes/theme-page.component').then(m => m.ThemePageComponent),
title: 'NewTube - Theme'
},
{
path: 'p/:provider/t/:theme',
loadComponent: () => import('./components/themes/provider-theme-page.component').then(m => m.ProviderThemePageComponent),
title: 'NewTube - Provider Theme'
},
{
path: 'search',
loadComponent: () => import('./components/search/search.component').then(m => m.SearchComponent),
title: 'NewTube - Search'
},
{
path: 'shorts',
loadComponent: () => import('./components/shorts/watch-short.component').then(m => m.WatchShortComponent),
title: 'NewTube - Shorts'
},
{
path: 'watch/:id',
loadComponent: () => import('./components/watch/watch.component').then(m => m.WatchComponent),
title: 'NewTube - Watch'
},
// Alias supporting provider in path: /watch/:provider/:id
{
path: 'watch/:provider/:id',
loadComponent: () => import('./components/watch/watch.component').then(m => m.WatchComponent),
title: 'NewTube - Watch'
},
{
path: 'library',
pathMatch: 'full',
redirectTo: 'library/playlists'
},
{
path: 'library/playlists',
loadComponent: () => import('./components/library/playlists/playlists.component').then(m => m.PlaylistsComponent),
title: 'NewTube - Playlists'
},
{
path: 'library/liked',
loadComponent: () => import('./components/library/liked/liked.component').then(m => m.LikedComponent),
title: 'NewTube - Liked'
},
{
path: 'library/subscriptions',
loadComponent: () => import('./components/library/subscriptions/subscriptions.component').then(m => m.SubscriptionsComponent),
title: 'NewTube - Subscriptions'
},
{
path: 'account',
pathMatch: 'full',
redirectTo: 'account/preferences'
},
{
path: 'account/preferences',
loadComponent: () => import('./components/account/preferences/preferences.component').then(m => m.PreferencesComponent),
title: 'NewTube - Preferences'
},
{
path: 'account/history',
loadComponent: () => import('./components/account/history/history.component').then(m => m.HistoryComponent),
title: 'NewTube - History'
},
{
path: 'account/sessions',
loadComponent: () => import('./components/account/sessions/sessions.component').then(m => m.SessionsComponent),
title: 'NewTube - Sessions'
},
{
path: 'auth/login',
loadComponent: () => import('./components/auth/login/login.component').then(m => m.LoginComponent),
title: 'NewTube - Login'
},
{
path: 'auth/register',
loadComponent: () => import('./components/auth/register/register.component').then(m => m.RegisterComponent),
title: 'NewTube - Register'
},
{
path: 'info/utilisation',
loadComponent: () => import('./components/info/utilisation/utilisation.component').then(m => m.UtilisationComponent),
title: 'NewTube - Utilisation'
},
{ path: '**', redirectTo: '' }
];
@@ -0,0 +1,124 @@
<div class="container mx-auto p-4 sm:p-6 max-w-6xl">
<h2 class="text-3xl font-bold mb-8 text-slate-100 border-l-4 border-red-500 pl-4 flex items-center">
<svg xmlns="http://www.w3.org/2000/svg" class="h-8 w-8 mr-2" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
Historique
</h2>
<div class="grid grid-cols-1 lg:grid-cols-2 gap-8">
<!-- Search History Section -->
<section class="bg-slate-800/50 rounded-lg p-6 shadow-lg">
<div class="flex items-center justify-between mb-4">
<h3 class="text-xl font-semibold text-slate-200 flex items-center">
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 mr-2 text-blue-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
</svg>
Historique des recherches
</h3>
<span class="text-xs bg-slate-700 text-slate-300 px-2 py-1 rounded-full">
{{ searchHistory().length }} recherche(s)
</span>
</div>
<div *ngIf="searchHistory().length === 0" class="text-slate-400 text-center py-4">
Aucune recherche récente
</div>
<ul class="space-y-3">
<li *ngFor="let s of searchHistory()"
class="group bg-slate-700/50 hover:bg-slate-700/80 rounded-lg overflow-hidden transition-all duration-200">
<a [routerLink]="['/search']" [queryParams]="{ q: s.query, provider: getSearchProvider(s).id }"
class="block p-4 hover:no-underline">
<div class="flex justify-between items-start">
<div class="flex-1">
<div class="text-slate-100 font-medium group-hover:text-blue-400 transition-colors">
{{ s.query }}
</div>
<div class="flex items-center mt-1 text-xs text-slate-400">
<span class="inline-flex items-center bg-slate-800/80 px-2 py-0.5 rounded mr-2">
<svg xmlns="http://www.w3.org/2000/svg" class="h-3 w-3 mr-1" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 21V5a2 2 0 00-2-2H7a2 2 0 00-2 2v16m14 0h2m-2 0h-5m-9 0H3m2 0h5M9 7h1m-1 4h1m4-4h1m-1 4h1m-5 10v-5a1 1 0 011-1h2a1 1 0 011 1v5m-4 0h4" />
</svg>
{{ getSearchProvider(s).name }}
</span>
<span class="text-slate-500">
{{ formatDate(s.created_at) }}
</span>
</div>
</div>
<button class="text-slate-400 hover:text-blue-400 p-1 -mr-1">
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" />
</svg>
</button>
</div>
</a>
</li>
</ul>
</section>
<!-- Watch History Section -->
<section class="bg-slate-800/50 rounded-lg p-6 shadow-lg">
<div class="flex items-center justify-between mb-4">
<h3 class="text-xl font-semibold text-slate-200 flex items-center">
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 mr-2 text-red-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 10l4.553-2.276A1 1 0 0121 8.618v6.764a1 1 0 01-1.447.894L15 14M5 18h8a2 2 0 002-2V8a2 2 0 00-2-2H5a2 2 0 00-2 2v8a2 2 0 002 2z" />
</svg>
Historique de visionnage
</h3>
<span class="text-xs bg-slate-700 text-slate-300 px-2 py-1 rounded-full">
{{ watchHistory().length }} vidéo(s)
</span>
</div>
<div *ngIf="watchHistory().length === 0" class="text-slate-400 text-center py-4">
Aucune vidéo récemment regardée
</div>
<ul class="space-y-3">
<li *ngFor="let w of watchHistory()"
class="group bg-slate-700/50 hover:bg-slate-700/80 rounded-lg overflow-hidden transition-all duration-200">
<a [routerLink]="['/watch', w.video_id]" [queryParams]="{ p: w.provider }"
class="block p-4 hover:no-underline">
<div class="flex">
<div class="relative flex-shrink-0 w-24 h-16 bg-slate-700 rounded overflow-hidden flex items-center justify-center">
<div class="text-slate-400">
<svg xmlns="http://www.w3.org/2000/svg" class="h-8 w-8" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 10l4.553-2.276A1 1 0 0121 8.618v6.764a1 1 0 01-1.447.894L15 14M5 18h8a2 2 0 002-2V8a2 2 0 00-2-2H5a2 2 0 00-2 2v8a2 2 0 002 2z" />
</svg>
</div>
<div class="absolute bottom-0 left-0 right-0 h-1 bg-slate-600">
<div class="h-full bg-red-500" [style.width.%]="getProgressPercentage(w)"></div>
</div>
<div *ngIf="w.duration_seconds" class="absolute bottom-1 right-1 bg-black/80 text-white text-2xs px-1 rounded">
{{ formatDuration(w.duration_seconds) }}
</div>
</div>
<div class="ml-4 flex-1 min-w-0">
<h4 class="text-sm font-medium text-slate-100 group-hover:text-blue-400 transition-colors truncate">
{{ w.title || 'Sans titre' }}
</h4>
<div class="mt-1 text-xs text-slate-400">
<span class="inline-flex items-center bg-slate-800/80 px-2 py-0.5 rounded mr-2">
{{ w.provider }}
</span>
</div>
<div class="mt-2 text-2xs text-slate-500">
<span>Regardé le {{ formatDate(w.watched_at) }}</span>
<span class="mx-2">•</span>
<span>Progression: {{ formatDuration(w.progress_seconds || 0) }} / {{ formatDuration(w.duration_seconds || 0) }}</span>
</div>
</div>
<button class="text-slate-400 hover:text-blue-400 p-1 -mr-1 self-center">
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" />
</svg>
</button>
</div>
</a>
</li>
</ul>
</section>
</div>
</div>
@@ -0,0 +1,139 @@
import { ChangeDetectionStrategy, Component, inject, signal } from '@angular/core';
import { CommonModule, DatePipe } from '@angular/common';
import { RouterModule } from '@angular/router';
import { HistoryService, SearchHistoryItem, WatchHistoryItem } from '../../../services/history.service';
@Component({
selector: 'app-history',
standalone: true,
templateUrl: './history.component.html',
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [CommonModule, RouterModule]
})
export class HistoryComponent {
private history = inject(HistoryService);
loading = signal<boolean>(false);
searchHistory = signal<SearchHistoryItem[]>([]);
watchHistory = signal<WatchHistoryItem[]>([]);
constructor() {
this.reload();
}
reload() {
this.loading.set(true);
this.history.getSearchHistory(50).subscribe({
next: (items) => this.searchHistory.set(items || []),
error: () => {},
});
this.history.getWatchHistory(50).subscribe({
next: (items) => this.watchHistory.set(items || []),
error: () => {},
});
this.loading.set(false);
}
// Format a date to a readable string
formatDate(dateString: string | Date): string {
if (!dateString) return '';
const date = new Date(dateString);
return date.toLocaleDateString('fr-FR', {
year: 'numeric',
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit'
});
}
// Get provider from search filters or query params
getSearchProvider(item: SearchHistoryItem): { name: string; id: string } {
// Try to get provider from filters first
if (item.filters_json) {
try {
const filters = JSON.parse(item.filters_json);
if (filters.provider) {
const providerName = this.formatProviderName(filters.provider);
return { name: providerName, id: filters.provider };
}
} catch (e) {
console.error('Error parsing filters_json:', e);
}
}
// If no provider in filters, try to extract from URL query params
try {
const url = new URL(item.query);
const provider = url.searchParams.get('provider');
if (provider) {
const providerName = this.formatProviderName(provider);
return { name: providerName, id: provider };
}
} catch (e) {
// Not a valid URL or no provider in query params
}
// Default to the selected provider if available
const defaultProvider = this.getDefaultProvider();
if (defaultProvider) {
return { name: this.formatProviderName(defaultProvider), id: defaultProvider };
}
// Fallback to 'all' if no provider can be determined
return { name: 'Tous', id: 'all' };
}
// Get the default provider from the current URL or settings
private getDefaultProvider(): string | null {
try {
// Try to get from current URL
const url = new URL(window.location.href);
const provider = url.searchParams.get('provider');
if (provider) return provider;
// Or get from localStorage if available
const savedProvider = localStorage.getItem('selectedProvider');
if (savedProvider) return savedProvider;
// Default to youtube if nothing else
return 'youtube';
} catch (e) {
return 'youtube'; // Fallback
}
}
// Format provider ID to a display name
private formatProviderName(providerId: string): string {
const providerMap: { [key: string]: string } = {
'youtube': 'YouTube',
'vimeo': 'Vimeo',
'dailymotion': 'Dailymotion',
'peertube': 'PeerTube',
'rumble': 'Rumble'
};
return providerMap[providerId.toLowerCase()] || providerId;
}
// Format seconds to HH:MM:SS or MM:SS
formatDuration(seconds: number): string {
if (!seconds && seconds !== 0) return '00:00';
const hours = Math.floor(seconds / 3600);
const minutes = Math.floor((seconds % 3600) / 60);
const secs = Math.floor(seconds % 60);
if (hours > 0) {
return `${hours}:${minutes.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`;
} else {
return `${minutes}:${secs.toString().padStart(2, '0')}`;
}
}
// Calculate watch progress percentage
getProgressPercentage(item: WatchHistoryItem): number {
if (!item.duration_seconds || item.duration_seconds === 0) return 0;
const progress = (item.progress_seconds || 0) / item.duration_seconds;
return Math.min(100, Math.max(0, progress * 100));
}
}
@@ -0,0 +1,95 @@
<div class="container mx-auto p-4 sm:p-6">
<h2 class="text-3xl font-bold mb-6 text-slate-100 border-l-4 border-red-500 pl-4">{{ 'preferences.title' | t }}</h2>
<!-- Notices -->
<div *ngIf="notice()" class="mb-4 bg-emerald-900/30 border border-emerald-600 text-emerald-200 px-4 py-3 rounded">{{ notice() }}</div>
<div *ngIf="error()" class="mb-4 bg-red-900/30 border border-red-600 text-red-200 px-4 py-3 rounded">{{ error() }}</div>
<div class="grid grid-cols-1 gap-6">
<!-- General -->
<form (submit)="onSave($event)" class="space-y-6">
<div class="bg-slate-800/50 rounded-xl border border-slate-700 overflow-hidden">
<div class="px-5 py-4 border-b border-slate-700 flex items-center justify-between">
<div>
<h3 class="text-lg font-semibold text-slate-100">{{ 'preferences.general' | t : 'Préférences générales' }}</h3>
<p class="text-sm text-slate-400">{{ 'preferences.general_hint' | t : 'Personnalisez votre expérience' }}</p>
</div>
<button [disabled]="saving()" class="px-4 py-2 rounded bg-red-600 hover:bg-red-500 disabled:opacity-50">{{ 'btn.save' | t : 'Enregistrer' }}</button>
</div>
<div class="p-5 grid grid-cols-1 md:grid-cols-2 gap-5">
<div>
<label class="block text-sm text-slate-400 mb-1">{{ 'label.language' | t : 'Langue' }}</label>
<select class="w-full rounded bg-slate-900 text-slate-100 border border-slate-700 px-3 py-2" [ngModel]="language()" (ngModelChange)="language.set($event)" name="language">
<option value="en">English</option>
<option value="fr">Français</option>
</select>
</div>
<div>
<label class="block text-sm text-slate-400 mb-1">{{ 'label.defaultProvider' | t : 'Fournisseur par défaut' }}</label>
<select class="w-full rounded bg-slate-900 text-slate-100 border border-slate-700 px-3 py-2" [ngModel]="defaultProvider()" (ngModelChange)="defaultProvider.set($event)" name="provider">
<option *ngFor="let p of providers()" [value]="p.id">{{ p.label }}</option>
</select>
</div>
<div>
<label class="block text-sm text-slate-400 mb-1">{{ 'label.theme' | t : 'Thème' }}</label>
<select class="w-full rounded bg-slate-900 text-slate-100 border border-slate-700 px-3 py-2" [ngModel]="theme()" (ngModelChange)="theme.set($event)" name="theme">
<option *ngFor="let t of themes" [value]="t">{{ t | titlecase }}</option>
</select>
</div>
<div>
<label class="block text-sm text-slate-400 mb-1">{{ 'label.quality' | t : 'Qualité par défaut' }}</label>
<select class="w-full rounded bg-slate-900 text-slate-100 border border-slate-700 px-3 py-2" [ngModel]="videoQuality()" (ngModelChange)="videoQuality.set($event)" name="quality">
<option *ngFor="let q of qualities" [value]="q">{{ q }}</option>
</select>
</div>
<div>
<label class="block text-sm text-slate-400 mb-1">{{ 'label.region' | t : 'Région' }}</label>
<select class="w-full rounded bg-slate-900 text-slate-100 border border-slate-700 px-3 py-2" [ngModel]="region()" (ngModelChange)="region.set($event)" name="region">
<option *ngFor="let r of regions" [value]="r">{{ ('region.' + r) | t }}</option>
</select>
</div>
</div>
</div>
</form>
<!-- PeerTube Instances (below general) -->
<section class="space-y-4">
<div class="bg-slate-800/50 rounded-xl border border-slate-700 overflow-hidden">
<div class="px-5 py-4 border-b border-slate-700">
<h3 class="text-lg font-semibold text-slate-100">PeerTube</h3>
<p class="text-sm text-slate-400">{{ 'preferences.peertube_hint' | t : 'Gérez vos instances PeerTube' }}</p>
</div>
<div class="p-5 space-y-4">
<form (submit)="addPeerTubeInstance($event)" class="flex items-center gap-3">
<input type="text" name="peertube"
class="flex-1 rounded bg-slate-900 text-slate-100 border border-slate-700 px-3 py-2"
[placeholder]="'preferences.add_instance_placeholder' | t : 'ex: example.org'"
[ngModel]="newPeerTubeInstance()" (ngModelChange)="newPeerTubeInstance.set($event)" />
<button type="submit" class="px-3 py-2 rounded bg-slate-700 hover:bg-slate-600 text-white">{{ 'btn.add' | t : 'Ajouter' }}</button>
</form>
<div *ngIf="(peerTubeInstances() || []).length === 0" class="text-sm text-slate-400">{{ 'preferences.no_instances' | t : 'Aucune instance pour le moment.' }}</div>
<ul class="divide-y divide-slate-700 rounded border border-slate-700 overflow-hidden">
<li *ngFor="let host of peerTubeInstances()" class="flex items-center justify-between px-4 py-2 bg-slate-900">
<div class="flex items-center gap-2">
<span class="inline-block w-2 h-2 rounded-full"
[ngClass]="{ 'bg-emerald-400': activePeerTubeInstance() === host, 'bg-slate-600': activePeerTubeInstance() !== host }"></span>
<span class="text-slate-200">{{ host }}</span>
</div>
<div class="flex items-center gap-2">
<button type="button" (click)="setActivePeerTube(host)"
class="px-2 py-1 rounded border"
[ngClass]="{ 'border-emerald-500 text-emerald-300': activePeerTubeInstance() === host, 'border-slate-600 text-slate-300 hover:bg-slate-800': activePeerTubeInstance() !== host }">
{{ activePeerTubeInstance() === host ? ('preferences.active' | t : 'Actif') : ('preferences.set_active' | t : 'Définir actif') }}
</button>
<button type="button" (click)="removePeerTubeInstance(host)" class="px-2 py-1 rounded bg-red-600 hover:bg-red-500 text-white">{{ 'btn.remove' | t : 'Supprimer' }}</button>
</div>
</li>
</ul>
</div>
</div>
</section>
</div>
</div>
@@ -0,0 +1,134 @@
import { ChangeDetectionStrategy, Component, inject, signal } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { UserService, UserPreferences } from '../../../services/user.service';
import { InstanceService, Provider } from '../../../services/instance.service';
import { firstValueFrom } from 'rxjs';
import { I18nService } from '../../../services/i18n.service';
import { TranslatePipe } from '../../../pipes/translate.pipe';
@Component({
selector: 'app-preferences',
standalone: true,
templateUrl: './preferences.component.html',
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [CommonModule, FormsModule, TranslatePipe]
})
export class PreferencesComponent {
private user = inject(UserService);
instances = inject(InstanceService);
private i18n = inject(I18nService);
// Form model
language = signal<string>('en');
defaultProvider = signal<Provider>('youtube');
theme = signal<string>('system');
videoQuality = signal<string>('auto');
region = signal<string>('US');
saving = signal<boolean>(false);
notice = signal<string | null>(null);
error = signal<string | null>(null);
providers = this.instances.providers; // signal of ProviderInfo[]
regions = ['US', 'FR', 'BR', 'DE', 'GB', 'CA'];
themes = ['system', 'light', 'dark', 'black', 'blue'];
qualities = ['auto', '720p', '1080p'];
// PeerTube instances management
newPeerTubeInstance = signal<string>('');
peerTubeInstances = this.instances.peerTubeInstances; // signal<string[]>
activePeerTubeInstance = this.instances.activePeerTubeInstance; // signal<string>
constructor() {
// Load current preferences into form
firstValueFrom(this.user.loadPreferences()).then((prefs) => this.applyPrefs(prefs)).catch(() => {});
}
private applyPrefs(p: UserPreferences | null) {
if (!p) return;
if (p.language) this.language.set(p.language);
// apply language immediately
if (p.language) this.i18n.setLanguage(p.language);
if (p.defaultProvider) this.defaultProvider.set(p.defaultProvider as Provider);
if (p.theme) this.theme.set(p.theme);
if (p.videoQuality) this.videoQuality.set(p.videoQuality);
if (p.region) this.region.set(p.region);
// Apply theme immediately on load
try {
document.documentElement.setAttribute('data-theme', p.theme || 'system');
localStorage.setItem('newtube.theme', p.theme || 'system');
} catch {}
}
async onSave(ev: Event) {
ev.preventDefault();
this.saving.set(true);
this.notice.set(null);
try {
const patch: Partial<UserPreferences> = {
language: this.language(),
defaultProvider: this.defaultProvider(),
theme: this.theme(),
videoQuality: this.videoQuality(),
region: this.region(),
};
const prefs = await firstValueFrom(this.user.updatePreferences(patch));
// Also apply immediately to runtime (provider/region/theme)
if (prefs.defaultProvider) this.instances.setSelectedProvider(prefs.defaultProvider as Provider);
if (prefs.region) this.instances.setRegion(prefs.region);
if (prefs.language) this.i18n.setLanguage(prefs.language);
try {
document.documentElement.setAttribute('data-theme', prefs.theme || 'system');
localStorage.setItem('newtube.theme', prefs.theme || 'system');
} catch {}
this.notice.set('Preferences saved.');
} catch {
this.notice.set('Failed to save preferences.');
} finally {
this.saving.set(false);
}
}
// ---- PeerTube instances helpers ----
private isValidDomain(host: string): boolean {
const v = (host || '').trim().toLowerCase();
// Basic domain validation (no protocol, no path)
return !!v && /^[a-z0-9.-]+\.[a-z]{2,}$/i.test(v) && !/^https?:\/\//i.test(v) && !/[\/\s]/.test(v);
}
addPeerTubeInstance(ev?: Event) {
ev?.preventDefault();
this.error.set(null);
const raw = (this.newPeerTubeInstance() || '').trim();
if (!this.isValidDomain(raw)) {
this.error.set('Enter a valid domain like "example.org" (no http://, no path).');
return;
}
const domain = raw.toLowerCase();
const list = [...(this.peerTubeInstances() || [])];
if (list.includes(domain)) {
this.error.set('This instance is already in the list.');
return;
}
list.push(domain);
this.instances.setPeerTubeInstances(list);
this.newPeerTubeInstance.set('');
this.notice.set('PeerTube instance added.');
}
removePeerTubeInstance(domain: string) {
const list = (this.peerTubeInstances() || []).filter(d => d !== domain);
this.instances.setPeerTubeInstances(list);
if (this.activePeerTubeInstance() === domain) {
const next = list[0] || '';
if (next) this.instances.setActivePeerTubeInstance(next);
}
this.notice.set('PeerTube instance removed.');
}
setActivePeerTube(domain: string) {
this.instances.setActivePeerTubeInstance(domain);
this.notice.set(`Active PeerTube instance set to ${domain}.`);
}
}
@@ -0,0 +1,33 @@
<div class="container mx-auto p-4 sm:p-6">
<h2 class="text-3xl font-bold mb-6 text-slate-100 border-l-4 border-red-500 pl-4">Sessions</h2>
<div *ngIf="error()" class="mb-4 bg-amber-900/40 border border-amber-600 text-amber-200 p-3 rounded">{{ error() }}</div>
<div class="flex items-center gap-3 mb-4">
<button class="px-4 py-2 rounded bg-slate-700 hover:bg-slate-600 text-white" (click)="reload()" [disabled]="loading()">Reload</button>
<button class="px-4 py-2 rounded bg-red-600 hover:bg-red-500 text-white" (click)="logoutAll()" [disabled]="loading()">Logout all devices</button>
</div>
<div *ngIf="loading()" class="text-slate-400">Loading...</div>
<div *ngIf="!loading() && items().length === 0" class="text-slate-400">No active sessions.</div>
<ul class="space-y-3">
<li *ngFor="let s of items()" class="p-4 bg-slate-800 rounded flex items-start justify-between gap-4">
<div class="text-sm text-slate-200">
<div><span class="text-slate-400">Session ID:</span> <code>{{ s.id }}</code></div>
<div><span class="text-slate-400">User Agent:</span> {{ s.userAgent || '—' }}</div>
<div><span class="text-slate-400">Device:</span> {{ s.deviceInfo || '—' }}</div>
<div><span class="text-slate-400">IP:</span> {{ s.ip || '—' }}</div>
<div><span class="text-slate-400">Remember me:</span> {{ s.isRemember ? 'Yes' : 'No' }}</div>
<div><span class="text-slate-400">Created:</span> {{ s.createdAt || '—' }}</div>
<div><span class="text-slate-400">Last seen:</span> {{ s.lastSeenAt || '—' }}</div>
<div><span class="text-slate-400">Expires:</span> {{ s.expiresAt || '—' }}</div>
<div *ngIf="s.revokedAt"><span class="text-slate-400">Revoked:</span> {{ s.revokedAt }}</div>
</div>
<div class="shrink-0">
<button class="px-3 py-1 rounded bg-slate-700 hover:bg-slate-600 text-white" (click)="revoke(s.id)">Revoke</button>
</div>
</li>
</ul>
</div>
@@ -0,0 +1,57 @@
import { ChangeDetectionStrategy, Component, inject, signal } from '@angular/core';
import { CommonModule } from '@angular/common';
import { AuthService, SessionInfo } from '../../../services/auth.service';
import { firstValueFrom } from 'rxjs';
@Component({
selector: 'app-sessions',
standalone: true,
templateUrl: './sessions.component.html',
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [CommonModule]
})
export class SessionsComponent {
private auth = inject(AuthService);
loading = signal<boolean>(false);
items = signal<SessionInfo[]>([]);
error = signal<string | null>(null);
constructor() {
this.reload();
}
reload() {
this.loading.set(true);
this.error.set(null);
this.auth.listSessions().subscribe({
next: (list) => {
this.items.set(list || []);
this.loading.set(false);
},
error: (e: any) => {
const status = (e && typeof e.status === 'number') ? e.status : 0;
if (status === 401) {
this.error.set('Please login to view sessions.');
} else {
this.error.set('Failed to load sessions.');
}
this.loading.set(false);
}
});
}
async revoke(id: string) {
try {
await firstValueFrom(this.auth.revokeSession(id));
this.items.update(arr => arr.filter(s => s.id !== id));
} catch {}
}
async logoutAll() {
try {
await firstValueFrom(this.auth.logout(true));
this.items.set([]);
} catch {}
}
}
@@ -0,0 +1,32 @@
<div class="min-h-[calc(100vh-64px)] flex items-center justify-center py-10">
<div class="w-full max-w-md bg-slate-800 rounded-xl shadow-xl p-8">
<div class="flex items-center gap-2 mb-6">
<svg xmlns="http://www.w3.org/2000/svg" class="h-8 w-8 text-red-500" viewBox="0 0 24 24" fill="currentColor"><path d="M10,15.6l5.3-3.3L10,9V15.6z M21.8,8.1c-0.2-0.8-0.9-1.4-1.7-1.7C18.2,6,12,6,12,6s-6.2,0-8.1,0.5 c-0.8,0.2-1.4,0.9-1.7,1.7C2,9.9,2,12,2,12s0,2.1,0.5,3.9c0.2,0.8,0.9,1.4,1.7,1.7C6.2,18,12,18,12,18s6.2,0,8.1-0.5 c0.8-0.2,1.4-0.9,1.7-1.7c0.5-1.8,0.5-3.9,0.5-3.9S22.2,9.9,21.8,8.1z"/></svg>
<h1 class="text-2xl font-bold">Sign in to NewTube</h1>
</div>
<form (submit)="$event.preventDefault(); submit()" class="space-y-4">
<div>
<label class="block text-sm text-slate-300 mb-1">Username (or Email)</label>
<input [ngModel]="username()" (ngModelChange)="username.set($event)" name="username" autocomplete="username" class="w-full bg-slate-900 text-white rounded px-3 py-2 focus:outline-none focus:ring-2 focus:ring-red-500" placeholder="yourname or [email protected]">
</div>
<div>
<label class="block text-sm text-slate-300 mb-1">Password</label>
<input [ngModel]="password()" (ngModelChange)="password.set($event)" type="password" name="password" autocomplete="current-password" class="w-full bg-slate-900 text-white rounded px-3 py-2 focus:outline-none focus:ring-2 focus:ring-red-500" placeholder="••••••••">
</div>
<label class="flex items-center gap-2 text-sm text-slate-300">
<input type="checkbox" [ngModel]="remember()" (ngModelChange)="remember.set($event)" name="remember" class="accent-red-600">
<span>Remember me</span>
</label>
<div *ngIf="error()" class="bg-red-900/50 border border-red-600 text-red-200 rounded px-3 py-2 text-sm">{{ error() }}</div>
<button [disabled]="busy()" class="w-full py-2 rounded bg-red-600 hover:bg-red-500 disabled:opacity-60">Sign in</button>
</form>
<div class="mt-4 text-sm text-slate-300">
Don't have an account?
<a routerLink="/auth/register" class="text-red-400 hover:text-red-300">Create one</a>
</div>
</div>
</div>
@@ -0,0 +1,59 @@
import { ChangeDetectionStrategy, Component, inject, signal } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { Router, RouterLink } from '@angular/router';
import { AuthService } from '../../../services/auth.service';
import { UserService } from '../../../services/user.service';
import { InstanceService } from '../../../services/instance.service';
import { firstValueFrom } from 'rxjs';
@Component({
selector: 'app-login-page',
standalone: true,
templateUrl: './login.component.html',
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [CommonModule, FormsModule, RouterLink]
})
export class LoginComponent {
private auth = inject(AuthService);
private userService = inject(UserService);
private instances = inject(InstanceService);
private router = inject(Router);
username = signal('');
password = signal('');
remember = signal(true);
error = signal<string | null>(null);
busy = signal(false);
async submit() {
this.error.set(null);
this.busy.set(true);
try {
const u = this.username().trim();
const p = this.password();
if (!u || !p) {
this.error.set('Username (or Email) and password are required.');
return;
}
await firstValueFrom(this.auth.login(u, p, this.remember()));
try {
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 { document.documentElement.setAttribute('data-theme', prefs.theme || 'system'); } catch {}
}
} catch {}
this.router.navigate(['/']);
} 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.error.set(serverMsg || 'Invalid credentials.');
else if (status === 429) this.error.set('Too many attempts. Please wait a minute and try again.');
else this.error.set(serverMsg || 'Login failed.');
} finally {
this.busy.set(false);
}
}
}
@@ -0,0 +1,32 @@
<div class="min-h-[calc(100vh-64px)] flex items-center justify-center py-10">
<div class="w-full max-w-md bg-slate-800 rounded-xl shadow-xl p-8">
<div class="flex items-center gap-2 mb-6">
<svg xmlns="http://www.w3.org/2000/svg" class="h-8 w-8 text-red-500" viewBox="0 0 24 24" fill="currentColor"><path d="M10,15.6l5.3-3.3L10,9V15.6z M21.8,8.1c-0.2-0.8-0.9-1.4-1.7-1.7C18.2,6,12,6,12,6s-6.2,0-8.1,0.5 c-0.8,0.2-1.4,0.9-1.7,1.7C2,9.9,2,12,2,12s0,2.1,0.5,3.9c0.2,0.8,0.9,1.4,1.7,1.7C6.2,18,12,18,12,18s6.2,0,8.1-0.5 c0.8-0.2,1.4-0.9,1.7-1.7c0.5-1.8,0.5-3.9,0.5-3.9S22.2,9.9,21.8,8.1z"/></svg>
<h1 class="text-2xl font-bold">Create your account</h1>
</div>
<form (submit)="$event.preventDefault(); submit()" class="space-y-4">
<div>
<label class="block text-sm text-slate-300 mb-1">Username (or Email)</label>
<input [ngModel]="username()" (ngModelChange)="username.set($event)" name="username" autocomplete="username" class="w-full bg-slate-900 text-white rounded px-3 py-2 focus:outline-none focus:ring-2 focus:ring-red-500" placeholder="yourname (or leave empty to use email)">
</div>
<div>
<label class="block text-sm text-slate-300 mb-1">Email (optional)</label>
<input [ngModel]="email()" (ngModelChange)="email.set($event)" type="email" name="email" autocomplete="email" class="w-full bg-slate-900 text-white rounded px-3 py-2 focus:outline-none focus:ring-2 focus:ring-red-500" placeholder="[email protected]">
</div>
<div>
<label class="block text-sm text-slate-300 mb-1">Password</label>
<input [ngModel]="password()" (ngModelChange)="password.set($event)" type="password" name="new-password" autocomplete="new-password" class="w-full bg-slate-900 text-white rounded px-3 py-2 focus:outline-none focus:ring-2 focus:ring-red-500" placeholder="••••••••">
</div>
<div *ngIf="error()" class="bg-red-900/50 border border-red-600 text-red-200 rounded px-3 py-2 text-sm">{{ error() }}</div>
<button [disabled]="busy()" class="w-full py-2 rounded bg-red-600 hover:bg-red-500 disabled:opacity-60">Create account</button>
</form>
<div class="mt-4 text-sm text-slate-300">
Already have an account?
<a routerLink="/auth/login" class="text-red-400 hover:text-red-300">Sign in</a>
</div>
</div>
</div>
@@ -0,0 +1,62 @@
import { ChangeDetectionStrategy, Component, inject, signal } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { Router, RouterLink } from '@angular/router';
import { AuthService } from '../../../services/auth.service';
import { UserService } from '../../../services/user.service';
import { InstanceService } from '../../../services/instance.service';
import { firstValueFrom } from 'rxjs';
@Component({
selector: 'app-register-page',
standalone: true,
templateUrl: './register.component.html',
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [CommonModule, FormsModule, RouterLink]
})
export class RegisterComponent {
private auth = inject(AuthService);
private userService = inject(UserService);
private instances = inject(InstanceService);
private router = inject(Router);
username = signal('');
email = signal('');
password = signal('');
error = signal<string | null>(null);
busy = signal(false);
async submit() {
this.error.set(null);
this.busy.set(true);
try {
const uRaw = this.username().trim();
const eRaw = this.email().trim();
const p = this.password();
const username = uRaw || eRaw; // allow email as identifier
if (!username || !p) {
this.error.set('Username (or Email) and password are required.');
return;
}
await firstValueFrom(this.auth.register(username, p, eRaw || undefined));
try {
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 { document.documentElement.setAttribute('data-theme', prefs.theme || 'system'); } catch {}
}
} catch {}
this.router.navigate(['/']);
} 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.error.set(serverMsg || 'Username already exists.');
else if (status === 400) this.error.set(serverMsg || 'Username (or Email) and password are required.');
else if (status === 429) this.error.set('Too many attempts. Please wait a minute and try again.');
else this.error.set(serverMsg || 'Registration failed.');
} finally {
this.busy.set(false);
}
}
}
+222
View File
@@ -0,0 +1,222 @@
<header class="bg-slate-800/50 backdrop-blur-sm fixed top-0 left-0 right-0 z-50 shadow-lg w-full border-b border-slate-700/60">
<div class="w-full px-0 py-3 grid items-center gap-4 relative grid-cols-[auto_1fr_auto]">
<!-- Left: burger + logo -->
<div class="flex items-center gap-3 shrink-0 pl-3 md:pl-4">
<button (click)="menuToggle.emit()" class="p-2 rounded-full hover:bg-slate-700 focus:outline-none" aria-label="Toggle menu">
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6 text-slate-200" viewBox="0 0 24 24" fill="currentColor">
<path d="M3 6h18v2H3V6zm0 5h18v2H3v-2zm0 5h18v2H3v-2z"/>
</svg>
</button>
<a routerLink="/" class="flex items-center space-x-2 shrink-0">
<svg xmlns="http://www.w3.org/2000/svg" class="h-8 w-8 text-red-500" viewBox="0 0 24 24" fill="currentColor">
<path d="M10,15.6l5.3-3.3L10,9V15.6z M21.8,8.1c-0.2-0.8-0.9-1.4-1.7-1.7C18.2,6,12,6,12,6s-6.2,0-8.1,0.5 c-0.8,0.2-1.4,0.9-1.7,1.7C2,9.9,2,12,2,12s0,2.1,0.5,3.9c0.2,0.8,0.9,1.4,1.7,1.7C6.2,18,12,18,12,18s6.2,0,8.1-0.5 c0.8-0.2,1.4-0.9,1.7-1.7c0.5-1.8,0.5-3.9,0.5-3.9S22.2,9.9,21.8,8.1z"/>
</svg>
<h1 class="text-2xl font-bold tracking-tight text-white">NewTube</h1>
</a>
</div>
<!-- Center: search (grid-centered, avoids overlap) -->
<form class="justify-self-center mx-auto w-full max-w-2xl md:max-w-3xl lg:max-w-4xl px-4 relative group" (submit)="onSubmitSearch($event, searchInput)">
<input #searchInput type="search" [placeholder]="('search.placeholder' | t)" aria-label="Search"
(focus)="onSearchFocus()" (input)="onSearchInput(searchInput)" (blur)="onSearchBlur()" (keydown)="onSearchKeydown($event, searchInput)"
(search)="onSearchCleared(searchInput)"
[ngClass]="{
'bg-white text-slate-900 placeholder-slate-500 ring-slate-300 hover:bg-slate-50': isLightTheme(),
'bg-slate-700/80 text-slate-200 placeholder-slate-400 ring-slate-600/50 hover:bg-slate-700/70': isDarkTheme(),
'bg-black text-slate-200 placeholder-slate-400 ring-slate-800/50 hover:bg-slate-900': isBlackTheme(),
'bg-blue-950/80 text-blue-100 placeholder-blue-300 ring-blue-900/60 hover:bg-blue-900/70': isBlueTheme()
}"
class="w-full rounded-lg py-2.5 px-4 pl-10 pr-16 focus:outline-none ring-1 focus:ring-2 focus:ring-red-500/70 transition-all duration-200 shadow-sm">
<svg class="w-5 h-5 absolute left-6 md:left-5 top-1/2 -translate-y-1/2"
[ngClass]="{
'text-slate-500': isLightTheme(),
'text-slate-400': isDarkTheme(),
'text-slate-300': isBlackTheme(),
'text-blue-200': isBlueTheme()
}"
xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
</svg>
<!-- Keyboard shortcut hint (only when no provider indicator) -->
<div *ngIf="!providerContextLabel()" class="absolute right-6 top-1/2 -translate-y-1/2 hidden md:flex items-center gap-1 text-xs select-none"
[ngClass]="{
'text-slate-700/80': isLightTheme(),
'text-slate-300/80': isDarkTheme() || isBlackTheme(),
'text-blue-200/80': isBlueTheme()
}">
<kbd [ngClass]="{
'bg-slate-200/80 border-slate-300 text-slate-700': isLightTheme(),
'bg-slate-700/80 border-slate-600/60': isDarkTheme(),
'bg-slate-900/80 border-slate-800/60 text-slate-300': isBlackTheme(),
'bg-blue-800/80 border-blue-700/60 text-blue-200': isBlueTheme()
}" class="px-1.5 py-0.5 rounded border">Ctrl</kbd>
<span>+</span>
<kbd [ngClass]="{
'bg-slate-200/80 border-slate-300 text-slate-700': isLightTheme(),
'bg-slate-700/80 border-slate-600/60 text-slate-300': isDarkTheme(),
'bg-slate-900/80 border-slate-800/60 text-slate-300': isBlackTheme(),
'bg-blue-800/80 border-blue-700/60 text-blue-200': isBlueTheme()
}" class="px-1.5 py-0.5 rounded border">K</kbd>
</div>
<!-- Suggestions dropdown -->
@if (suggestionsOpen() && suggestionItems().length > 0) {
<div class="absolute z-50 mt-2 left-1/2 -translate-x-1/2 w-full max-w-2xl md:max-w-3xl lg:max-w-4xl rounded-xl shadow-2xl overflow-hidden"
[ngClass]="{
'bg-white border border-slate-200 text-slate-900': isLightTheme(),
'bg-slate-800/95 border border-slate-700 text-slate-200': isDarkTheme(),
'bg-black/95 border border-slate-900 text-slate-200': isBlackTheme(),
'bg-blue-950/90 border border-blue-800 text-blue-100': isBlueTheme(),
'backdrop-blur-md': !isLightTheme()
}">
<ul class="max-h-[70vh] overflow-y-auto py-1">
@for (item of suggestionItems(); track $index; let i = $index) {
<!-- Divider before first generated suggestion -->
@if (isFirstGenerated(i)) {
<li class="px-4 py-2 text-xs uppercase tracking-wide border-t border-b"
[ngClass]="{
'text-slate-500 border-slate-100': isLightTheme(),
'text-slate-400 border-slate-700': isDarkTheme(),
'text-slate-400 border-slate-800': isBlackTheme(),
'text-blue-300 border-blue-800': isBlueTheme()
}">Suggestions</li>
}
<li (mouseenter)="highlightedIndex.set(i)">
<button type="button" (mousedown)="pickSuggestion(item.text, searchInput)"
class="w-full text-left px-4 py-2 flex items-center gap-3 transition-colors duration-150"
[ngClass]="{
'hover:bg-slate-100': isLightTheme(),
'hover:bg-slate-700/70': isDarkTheme(),
'hover:bg-slate-900': isBlackTheme(),
'hover:bg-blue-800/60': isBlueTheme(),
'bg-slate-200': isLightTheme() && highlightedIndex() === i,
'bg-slate-700/50': isDarkTheme() && highlightedIndex() === i,
'bg-slate-900/70': isBlackTheme() && highlightedIndex() === i,
'bg-blue-800/50': isBlueTheme() && highlightedIndex() === i,
'text-slate-900': isLightTheme(),
'text-slate-200': isDarkTheme() || isBlackTheme(),
'text-blue-100': isBlueTheme()
}">
<!-- Clock for history, magnifier for generated -->
<svg *ngIf="item.source === 'history'" xmlns="http://www.w3.org/2000/svg" class="h-5 w-5"
[ngClass]="{
'text-slate-500': isLightTheme(),
'text-slate-400': isDarkTheme(),
'text-slate-300': isBlackTheme(),
'text-blue-200': isBlueTheme()
}" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<svg *ngIf="item.source === 'generated'" xmlns="http://www.w3.org/2000/svg" class="h-5 w-5"
[ngClass]="{
'text-slate-500': isLightTheme(),
'text-slate-400': isDarkTheme(),
'text-slate-300': isBlackTheme(),
'text-blue-200': isBlueTheme()
}" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
</svg>
<span class="truncate">{{ item.text }}</span>
</button>
</li>
}
</ul>
</div>
}
</form>
<!-- Right: user -->
<div class="justify-self-end flex items-center gap-3 pr-3 md:pr-4">
<!-- Provider context indicator placed between search and user area -->
<div *ngIf="providerContextLabel()" class="flex items-center gap-1 text-xs">
<span class="inline-flex items-center gap-1 px-2 py-0.5 rounded bg-yellow-400/30 border border-yellow-400/40 shadow-sm"
[ngClass]="{ 'text-black': isLightTheme(), 'text-yellow-200': !isLightTheme() }">
<span class="inline-block w-2 h-2 rounded-full bg-yellow-300 animate-pulse"></span>
<span>{{ providerContextLabel() }}</span>
</span>
</div>
<div class="h-6 w-px bg-slate-700/60 mx-2"></div>
<!-- Auth / User Menu Area -->
<div *ngIf="!user(); else userMenu" class="flex items-center gap-2">
<a routerLink="/auth/login" class="px-3 py-1 rounded bg-slate-700 hover:bg-slate-600 text-white">{{ 'btn.login' | t }}</a>
<a routerLink="/auth/register" class="px-3 py-1 rounded bg-red-600 hover:bg-red-500 text-white">{{ 'btn.register' | t }}</a>
</div>
<ng-template #userMenu>
<div class="relative" #userMenuContainer>
<!-- Avatar Button -->
<button (click)="toggleUserMenu()" class="flex items-center gap-2 p-1 rounded-full hover:bg-slate-700">
<div class="h-8 w-8 rounded-full flex items-center justify-center text-sm font-semibold border"
[ngClass]="{
'bg-slate-200 text-slate-800 border-slate-300': isLightTheme(),
'bg-slate-600 text-white border-slate-500': isDarkTheme(),
'bg-slate-800 text-slate-200 border-slate-700': isBlackTheme(),
'bg-blue-900 text-blue-100 border-blue-800': isBlueTheme()
}">
{{ (user()?.username || user()?.email || 'U') | slice:0:1 | uppercase }}
</div>
</button>
<!-- Dropdown Menu -->
@if (userMenuOpen()) {
<div class="absolute right-0 mt-2 w-64 bg-slate-900 border border-slate-700 rounded-xl shadow-xl overflow-hidden z-50">
<div class="px-4 py-3 border-b border-slate-700">
<div class="font-semibold text-slate-100">{{ user()?.username || user()?.email }}</div>
<div class="text-xs text-slate-400 truncate">{{ user()?.email }}</div>
</div>
<div class="py-1">
<a routerLink="/account/preferences" class="block px-4 py-2 text-sm text-slate-200 hover:bg-slate-800">{{ 'menu.preferences' | t }}</a>
<div class="px-4 py-2 text-xs uppercase tracking-wide text-slate-500">{{ 'menu.appearance' | t }}</div>
<div class="px-3 pb-2 grid grid-cols-4 gap-2">
<!-- Black -->
<button (click)="setTheme('black')"
[ngClass]="{ 'ring-2': currentTheme() === 'black', 'ring-red-500': currentTheme() === 'black' }"
class="px-2 py-2 text-xs rounded bg-slate-800 hover:bg-slate-700 flex items-center justify-center gap-1">
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" viewBox="0 0 24 24" fill="currentColor"><circle cx="12" cy="12" r="9"/></svg>
<span>{{ 'theme.black' | t }}</span>
</button>
<!-- Dark -->
<button (click)="setTheme('dark')"
[ngClass]="{ 'ring-2': currentTheme() === 'dark', 'ring-red-500': currentTheme() === 'dark' }"
class="px-2 py-2 text-xs rounded bg-slate-800 hover:bg-slate-700 flex items-center justify-center gap-1">
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" viewBox="0 0 24 24" fill="currentColor"><path d="M21 12.79A9 9 0 1 1 11.21 3a7 7 0 1 0 9.79 9.79Z"/></svg>
<span>{{ 'theme.dark' | t }}</span>
</button>
<!-- Blue -->
<button (click)="setTheme('blue')"
[ngClass]="{ 'ring-2': currentTheme() === 'blue', 'ring-red-500': currentTheme() === 'blue' }"
class="px-2 py-2 text-xs rounded bg-slate-800 hover:bg-slate-700 flex items-center justify-center gap-1">
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" viewBox="0 0 24 24" fill="currentColor"><path d="M12 2s7 7.58 7 12a7 7 0 1 1-14 0C5 9.58 12 2 12 2Z"/></svg>
<span>{{ 'theme.blue' | t }}</span>
</button>
<!-- Light -->
<button (click)="setTheme('light')"
[ngClass]="{ 'ring-2': currentTheme() === 'light', 'ring-red-500': currentTheme() === 'light' }"
class="px-2 py-2 text-xs rounded bg-slate-800 hover:bg-slate-700 flex items-center justify-center gap-1">
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" viewBox="0 0 24 24" fill="currentColor"><path d="M12 18a6 6 0 1 0 0-12 6 6 0 0 0 0 12Zm0-16a1 1 0 0 1 1 1v2a1 1 0 1 1-2 0V3a1 1 0 0 1 1-1Zm0 18a1 1 0 0 1 1 1v2a1 1 0 1 1-2 0v-2a1 1 0 0 1 1-1Zm9-9a1 1 0 0 1 1 1h-2a1 1 0 1 1 0-2h2a1 1 0 0 1-1 1ZM4 12a1 1 0 0 1 1-1H3a1 1 0 1 1 0 2h2a1 1 0 0 1-1-1Zm12.95 6.364a1 1 0 0 1 1.414 1.414l-1.415 1.415a1 1 0 1 1-1.414-1.415l1.415-1.414ZM7.05 4.222a1 1 0 1 1 1.414-1.415L9.88 4.222A1 1 0 0 1 8.465 5.636L7.05 4.222zm9.9-1.415a1 1 0 1 1 1.414 1.415L17.95 5.636A1 1 0 0 1 16.536 4.22l1.414-1.414ZM7.05 19.778 5.636 21.19a1 1 0 0 1-1.414-1.414L5.636 18.364A1 1 0 0 1 7.05 19.78Z"/></svg>
<span>{{ 'theme.light' | t }}</span>
</button>
</div>
<button (click)="onLogout()" class="w-full text-left px-4 py-2 text-sm text-red-300 hover:bg-red-900/30">{{ 'menu.logout' | t }}</button>
</div>
</div>
}
</div>
</ng-template>
</div>
</div>
</header>
<!-- PeerTube Instance Editor Modal -->
<div *ngIf="editingPeerTubeInstances()" class="fixed inset-0 bg-black bg-opacity-75 flex items-center justify-center z-50">
<div class="bg-gray-800 rounded-lg p-6 w-full max-w-md">
<h2 class="text-xl font-bold mb-4">Edit PeerTube Instances</h2>
<p class="text-sm text-gray-400 mb-4">Enter one instance domain per line.</p>
<textarea [(ngModel)]="peerTubeInstancesInput" class="w-full h-48 bg-gray-900 text-white rounded p-2 focus:outline-none focus:ring-2 focus:ring-red-500"></textarea>
<div class="mt-4 flex justify-end gap-3">
<button (click)="editingPeerTubeInstances.set(false)" class="px-4 py-2 rounded bg-gray-600 hover:bg-gray-500">Cancel</button>
<button (click)="savePeerTubeInstances()" class="px-4 py-2 rounded bg-red-600 hover:bg-red-500">Save</button>
</div>
</div>
</div>
+550
View File
@@ -0,0 +1,550 @@
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<HTMLInputElement>;
@ViewChild('userMenuContainer', { static: false }) userMenuContainerRef?: ElementRef<HTMLElement>;
searchQuery = '';
editingPeerTubeInstances = signal(false);
peerTubeInstancesInput = '';
// Search suggestions dropdown state
suggestionsOpen = signal(false);
recentSearches = signal<SearchHistoryItem[]>([]);
highlightedIndex = signal<number>(-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<string>();
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<Provider | null>(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<string>((() => {
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<string | null>(null);
userMenuOpen = signal(false);
// Sidebar toggle (emits to parent AppComponent)
@Output() menuToggle = new EventEmitter<void>();
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'
};
}
// 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 {}
}
ngOnInit() {
// Initialize and keep provider context in sync with URL
this.updateProviderContextFromUrl(this.router.url);
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);
let provider = (params.get('provider') || '').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, 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;
}
}
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 {}
}
}
+57
View File
@@ -0,0 +1,57 @@
<div class="container mx-auto p-4 sm:p-6">
<h2 class="text-3xl font-bold mb-6 text-slate-100 border-l-4 border-red-500 pl-4">{{ 'home.trending' | t }}</h2>
@if (notice()) {
<div class="mb-4 bg-amber-900/40 border border-amber-600 text-amber-200 p-3 rounded">
{{ notice() }}
</div>
}
@if (loading()) {
<div class="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-6">
@for (item of [1,2,3,4,5,6,7,8]; track item) {
<div class="animate-pulse bg-slate-800 rounded-lg overflow-hidden">
<div class="w-full h-48 bg-slate-700"></div>
<div class="p-4 space-y-3">
<div class="h-4 bg-slate-700 rounded w-3/4"></div>
<div class="h-4 bg-slate-700 rounded w-1/2"></div>
</div>
</div>
}
</div>
} @else {
<div class="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-x-6 gap-y-8">
@for (video of trendingVideos(); track video.videoId) {
<a [routerLink]="['/watch', video.videoId]" [queryParams]="watchQueryParams(video)" [state]="{ video }" class="group flex flex-col bg-slate-800/50 rounded-lg overflow-hidden hover:bg-slate-700/50 transition-all duration-300 transform hover:-translate-y-1">
<div class="relative">
<img [src]="video.thumbnail" [alt]="video.title" class="w-full h-48 object-cover transition-transform duration-300 group-hover:scale-105">
<div class="absolute bottom-2 right-2 bg-black/75 text-white text-xs px-2 py-1 rounded">
{{ video.duration / 60 | number:'1.0-0' }}:{{ (video.duration % 60) | number:'2.0-0' }}
</div>
</div>
<div class="p-4 flex-grow flex flex-col">
<h3 class="font-semibold text-slate-100 group-hover:text-red-400 transition-colors duration-200 line-clamp-2">{{ video.title }}</h3>
<div class="mt-2 flex items-center space-x-3 text-sm text-slate-400">
<img [src]="video.uploaderAvatar" [alt]="video.uploaderName" class="w-8 h-8 rounded-full">
<span>{{ video.uploaderName }}</span>
</div>
<div class="mt-auto pt-2 text-sm text-slate-400">
<span>{{ formatViews(video.views) }} visionnements</span> &bull; <span>{{ formatRelative(video.uploadedDate) }}</span>
</div>
</div>
</a>
}
</div>
<!-- Infinite scroll anchor -->
<app-infinite-anchor class="mt-6"
[disabled]="!nextCursor()"
[busy]="busyMore()"
(loadMore)="fetchNextPage()"></app-infinite-anchor>
@if (busyMore()) {
<div class="flex items-center justify-center py-4 text-slate-400">
<svg class="animate-spin h-5 w-5 mr-2" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><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 12h4z"></path></svg>
{{ 'loading.more' | t }}
</div>
}
}
</div>
+126
View File
@@ -0,0 +1,126 @@
import { ChangeDetectionStrategy, Component, effect, inject, signal, untracked } from '@angular/core';
import { CommonModule } from '@angular/common';
import { RouterLink } from '@angular/router';
import { YoutubeApiService } from '../../services/youtube-api.service';
import { Video } from '../../models/video.model';
import { InstanceService } from '../../services/instance.service';
import { InfiniteAnchorComponent } from '../shared/infinite-anchor/infinite-anchor.component';
import { formatRelativeFr } from '../../utils/date.util';
import { TranslatePipe } from '../../pipes/translate.pipe';
@Component({
selector: 'app-home',
templateUrl: './home.component.html',
standalone: true,
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [CommonModule, RouterLink, InfiniteAnchorComponent, TranslatePipe]
})
export class HomeComponent {
private apiService = inject(YoutubeApiService);
private instances = inject(InstanceService);
trendingVideos = signal<Video[]>([]);
loading = signal(true);
busyMore = signal(false);
nextCursor = signal<string | null>(null);
notice = signal<string | null>(null);
constructor() {
this.reloadTrending();
// React to provider/region changes to refresh trending automatically
effect(() => {
// Rerun when these signals change
const provider = this.instances.selectedProvider();
const region = this.instances.region();
const ptInstance = this.instances.activePeerTubeInstance();
// Reset notice and trigger reload, but without re-triggering the effect
untracked(() => {
this.notice.set(null);
this.reloadTrending();
});
}, { allowSignalWrites: true });
}
reloadTrending() {
const readiness = this.instances.getProviderReadiness();
if (!readiness.ready) {
this.notice.set(readiness.reason || 'Le provider sélectionné n\'est pas prêt.');
this.trendingVideos.set([]);
this.nextCursor.set(null);
this.loading.set(false);
return;
}
this.loading.set(true);
this.trendingVideos.set([]);
this.nextCursor.set(null);
// Important: reset busy flag so a previous pending state doesn't block new loads
this.busyMore.set(false);
this.fetchNextPage();
}
fetchNextPage() {
if (this.busyMore()) return;
const readiness = this.instances.getProviderReadiness();
if (!readiness.ready) {
if (!this.notice()) this.notice.set(readiness.reason || 'Le provider sélectionné n\'est pas prêt.');
return;
}
this.busyMore.set(true);
this.apiService.getTrendingPage(this.nextCursor()).subscribe(res => {
const merged = [...this.trendingVideos(), ...res.items];
this.trendingVideos.set(merged);
this.nextCursor.set(res.nextCursor || null);
this.busyMore.set(false);
this.loading.set(false);
const provider = this.instances.selectedProvider();
if (merged.length === 0 && !this.notice()) {
const readiness2 = this.instances.getProviderReadiness();
if (!readiness2.ready) {
this.notice.set(readiness2.reason || 'Le provider sélectionné n\'est pas prêt.');
} else if (provider === 'youtube') {
this.notice.set('Aucune vidéo tendance YouTube chargée. Vérifiez que votre YOUTUBE_API_KEY est valide et que les restrictions HTTP referrer incluent http://localhost:4200/*.');
} else if (provider === 'peertube') {
const inst = this.instances.activePeerTubeInstance();
this.notice.set(`PeerTube: les vidéos ne sont pas disponibles depuis l'instance "${inst}" pour le moment. Essayez une autre instance dans l'en-tête.`);
} else if (provider === 'rumble') {
const label = this.instances.selectedProviderLabel();
this.notice.set(`Les vidéos ne sont pas disponibles pour le provider "${label}" pour le moment. Réessayez plus tard ou choisissez un autre provider.`);
}
}
});
}
formatViews(views: number): string {
if (views >= 1_000_000_000) {
return (views / 1_000_000_000).toFixed(1) + 'B';
}
if (views >= 1_000_000) {
return (views / 1_000_000).toFixed(1) + 'M';
}
if (views >= 1_000) {
return (views / 1_000).toFixed(1) + 'K';
}
return views.toString();
}
// Relative date for cards (e.g., "il y a 2 heures")
formatRelative(dateIso?: string): string {
if (!dateIso) return '';
return formatRelativeFr(dateIso);
}
// Build query params for Watch page (provider + optional odysee slug)
watchQueryParams(v: Video): Record<string, any> | null {
const p = this.instances.selectedProvider();
const qp: any = { p };
if (p === 'odysee' && v.url?.startsWith('https://odysee.com/')) {
let slug = v.url.substring('https://odysee.com/'.length);
if (slug.startsWith('/')) slug = slug.slice(1);
qp.slug = slug;
}
return qp;
}
}
@@ -0,0 +1,119 @@
<div class="container mx-auto p-4 sm:p-6">
<h1 class="text-3xl font-bold mb-6 text-slate-100 border-l-4 border-red-500 pl-4">🧭 Guide d'utilisation</h1>
<div class="grid grid-cols-1 gap-6">
<!-- Introduction -->
<section class="bg-slate-800/50 rounded-xl border border-slate-700 overflow-hidden">
<div class="px-5 py-4 border-b border-slate-700">
<h2 class="text-lg font-semibold text-slate-100">👋 Bienvenue sur NewTube</h2>
<p class="text-sm text-slate-400">Cette page explique comment utiliser le site, découvrir des contenus, gérer vos préférences et tirer le meilleur parti des fournisseurs comme PeerTube, YouTube, Twitch, etc. 🎬</p>
</div>
<div class="p-5 space-y-4 text-slate-200">
<p>Utilisez le panneau de navigation à gauche pour accéder rapidement aux thèmes, aux tendances, à vos abonnements et à cette page d'aide. 🧰</p>
<ul class="space-y-1 text-slate-300">
<li>🏠 <span class="text-slate-200 font-medium">Accueil</span> — contenus populaires selon le thème sélectionné.</li>
<li>🎞️ <span class="text-slate-200 font-medium">Shorts</span> — vidéos courtes dans un lecteur optimisé.</li>
<li>📚 <span class="text-slate-200 font-medium">Bibliothèque</span> — playlists, vidéos aimées et abonnements.</li>
<li>⚙️ <span class="text-slate-200 font-medium">Compte</span> — préférences, historique et gestion des sessions.</li>
</ul>
<div class="mt-4 grid grid-cols-1 md:grid-cols-3 gap-3">
<div class="rounded-lg border border-slate-700 bg-slate-900/40 p-4">
<div class="text-xl">🔍</div>
<div class="mt-1 text-sm text-slate-300">Recherchez et explorez par thèmes</div>
</div>
<div class="rounded-lg border border-slate-700 bg-slate-900/40 p-4">
<div class="text-xl">🧩</div>
<div class="mt-1 text-sm text-slate-300">Choisissez un fournisseur (YouTube, PeerTube...)</div>
</div>
<div class="rounded-lg border border-slate-700 bg-slate-900/40 p-4">
<div class="text-xl">🎛️</div>
<div class="mt-1 text-sm text-slate-300">Personnalisez vos préférences</div>
</div>
</div>
</div>
</section>
<!-- Recherche -->
<section class="bg-slate-800/50 rounded-xl border border-slate-700 overflow-hidden">
<div class="px-5 py-4 border-b border-slate-700">
<h2 class="text-lg font-semibold text-slate-100">🔎 Rechercher des vidéos</h2>
<p class="text-sm text-slate-400">Cherchez sur plusieurs plateformes depuis une seule barre de recherche.</p>
</div>
<div class="p-5 space-y-3 text-slate-200">
<ul class="space-y-1 text-slate-300">
<li>⌨️ Tapez votre requête et appuyez sur Entrée.</li>
<li>🏷️ Filtrez par thèmes via la barre située sous l'en-tête.</li>
<li>▶️ Ouvrez une vidéo pour afficher la page de lecture et les actions.</li>
</ul>
<div class="mt-3 rounded-lg border border-sky-700/60 bg-sky-900/20 px-4 py-3 text-sky-200">
💡 Astuce&nbsp;: combinez un thème et un mot-clé (ex.&nbsp;: «&nbsp;Tech IA&nbsp;») pour des résultats plus pertinents.
</div>
</div>
</section>
<!-- Thèmes et Fournisseurs -->
<section class="bg-slate-800/50 rounded-xl border border-slate-700 overflow-hidden">
<div class="px-5 py-4 border-b border-slate-700">
<h2 class="text-lg font-semibold text-slate-100">🧩 Thèmes et Fournisseurs</h2>
<p class="text-sm text-slate-400">Parcourez par centres d'intérêt et choisissez un fournisseur de contenus.</p>
</div>
<div class="p-5 space-y-3 text-slate-200">
<ul class="space-y-1 text-slate-300">
<li>🧭 <span class="text-slate-200 font-medium">Explorer</span> — liste de thèmes (Sport, Tech, Jeux, etc.).</li>
<li>🎥 <span class="text-slate-200 font-medium">Fournisseurs</span> — YouTube, Dailymotion, Twitch, Rumble, Odysee et PeerTube.</li>
<li>🟠 <span class="text-slate-200 font-medium">PeerTube</span> — gérez et basculez entre vos instances dans les Préférences.</li>
</ul>
</div>
</section>
<!-- PeerTube: Ressource utile -->
<section class="bg-slate-800/50 rounded-xl border border-slate-700 overflow-hidden">
<div class="px-5 py-4 border-b border-slate-700 flex items-center justify-between">
<div>
<h2 class="text-lg font-semibold text-slate-100">🟠 Trouver des instances PeerTube</h2>
<p class="text-sm text-slate-400">Découvrez de nouvelles instances PeerTube publiques et actives.</p>
</div>
<a class="px-4 py-2 rounded bg-emerald-600 hover:bg-emerald-500 text-white" href="https://joinpeertube.org/" target="_blank" rel="noopener noreferrer">Ouvrir joinpeertube.org ↗️</a>
</div>
<div class="p-5 text-slate-200">
<p>Une instance PeerTube est un serveur hébergeant des vidéos. Vous pouvez en ajouter plusieurs dans <span class="font-medium">Compte → Préférences → PeerTube</span> puis choisir celle active. Pour en trouver, consultez le <a class="text-sky-400 hover:text-sky-300 underline" href="https://joinpeertube.org/" target="_blank" rel="noopener noreferrer">répertoire des instances</a>.</p>
</div>
</section>
<!-- Confidentialité et Paramètres -->
<section class="bg-slate-800/50 rounded-xl border border-slate-700 overflow-hidden">
<div class="px-5 py-4 border-b border-slate-700">
<h2 class="text-lg font-semibold text-slate-100">🔐 Confidentialité et paramètres</h2>
<p class="text-sm text-slate-400">Personnalisez votre expérience dans la page Préférences.</p>
</div>
<div class="p-5 space-y-2 text-slate-200">
<ul class="space-y-1 text-slate-300">
<li>🌐 Choisissez votre langue, thème visuel et qualité vidéo par défaut.</li>
<li>🎯 Définissez un fournisseur par défaut et gérez vos instances PeerTube.</li>
<li>🕑 Consultez et effacez l'historique de visionnage dans <span class="font-medium">Compte → Historique</span>.</li>
</ul>
<div class="mt-3 rounded-lg border border-amber-600/60 bg-amber-900/20 px-4 py-3 text-amber-200">
⚠️ Conseil&nbsp;: si une instance PeerTube est lente, essayez d'en définir une autre comme active.
</div>
</div>
</section>
<!-- Aide supplémentaire -->
<section class="bg-slate-800/50 rounded-xl border border-slate-700 overflow-hidden">
<div class="px-5 py-4 border-b border-slate-700">
<h2 class="text-lg font-semibold text-slate-100">🆘 Besoin d'aide ?</h2>
<p class="text-sm text-slate-400">Signalez un problème ou consultez le code source.</p>
</div>
<div class="p-5 text-slate-200 space-y-2">
<p>Le dépôt du projet est disponible sur notre instance Gitea&nbsp;:</p>
<p>
<a class="inline-flex items-center gap-2 px-3 py-2 rounded bg-slate-700 hover:bg-slate-600 text-white"
href="https://git.dracodev.net/Projets/NewTube" target="_blank" rel="noopener noreferrer">
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" viewBox="0 0 24 24" fill="currentColor"><path d="M12 2a10 10 0 1 0 10 10A10.011 10.011 0 0 0 12 2zm1 14.59V20h-2v-3.41a4.994 4.994 0 0 1-4-4.9V7h2v4.69A3 3 0 0 0 12 14a3 3 0 0 0 3-2.31V7h2v4.69a4.994 4.994 0 0 1-4 4.9z"/></svg>
Ouvrir Gitea ↗️
</a>
</p>
</div>
</section>
</div>
</div>
@@ -0,0 +1,11 @@
import { ChangeDetectionStrategy, Component } from '@angular/core';
import { CommonModule } from '@angular/common';
@Component({
selector: 'app-utilisation',
standalone: true,
imports: [CommonModule],
templateUrl: './utilisation.component.html',
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class UtilisationComponent {}
@@ -0,0 +1,6 @@
<div class="container mx-auto p-4 sm:p-6">
<h2 class="text-3xl font-bold mb-6 text-slate-100 border-l-4 border-red-500 pl-4">Vidéos que vous aimez</h2>
<div class="bg-slate-800/50 rounded-lg p-6 border border-slate-700">
<p class="text-slate-300">Ici apparaîtront vos vidéos aimées (tous providers). Fonctionnalité à venir.</p>
</div>
</div>
@@ -0,0 +1,11 @@
import { ChangeDetectionStrategy, Component } from '@angular/core';
import { CommonModule } from '@angular/common';
@Component({
selector: 'app-library-liked',
standalone: true,
imports: [CommonModule],
templateUrl: './liked.component.html',
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class LikedComponent { }
@@ -0,0 +1,6 @@
<div class="container mx-auto p-4 sm:p-6">
<h2 class="text-3xl font-bold mb-6 text-slate-100 border-l-4 border-red-500 pl-4">Listes de lecture</h2>
<div class="bg-slate-800/50 rounded-lg p-6 border border-slate-700">
<p class="text-slate-300">Bientôt disponible: vos listes de lecture centralisées, tous providers confondus.</p>
</div>
</div>
@@ -0,0 +1,11 @@
import { ChangeDetectionStrategy, Component } from '@angular/core';
import { CommonModule } from '@angular/common';
@Component({
selector: 'app-library-playlists',
standalone: true,
imports: [CommonModule],
templateUrl: './playlists.component.html',
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class PlaylistsComponent { }
@@ -0,0 +1,6 @@
<div class="container mx-auto p-4 sm:p-6">
<h2 class="text-3xl font-bold mb-6 text-slate-100 border-l-4 border-red-500 pl-4">Abonnements</h2>
<div class="bg-slate-800/50 rounded-lg p-6 border border-slate-700">
<p class="text-slate-300">Section pour suivre vos abonnements (à implémenter). Vous verrez ici les dernières vidéos des chaînes suivies sur chaque provider.</p>
</div>
</div>
@@ -0,0 +1,11 @@
import { ChangeDetectionStrategy, Component } from '@angular/core';
import { CommonModule } from '@angular/common';
@Component({
selector: 'app-library-subscriptions',
standalone: true,
imports: [CommonModule],
templateUrl: './subscriptions.component.html',
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class SubscriptionsComponent { }
+151
View File
@@ -0,0 +1,151 @@
<div class="container mx-auto p-4 sm:p-6">
<h2 class="text-3xl font-bold mb-6 text-slate-100 border-l-4 border-red-500 pl-4">{{ pageHeading() }}</h2>
@if (notice()) {
<div class="mb-4 bg-amber-900/40 border border-amber-600 text-amber-200 p-3 rounded">
{{ notice() }}
</div>
}
@if (!hasQuery()) {
<p class="text-slate-400">{{ 'search.hint' | t }}</p>
}
@if (hasQuery() && availableTags().length > 0) {
<div class="mb-4 flex items-center gap-2 overflow-x-auto pb-1">
@for (tag of availableTags(); track tag.key) {
<button
class="px-3 py-1 rounded-full text-sm whitespace-nowrap border transition-colors"
[ngClass]="{
'bg-slate-200 text-slate-900 border-slate-200': filterTag() === tag.key,
'bg-slate-800 text-slate-200 border-slate-600 hover:bg-slate-700': filterTag() !== tag.key
}"
(click)="setFilterTag(tag.key)">
{{ tag.label }}
</button>
}
</div>
}
@if (loading()) {
<div class="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-6">
@for (item of [1,2,3,4,5,6,7,8]; track item) {
<div class="animate-pulse bg-slate-800 rounded-lg overflow-hidden">
<div class="w-full h-48 bg-slate-700"></div>
<div class="p-4 space-y-3">
<div class="h-4 bg-slate-700 rounded w-3/4"></div>
<div class="h-4 bg-slate-700 rounded w-1/2"></div>
</div>
</div>
}
</div>
} @else if (selectedProviderForView() === 'twitch') {
<!-- Twitch: two sections -->
<div class="space-y-10">
<!-- Live Channels -->
<section *ngIf="filterTag() === 'twitch_all' || filterTag() === 'twitch_live'">
<h3 class="text-xl font-semibold text-slate-200 mb-3">Chaînes en direct</h3>
@if (twitchChannels().length > 0) {
<div class="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-x-6 gap-y-8">
@for (video of twitchChannels(); track video.videoId) {
<a [routerLink]="['/watch', video.videoId]" [queryParams]="watchQueryParams(video)" [state]="{ video }" class="group flex flex-col bg-slate-800/50 rounded-lg overflow-hidden hover:bg-slate-700/50 transition-all duration-300 transform hover:-translate-y-1">
<div class="relative">
<img [src]="video.thumbnail" [alt]="video.title" class="w-full h-48 object-cover transition-transform duration-300 group-hover:scale-105">
<div class="absolute top-2 left-2 bg-black/75 text-white text-xs px-2 py-1 rounded">
{{ formatViews(video.views) }} en direct
</div>
</div>
<div class="p-4 flex-grow flex flex-col">
<h4 class="font-semibold text-slate-100 group-hover:text-red-400 transition-colors duration-200 line-clamp-2">{{ video.title }}</h4>
<div class="mt-2 flex items-center space-x-3 text-sm text-slate-400">
<img [src]="video.uploaderAvatar" [alt]="video.uploaderName" class="w-8 h-8 rounded-full">
<span>{{ video.uploaderName }}</span>
</div>
</div>
</a>
}
</div>
@if (twitchCursorChannels()) {
<div class="mt-4 flex justify-center">
<button class="px-4 py-2 rounded bg-slate-700 hover:bg-slate-600 text-slate-100 disabled:opacity-50" (click)="loadMoreTwitchChannels()" [disabled]="twitchBusyChannels()">Afficher plus</button>
</div>
}
} @else {
<p class="text-slate-400">Aucune chaîne en direct trouvée.</p>
}
</section>
<!-- Past Videos (VODs) -->
<section *ngIf="filterTag() === 'twitch_all' || filterTag() === 'twitch_vod'">
<h3 class="text-xl font-semibold text-slate-200 mb-3">Vidéos (VOD)</h3>
@if (twitchVods().length > 0) {
<div class="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-x-6 gap-y-8">
@for (video of twitchVods(); track video.videoId) {
<a [routerLink]="['/watch', video.videoId]" [queryParams]="watchQueryParams(video)" [state]="{ video }" class="group flex flex-col bg-slate-800/50 rounded-lg overflow-hidden hover:bg-slate-700/50 transition-all duration-300 transform hover:-translate-y-1">
<div class="relative">
<img [src]="video.thumbnail" [alt]="video.title" class="w-full h-48 object-cover transition-transform duration-300 group-hover:scale-105">
<div class="absolute bottom-2 right-2 bg-black/75 text-white text-xs px-2 py-1 rounded">
{{ video.duration / 60 | number:'1.0-0' }}:{{ (video.duration % 60) | number:'2.0-0' }}
</div>
</div>
<div class="p-4 flex-grow flex flex-col">
<h4 class="font-semibold text-slate-100 group-hover:text-red-400 transition-colors duration-200 line-clamp-2">{{ video.title }}</h4>
<div class="mt-2 flex items-center space-x-3 text-sm text-slate-400">
<img [src]="video.uploaderAvatar" [alt]="video.uploaderName" class="w-8 h-8 rounded-full">
<span>{{ video.uploaderName }}</span>
</div>
<div class="mt-auto pt-2 text-sm text-slate-400">
<span>{{ formatViews(video.views) }} visionnements</span> &bull; <span>{{ formatRelative(video.uploadedDate) }}</span>
</div>
</div>
</a>
}
</div>
@if (twitchCursorVods()) {
<div class="mt-4 flex justify-center">
<button class="px-4 py-2 rounded bg-slate-700 hover:bg-slate-600 text-slate-100 disabled:opacity-50" (click)="loadMoreTwitchVods()" [disabled]="twitchBusyVods()">Afficher plus</button>
</div>
}
} @else {
<p class="text-slate-400">Aucune vidéo VOD trouvée.</p>
}
</section>
</div>
} @else if (results().length > 0) {
<div class="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-x-6 gap-y-8">
@for (video of filteredResults(); track video.videoId) {
<a [routerLink]="['/watch', video.videoId]" [queryParams]="watchQueryParams(video)" [state]="{ video }" class="group flex flex-col bg-slate-800/50 rounded-lg overflow-hidden hover:bg-slate-700/50 transition-all duration-300 transform hover:-translate-y-1">
<div class="relative">
<img [src]="video.thumbnail" [alt]="video.title" class="w-full h-48 object-cover transition-transform duration-300 group-hover:scale-105">
<div class="absolute bottom-2 right-2 bg-black/75 text-white text-xs px-2 py-1 rounded">
{{ video.duration / 60 | number:'1.0-0' }}:{{ (video.duration % 60) | number:'2.0-0' }}
</div>
</div>
<div class="p-4 flex-grow flex flex-col">
<h3 class="font-semibold text-slate-100 group-hover:text-red-400 transition-colors duration-200 line-clamp-2">{{ video.title }}</h3>
<div class="mt-2 flex items-center space-x-3 text-sm text-slate-400">
<img [src]="video.uploaderAvatar" [alt]="video.uploaderName" class="w-8 h-8 rounded-full">
<span>{{ video.uploaderName }}</span>
</div>
<div class="mt-auto pt-2 text-sm text-slate-400">
<span>{{ formatViews(video.views) }} visionnements</span> &bull; <span>{{ formatRelative(video.uploadedDate) }}</span>
</div>
</div>
</a>
}
</div>
<!-- Infinite scroll anchor -->
<app-infinite-anchor class="mt-6"
[disabled]="!nextCursor()"
[busy]="busyMore()"
(loadMore)="fetchNextPage()"></app-infinite-anchor>
@if (busyMore()) {
<div class="flex items-center justify-center py-4 text-slate-400">
<svg class="animate-spin h-5 w-5 mr-2" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><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 12h4z"></path></svg>
{{ 'loading.more' | t }}
</div>
}
} @else if (hasQuery()) {
<p class="text-slate-400">{{ 'search.noResults' | t }}</p>
}
</div>
+359
View File
@@ -0,0 +1,359 @@
import { ChangeDetectionStrategy, Component, computed, effect, inject, signal, untracked } from '@angular/core';
import { CommonModule } from '@angular/common';
import { ActivatedRoute, RouterLink } from '@angular/router';
import { YoutubeApiService } from '../../services/youtube-api.service';
import { Video } from '../../models/video.model';
import { InfiniteAnchorComponent } from '../shared/infinite-anchor/infinite-anchor.component';
import { formatRelativeFr } from '../../utils/date.util';
import { InstanceService, Provider } from '../../services/instance.service';
import { HistoryService } from '../../services/history.service';
import { Title } from '@angular/platform-browser';
import { TranslatePipe } from '../../pipes/translate.pipe';
@Component({
selector: 'app-search',
standalone: true,
templateUrl: './search.component.html',
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [CommonModule, RouterLink, InfiniteAnchorComponent, TranslatePipe]
})
export class SearchComponent {
private route = inject(ActivatedRoute);
private api = inject(YoutubeApiService);
private instances = inject(InstanceService);
private history = inject(HistoryService);
private title = inject(Title);
q = signal<string>('');
loading = signal<boolean>(false);
results = signal<Video[]>([]);
busyMore = signal<boolean>(false);
nextCursor = signal<string | null>(null);
// Twitch-specific dual lists
twitchChannels = signal<Video[]>([]);
twitchVods = signal<Video[]>([]);
twitchCursorChannels = signal<string | null>(null);
twitchCursorVods = signal<string | null>(null);
twitchBusyChannels = signal<boolean>(false);
twitchBusyVods = signal<boolean>(false);
notice = signal<string | null>(null);
providerParam = signal<Provider | null>(null);
themeParam = signal<string | null>(null);
hasQuery = computed(() => this.q().length > 0);
providerLabel = computed(() => {
const param = this.providerParam();
if (param) {
const p = this.instances.providers().find(p => p.id === param);
return p?.label || param;
}
return this.instances.selectedProviderLabel();
});
pageHeading = computed(() => `Search - ${this.providerLabel()}${this.q() ? ' - ' + this.q() : ''}`);
// Public computed used by the template to avoid referencing private `instances`
selectedProviderForView = computed(() => this.providerParam() || this.instances.selectedProvider());
// Active tag filter. For non-Twitch providers: 'all' | 'short' | 'medium' | 'long' | 'recent'.
// For Twitch provider: 'twitch_all' | 'twitch_live' | 'twitch_vod'.
filterTag = signal<string>('all');
// Helper computed: filtered list based on active tag (non-Twitch only)
filteredResults = computed(() => {
const tag = this.filterTag();
const provider = this.selectedProviderForView();
const list = this.results();
if (provider === 'twitch') return list; // Not used for Twitch (separate sections)
if (tag === 'all') return list;
const now = Date.now();
const oneDay = 24 * 60 * 60 * 1000;
const sevenDays = 7 * oneDay;
const thirtyDays = 30 * oneDay;
const oneYear = 365 * oneDay;
// Duration filters
if (tag === 'short') return list.filter(v => {
const d = Number(v.duration || 0);
return d > 0 && d < 4 * 60;
});
if (tag === 'medium') return list.filter(v => {
const d = Number(v.duration || 0);
return d >= 4 * 60 && d < 20 * 60;
});
if (tag === 'long') return list.filter(v => {
const d = Number(v.duration || 0);
return d >= 20 * 60;
});
// Date filters
if (tag === 'today') return list.filter(v => {
const uploadTime = typeof v.uploaded === 'number' ? v.uploaded : 0;
return uploadTime > 0 && (now - uploadTime) <= oneDay;
});
if (tag === 'this_week') return list.filter(v => {
const uploadTime = typeof v.uploaded === 'number' ? v.uploaded : 0;
return uploadTime > 0 && (now - uploadTime) <= sevenDays;
});
if (tag === 'this_month') return list.filter(v => {
const uploadTime = typeof v.uploaded === 'number' ? v.uploaded : 0;
return uploadTime > 0 && (now - uploadTime) <= thirtyDays;
});
if (tag === 'this_year') return list.filter(v => {
const uploadTime = typeof v.uploaded === 'number' ? v.uploaded : 0;
return uploadTime > 0 && (now - uploadTime) <= oneYear;
});
return list;
});
// Available tags computed from current results (only show tags that can apply)
availableTags = computed(() => {
const provider = this.selectedProviderForView();
const tags: { key: string; label: string; show: boolean }[] = [];
const now = Date.now();
const list = this.results();
if (provider === 'twitch') {
const hasLive = this.twitchChannels().length > 0;
const hasVods = this.twitchVods().length > 0;
// Show the group if at least one of the sections is available
if (hasLive || hasVods) {
tags.push({ key: 'twitch_all', label: 'Tout', show: hasLive && hasVods });
tags.push({ key: 'twitch_live', label: 'En direct', show: hasLive });
tags.push({ key: 'twitch_vod', label: 'VOD', show: hasVods });
}
return tags.filter(t => t.show);
}
// Non-Twitch providers: duration + date filters
const oneDay = 24 * 60 * 60 * 1000;
const sevenDays = 7 * oneDay;
const thirtyDays = 30 * oneDay;
const oneYear = 365 * oneDay;
let hasShort = false, hasMedium = false, hasLong = false,
hasToday = false, hasThisWeek = false, hasThisMonth = false, hasThisYear = false;
for (const v of list) {
const d = Number(v.duration || 0);
const uploadTime = typeof v.uploaded === 'number' ? v.uploaded : 0;
const timeDiff = now - uploadTime;
// Duration filters
if (d > 0 && d < 4 * 60) hasShort = true;
else if (d >= 4 * 60 && d < 20 * 60) hasMedium = true;
else if (d >= 20 * 60) hasLong = true;
// Date filters
if (uploadTime > 0) {
if (timeDiff <= oneDay) hasToday = true;
if (timeDiff <= sevenDays) hasThisWeek = true;
if (timeDiff <= thirtyDays) hasThisMonth = true;
if (timeDiff <= oneYear) hasThisYear = true;
}
}
// Add duration filters
tags.push({ key: 'all', label: 'Tout', show: list.length > 0 });
tags.push({ key: 'short', label: 'Moins de 4 min', show: hasShort });
tags.push({ key: 'medium', label: 'De 4 à 20 min', show: hasMedium });
tags.push({ key: 'long', label: '20 min et plus', show: hasLong });
// Add date filters
tags.push({ key: 'today', label: 'Aujourd\'hui', show: hasToday });
tags.push({ key: 'this_week', label: 'Cette semaine', show: hasThisWeek });
tags.push({ key: 'this_month', label: 'Ce mois-ci', show: hasThisMonth });
tags.push({ key: 'this_year', label: 'Cette année', show: hasThisYear });
return tags.filter(t => t.show);
});
constructor() {
// Update document title when provider or query changes
effect(() => {
this.title.setTitle(this.pageHeading());
});
// Listen to query param changes (so subsequent searches update)
this.route.queryParamMap.subscribe((pm) => {
const q = (pm.get('q') || '').trim();
this.q.set(q);
const prov = (pm.get('provider') as Provider) || null;
const theme = pm.get('theme');
this.providerParam.set(prov);
this.themeParam.set(theme);
if (q) {
this.notice.set(null);
// Reset active tag on new query
const provider = (prov || this.instances.selectedProvider());
this.filterTag.set(provider === 'twitch' ? 'twitch_all' : 'all');
this.reloadSearch();
} else {
this.results.set([]);
this.nextCursor.set(null);
this.loading.set(false);
}
});
// React to provider/region/PeerTube instance changes to refresh search automatically
effect(() => {
const provider = this.instances.selectedProvider();
const region = this.instances.region();
const ptInstance = this.instances.activePeerTubeInstance();
untracked(() => {
// If provider is explicitly specified in query, do not override it with global changes
if (this.providerParam()) return;
if (this.q()) {
this.notice.set(null);
this.reloadSearch();
}
});
}, { allowSignalWrites: true });
}
reloadSearch() {
const readiness = this.instances.getProviderReadiness(this.providerParam() || undefined as any);
if (!readiness.ready) {
this.notice.set(readiness.reason || 'Le provider sélectionné n\'est pas prêt.');
this.results.set([]);
this.nextCursor.set(null);
this.twitchChannels.set([]);
this.twitchVods.set([]);
this.twitchCursorChannels.set(null);
this.twitchCursorVods.set(null);
this.loading.set(false);
return;
}
this.loading.set(true);
this.results.set([]);
this.nextCursor.set(null);
this.twitchChannels.set([]);
this.twitchVods.set([]);
this.twitchCursorChannels.set(null);
this.twitchCursorVods.set(null);
const provider = this.providerParam() || this.instances.selectedProvider();
// Record search term with provider once per reload
try {
this.history.recordSearch(this.q(), { provider }).subscribe({
next: () => {},
error: (err) => console.error('Error recording search:', err)
});
} catch (err) {
console.error('Error in recordSearch:', err);
}
// Ensure default tag matches provider
this.filterTag.set(provider === 'twitch' ? 'twitch_all' : 'all');
if (provider === 'twitch') {
// Load both sections in parallel
this.twitchBusyChannels.set(true);
this.twitchBusyVods.set(true);
this.api.searchTwitchChannelsPage(this.q(), null).subscribe(res => {
this.twitchChannels.set(res.items);
this.twitchCursorChannels.set(res.nextCursor || null);
this.twitchBusyChannels.set(false);
this.loading.set(false);
});
this.api.searchTwitchVodsPage(this.q(), null).subscribe(res => {
this.twitchVods.set(res.items);
this.twitchCursorVods.set(res.nextCursor || null);
this.twitchBusyVods.set(false);
this.loading.set(false);
});
} else {
this.fetchNextPage();
}
}
fetchNextPage() {
if (this.busyMore() || !this.q()) return;
const readiness = this.instances.getProviderReadiness(this.providerParam() || undefined as any);
if (!readiness.ready) {
if (!this.notice()) this.notice.set(readiness.reason || 'Le provider sélectionné n\'est pas prêt.');
return;
}
this.busyMore.set(true);
const providerOverride = this.providerParam();
this.api.searchVideosPage(this.q(), this.nextCursor(), providerOverride as any).subscribe(res => {
const merged = [...this.results(), ...res.items];
this.results.set(merged);
this.nextCursor.set(res.nextCursor || null);
this.busyMore.set(false);
this.loading.set(false);
// Provider/instance availability notices (avoid overriding legitimate no-results unless clear)
if (merged.length === 0 && !this.notice()) {
const readiness2 = this.instances.getProviderReadiness();
if (!readiness2.ready) {
this.notice.set(readiness2.reason || 'Le provider sélectionné n\'est pas prêt.');
} else {
const provider = this.providerParam() || this.instances.selectedProvider();
if (provider === 'peertube') {
const inst = this.instances.activePeerTubeInstance();
this.notice.set(`PeerTube: les vidéos ne sont pas disponibles depuis l'instance "${inst}" pour le moment. Essayez une autre instance dans l'en-tête.`);
} else if (provider === 'rumble') {
const label = this.instances.selectedProviderLabel();
this.notice.set(`Les vidéos ne sont pas disponibles pour le provider "${label}" pour le moment. Réessayez plus tard ou choisissez un autre provider.`);
}
}
}
});
}
loadMoreTwitchChannels() {
if (this.twitchBusyChannels() || !this.twitchCursorChannels()) return;
this.twitchBusyChannels.set(true);
this.api.searchTwitchChannelsPage(this.q(), this.twitchCursorChannels()).subscribe(res => {
this.twitchChannels.set([...this.twitchChannels(), ...res.items]);
this.twitchCursorChannels.set(res.nextCursor || null);
this.twitchBusyChannels.set(false);
});
}
loadMoreTwitchVods() {
if (this.twitchBusyVods() || !this.twitchCursorVods()) return;
this.twitchBusyVods.set(true);
this.api.searchTwitchVodsPage(this.q(), this.twitchCursorVods()).subscribe(res => {
this.twitchVods.set([...this.twitchVods(), ...res.items]);
this.twitchCursorVods.set(res.nextCursor || null);
this.twitchBusyVods.set(false);
});
}
formatViews(views: number): string {
if (views >= 1_000_000_000) return (views / 1_000_000_000).toFixed(1) + 'B';
if (views >= 1_000_000) return (views / 1_000_000).toFixed(1) + 'M';
if (views >= 1_000) return (views / 1_000).toFixed(1) + 'K';
return views.toString();
}
formatRelative(dateIso?: string): string {
if (!dateIso) return '';
return formatRelativeFr(dateIso);
}
// Build query params for Watch page (provider + optional odysee slug)
watchQueryParams(v: Video): Record<string, any> | null {
const p = this.providerParam() || this.instances.selectedProvider();
const qp: any = { p };
if (p === 'odysee' && v.url?.startsWith('https://odysee.com/')) {
let slug = v.url.substring('https://odysee.com/'.length);
if (slug.startsWith('/')) slug = slug.slice(1);
qp.slug = slug;
}
if (p === 'twitch' && v.type === 'channel') {
// Extract channel login from uploaderUrl if available
const url = v.uploaderUrl || v.url || '';
try {
const u = new URL(url);
const parts = u.pathname.split('/').filter(Boolean);
if (parts.length > 0) qp.channel = parts[0];
} catch {}
}
return qp;
}
// Update active filter tag from the template
setFilterTag(key: string) {
this.filterTag.set(key);
}
}
@@ -0,0 +1,5 @@
<div #sentinel class="w-full h-10 flex items-center justify-center">
<div class="text-slate-400 text-sm" aria-live="polite">
<!-- The parent can show a spinner near the bottom; this anchor is the sentinel only. -->
</div>
</div>
@@ -0,0 +1,42 @@
import { AfterViewInit, ChangeDetectionStrategy, Component, ElementRef, EventEmitter, Input, OnDestroy, Output, ViewChild } from '@angular/core';
import { CommonModule } from '@angular/common';
@Component({
selector: 'app-infinite-anchor',
standalone: true,
templateUrl: './infinite-anchor.component.html',
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [CommonModule]
})
export class InfiniteAnchorComponent implements AfterViewInit, OnDestroy {
@ViewChild('sentinel', { static: true }) sentinelRef!: ElementRef<HTMLDivElement>;
@Input() disabled = false;
@Input() busy = false;
@Input() rootMargin = '300px 0px 300px 0px';
@Input() threshold: number | number[] = 0.01;
@Output() loadMore = new EventEmitter<void>();
private observer?: IntersectionObserver;
ngAfterViewInit(): void {
this.observer = new IntersectionObserver((entries) => {
for (const entry of entries) {
if (entry.isIntersecting && !this.disabled && !this.busy) {
this.loadMore.emit();
}
}
}, {
root: null,
rootMargin: this.rootMargin,
threshold: this.threshold,
});
this.observer.observe(this.sentinelRef.nativeElement);
}
ngOnDestroy(): void {
this.observer?.disconnect();
}
}
@@ -0,0 +1,52 @@
<div class="h-[calc(100vh-4rem)] w-full flex items-center justify-center p-2 sm:p-4"
(wheel)="onWheel($event)" (touchstart)="onTouchStart($event)" (touchend)="onTouchEnd($event)">
<!-- Center column -->
<div class="relative">
<!-- Video frame (9:16) -->
<div class="rounded-xl overflow-hidden shadow-2xl ring-1 ring-slate-700/50 bg-black/90"
style="height: calc(100vh - 6rem); max-height: calc(100vh - 6rem); aspect-ratio: 9 / 16; width: auto; max-width: 92vw;">
@if (embedUrl(); as url) {
<iframe [src]="url" class="w-full h-full" frameborder="0"
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"
allowfullscreen></iframe>
} @else if (loading()) {
<div class="w-full h-full animate-pulse bg-slate-800"></div>
} @else if (error()) {
<div class="w-full h-full flex items-center justify-center text-slate-300">
{{ error() }}
</div>
}
<!-- Bottom overlay: meta -->
@if (current(); as v) {
<div class="absolute left-0 right-0 bottom-0 p-3 sm:p-4 bg-gradient-to-t from-black/60 to-transparent text-white">
<div class="flex items-center gap-3 text-sm">
<img [src]="v.uploaderAvatar" [alt]="v.uploaderName" class="h-8 w-8 rounded-full object-cover">
<div class="flex-1">
<div class="font-semibold line-clamp-1">{{ v.title }}</div>
<div class="opacity-80 line-clamp-1">@{{ v.uploaderName }}</div>
</div>
</div>
</div>
}
</div>
<!-- Navigation buttons -->
<button (click)="prev()" [disabled]="!canPrev()"
class="absolute -right-14 top-6 hidden md:flex items-center justify-center h-10 w-10 rounded-full bg-slate-800/70 hover:bg-slate-700 text-white ring-1 ring-slate-700/60 disabled:opacity-40"
aria-label="Previous short">
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" viewBox="0 0 24 24" fill="currentColor"><path d="M7 14l5-5 5 5H7z"/></svg>
</button>
<button (click)="next()" [disabled]="!canNext()"
class="absolute -right-14 top-20 hidden md:flex items-center justify-center h-10 w-10 rounded-full bg-slate-800/70 hover:bg-slate-700 text-white ring-1 ring-slate-700/60 disabled:opacity-40"
aria-label="Next short">
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" viewBox="0 0 24 24" fill="currentColor"><path d="M7 10l5 5 5-5H7z"/></svg>
</button>
<!-- Mobile next/prev (bottom) -->
<div class="flex md:hidden items-center justify-between mt-3">
<button (click)="prev()" [disabled]="!canPrev()" class="px-3 py-2 rounded bg-slate-800/70 text-white disabled:opacity-40">Prev</button>
<button (click)="next()" [disabled]="!canNext()" class="px-3 py-2 rounded bg-slate-800/70 text-white disabled:opacity-40">Next</button>
</div>
</div>
</div>
@@ -0,0 +1,267 @@
import { ChangeDetectionStrategy, Component, computed, inject, signal, HostListener } from '@angular/core';
import { CommonModule } from '@angular/common';
import { DomSanitizer, SafeResourceUrl } from '@angular/platform-browser';
import { firstValueFrom } from 'rxjs';
import { YoutubeApiService } from '../../services/youtube-api.service';
import { InstanceService } from '../../services/instance.service';
import { Video } from '../../models/video.model';
@Component({
selector: 'app-watch-short',
standalone: true,
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [CommonModule],
templateUrl: './watch-short.component.html'
})
export class WatchShortComponent {
private api = inject(YoutubeApiService);
private instances = inject(InstanceService);
private sanitizer = inject(DomSanitizer);
loading = signal(true);
error = signal<string | null>(null);
items = signal<Video[]>([]);
index = signal(0);
nextCursor = signal<string | null>(null);
busyMore = signal(false);
// scroll/swipe helpers
private wheelAccum = 0;
private lastSwipeY: number | null = null;
private lastScrollTs = 0;
readonly provider = computed(() => this.instances.selectedProvider());
readonly current = computed<Video | null>(() => {
const list = this.items();
const i = this.index();
return (i >= 0 && i < list.length) ? list[i] : null;
});
readonly canPrev = computed(() => this.index() > 0);
readonly canNext = computed(() => this.index() < this.items().length - 1);
readonly embedUrl = computed<SafeResourceUrl | null>(() => {
const v = this.current();
if (!v) return null;
const p = this.provider();
try {
const host = (location && location.hostname) ? location.hostname : 'localhost';
if (p === 'youtube' && v.videoId) {
const qs = 'autoplay=1&mute=1&rel=0&modestbranding=1&playsinline=1&controls=1';
const u = `https://www.youtube.com/embed/${encodeURIComponent(v.videoId)}?${qs}`;
return this.sanitizer.bypassSecurityTrustResourceUrl(u);
}
if (p === 'dailymotion' && v.videoId) {
const u = `https://www.dailymotion.com/embed/video/${encodeURIComponent(v.videoId)}?autoplay=1&mute=1`;
return this.sanitizer.bypassSecurityTrustResourceUrl(u);
}
if (p === 'twitch' && v.videoId) {
const parentsSet = new Set<string>([host, 'localhost', '127.0.0.1']);
const parentParams = Array.from(parentsSet).map(h => `parent=${encodeURIComponent(h)}`).join('&');
const base = 'https://player.twitch.tv/';
const qs = `?channel=${encodeURIComponent(v.videoId)}&${parentParams}&autoplay=false`;
const u = base + qs;
return this.sanitizer.bypassSecurityTrustResourceUrl(u);
}
if (p === 'peertube' && v.videoId) {
const inst = this.instances.activePeerTubeInstance();
const u = `https://${inst}/videos/embed/${encodeURIComponent(v.videoId)}?autoplay=1`;
return this.sanitizer.bypassSecurityTrustResourceUrl(u);
}
if (p === 'odysee') {
// Try to derive slug from URL
let slug: string | null = null;
try {
const url = new URL(v.url || '');
slug = url.pathname.startsWith('/') ? url.pathname.slice(1) : url.pathname;
} catch {}
if (slug || v.videoId) {
const target = slug || v.videoId;
const u = `https://odysee.com/$/embed/${target}?autoplay=1&muted=1`;
return this.sanitizer.bypassSecurityTrustResourceUrl(u);
}
}
if (p === 'rumble' && v.videoId) {
const u = `https://rumble.com/embed/${encodeURIComponent(v.videoId)}/?autoplay=2&muted=1`;
return this.sanitizer.bypassSecurityTrustResourceUrl(u);
}
} catch {}
return null;
});
// Called when a query returns no usable items; falls back to Trending
private afterNoResults(): void {
const p = this.provider();
const hint = p === 'youtube'
? 'Aucun Short trouvé. Assurez-vous de définir YOUTUBE_API_KEY dans assets/config.local.js (window.YOUTUBE_API_KEY = "...") ou changez de fournisseur.'
: 'Aucun Short trouvé pour ce fournisseur.';
this.loadFallbackTrending(hint);
}
// Filter YouTube results to real Shorts using the Videos API to get durations.
private async filterYouTubeShorts(list: Video[]): Promise<Video[]> {
const ids = Array.from(new Set(list.map(v => v.videoId))).slice(0, 50);
try {
const durations = await firstValueFrom(this.api.getYouTubeDurations(ids));
const maxShort = 70; // seconds (include some margin)
return list.filter(v => {
const d = durations?.[v.videoId] ?? 0;
return d > 0 && d <= maxShort;
});
} catch {
return [];
}
}
constructor() {
// Basic feed: search for "shorts"
this.loadFeed();
}
loadFeed(): void {
this.loading.set(true);
this.error.set(null);
const ready = this.instances.getProviderReadiness(this.provider());
if (!ready.ready) {
this.loading.set(false);
this.error.set(ready.reason || 'Provider not ready.');
return;
}
this.api.searchVideosPage('shorts OR #shorts').subscribe({
next: (res) => {
const raw = (res.items || []).filter(v => !!v.videoId);
this.nextCursor.set(res.nextCursor || null);
const p = this.provider();
if (p === 'youtube' && raw.length) {
this.filterYouTubeShorts(raw).then(list => {
this.items.set(list);
this.index.set(0);
if (list.length === 0) this.afterNoResults();
else this.error.set(null);
this.loading.set(false);
});
return;
}
// Non-YouTube: take raw as-is
const list = raw;
this.items.set(list);
this.index.set(0);
if (list.length === 0) {
const p = this.provider();
const hint = p === 'youtube'
? 'Aucun Short trouvé. Assurez-vous de définir YOUTUBE_API_KEY dans assets/config.local.js (window.YOUTUBE_API_KEY = "...") ou changez de fournisseur.'
: 'Aucun Short trouvé pour ce fournisseur.';
// Essayez une retombée vers le fil des tendances
this.loadFallbackTrending(hint);
return;
} else {
this.error.set(null);
}
this.loading.set(false);
},
error: () => {
this.error.set('Failed to load Shorts.');
this.loading.set(false);
}
});
}
private loadFallbackTrending(hint?: string): void {
this.api.getTrendingPage().subscribe({
next: (res) => {
const list = (res.items || []).filter(v => !!v.videoId);
this.items.set(list);
this.index.set(0);
this.nextCursor.set(res.nextCursor || null);
if (list.length === 0) this.error.set(hint || 'Aucun contenu disponible.');
else this.error.set(null);
this.loading.set(false);
},
error: () => {
this.error.set(hint || 'Impossible de charger du contenu.');
this.loading.set(false);
}
});
}
private fetchNextPage(autoAdvance = false) {
if (this.busyMore() || !this.nextCursor()) return;
this.busyMore.set(true);
const cursor = this.nextCursor();
this.api.searchVideosPage('shorts OR #shorts', cursor || undefined).subscribe({
next: (res) => {
const raw = (res.items || []).filter(v => !!v.videoId);
const p = this.provider();
const apply = (more: Video[]) => {
const merged = this.items().concat(more);
this.items.set(merged);
this.nextCursor.set(res.nextCursor || null);
if (autoAdvance && more.length > 0) {
this.index.update(i => Math.min(i + 1, merged.length - 1));
}
this.busyMore.set(false);
};
if (p === 'youtube' && raw.length) {
this.filterYouTubeShorts(raw).then(apply).catch(() => { apply([]); });
} else {
apply(raw);
}
},
error: () => {
this.busyMore.set(false);
}
});
}
next(): void {
if (this.canNext()) {
this.index.update(i => i + 1);
} else if (this.nextCursor()) {
this.fetchNextPage(true);
}
}
prev(): void { if (this.canPrev()) this.index.update(i => i - 1); }
@HostListener('document:keydown', ['$event'])
onKey(ev: KeyboardEvent) {
if (this.items().length === 0) return;
if (ev.key === 'ArrowDown') { ev.preventDefault(); this.next(); }
if (ev.key === 'ArrowUp') { ev.preventDefault(); this.prev(); }
}
// Also listen on window to catch events when the cursor is over the iframe
@HostListener('window:wheel', ['$event'])
onWindowWheel(ev: WheelEvent) { this.onWheel(ev); }
@HostListener('window:touchstart', ['$event'])
onWindowTouchStart(ev: TouchEvent) { this.onTouchStart(ev); }
@HostListener('window:touchend', ['$event'])
onWindowTouchEnd(ev: TouchEvent) { this.onTouchEnd(ev); }
// Handle mouse wheel on container (bound in template to allow preventDefault)
onWheel(ev: WheelEvent) {
if (this.items().length === 0) return;
try { ev.preventDefault(); ev.stopPropagation(); } catch {}
const now = Date.now();
if (now - this.lastScrollTs < 250) return; // cooldown to avoid rapid fire
this.wheelAccum += ev.deltaY;
const threshold = 40; // more sensitive for trackpads
if (this.wheelAccum > threshold) { this.wheelAccum = 0; this.lastScrollTs = now; this.next(); }
else if (this.wheelAccum < -threshold) { this.wheelAccum = 0; this.lastScrollTs = now; this.prev(); }
}
onTouchStart(ev: TouchEvent) {
if (this.items().length === 0) return;
this.lastSwipeY = (ev.changedTouches?.[0]?.clientY ?? null);
}
onTouchEnd(ev: TouchEvent) {
if (this.items().length === 0) return;
const y = ev.changedTouches?.[0]?.clientY;
if (this.lastSwipeY == null || y == null) return;
const dy = y - this.lastSwipeY;
const threshold = 50;
if (dy < -threshold) this.next();
else if (dy > threshold) this.prev();
this.lastSwipeY = null;
}
}
@@ -0,0 +1,171 @@
<nav class="h-full w-full bg-slate-900 md:bg-transparent text-slate-200 p-3 md:p-0 overflow-y-auto">
<div class="space-y-6">
<!-- Brand (small only) -->
<div class="md:hidden flex items-center gap-2 px-3 py-2">
<svg xmlns="http://www.w3.org/2000/svg" class="h-7 w-7 text-red-500" viewBox="0 0 24 24" fill="currentColor"><path d="M10,15.6l5.3-3.3L10,9V15.6z M21.8,8.1c-0.2-0.8-0.9-1.4-1.7-1.7C18.2,6,12,6,12,6s-6.2,0-8.1,0.5 c-0.8,0.2-1.4,0.9-1.7,1.7C2,9.9,2,12,2,12s0,2.1,0.5,3.9c0.2,0.8,0.9,1.4,1.7,1.7C6.2,18,12,18,12,18s6.2,0,8.1-0.5 c0.8-0.2,1.4-0.9,1.7-1.7c0.5-1.8,0.5-3.9,0.5-3.9S22.2,9.9,21.8,8.1z"/></svg>
<span class="text-xl font-bold">NewTube</span>
</div>
<!-- Main -->
<div>
<ul class="space-y-1">
<li>
<a routerLink="/t/trending" routerLinkActive="bg-slate-800" class="flex items-center px-3 py-2 rounded hover:bg-slate-800 transition" [ngClass]="{ 'gap-0 justify-center': collapsed, 'gap-3': !collapsed }" [attr.title]="collapsed ? ('nav.home' | t) : null">
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" viewBox="0 0 24 24" fill="currentColor"><path d="M3 10.5L12 3l9 7.5V21a1 1 0 0 1-1 1h-5v-6H9v6H4a1 1 0 0 1-1-1v-10.5z"/></svg>
<span [class.hidden]="collapsed">{{ 'nav.home' | t }}</span>
</a>
</li>
<li>
<a routerLink="/shorts" routerLinkActive="bg-slate-800" class="flex items-center px-3 py-2 rounded hover:bg-slate-800 transition" [ngClass]="{ 'gap-0 justify-center': collapsed, 'gap-3': !collapsed }" [attr.title]="collapsed ? ('nav.shorts' | t) : null">
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" viewBox="0 0 24 24" fill="currentColor"><path d="M10 2l8 4v9l-8 4-8-4V6l8-4zm0 2.2L4 6.8v6.4l6 2.6 6-2.6V6.8l-6-2.6z"/></svg>
<span [class.hidden]="collapsed">{{ 'nav.shorts' | t }}</span>
</a>
</li>
<li>
<a routerLink="/library/subscriptions" routerLinkActive="bg-slate-800" class="flex items-center px-3 py-2 rounded hover:bg-slate-800 transition" [ngClass]="{ 'gap-0 justify-center': collapsed, 'gap-3': !collapsed }" [attr.title]="collapsed ? ('nav.subscriptions' | t) : null">
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" viewBox="0 0 24 24" fill="currentColor"><path d="M4 4h16a2 2 0 0 1 2 2v12a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2zm0 2v12h16V6H4zm3 3h10v2H7V9zm0 4h7v2H7v-2z"/></svg>
<span [class.hidden]="collapsed">{{ 'nav.subscriptions' | t }}</span>
</a>
</li>
</ul>
</div>
<!-- Vous -->
<div>
<div class="px-3 py-2 text-xs uppercase tracking-wide text-slate-400" [class.hidden]="collapsed">{{ 'nav.you' | t }}</div>
<ul class="space-y-1">
<li>
<a routerLink="/account/history" routerLinkActive="bg-slate-800" class="flex items-center px-3 py-2 rounded hover:bg-slate-800 transition" [ngClass]="{ 'gap-0 justify-center': collapsed, 'gap-3': !collapsed }" [attr.title]="collapsed ? ('nav.history' | t) : null">
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" viewBox="0 0 24 24" fill="currentColor"><path d="M12 8a1 1 0 0 1 1 1v3.38l2.32 1.34a1 1 0 1 1-1 1.74l-2.82-1.63A1 1 0 0 1 11 13V9a1 1 0 0 1 1-1zm0-6a10 10 0 1 0 10 10 10.011 10.011 0 0 0-10-10z"/></svg>
<span [class.hidden]="collapsed">{{ 'nav.history' | t }}</span>
</a>
</li>
<li>
<a routerLink="/library/playlists" routerLinkActive="bg-slate-800" class="flex items-center px-3 py-2 rounded hover:bg-slate-800 transition" [ngClass]="{ 'gap-0 justify-center': collapsed, 'gap-3': !collapsed }" [attr.title]="collapsed ? ('nav.playlists' | t) : null">
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" viewBox="0 0 24 24" fill="currentColor"><path d="M4 6h12v2H4V6zm0 4h12v2H4v-2zm0 4h8v2H4v-2zm14-6h2v8h-2v-8z"/></svg>
<span [class.hidden]="collapsed">{{ 'nav.playlists' | t }}</span>
</a>
</li>
<li>
<a routerLink="/library/liked" routerLinkActive="bg-slate-800" class="flex items-center px-3 py-2 rounded hover:bg-slate-800 transition" [ngClass]="{ 'gap-0 justify-center': collapsed, 'gap-3': !collapsed }" [attr.title]="collapsed ? ('nav.liked' | t) : null">
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" viewBox="0 0 24 24" fill="currentColor"><path d="M12 21s-6.716-4.248-9.193-6.725A6 6 0 0 1 12 5.414 6 6 0 0 1 21.193 14.275C18.716 16.752 12 21 12 21z"/></svg>
<span [class.hidden]="collapsed">{{ 'nav.liked' | t }}</span>
</a>
</li>
</ul>
</div>
<!-- Fournisseurs -->
<div>
<div class="px-3 py-2 text-xs uppercase tracking-wide text-slate-400" [class.hidden]="collapsed">Fournisseurs</div>
<ul class="space-y-1">
<!-- All providers except PeerTube (order handled in TS so PT is last) -->
<ng-container *ngFor="let p of providersList()">
<li *ngIf="p.id !== 'peertube'">
<a [routerLink]="['/p', p.id, 't', 'trending']" class="flex items-center px-3 py-2 rounded hover:bg-slate-800 transition" [ngClass]="{ 'gap-0 justify-center': collapsed, 'gap-3': !collapsed }" [attr.title]="collapsed ? (p.label) : null">
<span class="h-6 w-6 flex items-center justify-center">
<ng-container [ngSwitch]="p.id">
<!-- YouTube -->
<svg *ngSwitchCase="'youtube'" class="h-5 w-5" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
<rect x="3" y="6" width="18" height="12" rx="3" class="text-red-600" fill="currentColor"/>
<polygon points="10,9 16,12 10,15" fill="white"/>
</svg>
<!-- Dailymotion -->
<svg *ngSwitchCase="'dailymotion'" class="h-5 w-5 text-blue-500" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
<circle cx="12" cy="12" r="10" fill="currentColor"/>
<polygon points="11,8 16,12 11,16" fill="white"/>
</svg>
<!-- Twitch -->
<svg *ngSwitchCase="'twitch'" class="h-5 w-5 text-purple-500" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
<path d="M4 3h16v11l-4 4h-3l-2 2H9v-2H6V3zm3 2v9h6l2-2h3V5H7z" fill="currentColor"/>
</svg>
<!-- Rumble -->
<svg *ngSwitchCase="'rumble'" class="h-5 w-5 text-green-500" viewBox="0 0 24 24" aria-hidden="true">
<circle cx="12" cy="12" r="10" fill="currentColor"/>
<polygon points="10,8 16,12 10,16" fill="white"/>
</svg>
<!-- Odysee -->
<svg *ngSwitchCase="'odysee'" class="h-5 w-5 text-pink-500" viewBox="0 0 24 24" aria-hidden="true">
<circle cx="12" cy="12" r="10" fill="currentColor"/>
<circle cx="12" cy="12" r="5" fill="white"/>
</svg>
<!-- Fallback generic play icon -->
<svg *ngSwitchDefault class="h-5 w-5 text-slate-300" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
<polygon points="9,7 17,12 9,17" fill="currentColor"/>
</svg>
</ng-container>
</span>
<span [class.hidden]="collapsed">{{ p.label }}</span>
</a>
</li>
</ng-container>
<!-- PeerTube group with instances as sub-items -->
<ng-container *ngFor="let p of providersList()">
<li [ngClass]="{ 'mt-2': true }" *ngIf="p.id === 'peertube'">
<a [routerLink]="['/p', 'peertube', 't', 'trending']" class="flex items-center px-3 py-2 rounded hover:bg-slate-800 transition" [ngClass]="{ 'gap-0 justify-center': collapsed, 'gap-3': !collapsed }" [attr.title]="collapsed ? (p.label) : null">
<span class="h-6 w-6 flex items-center justify-center">
<!-- PeerTube logo style (orange play) -->
<svg class="h-5 w-5 text-orange-500" viewBox="0 0 24 24" aria-hidden="true">
<circle cx="12" cy="12" r="10" fill="currentColor"/>
<polygon points="10,8 16,12 10,16" fill="white"/>
</svg>
</span>
<span [class.hidden]="collapsed">{{ p.label }}</span>
</a>
<!-- Sub-list of instances (visible when not collapsed) -->
<ul class="ml-8 mt-1 space-y-1" [class.hidden]="collapsed">
<li *ngFor="let inst of peerTubeInstances()">
<a [routerLink]="['/p', 'peertube', 't', 'trending']"
(click)="$event.preventDefault(); selectPeerTubeInstance(inst)"
class="flex items-center px-3 py-1.5 rounded hover:bg-slate-800 transition gap-2">
<svg class="h-4 w-4 text-orange-500" viewBox="0 0 24 24" aria-hidden="true">
<circle cx="12" cy="12" r="10" fill="currentColor"/>
<polygon points="10,8 16,12 10,16" fill="white"/>
</svg>
<span class="text-xs" [ngClass]="{ 'text-sky-400': inst === activePeerTubeInstance(), 'text-slate-300': inst !== activePeerTubeInstance() }">{{ inst }}</span>
</a>
</li>
</ul>
</li>
</ng-container>
</ul>
</div>
<!-- Explorer (themes list with more/less) -->
<div>
<div class="px-3 py-2 text-xs uppercase tracking-wide text-slate-400" [class.hidden]="collapsed">{{ 'nav.explore' | t }}</div>
<ul class="space-y-1">
<li *ngFor="let t of displayedThemes()">
<a [routerLink]="['/t', t.slug]" class="flex items-center px-3 py-2 rounded hover:bg-slate-800 transition" [ngClass]="{ 'gap-0 justify-center': collapsed, 'gap-3': !collapsed }" [attr.title]="collapsed ? (t.label) : null">
<span class="h-6 w-6 flex items-center justify-center text-lg">{{ t.emoji }}</span>
<span [class.hidden]="collapsed">{{ t.label }}</span>
</a>
</li>
</ul>
<button class="flex items-center gap-2 px-3 py-2 text-sm text-slate-300 hover:text-white" (click)="toggleThemes()" [class.hidden]="collapsed">
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" viewBox="0 0 24 24" fill="currentColor"><path d="M12 5l7 7-7 7-1.41-1.41L15.17 13H5v-2h10.17l-4.58-4.59L12 5z"/></svg>
<span>{{ showAllThemes() ? ('common.less' | t) : ('common.more' | t) }}</span>
</button>
</div>
<!-- Informations -->
<div>
<div class="px-3 py-2 text-xs uppercase tracking-wide text-slate-400" [class.hidden]="collapsed">{{ 'nav.info' | t : 'Informations' }}</div>
<ul class="space-y-1">
<li>
<a routerLink="/info/utilisation" routerLinkActive="bg-slate-800" class="flex items-center px-3 py-2 rounded hover:bg-slate-800 transition" [ngClass]="{ 'gap-0 justify-center': collapsed, 'gap-3': !collapsed }" [attr.title]="collapsed ? ('nav.usage' | t : 'Utilisation') : null">
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" viewBox="0 0 24 24" fill="currentColor"><path d="M12 2a10 10 0 1 0 10 10A10.011 10.011 0 0 0 12 2zm1 15h-2v-6h2zm0-8h-2V7h2z"/></svg>
<span [class.hidden]="collapsed">{{ 'nav.usage' | t : 'Utilisation' }}</span>
</a>
</li>
<li>
<a href="https://git.dracodev.net/Projets/NewTube" target="_blank" rel="noopener noreferrer" class="flex items-center px-3 py-2 rounded hover:bg-slate-800 transition" [ngClass]="{ 'gap-0 justify-center': collapsed, 'gap-3': !collapsed }" [attr.title]="collapsed ? 'Gitea' : null">
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" viewBox="0 0 24 24" fill="currentColor"><path d="M12 2a10 10 0 1 0 10 10A10.011 10.011 0 0 0 12 2zm1 14.59V20h-2v-3.41a4.994 4.994 0 0 1-4-4.9V7h2v4.69A3 3 0 0 0 12 14a3 3 0 0 0 3-2.31V7h2v4.69a4.994 4.994 0 0 1-4 4.9z"/></svg>
<span [class.hidden]="collapsed">Gitea</span>
</a>
</li>
</ul>
</div>
</div>
</nav>
@@ -0,0 +1,40 @@
import { ChangeDetectionStrategy, Component, computed, inject, Input } from '@angular/core';
import { CommonModule } from '@angular/common';
import { RouterLink, RouterLinkActive } from '@angular/router';
import { InstanceService } from '../../services/instance.service';
import { ThemesService } from '../../services/themes.service';
import { TranslatePipe } from '../../pipes/translate.pipe';
@Component({
selector: 'app-sidebar',
standalone: true,
imports: [CommonModule, RouterLink, RouterLinkActive, TranslatePipe],
templateUrl: './sidebar.component.html',
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class SidebarComponent {
private instances = inject(InstanceService);
themes = inject(ThemesService);
provider = computed(() => this.instances.selectedProvider());
providerLabel = computed(() => this.instances.selectedProviderLabel());
// Providers with PeerTube last
providersList = computed(() => {
const list = this.instances.providers();
const others = list.filter(p => p.id !== 'peertube');
const peertube = list.find(p => p.id === 'peertube');
return peertube ? [...others, peertube] : list;
});
peerTubeInstances = computed(() => this.instances.peerTubeInstances());
activePeerTubeInstance = computed(() => this.instances.activePeerTubeInstance());
showAllThemes = this.themes.showAll;
displayedThemes = computed(() => this.showAllThemes() ? this.themes.themes() : this.themes.themes().slice(0, 8));
toggleThemes() { this.themes.toggleShowAll(); }
selectPeerTubeInstance(inst: string) {
if (!inst) return;
this.instances.setActivePeerTubeInstance(inst);
}
@Input() collapsed = false;
}
@@ -0,0 +1,162 @@
<div class="container mx-auto p-4 sm:p-6">
<div class="flex items-center justify-between mb-4">
<div class="flex items-center gap-2 text-slate-200">
<a [routerLink]="['/t', theme()]" class="px-3 py-1 rounded bg-slate-800 hover:bg-slate-700 border border-slate-700">← {{ 'themes.backToThemes' | t }}</a>
<h2 class="text-2xl font-bold">
{{ themes.bySlug(theme())?.emoji }} {{ themes.bySlug(theme())?.label }}
<span class="text-slate-400 text-base">— {{ provider() | titlecase }}</span>
</h2>
</div>
<div class="flex items-center gap-2">
<label class="text-sm text-slate-400">{{ 'themes.changeProvider' | t }}</label>
<select [ngModel]="provider()" (ngModelChange)="changeProvider($event)" class="bg-gray-800 text-white rounded px-2 py-1">
<option *ngFor="let p of providersList()" [value]="p.id">{{ p.label }}</option>
</select>
</div>
</div>
<!-- Filters -->
<div class="grid sm:grid-cols-4 gap-3 mb-6 bg-slate-800/50 p-3 rounded-lg border border-slate-700">
<div>
<label class="block text-xs text-slate-400 mb-1">{{ 'filter.sort' | t }}</label>
<select [ngModel]="sort()" (ngModelChange)="onSortChange($event)" class="w-full bg-gray-800 text-white rounded px-2 py-1">
<option value="recent">{{ 'filter.sort.recent' | t }}</option>
<option value="viewed">{{ 'filter.sort.viewed' | t }}</option>
<option value="longest">{{ 'filter.sort.longest' | t }}</option>
</select>
</div>
<div>
<label class="block text-xs text-slate-400 mb-1">{{ 'filter.duration' | t }}</label>
<select [ngModel]="duration()" (ngModelChange)="onDurationChange($event)" class="w-full bg-gray-800 text-white rounded px-2 py-1">
<option value="any">{{ 'filter.duration.any' | t }}</option>
<option value="short">{{ 'filter.duration.short' | t }}</option>
<option value="medium">{{ 'filter.duration.medium' | t }}</option>
<option value="long">{{ 'filter.duration.long' | t }}</option>
</select>
</div>
<div>
<label class="block text-xs text-slate-400 mb-1">{{ 'filter.language' | t }}</label>
<select [ngModel]="language()" (ngModelChange)="onLanguageChange($event)" class="w-full bg-gray-800 text-white rounded px-2 py-1">
<option value="any">Any</option>
<option value="en">English</option>
<option value="fr">Français</option>
</select>
</div>
<div>
<label class="block text-xs text-slate-400 mb-1">{{ 'filter.date' | t }}</label>
<select [ngModel]="date()" (ngModelChange)="onDateChange($event)" class="w-full bg-gray-800 text-white rounded px-2 py-1">
<option value="any">Any</option>
<option value="day">Last 24h</option>
<option value="week">Last week</option>
<option value="month">Last month</option>
<option value="year">Last year</option>
</select>
</div>
</div>
<!-- Sections Tabs mimic (simple headings) -->
<div class="space-y-10">
<!-- Recorded Streams -->
<section>
<h3 class="text-xl font-semibold mb-3">{{ 'themes.recorded' | t }}</h3>
<div *ngIf="recorded.loading && !recorded.items.length" class="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-6 gap-4">
<div *ngFor="let i of [1,2,3,4,5,6,7,8]" class="animate-pulse bg-slate-800/50 rounded-lg overflow-hidden border border-slate-800">
<div class="w-full h-32 bg-slate-800"></div>
<div class="p-2">
<div class="h-3 bg-slate-700 rounded w-11/12 mb-2"></div>
<div class="h-3 bg-slate-700 rounded w-8/12"></div>
</div>
</div>
</div>
<div *ngIf="!recorded.loading && recorded.items.length === 0" class="text-slate-400">{{ 'empty.noItems' | t }}</div>
<div *ngIf="recorded.error" class="mt-2 p-3 bg-red-900/30 border border-red-700 text-red-200 rounded">
{{ recorded.error }}
<button class="ml-2 underline hover:text-red-100" (click)="retry(recorded)">{{ 'action.retry' | t }}</button>
</div>
<div class="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-6 gap-4" *ngIf="recorded.items.length">
<a *ngFor="let v of (recorded.items | slice:0:recorded.visibleCount); trackBy: trackByVideo"
[routerLink]="['/watch', v.videoId]" [queryParams]="watchQueryParams(v)" [state]="{ video: v }"
class="group bg-slate-800/50 rounded-lg overflow-hidden hover:bg-slate-700/50">
<img [src]="v.thumbnail" [alt]="v.title" loading="lazy" decoding="async" class="w-full h-32 object-cover">
<div class="p-2 text-sm line-clamp-2">{{ v.title }}</div>
</a>
</div>
<!-- Infinite scroll anchor -->
<app-infinite-anchor
*ngIf="recorded.items.length && (recorded.items.length >= recorded.visibleCount)"
[busy]="recorded.loading"
[disabled]="!recorded.nextCursor && recorded.items.length <= recorded.visibleCount"
[rootMargin]="'1000px 0px 1000px 0px'"
(loadMore)="showMore(recorded)">
</app-infinite-anchor>
<div class="mt-2 flex items-center justify-center text-slate-400 text-sm" *ngIf="recorded.loading">
<span class="inline-block h-4 w-4 border-2 border-slate-500 border-t-transparent rounded-full animate-spin mr-2"></span>
{{ 'loading.more' | t }}
</div>
<div class="mt-3" *ngIf="recorded.items.length && (recorded.items.length > recorded.visibleCount || recorded.nextCursor)">
<button class="px-4 py-2 bg-slate-800 hover:bg-slate-700 rounded border border-slate-700 text-slate-200 disabled:opacity-60"
[disabled]="recorded.loading"
(click)="showMore(recorded)">
{{ 'loading.more' | t }} (20)
</button>
</div>
</section>
<!-- Videos -->
<section>
<h3 class="text-xl font-semibold mb-3">{{ 'themes.videos' | t }}</h3>
<div *ngIf="videos.loading && !videos.items.length" class="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-6 gap-4">
<div *ngFor="let i of [1,2,3,4,5,6,7,8]" class="animate-pulse bg-slate-800/50 rounded-lg overflow-hidden border border-slate-800">
<div class="w-full h-32 bg-slate-800"></div>
<div class="p-2">
<div class="h-3 bg-slate-700 rounded w-11/12 mb-2"></div>
<div class="h-3 bg-slate-700 rounded w-8/12"></div>
</div>
</div>
</div>
<div *ngIf="!videos.loading && videos.items.length === 0" class="text-slate-400">{{ 'empty.noItems' | t }}</div>
<div *ngIf="videos.error" class="mt-2 p-3 bg-red-900/30 border border-red-700 text-red-200 rounded">
{{ videos.error }}
<button class="ml-2 underline hover:text-red-100" (click)="retry(videos)">{{ 'action.retry' | t }}</button>
</div>
<div class="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-6 gap-4" *ngIf="videos.items.length">
<a *ngFor="let v of (videos.items | slice:0:videos.visibleCount); trackBy: trackByVideo"
[routerLink]="['/watch', v.videoId]" [queryParams]="watchQueryParams(v)" [state]="{ video: v }"
class="group bg-slate-800/50 rounded-lg overflow-hidden hover:bg-slate-700/50">
<img [src]="v.thumbnail" [alt]="v.title" loading="lazy" decoding="async" class="w-full h-32 object-cover">
<div class="p-2 text-sm line-clamp-2">{{ v.title }}</div>
</a>
</div>
<!-- Infinite scroll anchor -->
<app-infinite-anchor
*ngIf="videos.items.length && (videos.items.length >= videos.visibleCount)"
[busy]="videos.loading"
[disabled]="!videos.nextCursor && videos.items.length <= videos.visibleCount"
[rootMargin]="'1000px 0px 1000px 0px'"
(loadMore)="showMore(videos)">
</app-infinite-anchor>
<div class="mt-2 flex items-center justify-center text-slate-400 text-sm" *ngIf="videos.loading">
<span class="inline-block h-4 w-4 border-2 border-slate-500 border-t-transparent rounded-full animate-spin mr-2"></span>
{{ 'loading.more' | t }}
</div>
<div class="mt-3" *ngIf="videos.items.length && (videos.items.length > videos.visibleCount || videos.nextCursor)">
<button class="px-4 py-2 bg-slate-800 hover:bg-slate-700 rounded border border-slate-700 text-slate-200 disabled:opacity-60"
[disabled]="videos.loading"
(click)="showMore(videos)">
{{ 'loading.more' | t }} (20)
</button>
</div>
</section>
<!-- Categories (static recommended) -->
<section>
<h3 class="text-xl font-semibold mb-3">{{ 'themes.categories' | t }}</h3>
<div class="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-6 gap-4">
<div *ngFor="let c of categories()" class="bg-slate-800/60 rounded-xl p-4 border border-slate-700">
<div class="text-4xl">{{ c.emoji }}</div>
<div class="mt-2 font-semibold">{{ c.label }}</div>
</div>
</div>
</section>
</div>
</div>
@@ -0,0 +1,375 @@
import { ChangeDetectionStrategy, ChangeDetectorRef, Component, computed, effect, inject, signal, OnDestroy } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { ActivatedRoute, Router, RouterLink } from '@angular/router';
import { InstanceService, Provider } from '../../services/instance.service';
import { ThemesService } from '../../services/themes.service';
import { YoutubeApiService } from '../../services/youtube-api.service';
import { Video } from '../../models/video.model';
import { InfiniteAnchorComponent } from '../shared/infinite-anchor/infinite-anchor.component';
import { TranslatePipe } from '../../pipes/translate.pipe';
interface SectionState {
key: 'recorded' | 'videos' | 'categories';
titleKey: string;
items: Video[];
nextCursor: string | null;
loading: boolean;
error: string | null;
visibleCount: number; // how many items are currently shown
}
@Component({
selector: 'app-provider-theme',
standalone: true,
templateUrl: './provider-theme-page.component.html',
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [CommonModule, FormsModule, RouterLink, InfiniteAnchorComponent, TranslatePipe]
})
export class ProviderThemePageComponent implements OnDestroy {
private route = inject(ActivatedRoute);
private router = inject(Router);
private instances = inject(InstanceService);
themes = inject(ThemesService);
private api = inject(YoutubeApiService);
private cdr = inject(ChangeDetectorRef);
provider = signal<Provider>('youtube');
theme = signal<string>('trending');
// Guard to ignore stale async responses after route/theme/provider changes
private version = 0;
// Filters state
sort = signal<'recent' | 'viewed' | 'longest'>('recent');
duration = signal<'any' | 'short' | 'medium' | 'long'>('any');
language = signal<'any' | 'en' | 'fr'>('any');
date = signal<'any' | 'day' | 'week' | 'month' | 'year'>('any');
// UI
providersList = computed(() => this.instances.providers());
categories = computed(() => this.themes.categoriesFor(this.theme()));
// Sections (Live removed on provider pages)
recorded: SectionState = { key: 'recorded', titleKey: 'themes.recorded', items: [], nextCursor: null, loading: false, error: null, visibleCount: 20 };
videos: SectionState = { key: 'videos', titleKey: 'themes.videos', items: [], nextCursor: null, loading: false, error: null, visibleCount: 20 };
constructor() {
this.route.paramMap.subscribe(pm => {
const provider = (pm.get('provider') as Provider) || 'youtube';
const theme = pm.get('theme') || 'trending';
this.provider.set(provider);
this.theme.set(theme);
// Keep global provider in sync so header search uses the current context
try { this.instances.setSelectedProvider(provider); } catch {}
this.resetAll();
// Increment version so in-flight requests from previous state are ignored
this.version++;
this.loadInitial();
this.cdr.markForCheck();
});
// Prefetch recalculation on resize (affects columns/rows thresholds)
this.handleResize = () => {
this.checkPrefetch(this.recorded);
this.checkPrefetch(this.videos);
};
try { window.addEventListener('resize', this.handleResize); } catch {}
// Re-apply filters on change (Live removed) with a tiny debounce to avoid thrashing
effect(() => {
const _s = this.sort();
const _d = this.duration();
const _l = this.language();
const _dt = this.date();
const apply = () => {
// Reset visible window to ensure top results reflect new sort/filter instantly
this.recorded.visibleCount = Math.max(20, this.recorded.visibleCount);
this.videos.visibleCount = Math.max(20, this.videos.visibleCount);
this.recorded.items = this.applyFilters(this.recorded.items);
this.videos.items = this.applyFilters(this.videos.items);
// After filtering, re-evaluate if we need to prefetch more
this.checkPrefetch(this.recorded);
this.checkPrefetch(this.videos);
this.cdr.markForCheck();
try { this.cdr.detectChanges(); } catch {}
};
try {
clearTimeout((this as any).__fltTimer);
} catch {}
(this as any).__fltTimer = setTimeout(apply, 150);
}, { allowSignalWrites: true });
}
private resetAll() {
for (const s of [this.recorded, this.videos]) {
s.items = []; s.nextCursor = null; s.loading = false; s.error = null; s.visibleCount = 20;
}
this.cdr.markForCheck();
}
private baseTokens(): string[] {
const t = this.theme();
if (t === 'trending') return [];
if (t === 'live') return ['live'];
return this.themes.tokensFor(t);
}
private buildQueryFor(section: SectionState): string {
const tokens = [...this.baseTokens()];
if (section.key === 'recorded') {
const p = this.provider();
// Only providers that benefit from a 'replay' search hint
if (p === 'youtube' || p === 'twitch') tokens.push('replay');
// For other providers, prefer trending (empty query) to avoid bad/no results
}
return tokens.join(' ').trim();
}
private applyFilters(list: Video[]): Video[] {
let arr = list.slice();
const d = this.duration();
if (d !== 'any') {
arr = arr.filter(v => {
const dur = v.duration || 0;
if (d === 'short') return dur > 0 && dur < 600;
if (d === 'medium') return dur >= 600 && dur < 1800;
if (d === 'long') return dur >= 1800;
return true;
});
}
// Language heuristic (very lightweight)
const lang = this.language();
if (lang !== 'any') {
const isFrWord = (s: string) => /\b(le|la|les|des|un|une|et|ou|pour|avec|sans|sur|dans|est|cette|vidéo)\b/i.test(s);
const isEnWord = (s: string) => /\b(the|a|an|and|or|with|without|for|this|video|live)\b/i.test(s);
arr = arr.filter(v => {
const text = `${v.title} ${v.uploaderName}`;
const fr = isFrWord(text) || /[éèêàùçîïô]/i.test(text);
const en = isEnWord(text);
return lang === 'fr' ? fr && !en : en && !fr;
});
}
// Date filter based on uploaded timestamp if available
const dateOpt = this.date();
if (dateOpt !== 'any') {
const now = Date.now();
const cutoff = (() => {
if (dateOpt === 'day') return now - 24 * 3600_000;
if (dateOpt === 'week') return now - 7 * 24 * 3600_000;
if (dateOpt === 'month') return now - 30 * 24 * 3600_000;
if (dateOpt === 'year') return now - 365 * 24 * 3600_000;
return 0;
})();
if (cutoff > 0) {
arr = arr.filter(v => (v.uploaded || 0) >= cutoff);
}
}
const s = this.sort();
if (s === 'recent') arr.sort((a, b) => (b.uploaded || 0) - (a.uploaded || 0));
if (s === 'viewed') arr.sort((a, b) => (b.views || 0) - (a.views || 0));
if (s === 'longest') arr.sort((a, b) => (b.duration || 0) - (a.duration || 0));
return arr;
}
changeProvider(p: any) {
const provider = String(p) as Provider;
// Sync the global provider so header search reflects the current context immediately
try { this.instances.setSelectedProvider(provider); } catch {}
this.router.navigate(['/p', provider, 't', this.theme()]);
}
onSortChange(val: any) { this.sort.set(val as any); }
onDurationChange(val: any) { this.duration.set(val as any); }
onLanguageChange(val: any) { this.language.set(val as any); }
onDateChange(val: any) { this.date.set(val as any); }
loadInitial() {
const readiness = this.instances.getProviderReadiness(this.provider());
if (!readiness.ready) {
const msg = readiness.reason || 'Provider not ready';
console.warn('[ProviderTheme] Provider not ready', { provider: this.provider(), reason: msg, theme: this.theme() });
this.recorded.error = msg; this.recorded.loading = false;
this.videos.error = msg; this.videos.loading = false;
return;
}
this.loadMore(this.recorded);
this.loadMore(this.videos);
}
loadMore(section: SectionState) {
if (section.loading) return;
const readiness = this.instances.getProviderReadiness(this.provider());
if (!readiness.ready) {
section.error = readiness.reason || 'Provider not ready';
console.warn('[ProviderTheme] Skip load: provider not ready', { provider: this.provider(), section: section.key, reason: section.error, theme: this.theme() });
section.loading = false;
return;
}
section.loading = true;
this.cdr.markForCheck();
const provider = this.provider();
const query = this.buildQueryFor(section);
const snapshotVersion = this.version;
// If YouTube quota has already been flagged exceeded, surface it and avoid requests
if (provider === 'youtube' && this.api.quotaExceeded$.getValue()) {
section.error = 'YouTube quota exceeded. Try again later or switch provider.';
section.loading = false;
console.warn('[ProviderTheme] Skip load: YouTube quota exceeded', { section: section.key, theme: this.theme() });
return;
}
const next = (res: { items: Video[]; nextCursor?: string | null }) => {
// Ignore responses from a previous state
if (snapshotVersion !== this.version) return;
// Deduplicate by videoId or URL before applying filters
const existing = new Set(section.items.map(v => v.videoId || v.url || ''));
const beforeLen = section.items.length;
const merged: Video[] = [...section.items];
const incoming = res.items || [];
const beforeKeys = existing.size;
for (const v of res.items || []) {
const key = v.videoId || v.url || '';
if (!existing.has(key)) { existing.add(key); merged.push(v); }
}
const afterKeys = existing.size;
const added = Math.max(0, afterKeys - beforeKeys);
const duplicates = Math.max(0, (incoming.length || 0) - added);
section.items = this.applyFilters(merged);
section.nextCursor = res.nextCursor || null;
section.loading = false;
if (incoming.length === 0) {
console.info('[ProviderTheme] Empty page', { provider, section: section.key, theme: this.theme(), query, cursor: section.nextCursor });
}
if (!res.nextCursor) {
console.info('[ProviderTheme] Reached end of pagination', { provider, section: section.key, theme: this.theme(), total: section.items.length });
}
if (duplicates > 0) {
console.debug('[ProviderTheme] Deduplicated incoming items', { provider, section: section.key, duplicates, added, before: beforeLen, after: section.items.length });
}
// Post-process for YouTube: fetch durations for items lacking them to make duration filters/sorts work
if (provider === 'youtube') {
try {
const wantIds = merged.filter(v => (v.duration || 0) === 0 && !!v.videoId).slice(0, 50).map(v => v.videoId as string);
const unique = Array.from(new Set(wantIds));
if (unique.length) {
this.api.getYouTubeDurations(unique).subscribe({
next: (mapDur) => {
if (snapshotVersion !== this.version) return;
if (!mapDur) return;
// Update durations in-place
for (const v of section.items) {
const id = v.videoId || '';
if (id && mapDur[id] != null) v.duration = Number(mapDur[id]) || 0;
}
// Re-apply filters/sorts that depend on duration
section.items = this.applyFilters(section.items);
this.checkPrefetch(section);
this.cdr.markForCheck();
},
error: () => {}
});
}
} catch {}
}
// Logic-based prefetch: if we are within ~2 rows of the end, prefetch next page
this.checkPrefetch(section);
this.cdr.markForCheck();
try { this.cdr.detectChanges(); } catch {}
};
const onErr = (e?: any) => {
// Specific message for quota exceeded on YouTube
if (provider === 'youtube' && e?.status === 403 && (e?.error?.error?.errors?.[0]?.reason === 'quotaExceeded' || /quota/i.test(String(e?.error?.error?.message || e?.message || '')))) {
this.api.quotaExceeded$.next(true);
section.error = 'YouTube quota exceeded. Try again later or switch provider.';
} else if (provider === 'peertube') {
const inst = this.instances.activePeerTubeInstance();
const status = e?.status;
if (status === 404) section.error = `PeerTube (${inst}) introuvable. Essayez une autre instance.`;
else if (status === 429) section.error = `PeerTube (${inst}) limite atteinte. Réessayez plus tard.`;
else section.error = `PeerTube (${inst}) indisponible. Réessayez plus tard.`;
} else {
section.error = 'Erreur de chargement';
}
section.loading = false;
console.error('[ProviderTheme] Load error', { provider, section: section.key, theme: this.theme(), query, cursor: section.nextCursor, error: String(e?.message || e) });
this.cdr.markForCheck();
try { this.cdr.detectChanges(); } catch {}
};
if (query) {
this.api.searchVideosPage(query, section.nextCursor, provider).subscribe({ next, error: onErr });
} else {
this.api.getTrendingPage(section.nextCursor, provider).subscribe({ next, error: onErr });
}
}
// Increase visible items by 20; fetch the next page if we need more data
showMore(section: SectionState) {
const target = section.visibleCount + 20;
section.visibleCount = target;
// If we don't have enough items yet and there is a next page, fetch it
if (section.items.length < target && section.nextCursor && !section.loading) {
console.debug('[ProviderTheme] showMore triggers fetch', { section: section.key, target, have: section.items.length, nextCursor: section.nextCursor });
this.loadMore(section);
}
// Additionally, run logic-based prefetch check in case of edge conditions
this.checkPrefetch(section);
this.cdr.markForCheck();
}
// Helpers for watch params
watchQueryParams(v: Video): Record<string, any> | null {
const p = this.provider();
const qp: any = { p };
if (p === 'odysee' && v.url?.startsWith('https://odysee.com/')) {
let slug = v.url.substring('https://odysee.com/'.length);
if (slug.startsWith('/')) slug = slug.slice(1);
qp.slug = slug;
}
if (p === 'twitch' && v.type === 'channel') qp.channel = v.videoId;
return qp;
}
// trackBy to reduce DOM churn
trackByVideo(_idx: number, v: Video) { return v.videoId || v.url || _idx; }
// Manual retry helper
retry(section: SectionState) {
section.error = null;
this.loadMore(section);
}
// --- Prefetch helpers ---
private handleResize: (() => void) | null = null;
private currentCols(): number {
try {
// Tailwind breakpoints: md>=768px -> 4 cols, lg>=1024px -> 6 cols, else 2 cols
if (window.matchMedia && window.matchMedia('(min-width: 1024px)').matches) return 6;
if (window.matchMedia && window.matchMedia('(min-width: 768px)').matches) return 4;
return 2;
} catch {
return 4; // safe default
}
}
private checkPrefetch(section: SectionState) {
const cols = this.currentCols();
const threshold = cols * 2; // ~2 rows
const remaining = section.items.length - section.visibleCount;
if (remaining <= threshold && section.nextCursor && !section.loading) {
console.debug('[ProviderTheme] Logic prefetch triggered', { section: section.key, remaining, threshold, nextCursor: section.nextCursor });
this.loadMore(section);
}
}
ngOnDestroy(): void {
try { if (this.handleResize) window.removeEventListener('resize', this.handleResize); } catch {}
}
}
@@ -0,0 +1,44 @@
<div class="container mx-auto p-4 sm:p-6">
<h2 class="text-3xl font-bold mb-6 text-slate-100 border-l-4 border-red-500 pl-4 flex items-center gap-2">
<span>{{ themes.bySlug(themeSlug())?.emoji }}</span>
<span>{{ themeLabel() }}</span>
</h2>
<div class="space-y-10">
<section *ngFor="let b of blocks()">
<div class="flex items-center justify-between mb-3">
<h3 class="text-xl font-semibold">{{ b.label }}</h3>
<a [routerLink]="['/p', b.provider, 't', themeSlug()]" class="text-sm px-3 py-1 rounded bg-slate-800 hover:bg-slate-700 border border-slate-700">{{ 'themes.viewAll' | t }}</a>
</div>
<!-- Loading state -->
<div *ngIf="b.loading" class="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-5 gap-4">
<div *ngFor="let i of [1,2,3,4,5,6,7,8,9,10]" class="animate-pulse bg-slate-800 rounded-lg h-40"></div>
</div>
<!-- Error / empty -->
<div *ngIf="!b.loading && b.error" class="text-amber-300">{{ b.error }}</div>
<div *ngIf="!b.loading && !b.error && b.items.length === 0" class="text-slate-400">{{ 'empty.noItems' | t }}</div>
<!-- Grid -->
<div *ngIf="!b.loading && !b.error && b.items.length" class="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-5 gap-4">
<a *ngFor="let v of b.items"
[routerLink]="['/watch', v.videoId]"
[queryParams]="watchQueryParams(b.provider, v)"
[state]="{ video: v }"
class="group bg-slate-800/50 rounded-lg overflow-hidden hover:bg-slate-700/50 transition-all duration-300">
<div class="relative">
<img [src]="v.thumbnail" [alt]="v.title" loading="lazy" decoding="async" class="w-full h-40 object-cover">
<div class="absolute bottom-2 right-2 bg-black/70 text-white text-xs px-2 py-0.5 rounded" *ngIf="v.duration">
{{ v.duration / 60 | number:'1.0-0' }}:{{ (v.duration % 60) | number:'2.0-0' }}
</div>
</div>
<div class="p-3">
<h4 class="font-semibold line-clamp-2">{{ v.title }}</h4>
<div class="text-sm text-slate-400 mt-1">{{ v.uploaderName }}</div>
</div>
</a>
</div>
</section>
</div>
</div>
@@ -0,0 +1,171 @@
import { ChangeDetectionStrategy, Component, computed, inject, signal } from '@angular/core';
import { CommonModule } from '@angular/common';
import { ActivatedRoute, RouterLink } from '@angular/router';
import { ThemesService } from '../../services/themes.service';
import { YoutubeApiService } from '../../services/youtube-api.service';
import { Provider, InstanceService } from '../../services/instance.service';
import { Video } from '../../models/video.model';
import { TranslatePipe } from '../../pipes/translate.pipe';
interface ProviderBlock {
provider: Provider;
label: string;
loading: boolean;
error: string | null;
items: Video[];
}
@Component({
selector: 'app-theme-page',
standalone: true,
changeDetection: ChangeDetectionStrategy.OnPush,
templateUrl: './theme-page.component.html',
imports: [CommonModule, RouterLink, TranslatePipe]
})
export class ThemePageComponent {
private route = inject(ActivatedRoute);
themes = inject(ThemesService);
private api = inject(YoutubeApiService);
private instances = inject(InstanceService);
// Show all supported providers on Tendances
private providersToShow: Provider[] = ['youtube', 'twitch', 'dailymotion', 'peertube', 'rumble', 'odysee'];
readonly themeSlug = signal<string>('trending');
readonly themeLabel = computed(() => this.themes.bySlug(this.themeSlug())?.label || this.themeSlug());
blocks = signal<ProviderBlock[]>([]);
constructor() {
this.route.paramMap.subscribe(pm => {
const slug = pm.get('theme') || pm.get('slug') || 'trending';
this.themeSlug.set(slug);
this.loadAllBlocks();
});
}
private loadAllBlocks() {
const slug = this.themeSlug();
const blocks: ProviderBlock[] = this.providersToShow.map(p => ({ provider: p, label: this.labelFor(p), loading: true, error: null, items: [] }));
this.blocks.set(blocks);
for (const b of blocks) this.fetchForProvider(b, slug);
}
private tokensForTheme(slug: string): string[] {
const tokens = this.themes.tokensFor(slug);
// Special cases
if (slug === 'trending') return [];
if (slug === 'live') return ['live'];
return tokens;
}
private labelFor(p: Provider): string {
switch (p) {
case 'youtube': return 'YouTube';
case 'twitch': return 'Twitch';
case 'peertube': return 'PeerTube';
case 'rumble': return 'Rumble';
case 'odysee': return 'Odysee';
case 'dailymotion': return 'Dailymotion';
}
}
private fetchForProvider(block: ProviderBlock, slug: string) {
// Provider readiness (e.g., YouTube key, Twitch creds). If not ready, surface reason and skip network calls.
const ready = this.instances.getProviderReadiness(block.provider);
if (!ready.ready) {
block.loading = false;
block.error = ready.reason || 'Provider not ready';
console.warn('[ThemePage] Provider not ready', { provider: block.provider, theme: slug, reason: block.error });
this.blocks.update(a => [...a]);
return;
}
// If YouTube quota already exceeded, surface and skip
if (block.provider === 'youtube' && this.api.quotaExceeded$.getValue()) {
block.loading = false;
block.error = 'YouTube quota exceeded. Try again later or switch provider.';
console.warn('[ThemePage] Skip fetch: YouTube quota exceeded', { theme: slug });
this.blocks.update(a => [...a]);
return;
}
const tokens = this.tokensForTheme(slug);
const max = 10;
block.loading = true;
block.error = null;
if (slug === 'trending') {
this.api.getTrendingPage(null, block.provider).subscribe({
next: res => {
block.items = (res.items || []).slice(0, max);
block.loading = false;
if (!block.items.length) console.info('[ThemePage] Empty block page', { provider: block.provider, theme: slug });
this.blocks.update(a => [...a]);
},
error: (e) => {
if (block.provider === 'youtube' && e?.status === 403 && (e?.error?.error?.errors?.[0]?.reason === 'quotaExceeded' || /quota/i.test(String(e?.error?.error?.message || e?.message || '')))) {
this.api.quotaExceeded$.next(true);
block.error = 'YouTube quota exceeded. Try again later or switch provider.';
} else {
block.error = 'Erreur de chargement';
}
block.loading = false; this.blocks.update(a => [...a]);
}
});
return;
}
const q = tokens.join(' ');
if (!q) {
// fallback to trending
this.api.getTrendingPage(null, block.provider).subscribe({
next: res => {
block.items = (res.items || []).slice(0, max);
block.loading = false;
if (!block.items.length) console.info('[ThemePage] Empty block page', { provider: block.provider, theme: slug, mode: 'fallback_trending' });
this.blocks.update(a => [...a]);
},
error: (e) => {
if (block.provider === 'youtube' && e?.status === 403 && (e?.error?.error?.errors?.[0]?.reason === 'quotaExceeded' || /quota/i.test(String(e?.error?.error?.message || e?.message || '')))) {
this.api.quotaExceeded$.next(true);
block.error = 'YouTube quota exceeded. Try again later or switch provider.';
} else {
block.error = 'Erreur de chargement';
}
block.loading = false; this.blocks.update(a => [...a]);
}
});
} else {
this.api.searchVideosPage(q, null, block.provider).subscribe({
next: res => {
block.items = (res.items || []).slice(0, max);
block.loading = false;
if (!block.items.length) console.info('[ThemePage] Empty block page', { provider: block.provider, theme: slug, query: q });
this.blocks.update(a => [...a]);
},
error: (e) => {
if (block.provider === 'youtube' && e?.status === 403 && (e?.error?.error?.errors?.[0]?.reason === 'quotaExceeded' || /quota/i.test(String(e?.error?.error?.message || e?.message || '')))) {
this.api.quotaExceeded$.next(true);
block.error = 'YouTube quota exceeded. Try again later or switch provider.';
} else {
block.error = 'Erreur de chargement';
}
block.loading = false; this.blocks.update(a => [...a]);
}
});
}
}
watchQueryParams(provider: Provider, v: Video): Record<string, any> {
const qp: any = { p: provider };
if (provider === 'odysee' && v.url?.startsWith('https://odysee.com/')) {
try {
let slug = v.url.substring('https://odysee.com/'.length);
if (slug.startsWith('/')) slug = slug.slice(1);
qp.slug = slug;
} catch {}
}
if (provider === 'twitch' && v.type === 'channel') {
// Try to use channel name
if (v.videoId) qp.channel = v.videoId;
}
return qp;
}
}
@@ -0,0 +1,51 @@
<nav class="sticky top-16 left-0 right-0 z-40 w-full relative"
[ngClass]="{
'bg-slate-100 border-b border-slate-200': isLightTheme(),
'bg-slate-900 border-b border-slate-800': isDarkTheme(),
'bg-black border-b border-slate-900': isBlackTheme(),
'bg-blue-950 border-b border-blue-900': isBlueTheme()
}">
<!-- Left scroll button -->
<button type="button" (click)="scrollBy(-320)" aria-label="Scroll left"
class="hidden md:flex items-center justify-center absolute left-2 top-1/2 -translate-y-1/2 h-9 w-9 rounded-full shadow z-10"
[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': !isLightTheme()
}">
«
</button>
<!-- Right scroll button -->
<button type="button" (click)="scrollBy(320)" aria-label="Scroll right"
class="hidden md:flex items-center justify-center absolute right-2 top-1/2 -translate-y-1/2 h-9 w-9 rounded-full shadow z-10"
[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': !isLightTheme()
}">
»
</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">
<button
#pill
role="tab"
type="button"
class="px-3 py-1.5 rounded-full text-sm font-medium border transition-colors whitespace-nowrap focus:outline-none focus:ring-2 focus:ring-red-500"
[class.bg-slate-100/10]="activeSlug() === t.slug"
[class.text-white]="activeSlug() === t.slug"
[class.border-red-500]="activeSlug() === t.slug"
[class.text-slate-300]="activeSlug() !== t.slug"
[class.border-slate-700]="activeSlug() !== t.slug"
[attr.aria-selected]="activeSlug() === t.slug"
(click)="goToTheme(t.slug)"
(keydown)="onKeydown($event, i)"
>
<span class="mr-1 select-none">{{ t.emoji }}</span>
<span>{{ themesSvc.i18nLabel(t) }}</span>
</button>
</li>
</ul>
</div>
</nav>
@@ -0,0 +1,104 @@
import { ChangeDetectionStrategy, Component, ElementRef, QueryList, ViewChild, ViewChildren, inject, signal, effect } from '@angular/core';
import { CommonModule } from '@angular/common';
import { Router, NavigationEnd, ActivatedRoute } from '@angular/router';
import { ThemesService } from '../../services/themes.service';
@Component({
selector: 'app-themes-nav',
standalone: true,
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [CommonModule],
templateUrl: './themes-nav.component.html'
})
export class ThemesNavComponent {
private router = inject(Router);
private route = inject(ActivatedRoute);
themesSvc = inject(ThemesService);
themes = () => this.themesSvc.themes();
activeSlug = this.themesSvc.activeSlug;
displayedThemes = () => this.themes();
@ViewChildren('pill', { read: ElementRef }) pills!: QueryList<ElementRef<HTMLButtonElement>>;
@ViewChild('scroll', { static: false }) scrollRef?: ElementRef<HTMLDivElement>;
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);
}
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(): string {
try {
const fromAttr = document.documentElement.getAttribute('data-theme');
if (fromAttr) return fromAttr;
const fromStorage = localStorage.getItem('newtube.theme');
const val = fromStorage || 'system';
if (val === 'system') {
// Resolve to light/dark using media query so styles can be deterministic
const prefersDark = window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches;
return prefersDark ? 'dark' : 'light';
}
return val;
} catch {
// Safe fallback: prefer dark for safety in most UIs
const prefersDark = typeof window !== 'undefined' && (window.matchMedia?.('(prefers-color-scheme: dark)').matches ?? false);
return prefersDark ? 'dark' : 'light';
}
}
isLightTheme(): boolean {
const t = this.getCurrentTheme();
return t === 'light' || t === 'system';
}
isDarkTheme(): boolean { return this.getCurrentTheme() === 'dark'; }
isBlackTheme(): boolean { return this.getCurrentTheme() === 'black'; }
isBlueTheme(): boolean { return this.getCurrentTheme() === 'blue'; }
}
@@ -0,0 +1,10 @@
@if (videoSource()) {
<video #player class="w-full h-full" [src]="videoSource()" controls autoplay muted playsinline>
Your browser does not support the video tag.
</video>
} @else {
<div class="w-full h-full bg-black flex items-center justify-center">
<p class="text-slate-400">Loading player...</p>
</div>
}
@@ -0,0 +1,48 @@
import { ChangeDetectionStrategy, Component, ElementRef, ViewChild, input } from '@angular/core';
@Component({
selector: 'app-video-player',
templateUrl: './video-player.component.html',
standalone: true,
changeDetection: ChangeDetectionStrategy.OnPush
})
export class VideoPlayerComponent {
videoSource = input.required<string>();
@ViewChild('player', { static: false }) playerRef?: ElementRef<HTMLVideoElement>;
get el(): HTMLVideoElement | null {
return this.playerRef?.nativeElement ?? null;
}
togglePlayPause(): void {
const v = this.el; if (!v) return;
if (v.paused) v.play(); else v.pause();
}
seekBy(seconds: number): void {
const v = this.el; if (!v) return;
try { v.currentTime = Math.max(0, v.currentTime + seconds); } catch {}
}
toggleMute(): void {
const v = this.el; if (!v) return;
v.muted = !v.muted;
}
setPlaybackRate(delta: number): void {
const v = this.el; if (!v) return;
const next = Math.min(2.0, Math.max(0.25, (v.playbackRate || 1) + delta));
v.playbackRate = next;
}
async toggleFullscreen(): Promise<void> {
const v = this.el; if (!v) return;
const anyDoc: any = document;
if (!anyDoc.fullscreenElement) {
await v.requestFullscreen?.();
} else {
await anyDoc.exitFullscreen?.();
}
}
}
+285
View File
@@ -0,0 +1,285 @@
<div class="container mx-auto p-4 lg:p-6 lg:max-w-7xl">
@if (loading()) {
<div class="animate-pulse flex flex-col lg:flex-row gap-6">
<div class="lg:w-2/3">
<div class="aspect-video bg-slate-700 rounded-lg"></div>
<div class="mt-4 space-y-3">
<div class="h-6 bg-slate-700 rounded w-3/4"></div>
<div class="h-4 bg-slate-700 rounded w-1/2"></div>
</div>
</div>
<div class="lg:w-1/3 space-y-4">
@for (item of [1,2,3,4]; track item) {
<div class="flex items-start space-x-3">
<div class="w-40 h-24 bg-slate-700 rounded"></div>
<div class="flex-1 space-y-2">
<div class="h-4 bg-slate-700 rounded"></div>
<div class="h-4 bg-slate-700 rounded w-2/3"></div>
</div>
</div>
}
</div>
</div>
} @else if (video(); as v) {
<div class="flex flex-col lg:flex-row gap-6">
<!-- Main Content: Player and Details -->
<div class="lg:w-2/3">
<div class="aspect-video rounded-lg overflow-hidden shadow-2xl bg-black">
@if (embedUrl(); as src) {
<iframe class="w-full h-full" [src]="src" frameborder="0"
referrerpolicy="origin"
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"
allowfullscreen></iframe>
} @else {
<app-video-player [videoSource]="videoSource()"></app-video-player>
}
</div>
@if (provider() === 'twitch') {
<div class="mt-2">
<a [href]="twitchOpenUrl()" target="_blank" rel="noopener noreferrer"
class="inline-flex items-center gap-2 text-sm text-slate-300 hover:text-white underline">
Ouvrir sur Twitch
</a>
</div>
}
<div class="mt-4">
@if (availableQualities().length > 0) {
<div class="flex items-center gap-3 text-sm text-slate-300 mb-3">
<label class="text-slate-400">Quality</label>
<select #qsel class="bg-slate-800 text-slate-200 rounded px-2 py-1"
(change)="onSelectQuality(qsel.value)">
<option value="">Auto</option>
@for (q of availableQualities(); track q) {
<option [value]="q">{{ q }}</option>
}
</select>
</div>
}
<h1 class="text-2xl font-bold text-white">{{ v.title }}</h1>
<div class="text-sm text-slate-400 mt-1">
{{ formatViewsFr(v.views) }} • {{ formatDateAbsFr(v.uploadedDate) }}
</div>
<div class="flex flex-col sm:flex-row sm:items-center sm:justify-between mt-3 text-slate-400">
<div class="flex items-center space-x-3">
<img [src]="v.uploaderAvatar" [alt]="v.uploaderName" class="w-10 h-10 rounded-full">
<div>
<div class="font-semibold text-slate-200">{{ v.uploaderName }}</div>
<div>{{ formatNumber(v.subscribers) }} subscribers</div>
</div>
</div>
<div class="flex items-center space-x-4 mt-3 sm:mt-0">
<a [href]="openOnProviderUrl() || '#'" target="_blank" rel="noopener noreferrer"
class="flex items-center space-x-2 bg-slate-700 hover:bg-slate-600 px-4 py-2 rounded-full transition"
[class.pointer-events-none]="!openOnProviderUrl()" [class.opacity-60]="!openOnProviderUrl()"
title="Ouvrir sur le site du fournisseur">
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" viewBox="0 0 20 20" fill="currentColor">
<path d="M10 3a1 1 0 011-1h6a1 1 0 011 1v6a1 1 0 11-2 0V5.414l-7.293 7.293a1 1 0 01-1.414-1.414L14.586 4H11a1 1 0 01-1-1z"/>
<path d="M5 4a1 1 0 011-1h3a1 1 0 110 2H7v8h8v-2a1 1 0 112 0v3a1 1 0 01-1 1H6a1 1 0 01-1-1V4z"/>
</svg>
<span class="hidden sm:inline"></span>
</a>
<button class="flex items-center space-x-2 bg-slate-700 hover:bg-slate-600 px-4 py-2 rounded-full transition">
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" viewBox="0 0 20 20" fill="currentColor"><path d="M2 10.5a1.5 1.5 0 113 0v6a1.5 1.5 0 01-3 0v-6zM6 10.333v5.43a2 2 0 001.106 1.787l.09.044a2 2 0 002.253-.334l.09-.092l3.535-3.536a2 2 0 00.586-1.414V5a2 2 0 00-2-2H9a2 2 0 00-2 2v5.333z" /></svg>
<span>{{ formatNumber(v.likes) }}</span>
</button>
@if (isLoggedIn()) {
<button (click)="openDownloadPanel()"
class="flex items-center space-x-2 bg-red-600 hover:bg-red-500 px-4 py-2 rounded-full transition font-semibold">
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" viewBox="0 0 20 20" fill="currentColor">
<path d="M3 14a1 1 0 011-1h3V3a1 1 0 112 0v10h3a1 1 0 110 2H4a1 1 0 01-1-1z" />
<path d="M7 11l3 3 3-3H7z" />
</svg>
<span>Download</span>
</button>
}
</div>
</div>
<div class="mt-4 bg-slate-800/70 p-4 rounded-lg">
<p [class.line-clamp-3]="!showFullDescription()">{{ v.description }}</p>
<button (click)="toggleDescription()" class="text-red-400 hover:text-red-300 font-semibold mt-2">
{{ showFullDescription() ? 'Show less' : 'Show more' }}
</button>
</div>
<!-- Download Panel (shown only when logged in) -->
@if (isLoggedIn() && downloadOpen()) {
<div class="mt-4 bg-slate-800/70 p-4 rounded-lg border border-slate-700">
<div class="flex items-start gap-4 flex-wrap">
<div class="flex-1 min-w-[240px]">
<label class="block text-sm text-slate-400 mb-1">Format / Qualité</label>
@if (formatsLoading()) {
<div class="text-slate-400 text-sm">Chargement des formats...</div>
} @else if (downloadFormats().length > 0) {
<select #fmtSel class="w-full bg-slate-900 text-slate-200 border border-slate-700 rounded px-2 py-2"
(change)="selectedFormatId.set(fmtSel.value || null)">
<option value="">Auto (best)</option>
@for (f of downloadFormats(); track f.id) {
<option [value]="f.id">{{ f.label }}</option>
}
</select>
} @else if (downloadError()) {
<div class="text-red-300 text-sm">{{ downloadError() }}</div>
} @else {
<div class="text-slate-400 text-sm">Aucun format disponible.</div>
}
</div>
<div class="flex items-center gap-2 mt-2">
<input id="audioOnly" #audioChk type="checkbox" class="accent-red-500"
(change)="audioOnly.set(audioChk.checked)" />
<label for="audioOnly" class="text-sm text-slate-300">Audio uniquement</label>
</div>
<div class="ml-auto flex items-center gap-3 mt-2">
@if (!jobId()) {
<button (click)="startDownload()" class="bg-red-600 hover:bg-red-500 text-white font-semibold px-4 py-2 rounded">
Démarrer
</button>
} @else {
<button (click)="cancelDownload()" class="bg-slate-700 hover:bg-slate-600 text-white px-4 py-2 rounded">
Annuler
</button>
}
</div>
</div>
@if (jobId()) {
<div class="mt-4">
<div class="text-sm text-slate-300 mb-1 flex items-center gap-2">
<span>Statut:</span>
<span class="font-semibold">{{ jobState() || 'en attente' }}</span>
<span>•</span>
<span>Progression: {{ jobProgress() }}%</span>
</div>
<div class="w-full h-3 bg-slate-900 rounded">
<div class="h-3 bg-red-600 rounded" [style.width.%]="jobProgress()"></div>
</div>
</div>
@if (jobFileReady()) {
<div class="mt-4 flex items-center gap-3">
<button (click)="downloadFile()" class="bg-green-600 hover:bg-green-500 text-white font-semibold px-4 py-2 rounded">
Télécharger le fichier
</button>
@if (jobFileSize(); as size) {
<span class="text-sm text-slate-400">{{ formatBytes(size) }}</span>
}
</div>
}
}
@if (downloadError() && !formatsLoading()) {
<div class="mt-3 text-red-300 text-sm">{{ downloadError() }}</div>
}
</div>
}
</div>
<!-- Comments Section -->
<div class="mt-8">
<div class="flex items-center justify-between flex-wrap gap-4 mb-4">
<h2 class="text-xl font-bold">{{ v.comments.length }} Comments</h2>
@if (v.comments.length > 0) {
<button (click)="onSummarizeComments()"
[disabled]="isSummarizing() || !geminiReady"
class="flex items-center space-x-2 bg-slate-700 hover:bg-slate-600 disabled:bg-slate-800 disabled:text-slate-400 disabled:cursor-not-allowed px-3 py-2 rounded-full transition text-sm font-semibold">
@if (isSummarizing()) {
<svg class="animate-spin h-5 w-5" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<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>Summarizing...</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">
<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>Summarize Comments</span>
}
</button>
@if (!geminiReady && geminiReason) {
<div class="text-xs text-amber-300 ml-2">
{{ geminiReason }}
</div>
}
}
</div>
<!-- Summary/Error/Loading Display -->
@if (isSummarizing() || summary() || summaryError()) {
<div class="mb-6">
@if (isSummarizing() && !summary() && !summaryError()) {
<div class="bg-slate-800 p-4 rounded-lg animate-pulse">
<div class="h-4 bg-slate-700 rounded w-1/4 mb-3"></div>
<div class="space-y-2">
<div class="h-4 bg-slate-700 rounded"></div>
<div class="h-4 bg-slate-700 rounded w-5/6"></div>
<div class="h-4 bg-slate-700 rounded w-3/4"></div>
</div>
</div>
} @else if (summary(); as summaryText) {
<div class="bg-gradient-to-br from-slate-800 to-slate-800/50 p-4 rounded-lg border border-slate-700">
<h3 class="text-lg font-bold text-yellow-300 flex items-center space-x-2">
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" viewBox="0 0 20 20" fill="currentColor">
<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>AI Summary</span>
</h3>
<p class="mt-2 text-slate-300 whitespace-pre-wrap">{{ summaryText }}</p>
</div>
} @else if (summaryError(); as errorText) {
<div class="bg-red-900/50 border border-red-700 text-red-300 p-4 rounded-lg">
<p>{{ errorText }}</p>
</div>
}
</div>
}
<div class="space-y-6">
@for (comment of v.comments; track comment.author + comment.commentedTime) {
<div class="flex items-start space-x-4">
<img [src]="comment.thumbnail" alt="" class="w-10 h-10 rounded-full">
<div class="flex-1">
<div class="flex items-center space-x-2 text-sm">
<span class="font-semibold text-slate-200">{{ comment.author }}</span>
<span class="text-slate-400">{{ comment.commentedTime }}</span>
</div>
<p class="mt-1 text-slate-300">{{ comment.commentText }}</p>
</div>
</div>
}
</div>
</div>
</div>
<!-- Sidebar: Related Videos -->
<div class="lg:w-1/3">
<h2 class="text-xl font-bold mb-4">Up next</h2>
<div class="space-y-4">
@for (related of v.relatedStreams; track related.videoId) {
<a [routerLink]="['/watch', related.videoId]" [queryParams]="watchQueryParams(related)" [state]="{ video: related }" class="flex items-start space-x-3 group hover:bg-slate-800/50 p-2 rounded-lg transition">
<div class="w-40 flex-shrink-0">
<img [src]="related.thumbnail" [alt]="related.title" class="w-full h-24 object-cover rounded">
</div>
<div class="flex-1">
<h3 class="font-semibold text-sm line-clamp-2 group-hover:text-red-400">{{ related.title }}</h3>
<div class="text-xs text-slate-400 mt-1">{{ related.uploaderName }}</div>
<div class="text-xs text-slate-400">{{ formatNumber(related.views) }} views</div>
</div>
</a>
}
</div>
</div>
</div>
} @else {
<div class="text-center py-20">
<h2 class="text-2xl font-bold text-slate-300">Video not found</h2>
<p class="text-slate-400 mt-2">The requested video could not be loaded.</p>
<a routerLink="/" class="mt-6 inline-block bg-red-600 hover:bg-red-700 text-white font-bold py-2 px-4 rounded-full transition">Go to Home</a>
</div>
}
</div>
+561
View File
@@ -0,0 +1,561 @@
import { ChangeDetectionStrategy, Component, inject, signal, computed, OnDestroy } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { CommonModule } from '@angular/common';
import { ActivatedRoute, RouterLink } from '@angular/router';
import { YoutubeApiService } from '../../services/youtube-api.service';
import { Video, VideoDetail } from '../../models/video.model';
import { VideoPlayerComponent } from '../video-player/video-player.component';
import { Subscription } from 'rxjs';
import { GeminiService } from '../../services/gemini.service';
import { HistoryService } from '../../services/history.service';
import { InstanceService } from '../../services/instance.service';
import { DownloadService, DownloadFormat, DownloadJob } from '../../services/download.service';
import { AuthService } from '../../services/auth.service';
import { DomSanitizer, SafeResourceUrl } from '@angular/platform-browser';
import { formatAbsoluteFr, formatNumberFr } from '../../utils/date.util';
@Component({
selector: 'app-watch',
templateUrl: './watch.component.html',
standalone: true,
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [CommonModule, VideoPlayerComponent, RouterLink]
})
export class WatchComponent implements OnDestroy {
private route = inject(ActivatedRoute);
private apiService = inject(YoutubeApiService);
private geminiService = inject(GeminiService);
private instances = inject(InstanceService);
private sanitizer = inject(DomSanitizer);
private history = inject(HistoryService);
private downloads = inject(DownloadService);
private auth = inject(AuthService);
private http = inject(HttpClient);
private routeSubscription: Subscription;
video = signal<VideoDetail | null>(null);
loading = signal(true);
showFullDescription = signal(false);
summary = signal<string | null>(null);
isSummarizing = signal(false);
summaryError = signal<string | null>(null);
// Gemini readiness
geminiReady = this.geminiService.isReady();
geminiReason = this.geminiService.readinessReason();
// Auth status for gating download UI
isLoggedIn = computed(() => !!this.auth.currentUser());
// Quality selection (progressive streams only to ensure audio present)
selectedQuality = signal<string | null>(null);
progressiveStreams = computed(() => (this.video()?.videoStreams || []).filter(s => !s.videoOnly));
sortedStreams = computed(() => {
const arr = [...this.progressiveStreams()];
// sort by numeric quality descending if possible (e.g., "1080p")
const parseQ = (q?: string) => Number.parseInt((q || '').replace(/[^0-9]/g, '')) || 0;
arr.sort((a, b) => parseQ(b.quality) - parseQ(a.quality));
return arr;
});
// Build a direct Twitch URL as a fallback open-in-new-tab action
twitchOpenUrl(): string | null {
if (this.provider() !== 'twitch') return null;
const ch = this.twitchChannel();
const id = this.videoId();
const cur = this.video();
// If the current item is a channel stream, open the channel directly
if (cur && (cur as any).type === 'channel' && cur.videoId) {
return `https://www.twitch.tv/${encodeURIComponent(cur.videoId)}`;
}
if (ch) return `https://www.twitch.tv/${encodeURIComponent(ch)}`;
if (id) return `https://www.twitch.tv/videos/${encodeURIComponent(id)}`;
return null;
}
// Generic "open on provider" URL
openOnProviderUrl(): string | null {
const p = this.provider();
const id = this.videoId();
const v = this.video();
const directUrl = v?.url && /^https?:\/\//i.test(v.url) ? v.url : null;
try {
if (p === 'youtube') return `https://www.youtube.com/watch?v=${encodeURIComponent(id)}`;
if (p === 'dailymotion') return `https://www.dailymotion.com/video/${encodeURIComponent(id)}`;
if (p === 'twitch') return this.twitchOpenUrl();
if (p === 'peertube') {
const inst = this.instances.activePeerTubeInstance();
if (inst && id) return `https://${inst}/w/${encodeURIComponent(id)}`;
}
if (p === 'odysee') {
const slug = this.odyseeSlug();
if (slug) return `https://odysee.com/${slug.replace(/^\//, '')}`;
if (directUrl) return directUrl;
}
if (p === 'rumble') {
if (directUrl) return directUrl;
if (id) return `https://rumble.com/${encodeURIComponent(id)}`;
}
return directUrl;
} catch {
return directUrl || null;
}
}
availableQualities = computed(() => this.sortedStreams().map(s => s.quality).filter((q, i, arr) => q && arr.indexOf(q) === i) as string[]);
videoSource = computed(() => {
const qual = this.selectedQuality();
const list = this.sortedStreams();
if (!list.length) return '';
if (!qual) return list[0].url;
return (list.find(s => s.quality === qual)?.url) || list[0].url;
});
// --- Download UI state ---
downloadOpen = signal(false);
formatsLoading = signal(false);
downloadError = signal<string | null>(null);
downloadFormats = signal<DownloadFormat[]>([]);
selectedFormatId = signal<string | null>(null);
audioOnly = signal<boolean>(false);
jobId = signal<string | null>(null);
jobState = signal<DownloadJob['state'] | null>(null);
jobProgress = signal<number>(0);
jobFileReady = signal<boolean>(false);
jobFileSize = signal<number | null>(null);
private jobPoller: any = null;
// Route params for building embeds
private videoId = signal<string>('');
private odyseeSlug = signal<string | null>(null);
private providerSel = signal<string>('');
provider = computed(() => this.providerSel() || this.instances.selectedProvider());
private twitchChannel = signal<string | null>(null);
embedUrl = computed<SafeResourceUrl | null>(() => {
const id = this.videoId();
const p = this.provider();
const ch = this.twitchChannel();
const slug = this.odyseeSlug();
if (!id && !(p === 'twitch' && ch) && !(p === 'odysee' && slug)) return null;
try {
const host = (location && location.hostname) ? location.hostname : 'localhost';
if (p === 'youtube') {
const origin = window.location.origin || 'https://' + (window.location.hostname || 'localhost');
const ref = typeof window !== 'undefined' ? window.location.href : '';
const u = `https://www.youtube-nocookie.com/embed/${encodeURIComponent(id)}?autoplay=1&mute=1&rel=0&modestbranding=1&playsinline=1&enablejsapi=1&origin=${encodeURIComponent(origin)}&widget_referrer=${encodeURIComponent(ref)}`;
return this.sanitizer.bypassSecurityTrustResourceUrl(u);
}
if (p === 'dailymotion') {
const u = `https://www.dailymotion.com/embed/video/${encodeURIComponent(id)}?autoplay=1&mute=1`;
return this.sanitizer.bypassSecurityTrustResourceUrl(u);
}
if (p === 'twitch') {
// Channel or VOD; Twitch requires one or more 'parent' params that match the embedding domain
const parentsSet = new Set<string>([host, 'localhost', '127.0.0.1']);
const parentParams = Array.from(parentsSet).map(h => `parent=${encodeURIComponent(h)}`).join('&');
const base = 'https://player.twitch.tv/';
const qs = ch
? `?channel=${encodeURIComponent(ch)}&${parentParams}&autoplay=false`
: `?video=${encodeURIComponent(id)}&${parentParams}&autoplay=false`;
const u = base + qs;
return this.sanitizer.bypassSecurityTrustResourceUrl(u);
}
if (p === 'peertube') {
const inst = this.instances.activePeerTubeInstance();
const u = `https://${inst}/videos/embed/${encodeURIComponent(id)}?autoplay=1`;
return this.sanitizer.bypassSecurityTrustResourceUrl(u);
}
if (p === 'odysee') {
const slug = this.odyseeSlug();
if (slug) {
const u = `https://odysee.com/$/embed/${slug}?autoplay=1&muted=1`;
return this.sanitizer.bypassSecurityTrustResourceUrl(u);
}
// Fallback (may fail if slug absent)
const u = `https://odysee.com/$/embed/${encodeURIComponent(id)}?autoplay=1&muted=1`;
return this.sanitizer.bypassSecurityTrustResourceUrl(u);
}
if (p === 'rumble') {
// Best-effort: Rumble embed path may vary depending on the id format
const u = `https://rumble.com/embed/${encodeURIComponent(id)}/?autoplay=2&muted=1`;
return this.sanitizer.bypassSecurityTrustResourceUrl(u);
}
} catch {}
return null;
});
constructor() {
this.routeSubscription = this.route.paramMap.subscribe(params => {
const id = params.get('id');
const providerFromPath = params.get('provider');
const slug = this.route.snapshot.queryParamMap.get('slug');
const p = this.route.snapshot.queryParamMap.get('p');
const ch = this.route.snapshot.queryParamMap.get('channel');
if (id) {
this.videoId.set(id);
this.odyseeSlug.set(slug);
this.providerSel.set(p || providerFromPath || '');
this.twitchChannel.set(ch);
this.loadVideo(id);
}
});
}
ngOnDestroy(): void {
this.routeSubscription.unsubscribe();
}
loadVideo(id: string) {
this.loading.set(true);
// Prepare placeholder from router state if available for richer UI
const stateVideo = (history.state && (history.state as any).video) as Video | undefined;
this.video.set({
url: stateVideo?.url || '',
type: stateVideo?.type || 'video',
title: stateVideo?.title || '',
thumbnail: stateVideo?.thumbnail || '',
uploaderName: stateVideo?.uploaderName || '',
uploaderUrl: stateVideo?.uploaderUrl || '',
uploaderAvatar: stateVideo?.uploaderAvatar || '',
uploadedDate: stateVideo?.uploadedDate || '',
shortDescription: stateVideo?.shortDescription || '',
duration: stateVideo?.duration || 0,
views: stateVideo?.views || 0,
uploaded: stateVideo?.uploaded || 0,
videoId: id,
description: '',
subscribers: 0,
likes: 0,
dislikes: 0,
videoStreams: [],
audioStreams: [],
relatedStreams: [],
comments: []
});
this.showFullDescription.set(false);
this.summary.set(null);
this.isSummarizing.set(false);
this.summaryError.set(null);
this.selectedQuality.set(null);
this.resetDownloadUi();
const provider = this.provider();
if (provider === 'rumble') {
// Validate and enrich Rumble video via backend scraping (may also normalize id)
this.http.get<any>(`/api/rumble/video/${encodeURIComponent(id)}`).subscribe({
next: (data) => {
const newId = (data && (data.videoId || data.id)) ? String(data.videoId || data.id) : id;
// Update id if backend provided a more accurate one
if (newId && newId !== id) {
this.videoId.set(newId);
}
// Merge enriched fields
this.video.update(v => v ? {
...v,
title: data?.title || v.title,
thumbnail: data?.thumbnail || v.thumbnail,
uploaderName: data?.uploaderName || v.uploaderName,
uploaderAvatar: data?.uploaderAvatar || v.uploaderAvatar,
uploadedDate: data?.uploadedDate || v.uploadedDate,
views: typeof data?.views === 'number' ? data.views : v.views,
duration: typeof data?.duration === 'number' ? data.duration : v.duration,
description: data?.description || v.description,
url: data?.url || v.url,
} : v);
this.loading.set(false);
this.loadRelatedSuggestions();
},
error: () => {
// Even if details fail, try to display embed with the original id
this.loading.set(false);
this.loadRelatedSuggestions();
}
});
} else {
// For other providers rely on embed/placeholder
this.loading.set(false);
this.loadRelatedSuggestions();
}
// Record watch start
try {
const provider = this.provider();
const title = this.video()?.title || '';
this.history.recordWatchStart(provider, id, title).subscribe({ next: () => {}, error: () => {} });
} catch {}
}
toggleDescription() {
this.showFullDescription.update(value => !value);
}
formatNumber(value: number): string {
return new Intl.NumberFormat('en-US', {
notation: 'compact',
compactDisplay: 'short'
}).format(value);
}
// Watch page: French absolute date (e.g., 9 sept. 2025) and localized views label
formatViewsFr(n?: number | null): string {
return `${formatNumberFr(typeof n === 'number' ? n : 0)} visionnements`;
}
formatDateAbsFr(dateIso?: string): string {
return dateIso ? formatAbsoluteFr(dateIso) : '';
}
async onSummarizeComments(): Promise<void> {
const currentVideo = this.video();
if (!currentVideo || currentVideo.comments.length === 0) {
return;
}
// Avoid unnecessary calls if Gemini is not configured
if (!this.geminiReady) {
this.summaryError.set(this.geminiReason || 'AI summary is not available. Configure GEMINI_API_KEY.');
return;
}
this.isSummarizing.set(true);
this.summary.set(null);
this.summaryError.set(null);
try {
const commentTexts = currentVideo.comments.map(c => c.commentText);
const result = await this.geminiService.summarizeComments(commentTexts);
this.summary.set(result);
} catch (error) {
this.summaryError.set('Sorry, we couldn\'t generate a summary at this time. Please try again later.');
console.error(error);
} finally {
this.isSummarizing.set(false);
}
}
onSelectQuality(value: string) {
this.selectedQuality.set(value || null);
}
// --- Download logic ---
openDownloadPanel() {
this.downloadOpen.set(true);
this.downloadError.set(null);
this.loadFormats();
}
closeDownloadPanel() {
this.downloadOpen.set(false);
}
private resetDownloadUi() {
this.downloadOpen.set(false);
this.formatsLoading.set(false);
this.downloadError.set(null);
this.downloadFormats.set([]);
this.selectedFormatId.set(null);
this.audioOnly.set(false);
this.jobId.set(null);
this.jobState.set(null);
this.jobProgress.set(0);
this.jobFileReady.set(false);
this.jobFileSize.set(null);
if (this.jobPoller) {
clearInterval(this.jobPoller);
this.jobPoller = null;
}
}
private buildProviderOpts(): { instance?: string; slug?: string } {
const p = this.provider();
if (p === 'peertube') {
return { instance: this.instances.activePeerTubeInstance() };
}
if (p === 'odysee') {
const slug = this.odyseeSlug();
return slug ? { slug } : {};
}
return {};
}
loadFormats() {
const p = this.provider();
const id = this.videoId();
if (!p || !id) return;
this.formatsLoading.set(true);
this.downloads.listFormats(p, id, this.buildProviderOpts()).subscribe({
next: (res) => {
const list = (res?.formats || []).slice();
// Sort by resolution descending if available
list.sort((a, b) => (parseInt((b.resolution || '').replace(/[^0-9]/g, '')) || 0) - (parseInt((a.resolution || '').replace(/[^0-9]/g, '')) || 0));
this.downloadFormats.set(list);
this.formatsLoading.set(false);
},
error: (err) => {
const status = err?.status || 0;
if (status === 403) this.downloadError.set('Le téléchargement est désactivé pour ce provider.');
else {
const details = (err && err.error && (err.error.details || err.error.error)) || err?.message || '';
this.downloadError.set(details ? `Impossible de récupérer les formats. (${details})` : 'Impossible de récupérer les formats.');
}
this.formatsLoading.set(false);
}
});
}
startDownload() {
const p = this.provider();
const id = this.videoId();
if (!p || !id) return;
this.downloadError.set(null);
const body: any = {};
const fmt = this.selectedFormatId();
if (fmt) body.formatId = fmt;
if (this.audioOnly()) body.audioOnly = true;
this.downloads.startDownload(p, id, body, this.buildProviderOpts()).subscribe({
next: (res) => {
const jobId = res?.jobId;
if (!jobId) {
this.downloadError.set('Le job de téléchargement n\'a pas pu démarrer.');
return;
}
this.jobId.set(jobId);
this.jobState.set('queued');
this.jobProgress.set(0);
this.pollJob(jobId);
},
error: (err) => {
const status = err?.status || 0;
if (status === 429) this.downloadError.set('Trop de téléchargements en parallèle. Réessayez plus tard.');
else if (status === 403) this.downloadError.set('Le téléchargement est désactivé pour ce provider.');
else {
const details = (err && err.error && (err.error.details || err.error.error)) || err?.message || '';
this.downloadError.set(details ? `Échec du démarrage du téléchargement. (${details})` : 'Échec du démarrage du téléchargement.');
}
}
});
}
private pollJob(jobId: string) {
if (this.jobPoller) clearInterval(this.jobPoller);
this.jobPoller = setInterval(() => {
this.downloads.getJob(jobId).subscribe({
next: (job) => {
this.jobState.set(job.state);
this.jobProgress.set(job.progress || 0);
this.jobFileSize.set(job.fileSize ?? null);
if (job.state === 'completed' || job.state === 'failed') {
clearInterval(this.jobPoller);
this.jobPoller = null;
if (job.state === 'completed') this.jobFileReady.set(true);
if (job.state === 'failed') this.downloadError.set(job.error || 'Téléchargement échoué.');
}
},
error: () => {
// Stop polling on error
clearInterval(this.jobPoller);
this.jobPoller = null;
this.downloadError.set('Erreur de suivi du téléchargement.');
}
});
}, 2000);
}
cancelDownload() {
const id = this.jobId();
if (!id) {
this.resetDownloadUi();
return;
}
this.downloads.cancelJob(id).subscribe({ next: () => this.resetDownloadUi(), error: () => this.resetDownloadUi() });
}
downloadFileUrl(): string | null {
const id = this.jobId();
return id ? this.downloads.fileUrl(id) : null;
}
downloadFile(): void {
const id = this.jobId();
if (!id) return;
const url = this.downloads.fileUrl(id);
this.downloadError.set(null);
this.http
.get(url, { responseType: 'blob', observe: 'response', withCredentials: true })
.subscribe({
next: (res) => {
const blob = res.body as Blob;
const cd = res.headers.get('content-disposition') || '';
let filename = 'video';
try {
const m = /filename\*=UTF-8''([^;]+)|filename="?([^";]+)"?/i.exec(cd);
filename = decodeURIComponent((m?.[1] || m?.[2] || 'video').trim());
} catch {}
const a = document.createElement('a');
const objUrl = URL.createObjectURL(blob);
a.href = objUrl;
a.download = filename;
document.body.appendChild(a);
a.click();
setTimeout(() => {
document.body.removeChild(a);
URL.revokeObjectURL(objUrl);
// Return to initial state after download starts
this.resetDownloadUi();
}, 0);
},
error: (err) => {
const details = (err && err.error && (err.error.details || err.error.error)) || err?.message || '';
this.downloadError.set(details ? `Téléchargement impossible. (${details})` : 'Téléchargement impossible.');
},
});
}
formatBytes(n?: number | null): string {
const x = typeof n === 'number' ? n : 0;
if (x <= 0) return '';
const units = ['B','KB','MB','GB','TB'];
const i = Math.floor(Math.log(x) / Math.log(1024));
const val = x / Math.pow(1024, i);
return `${val.toFixed(1)} ${units[i]}`;
}
// Build query params for Watch page (provider + optional odysee slug)
watchQueryParams(v: Video): Record<string, any> | null {
const p = this.provider();
const qp: any = { p };
if (p === 'odysee' && v.url?.startsWith('https://odysee.com/')) {
let slug = v.url.substring('https://odysee.com/'.length);
if (slug.startsWith('/')) slug = slug.slice(1);
qp.slug = slug;
}
if (p === 'twitch' && v.type === 'channel') {
// Prefer explicit videoId as channel login; fallback to parsing URL
if (v.videoId) {
qp.channel = v.videoId;
} else {
const url = v.uploaderUrl || v.url || '';
try {
const u = new URL(url);
const parts = u.pathname.split('/').filter(Boolean);
if (parts.length > 0) qp.channel = parts[0];
} catch {}
}
}
return qp;
}
private loadRelatedSuggestions(): void {
const cur = this.video();
if (!cur) return;
const title = (cur.title || '').trim();
const maxItems = 12;
if (title) {
this.apiService.searchVideosPage(title).subscribe(res => {
const items = (res.items || []).filter(v => v.videoId !== cur.videoId).slice(0, maxItems);
this.video.update(v => v ? { ...v, relatedStreams: items } : v);
});
} else {
this.apiService.getTrendingPage().subscribe(res => {
const items = (res.items || []).filter(v => v.videoId !== cur.videoId).slice(0, maxItems);
this.video.update(v => v ? { ...v, relatedStreams: items } : v);
});
}
}
}
+55
View File
@@ -0,0 +1,55 @@
import { HttpInterceptorFn, HttpErrorResponse } from '@angular/common/http';
import { inject } from '@angular/core';
import { AuthService } from '../services/auth.service';
import { catchError, switchMap } from 'rxjs/operators';
import { throwError, of } from 'rxjs';
export const authInterceptor: HttpInterceptorFn = (req, next) => {
const auth = inject(AuthService);
// Attach Authorization header when we have a token
const token = auth.getAccessToken();
let authReq = req;
const isRefreshCall = req.url.includes('/auth/refresh');
const isApiCall = req.url.startsWith('/proxy/api') || req.url.startsWith('/api');
// Ensure cookies are sent for API calls (dev proxy and prod)
if (req.url.startsWith('/proxy/api') || req.url.startsWith('/api')) {
authReq = authReq.clone({ withCredentials: true });
}
if (token && isApiCall) {
authReq = authReq.clone({ setHeaders: { Authorization: `Bearer ${token}` } });
}
return next(authReq).pipe(
catchError((err) => {
if (!isApiCall) {
// Do not attempt refresh for non-API requests (e.g., YouTube/Twitch/Rumble)
return throwError(() => err);
}
if (err instanceof HttpErrorResponse && err.status === 401) {
// If the 401 is for the refresh endpoint itself, do NOT try to refresh again.
if (isRefreshCall) {
return throwError(() => err);
}
// Try a single refresh then retry the request once
return auth.refresh().pipe(
switchMap((ok) => {
if (!ok) return throwError(() => err);
const newToken = auth.getAccessToken();
let retry = authReq;
if (newToken) {
retry = retry.clone({ setHeaders: { Authorization: `Bearer ${newToken}` } });
}
if (retry.url.startsWith('/proxy/api')) {
retry = retry.clone({ withCredentials: true });
}
return next(retry);
})
);
}
return throwError(() => err);
})
);
};
+44
View File
@@ -0,0 +1,44 @@
export interface ThemeDef {
slug: string;
label: string;
emoji: string;
// Optional search tokens for providers; fallback to label
tokens?: string[];
}
export const THEMES: ThemeDef[] = [
{ label: 'Tendances & Viral', slug: 'trending', emoji: '🔥', tokens: ['Trending', 'Viral'] },
{ label: 'En direct', slug: 'live', emoji: '🎥', tokens: ['Live', 'Streaming'] },
{ label: 'Jeux vidéo & Esports', slug: 'gaming', emoji: '🎮', tokens: ['Gaming', 'Esports'] },
{ label: 'Sports', slug: 'sports', emoji: '🏅' },
{ label: 'Actualités & Politique', slug: 'news', emoji: '🗞️', tokens: ['News', 'Politics'] },
{ label: 'Finance & Crypto', slug: 'finance-crypto', emoji: '💰', tokens: ['Finance', 'Crypto'] },
{ label: 'Business & Startups', slug: 'business', emoji: '🏢', tokens: ['Business', 'Startups'] },
{ label: 'Tech & Gadgets', slug: 'tech', emoji: '🧠', tokens: ['Tech', 'Gadgets'] },
{ label: 'Science & Espace', slug: 'science', emoji: '🚀', tokens: ['Science', 'Space'] },
{ label: 'Santé & Bien-être', slug: 'health', emoji: '🩺', tokens: ['Health', 'Wellness'] },
{ label: 'Musique', slug: 'music', emoji: '🎵', tokens: ['Music'] },
{ label: 'Podcasts & Talk-shows', slug: 'podcasts', emoji: '🎙️', tokens: ['Podcasts', 'Talk shows'] },
{ label: 'Cinéma & Séries', slug: 'movies-tv', emoji: '🎬', tokens: ['Movies', 'TV'] },
{ label: 'Éducation & Tutoriels', slug: 'education', emoji: '🎓', tokens: ['Education', 'Tutorials'] },
{ label: 'Voyage', slug: 'travel', emoji: '🧳', tokens: ['Travel'] },
{ label: 'Cuisine & Alimentation', slug: 'food', emoji: '🍳', tokens: ['Food', 'Cooking'] },
{ label: 'DIY, Bricolage & Makers', slug: 'diy-makers', emoji: '🛠️', tokens: ['DIY', 'Makers'] },
{ label: 'Auto, Moto & Mécanique', slug: 'autos', emoji: '🚗', tokens: ['Cars', 'Auto', 'Moto'] },
{ label: 'Nature & Animaux', slug: 'nature-animals', emoji: '🌿', tokens: ['Nature', 'Animals'] },
{ label: 'Humour & Sketches', slug: 'comedy', emoji: '😂', tokens: ['Comedy'] },
{ label: 'Art, Design & Photo', slug: 'art-design', emoji: '🖌️', tokens: ['Art', 'Design', 'Photography'] },
{ label: 'Vlogs & Lifestyle', slug: 'vlogs', emoji: '📹', tokens: ['Vlogs', 'Lifestyle'] },
{ label: 'Tests, Avis & Unboxings', slug: 'reviews', emoji: '📦', tokens: ['Reviews', 'Unboxing'] },
{ label: 'Documentaires', slug: 'documentary', emoji: '📜', tokens: ['Documentary'] },
{ label: 'Famille & Enfants', slug: 'family-kids', emoji: '👨‍👩‍👧', tokens: ['Family', 'Kids'] },
];
export function slugifyThemeLabel(label: string): string {
return String(label || '')
.normalize('NFKD')
.replace(/[\u0300-\u036f]/g, '')
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '');
}
+53
View File
@@ -0,0 +1,53 @@
export interface Video {
url: string;
type: string;
title: string;
thumbnail: string;
uploaderName: string;
uploaderUrl?: string;
uploaderAvatar: string;
uploadedDate: string;
shortDescription?: string | null;
duration: number;
views: number;
uploaded: number;
videoId: string;
}
export interface VideoDetail extends Video {
description: string;
subscribers: number;
likes: number;
dislikes: number;
videoStreams: VideoStream[];
audioStreams: AudioStream[];
relatedStreams: Video[];
comments: Comment[];
}
export interface VideoStream {
url: string;
format: string;
quality: string;
mimeType: string;
codec: string;
videoOnly: boolean;
}
export interface AudioStream {
url: string;
format: string;
quality: string;
mimeType: string;
codec: string;
}
export interface Comment {
author: string;
thumbnail: string;
commentText: string;
commentedTime: string;
likeCount: number;
replies?: Comment[];
}
+21
View File
@@ -0,0 +1,21 @@
import { Pipe, PipeTransform } from '@angular/core';
import { I18nService } from '../services/i18n.service';
@Pipe({
name: 't',
standalone: true,
pure: false,
})
export class TranslatePipe implements PipeTransform {
constructor(private i18n: I18nService) {}
transform(key: string, paramsOrFallback?: any): string {
// If a string is passed as the second arg, treat it as a fallback text.
// Otherwise, assume it's a params object for interpolation.
const params = (paramsOrFallback && typeof paramsOrFallback === 'object') ? paramsOrFallback : undefined;
const fallback = (typeof paramsOrFallback === 'string') ? paramsOrFallback : undefined;
const val = this.i18n.t(key, params);
// When i18n returns the key unchanged, use the provided fallback if any
if (fallback && val === key) return fallback;
return val;
}
}
+140
View File
@@ -0,0 +1,140 @@
import { Injectable, signal } from '@angular/core';
import { HttpClient, HttpErrorResponse } from '@angular/common/http';
import { Observable, of } from 'rxjs';
import { map, catchError, tap } from 'rxjs/operators';
export interface AuthUser {
id: string;
username: string;
email?: string | null;
}
export interface SessionInfo {
id: string;
userAgent?: string;
deviceInfo?: string;
ip?: string;
isRemember?: number | boolean;
createdAt?: string;
lastSeenAt?: string;
expiresAt?: string;
revokedAt?: string | null;
}
@Injectable({ providedIn: 'root' })
export class AuthService {
private _accessToken = signal<string | null>(null);
private _currentUser = signal<AuthUser | null>(null);
constructor(private http: HttpClient) {}
getAccessToken(): string | null {
return this._accessToken();
}
currentUser() {
return this._currentUser();
}
setCurrentUser(user: AuthUser | null) {
this._currentUser.set(user);
}
setAccessToken(token: string | null) {
this._accessToken.set(token);
}
login(username: string, password: string, rememberMe = false): Observable<AuthUser> {
const body = { username, password, rememberMe };
return this.http.post<any>('/api/auth/login', body, { withCredentials: true }).pipe(
catchError((err: HttpErrorResponse) => {
// Only fallback in dev when /api is missing (404) or on network error (status 0)
if (!err || err.status === 0 || err.status === 404) {
return this.http.post<any>('/proxy/api/auth/login', body, { withCredentials: true });
}
throw err;
}),
tap(res => {
this._accessToken.set(res?.accessToken || null);
this._currentUser.set(res?.user || null);
}),
map(res => res.user as AuthUser)
);
}
register(username: string, password: string, email?: string): Observable<AuthUser> {
const body = { username, password, email };
return this.http.post<any>('/api/auth/register', body, { withCredentials: true }).pipe(
catchError((err: HttpErrorResponse) => {
if (!err || err.status === 0 || err.status === 404) {
return this.http.post<any>('/proxy/api/auth/register', body, { withCredentials: true });
}
throw err;
}),
tap(res => {
this._accessToken.set(res?.accessToken || null);
this._currentUser.set(res?.user || null);
}),
map(res => res.user as AuthUser)
);
}
refresh(): Observable<boolean> {
return this.http.post<any>('/api/auth/refresh', {}, { withCredentials: true }).pipe(
catchError((err: HttpErrorResponse) => {
if (!err || err.status === 0 || err.status === 404) {
return this.http.post<any>('/proxy/api/auth/refresh', {}, { withCredentials: true });
}
throw err;
}),
tap(res => {
const token = res?.accessToken || null;
this._accessToken.set(token);
}),
map(() => true),
catchError(() => {
this._accessToken.set(null);
return of(false);
})
);
}
logout(allDevices = false): Observable<void> {
const body = { allDevices };
return this.http.post<void>('/api/auth/logout', body, { withCredentials: true }).pipe(
catchError((err: HttpErrorResponse) => {
if (!err || err.status === 0 || err.status === 404) {
return this.http.post<void>('/proxy/api/auth/logout', body, { withCredentials: true });
}
throw err;
}),
tap(() => {
this._accessToken.set(null);
this._currentUser.set(null);
})
);
}
listSessions(): Observable<SessionInfo[]> {
return this.http.get<SessionInfo[]>('/api/auth/sessions', { withCredentials: true }).pipe(
catchError((err: HttpErrorResponse) => {
if (!err || err.status === 0 || err.status === 404) {
return this.http.get<SessionInfo[]>('/proxy/api/auth/sessions', { withCredentials: true });
}
throw err;
})
);
}
revokeSession(id: string): Observable<void> {
const url = `/api/auth/sessions/${encodeURIComponent(id)}`;
return this.http.delete<void>(url, { withCredentials: true }).pipe(
catchError((err: HttpErrorResponse) => {
if (!err || err.status === 0 || err.status === 404) {
return this.http.delete<void>(`/proxy/api/auth/sessions/${encodeURIComponent(id)}`, { withCredentials: true });
}
throw err;
})
);
}
}
+80
View File
@@ -0,0 +1,80 @@
import { Injectable } from '@angular/core';
import { HttpClient, HttpParams } from '@angular/common/http';
import { Observable } from 'rxjs';
export interface DownloadFormat {
id: string;
resolution?: string;
fps?: number;
ext?: string;
vcodec?: string;
acodec?: string;
sizeEstimate?: number;
label: string;
}
export type DownloadState = 'queued' | 'running' | 'merging' | 'completed' | 'failed';
export interface DownloadJob {
id: string;
userId: string;
provider: string;
videoId: string;
state: DownloadState;
progress: number;
createdAt: string;
updatedAt: string;
filePath?: string | null;
fileExt?: string | null;
fileSize?: number | null;
error?: string | null;
url?: string;
}
@Injectable({ providedIn: 'root' })
export class DownloadService {
constructor(private http: HttpClient) {}
listFormats(
provider: string,
videoId: string,
opts?: { instance?: string; slug?: string; sourceUrl?: string }
): Observable<{ url: string; formats: DownloadFormat[]; title: string; duration: number }> {
let params = new HttpParams();
if (opts?.instance) params = params.set('instance', opts.instance);
if (opts?.slug) params = params.set('slug', opts.slug);
if (opts?.sourceUrl) params = params.set('sourceUrl', opts.sourceUrl);
return this.http.get<{ url: string; formats: DownloadFormat[]; title: string; duration: number }>(
`/proxy/api/download/${encodeURIComponent(provider)}/${encodeURIComponent(videoId)}/formats`,
{ params, withCredentials: true }
);
}
startDownload(
provider: string,
videoId: string,
body?: { formatId?: string; audioOnly?: boolean; sourceUrl?: string },
opts?: { instance?: string; slug?: string }
): Observable<{ jobId: string }> {
let params = new HttpParams();
if (opts?.instance) params = params.set('instance', opts.instance);
if (opts?.slug) params = params.set('slug', opts.slug);
return this.http.post<{ jobId: string }>(
`/proxy/api/download/${encodeURIComponent(provider)}/${encodeURIComponent(videoId)}`,
body || {},
{ params, withCredentials: true }
);
}
getJob(id: string): Observable<DownloadJob> {
return this.http.get<DownloadJob>(`/proxy/api/download/jobs/${encodeURIComponent(id)}`, { withCredentials: true });
}
cancelJob(id: string): Observable<void> {
return this.http.delete<void>(`/proxy/api/download/jobs/${encodeURIComponent(id)}`, { withCredentials: true });
}
fileUrl(id: string): string {
return `/proxy/api/download/jobs/${encodeURIComponent(id)}/file`;
}
}
+76
View File
@@ -0,0 +1,76 @@
import { Injectable } from '@angular/core';
import { GoogleGenAI } from '@google/genai';
@Injectable({
providedIn: 'root',
})
export class GeminiService {
private ai: GoogleGenAI;
private readyFlag = false;
private readinessMsg: string | undefined;
constructor() {
const apiKey = this.resolveApiKey();
if (!apiKey) {
// This error will be caught in the console if the API key is missing.
// The app continues to work – only comment summarization is disabled.
console.warn('Gemini API key not found. Set GEMINI_API_KEY to enable summarization.');
// Create a dummy object to avoid crashing if key is not set.
this.ai = {} as GoogleGenAI;
this.readyFlag = false;
this.readinessMsg = 'La fonctionnalité de résumé IA est désactivée : configurez GEMINI_API_KEY dans assets/config.local.js puis rechargez la page.';
return;
}
this.ai = new GoogleGenAI({ apiKey });
this.readyFlag = true;
this.readinessMsg = undefined;
}
async summarizeComments(comments: string[]): Promise<string> {
// If API key was not supplied, the client will be a dummy object.
if (!(this.ai as any).models) {
throw new Error(this.readinessMsg || 'Gemini API client is not initialized. Is the API key missing?');
}
if (comments.length === 0) {
return 'There are no comments to summarize.';
}
const commentsText = comments.join('\n- ');
const prompt = `Please summarize the following YouTube comments. Provide a concise, neutral overview of the general sentiment and the main topics discussed. Do not add any preamble or sign-off. Just provide the summary. Here are the comments:\n\n- ${commentsText}`;
try {
const response = await (this.ai as any).models.generateContent({
model: 'gemini-2.5-flash',
contents: prompt,
});
return response.text;
} catch (error) {
console.error('Error summarizing comments:', error);
throw new Error('Failed to generate summary from comments.');
}
}
// Attempt to resolve the API key from multiple sources so the app works across tools/builds.
private resolveApiKey(): string | undefined {
// 1) Expose a global variable via <script> on window or globalThis
const fromGlobal = (globalThis as any)?.GEMINI_API_KEY || (window as any)?.GEMINI_API_KEY;
// 2) Vite-style env (if present in a different toolchain)
const fromImportMeta = (typeof import.meta !== 'undefined' && (import.meta as any).env)
? ((import.meta as any).env.GEMINI_API_KEY || (import.meta as any).env.VITE_GEMINI_API_KEY)
: undefined;
// 3) Node/bundler style replacement (rare in browser-only setup)
const fromProcess = (globalThis as any)?.process?.env
? ((globalThis as any).process.env.GEMINI_API_KEY || (globalThis as any).process.env.API_KEY)
: undefined;
return fromGlobal || fromImportMeta || fromProcess || undefined;
}
// Public readiness helpers for UI
isReady(): boolean { return this.readyFlag; }
readinessReason(): string | undefined { return this.readinessMsg; }
}
+67
View File
@@ -0,0 +1,67 @@
import { Injectable } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { Observable } from 'rxjs';
export interface SearchHistoryItem {
id: string;
query: string;
filters_json?: string | null;
created_at: string;
}
export interface WatchHistoryItem {
id: string;
provider: string;
video_id: string;
title?: string | null;
watched_at: string;
progress_seconds: number;
duration_seconds: number;
last_position_seconds: number;
last_watched_at?: string;
}
@Injectable({ providedIn: 'root' })
export class HistoryService {
constructor(private http: HttpClient) {}
// --- Search ---
recordSearch(query: string, filters?: Record<string, any>): Observable<{ id: string; created_at: string }> {
return this.http.post<{ id: string; created_at: string }>(
'/proxy/api/user/history/search',
{ query, filters },
{ withCredentials: true }
);
}
getSearchHistory(limit = 50, before?: string): Observable<SearchHistoryItem[]> {
const params = new URLSearchParams();
params.set('limit', String(limit));
if (before) params.set('before', before);
return this.http.get<SearchHistoryItem[]>(`/proxy/api/user/history/search?${params.toString()}`, { withCredentials: true });
}
// --- Watch ---
recordWatchStart(provider: string, videoId: string, title?: string | null, watchedAt?: string): Observable<WatchHistoryItem> {
return this.http.post<WatchHistoryItem>(
'/proxy/api/user/history/watch',
{ provider, videoId, title: title ?? null, watchedAt },
{ withCredentials: true }
);
}
updateWatchProgress(id: string, progressSeconds: number, lastPositionSeconds?: number): Observable<WatchHistoryItem> {
return this.http.patch<WatchHistoryItem>(
`/proxy/api/user/history/watch/${encodeURIComponent(id)}`,
{ progressSeconds, lastPositionSeconds },
{ withCredentials: true }
);
}
getWatchHistory(limit = 50, before?: string): Observable<WatchHistoryItem[]> {
const params = new URLSearchParams();
params.set('limit', String(limit));
if (before) params.set('before', before);
return this.http.get<WatchHistoryItem[]>(`/proxy/api/user/history/watch?${params.toString()}`, { withCredentials: true });
}
}
+233
View File
@@ -0,0 +1,233 @@
import { Injectable, computed, signal } from '@angular/core';
export type LangCode = 'en' | 'fr';
// Simple key-value dictionaries. Extend as needed.
const EN: Record<string, string> = {
'app.name': 'NewTube',
// Header
'search.placeholder': 'Search...',
'label.provider': 'Provider',
'label.region': 'Region',
// Regions
'region.US': 'United States',
'region.FR': 'France',
'region.BR': 'Brazil',
'region.DE': 'Germany',
'region.GB': 'United Kingdom',
'region.CA': 'Canada',
'btn.edit': 'Edit',
'btn.login': 'Login',
'btn.register': 'Register',
'menu.account': 'Account',
'menu.preferences': 'Preferences',
'menu.appearance': 'Appearance',
'menu.logout': 'Logout',
'theme.black': 'Black',
'theme.dark': 'Dark',
'theme.blue': 'Blue',
'theme.light': 'Light',
// Preferences
'preferences.title': 'Preferences',
'preferences.general': 'General',
'preferences.general_hint': 'Customize your experience',
'preferences.peertube_hint': 'Manage your PeerTube instances',
'preferences.add_instance_placeholder': 'e.g. example.org',
'preferences.no_instances': 'No instances yet.',
'preferences.active': 'Active',
'preferences.set_active': 'Set Active',
'label.language': 'Language',
'label.defaultProvider': 'Default Provider',
'label.theme': 'Theme',
'label.quality': 'Default Quality',
'btn.save': 'Save',
'btn.add': 'Add',
'btn.remove': 'Remove',
// Sidebar
'nav.home': 'Home',
'nav.shorts': 'Shorts',
'nav.subscriptions': 'Subscriptions',
'nav.you': 'You',
'nav.history': 'History',
'nav.playlists': 'Playlists',
'nav.liked': 'Liked videos',
'nav.explore': 'Explore',
'explore.music': 'Music',
'explore.gaming': 'Gaming',
'explore.news': 'News',
'explore.sports': 'Sports',
'explore.learning': 'Learning',
// Home
'home.trending': 'Trending',
'loading.more': 'Loading more...',
'search.hint': 'Type something in the search bar to get started.',
'search.noResults': 'No results found.',
// Themes + Provider/Theme pages
'themes.viewAll': 'View All',
'themes.live': 'Live Streams',
'themes.recorded': 'Recorded Streams',
'themes.videos': 'Videos',
'themes.categories': 'Categories',
'themes.backToThemes': 'Back to themes',
'themes.changeProvider': 'Change provider',
// Common
'common.error': 'An error occurred.',
'empty.noItems': 'No items.',
'common.more': 'More',
'common.less': 'Less',
// Filters
'filter.sort': 'Sort by',
'filter.sort.recent': 'Most recent',
'filter.sort.viewed': 'Most viewed',
'filter.sort.longest': 'Longest',
'filter.duration': 'Duration',
'filter.duration.any': 'Any',
'filter.duration.short': 'Short (<10m)',
'filter.duration.medium': 'Medium (10–30m)',
'filter.duration.long': 'Long (30m+)',
'filter.language': 'Language',
'filter.date': 'Date',
// Optional per-theme labels
'theme.finance-crypto': 'Finance & Crypto',
'theme.gaming': 'Gaming',
'theme.news': 'News',
'theme.sports': 'Sports',
};
const FR: Record<string, string> = {
'app.name': 'NewTube',
// Header
'search.placeholder': 'Rechercher...',
'label.provider': 'Fournisseur',
'label.region': 'Région',
// Régions
'region.US': 'États-Unis',
'region.FR': 'France',
'region.BR': 'Brésil',
'region.DE': 'Allemagne',
'region.GB': 'Royaume-Uni',
'region.CA': 'Canada',
'btn.edit': 'Modifier',
'btn.login': 'Connexion',
'btn.register': 'Inscription',
'menu.account': 'Compte',
'menu.preferences': 'Préférences',
'menu.appearance': 'Apparence',
'menu.logout': 'Déconnexion',
'theme.black': 'Noir',
'theme.dark': 'Sombre',
'theme.blue': 'Bleu',
'theme.light': 'Clair',
// Preferences
'preferences.title': 'Préférences',
'preferences.general': 'Préférences générales',
'preferences.general_hint': 'Personnalisez votre expérience',
'preferences.peertube_hint': 'Gérez vos instances PeerTube',
'preferences.add_instance_placeholder': 'ex: example.org',
'preferences.no_instances': 'Aucune instance pour le moment.',
'preferences.active': 'Actif',
'preferences.set_active': 'Définir actif',
'label.language': 'Langue',
'label.defaultProvider': 'Fournisseur par défaut',
'label.theme': 'Thème',
'label.quality': 'Qualité par défaut',
'btn.save': 'Enregistrer',
'btn.add': 'Ajouter',
'btn.remove': 'Supprimer',
// Sidebar
'nav.home': 'Accueil',
'nav.shorts': 'Shorts',
'nav.subscriptions': 'Abonnements',
'nav.you': 'Vous',
'nav.history': 'Historique',
'nav.playlists': 'Listes de lecture',
'nav.liked': 'Vidéos que vous aimez',
'nav.explore': 'Explorer',
'explore.music': 'Musique',
'explore.gaming': 'Jeux vidéo',
'explore.news': 'Actualités',
'explore.sports': 'Sports',
'explore.learning': 'Apprentissage',
// Home
'home.trending': 'Tendances',
'loading.more': 'Chargement...',
'search.hint': 'Tapez quelque chose dans la barre de recherche pour commencer.',
'search.noResults': 'Aucun résultat.',
// Themes + Provider/Theme pages
'themes.viewAll': 'Voir tout',
'themes.live': 'En direct',
'themes.recorded': 'Rediffusions',
'themes.videos': 'Vidéos',
'themes.categories': 'Catégories',
'themes.backToThemes': 'Retour aux thèmes',
'themes.changeProvider': 'Changer de provider',
// Common
'common.error': 'Une erreur est survenue.',
'empty.noItems': 'Aucun élément.',
'common.more': 'Plus',
'common.less': 'Moins',
// Filters
'filter.sort': 'Tri',
'filter.sort.recent': 'Plus récents',
'filter.sort.viewed': 'Plus vus',
'filter.sort.longest': 'Plus longs',
'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.language': 'Langue',
'filter.date': 'Date',
// Optional per-theme labels
'theme.finance-crypto': 'Finance & Crypto',
'theme.gaming': 'Jeux vidéo',
'theme.news': 'Actualités',
'theme.sports': 'Sports',
};
@Injectable({ providedIn: 'root' })
export class I18nService {
private readonly LS_KEY = 'newtube.lang';
private readonly dictionaries: Record<string, Record<string, string>> = { en: EN, fr: FR };
private readonly defaultLang: LangCode = 'en';
// initialize from localStorage, fallback 'en'
lang = signal<LangCode>((() => {
try {
const ls = (localStorage.getItem(this.LS_KEY) || '').toLowerCase();
return (ls === 'fr' ? 'fr' : 'en') as LangCode;
} catch {
return 'en';
}
})());
readonly currentDict = computed(() => this.dictionaries[this.lang()] || this.dictionaries[this.defaultLang]);
constructor() {
try {
if (typeof document !== 'undefined') {
document.documentElement.setAttribute('lang', this.lang());
}
} catch {}
}
setLanguage(l: string | null | undefined) {
const v = (String(l || '').toLowerCase() === 'fr') ? 'fr' : 'en';
this.lang.set(v as LangCode);
try {
localStorage.setItem(this.LS_KEY, v);
if (typeof document !== 'undefined') {
document.documentElement.setAttribute('lang', v);
}
} catch {}
}
t(key: string, params?: Record<string, any>): string {
const dict = this.currentDict();
const base = dict[key] ?? this.dictionaries[this.defaultLang][key] ?? key;
if (params) {
return Object.keys(params).reduce((acc, k) => acc.replace(new RegExp('{' + k + '}', 'g'), String(params[k])), base);
}
return base;
}
}
+126
View File
@@ -0,0 +1,126 @@
import { Injectable, computed, signal } from '@angular/core';
const LS_REGION_KEY = 'newtube.region';
const LS_PROVIDER_KEY = 'newtube.provider';
const LS_PEERTUBE_INSTANCES_KEY = 'newtube.peertube.instances';
const LS_PEERTUBE_ACTIVE_INSTANCE_KEY = 'newtube.peertube.active_instance';
export type Provider = 'youtube' | 'dailymotion' | 'twitch' | 'peertube' | 'rumble' | 'odysee';
export interface ProviderInfo {
id: Provider;
label: string;
}
@Injectable({ providedIn: 'root' })
export class InstanceService {
readonly providers = signal<ProviderInfo[]>([
{ id: 'youtube', label: 'YouTube' },
{ id: 'dailymotion', label: 'Dailymotion' },
{ id: 'twitch', label: 'Twitch' },
{ id: 'peertube', label: 'PeerTube' },
{ id: 'rumble', label: 'Rumble' },
{ id: 'odysee', label: 'Odysee' },
]);
private storedRegion = ((): string | null => {
try { return localStorage.getItem(LS_REGION_KEY); } catch { return null; }
})();
private storedProvider = ((): Provider | null => {
try { return (localStorage.getItem(LS_PROVIDER_KEY) as Provider) || null; } catch { return null; }
})();
readonly region = signal<string>(this.storedRegion || 'US');
readonly selectedProvider = signal<Provider>(this.storedProvider || 'youtube');
readonly selectedProviderLabel = computed(() => {
const cur = this.selectedProvider();
return this.providers().find(p => p.id === cur)?.label || cur;
});
// Centralized provider readiness check to avoid unnecessary requests
// and provide user-facing reasons when configuration is missing.
getProviderReadiness(provider?: Provider): { ready: boolean; reason?: string } {
const p = provider || this.selectedProvider();
const g: any = globalThis as any;
if (p === 'youtube') {
const hasKey = !!(
g?.YOUTUBE_API_KEY ||
g?.process?.env?.YOUTUBE_API_KEY ||
((typeof import.meta !== 'undefined' && (import.meta as any).env)
? ((import.meta as any).env.YOUTUBE_API_KEY || (import.meta as any).env.NG_APP_YOUTUBE_API_KEY)
: undefined)
);
if (!hasKey) {
return { ready: false, reason: 'YouTube is selected but YOUTUBE_API_KEY is missing. Create assets/config.local.js with window.YOUTUBE_API_KEY = "..." and reload.' };
}
return { ready: true };
}
if (p === 'twitch') {
const im: any = (typeof import.meta !== 'undefined' && (import.meta as any).env) ? (import.meta as any).env : undefined;
const hasClientId = !!(g?.TWITCH_CLIENT_ID || im?.TWITCH_CLIENT_ID || g?.process?.env?.TWITCH_CLIENT_ID);
const hasSecret = !!(g?.TWITCH_CLIENT_SECRET || im?.TWITCH_CLIENT_SECRET || g?.process?.env?.TWITCH_CLIENT_SECRET);
const hasCreds = hasClientId && hasSecret;
if (!hasCreds) {
return { ready: false, reason: 'Twitch requires TWITCH_CLIENT_ID and TWITCH_CLIENT_SECRET in assets/config.local.js.' };
}
return { ready: true };
}
if (p === 'peertube') {
const inst = this.activePeerTubeInstance();
if (!inst) {
return { ready: false, reason: 'No active PeerTube instance selected. Choose one in the header.' };
}
return { ready: true };
}
if (p === 'rumble') {
// Rumble utilise maintenant le web scraping, pas d'API key requise
return { ready: true };
}
if (p === 'odysee') {
// Pas de clé requise pour Odysee via l’API proxy.
return { ready: true };
}
// Dailymotion or others: assume ready
return { ready: true };
}
setRegion(region: string): void {
this.region.set(region);
try { localStorage.setItem(LS_REGION_KEY, region); } catch {}
}
setSelectedProvider(provider: Provider): void {
this.selectedProvider.set(provider);
try { localStorage.setItem(LS_PROVIDER_KEY, provider); } catch {}
}
// PeerTube specific instance management
private defaultPeerTubeInstances = ['video.manu.quebec', 'peerate.fr', 'mytube.pyramix.ca'];
private storedPeerTubeInstances = ((): string[] => {
try { return JSON.parse(localStorage.getItem(LS_PEERTUBE_INSTANCES_KEY) || 'null') || this.defaultPeerTubeInstances; } catch { return this.defaultPeerTubeInstances; }
})();
private storedActivePeerTubeInstance = ((): string | null => {
try { return localStorage.getItem(LS_PEERTUBE_ACTIVE_INSTANCE_KEY); } catch { return null; }
})();
readonly peerTubeInstances = signal<string[]>(this.storedPeerTubeInstances);
readonly activePeerTubeInstance = signal<string>(this.storedActivePeerTubeInstance || this.peerTubeInstances()[0]);
setPeerTubeInstances(instances: string[]): void {
this.peerTubeInstances.set(instances);
try { localStorage.setItem(LS_PEERTUBE_INSTANCES_KEY, JSON.stringify(instances)); } catch {}
}
setActivePeerTubeInstance(instance: string): void {
this.activePeerTubeInstance.set(instance);
try { localStorage.setItem(LS_PEERTUBE_ACTIVE_INSTANCE_KEY, instance); } catch {}
}
}
+81
View File
@@ -0,0 +1,81 @@
import { Injectable, computed, signal } from '@angular/core';
import { ThemeDef, THEMES } from '../models/theme.model';
import { I18nService } from './i18n.service';
export interface ThemeCategory {
key: string;
label: string;
emoji?: string;
}
@Injectable({ providedIn: 'root' })
export class ThemesService {
private readonly list = signal<ThemeDef[]>(THEMES);
// Active theme slug derived from router (set externally by the theme bar)
readonly activeSlug = signal<string>('trending');
// Controls whether the themes nav should show all themes or only a subset (e.g., 8)
readonly showAll = signal<boolean>(false);
constructor(private i18n: I18nService) {}
themes() { return this.list(); }
setShowAll(v: boolean) { this.showAll.set(!!v); }
toggleShowAll() { this.showAll.update(x => !x); }
bySlug(slug: string | null | undefined): ThemeDef | undefined {
if (!slug) return undefined;
const s = String(slug).toLowerCase();
return this.list().find(t => t.slug === s);
}
label(t: ThemeDef): string {
// Full label including emoji
const v = this.i18nLabel(t);
return `${t.emoji} ${v}`;
}
// Label without emoji, i18n-aware
i18nLabel(t: ThemeDef): string {
const key = `theme.${t.slug}`;
const v = this.i18n.t(key);
return v && v !== key ? v : t.label;
}
tokensFor(slug: string): string[] {
const t = this.bySlug(slug);
if (!t) return [];
const base = t.tokens && t.tokens.length ? t.tokens : [t.label];
return base;
}
// Best-effort suggested categories per theme (static for now)
categoriesFor(slug: string): ThemeCategory[] {
switch (slug) {
case 'finance-crypto':
return [
{ key: 'stock-market', label: 'Stock Market', emoji: '📈' },
{ key: 'crypto', label: 'Crypto', emoji: '🪙' },
{ key: 'economy', label: 'Economy', emoji: '🏦' },
];
case 'gaming':
return [
{ key: 'esports', label: 'Esports', emoji: '🎮' },
{ key: 'retro', label: 'Retro', emoji: '🕹️' },
{ key: 'fps', label: 'FPS', emoji: '🎯' },
];
case 'news':
return [
{ key: 'politics', label: 'Politics', emoji: '🏛️' },
{ key: 'world', label: 'World', emoji: '🌍' },
{ key: 'tech', label: 'Tech', emoji: '🧠' },
];
default:
return [
{ key: 'featured', label: 'Featured', emoji: '⭐' },
{ key: 'popular', label: 'Popular', emoji: '🔥' },
{ key: 'recent', label: 'Recent', emoji: '🕒' },
];
}
}
}
+41
View File
@@ -0,0 +1,41 @@
import { Injectable, signal } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { Observable } from 'rxjs';
import { tap } from 'rxjs/operators';
import type { AuthUser } from './auth.service';
export interface UserPreferences {
language?: string | null;
defaultProvider?: string | null;
theme?: 'light' | 'dark' | 'black' | 'system' | string | null;
videoQuality?: string | null;
region?: string | null;
version?: number | null;
}
@Injectable({ providedIn: 'root' })
export class UserService {
private _preferences = signal<UserPreferences | null>(null);
constructor(private http: HttpClient) {}
preferences() {
return this._preferences();
}
loadPreferences(): Observable<UserPreferences> {
return this.http.get<UserPreferences>('/proxy/api/user/preferences', { withCredentials: true }).pipe(
tap(p => this._preferences.set(p || {}))
);
}
updatePreferences(patch: Partial<UserPreferences>): Observable<UserPreferences> {
return this.http.patch<UserPreferences>('/proxy/api/user/preferences', patch, { withCredentials: true }).pipe(
tap(p => this._preferences.set(p || {}))
);
}
loadMe(): Observable<AuthUser> {
return this.http.get<AuthUser>('/proxy/api/user/me', { withCredentials: true });
}
}
+930
View File
@@ -0,0 +1,930 @@
import { Injectable } from '@angular/core';
import { HttpClient, HttpHeaders } from '@angular/common/http';
import { Observable, of, BehaviorSubject, forkJoin } from 'rxjs';
import { catchError, map, switchMap, shareReplay, first } from 'rxjs/operators';
import { Video, VideoDetail, Comment } from '../models/video.model';
import { InstanceService, Provider } from './instance.service';
import { I18nService } from './i18n.service';
export interface PagedResult<T> {
items: T[];
nextCursor?: string | null;
}
@Injectable({ providedIn: 'root' })
export class YoutubeApiService {
private twitchAuthToken$ = new BehaviorSubject<string | null>(null);
// Quota exceeded flag to surface to UI components
public quotaExceeded$ = new BehaviorSubject<boolean>(false);
// Lightweight in-memory caches (session scope)
private readonly CACHE_TTL_MS = 10 * 60 * 1000; // 10 minutes
private searchCache = new Map<string, { ts: number; data: PagedResult<Video> }>();
private trendingCache = new Map<string, { ts: number; data: PagedResult<Video> }>();
// ---- YouTube multi-key rotation ----
private ytKeys: string[] = [];
private ytKeyIndex = 0;
private ytKeyBans = new Map<string, number>(); // key -> bannedUntil epoch ms
// Ban duration (default 6h) can be overridden via window.YT_KEY_BAN_MS
private readonly YT_KEY_BAN_MS = (() => {
try { const v = Number((globalThis as any)?.YT_KEY_BAN_MS); return Number.isFinite(v) && v > 0 ? v : 6 * 60 * 60 * 1000; } catch { return 6 * 60 * 60 * 1000; }
})();
constructor(private http: HttpClient, private instances: InstanceService, private i18n: I18nService) {
this.getTwitchAuthToken().subscribe(this.twitchAuthToken$);
this.ytKeys = this.loadYouTubeKeys();
}
// ... (rest of the code remains the same)
// ===================== Twitch Search Helpers =====================
/** Search live channels matching query (around 20), with pagination cursor. */
public searchTwitchChannelsPage(query: string, cursor?: string | null): Observable<PagedResult<Video>> {
return this.getTwitchAuthToken().pipe(
switchMap(token => {
if (!token) return of({ items: [], nextCursor: null });
const headers = new HttpHeaders({ 'Client-ID': this.resolveTwitchClientId()!, 'Authorization': `Bearer ${token}` });
const after = cursor ? `&after=${encodeURIComponent(cursor)}` : '';
const primary = `/api/twitch-api/helix/search/channels?query=${encodeURIComponent(query)}&first=20&live_only=true${after}`;
const fallback = `/proxy/twitch-api/helix/search/channels?query=${encodeURIComponent(query)}&first=20&live_only=true${after}`;
return this.http.get<any>(primary, { headers }).pipe(
catchError(() => this.http.get<any>(fallback, { headers })),
switchMap(res => {
const data = Array.isArray(res?.data) ? res.data : [];
const live = data.filter((i: any) => i?.is_live === true).slice(0, 20);
const baseItems = live.map((i: any) => this.mapTwitchChannelToVideo(i));
const logins: string[] = live.map((i: any) => i?.broadcaster_login || i?.user_login).filter(Boolean);
if (logins.length === 0) {
return of({ items: baseItems, nextCursor: res?.pagination?.cursor || null } as PagedResult<Video>);
}
// Fetch viewer counts for these channels
const queryLogins = logins.map(l => `user_login=${encodeURIComponent(l)}`).join('&');
const sPrimary = `/api/twitch-api/helix/streams?${queryLogins}`;
const sFallback = `/proxy/twitch-api/helix/streams?${queryLogins}`;
return this.http.get<any>(sPrimary, { headers }).pipe(
catchError(() => this.http.get<any>(sFallback, { headers })),
map((sres: any) => {
const streams: any[] = Array.isArray(sres?.data) ? sres.data : [];
const vc = new Map<string, number>();
for (const s of streams) {
const login = s?.user_login || s?.broadcaster_login;
if (login) vc.set(String(login).toLowerCase(), Number(s?.viewer_count || 0));
}
const items = baseItems.map((v: Video) => {
const key = String(v.videoId || '').toLowerCase();
const views = vc.get(key);
return typeof views === 'number' ? { ...v, views } : v;
});
return { items, nextCursor: res?.pagination?.cursor || null } as PagedResult<Video>;
}),
catchError(() => of({ items: baseItems, nextCursor: res?.pagination?.cursor || null } as PagedResult<Video>))
);
}),
catchError((e) => { console.error('Twitch search channels error', e); return of({ items: [], nextCursor: null }); })
);
})
);
}
/** Search VODs: find channels by query then fetch recent videos per channel; flatten to ~20. */
public searchTwitchVodsPage(query: string, cursor?: string | null): Observable<PagedResult<Video>> {
return this.getTwitchAuthToken().pipe(
switchMap(token => {
if (!token) return of({ items: [], nextCursor: null });
const headers = new HttpHeaders({ 'Client-ID': this.resolveTwitchClientId()!, 'Authorization': `Bearer ${token}` });
const after = cursor ? `&after=${encodeURIComponent(cursor)}` : '';
const chPrimary = `/api/twitch-api/helix/search/channels?query=${encodeURIComponent(query)}&first=10${after}`;
const chFallback = `/proxy/twitch-api/helix/search/channels?query=${encodeURIComponent(query)}&first=10${after}`;
const fetchChannels$ = this.http.get<any>(chPrimary, { headers }).pipe(
catchError(() => this.http.get<any>(chFallback, { headers }))
);
return fetchChannels$.pipe(
switchMap((chRes: any) => {
const channels = Array.isArray(chRes?.data) ? chRes.data : [];
const userIds: string[] = channels.map((c: any) => c?.id).filter(Boolean).slice(0, 10);
if (userIds.length === 0) return of({ items: [], nextCursor: null } as PagedResult<Video>);
const perUserRequests = userIds.map(uid => {
// type=all to include archive, upload, highlight; fetch a few more to increase hit rate
const vPrimary = `/api/twitch-api/helix/videos?user_id=${encodeURIComponent(uid)}&first=10&type=all`;
const vFallback = `/proxy/twitch-api/helix/videos?user_id=${encodeURIComponent(uid)}&first=10&type=all`;
return this.http.get<any>(vPrimary, { headers }).pipe(
catchError(() => this.http.get<any>(vFallback, { headers })),
map(vres => Array.isArray(vres?.data) ? vres.data : [])
);
});
return forkJoin(perUserRequests).pipe(
map((lists: any[][]) => lists.flat().slice(0, 20)),
map((videos: any[]) => ({
items: videos.map((i: any) => this.mapTwitchItemToVideo(i)),
nextCursor: chRes?.pagination?.cursor || null,
} as PagedResult<Video>))
);
}),
catchError((e) => { console.error('Twitch search VODs error', e); return of({ items: [], nextCursor: null }); })
);
})
);
}
private fetchOdysee(body: any) {
const prod = `/api/odysee/api/v1/proxy`;
const dev = `/proxy/odysee/api/v1/proxy`;
return this.http.post<any>(prod, body).pipe(
catchError(() => this.http.post<any>(dev, body))
);
}
private lbryToOdyseeUrl(lbryUrl: string): string {
if (!lbryUrl || typeof lbryUrl !== 'string') return '';
// Convert lbry://@Channel#id/Video#id => https://odysee.com/@Channel:id/Video:id
try {
const noScheme = lbryUrl.replace('lbry://', '');
const parts = noScheme.split('/');
const segs = parts.map(seg => seg.replace('#', ':'));
return `https://odysee.com/${segs.join('/')}`;
} catch {
return '';
}
}
getTrendingVideos(): Observable<Video[]> {
return this.getTrendingPage().pipe(map(r => r.items));
}
searchVideos(query: string): Observable<Video[]> {
return this.searchVideosPage(query).pipe(map(r => r.items));
}
getVideoDetails(id: string): Observable<VideoDetail | null> {
// Not implemented yet per-provider; return null so Watch page shows fallback.
return of(null);
}
// Pagination-enabled APIs
getTrendingPage(cursor?: string | null, providerOverride?: Provider | null): Observable<PagedResult<Video>> {
const provider = providerOverride || this.instances.selectedProvider();
const region = this.instances.region();
switch (provider) {
case 'youtube': {
const key = this.getActiveYouTubeKey();
if (!key) {
console.warn('YOUTUBE_API_KEY not found. Set it to enable YouTube trending.');
return of({ items: [], nextCursor: null });
}
const params = new URLSearchParams({
chart: 'mostPopular',
regionCode: String(region),
maxResults: '24',
part: 'snippet,contentDetails,statistics',
key: String(key),
});
if (cursor) params.set('pageToken', cursor);
// Cache key: provider|chart|region|cursor
const cacheKey = `yt:trending|${region}|${cursor || ''}`;
const cached = this.trendingCache.get(cacheKey);
const now = Date.now();
if (cached && (now - cached.ts) < this.CACHE_TTL_MS) {
return of(cached.data);
}
const path = `/youtube/v3/videos`;
return this.fetchYouTube(path, params, key).pipe(
map(res => ({
items: (res.items || []).map((i: any) => this.mapYouTubeItemToVideo(i)),
nextCursor: res.nextPageToken || null,
} as PagedResult<Video>)),
map((out) => {
this.trendingCache.set(cacheKey, { ts: now, data: out });
return out;
}),
catchError((e) => {
// Surface quotaExceeded if present
try {
if (e?.status === 403 && (e?.error?.error?.errors?.[0]?.reason === 'quotaExceeded' || /quota/i.test(String(e?.error?.error?.message || e?.message || '')))) {
this.quotaExceeded$.next(true);
this.banYouTubeKey(key);
}
} catch {}
console.error('YouTube trending error', e);
return of({ items: [], nextCursor: null });
})
);
}
case 'odysee': {
// Use LBRY/Odysee claim_search with trending order
const page = cursor ? Number(cursor) : 1;
const body = {
method: 'claim_search',
params: {
page: page,
page_size: 24,
no_totals: true,
claim_type: 'stream',
stream_types: ['video'],
order_by: ['trending_group', 'trending_mixed']
}
};
return this.fetchOdysee(body).pipe(
map(res => {
const items = (res?.result?.items || res?.result?.claims || res?.items || []) as any[];
return {
items: items.map(i => this.mapOdyseeItemToVideo(i)),
nextCursor: items.length > 0 ? String(page + 1) : null,
} as PagedResult<Video>;
}),
catchError((e) => { console.error('Odysee trending error', e); return of({ items: [], nextCursor: null }); })
);
}
case 'dailymotion': {
const page = cursor ? String(cursor) : '1';
const limit = 24;
const params = new URLSearchParams({
sort: 'trending',
limit: String(limit),
page: String(page),
country: String(region).toLowerCase(),
fields: 'id,title,thumbnail_url,thumbnail_480_url,thumbnail_720_url,duration,views_total,owner.screenname,owner.avatar_80_url,created_time',
});
return this.fetchDailymotion('/videos', params).pipe(
map(res => {
const list = (res.list || res.result || res.items || res.data || []) as any[];
const currentPage = Number(page) || 1;
const hasMore = typeof res.has_more === 'boolean' ? res.has_more : (list.length === limit);
return {
items: list.map((i: any) => this.mapDailymotionItemToVideo(i)),
nextCursor: hasMore ? String(currentPage + 1) : null,
} as PagedResult<Video>;
}),
catchError((e) => { console.error('Dailymotion trending error', e); return of({ items: [], nextCursor: null }); })
);
}
case 'twitch': {
// Use Helix Streams for a "trending" like experience (popular live streams)
return this.getTwitchAuthToken().pipe(
switchMap(token => {
if (!token) return of({ items: [], nextCursor: null });
const headers = new HttpHeaders({ 'Client-ID': this.resolveTwitchClientId()!, 'Authorization': `Bearer ${token}` });
const after = cursor ? `&after=${encodeURIComponent(cursor)}` : '';
const primary = `/api/twitch-api/helix/streams?first=24${after}`;
const fallback = `/proxy/twitch-api/helix/streams?first=24${after}`;
return this.http.get<any>(primary, { headers }).pipe(
catchError(() => this.http.get<any>(fallback, { headers })),
map(res => ({
items: (res.data || []).map((i: any) => this.mapTwitchStreamToVideo(i)),
nextCursor: res.pagination?.cursor || null,
} as PagedResult<Video>)),
catchError((e) => { console.error('Twitch trending error', e); return of({ items: [], nextCursor: null }); })
);
})
);
}
case 'rumble': {
// Rumble utilise maintenant le web scraping côté serveur, pas d'API key requise
const page = cursor ? Number(cursor) : 1;
const params = new URLSearchParams({
sort: 'trending',
limit: '24',
page: String(page),
});
return this.fetchRumble('/browse', params).pipe(
map(res => {
const list = (res?.items || res?.result || res?.data || []) as any[];
const backendCursor = res?.nextCursor != null ? String(res.nextCursor) : null;
const next = backendCursor ?? (list.length > 0 ? String(page + 1) : null);
return {
items: list.map(i => this.mapRumbleItemToVideo(i)),
nextCursor: next,
} as PagedResult<Video>;
}),
catchError((e) => { console.error('Rumble trending error', e); return of({ items: [], nextCursor: null }); })
);
}
case 'peertube': {
const instance = this.instances.activePeerTubeInstance();
const start = cursor ? Number(cursor) : 0;
const primary = `/api/peertube/${instance}/api/v1/videos?sort=-trending&count=24&start=${start}`;
const fallback = `/proxy/api/peertube/${instance}/api/v1/videos?sort=-trending&count=24&start=${start}`;
return this.http.get<any>(primary).pipe(
catchError(() => this.http.get<any>(fallback)),
map(res => ({
items: (res.data || []).map((i: any) => this.mapPeerTubeItemToVideo(i, instance)),
nextCursor: res.data?.length > 0 ? String(start + 24) : null,
} as PagedResult<Video>)),
catchError((e) => { console.error(`PeerTube (${instance}) trending error`, e); return of({ items: [], nextCursor: null }); })
);
}
default:
console.warn('Provider not yet implemented for trending:', provider);
return of({ items: [], nextCursor: null });
}
}
searchVideosPage(query: string, cursor?: string | null, providerOverride?: Provider | null): Observable<PagedResult<Video>> {
const provider = providerOverride || this.instances.selectedProvider();
const region = this.instances.region();
if (!query) return of({ items: [], nextCursor: null });
switch (provider) {
case 'youtube': {
const key = this.getActiveYouTubeKey();
if (!key) return of({ items: [], nextCursor: null });
const params = new URLSearchParams({
type: 'video',
part: 'snippet',
// Reduce initial page size to 12 to limit payload; unit cost per call remains but fewer follow-up calls
maxResults: '12',
q: String(query),
regionCode: String(region),
key: String(key),
});
// Prefer results matching user's language when available
try {
const lang = String(this.i18n.lang()).toLowerCase();
if (lang) params.set('relevanceLanguage', lang);
} catch {}
if (cursor) params.set('pageToken', cursor);
// Cache key: provider|search|region|query|cursor
const cacheKey = `yt:search|${region}|${query}|${cursor || ''}`;
const cached = this.searchCache.get(cacheKey);
const now = Date.now();
if (cached && (now - cached.ts) < this.CACHE_TTL_MS) {
return of(cached.data);
}
const path = `/youtube/v3/search`;
return this.fetchYouTube(path, params, key).pipe(
map(res => ({
items: (res.items || []).map((i: any) => this.mapYouTubeSearchItemToVideo(i)),
nextCursor: res.nextPageToken || null,
} as PagedResult<Video>)),
// Enrich with durations and view counts
switchMap((out) => {
const ids = out.items.map(v => v.videoId).filter(Boolean);
if (!ids.length) return of(out);
return this.getYouTubeContentAndStats(ids).pipe(
map((stats) => {
const enriched = out.items.map(v => {
const s = stats[v.videoId];
return s ? { ...v, duration: s.duration, views: s.views } : v;
});
const next = { ...out, items: enriched } as PagedResult<Video>;
this.searchCache.set(cacheKey, { ts: now, data: next });
return next;
})
);
}),
catchError((e) => {
// Surface quotaExceeded if present
try {
if (e?.status === 403 && (e?.error?.error?.errors?.[0]?.reason === 'quotaExceeded' || /quota/i.test(String(e?.error?.error?.message || e?.message || '')))) {
this.quotaExceeded$.next(true);
this.banYouTubeKey(key);
}
} catch {}
console.error('YouTube search error', e);
return of({ items: [], nextCursor: null });
})
);
}
case 'odysee': {
const page = cursor ? Number(cursor) : 1;
const body = {
method: 'claim_search',
params: {
page: page,
page_size: 24,
no_totals: true,
claim_type: 'stream',
stream_types: ['video'],
text: String(query),
order_by: ['trending_group', 'trending_mixed']
}
};
return this.fetchOdysee(body).pipe(
map(res => {
const items = (res?.result?.items || res?.result?.claims || res?.items || []) as any[];
return {
items: items.map(i => this.mapOdyseeItemToVideo(i)),
nextCursor: items.length > 0 ? String(page + 1) : null,
} as PagedResult<Video>;
}),
catchError((e) => { console.error('Odysee search error', e); return of({ items: [], nextCursor: null }); })
);
}
case 'dailymotion': {
const page = cursor ? String(cursor) : '1';
const limit = 24;
const params = new URLSearchParams({
limit: String(limit),
page: String(page),
country: String(region).toLowerCase(),
fields: 'id,title,thumbnail_url,thumbnail_480_url,thumbnail_720_url,duration,views_total,owner.screenname,owner.avatar_80_url,created_time',
search: String(query),
});
return this.fetchDailymotion('/videos', params).pipe(
map(res => {
const list = (res.list || res.result || res.items || res.data || []) as any[];
const currentPage = Number(page) || 1;
const hasMore = typeof res.has_more === 'boolean' ? res.has_more : (list.length === limit);
return {
items: list.map((i: any) => this.mapDailymotionItemToVideo(i)),
nextCursor: hasMore ? String(currentPage + 1) : null,
} as PagedResult<Video>;
}),
catchError((e) => { console.error('Dailymotion search error', e); return of({ items: [], nextCursor: null }); })
);
}
case 'rumble': {
// Rumble utilise maintenant le web scraping côté serveur, pas d'API key requise
const limit = 24;
const offset = cursor ? Number(cursor) : 0;
const params = new URLSearchParams({
q: String(query),
limit: String(limit),
offset: String(offset),
});
return this.fetchRumble('/search', params).pipe(
map(res => {
const list = (res?.items || res?.result || res?.data || []) as any[];
// Prefer backend-provided nextCursor if present (page-based). Translate it to offset-based cursor for the frontend.
let next: string | null = null;
if (res?.nextCursor != null && res?.page != null && res?.limit != null) {
try {
const nextPage = Number(res.nextCursor);
const perPage = Number(res.limit);
if (!isNaN(nextPage) && !isNaN(perPage)) next = String((nextPage - 1) * perPage);
} catch {}
}
if (next == null) {
const hasMore = list.length === limit;
next = hasMore ? String(offset + limit) : null;
}
return {
items: list.map(i => this.mapRumbleItemToVideo(i)),
nextCursor: next,
} as PagedResult<Video>;
}),
catchError((e) => { console.error('Rumble search error', e); return of({ items: [], nextCursor: null }); })
);
}
case 'twitch': {
return this.getTwitchAuthToken().pipe(
switchMap(token => {
if (!token) return of({ items: [], nextCursor: null });
const headers = new HttpHeaders({ 'Client-ID': this.resolveTwitchClientId()!, 'Authorization': `Bearer ${token}` });
const primary = `/api/twitch-api/helix/search/channels?query=${encodeURIComponent(query)}&first=24&after=${cursor || ''}&live_only=true`;
const fallback = `/proxy/twitch-api/helix/search/channels?query=${encodeURIComponent(query)}&first=24&after=${cursor || ''}&live_only=true`;
return this.http.get<any>(primary, { headers }).pipe(
catchError(() => this.http.get<any>(fallback, { headers })),
map(res => {
const data = Array.isArray(res?.data) ? res.data : [];
const live = data.filter((i: any) => i?.is_live === true);
return {
items: live.map((i: any) => this.mapTwitchChannelToVideo(i)),
nextCursor: res.pagination?.cursor || null,
} as PagedResult<Video>;
}),
catchError((e) => { console.error('Twitch search error', e); return of({ items: [], nextCursor: null }); })
);
})
);
}
case 'peertube': {
const instance = this.instances.activePeerTubeInstance();
const start = cursor ? Number(cursor) : 0;
const primary = `/api/peertube/${instance}/api/v1/search/videos?search=${encodeURIComponent(query)}&count=24&start=${start}`;
const fallback = `/proxy/api/peertube/${instance}/api/v1/search/videos?search=${encodeURIComponent(query)}&count=24&start=${start}`;
return this.http.get<any>(primary).pipe(
catchError(() => this.http.get<any>(fallback)),
map(res => ({
items: (res.data || []).map((i: any) => this.mapPeerTubeItemToVideo(i, instance)),
nextCursor: res.data?.length > 0 ? String(start + 24) : null,
} as PagedResult<Video>)),
catchError((e) => { console.error(`PeerTube (${instance}) search error`, e); return of({ items: [], nextCursor: null }); })
);
}
default:
return of({ items: [], nextCursor: null });
}
}
// YouTube mappers
private mapYouTubeItemToVideo = (i: any): Video => {
const id = i.id;
const sn = i.snippet || {};
const stats = i.statistics || {};
const cd = i.contentDetails || {};
const thumb = sn.thumbnails?.high?.url || sn.thumbnails?.medium?.url || sn.thumbnails?.default?.url || '';
const publishedAt = sn.publishedAt || '';
return {
url: `/watch/${id}`,
videoId: id,
type: 'video',
title: sn.title || '',
thumbnail: thumb,
uploaderName: sn.channelTitle || '',
uploaderUrl: '',
uploaderAvatar: thumb, // placeholder; channel avatar would need another call
uploadedDate: publishedAt,
duration: this.parseISODurationToSeconds(cd.duration || 'PT0S'),
views: Number(stats.viewCount || 0),
uploaded: publishedAt ? Date.parse(publishedAt) : 0,
} as Video;
};
private mapYouTubeSearchItemToVideo = (i: any): Video => {
const id = i.id?.videoId || i.id;
const sn = i.snippet || {};
const thumb = sn.thumbnails?.high?.url || sn.thumbnails?.medium?.url || sn.thumbnails?.default?.url || '';
const publishedAt = sn.publishedAt || '';
return {
url: `/watch/${id}`,
videoId: id,
type: 'video',
title: sn.title || '',
thumbnail: thumb,
uploaderName: sn.channelTitle || '',
uploaderUrl: '',
uploaderAvatar: thumb,
uploadedDate: publishedAt,
duration: 0,
views: 0,
uploaded: publishedAt ? Date.parse(publishedAt) : 0,
} as Video;
};
// Dailymotion mapper
private mapDailymotionItemToVideo = (i: any): Video => ({
url: `/watch/${i.id}`,
videoId: i.id || '',
type: 'video',
title: i.title || '',
thumbnail: i.thumbnail_720_url || i.thumbnail_480_url || i.thumbnail_url || '',
uploaderName: i['owner.screenname'] || '',
uploaderUrl: '',
uploaderAvatar: i['owner.avatar_80_url'] || i.thumbnail_480_url || i.thumbnail_url || '',
uploadedDate: i.created_time ? new Date(i.created_time * 1000).toISOString() : '',
duration: Number(i.duration || 0),
views: Number(i.views_total || 0),
uploaded: i.created_time ? i.created_time * 1000 : 0,
});
// PeerTube mapper (robust handling for thumbnail/avatar fields across instances)
private mapPeerTubeItemToVideo = (i: any, instance: string): Video => {
const abs = (p?: string) => {
if (!p) return '';
const s = String(p);
return s.startsWith('http') ? s : `https://${instance}${s.startsWith('/') ? s : '/' + s}`;
};
const thumbnail = abs(i.thumbnailUrl) || abs(i.thumbnailPath) || abs(i.previewPath) || '';
const uploaderAvatar = abs(i.account?.avatar?.path) || abs(i.account?.avatars?.[0]?.path) || abs(i.channel?.avatar?.path) || '';
const uploaderName = i.account?.displayName || i.account?.name || i.channel?.displayName || '';
const uploaderUrl = i.account?.url || i.channel?.url || '';
return {
url: `https://${instance}/w/${i.uuid}`,
videoId: i.uuid || '',
type: 'video',
title: i.name || '',
thumbnail,
uploaderName,
uploaderUrl,
uploaderAvatar: uploaderAvatar || thumbnail,
uploadedDate: i.publishedAt || '',
duration: Number(i.duration || 0),
views: Number(i.views || 0),
uploaded: i.publishedAt ? Date.parse(i.publishedAt) : 0,
} as Video;
};
// Odysee mapper
private mapOdyseeItemToVideo = (i: any): Video => {
const val = i?.value || {};
const channel = i?.signing_channel || {};
const channelVal = channel?.value || {};
const lbryUrl = i?.permanent_url || i?.canonical_url || '';
const webUrl = this.lbryToOdyseeUrl(lbryUrl) || (i?.name ? `https://odysee.com/${i.name}` : '');
const thumb = val?.thumbnail?.url || '';
const publishedAt = val?.release_time ? new Date(val.release_time * 1000).toISOString() : '';
const duration = val?.video?.duration || 0;
const views = (i?.meta?.view_count != null) ? Number(i.meta.view_count) : 0;
const uploaderName = channelVal?.title || channel?.name || '';
return {
url: webUrl,
videoId: i?.claim_id || i?.txid || i?.claimId || i?.id || '',
type: 'video',
title: val?.title || i?.name || '',
thumbnail: thumb,
uploaderName,
uploaderUrl: channel?.permanent_url ? this.lbryToOdyseeUrl(channel.permanent_url) : '',
uploaderAvatar: channelVal?.thumbnail?.url || thumb,
uploadedDate: publishedAt,
duration: Number(duration || 0),
views: views,
uploaded: publishedAt ? Date.parse(publishedAt) : 0,
} as Video;
};
// Rumble mapper (best-effort over common fields)
private mapRumbleItemToVideo = (i: any): Video => {
let id = i?.id || i?.video_id || i?.videoId || i?.hash || '';
const title = i?.title || i?.name || '';
let thumb = i?.thumbnail || i?.thumbnail_url || i?.image || '';
const uploaderName = i?.author || i?.uploader || i?.channel?.name || i?.uploaderName || '';
const uploaderAvatar = i?.author_avatar || i?.uploader_avatar || i?.channel?.avatar || i?.uploaderAvatar || thumb || '';
const uploadedDate = i?.published_at || i?.created_at || i?.uploadedDate || '';
const views = Number(i?.views || i?.view_count || 0);
const duration = Number(i?.duration || 0);
const url = i?.url || (id ? `https://rumble.com/${id}` : '');
if (!id && url) {
try {
const u = new URL(url);
const m = u.pathname.match(/\/v[\w]+/) || u.pathname.match(/\/video\/([\w]+)/);
if (m) id = Array.isArray(m) ? (m[1] ? `v${m[1]}` : m[0].replace(/^\//, '')) : id;
} catch {}
}
if (!thumb && url) {
// Best-effort: construct a thumbnail CDN path if present in URL pattern (fallback to uploader avatar)
thumb = uploaderAvatar || '';
}
return {
url,
videoId: id,
type: 'video',
title,
thumbnail: thumb,
uploaderName,
uploaderUrl: i?.channel_url || '',
uploaderAvatar,
uploadedDate,
duration,
views,
uploaded: uploadedDate ? Date.parse(uploadedDate) : 0,
} as Video;
};
// Twitch Mappers
private mapTwitchItemToVideo = (i: any): Video => ({
url: `/watch/${i.id}`,
videoId: i.id || '',
type: 'video',
title: i.title || '',
thumbnail: i.thumbnail_url?.replace('%{width}', '640').replace('%{height}', '360') || '',
uploaderName: i.user_name || '',
uploaderUrl: `https://www.twitch.tv/${i.user_login}`,
uploaderAvatar: '', // Requires another API call
uploadedDate: i.created_at || '',
duration: this.parseTwitchDurationToSeconds(i.duration || '0s'),
views: i.view_count || 0,
uploaded: i.created_at ? Date.parse(i.created_at) : 0,
});
// Twitch Streams mapper (for trending popular live streams)
private mapTwitchStreamToVideo = (i: any): Video => ({
url: `https://www.twitch.tv/${i.user_login}`,
videoId: i.user_login || i.id || '',
type: 'channel',
title: i.title || '',
thumbnail: (i.thumbnail_url || '').replace('{width}', '640').replace('{height}', '360'),
uploaderName: i.user_name || '',
uploaderUrl: `https://www.twitch.tv/${i.user_login}`,
uploaderAvatar: (i.thumbnail_url || '').replace('{width}', '70').replace('{height}', '70'),
uploadedDate: i.started_at || '',
duration: 0,
views: Number(i.viewer_count || 0),
uploaded: i.started_at ? Date.parse(i.started_at) : 0,
});
private mapTwitchChannelToVideo = (i: any): Video => ({
url: `https://www.twitch.tv/${i.broadcaster_login || i.user_login || ''}`,
videoId: i.broadcaster_login || i.user_login || '',
type: 'channel',
title: i.display_name || i.title || '',
thumbnail: (i.thumbnail_url || '').replace('{width}', '640').replace('{height}', '360'),
uploaderName: i.display_name || i.broadcaster_login || i.user_login || '',
uploaderUrl: `https://www.twitch.tv/${i.broadcaster_login || i.user_login || ''}`,
uploaderAvatar: (i.thumbnail_url || '').replace('{width}', '70').replace('{height}', '70'),
uploadedDate: i.started_at || '',
duration: 0, // Not a VOD
views: 0, // Not applicable
uploaded: i.started_at ? Date.parse(i.started_at) : 0,
});
private parseISODurationToSeconds(iso: string): number {
// ... (rest of the code remains the same)
const m = /^PT(?:(\d+)H)?(?:(\d+)M)?(?:(\d+)S)?$/i.exec(iso || '');
if (!m) return 0;
const h = parseInt(m[1] || '0', 10);
const mn = parseInt(m[2] || '0', 10);
const s = parseInt(m[3] || '0', 10);
return h * 3600 + mn * 60 + s;
}
private resolveYouTubeApiKey(): string | undefined {
const g: any = globalThis as any;
const fromGlobal = g?.YOUTUBE_API_KEY;
const fromImportMeta = (typeof import.meta !== 'undefined' && (import.meta as any).env)
? ((import.meta as any).env.YOUTUBE_API_KEY || (import.meta as any).env.NG_APP_YOUTUBE_API_KEY)
: undefined;
const fromProcess = (g?.process?.env) ? (g.process.env.YOUTUBE_API_KEY || g.process.env.NG_APP_YOUTUBE_API_KEY) : undefined;
return fromGlobal || fromImportMeta || fromProcess || undefined;
}
// ---- Multi-key helpers ----
private loadYouTubeKeys(): string[] {
const g: any = globalThis as any;
const keys: string[] = [];
try {
// Prefer explicit array window.YOUTUBE_API_KEYS
const arr = Array.isArray(g?.YOUTUBE_API_KEYS) ? g.YOUTUBE_API_KEYS : null;
if (arr && arr.length) keys.push(...arr.map((s: any) => String(s).trim()).filter(Boolean));
} catch {}
try {
// Comma-separated string support
const csv = g?.YOUTUBE_API_KEYS;
if (!Array.isArray(csv) && typeof csv === 'string' && csv.includes(',')) {
keys.push(...csv.split(',').map((s: string) => s.trim()).filter(Boolean));
}
} catch {}
try {
// Env-based multi-keys
const im: any = (typeof import.meta !== 'undefined' && (import.meta as any).env) ? (import.meta as any).env : undefined;
const fromEnvCsv = im?.YOUTUBE_API_KEYS || im?.NG_APP_YOUTUBE_API_KEYS || (globalThis as any)?.process?.env?.YOUTUBE_API_KEYS || (globalThis as any)?.process?.env?.NG_APP_YOUTUBE_API_KEYS;
if (typeof fromEnvCsv === 'string' && fromEnvCsv.includes(',')) {
keys.push(...fromEnvCsv.split(',').map((s: string) => s.trim()).filter(Boolean));
}
} catch {}
// Fallback single key
const single = this.resolveYouTubeApiKey();
if (single) keys.push(String(single));
// De-duplicate and return
const uniq = Array.from(new Set(keys.filter(Boolean)));
return uniq;
}
private isKeyBanned(k: string): boolean {
const until = this.ytKeyBans.get(k) || 0;
return Date.now() < until;
}
private banYouTubeKey(k: string) {
if (!k) return;
const until = Date.now() + this.YT_KEY_BAN_MS;
this.ytKeyBans.set(k, until);
}
private getActiveYouTubeKey(): string | undefined {
// If no keys loaded, attempt to load once
if (!this.ytKeys || this.ytKeys.length === 0) this.ytKeys = this.loadYouTubeKeys();
const n = this.ytKeys.length;
if (!n) return undefined;
// Round-robin until we find a non-banned key
for (let i = 0; i < n; i++) {
const idx = (this.ytKeyIndex + i) % n;
const candidate = this.ytKeys[idx];
if (!this.isKeyBanned(candidate)) {
this.ytKeyIndex = (idx + 1) % n; // advance pointer for next call
return candidate;
}
}
// All keys currently banned
return undefined;
}
private resolveTwitchClientId(): string | undefined {
const g: any = globalThis as any;
return g?.TWITCH_CLIENT_ID || (typeof import.meta !== 'undefined' && (import.meta as any).env?.TWITCH_CLIENT_ID) || g?.process?.env?.TWITCH_CLIENT_ID;
}
private getTwitchAuthToken(): Observable<string | null> {
const clientId = this.resolveTwitchClientId();
const clientSecret = (globalThis as any)?.TWITCH_CLIENT_SECRET || (typeof import.meta !== 'undefined' && (import.meta as any).env?.TWITCH_CLIENT_SECRET) || (globalThis as any)?.process?.env?.TWITCH_CLIENT_SECRET;
if (!clientId || !clientSecret) {
console.warn('Twitch Client ID or Secret not found.');
return of(null);
}
const body = `client_id=${clientId}&client_secret=${clientSecret}&grant_type=client_credentials`;
const headers = new HttpHeaders({ 'Content-Type': 'application/x-www-form-urlencoded' });
return this.http.post<any>('/proxy/twitch-auth/oauth2/token', body, { headers }).pipe(
map(res => res.access_token),
shareReplay(1),
catchError(() => of(null))
);
}
private parseTwitchDurationToSeconds(duration: string): number {
let totalSeconds = 0;
if (duration.includes('h')) {
totalSeconds += parseInt(duration.split('h')[0]) * 3600;
duration = duration.split('h')[1];
}
if (duration.includes('m')) {
totalSeconds += parseInt(duration.split('m')[0]) * 60;
duration = duration.split('m')[1];
}
if (duration.includes('s')) {
totalSeconds += parseInt(duration.split('s')[0]);
}
return totalSeconds;
}
// Dev proxy helpers with fallback to direct URLs (returns Observable<any>)
private fetchYouTube(path: string, params: URLSearchParams, _activeKey?: string) {
const qs = params.toString();
const cachedProd = `/api/yt${path}?${qs}`; // server cache in prod
const cachedDev = `/proxy/api/yt${path}?${qs}`; // dev proxy to server cache
const direct = `https://www.googleapis.com${path}?${qs}`;
const proxied = `/proxy/yt${path}?${qs}`;
// Try prod server cache → dev server cache → direct → dev proxy
return this.http.get<any>(cachedProd).pipe(
catchError(() => this.http.get<any>(cachedDev).pipe(
catchError((_e1) => this.http.get<any>(direct).pipe(
catchError((err) => {
console.warn('YouTube direct call failed, trying /proxy/yt...', err);
return this.http.get<any>(proxied);
})
))
))
);
}
private fetchDailymotion(path: string, params: URLSearchParams) {
const prod = `/api/dm${path}?${params.toString()}`;
const proxied = `/proxy/dm${path}?${params.toString()}`;
const direct = `https://api.dailymotion.com${path}?${params.toString()}`;
return this.http.get<any>(prod).pipe(
catchError(() => this.http.get<any>(proxied).pipe(
catchError(() => this.http.get<any>(direct))
))
);
}
private fetchRumble(path: string, params: URLSearchParams) {
const url = `/api/rumble${path}?${params.toString()}`;
return this.http.get<any>(url).pipe(
catchError((err) => {
console.error('Rumble API call via proxy failed', err);
return of({ items: [], total: 0 });
})
);
}
/**
* Fetch durations (in seconds) for a list of YouTube video IDs.
*/
public getYouTubeDurations(ids: string[]): Observable<Record<string, number>> {
const apiKey = this.getActiveYouTubeKey();
if (!apiKey || !ids.length) {
return of({});
}
const params = new URLSearchParams({
key: apiKey,
part: 'contentDetails',
id: ids.join(','),
});
return this.fetchYouTube('/youtube/v3/videos', params, apiKey).pipe(
map((res: any) => Array.isArray(res?.items) ? res.items : []),
map((items: any[]) => items.reduce((acc: Record<string, number>, item: any) => {
const duration = item?.contentDetails?.duration || 'PT0S';
const id = String(item?.id || '');
if (id) acc[id] = this.parseISODurationToSeconds(duration);
return acc;
}, {} as Record<string, number>)),
catchError(() => of({}))
);
}
/**
* Fetch minimal details (duration seconds + viewCount) for a list of YouTube IDs.
*/
private getYouTubeContentAndStats(ids: string[]): Observable<Record<string, { duration: number; views: number }>> {
const apiKey = this.getActiveYouTubeKey();
if (!apiKey || !ids.length) return of({});
const params = new URLSearchParams({
key: apiKey,
part: 'contentDetails,statistics',
id: ids.join(','),
maxResults: String(Math.min(50, ids.length)),
});
return this.fetchYouTube('/youtube/v3/videos', params, apiKey).pipe(
map((res: any) => Array.isArray(res?.items) ? res.items : []),
map((items: any[]) => {
const out: Record<string, { duration: number; views: number }> = {};
for (const it of items) {
const id = String(it?.id || '');
if (!id) continue;
const duration = this.parseISODurationToSeconds(it?.contentDetails?.duration || 'PT0S');
const views = Number(it?.statistics?.viewCount || 0);
out[id] = { duration, views };
}
return out;
}),
catchError(() => of({} as Record<string, { duration: number; views: number }>))
);
}
}
+32
View File
@@ -0,0 +1,32 @@
export function formatRelativeFr(iso: string | number | Date): string {
const d = typeof iso === 'string' || typeof iso === 'number' ? new Date(iso) : iso;
if (!d || isNaN(d.getTime())) return '';
const now = new Date();
const diffMs = now.getTime() - d.getTime();
const sec = Math.max(0, Math.floor(diffMs / 1000));
const min = Math.floor(sec / 60);
const hr = Math.floor(min / 60);
const day = Math.floor(hr / 24);
const week = Math.floor(day / 7);
const month = Math.floor(day / 30);
const year = Math.floor(day / 365);
if (year >= 1) return `il y a ${year} ${year > 1 ? 'ans' : 'an'}`;
if (month >= 1) return `il y a ${month} ${month > 1 ? 'mois' : 'mois'}`;
if (week >= 1) return `il y a ${week} ${week > 1 ? 'semaines' : 'semaine'}`;
if (day >= 1) return `il y a ${day} ${day > 1 ? 'jours' : 'jour'}`;
if (hr >= 1) return `il y a ${hr} ${hr > 1 ? 'heures' : 'heure'}`;
if (min >= 1) return `il y a ${min} ${min > 1 ? 'minutes' : 'minute'}`;
return `à l'instant`;
}
export function formatAbsoluteFr(iso: string | number | Date): string {
const d = typeof iso === 'string' || typeof iso === 'number' ? new Date(iso) : iso;
if (!d || isNaN(d.getTime())) return '';
// Exemple: 9 sept. 2025
return new Intl.DateTimeFormat('fr-CA', { day: 'numeric', month: 'short', year: 'numeric' }).format(d);
}
export function formatNumberFr(n: number): string {
return new Intl.NumberFormat('fr-CA').format(n || 0);
}