first commit

This commit is contained in:
2025-09-14 23:05:30 -04:00
commit 2866d74b32
122 changed files with 95474 additions and 0 deletions
+151
View File
@@ -0,0 +1,151 @@
<div class="container mx-auto p-4 sm:p-6">
<h2 class="text-3xl font-bold mb-6 text-slate-100 border-l-4 border-red-500 pl-4">{{ pageHeading() }}</h2>
@if (notice()) {
<div class="mb-4 bg-amber-900/40 border border-amber-600 text-amber-200 p-3 rounded">
{{ notice() }}
</div>
}
@if (!hasQuery()) {
<p class="text-slate-400">{{ 'search.hint' | t }}</p>
}
@if (hasQuery() && availableTags().length > 0) {
<div class="mb-4 flex items-center gap-2 overflow-x-auto pb-1">
@for (tag of availableTags(); track tag.key) {
<button
class="px-3 py-1 rounded-full text-sm whitespace-nowrap border transition-colors"
[ngClass]="{
'bg-slate-200 text-slate-900 border-slate-200': filterTag() === tag.key,
'bg-slate-800 text-slate-200 border-slate-600 hover:bg-slate-700': filterTag() !== tag.key
}"
(click)="setFilterTag(tag.key)">
{{ tag.label }}
</button>
}
</div>
}
@if (loading()) {
<div class="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-6">
@for (item of [1,2,3,4,5,6,7,8]; track item) {
<div class="animate-pulse bg-slate-800 rounded-lg overflow-hidden">
<div class="w-full h-48 bg-slate-700"></div>
<div class="p-4 space-y-3">
<div class="h-4 bg-slate-700 rounded w-3/4"></div>
<div class="h-4 bg-slate-700 rounded w-1/2"></div>
</div>
</div>
}
</div>
} @else if (selectedProviderForView() === 'twitch') {
<!-- Twitch: two sections -->
<div class="space-y-10">
<!-- Live Channels -->
<section *ngIf="filterTag() === 'twitch_all' || filterTag() === 'twitch_live'">
<h3 class="text-xl font-semibold text-slate-200 mb-3">Chaînes en direct</h3>
@if (twitchChannels().length > 0) {
<div class="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-x-6 gap-y-8">
@for (video of twitchChannels(); track video.videoId) {
<a [routerLink]="['/watch', video.videoId]" [queryParams]="watchQueryParams(video)" [state]="{ video }" class="group flex flex-col bg-slate-800/50 rounded-lg overflow-hidden hover:bg-slate-700/50 transition-all duration-300 transform hover:-translate-y-1">
<div class="relative">
<img [src]="video.thumbnail" [alt]="video.title" class="w-full h-48 object-cover transition-transform duration-300 group-hover:scale-105">
<div class="absolute top-2 left-2 bg-black/75 text-white text-xs px-2 py-1 rounded">
{{ formatViews(video.views) }} en direct
</div>
</div>
<div class="p-4 flex-grow flex flex-col">
<h4 class="font-semibold text-slate-100 group-hover:text-red-400 transition-colors duration-200 line-clamp-2">{{ video.title }}</h4>
<div class="mt-2 flex items-center space-x-3 text-sm text-slate-400">
<img [src]="video.uploaderAvatar" [alt]="video.uploaderName" class="w-8 h-8 rounded-full">
<span>{{ video.uploaderName }}</span>
</div>
</div>
</a>
}
</div>
@if (twitchCursorChannels()) {
<div class="mt-4 flex justify-center">
<button class="px-4 py-2 rounded bg-slate-700 hover:bg-slate-600 text-slate-100 disabled:opacity-50" (click)="loadMoreTwitchChannels()" [disabled]="twitchBusyChannels()">Afficher plus</button>
</div>
}
} @else {
<p class="text-slate-400">Aucune chaîne en direct trouvée.</p>
}
</section>
<!-- Past Videos (VODs) -->
<section *ngIf="filterTag() === 'twitch_all' || filterTag() === 'twitch_vod'">
<h3 class="text-xl font-semibold text-slate-200 mb-3">Vidéos (VOD)</h3>
@if (twitchVods().length > 0) {
<div class="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-x-6 gap-y-8">
@for (video of twitchVods(); track video.videoId) {
<a [routerLink]="['/watch', video.videoId]" [queryParams]="watchQueryParams(video)" [state]="{ video }" class="group flex flex-col bg-slate-800/50 rounded-lg overflow-hidden hover:bg-slate-700/50 transition-all duration-300 transform hover:-translate-y-1">
<div class="relative">
<img [src]="video.thumbnail" [alt]="video.title" class="w-full h-48 object-cover transition-transform duration-300 group-hover:scale-105">
<div class="absolute bottom-2 right-2 bg-black/75 text-white text-xs px-2 py-1 rounded">
{{ video.duration / 60 | number:'1.0-0' }}:{{ (video.duration % 60) | number:'2.0-0' }}
</div>
</div>
<div class="p-4 flex-grow flex flex-col">
<h4 class="font-semibold text-slate-100 group-hover:text-red-400 transition-colors duration-200 line-clamp-2">{{ video.title }}</h4>
<div class="mt-2 flex items-center space-x-3 text-sm text-slate-400">
<img [src]="video.uploaderAvatar" [alt]="video.uploaderName" class="w-8 h-8 rounded-full">
<span>{{ video.uploaderName }}</span>
</div>
<div class="mt-auto pt-2 text-sm text-slate-400">
<span>{{ formatViews(video.views) }} visionnements</span> &bull; <span>{{ formatRelative(video.uploadedDate) }}</span>
</div>
</div>
</a>
}
</div>
@if (twitchCursorVods()) {
<div class="mt-4 flex justify-center">
<button class="px-4 py-2 rounded bg-slate-700 hover:bg-slate-600 text-slate-100 disabled:opacity-50" (click)="loadMoreTwitchVods()" [disabled]="twitchBusyVods()">Afficher plus</button>
</div>
}
} @else {
<p class="text-slate-400">Aucune vidéo VOD trouvée.</p>
}
</section>
</div>
} @else if (results().length > 0) {
<div class="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-x-6 gap-y-8">
@for (video of filteredResults(); track video.videoId) {
<a [routerLink]="['/watch', video.videoId]" [queryParams]="watchQueryParams(video)" [state]="{ video }" class="group flex flex-col bg-slate-800/50 rounded-lg overflow-hidden hover:bg-slate-700/50 transition-all duration-300 transform hover:-translate-y-1">
<div class="relative">
<img [src]="video.thumbnail" [alt]="video.title" class="w-full h-48 object-cover transition-transform duration-300 group-hover:scale-105">
<div class="absolute bottom-2 right-2 bg-black/75 text-white text-xs px-2 py-1 rounded">
{{ video.duration / 60 | number:'1.0-0' }}:{{ (video.duration % 60) | number:'2.0-0' }}
</div>
</div>
<div class="p-4 flex-grow flex flex-col">
<h3 class="font-semibold text-slate-100 group-hover:text-red-400 transition-colors duration-200 line-clamp-2">{{ video.title }}</h3>
<div class="mt-2 flex items-center space-x-3 text-sm text-slate-400">
<img [src]="video.uploaderAvatar" [alt]="video.uploaderName" class="w-8 h-8 rounded-full">
<span>{{ video.uploaderName }}</span>
</div>
<div class="mt-auto pt-2 text-sm text-slate-400">
<span>{{ formatViews(video.views) }} visionnements</span> &bull; <span>{{ formatRelative(video.uploadedDate) }}</span>
</div>
</div>
</a>
}
</div>
<!-- Infinite scroll anchor -->
<app-infinite-anchor class="mt-6"
[disabled]="!nextCursor()"
[busy]="busyMore()"
(loadMore)="fetchNextPage()"></app-infinite-anchor>
@if (busyMore()) {
<div class="flex items-center justify-center py-4 text-slate-400">
<svg class="animate-spin h-5 w-5 mr-2" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle><path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path></svg>
{{ 'loading.more' | t }}
</div>
}
} @else if (hasQuery()) {
<p class="text-slate-400">{{ 'search.noResults' | t }}</p>
}
</div>
+359
View File
@@ -0,0 +1,359 @@
import { ChangeDetectionStrategy, Component, computed, effect, inject, signal, untracked } from '@angular/core';
import { CommonModule } from '@angular/common';
import { ActivatedRoute, RouterLink } from '@angular/router';
import { YoutubeApiService } from '../../services/youtube-api.service';
import { Video } from '../../models/video.model';
import { InfiniteAnchorComponent } from '../shared/infinite-anchor/infinite-anchor.component';
import { formatRelativeFr } from '../../utils/date.util';
import { InstanceService, Provider } from '../../services/instance.service';
import { HistoryService } from '../../services/history.service';
import { Title } from '@angular/platform-browser';
import { TranslatePipe } from '../../pipes/translate.pipe';
@Component({
selector: 'app-search',
standalone: true,
templateUrl: './search.component.html',
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [CommonModule, RouterLink, InfiniteAnchorComponent, TranslatePipe]
})
export class SearchComponent {
private route = inject(ActivatedRoute);
private api = inject(YoutubeApiService);
private instances = inject(InstanceService);
private history = inject(HistoryService);
private title = inject(Title);
q = signal<string>('');
loading = signal<boolean>(false);
results = signal<Video[]>([]);
busyMore = signal<boolean>(false);
nextCursor = signal<string | null>(null);
// Twitch-specific dual lists
twitchChannels = signal<Video[]>([]);
twitchVods = signal<Video[]>([]);
twitchCursorChannels = signal<string | null>(null);
twitchCursorVods = signal<string | null>(null);
twitchBusyChannels = signal<boolean>(false);
twitchBusyVods = signal<boolean>(false);
notice = signal<string | null>(null);
providerParam = signal<Provider | null>(null);
themeParam = signal<string | null>(null);
hasQuery = computed(() => this.q().length > 0);
providerLabel = computed(() => {
const param = this.providerParam();
if (param) {
const p = this.instances.providers().find(p => p.id === param);
return p?.label || param;
}
return this.instances.selectedProviderLabel();
});
pageHeading = computed(() => `Search - ${this.providerLabel()}${this.q() ? ' - ' + this.q() : ''}`);
// Public computed used by the template to avoid referencing private `instances`
selectedProviderForView = computed(() => this.providerParam() || this.instances.selectedProvider());
// Active tag filter. For non-Twitch providers: 'all' | 'short' | 'medium' | 'long' | 'recent'.
// For Twitch provider: 'twitch_all' | 'twitch_live' | 'twitch_vod'.
filterTag = signal<string>('all');
// Helper computed: filtered list based on active tag (non-Twitch only)
filteredResults = computed(() => {
const tag = this.filterTag();
const provider = this.selectedProviderForView();
const list = this.results();
if (provider === 'twitch') return list; // Not used for Twitch (separate sections)
if (tag === 'all') return list;
const now = Date.now();
const oneDay = 24 * 60 * 60 * 1000;
const sevenDays = 7 * oneDay;
const thirtyDays = 30 * oneDay;
const oneYear = 365 * oneDay;
// Duration filters
if (tag === 'short') return list.filter(v => {
const d = Number(v.duration || 0);
return d > 0 && d < 4 * 60;
});
if (tag === 'medium') return list.filter(v => {
const d = Number(v.duration || 0);
return d >= 4 * 60 && d < 20 * 60;
});
if (tag === 'long') return list.filter(v => {
const d = Number(v.duration || 0);
return d >= 20 * 60;
});
// Date filters
if (tag === 'today') return list.filter(v => {
const uploadTime = typeof v.uploaded === 'number' ? v.uploaded : 0;
return uploadTime > 0 && (now - uploadTime) <= oneDay;
});
if (tag === 'this_week') return list.filter(v => {
const uploadTime = typeof v.uploaded === 'number' ? v.uploaded : 0;
return uploadTime > 0 && (now - uploadTime) <= sevenDays;
});
if (tag === 'this_month') return list.filter(v => {
const uploadTime = typeof v.uploaded === 'number' ? v.uploaded : 0;
return uploadTime > 0 && (now - uploadTime) <= thirtyDays;
});
if (tag === 'this_year') return list.filter(v => {
const uploadTime = typeof v.uploaded === 'number' ? v.uploaded : 0;
return uploadTime > 0 && (now - uploadTime) <= oneYear;
});
return list;
});
// Available tags computed from current results (only show tags that can apply)
availableTags = computed(() => {
const provider = this.selectedProviderForView();
const tags: { key: string; label: string; show: boolean }[] = [];
const now = Date.now();
const list = this.results();
if (provider === 'twitch') {
const hasLive = this.twitchChannels().length > 0;
const hasVods = this.twitchVods().length > 0;
// Show the group if at least one of the sections is available
if (hasLive || hasVods) {
tags.push({ key: 'twitch_all', label: 'Tout', show: hasLive && hasVods });
tags.push({ key: 'twitch_live', label: 'En direct', show: hasLive });
tags.push({ key: 'twitch_vod', label: 'VOD', show: hasVods });
}
return tags.filter(t => t.show);
}
// Non-Twitch providers: duration + date filters
const oneDay = 24 * 60 * 60 * 1000;
const sevenDays = 7 * oneDay;
const thirtyDays = 30 * oneDay;
const oneYear = 365 * oneDay;
let hasShort = false, hasMedium = false, hasLong = false,
hasToday = false, hasThisWeek = false, hasThisMonth = false, hasThisYear = false;
for (const v of list) {
const d = Number(v.duration || 0);
const uploadTime = typeof v.uploaded === 'number' ? v.uploaded : 0;
const timeDiff = now - uploadTime;
// Duration filters
if (d > 0 && d < 4 * 60) hasShort = true;
else if (d >= 4 * 60 && d < 20 * 60) hasMedium = true;
else if (d >= 20 * 60) hasLong = true;
// Date filters
if (uploadTime > 0) {
if (timeDiff <= oneDay) hasToday = true;
if (timeDiff <= sevenDays) hasThisWeek = true;
if (timeDiff <= thirtyDays) hasThisMonth = true;
if (timeDiff <= oneYear) hasThisYear = true;
}
}
// Add duration filters
tags.push({ key: 'all', label: 'Tout', show: list.length > 0 });
tags.push({ key: 'short', label: 'Moins de 4 min', show: hasShort });
tags.push({ key: 'medium', label: 'De 4 à 20 min', show: hasMedium });
tags.push({ key: 'long', label: '20 min et plus', show: hasLong });
// Add date filters
tags.push({ key: 'today', label: 'Aujourd\'hui', show: hasToday });
tags.push({ key: 'this_week', label: 'Cette semaine', show: hasThisWeek });
tags.push({ key: 'this_month', label: 'Ce mois-ci', show: hasThisMonth });
tags.push({ key: 'this_year', label: 'Cette année', show: hasThisYear });
return tags.filter(t => t.show);
});
constructor() {
// Update document title when provider or query changes
effect(() => {
this.title.setTitle(this.pageHeading());
});
// Listen to query param changes (so subsequent searches update)
this.route.queryParamMap.subscribe((pm) => {
const q = (pm.get('q') || '').trim();
this.q.set(q);
const prov = (pm.get('provider') as Provider) || null;
const theme = pm.get('theme');
this.providerParam.set(prov);
this.themeParam.set(theme);
if (q) {
this.notice.set(null);
// Reset active tag on new query
const provider = (prov || this.instances.selectedProvider());
this.filterTag.set(provider === 'twitch' ? 'twitch_all' : 'all');
this.reloadSearch();
} else {
this.results.set([]);
this.nextCursor.set(null);
this.loading.set(false);
}
});
// React to provider/region/PeerTube instance changes to refresh search automatically
effect(() => {
const provider = this.instances.selectedProvider();
const region = this.instances.region();
const ptInstance = this.instances.activePeerTubeInstance();
untracked(() => {
// If provider is explicitly specified in query, do not override it with global changes
if (this.providerParam()) return;
if (this.q()) {
this.notice.set(null);
this.reloadSearch();
}
});
}, { allowSignalWrites: true });
}
reloadSearch() {
const readiness = this.instances.getProviderReadiness(this.providerParam() || undefined as any);
if (!readiness.ready) {
this.notice.set(readiness.reason || 'Le provider sélectionné n\'est pas prêt.');
this.results.set([]);
this.nextCursor.set(null);
this.twitchChannels.set([]);
this.twitchVods.set([]);
this.twitchCursorChannels.set(null);
this.twitchCursorVods.set(null);
this.loading.set(false);
return;
}
this.loading.set(true);
this.results.set([]);
this.nextCursor.set(null);
this.twitchChannels.set([]);
this.twitchVods.set([]);
this.twitchCursorChannels.set(null);
this.twitchCursorVods.set(null);
const provider = this.providerParam() || this.instances.selectedProvider();
// Record search term with provider once per reload
try {
this.history.recordSearch(this.q(), { provider }).subscribe({
next: () => {},
error: (err) => console.error('Error recording search:', err)
});
} catch (err) {
console.error('Error in recordSearch:', err);
}
// Ensure default tag matches provider
this.filterTag.set(provider === 'twitch' ? 'twitch_all' : 'all');
if (provider === 'twitch') {
// Load both sections in parallel
this.twitchBusyChannels.set(true);
this.twitchBusyVods.set(true);
this.api.searchTwitchChannelsPage(this.q(), null).subscribe(res => {
this.twitchChannels.set(res.items);
this.twitchCursorChannels.set(res.nextCursor || null);
this.twitchBusyChannels.set(false);
this.loading.set(false);
});
this.api.searchTwitchVodsPage(this.q(), null).subscribe(res => {
this.twitchVods.set(res.items);
this.twitchCursorVods.set(res.nextCursor || null);
this.twitchBusyVods.set(false);
this.loading.set(false);
});
} else {
this.fetchNextPage();
}
}
fetchNextPage() {
if (this.busyMore() || !this.q()) return;
const readiness = this.instances.getProviderReadiness(this.providerParam() || undefined as any);
if (!readiness.ready) {
if (!this.notice()) this.notice.set(readiness.reason || 'Le provider sélectionné n\'est pas prêt.');
return;
}
this.busyMore.set(true);
const providerOverride = this.providerParam();
this.api.searchVideosPage(this.q(), this.nextCursor(), providerOverride as any).subscribe(res => {
const merged = [...this.results(), ...res.items];
this.results.set(merged);
this.nextCursor.set(res.nextCursor || null);
this.busyMore.set(false);
this.loading.set(false);
// Provider/instance availability notices (avoid overriding legitimate no-results unless clear)
if (merged.length === 0 && !this.notice()) {
const readiness2 = this.instances.getProviderReadiness();
if (!readiness2.ready) {
this.notice.set(readiness2.reason || 'Le provider sélectionné n\'est pas prêt.');
} else {
const provider = this.providerParam() || this.instances.selectedProvider();
if (provider === 'peertube') {
const inst = this.instances.activePeerTubeInstance();
this.notice.set(`PeerTube: les vidéos ne sont pas disponibles depuis l'instance "${inst}" pour le moment. Essayez une autre instance dans l'en-tête.`);
} else if (provider === 'rumble') {
const label = this.instances.selectedProviderLabel();
this.notice.set(`Les vidéos ne sont pas disponibles pour le provider "${label}" pour le moment. Réessayez plus tard ou choisissez un autre provider.`);
}
}
}
});
}
loadMoreTwitchChannels() {
if (this.twitchBusyChannels() || !this.twitchCursorChannels()) return;
this.twitchBusyChannels.set(true);
this.api.searchTwitchChannelsPage(this.q(), this.twitchCursorChannels()).subscribe(res => {
this.twitchChannels.set([...this.twitchChannels(), ...res.items]);
this.twitchCursorChannels.set(res.nextCursor || null);
this.twitchBusyChannels.set(false);
});
}
loadMoreTwitchVods() {
if (this.twitchBusyVods() || !this.twitchCursorVods()) return;
this.twitchBusyVods.set(true);
this.api.searchTwitchVodsPage(this.q(), this.twitchCursorVods()).subscribe(res => {
this.twitchVods.set([...this.twitchVods(), ...res.items]);
this.twitchCursorVods.set(res.nextCursor || null);
this.twitchBusyVods.set(false);
});
}
formatViews(views: number): string {
if (views >= 1_000_000_000) return (views / 1_000_000_000).toFixed(1) + 'B';
if (views >= 1_000_000) return (views / 1_000_000).toFixed(1) + 'M';
if (views >= 1_000) return (views / 1_000).toFixed(1) + 'K';
return views.toString();
}
formatRelative(dateIso?: string): string {
if (!dateIso) return '';
return formatRelativeFr(dateIso);
}
// Build query params for Watch page (provider + optional odysee slug)
watchQueryParams(v: Video): Record<string, any> | null {
const p = this.providerParam() || this.instances.selectedProvider();
const qp: any = { p };
if (p === 'odysee' && v.url?.startsWith('https://odysee.com/')) {
let slug = v.url.substring('https://odysee.com/'.length);
if (slug.startsWith('/')) slug = slug.slice(1);
qp.slug = slug;
}
if (p === 'twitch' && v.type === 'channel') {
// Extract channel login from uploaderUrl if available
const url = v.uploaderUrl || v.url || '';
try {
const u = new URL(url);
const parts = u.pathname.split('/').filter(Boolean);
if (parts.length > 0) qp.channel = parts[0];
} catch {}
}
return qp;
}
// Update active filter tag from the template
setFilterTag(key: string) {
this.filterTag.set(key);
}
}