CI / build-and-test (push) Successful in 13m25s
- Nouveau app-channel-identity (avatar+nom -> /channel, fallback resolve /api/channels, initiales sinon) - ProviderBadge: accepte les ids courts yt/dm/tw/pt/od/ru - Search adapters yt/dm/pt/od: conservent channelExternalId/channelAvatarUrl (pt derive instance|channel) - video-card/themes/watch/related/suggestions/subscriptions/channel-header uniformises sur l'identite partagee
322 lines
12 KiB
TypeScript
322 lines
12 KiB
TypeScript
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 { TranslatePipe } from '../../pipes/translate.pipe';
|
|
import { LikeButtonComponent } from '../shared/components/like-button/like-button.component';
|
|
import { DurationPipe } from '../../app/shared/pipes/duration.pipe';
|
|
import { ProviderBadgeComponent } from '../../app/shared/components/provider-badge/provider-badge.component';
|
|
import { ChannelIdentityComponent } from '../../app/shared/components/channel-identity/channel-identity.component';
|
|
import { classifyVideo, isShortVideo } from '../../app/shared/utils/video-kind';
|
|
import { map, of, switchMap, catchError, Observable } from 'rxjs';
|
|
import { PagedResult } from '../../services/youtube-api.service';
|
|
|
|
const TWITCH_THEME_KEYWORDS: Record<string, string[]> = {
|
|
trending: ['just chatting', 'highlights'],
|
|
live: ['live', 'stream'],
|
|
gaming: ['gaming', 'playthrough'],
|
|
sports: ['sports', 'esports'],
|
|
'finance-crypto': ['finance', 'crypto'],
|
|
business: ['business', 'entrepreneur'],
|
|
tech: ['technology', 'coding'],
|
|
science: ['science', 'space'],
|
|
health: ['fitness', 'wellness'],
|
|
music: ['music', 'performance'],
|
|
podcasts: ['podcast', 'talk show'],
|
|
'movies-tv': ['movie review', 'tv discussion'],
|
|
education: ['education', 'tutorial'],
|
|
travel: ['travel vlog'],
|
|
food: ['cooking', 'food'],
|
|
'diy-makers': ['diy', 'maker'],
|
|
autos: ['car build', 'automotive'],
|
|
'nature-animals': ['outdoors', 'wildlife'],
|
|
comedy: ['comedy', 'standup'],
|
|
'art-design': ['art stream', 'drawing'],
|
|
vlogs: ['vlog', 'lifestyle'],
|
|
reviews: ['tech review', 'unboxing'],
|
|
documentary: ['history', 'documentary'],
|
|
'family-kids': ['family', 'kids'],
|
|
news: ['news', 'politics'],
|
|
};
|
|
@Component({
|
|
selector: 'app-provider-theme',
|
|
standalone: true,
|
|
templateUrl: './provider-theme-page.component.html',
|
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
|
imports: [CommonModule, FormsModule, RouterLink, TranslatePipe, LikeButtonComponent, DurationPipe, ProviderBadgeComponent, ChannelIdentityComponent]
|
|
})
|
|
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
|
|
private version = 0;
|
|
|
|
// Filters state
|
|
sort = signal<'recent' | 'viewed' | 'longest'>('recent');
|
|
duration = signal<'any' | 'short' | 'medium' | 'long'>('any');
|
|
type = signal<'any' | 'video' | 'live' | 'recorded' | 'short'>('any');
|
|
language = signal<'any' | 'en' | 'fr'>('any');
|
|
date = signal<'any' | 'day' | 'week' | 'month' | 'year'>('any');
|
|
|
|
// Data state
|
|
allVideos = signal<Video[]>([]);
|
|
nextCursor = signal<string | null>(null);
|
|
loading = signal(false);
|
|
error = signal<string | null>(null);
|
|
|
|
// Derived state for the UI
|
|
providersList = computed(() => this.instances.providers());
|
|
videos = computed(() => this.applyFilters(this.allVideos()));
|
|
|
|
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);
|
|
this.instances.setSelectedProvider(provider);
|
|
this.resetAndLoad();
|
|
});
|
|
|
|
// Re-apply filters on change
|
|
effect(() => {
|
|
this.sort();
|
|
this.duration();
|
|
this.type();
|
|
this.language();
|
|
this.date();
|
|
this.cdr.markForCheck();
|
|
});
|
|
}
|
|
|
|
private resetAndLoad() {
|
|
this.version++;
|
|
this.allVideos.set([]);
|
|
this.nextCursor.set(null);
|
|
this.error.set(null);
|
|
this.loadMore();
|
|
}
|
|
|
|
private buildQuery(provider: Provider): string {
|
|
const slug = this.theme();
|
|
const baseTokens = this.themes.tokensFor(slug);
|
|
|
|
if (provider === 'twitch') {
|
|
const twitchTokens = TWITCH_THEME_KEYWORDS[slug] || [];
|
|
const merged = [...new Set([...twitchTokens, ...baseTokens])];
|
|
const value = merged.join(' ').trim();
|
|
if (value) return value;
|
|
if (twitchTokens.length) return twitchTokens.join(' ');
|
|
return slug.replace(/-/g, ' ');
|
|
}
|
|
|
|
const value = baseTokens.join(' ').trim();
|
|
return value || slug.replace(/-/g, ' ');
|
|
}
|
|
|
|
loadMore() {
|
|
if (this.loading()) return;
|
|
|
|
const readiness = this.instances.getProviderReadiness(this.provider());
|
|
if (!readiness.ready) {
|
|
this.error.set(readiness.reason || 'Provider not ready');
|
|
return;
|
|
}
|
|
|
|
this.loading.set(true);
|
|
this.error.set(null);
|
|
const snapshotVersion = this.version;
|
|
const provider = this.provider();
|
|
const query = this.buildQuery(provider);
|
|
const cursor = this.nextCursor();
|
|
const themeSlug = this.theme();
|
|
const isLiveTheme = themeSlug === 'live';
|
|
|
|
let apiCall: Observable<PagedResult<Video>>;
|
|
if (provider === 'twitch') {
|
|
if (isLiveTheme) {
|
|
// Thème live : top streams (jamais vide quand Twitch est configuré).
|
|
apiCall = this.api.getTwitchTopStreamsPage(cursor || undefined);
|
|
} else {
|
|
// Thème classique : VODs puis clips, puis top streams en filet de sécurité.
|
|
apiCall = this.api.searchTwitchVodsPage(query, cursor || undefined).pipe(
|
|
switchMap((res) => {
|
|
if ((res?.items?.length || 0) >= 8) {
|
|
return of(res);
|
|
}
|
|
return this.api.searchTwitchClipsPage(query, cursor || undefined).pipe(
|
|
map((clips) => ({ ...clips, items: [...(res?.items || []), ...(clips.items || [])] })),
|
|
switchMap((merged: any) => {
|
|
if (((merged?.items?.length) || 0) >= 4) return of(merged);
|
|
return this.api.getTwitchTopStreamsPage(cursor || undefined).pipe(
|
|
map((top) => {
|
|
const seen = new Set((merged?.items || []).map((v: Video) => v.videoId));
|
|
const extra = (top?.items || []).filter((v: Video) => !seen.has(v.videoId));
|
|
return { items: [...(merged?.items || []), ...extra], nextCursor: top?.nextCursor || merged?.nextCursor || null };
|
|
}),
|
|
// En cas d'échec du fallback, garder au moins VODs+clips.
|
|
catchError(() => of(merged))
|
|
);
|
|
})
|
|
);
|
|
})
|
|
);
|
|
}
|
|
} else {
|
|
apiCall = query
|
|
? this.api.searchVideosPage(query, cursor, provider)
|
|
: this.api.getTrendingPage(cursor, provider);
|
|
}
|
|
|
|
apiCall.subscribe({
|
|
next: (res: PagedResult<Video>) => {
|
|
if (snapshotVersion !== this.version) return;
|
|
|
|
const existingIds = new Set(this.allVideos().map((v: Video) => v.videoId));
|
|
const newVideos = (res.items || []).filter((v: Video) => !existingIds.has(v.videoId));
|
|
|
|
this.allVideos.update(current => [...current, ...newVideos]);
|
|
this.nextCursor.set(res.nextCursor || null);
|
|
this.loading.set(false);
|
|
this.cdr.markForCheck();
|
|
},
|
|
error: (e) => {
|
|
if (snapshotVersion !== this.version) return;
|
|
this.error.set(e.message || 'Failed to load videos');
|
|
this.loading.set(false);
|
|
this.cdr.markForCheck();
|
|
}
|
|
});
|
|
}
|
|
|
|
private applyFilters(list: Video[]): Video[] {
|
|
let arr = [...list];
|
|
const durationFilter = this.duration();
|
|
if (durationFilter !== 'any') {
|
|
arr = arr.filter(v => {
|
|
const dur = v.duration || 0;
|
|
if (durationFilter === 'short') return dur > 0 && dur < 240; // < 4 min
|
|
if (durationFilter === 'medium') return dur >= 240 && dur < 1200; // 4-20 min
|
|
if (durationFilter === 'long') return dur >= 1200; // > 20 min
|
|
return true;
|
|
});
|
|
}
|
|
|
|
const typeFilter = this.type();
|
|
if (typeFilter !== 'any') {
|
|
const p = this.provider();
|
|
arr = arr.filter(v => {
|
|
const withProvider = { ...v, provider: (v.provider as string) || p };
|
|
if (typeFilter === 'video') return !isShortVideo(withProvider) && (v.type === 'video' || v.type === 'recorded');
|
|
if (typeFilter === 'live') return v.type === 'live' || v.type === 'channel' || v.type === 'stream' || (v as any).isLive === true;
|
|
if (typeFilter === 'recorded') return v.type === 'recorded' || (v.type === 'video' && !isShortVideo(withProvider));
|
|
// Shorts : classification partagée (durée 0/absente ⇒ jamais un Short).
|
|
if (typeFilter === 'short') return isShortVideo(withProvider);
|
|
return true;
|
|
});
|
|
}
|
|
|
|
const langFilter = this.language();
|
|
if (langFilter !== 'any') {
|
|
// Simple heuristic, can be improved
|
|
const isFr = (s: string) => /[éèêàùçîïô]/.test(s) || /\b(le|la|les|un|une)\b/i.test(s);
|
|
const isEn = (s: string) => /\b(the|a|an|and|for)\b/i.test(s);
|
|
arr = arr.filter(v => {
|
|
const text = `${v.title} ${v.uploaderName}`.toLowerCase();
|
|
if (langFilter === 'fr') return isFr(text);
|
|
if (langFilter === 'en') return isEn(text) && !isFr(text);
|
|
return true;
|
|
});
|
|
}
|
|
|
|
const dateFilter = this.date();
|
|
if (dateFilter !== 'any') {
|
|
const now = Date.now();
|
|
const cutoff = {
|
|
day: now - 24 * 3600 * 1000,
|
|
week: now - 7 * 24 * 3600 * 1000,
|
|
month: now - 30 * 24 * 3600 * 1000,
|
|
year: now - 365 * 24 * 3600 * 1000,
|
|
}[dateFilter] || 0;
|
|
if (cutoff > 0) {
|
|
arr = arr.filter(v => (v.uploaded || 0) >= cutoff);
|
|
}
|
|
}
|
|
|
|
const sortOrder = this.sort();
|
|
if (sortOrder === 'recent') arr.sort((a, b) => (b.uploaded || 0) - (a.uploaded || 0));
|
|
if (sortOrder === 'viewed') arr.sort((a, b) => (b.views || 0) - (a.views || 0));
|
|
if (sortOrder === 'longest') arr.sort((a, b) => (b.duration || 0) - (a.duration || 0));
|
|
|
|
return arr;
|
|
}
|
|
|
|
changeProvider(p: any) {
|
|
const provider = String(p) as Provider;
|
|
this.instances.setSelectedProvider(provider);
|
|
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); }
|
|
onTypeChange(val: any) { this.type.set(val as any); }
|
|
|
|
// Raccourci Vidéos / Shorts synchronisé avec le filtre "type" existant.
|
|
contentFilter = computed<'all' | 'videos' | 'shorts'>(() => {
|
|
const t = this.type();
|
|
if (t === 'short') return 'shorts';
|
|
if (t === 'video') return 'videos';
|
|
return 'all';
|
|
});
|
|
setContentFilter(v: 'all' | 'videos' | 'shorts'): void {
|
|
this.type.set(v === 'shorts' ? 'short' : v === 'videos' ? 'video' : 'any');
|
|
}
|
|
|
|
kindOf(v: Video): string {
|
|
const p = this.provider();
|
|
return classifyVideo({ ...v, provider: (v.provider as string) || p });
|
|
}
|
|
|
|
shortsCount(): number {
|
|
const p = this.provider();
|
|
return this.allVideos().filter(v => isShortVideo({ ...v, provider: (v.provider as string) || p })).length;
|
|
}
|
|
onLanguageChange(val: any) { this.language.set(val as any); }
|
|
onDateChange(val: any) { this.date.set(val as any); }
|
|
|
|
retry() {
|
|
this.error.set(null);
|
|
this.loadMore();
|
|
}
|
|
|
|
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;
|
|
}
|
|
|
|
trackByVideo(_idx: number, v: Video) { return v.videoId || v.url; }
|
|
|
|
ngOnDestroy(): void {
|
|
this.version++; // Invalidate any pending requests
|
|
}
|
|
}
|