first commit
This commit is contained in:
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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 {}
|
||||
}
|
||||
}
|
||||
@@ -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> • <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>
|
||||
|
||||
@@ -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 : combinez un thème et un mot-clé (ex. : « Tech IA ») 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 : 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 :</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 { }
|
||||
@@ -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> • <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> • <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>
|
||||
@@ -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?.();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user