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
@@ -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);
});
}
}
}