first commit
This commit is contained in:
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user