feat(channel): page details chaine multi-providers avec onglets et infinite scroll
CI / build-and-test (push) Successful in 13m34s
CI / build-and-test (push) Successful in 13m34s
- Strategy Pattern front: ChannelProviderInterface + 6 providers (yt/dm/tw/pt/od/ru) + factory (ids longs/courts) - ChannelContentService: cache par onglet/tri/recherche (TTL 5min), pagination nextPage - ChannelPage: header (banniere/avatar/subs/description ...plus), tabs dynamiques par capabilities, grilles videos/shorts 9:16/playlists/live, recherche debounced, tri recent/populaire, infinite scroll IntersectionObserver, skeletons/empty/error - Backend: GET /api/channels/:provider/:id/content (videos/shorts/playlists/live) natif par provider
This commit is contained in:
@@ -5,6 +5,7 @@ import { HeaderComponent } from './components/header/header.component';
|
||||
import { SidebarComponent } from './components/sidebar/sidebar.component';
|
||||
import { ThemesNavComponent } from './components/themes/themes-nav.component';
|
||||
import { I18nService } from './services/i18n.service';
|
||||
import { AuthService } from './services/auth.service';
|
||||
|
||||
@Component({
|
||||
selector: 'app-root',
|
||||
@@ -15,6 +16,7 @@ import { I18nService } from './services/i18n.service';
|
||||
})
|
||||
export class AppComponent {
|
||||
private i18n = inject(I18nService);
|
||||
private auth = inject(AuthService);
|
||||
private router = inject(Router);
|
||||
sidebarOpen = signal(false);
|
||||
sidebarCollapsed = signal(false);
|
||||
@@ -39,6 +41,13 @@ export class AppComponent {
|
||||
}
|
||||
|
||||
constructor() {
|
||||
// Restaure la session persistée (cookies) après F5 : sans ça l'app croit
|
||||
// l'utilisateur déconnecté et toutes les actions authentifiées font 401.
|
||||
try {
|
||||
this.auth.initSession().subscribe((ok) => {
|
||||
if (ok) this.auth.fetchMe().subscribe();
|
||||
});
|
||||
} catch {}
|
||||
// Initialize and react to route changes to show/hide the global themes bar
|
||||
try {
|
||||
this.updateShowThemesBar();
|
||||
|
||||
@@ -23,6 +23,11 @@ export const APP_ROUTES: Routes = [
|
||||
loadComponent: () => import('./components/shorts/watch-short.component').then(m => m.WatchShortComponent),
|
||||
title: 'NewTube - Shorts'
|
||||
},
|
||||
{
|
||||
path: 'channel/:provider/:externalId',
|
||||
loadComponent: () => import('./components/channel/channel-page.component').then(m => m.ChannelPageComponent),
|
||||
title: 'NewTube - Channel'
|
||||
},
|
||||
{
|
||||
path: 'watch/:id',
|
||||
loadComponent: () => import('./components/watch/watch.component').then(m => m.WatchComponent),
|
||||
@@ -59,6 +64,16 @@ export const APP_ROUTES: Routes = [
|
||||
loadComponent: () => import('./components/library/subscriptions/subscriptions.component').then(m => m.SubscriptionsComponent),
|
||||
title: 'NewTube - Subscriptions'
|
||||
},
|
||||
{
|
||||
path: 'library/downloads/:id',
|
||||
loadComponent: () => import('./components/library/downloads/download-play.component').then(m => m.DownloadPlayComponent),
|
||||
title: 'NewTube - Play download'
|
||||
},
|
||||
{
|
||||
path: 'library/downloads',
|
||||
loadComponent: () => import('./components/library/downloads/downloads.component').then(m => m.DownloadsComponent),
|
||||
title: 'NewTube - Downloads'
|
||||
},
|
||||
{
|
||||
path: 'account',
|
||||
pathMatch: 'full',
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
import { Injectable, inject } from '@angular/core';
|
||||
import { HttpClient } from '@angular/common/http';
|
||||
import type { ProviderId } from '../../../shared/models/channel.model';
|
||||
import type { ChannelProviderInterface } from './channel-provider.interface';
|
||||
import {
|
||||
DailymotionChannelProvider,
|
||||
OdyseeChannelProvider,
|
||||
PeerTubeChannelProvider,
|
||||
RumbleChannelProvider,
|
||||
TwitchChannelProvider,
|
||||
YouTubeChannelProvider,
|
||||
} from './channel-providers';
|
||||
|
||||
/** Normalise les ids longs (youtube) + courts (yt) vers ProviderId. */
|
||||
export function normalizeChannelProvider(value: string | null | undefined): ProviderId | null {
|
||||
if (!value) return null;
|
||||
const v = String(value).trim().toLowerCase();
|
||||
const map: Record<string, ProviderId> = {
|
||||
yt: 'yt', youtube: 'yt',
|
||||
dm: 'dm', dailymotion: 'dm',
|
||||
tw: 'tw', twitch: 'tw',
|
||||
pt: 'pt', peertube: 'pt',
|
||||
od: 'od', odysee: 'od',
|
||||
ru: 'ru', rumble: 'ru',
|
||||
};
|
||||
return map[v] ?? null;
|
||||
}
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class ChannelProviderFactory {
|
||||
private http = inject(HttpClient);
|
||||
private instances = new Map<ProviderId, ChannelProviderInterface>();
|
||||
|
||||
get(provider: ProviderId): ChannelProviderInterface {
|
||||
const existing = this.instances.get(provider);
|
||||
if (existing) return existing;
|
||||
let created: ChannelProviderInterface;
|
||||
switch (provider) {
|
||||
case 'yt': created = new YouTubeChannelProvider(this.http); break;
|
||||
case 'dm': created = new DailymotionChannelProvider(this.http); break;
|
||||
case 'tw': created = new TwitchChannelProvider(this.http); break;
|
||||
case 'pt': created = new PeerTubeChannelProvider(this.http); break;
|
||||
case 'od': created = new OdyseeChannelProvider(this.http); break;
|
||||
case 'ru': created = new RumbleChannelProvider(this.http); break;
|
||||
default: created = new YouTubeChannelProvider(this.http); break;
|
||||
}
|
||||
this.instances.set(provider, created);
|
||||
return created;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import { Observable } from 'rxjs';
|
||||
import type { ProviderId } from '../../../shared/models/channel.model';
|
||||
import type {
|
||||
ChannelContentPage,
|
||||
ChannelContentType,
|
||||
ChannelDetail,
|
||||
ChannelPlaylist,
|
||||
ChannelSort,
|
||||
} from '../../../shared/models/channel-detail.model';
|
||||
import type { VideoItem } from '../../../shared/models/video-item.model';
|
||||
|
||||
export interface ChannelContentQuery {
|
||||
page?: number;
|
||||
limit?: number;
|
||||
sort?: ChannelSort;
|
||||
q?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Strategy Pattern : un provider par plateforme.
|
||||
* Chaque implémentation normalise vers VideoItem / ChannelPlaylist.
|
||||
*/
|
||||
export interface ChannelProviderInterface {
|
||||
readonly id: ProviderId;
|
||||
getChannelInfo(externalId: string): Observable<ChannelDetail | null>;
|
||||
getChannelVideos(externalId: string, query?: ChannelContentQuery): Observable<ChannelContentPage<VideoItem>>;
|
||||
getChannelShorts?(externalId: string, query?: ChannelContentQuery): Observable<ChannelContentPage<VideoItem>>;
|
||||
getChannelPlaylists?(externalId: string, query?: ChannelContentQuery): Observable<ChannelContentPage<ChannelPlaylist>>;
|
||||
getChannelStreams?(externalId: string, query?: ChannelContentQuery): Observable<ChannelContentPage<VideoItem>>;
|
||||
searchInChannel?(externalId: string, q: string, query?: ChannelContentQuery): Observable<ChannelContentPage<VideoItem>>;
|
||||
supports(type: ChannelContentType | 'searchInChannel'): boolean;
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import type { HttpClient } from '@angular/common/http';
|
||||
import { DEFAULT_CAPABILITIES } from '../../../shared/models/channel-detail.model';
|
||||
import { HttpChannelProvider } from './http-channel.provider';
|
||||
|
||||
export class YouTubeChannelProvider extends HttpChannelProvider {
|
||||
constructor(http: HttpClient) { super('yt', http, DEFAULT_CAPABILITIES['yt']); }
|
||||
}
|
||||
export class DailymotionChannelProvider extends HttpChannelProvider {
|
||||
constructor(http: HttpClient) { super('dm', http, DEFAULT_CAPABILITIES['dm']); }
|
||||
}
|
||||
export class TwitchChannelProvider extends HttpChannelProvider {
|
||||
constructor(http: HttpClient) { super('tw', http, DEFAULT_CAPABILITIES['tw']); }
|
||||
}
|
||||
export class PeerTubeChannelProvider extends HttpChannelProvider {
|
||||
constructor(http: HttpClient) { super('pt', http, DEFAULT_CAPABILITIES['pt']); }
|
||||
}
|
||||
export class OdyseeChannelProvider extends HttpChannelProvider {
|
||||
constructor(http: HttpClient) { super('od', http, DEFAULT_CAPABILITIES['od']); }
|
||||
}
|
||||
export class RumbleChannelProvider extends HttpChannelProvider {
|
||||
constructor(http: HttpClient) { super('ru', http, DEFAULT_CAPABILITIES['ru']); }
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
import { HttpClient, HttpParams } from '@angular/common/http';
|
||||
import { Observable, catchError, map, of } from 'rxjs';
|
||||
import type { ChannelMeta, ProviderId } from '../../../shared/models/channel.model';
|
||||
import type {
|
||||
ChannelCapabilities,
|
||||
ChannelContentPage,
|
||||
ChannelContentType,
|
||||
ChannelDetail,
|
||||
ChannelPlaylist,
|
||||
ChannelSort,
|
||||
} from '../../../shared/models/channel-detail.model';
|
||||
import { DEFAULT_CAPABILITIES, buildChannelDetail } from '../../../shared/models/channel-detail.model';
|
||||
import type { VideoItem } from '../../../shared/models/video-item.model';
|
||||
import type { ChannelContentQuery, ChannelProviderInterface } from './channel-provider.interface';
|
||||
|
||||
const LONG_PROVIDER: Record<ProviderId, VideoItem['provider']> = {
|
||||
yt: 'youtube',
|
||||
dm: 'dailymotion',
|
||||
tw: 'twitch',
|
||||
pt: 'peertube',
|
||||
od: 'odysee',
|
||||
ru: 'rumble',
|
||||
};
|
||||
|
||||
function toVideoItem(raw: any, provider: ProviderId, fallbackChannelId: string): VideoItem {
|
||||
return {
|
||||
id: String(raw?.id ?? ''),
|
||||
provider: LONG_PROVIDER[provider],
|
||||
title: String(raw?.title ?? ''),
|
||||
thumbnailUrl: raw?.thumbnail ?? raw?.thumbnailUrl ?? '',
|
||||
durationSec: typeof raw?.duration === 'number' ? raw.duration : raw?.durationSec,
|
||||
channelName: raw?.uploaderName ?? raw?.channelName,
|
||||
channelExternalId: raw?.channelExternalId ?? raw?.channelId ?? fallbackChannelId,
|
||||
channelAvatarUrl: raw?.uploaderAvatar ?? raw?.channelAvatarUrl,
|
||||
viewCount: typeof raw?.views === 'number' ? raw.views : raw?.viewCount,
|
||||
publishedAt: raw?.publishedAt ?? raw?.uploadedDate,
|
||||
slug: raw?.slug,
|
||||
channel: raw?.channel,
|
||||
};
|
||||
}
|
||||
|
||||
function toPlaylist(raw: any, provider: ProviderId): ChannelPlaylist {
|
||||
return {
|
||||
id: String(raw?.id ?? ''),
|
||||
provider,
|
||||
title: String(raw?.title ?? raw?.name ?? ''),
|
||||
thumbnailUrl: raw?.thumbnail ?? raw?.thumbnailUrl ?? null,
|
||||
videoCount: typeof raw?.videoCount === 'number' ? raw.videoCount : (typeof raw?.videos_total === 'number' ? raw.videos_total : null),
|
||||
updatedAt: raw?.updatedAt ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Implémentation générique : appelle le backend
|
||||
* GET /api/channels/:provider/:id/content?type=&page=&limit=&sort=&q=
|
||||
* et normalise vers VideoItem / ChannelPlaylist.
|
||||
*/
|
||||
interface ContentApiResponse {
|
||||
items?: any[];
|
||||
nextPage?: number | null;
|
||||
total?: number | null;
|
||||
}
|
||||
|
||||
export class HttpChannelProvider implements ChannelProviderInterface {
|
||||
readonly id: ProviderId;
|
||||
protected capabilities: ChannelCapabilities;
|
||||
constructor(
|
||||
id: ProviderId,
|
||||
protected http: HttpClient,
|
||||
capabilities?: ChannelCapabilities,
|
||||
) {
|
||||
this.id = id;
|
||||
this.capabilities = capabilities ?? DEFAULT_CAPABILITIES[id];
|
||||
}
|
||||
|
||||
supports(type: ChannelContentType | 'searchInChannel'): boolean {
|
||||
return Boolean((this.capabilities as unknown as Record<string, boolean>)[type]);
|
||||
}
|
||||
|
||||
getChannelInfo(externalId: string): Observable<ChannelDetail | null> {
|
||||
return this.http.get<ChannelMeta>(`/api/channels/${this.id}/${encodeURIComponent(externalId)}`).pipe(
|
||||
map((meta) => (meta ? buildChannelDetail(meta, { capabilities: this.capabilities }) : null)),
|
||||
catchError(() => of(null)),
|
||||
);
|
||||
}
|
||||
|
||||
getChannelVideos(externalId: string, query?: ChannelContentQuery): Observable<ChannelContentPage<VideoItem>> {
|
||||
return this.fetchContent(externalId, 'videos', query).pipe(
|
||||
map((r) => ({ ...r, items: r.items.map((raw) => toVideoItem(raw, this.id, externalId)) })),
|
||||
);
|
||||
}
|
||||
|
||||
getChannelShorts(externalId: string, query?: ChannelContentQuery): Observable<ChannelContentPage<VideoItem>> {
|
||||
if (!this.supports('shorts')) return of({ items: [], nextPage: null });
|
||||
return this.fetchContent(externalId, 'shorts', query).pipe(
|
||||
map((r) => ({ ...r, items: r.items.map((raw) => toVideoItem(raw, this.id, externalId)) })),
|
||||
);
|
||||
}
|
||||
|
||||
getChannelPlaylists(externalId: string, query?: ChannelContentQuery): Observable<ChannelContentPage<ChannelPlaylist>> {
|
||||
if (!this.supports('playlists')) return of({ items: [], nextPage: null });
|
||||
return this.fetchContent(externalId, 'playlists', query).pipe(
|
||||
map((r) => ({ ...r, items: r.items.map((raw) => toPlaylist(raw, this.id)) })),
|
||||
);
|
||||
}
|
||||
|
||||
getChannelStreams(externalId: string, query?: ChannelContentQuery): Observable<ChannelContentPage<VideoItem>> {
|
||||
if (!this.supports('live')) return of({ items: [], nextPage: null });
|
||||
return this.fetchContent(externalId, 'live', query).pipe(
|
||||
map((r) => ({ ...r, items: r.items.map((raw) => toVideoItem(raw, this.id, externalId)) })),
|
||||
);
|
||||
}
|
||||
|
||||
searchInChannel(externalId: string, q: string, query?: ChannelContentQuery): Observable<ChannelContentPage<VideoItem>> {
|
||||
if (!this.supports('searchInChannel')) return of({ items: [], nextPage: null });
|
||||
return this.fetchContent(externalId, 'videos', { ...query, q }).pipe(
|
||||
map((page) => {
|
||||
const needle = q.trim().toLowerCase();
|
||||
const items = page.items
|
||||
.map((raw) => toVideoItem(raw, this.id, externalId))
|
||||
.filter((v) => !needle || v.title.toLowerCase().includes(needle));
|
||||
return { ...page, items };
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
protected fetchContent(
|
||||
externalId: string,
|
||||
type: ChannelContentType,
|
||||
query?: ChannelContentQuery,
|
||||
): Observable<ChannelContentPage<any>> {
|
||||
let params = new HttpParams().set('type', type);
|
||||
if (query?.page) params = params.set('page', String(query.page));
|
||||
if (query?.limit) params = params.set('limit', String(query.limit));
|
||||
if (query?.sort) params = params.set('sort', query.sort as ChannelSort);
|
||||
if (query?.q) params = params.set('q', query.q);
|
||||
return this.http
|
||||
.get<ContentApiResponse>(`/api/channels/${this.id}/${encodeURIComponent(externalId)}/content`, { params })
|
||||
.pipe(
|
||||
map((resp) => ({
|
||||
items: Array.isArray(resp?.items) ? resp!.items! : [],
|
||||
nextPage: resp?.nextPage ?? null,
|
||||
total: resp?.total ?? null,
|
||||
})),
|
||||
catchError(() => of({ items: [], nextPage: null })),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -13,7 +13,13 @@ export class TwAdapter implements ProviderAdapter<VideoItem> {
|
||||
if (params.sort) httpParams = httpParams.set('sort', params.sort);
|
||||
const res = await firstValueFrom(this.http.get<any>(`/api/search`, { params: httpParams }));
|
||||
const items: VideoItem[] = (res?.groups?.tw || []).map((it: any) => {
|
||||
const isChannel = String(it?.type || '').toLowerCase() === 'channel';
|
||||
// /api/search (tw) ne retourne que des chaînes : le player exige le login
|
||||
// (?channel=<login>), jamais l'id numérique. Sans `channel`, /watch tente
|
||||
// ?video=<id> et Twitch répond par une erreur.
|
||||
const login: string | undefined =
|
||||
it.channelExternalId || it.channelHandle || undefined;
|
||||
const kind = String(it?.type || '').toLowerCase();
|
||||
const isChannel = kind === 'channel' || kind === 'live' || Boolean(login);
|
||||
return {
|
||||
id: it.id,
|
||||
provider: 'twitch',
|
||||
@@ -23,7 +29,10 @@ export class TwAdapter implements ProviderAdapter<VideoItem> {
|
||||
thumbnailUrl: it.thumbnail,
|
||||
viewCount: typeof it.views === 'number' ? it.views : undefined,
|
||||
publishedAt: it.publishedAt,
|
||||
channel: isChannel ? it.id : undefined,
|
||||
channelExternalId: login,
|
||||
channelAvatarUrl: it.uploaderAvatar || undefined,
|
||||
// Login pour l'embed ?channel=... (video-card -> watchQueryParams).
|
||||
channel: isChannel ? login : undefined,
|
||||
} as VideoItem;
|
||||
});
|
||||
return { items, total: Array.isArray(res?.groups?.tw) ? res.groups.tw.length : 0 };
|
||||
|
||||
@@ -0,0 +1,223 @@
|
||||
import 'zone.js/node';
|
||||
import '@angular/compiler';
|
||||
import { Injector, runInInjectionContext, ɵChangeDetectionScheduler as ChangeDetectionScheduler, ɵEffectScheduler as EffectScheduler } from '@angular/core';
|
||||
|
||||
/**
|
||||
* Step 11 — Unit tests for the unified search UX components.
|
||||
*
|
||||
* ts-node compiles with isolated transpilation (no decorator output => no ɵfac),
|
||||
* so TestBed cannot instantiate Angular classes here. We import dynamically and
|
||||
* construct components inside a minimal injection context with fakes.
|
||||
*/
|
||||
|
||||
function fmtError(e: unknown): string {
|
||||
if (!e) return String(e);
|
||||
if (e instanceof Error) return e.stack || `${e.name}: ${e.message}`;
|
||||
try { return JSON.stringify(e, Object.getOwnPropertyNames(e)); } catch { return String(e); }
|
||||
}
|
||||
|
||||
process.on('uncaughtException', (e) => { console.error('UNCAUGHT:', fmtError(e)); process.exit(1); });
|
||||
process.on('unhandledRejection', (e) => { console.error('UNHANDLED:', fmtError(e)); process.exit(1); });
|
||||
|
||||
function assertEqual(actual: unknown, expected: unknown, message: string): void {
|
||||
const a = JSON.stringify(actual);
|
||||
const b = JSON.stringify(expected);
|
||||
if (a !== b) {
|
||||
throw new Error(`${message} (expected ${b}, got ${a})`);
|
||||
}
|
||||
}
|
||||
|
||||
function assert(condition: unknown, message: string): void {
|
||||
if (!condition) {
|
||||
throw new Error(message);
|
||||
}
|
||||
}
|
||||
|
||||
function sleep(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
(async () => {
|
||||
const [{ SearchBoxComponent }, { ProviderPickerComponent }, { SearchService }, { UserService }, { HistoryService }, { TelemetryService }, { PROVIDERS }] = await Promise.all([
|
||||
import('../../components/search/search-box.component'),
|
||||
import('../../components/search/provider-picker.component'),
|
||||
import('./search.service'),
|
||||
import('../../services/user.service'),
|
||||
import('../../services/history.service'),
|
||||
import('../../services/telemetry.service'),
|
||||
import('../core/providers/provider-registry'),
|
||||
]);
|
||||
|
||||
// --- Fakes for SearchBox dependencies ---
|
||||
|
||||
const makeSearchServiceFake = (initialProviders: any = 'all') => ({
|
||||
q$: { value: '' },
|
||||
providers$: { value: initialProviders },
|
||||
});
|
||||
|
||||
const makeUsersServiceFake = (prefs: any = null) => ({
|
||||
preferences: () => prefs,
|
||||
updatePreferences: (_patch: any) => ({ subscribe: () => {} }),
|
||||
});
|
||||
|
||||
const makeHistoryServiceFake = () => ({
|
||||
getSearchHistory: (_n: number) => ({ subscribe: () => {} }),
|
||||
});
|
||||
|
||||
function newBox(opts: { prefs?: any; providers?: any } = {}): any {
|
||||
// Constructor uses inject() + effect(): provide a minimal injection context with
|
||||
// the Angular internal schedulers plus fakes for the component's own deps.
|
||||
// The EffectScheduler mock runs the effect work immediately so component effects
|
||||
// (hydration, preference application) execute deterministically in tests.
|
||||
const injector = Injector.create({
|
||||
providers: [
|
||||
{ provide: ChangeDetectionScheduler, useValue: { notify: () => {} } },
|
||||
{ provide: EffectScheduler, useValue: { add: (e: any) => { try { e?.run?.(); } catch {} }, remove: () => {}, schedule: (e: any) => { try { e?.run?.(); } catch {} }, flush: () => {} } },
|
||||
{ provide: SearchService, useValue: makeSearchServiceFake(opts.providers ?? 'all') },
|
||||
{ provide: UserService, useValue: makeUsersServiceFake(opts.prefs ?? null) },
|
||||
{ provide: HistoryService, useValue: makeHistoryServiceFake() },
|
||||
{ provide: TelemetryService, useValue: { track: () => {} } },
|
||||
],
|
||||
});
|
||||
return runInInjectionContext(injector, () => new SearchBoxComponent());
|
||||
}
|
||||
|
||||
// --- SearchBox: inline @ parsing ---
|
||||
|
||||
function testAtParsingDetectsAtQuery(): void {
|
||||
const box = newBox();
|
||||
|
||||
box.queryUpdate('hello world');
|
||||
assert(box.atOptions().length === 0, 'plain text yields no @ options');
|
||||
assert(box.atOpen() === false, 'atOpen stays false on plain text');
|
||||
|
||||
box.queryUpdate('look for @yo');
|
||||
const opts = box.atOptions();
|
||||
assert(opts.length === 1, `'@yo' filters to one provider (got ${opts.length})`);
|
||||
assertEqual(opts[0].id, 'yt', "'@yo' matches YouTube");
|
||||
assert(box.atOpen() === true, 'atOpen becomes true when @ options exist');
|
||||
|
||||
box.queryUpdate('look for @yt');
|
||||
assert(box.atOptions().length === 1, '@yt still offers YouTube');
|
||||
assertEqual(box.atOptions()[0].id, 'yt', '@yt matches YouTube');
|
||||
|
||||
box.queryUpdate('look for @yt ');
|
||||
assert(box.atOpen() === false, 'space after @term closes autocomplete');
|
||||
|
||||
box.queryUpdate('look for @zz');
|
||||
assert(box.atOpen() === false && box.atOptions().length === 0, '@zz yields no options');
|
||||
}
|
||||
|
||||
function testApplyAtOptionStripsTermAndTogglesChip(): void {
|
||||
const box = newBox();
|
||||
|
||||
box.queryUpdate('linux @dm');
|
||||
box.applyAtOption('dm');
|
||||
assertEqual(box.query(), 'linux', 'applying an @ option strips the @term from the query');
|
||||
assertEqual(box.selected(), ['dm'], 'applying an @ option toggles the provider chip on');
|
||||
assert(box.atOpen() === false, 'popover closes after applying');
|
||||
assertEqual(box.providerMode(), 'custom', 'provider mode switches to custom');
|
||||
|
||||
box.applyAtOption('dm');
|
||||
assertEqual(box.selected(), [], 'applying again toggles the chip off');
|
||||
|
||||
box.toggleChip('pt');
|
||||
assertEqual(box.providersForRequest(), ['pt'], 'providersForRequest returns the selected list');
|
||||
box.toggleAll();
|
||||
assertEqual(box.providersForRequest(), 'all', 'toggleAll switches back to all');
|
||||
}
|
||||
|
||||
async function testDefaultProvidersPreferenceApplied(): Promise<void> {
|
||||
const box = newBox({ prefs: { defaultProviders: ['yt', 'dm'] } });
|
||||
// Allow queued microtasks/timeouts a tick (effects are driven by the fake scheduler)
|
||||
await sleep(50);
|
||||
assertEqual(box.providerMode(), 'custom', 'preference switches mode to custom');
|
||||
assertEqual(box.selected(), ['yt', 'dm'], 'preference providers become selected');
|
||||
}
|
||||
|
||||
function testAltShortcutsToggleChips(): void {
|
||||
const box = newBox();
|
||||
|
||||
const yt = PROVIDERS[0].id;
|
||||
const ev = {
|
||||
key: '1',
|
||||
altKey: true,
|
||||
ctrlKey: false,
|
||||
metaKey: false,
|
||||
shiftKey: false,
|
||||
preventDefault: () => {},
|
||||
stopImmediatePropagation: () => {},
|
||||
} as unknown as KeyboardEvent;
|
||||
box.handleKeydown(ev);
|
||||
assertEqual(box.selected(), [yt], 'Alt+1 toggles the first chip (yt) on');
|
||||
box.handleKeydown(ev);
|
||||
assertEqual(box.selected(), [], 'Alt+1 again toggles the chip off');
|
||||
}
|
||||
|
||||
// --- ProviderPicker toggles ---
|
||||
|
||||
function testPickerDraftSelection(): void {
|
||||
const picker = new ProviderPickerComponent();
|
||||
picker.selected = 'all';
|
||||
assert(picker.isChecked('yt'), 'All mode checks every provider');
|
||||
|
||||
picker.toggle('yt');
|
||||
const yt = 'yt';
|
||||
assert(picker.isChecked(yt) === false, 'unchecking from All removes only that provider');
|
||||
assert(picker.isChecked('dm'), 'other providers stay checked after unchecking one');
|
||||
assertEqual(picker.allMode(), false, 'All mode turns off after unchecking');
|
||||
|
||||
picker.toggle(yt);
|
||||
assert(picker.isChecked(yt), 're-checking adds the provider back');
|
||||
|
||||
let emitted: any = null;
|
||||
picker.apply.subscribe((v: any) => { emitted = v; });
|
||||
picker.onApply();
|
||||
assert(Array.isArray(emitted?.selection), 'apply emits a list selection');
|
||||
assertEqual(emitted.selection.length, PROVIDERS.length, 'apply emits every provider when all are checked');
|
||||
|
||||
picker.toggleAllMode();
|
||||
picker.onApply();
|
||||
assertEqual(emitted.selection, 'all', 'apply emits all after toggleAllMode');
|
||||
assert(emitted.rememberDefault === false, 'rememberDefault defaults to false');
|
||||
}
|
||||
|
||||
function testPickerRememberDefault(): void {
|
||||
const picker = new ProviderPickerComponent();
|
||||
picker.selected = ['yt'];
|
||||
picker.rememberDefault.set(true);
|
||||
let emitted: any = null;
|
||||
picker.apply.subscribe((v: any) => { emitted = v; });
|
||||
picker.toggle('dm');
|
||||
picker.onApply();
|
||||
assertEqual(emitted.selection, ['yt', 'dm'], 'draft selection is emitted on apply');
|
||||
assert(emitted.rememberDefault === true, 'rememberDefault flag is emitted');
|
||||
}
|
||||
|
||||
const tests: Array<[string, () => void | Promise<void>]> = [
|
||||
['SearchBox @ parsing detects and filters providers', testAtParsingDetectsAtQuery],
|
||||
['SearchBox applyAtOption strips term and toggles chip', testApplyAtOptionStripsTermAndTogglesChip],
|
||||
['SearchBox applies defaultProviders preference', testDefaultProvidersPreferenceApplied],
|
||||
['SearchBox Alt+1..6 shortcut toggles chips', testAltShortcutsToggleChips],
|
||||
['ProviderPicker draft selection + apply', testPickerDraftSelection],
|
||||
['ProviderPicker rememberDefault flag', testPickerRememberDefault],
|
||||
];
|
||||
|
||||
let failures = 0;
|
||||
for (const [name, fn] of tests) {
|
||||
try {
|
||||
await fn();
|
||||
console.log(`✓ ${name}`);
|
||||
} catch (error) {
|
||||
failures += 1;
|
||||
console.error(`✗ ${name}`);
|
||||
console.error(error instanceof Error ? error.stack : fmtError(error));
|
||||
}
|
||||
}
|
||||
|
||||
console.log(failures === 0 ? '\nAll search component tests passed.' : `\n${failures} test(s) failed.`);
|
||||
process.exit(failures === 0 ? 0 : 1);
|
||||
})().catch((e) => {
|
||||
console.error('Unhandled test runner error:', fmtError(e));
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,158 @@
|
||||
import 'zone.js/node';
|
||||
import '@angular/compiler'; // JIT needed: decorators are evaluated at import time under ts-node
|
||||
import { of } from 'rxjs';
|
||||
import { Injector, runInInjectionContext } from '@angular/core';
|
||||
import { HttpClient } from '@angular/common/http';
|
||||
import { SearchService } from './search.service';
|
||||
import type { ProviderId } from '../core/providers/provider-registry';
|
||||
|
||||
/**
|
||||
* Step 11 — Unit tests for SearchService.
|
||||
*
|
||||
* ts-node compiles in isolation (no decorator output => no ɵfac), so TestBed
|
||||
* cannot instantiate Angular classes here. We build the service manually with
|
||||
* a minimal HttpClient fake returning real Observables.
|
||||
*/
|
||||
|
||||
function assertEqual(actual: unknown, expected: unknown, message: string): void {
|
||||
const a = JSON.stringify(actual);
|
||||
const b = JSON.stringify(expected);
|
||||
if (a !== b) {
|
||||
throw new Error(`${message} (expected ${b}, got ${a})`);
|
||||
}
|
||||
}
|
||||
|
||||
function assert(condition: unknown, message: string): void {
|
||||
if (!condition) {
|
||||
throw new Error(message);
|
||||
}
|
||||
}
|
||||
|
||||
function sleep(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
interface RecordedRequest {
|
||||
url: string;
|
||||
providerParam: string | null;
|
||||
}
|
||||
|
||||
function makeHttpFake(payloads: Record<string, any>) {
|
||||
const requests: RecordedRequest[] = [];
|
||||
const fake = {
|
||||
// Adapters call firstValueFrom(http.get(...)) — return a real Observable
|
||||
get: (url: string, opts: any) => {
|
||||
let providerParam: string | null = null;
|
||||
try { providerParam = opts?.params?.get('providers') ?? null; } catch {}
|
||||
requests.push({ url, providerParam });
|
||||
// provider id embedded in the request is the key into payloads
|
||||
const key = providerParam || 'all';
|
||||
return of(payloads[key] ?? { groups: {} });
|
||||
},
|
||||
requests,
|
||||
};
|
||||
return fake;
|
||||
}
|
||||
|
||||
function makeService(payloads: Record<string, any> = {}) {
|
||||
const httpFake = makeHttpFake(payloads);
|
||||
// SearchService uses inject(HttpClient): create it inside an injection context
|
||||
const injector = Injector.create({
|
||||
providers: [{ provide: HttpClient, useValue: httpFake as unknown as HttpClient }],
|
||||
});
|
||||
const service = runInInjectionContext(injector, () => new SearchService());
|
||||
return { service, httpFake };
|
||||
}
|
||||
|
||||
function testSetProvidersNormalizesValues(): void {
|
||||
const { service } = makeService();
|
||||
service.setProviders(['yt', 'dm'] as ProviderId[]);
|
||||
assertEqual(service.providers$.value, ['yt', 'dm'], 'setProviders keeps a non-empty list');
|
||||
service.setProviders([] as any);
|
||||
assertEqual(service.providers$.value, 'all', 'empty list falls back to all');
|
||||
service.setProviders('all');
|
||||
assertEqual(service.providers$.value, 'all', 'all stays all');
|
||||
}
|
||||
|
||||
function testSetPageAndPageSizeNormalize(): void {
|
||||
const { service } = makeService();
|
||||
service.setPage(3);
|
||||
assert(service.page$.value === 3, 'page 3 is kept');
|
||||
service.setPage(-5);
|
||||
assert(service.page$.value === 1, 'negative page is floored to ' + 1);
|
||||
service.setPageSize(999);
|
||||
assert(service.pageSize$.value === 50, 'pageSize is capped at 50');
|
||||
service.setPageSize(0);
|
||||
assert(service.pageSize$.value === 24, 'pageSize 0 falls back to default 24');
|
||||
service.setSort('views');
|
||||
assertEqual(service.sort$.value, 'views', 'sort is applied');
|
||||
service.setSort('bogus' as any);
|
||||
assertEqual(service.sort$.value, 'relevance', 'invalid sort falls back to relevance');
|
||||
}
|
||||
|
||||
async function testRequestFanOutPerProvider(): Promise<void> {
|
||||
const payload = {
|
||||
yt: { groups: { yt: [{ id: 'a', title: 'A' }] } },
|
||||
dm: { groups: { dm: [{ id: 'b', title: 'B' }] } },
|
||||
};
|
||||
const { service, httpFake } = makeService(payload);
|
||||
|
||||
// request$ is cold: subscribing activates the params$ pipeline
|
||||
const responses: any[] = [];
|
||||
service.request$.subscribe((r: any) => responses.push(r));
|
||||
|
||||
service.setQuery('linux');
|
||||
service.setProviders(['yt', 'dm'] as ProviderId[]);
|
||||
|
||||
// params$ debounces 120ms before fanning out to adapters
|
||||
await sleep(400);
|
||||
|
||||
assert(httpFake.requests.length === 2, `two adapter HTTP calls expected (got ${httpFake.requests.length})`);
|
||||
const providerParams = httpFake.requests.map((r) => r.providerParam).sort();
|
||||
assertEqual(providerParams, ['dm', 'yt'], 'each adapter requests its own provider id');
|
||||
|
||||
// The aggregated response exposes groups keyed by provider
|
||||
await sleep(50);
|
||||
assert(responses.length >= 1, 'request$ emitted an aggregated response');
|
||||
const last = responses[responses.length - 1];
|
||||
assertEqual(last.groups?.yt?.length, 1, 'yt group has 1 item');
|
||||
assertEqual(last.groups?.dm?.length, 1, 'dm group has 1 item');
|
||||
}
|
||||
|
||||
async function testShortQueryTriggersNothing(): Promise<void> {
|
||||
const { service, httpFake } = makeService({});
|
||||
service.setQuery('a');
|
||||
await sleep(350);
|
||||
assert(httpFake.requests.length === 0, 'query shorter than 2 chars triggers no request');
|
||||
}
|
||||
|
||||
const tests: Array<[string, () => void | Promise<void>]> = [
|
||||
['SearchService.setProviders normalizes values', testSetProvidersNormalizesValues],
|
||||
['SearchService.setPage/setPageSize/setSort normalize', testSetPageAndPageSizeNormalize],
|
||||
['SearchService.request$ fans out one call per provider', testRequestFanOutPerProvider],
|
||||
['SearchService ignores short queries', testShortQueryTriggersNothing],
|
||||
];
|
||||
|
||||
(async () => {
|
||||
let failures = 0;
|
||||
for (const [name, fn] of tests) {
|
||||
try {
|
||||
await fn();
|
||||
console.log(`✓ ${name}`);
|
||||
} catch (error) {
|
||||
failures += 1;
|
||||
console.error(`✗ ${name}`);
|
||||
console.error(error instanceof Error ? error.stack : error);
|
||||
}
|
||||
}
|
||||
|
||||
console.log(failures === 0 ? '\nAll SearchService tests passed.' : `\n${failures} test(s) failed.`);
|
||||
if (failures > 0) {
|
||||
process.exitCode = 1;
|
||||
}
|
||||
// Explicit exit: keep-alive handles (BehaviorSubjects, timers) can hang the process
|
||||
process.exit(failures === 0 ? 0 : 1);
|
||||
})().catch((e) => {
|
||||
console.error('Unhandled test runner error:', e instanceof Error ? e.stack : e);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -111,8 +111,14 @@ export class SearchService {
|
||||
|
||||
// Convenience setter helpers
|
||||
setQuery(q: string) { this.q$.next(q || ''); }
|
||||
setProviders(list: ProviderId[] | 'all') { this.providers$.next(list && (Array.isArray(list) ? list : 'all')); }
|
||||
setProviders(list: ProviderId[] | 'all') {
|
||||
// Normalize: 'all' or a non-empty list; anything else falls back to 'all'
|
||||
this.providers$.next(list === 'all' || (Array.isArray(list) && list.length > 0) ? list : 'all');
|
||||
}
|
||||
setPage(page: number) { this.page$.next(Math.max(1, Math.floor(page || 1))); }
|
||||
setPageSize(size: number) { this.pageSize$.next(Math.min(50, Math.max(1, Math.floor(size || 24)))); }
|
||||
setSort(sort: 'relevance' | 'date' | 'views' | 'duration') { this.sort$.next(sort || 'relevance'); }
|
||||
setSort(sort: 'relevance' | 'date' | 'views' | 'duration') {
|
||||
const allowed: Array<'relevance' | 'date' | 'views' | 'duration'> = ['relevance', 'date', 'views', 'duration'];
|
||||
this.sort$.next(allowed.includes(sort) ? sort : 'relevance');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
import { ChangeDetectionStrategy, Component, Input, computed, inject, signal } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { RouterLink } from '@angular/router';
|
||||
import type { ProviderId, ChannelMeta } from '../../models/channel.model';
|
||||
import { ChannelsService } from '../../../../services/channels.service';
|
||||
|
||||
@Component({
|
||||
selector: 'app-channel-link',
|
||||
standalone: true,
|
||||
imports: [CommonModule, RouterLink],
|
||||
template: `
|
||||
<ng-container *ngIf="hasDestination(); else plainLabel">
|
||||
<a
|
||||
[routerLink]="['/channel', provider, externalId]"
|
||||
class="inline-flex items-center gap-2 font-medium transition hover:text-red-300"
|
||||
(mouseenter)="prefetch()"
|
||||
>
|
||||
<span>{{ label() }}</span>
|
||||
<ng-container *ngIf="channel()?.verified">
|
||||
<span class="text-xs text-emerald-300">✓</span>
|
||||
</ng-container>
|
||||
</a>
|
||||
</ng-container>
|
||||
<ng-template #plainLabel>
|
||||
<span class="font-medium">{{ label() }}</span>
|
||||
</ng-template>
|
||||
`,
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class ChannelLinkComponent {
|
||||
private channels = inject(ChannelsService);
|
||||
|
||||
@Input() provider: ProviderId | null = null;
|
||||
@Input() externalId: string | null = null;
|
||||
@Input() fallbackLabel: string | null = null;
|
||||
@Input() eager = false;
|
||||
|
||||
private cached = signal<ChannelMeta | null>(null);
|
||||
readonly channel = computed(() => this.cached());
|
||||
|
||||
label(): string {
|
||||
const meta = this.cached();
|
||||
return meta?.title || meta?.handle || this.fallbackLabel || 'Chaîne';
|
||||
}
|
||||
|
||||
hasDestination(): boolean {
|
||||
return Boolean(this.provider && this.externalId);
|
||||
}
|
||||
|
||||
ngOnInit() {
|
||||
if (this.eager) this.prefetch();
|
||||
}
|
||||
|
||||
prefetch(): void {
|
||||
if (!this.provider || !this.externalId) return;
|
||||
if (this.cached()) return;
|
||||
this.channels.get(this.provider, this.externalId).subscribe((meta) => {
|
||||
if (meta) this.cached.set(meta);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import { ChangeDetectionStrategy, Component, Input } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
|
||||
@Component({
|
||||
selector: 'app-compact-number',
|
||||
standalone: true,
|
||||
imports: [CommonModule],
|
||||
template: `{{ formatted }}`,
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class CompactNumberComponent {
|
||||
@Input() value: number | null | undefined = null;
|
||||
get formatted(): string {
|
||||
const n = Number(this.value);
|
||||
if (!Number.isFinite(n)) return '—';
|
||||
try {
|
||||
return new Intl.NumberFormat('fr-FR', { notation: 'compact', compactDisplay: 'short' }).format(n);
|
||||
} catch {
|
||||
return String(n);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
import { ChangeDetectionStrategy, Component, EventEmitter, Input, Output, computed, inject, signal } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import type { ProviderId, ChannelMeta } from '../../models/channel.model';
|
||||
import { SubscriptionsService } from '../../../../services/subscriptions.service';
|
||||
|
||||
@Component({
|
||||
selector: 'app-subscribe-button',
|
||||
standalone: true,
|
||||
imports: [CommonModule],
|
||||
template: `
|
||||
<button
|
||||
type="button"
|
||||
class="inline-flex items-center gap-2 rounded-md border px-3 py-1.5 text-sm font-semibold transition focus:outline-none focus-visible:ring-2 focus-visible:ring-red-400"
|
||||
[class.border-red-500/60]="isSubscribed()"
|
||||
[class.bg-red-500/20]="isSubscribed()"
|
||||
[class.text-red-100]="isSubscribed()"
|
||||
[class.border-slate-600]="!isSubscribed()"
|
||||
[class.bg-slate-800/70]="!isSubscribed()"
|
||||
[class.text-slate-200]="!isSubscribed()"
|
||||
[disabled]="isBusy()"
|
||||
(click)="toggle()"
|
||||
[attr.aria-pressed]="isSubscribed()"
|
||||
>
|
||||
<span *ngIf="!isSubscribed(); else unsub">S'abonner</span>
|
||||
<ng-template #unsub>Se désabonner</ng-template>
|
||||
</button>
|
||||
`,
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class SubscribeButtonComponent {
|
||||
private readonly subs = inject(SubscriptionsService);
|
||||
|
||||
@Input({ required: true }) provider!: ProviderId;
|
||||
@Input({ required: true }) externalId!: string;
|
||||
@Input() channelMeta?: ChannelMeta | null;
|
||||
@Input() optimistic = true;
|
||||
|
||||
@Output() subscribed = new EventEmitter<ChannelMeta | null>();
|
||||
@Output() unsubscribed = new EventEmitter<void>();
|
||||
|
||||
private busy = signal(false);
|
||||
readonly isBusy = computed(() => this.busy());
|
||||
|
||||
readonly isSubscribed = computed(() => this.subs.isSubscribed(this.provider, this.externalId));
|
||||
|
||||
toggle(): void {
|
||||
if (this.busy()) return;
|
||||
const subscribed = this.isSubscribed();
|
||||
this.busy.set(true);
|
||||
|
||||
if (subscribed) {
|
||||
const current = this.subs.items().find((item) => item.channel.provider === this.provider && item.channel.externalId === this.externalId);
|
||||
if (!current) {
|
||||
this.busy.set(false);
|
||||
return;
|
||||
}
|
||||
this.subs.unsubscribe(current.subscriptionId).subscribe({
|
||||
next: (ok) => {
|
||||
if (ok) this.unsubscribed.emit();
|
||||
},
|
||||
complete: () => this.busy.set(false),
|
||||
error: () => this.busy.set(false),
|
||||
});
|
||||
} else {
|
||||
this.subs.subscribe(this.provider, this.externalId, { refresh: this.optimistic }).subscribe({
|
||||
next: (item) => {
|
||||
this.subscribed.emit(item?.channel ?? null);
|
||||
},
|
||||
complete: () => this.busy.set(false),
|
||||
error: () => this.busy.set(false),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,24 +1,94 @@
|
||||
<a [routerLink]="['/watch', video.id]" [queryParams]="buildQueryParams(video)" [state]="{ video }" class="group flex flex-col gap-2">
|
||||
<div class="relative aspect-video w-full overflow-hidden rounded-lg bg-zinc-800">
|
||||
<img [src]="video.thumbnailUrl" [alt]="video.title" class="h-full w-full object-cover transition-transform group-hover:scale-105" />
|
||||
<div class="absolute top-1 right-1">
|
||||
<app-provider-badge [provider]="video.provider"></app-provider-badge>
|
||||
<div class="flex flex-col gap-2">
|
||||
<a [routerLink]="['/watch', video.id]" [queryParams]="buildQueryParams(video)" [state]="{ video }" class="group flex flex-col gap-2">
|
||||
<div class="relative aspect-video w-full overflow-hidden rounded-lg bg-zinc-800">
|
||||
<img [src]="video.thumbnailUrl" [alt]="video.title" class="h-full w-full object-cover transition-transform group-hover:scale-105" />
|
||||
<div class="absolute top-1 right-1">
|
||||
<app-provider-badge [provider]="video.provider"></app-provider-badge>
|
||||
</div>
|
||||
<span *ngIf="video.durationSec !== undefined" class="absolute bottom-1 right-1 rounded bg-black/80 px-1.5 py-0.5 text-xs text-white">
|
||||
{{ video.durationSec | duration }}
|
||||
</span>
|
||||
</div>
|
||||
<span *ngIf="video.durationSec !== undefined" class="absolute bottom-1 right-1 rounded bg-black/80 px-1.5 py-0.5 text-xs text-white">
|
||||
{{ video.durationSec | duration }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col">
|
||||
<h3 class="text-base font-semibold text-zinc-100 line-clamp-2" [title]="video.title">
|
||||
{{ video.title }}
|
||||
</h3>
|
||||
<div class="text-sm text-zinc-400">
|
||||
<p *ngIf="video.channelName" class="font-medium">{{ video.channelName }}</p>
|
||||
<div class="flex gap-2">
|
||||
<div class="flex flex-col gap-1">
|
||||
<h3 class="text-base font-semibold text-zinc-100 line-clamp-2" [title]="video.title">
|
||||
{{ video.title }}
|
||||
</h3>
|
||||
<div class="text-xs text-zinc-400 flex gap-2">
|
||||
<span *ngIf="video.viewCount">{{ video.viewCount | number }} vues</span>
|
||||
<span *ngIf="video.publishedAt">{{ video.publishedAt | date:'shortDate' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
|
||||
<div class="flex items-center justify-between gap-2 text-sm text-zinc-400">
|
||||
<ng-container *ngIf="channelProviderId as provider; else noProvider">
|
||||
<ng-container *ngIf="channelExternalId() as externalId; else noLink">
|
||||
<a
|
||||
class="flex flex-1 items-center gap-2"
|
||||
[routerLink]="['/channel', provider, externalId]"
|
||||
>
|
||||
<div class="flex h-8 w-8 shrink-0 items-center justify-center overflow-hidden rounded-full border border-zinc-700 bg-zinc-800">
|
||||
<img
|
||||
*ngIf="channelAvatarUrl(); else linkedInitials"
|
||||
[src]="channelAvatarUrl()!"
|
||||
[alt]="channelDisplayName(video.channelMeta ?? null, video.channelName)"
|
||||
class="h-full w-full object-cover"
|
||||
loading="lazy"
|
||||
/>
|
||||
<ng-template #linkedInitials>
|
||||
<span class="text-sm font-semibold text-zinc-200">
|
||||
{{ channelInitial(video.channelMeta ?? null, video.channelName) }}
|
||||
</span>
|
||||
</ng-template>
|
||||
</div>
|
||||
<span class="font-medium text-zinc-200 hover:text-red-300">
|
||||
{{ channelDisplayName(video.channelMeta ?? null, video.channelName) }}
|
||||
</span>
|
||||
</a>
|
||||
<ng-container *ngIf="video.channelMeta as channel">
|
||||
<app-subscribe-button
|
||||
class="shrink-0"
|
||||
[provider]="provider"
|
||||
[externalId]="externalId"
|
||||
[channelMeta]="channel"
|
||||
[optimistic]="false"
|
||||
></app-subscribe-button>
|
||||
</ng-container>
|
||||
</ng-container>
|
||||
<ng-template #noLink>
|
||||
<div class="flex flex-1 items-center gap-2">
|
||||
<div class="flex h-8 w-8 shrink-0 items-center justify-center overflow-hidden rounded-full border border-zinc-700 bg-zinc-800">
|
||||
<img
|
||||
*ngIf="channelAvatarUrl(); else noLinkInitials"
|
||||
[src]="channelAvatarUrl()!"
|
||||
[alt]="channelDisplayName(video.channelMeta ?? null, video.channelName)"
|
||||
class="h-full w-full object-cover"
|
||||
loading="lazy"
|
||||
/>
|
||||
<ng-template #noLinkInitials>
|
||||
<span class="text-sm font-semibold text-zinc-200">
|
||||
{{ channelInitial(video.channelMeta ?? null, video.channelName) }}
|
||||
</span>
|
||||
</ng-template>
|
||||
</div>
|
||||
<span class="font-medium text-zinc-200">
|
||||
{{ channelDisplayName(video.channelMeta ?? null, video.channelName) }}
|
||||
</span>
|
||||
</div>
|
||||
</ng-template>
|
||||
</ng-container>
|
||||
<ng-template #noProvider>
|
||||
<div class="flex flex-1 items-center gap-2">
|
||||
<div class="flex h-8 w-8 shrink-0 items-center justify-center overflow-hidden rounded-full border border-zinc-700 bg-zinc-800">
|
||||
<span class="text-sm font-semibold text-zinc-200">
|
||||
{{ channelInitial(null, video.channelName) }}
|
||||
</span>
|
||||
</div>
|
||||
<span *ngIf="video.channelName" class="font-medium text-zinc-200">
|
||||
{{ video.channelName }}
|
||||
</span>
|
||||
</div>
|
||||
</ng-template>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
@@ -4,13 +4,15 @@ import { CommonModule } from '@angular/common';
|
||||
import { RouterLink } from '@angular/router';
|
||||
import { DurationPipe } from '../../pipes/duration.pipe';
|
||||
import { ProviderBadgeComponent } from '../provider-badge/provider-badge.component';
|
||||
import { SubscribeButtonComponent } from '../subscribe-button/subscribe-button.component';
|
||||
import type { ChannelMeta, ProviderId } from '../../models/channel.model';
|
||||
|
||||
@Component({
|
||||
selector: 'app-video-card',
|
||||
templateUrl: './video-card.component.html',
|
||||
styleUrls: ['./video-card.component.scss'],
|
||||
standalone: true,
|
||||
imports: [CommonModule, RouterLink, DurationPipe, ProviderBadgeComponent],
|
||||
imports: [CommonModule, RouterLink, DurationPipe, ProviderBadgeComponent, SubscribeButtonComponent],
|
||||
})
|
||||
export class VideoCardComponent {
|
||||
@Input() video!: VideoItem;
|
||||
@@ -21,4 +23,37 @@ export class VideoCardComponent {
|
||||
if (v.provider === 'twitch' && v.channel) qp.channel = v.channel;
|
||||
return qp;
|
||||
}
|
||||
|
||||
get channelProviderId(): ProviderId | null {
|
||||
const map: Record<VideoItem['provider'], ProviderId> = {
|
||||
youtube: 'yt',
|
||||
dailymotion: 'dm',
|
||||
twitch: 'tw',
|
||||
peertube: 'pt',
|
||||
odysee: 'od',
|
||||
rumble: 'ru',
|
||||
};
|
||||
return map[this.video.provider] || null;
|
||||
}
|
||||
|
||||
channelExternalId(): string | null {
|
||||
return this.video.channelMeta?.externalId ?? this.video.channelExternalId ?? null;
|
||||
}
|
||||
|
||||
channelAvatarUrl(): string | null {
|
||||
return this.video.channelMeta?.avatarUrl ?? this.video.channelAvatarUrl ?? null;
|
||||
}
|
||||
|
||||
channelDisplayName(channel?: ChannelMeta | null, fallback?: string | null): string {
|
||||
const preferred = (channel?.title || channel?.handle || fallback || '').toString().trim();
|
||||
if (preferred.length > 0) {
|
||||
return preferred;
|
||||
}
|
||||
return (channel?.externalId || '').trim();
|
||||
}
|
||||
|
||||
channelInitial(channel?: ChannelMeta | null, fallback?: string | null): string {
|
||||
const name = this.channelDisplayName(channel, fallback);
|
||||
return name ? name.charAt(0).toUpperCase() : 'C';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
import type { ChannelMeta, ProviderId } from './channel.model';
|
||||
import type { VideoItem } from './video-item.model';
|
||||
|
||||
export type ChannelTabId = 'videos' | 'shorts' | 'playlists' | 'live' | 'about';
|
||||
export type ChannelSort = 'recent' | 'popular';
|
||||
export type ChannelContentType = 'videos' | 'shorts' | 'playlists' | 'live';
|
||||
|
||||
export interface ChannelCapabilities {
|
||||
videos: boolean;
|
||||
shorts: boolean;
|
||||
playlists: boolean;
|
||||
live: boolean;
|
||||
about: boolean;
|
||||
searchInChannel: boolean;
|
||||
}
|
||||
|
||||
export interface ChannelDetail extends ChannelMeta {
|
||||
bannerUrl?: string | null;
|
||||
description?: string | null;
|
||||
videoCount?: number | null;
|
||||
viewCount?: number | null;
|
||||
createdAt?: string | null;
|
||||
links?: { label: string; url: string }[];
|
||||
capabilities: ChannelCapabilities;
|
||||
}
|
||||
|
||||
export interface ChannelContentPage<T> {
|
||||
items: T[];
|
||||
nextPage: number | null;
|
||||
total?: number | null;
|
||||
}
|
||||
|
||||
export interface ChannelPlaylist {
|
||||
id: string;
|
||||
provider: ProviderId;
|
||||
title: string;
|
||||
thumbnailUrl?: string | null;
|
||||
videoCount?: number | null;
|
||||
updatedAt?: string | null;
|
||||
}
|
||||
|
||||
export const DEFAULT_CAPABILITIES: Record<ProviderId, ChannelCapabilities> = {
|
||||
yt: { videos: true, shorts: true, playlists: true, live: true, about: true, searchInChannel: true },
|
||||
dm: { videos: true, shorts: false, playlists: true, live: false, about: true, searchInChannel: true },
|
||||
tw: { videos: true, shorts: false, playlists: false, live: true, about: true, searchInChannel: true },
|
||||
pt: { videos: true, shorts: false, playlists: true, live: false, about: true, searchInChannel: true },
|
||||
od: { videos: true, shorts: false, playlists: false, live: false, about: true, searchInChannel: true },
|
||||
ru: { videos: true, shorts: false, playlists: false, live: false, about: true, searchInChannel: true },
|
||||
};
|
||||
|
||||
export function buildChannelDetail(meta: ChannelMeta, extra?: Partial<ChannelDetail>): ChannelDetail {
|
||||
return {
|
||||
...meta,
|
||||
bannerUrl: extra?.bannerUrl ?? null,
|
||||
description: extra?.description ?? null,
|
||||
videoCount: extra?.videoCount ?? null,
|
||||
viewCount: extra?.viewCount ?? null,
|
||||
createdAt: extra?.createdAt ?? null,
|
||||
links: extra?.links ?? [],
|
||||
capabilities: extra?.capabilities ?? DEFAULT_CAPABILITIES[meta.provider],
|
||||
};
|
||||
}
|
||||
|
||||
export type { VideoItem };
|
||||
@@ -0,0 +1,14 @@
|
||||
export type ProviderId = 'yt' | 'dm' | 'tw' | 'pt' | 'od' | 'ru';
|
||||
|
||||
export interface ChannelMeta {
|
||||
id?: number;
|
||||
provider: ProviderId;
|
||||
externalId: string;
|
||||
title?: string | null;
|
||||
handle?: string | null;
|
||||
avatarUrl?: string | null;
|
||||
url?: string | null;
|
||||
subsCount?: number;
|
||||
verified?: boolean;
|
||||
lastRefreshedAt?: number | null;
|
||||
}
|
||||
@@ -1,3 +1,5 @@
|
||||
import type { ChannelMeta } from './channel.model';
|
||||
|
||||
export interface VideoItem {
|
||||
id: string; // id vidéo canonicalisé
|
||||
provider: 'youtube' | 'dailymotion' | 'twitch' | 'peertube' | 'odysee' | 'rumble';
|
||||
@@ -5,6 +7,9 @@ export interface VideoItem {
|
||||
thumbnailUrl: string;
|
||||
durationSec?: number;
|
||||
channelName?: string;
|
||||
channelExternalId?: string;
|
||||
channelAvatarUrl?: string;
|
||||
channelMeta?: ChannelMeta;
|
||||
viewCount?: number;
|
||||
publishedAt?: string; // ISO
|
||||
// Provider-specific routing hints
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
/**
|
||||
* Classification partagée Vidéo / Short / Live.
|
||||
*
|
||||
* Règles (alignées front + back) :
|
||||
* - `isShort === true` (flag natif) ⇒ Short, sauf durée connue > 75 s.
|
||||
* - Twitch `kind === 'clip'` ⇒ Short (les clips font ≤ 60 s par construction).
|
||||
* - YouTube : URL `/shorts/` ⇒ Short ; sinon durée 1..70 s ⇒ Short.
|
||||
* - Autres providers : durée 1..75 s ⇒ Short.
|
||||
* - IMPORTANT : une durée `0` / absente ne qualifie JAMAIS un Short
|
||||
* (les scrapers renvoient 0 quand la durée est inconnue).
|
||||
*/
|
||||
export type VideoKind = 'short' | 'live' | 'video';
|
||||
|
||||
export interface ClassifiableVideo {
|
||||
duration?: number | null;
|
||||
type?: string | null;
|
||||
kind?: string | null;
|
||||
url?: string | null;
|
||||
provider?: string | null;
|
||||
isShort?: boolean | null;
|
||||
isLive?: boolean | null;
|
||||
isVertical?: boolean | null;
|
||||
}
|
||||
|
||||
export const SHORT_MAX_SECONDS = 75;
|
||||
export const YOUTUBE_SHORT_MAX_SECONDS = 70;
|
||||
|
||||
export function isShortVideo(v: ClassifiableVideo | null | undefined): boolean {
|
||||
if (!v) return false;
|
||||
const duration = Number((v as { duration?: unknown }).duration ?? 0);
|
||||
const hasKnownDuration = Number.isFinite(duration) && duration > 0;
|
||||
const type = String((v as { type?: unknown }).type || '').toLowerCase();
|
||||
const kind = String((v as { kind?: unknown }).kind || '').toLowerCase();
|
||||
const url = String((v as { url?: unknown }).url || '');
|
||||
const provider = String((v as { provider?: unknown }).provider || '').toLowerCase();
|
||||
|
||||
// Clips Twitch = équivalent Shorts (durée max 60 s côté Twitch).
|
||||
if (kind === 'clip') return true;
|
||||
if (type === 'short') return true;
|
||||
|
||||
// Flag natif (Rumble router, YouTube, …) — ignoré si la durée connue le contredit.
|
||||
if ((v as { isShort?: unknown }).isShort === true) {
|
||||
if (hasKnownDuration && duration > SHORT_MAX_SECONDS) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!hasKnownDuration) return false;
|
||||
if (url.includes('/shorts/')) return true;
|
||||
if (provider === 'youtube') return duration <= YOUTUBE_SHORT_MAX_SECONDS;
|
||||
return duration <= SHORT_MAX_SECONDS;
|
||||
}
|
||||
|
||||
export function isLiveVideo(v: ClassifiableVideo | null | undefined): boolean {
|
||||
if (!v) return false;
|
||||
if ((v as { isLive?: unknown }).isLive === true) return true;
|
||||
const type = String((v as { type?: unknown }).type || '').toLowerCase();
|
||||
return type === 'live' || type === 'stream' || type === 'channel';
|
||||
}
|
||||
|
||||
export function classifyVideo(v: ClassifiableVideo | null | undefined): VideoKind {
|
||||
if (isShortVideo(v)) return 'short';
|
||||
if (isLiveVideo(v)) return 'live';
|
||||
return 'video';
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import { ChangeDetectionStrategy, Component, Input } from '@angular/core';
|
||||
import { CommonModule, DatePipe } from '@angular/common';
|
||||
import type { ChannelDetail } from '../../../app/shared/models/channel-detail.model';
|
||||
|
||||
@Component({
|
||||
selector: 'app-channel-about',
|
||||
standalone: true,
|
||||
imports: [CommonModule, DatePipe],
|
||||
template: `
|
||||
<div *ngIf="channel" class="grid gap-4 rounded-xl border border-zinc-800 bg-zinc-900/50 p-5 text-sm text-zinc-300 lg:grid-cols-2">
|
||||
<div>
|
||||
<h3 class="mb-2 text-base font-semibold text-white">Description</h3>
|
||||
<p class="whitespace-pre-line">{{ channel.description || 'Aucune description disponible.' }}</p>
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<h3 class="text-base font-semibold text-white">Informations</h3>
|
||||
<p *ngIf="channel.handle"><span class="text-zinc-500">Handle : </span>{{ channel.handle }}</p>
|
||||
<p><span class="text-zinc-500">ID externe : </span><span class="font-mono text-xs">{{ channel.externalId }}</span></p>
|
||||
<p *ngIf="channel.viewCount != null"><span class="text-zinc-500">Vues totales : </span>{{ channel.viewCount | number }}</p>
|
||||
<p *ngIf="channel.createdAt"><span class="text-zinc-500">Créée le : </span>{{ channel.createdAt | date:'longDate' }}</p>
|
||||
<p *ngIf="channel.lastRefreshedAt"><span class="text-zinc-500">Dernière synchro : </span>{{ channel.lastRefreshedAt | date:'medium' }}</p>
|
||||
<div *ngIf="channel.links?.length">
|
||||
<p class="text-zinc-500">Liens :</p>
|
||||
<ul class="mt-1 space-y-1">
|
||||
<li *ngFor="let link of channel.links">
|
||||
<a [href]="link.url" target="_blank" rel="noopener" class="text-red-300 hover:text-red-200 hover:underline">{{ link.label }}</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<a *ngIf="channel.url" [href]="channel.url" target="_blank" rel="noopener" class="inline-block rounded-md border border-zinc-700 px-3 py-1.5 text-zinc-200 hover:border-zinc-500 hover:text-white">
|
||||
Ouvrir la chaîne officielle
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
`,
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class ChannelAboutComponent {
|
||||
@Input() channel: ChannelDetail | null = null;
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
<div *ngIf="loading" class="animate-pulse">
|
||||
<div class="h-36 w-full rounded-xl bg-zinc-800 sm:h-48"></div>
|
||||
<div class="mt-4 flex items-center gap-4 px-1">
|
||||
<div class="h-24 w-24 rounded-full bg-zinc-800"></div>
|
||||
<div class="flex-1 space-y-2">
|
||||
<div class="h-6 w-1/3 rounded bg-zinc-800"></div>
|
||||
<div class="h-4 w-1/4 rounded bg-zinc-800"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div *ngIf="!loading && channel" class="overflow-hidden rounded-xl border border-zinc-800 bg-zinc-950">
|
||||
<!-- Bannière (masquée proprement si absente) -->
|
||||
<div *ngIf="channel.bannerUrl" class="h-36 w-full overflow-hidden bg-zinc-900 sm:h-48">
|
||||
<img [src]="channel.bannerUrl" [alt]="" class="h-full w-full object-cover" loading="lazy" />
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-4 p-4 sm:p-6">
|
||||
<div class="flex flex-col gap-4 sm:flex-row sm:items-start">
|
||||
<div class="relative -mt-2 shrink-0 sm:-mt-10">
|
||||
<img
|
||||
[src]="channel.avatarUrl || 'https://placehold.co/160x160/png'"
|
||||
[alt]="channel.title || channel.handle || 'Avatar'"
|
||||
class="h-24 w-24 rounded-full border-4 border-zinc-950 bg-zinc-800 object-cover sm:h-28 sm:w-28"
|
||||
/>
|
||||
</div>
|
||||
<div class="min-w-0 flex-1">
|
||||
<h1 class="truncate text-2xl font-bold text-white sm:text-3xl">
|
||||
{{ channel.title || channel.handle || 'Chaîne inconnue' }}
|
||||
</h1>
|
||||
<div class="mt-1 flex flex-wrap items-center gap-x-3 gap-y-1 text-sm text-zinc-400">
|
||||
<app-provider-badge [provider]="longProvider"></app-provider-badge>
|
||||
<span *ngIf="channel.handle" class="truncate">{{ channel.handle }}</span>
|
||||
<span *ngIf="channel.subsCount != null">
|
||||
<app-compact-number [value]="channel.subsCount"></app-compact-number> abonnés
|
||||
</span>
|
||||
<span *ngIf="channel.videoCount != null">
|
||||
<app-compact-number [value]="channel.videoCount"></app-compact-number> vidéos
|
||||
</span>
|
||||
<span *ngIf="channel.verified" class="font-medium text-emerald-300">✓ Vérifiée</span>
|
||||
</div>
|
||||
<!-- Description tronquée + ...plus -->
|
||||
<div *ngIf="channel.description" class="mt-2 max-w-3xl text-sm text-zinc-300">
|
||||
<p [class.line-clamp-2]="!expanded()">{{ channel.description }}</p>
|
||||
<button type="button" (click)="toggleExpand()" class="mt-1 font-medium text-zinc-100 hover:text-red-300">
|
||||
{{ expanded() ? '...moins' : '...plus' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<app-subscribe-button
|
||||
[provider]="channel.provider"
|
||||
[externalId]="channel.externalId"
|
||||
[channelMeta]="channel"
|
||||
></app-subscribe-button>
|
||||
<button
|
||||
type="button"
|
||||
(click)="share()"
|
||||
class="rounded-md border border-zinc-700 px-3 py-1.5 text-sm font-medium text-zinc-200 transition hover:border-zinc-500 hover:text-white"
|
||||
>
|
||||
Partager
|
||||
</button>
|
||||
<a
|
||||
*ngIf="channel.url"
|
||||
[href]="channel.url"
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
class="rounded-md border border-zinc-700 px-3 py-1.5 text-sm font-medium text-zinc-200 transition hover:border-zinc-500 hover:text-white"
|
||||
>
|
||||
Ouvrir la chaîne officielle
|
||||
</a>
|
||||
<button
|
||||
type="button"
|
||||
(click)="refresh.emit()"
|
||||
class="rounded-md border border-zinc-700 px-3 py-1.5 text-sm font-medium text-zinc-200 transition hover:border-zinc-500 hover:text-white"
|
||||
>
|
||||
Rafraîchir
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,42 @@
|
||||
import { ChangeDetectionStrategy, Component, EventEmitter, Input, Output, signal } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import type { ChannelDetail } from '../../../app/shared/models/channel-detail.model';
|
||||
import { ProviderBadgeComponent } from '../../../app/shared/components/provider-badge/provider-badge.component';
|
||||
import { SubscribeButtonComponent } from '../../../app/shared/components/subscribe-button/subscribe-button.component';
|
||||
import { CompactNumberComponent } from '../../../app/shared/components/compact-number/compact-number.component';
|
||||
|
||||
@Component({
|
||||
selector: 'app-channel-header',
|
||||
standalone: true,
|
||||
imports: [CommonModule, ProviderBadgeComponent, SubscribeButtonComponent, CompactNumberComponent],
|
||||
templateUrl: './channel-header.component.html',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class ChannelHeaderComponent {
|
||||
@Input() channel: ChannelDetail | null = null;
|
||||
@Input() loading = false;
|
||||
@Output() refresh = new EventEmitter<void>();
|
||||
|
||||
readonly expanded = signal(false);
|
||||
|
||||
toggleExpand(): void {
|
||||
this.expanded.update((v) => !v);
|
||||
}
|
||||
|
||||
share(): void {
|
||||
const url = window.location.href;
|
||||
const title = this.channel?.title || this.channel?.handle || 'Chaîne';
|
||||
if (navigator.share) {
|
||||
navigator.share({ title, url }).catch(() => {});
|
||||
} else {
|
||||
navigator.clipboard?.writeText(url).catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
get longProvider(): string | null {
|
||||
const map: Record<string, string> = {
|
||||
yt: 'youtube', dm: 'dailymotion', tw: 'twitch', pt: 'peertube', od: 'odysee', ru: 'rumble',
|
||||
};
|
||||
return this.channel ? (map[this.channel.provider] ?? null) : null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
<div class="container mx-auto max-w-7xl px-4 py-6">
|
||||
<!-- Erreur header (params invalides / chaîne introuvable) -->
|
||||
<div *ngIf="headerError() && !headerLoading()" class="rounded-xl border border-red-500/40 bg-red-500/10 p-8 text-center">
|
||||
<h2 class="mb-2 text-xl font-semibold text-red-100">Impossible de charger la chaîne</h2>
|
||||
<p class="text-sm text-red-200/80">{{ headerError() }}</p>
|
||||
<button type="button" (click)="refreshAll(true)" class="mt-4 rounded-md border border-red-500/60 px-4 py-1.5 text-sm text-red-100 hover:bg-red-500/20">
|
||||
Réessayer
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div *ngIf="!headerError() || headerLoading()">
|
||||
<app-channel-header [channel]="detail()" [loading]="headerLoading()" (refresh)="refreshAll(true)"></app-channel-header>
|
||||
|
||||
<div *ngIf="!headerLoading() && detail()" class="mt-4">
|
||||
<app-channel-tabs [capabilities]="capabilities()" [active]="activeTab()" (select)="onTabSelect($event)"></app-channel-tabs>
|
||||
|
||||
<!-- Barre d'outils : tri + recherche locale (masquée sur À propos) -->
|
||||
<div *ngIf="activeTab() !== 'about'" class="flex flex-col gap-2 py-4 sm:flex-row sm:items-center">
|
||||
<div *ngIf="activeTab() === 'videos' || activeTab() === 'shorts'" class="flex items-center gap-2 text-sm">
|
||||
<label for="channel-sort" class="text-zinc-400">Trier :</label>
|
||||
<select
|
||||
id="channel-sort"
|
||||
[value]="sort()"
|
||||
(change)="onSortChange($any($event.target).value)"
|
||||
class="rounded-md border border-zinc-700 bg-zinc-900 px-2 py-1.5 text-sm text-zinc-100"
|
||||
>
|
||||
<option value="recent">Plus récentes</option>
|
||||
<option value="popular">Plus populaires</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="relative flex-1 sm:max-w-md sm:ml-auto">
|
||||
<input
|
||||
type="search"
|
||||
placeholder="Rechercher dans cette chaîne…"
|
||||
[value]="searchQuery()"
|
||||
(input)="onSearchInput($any($event.target).value)"
|
||||
class="w-full rounded-lg border border-zinc-700 bg-zinc-900 px-3 py-2 pr-9 text-sm text-zinc-100 placeholder:text-zinc-500 focus:border-red-500 focus:outline-none"
|
||||
/>
|
||||
<button *ngIf="searchQuery()" type="button" (click)="clearSearch()" aria-label="Effacer"
|
||||
class="absolute right-2 top-1/2 -translate-y-1/2 rounded px-1 text-zinc-400 hover:text-white">✕</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Onglets contenu -->
|
||||
<div class="pb-10">
|
||||
<app-channel-video-grid
|
||||
*ngIf="activeTab() === 'videos'"
|
||||
[videos]="videos()"
|
||||
[loading]="listLoading()"
|
||||
[loadingMore]="listLoadingMore()"
|
||||
[hasMore]="hasMore()"
|
||||
[error]="listError()"
|
||||
layout="video"
|
||||
emptyMessage="Aucune vidéo trouvée pour cette chaîne."
|
||||
(loadMore)="onLoadMore()"
|
||||
(retry)="onRetryTab()"
|
||||
></app-channel-video-grid>
|
||||
|
||||
<app-channel-video-grid
|
||||
*ngIf="activeTab() === 'shorts'"
|
||||
[videos]="shorts()"
|
||||
[loading]="listLoading()"
|
||||
[loadingMore]="listLoadingMore()"
|
||||
[hasMore]="hasMore()"
|
||||
[error]="listError()"
|
||||
layout="short"
|
||||
emptyMessage="Aucun short trouvé pour cette chaîne."
|
||||
(loadMore)="onLoadMore()"
|
||||
(retry)="onRetryTab()"
|
||||
></app-channel-video-grid>
|
||||
|
||||
<app-channel-playlists
|
||||
*ngIf="activeTab() === 'playlists'"
|
||||
[playlists]="playlists()"
|
||||
[loading]="listLoading()"
|
||||
[error]="listError()"
|
||||
(retry)="onRetryTab()"
|
||||
></app-channel-playlists>
|
||||
|
||||
<app-channel-video-grid
|
||||
*ngIf="activeTab() === 'live'"
|
||||
[videos]="streams()"
|
||||
[loading]="listLoading()"
|
||||
[loadingMore]="listLoadingMore()"
|
||||
[hasMore]="hasMore()"
|
||||
[error]="listError()"
|
||||
layout="video"
|
||||
emptyMessage="Aucun stream en direct ou replay pour le moment."
|
||||
(loadMore)="onLoadMore()"
|
||||
(retry)="onRetryTab()"
|
||||
></app-channel-video-grid>
|
||||
|
||||
<app-channel-about *ngIf="activeTab() === 'about'" [channel]="detail()"></app-channel-about>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,296 @@
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { ChangeDetectionStrategy, Component, computed, inject, signal } from '@angular/core';
|
||||
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
|
||||
import { ActivatedRoute, Router } from '@angular/router';
|
||||
import { Subject, debounceTime, distinctUntilChanged, map, of, switchMap, take } from 'rxjs';
|
||||
import type { ProviderId } from '../../app/shared/models/channel.model';
|
||||
import type {
|
||||
ChannelDetail,
|
||||
ChannelPlaylist,
|
||||
ChannelSort,
|
||||
ChannelTabId,
|
||||
} from '../../app/shared/models/channel-detail.model';
|
||||
import { buildChannelDetail } from '../../app/shared/models/channel-detail.model';
|
||||
import type { VideoItem } from '../../app/shared/models/video-item.model';
|
||||
import { normalizeChannelProvider, ChannelProviderFactory } from '../../app/core/providers/channel/channel-provider.factory';
|
||||
import { ChannelsService } from '../../services/channels.service';
|
||||
import { ChannelContentService } from '../../services/channel-content.service';
|
||||
import { SubscriptionsService } from '../../services/subscriptions.service';
|
||||
import { ChannelHeaderComponent } from './channel-header/channel-header.component';
|
||||
import { ChannelTabsComponent } from './channel-tabs/channel-tabs.component';
|
||||
import { ChannelVideoGridComponent } from './channel-video-grid/channel-video-grid.component';
|
||||
import { ChannelPlaylistsComponent } from './channel-playlists/channel-playlists.component';
|
||||
import { ChannelAboutComponent } from './channel-about/channel-about.component';
|
||||
|
||||
@Component({
|
||||
selector: 'app-channel-page',
|
||||
standalone: true,
|
||||
imports: [
|
||||
CommonModule,
|
||||
ChannelHeaderComponent,
|
||||
ChannelTabsComponent,
|
||||
ChannelVideoGridComponent,
|
||||
ChannelPlaylistsComponent,
|
||||
ChannelAboutComponent,
|
||||
],
|
||||
templateUrl: './channel-page.component.html',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class ChannelPageComponent {
|
||||
private readonly route = inject(ActivatedRoute);
|
||||
private readonly router = inject(Router);
|
||||
private readonly channels = inject(ChannelsService);
|
||||
private readonly content = inject(ChannelContentService);
|
||||
private readonly providers = inject(ChannelProviderFactory);
|
||||
private readonly subs = inject(SubscriptionsService);
|
||||
private readonly searchInput$ = new Subject<string>();
|
||||
|
||||
readonly provider = signal<ProviderId | null>(null);
|
||||
readonly externalId = signal<string | null>(null);
|
||||
readonly detail = signal<ChannelDetail | null>(null);
|
||||
readonly headerLoading = signal(true);
|
||||
readonly headerError = signal<string | null>(null);
|
||||
|
||||
readonly activeTab = signal<ChannelTabId>('videos');
|
||||
readonly sort = signal<ChannelSort>('recent');
|
||||
readonly searchQuery = signal('');
|
||||
|
||||
readonly videos = signal<VideoItem[]>([]);
|
||||
readonly shorts = signal<VideoItem[]>([]);
|
||||
readonly streams = signal<VideoItem[]>([]);
|
||||
readonly playlists = signal<ChannelPlaylist[]>([]);
|
||||
|
||||
readonly listLoading = signal(false);
|
||||
readonly listLoadingMore = signal(false);
|
||||
readonly listError = signal<string | null>(null);
|
||||
readonly hasMore = signal(false);
|
||||
|
||||
readonly capabilities = computed(() => this.detail()?.capabilities ?? null);
|
||||
|
||||
constructor() {
|
||||
if (this.subs.items().length === 0) {
|
||||
this.subs.list().pipe(takeUntilDestroyed()).subscribe();
|
||||
}
|
||||
|
||||
// Recherche locale débouncée -> refetch via searchInChannel (ou filtre)
|
||||
this.searchInput$
|
||||
.pipe(debounceTime(400), distinctUntilChanged(), takeUntilDestroyed())
|
||||
.subscribe((q) => {
|
||||
this.searchQuery.set(q);
|
||||
this.syncQueryParams();
|
||||
this.reloadTab(true);
|
||||
});
|
||||
|
||||
this.route.paramMap
|
||||
.pipe(
|
||||
map((params) => ({
|
||||
provider: normalizeChannelProvider(params.get('provider')),
|
||||
externalId: params.get('externalId')?.trim() || null,
|
||||
})),
|
||||
switchMap(({ provider, externalId }) => {
|
||||
this.provider.set(provider);
|
||||
this.externalId.set(externalId);
|
||||
// Onglet / tri / recherche depuis l'URL (?tab=&sort=&q=)
|
||||
const qp = this.route.snapshot.queryParamMap;
|
||||
const tab = qp.get('tab') as ChannelTabId | null;
|
||||
if (tab && ['videos', 'shorts', 'playlists', 'live', 'about'].includes(tab)) this.activeTab.set(tab);
|
||||
const sort = qp.get('sort') as ChannelSort | null;
|
||||
if (sort === 'popular' || sort === 'recent') this.sort.set(sort);
|
||||
this.searchQuery.set(qp.get('q') ?? '');
|
||||
|
||||
if (!provider || !externalId) {
|
||||
this.headerLoading.set(false);
|
||||
this.headerError.set('channel_invalid_params');
|
||||
this.detail.set(null);
|
||||
return of(null);
|
||||
}
|
||||
this.headerLoading.set(true);
|
||||
this.headerError.set(null);
|
||||
const refresh = qp.get('refresh') === '1' || qp.get('refresh') === 'true';
|
||||
// Si ?refresh=1, vide aussi le cache des listes
|
||||
if (refresh) this.content.clear(provider, externalId);
|
||||
return this.channels.resolve(provider, externalId, { refresh });
|
||||
}),
|
||||
takeUntilDestroyed(),
|
||||
)
|
||||
.subscribe((meta) => {
|
||||
this.headerLoading.set(false);
|
||||
if (!meta) {
|
||||
this.headerError.set('channel_unavailable');
|
||||
this.detail.set(null);
|
||||
return;
|
||||
}
|
||||
// Enrichit la meta (cache 6h) en ChannelDetail avec capabilities du provider
|
||||
const providerApi = this.providers.get(meta.provider);
|
||||
this.detail.set(
|
||||
buildChannelDetail(meta, {
|
||||
capabilities: {
|
||||
videos: true,
|
||||
shorts: providerApi.supports('shorts'),
|
||||
playlists: providerApi.supports('playlists'),
|
||||
live: providerApi.supports('live'),
|
||||
about: true,
|
||||
searchInChannel: providerApi.supports('searchInChannel'),
|
||||
},
|
||||
}),
|
||||
);
|
||||
this.headerError.set(null);
|
||||
// Si l'onglet actif n'est pas supporté, retomber sur videos
|
||||
const capsNow = this.detail()?.capabilities;
|
||||
if (capsNow && !(capsNow as unknown as Record<string, boolean>)[this.activeTab()]) {
|
||||
this.activeTab.set('videos');
|
||||
}
|
||||
this.reloadTab();
|
||||
});
|
||||
}
|
||||
|
||||
onTabSelect(tab: ChannelTabId): void {
|
||||
if (tab === this.activeTab()) return;
|
||||
this.activeTab.set(tab);
|
||||
this.syncQueryParams();
|
||||
this.reloadTab();
|
||||
}
|
||||
|
||||
onSortChange(value: string): void {
|
||||
this.sort.set(value === 'popular' ? 'popular' : 'recent');
|
||||
this.syncQueryParams();
|
||||
this.reloadTab(true);
|
||||
}
|
||||
|
||||
onSearchInput(value: string): void {
|
||||
this.searchInput$.next(value);
|
||||
}
|
||||
|
||||
clearSearch(): void {
|
||||
this.searchQuery.set('');
|
||||
this.syncQueryParams();
|
||||
this.reloadTab(true);
|
||||
}
|
||||
|
||||
onLoadMore(): void {
|
||||
const p = this.provider();
|
||||
const id = this.externalId();
|
||||
if (!p || !id || this.listLoadingMore() || this.listLoading() || !this.hasMore()) return;
|
||||
this.listLoadingMore.set(true);
|
||||
const type = this.tabToContentType();
|
||||
if (!type) return;
|
||||
this.content
|
||||
.loadMore(p, id, type, { sort: this.sort(), q: this.searchQuery() })
|
||||
.pipe(take(1))
|
||||
.subscribe((ok) => {
|
||||
this.listLoadingMore.set(false);
|
||||
if (ok) this.pullState();
|
||||
});
|
||||
}
|
||||
|
||||
onRetryTab(): void {
|
||||
this.reloadTab(true);
|
||||
}
|
||||
|
||||
refreshAll(force = true): void {
|
||||
const p = this.provider();
|
||||
const id = this.externalId();
|
||||
if (!p || !id) return;
|
||||
this.headerLoading.set(true);
|
||||
if (force) this.content.clear(p, id);
|
||||
this.channels
|
||||
.resolve(p, id, { refresh: force })
|
||||
.pipe(take(1))
|
||||
.subscribe({
|
||||
next: (meta) => {
|
||||
if (meta) {
|
||||
const api = this.providers.get(meta.provider);
|
||||
this.detail.set(
|
||||
buildChannelDetail(meta, {
|
||||
description: this.detail()?.description ?? null,
|
||||
capabilities: {
|
||||
videos: true,
|
||||
shorts: api.supports('shorts'),
|
||||
playlists: api.supports('playlists'),
|
||||
live: api.supports('live'),
|
||||
about: true,
|
||||
searchInChannel: api.supports('searchInChannel'),
|
||||
},
|
||||
}),
|
||||
);
|
||||
this.headerError.set(null);
|
||||
} else {
|
||||
this.headerError.set('channel_unavailable');
|
||||
}
|
||||
},
|
||||
error: () => this.headerError.set('channel_unavailable'),
|
||||
complete: () => {
|
||||
this.headerLoading.set(false);
|
||||
this.reloadTab(true);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
private tabToContentType(): 'videos' | 'shorts' | 'playlists' | 'live' | null {
|
||||
switch (this.activeTab()) {
|
||||
case 'videos': return 'videos';
|
||||
case 'shorts': return 'shorts';
|
||||
case 'playlists': return 'playlists';
|
||||
case 'live': return 'live';
|
||||
default: return null;
|
||||
}
|
||||
}
|
||||
|
||||
private reloadTab(force = false): void {
|
||||
const p = this.provider();
|
||||
const id = this.externalId();
|
||||
const tab = this.activeTab();
|
||||
if (!p || !id || tab === 'about') return;
|
||||
this.listLoading.set(true);
|
||||
this.listError.set(null);
|
||||
const sort = this.sort();
|
||||
const q = this.searchQuery();
|
||||
const done = (ok: boolean) => {
|
||||
this.listLoading.set(false);
|
||||
if (!ok) this.listError.set('channel_content_failed');
|
||||
this.pullState();
|
||||
};
|
||||
if (tab === 'videos') {
|
||||
this.content.loadVideos(p, id, { sort, q, refresh: force }).pipe(take(1)).subscribe({ next: () => done(true), error: () => done(false) });
|
||||
} else if (tab === 'shorts') {
|
||||
this.content.loadShorts(p, id, { sort, q, refresh: force }).pipe(take(1)).subscribe({ next: () => done(true), error: () => done(false) });
|
||||
} else if (tab === 'live') {
|
||||
this.content.loadStreams(p, id, { q, refresh: force }).pipe(take(1)).subscribe({ next: () => done(true), error: () => done(false) });
|
||||
} else if (tab === 'playlists') {
|
||||
this.content.loadPlaylists(p, id, { q, refresh: force }).pipe(take(1)).subscribe({ next: () => done(true), error: () => done(false) });
|
||||
}
|
||||
}
|
||||
|
||||
private pullState(): void {
|
||||
const p = this.provider();
|
||||
const id = this.externalId();
|
||||
if (!p || !id) return;
|
||||
const sort = this.sort();
|
||||
const q = this.searchQuery();
|
||||
const tab = this.activeTab();
|
||||
if (tab === 'videos') {
|
||||
const s = this.content.snapshot<VideoItem>(p, id, 'videos', sort, q);
|
||||
this.videos.set(s.items); this.listError.set(s.error); this.hasMore.set(s.nextPage != null);
|
||||
} else if (tab === 'shorts') {
|
||||
const s = this.content.snapshot<VideoItem>(p, id, 'shorts', sort, q);
|
||||
this.shorts.set(s.items); this.listError.set(s.error); this.hasMore.set(s.nextPage != null);
|
||||
} else if (tab === 'live') {
|
||||
const s = this.content.snapshot<VideoItem>(p, id, 'live', 'recent', q);
|
||||
this.streams.set(s.items); this.listError.set(s.error); this.hasMore.set(s.nextPage != null);
|
||||
} else if (tab === 'playlists') {
|
||||
const s = this.content.snapshot<ChannelPlaylist>(p, id, 'playlists', 'recent', q);
|
||||
this.playlists.set(s.items); this.listError.set(s.error); this.hasMore.set(s.nextPage != null);
|
||||
}
|
||||
}
|
||||
|
||||
private syncQueryParams(): void {
|
||||
const p = this.provider();
|
||||
const id = this.externalId();
|
||||
if (!p || !id) return;
|
||||
this.router.navigate(['/channel', p, id], {
|
||||
queryParams: { tab: this.activeTab(), sort: this.sort(), q: this.searchQuery() || null },
|
||||
queryParamsHandling: 'merge',
|
||||
replaceUrl: true,
|
||||
}).catch(() => {});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import { ChangeDetectionStrategy, Component, EventEmitter, Input, Output } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import type { ChannelPlaylist } from '../../../app/shared/models/channel-detail.model';
|
||||
import { VideoCardSkeletonComponent } from '../../../app/shared/components/video-card-skeleton/video-card-skeleton.component';
|
||||
|
||||
@Component({
|
||||
selector: 'app-channel-playlists',
|
||||
standalone: true,
|
||||
imports: [CommonModule, VideoCardSkeletonComponent],
|
||||
template: `
|
||||
<div *ngIf="loading" class="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
<app-video-card-skeleton *ngFor="let _ of [0,1,2,3,4,5]"></app-video-card-skeleton>
|
||||
</div>
|
||||
<div *ngIf="!loading && error" class="flex flex-col items-center gap-3 rounded-xl border border-red-500/40 bg-red-500/10 p-8 text-center">
|
||||
<h3 class="text-lg font-semibold text-red-100">Impossible de charger les playlists</h3>
|
||||
<button type="button" (click)="retry.emit()" class="rounded-md border border-red-500/60 px-4 py-1.5 text-sm text-red-100 hover:bg-red-500/20">Réessayer</button>
|
||||
</div>
|
||||
<div *ngIf="!loading && !error && playlists.length === 0" class="flex flex-col items-center gap-2 rounded-xl border border-zinc-800 bg-zinc-900/50 p-10 text-center">
|
||||
<h3 class="text-lg font-semibold text-white">Aucune playlist trouvée</h3>
|
||||
<p class="text-sm text-zinc-400">Cette chaîne ne propose pas encore de playlists.</p>
|
||||
</div>
|
||||
<div *ngIf="!loading && !error && playlists.length > 0" class="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
<div *ngFor="let pl of playlists" class="group overflow-hidden rounded-xl border border-zinc-800 bg-zinc-900 transition hover:border-zinc-600">
|
||||
<div class="relative aspect-video w-full overflow-hidden bg-zinc-800">
|
||||
<img *ngIf="pl.thumbnailUrl; else noThumb" [src]="pl.thumbnailUrl!" [alt]="pl.title" loading="lazy" class="h-full w-full object-cover" />
|
||||
<ng-template #noThumb><div class="flex h-full w-full items-center justify-center text-4xl text-zinc-600">▤</div></ng-template>
|
||||
<div class="absolute inset-y-0 right-0 flex w-24 flex-col items-center justify-center gap-1 bg-black/70 text-white">
|
||||
<span class="text-xl font-bold">{{ pl.videoCount ?? '–' }}</span>
|
||||
<span class="text-xs">vidéos</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="p-3">
|
||||
<h3 class="line-clamp-2 text-sm font-semibold text-zinc-100">{{ pl.title }}</h3>
|
||||
<p *ngIf="pl.updatedAt" class="mt-1 text-xs text-zinc-400">Mis à jour le {{ pl.updatedAt | date:'shortDate' }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`,
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class ChannelPlaylistsComponent {
|
||||
@Input() playlists: ChannelPlaylist[] = [];
|
||||
@Input() loading = false;
|
||||
@Input() error: string | null = null;
|
||||
@Output() retry = new EventEmitter<void>();
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import { ChangeDetectionStrategy, Component, EventEmitter, Input, Output } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import type { ChannelCapabilities, ChannelTabId } from '../../../app/shared/models/channel-detail.model';
|
||||
|
||||
export interface ChannelTabDef {
|
||||
id: ChannelTabId;
|
||||
label: string;
|
||||
}
|
||||
|
||||
const ALL_TABS: ChannelTabDef[] = [
|
||||
{ id: 'videos', label: 'Vidéos' },
|
||||
{ id: 'shorts', label: 'Shorts' },
|
||||
{ id: 'playlists', label: 'Playlists' },
|
||||
{ id: 'live', label: 'En direct' },
|
||||
{ id: 'about', label: 'À propos' },
|
||||
];
|
||||
|
||||
@Component({
|
||||
selector: 'app-channel-tabs',
|
||||
standalone: true,
|
||||
imports: [CommonModule],
|
||||
template: `
|
||||
<nav class="sticky top-0 z-10 -mx-4 border-b border-zinc-800 bg-zinc-950/95 px-4 backdrop-blur" aria-label="Onglets chaîne">
|
||||
<div class="flex gap-1 overflow-x-auto py-2" role="tablist">
|
||||
<button
|
||||
*ngFor="let tab of visibleTabs"
|
||||
type="button"
|
||||
role="tab"
|
||||
[attr.aria-selected]="tab.id === active"
|
||||
(click)="select.emit(tab.id)"
|
||||
class="whitespace-nowrap rounded-lg px-4 py-2 text-sm font-medium transition"
|
||||
[class.bg-red-500/20]="tab.id === active"
|
||||
[class.text-red-100]="tab.id === active"
|
||||
[class.text-zinc-400]="tab.id !== active"
|
||||
[class.hover:text-white]="tab.id !== active"
|
||||
>
|
||||
{{ tab.label }}
|
||||
</button>
|
||||
</div>
|
||||
</nav>
|
||||
`,
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class ChannelTabsComponent {
|
||||
@Input() capabilities: ChannelCapabilities | null = null;
|
||||
@Input() active: ChannelTabId = 'videos';
|
||||
@Output() select = new EventEmitter<ChannelTabId>();
|
||||
|
||||
get visibleTabs(): ChannelTabDef[] {
|
||||
if (!this.capabilities) return [{ id: 'videos', label: 'Vidéos' }];
|
||||
return ALL_TABS.filter((t) => (this.capabilities as unknown as Record<string, boolean>)[t.id]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
<!-- Loading initial : squelettes -->
|
||||
<div *ngIf="loading" class="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
|
||||
<app-video-card-skeleton *ngFor="let _ of [0,1,2,3,4,5,6,7]"></app-video-card-skeleton>
|
||||
</div>
|
||||
|
||||
<!-- Erreur -->
|
||||
<div *ngIf="!loading && error" class="flex flex-col items-center gap-3 rounded-xl border border-red-500/40 bg-red-500/10 p-8 text-center">
|
||||
<h3 class="text-lg font-semibold text-red-100">Impossible de charger le contenu</h3>
|
||||
<p class="text-sm text-red-200/80">{{ error }}</p>
|
||||
<button type="button" (click)="retry.emit()" class="rounded-md border border-red-500/60 px-4 py-1.5 text-sm font-medium text-red-100 hover:bg-red-500/20">
|
||||
Réessayer
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Vide -->
|
||||
<div *ngIf="!loading && !error && videos.length === 0" class="flex flex-col items-center gap-2 rounded-xl border border-zinc-800 bg-zinc-900/50 p-10 text-center">
|
||||
<h3 class="text-lg font-semibold text-white">{{ emptyMessage }}</h3>
|
||||
<p class="text-sm text-zinc-400">Essayez un autre onglet ou revenez plus tard.</p>
|
||||
</div>
|
||||
|
||||
<!-- Grille vidéos classiques -->
|
||||
<div *ngIf="!loading && !error && videos.length > 0 && layout === 'video'" class="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
|
||||
<app-video-card *ngFor="let video of videos" [video]="video"></app-video-card>
|
||||
</div>
|
||||
|
||||
<!-- Grille shorts verticale 9:16 -->
|
||||
<div *ngIf="!loading && !error && videos.length > 0 && layout === 'short'" class="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-6">
|
||||
<a *ngFor="let video of videos" [routerLink]="['/watch', video.id]" [queryParams]="{ p: video.provider }"
|
||||
class="group overflow-hidden rounded-xl border border-zinc-800 bg-zinc-900">
|
||||
<div class="aspect-[9/16] w-full overflow-hidden bg-zinc-800">
|
||||
<img [src]="video.thumbnailUrl" [alt]="video.title" loading="lazy" class="h-full w-full object-cover transition-transform group-hover:scale-105" />
|
||||
</div>
|
||||
<div class="p-2">
|
||||
<h3 class="line-clamp-2 text-sm font-medium text-zinc-100">{{ video.title }}</h3>
|
||||
<p *ngIf="video.viewCount" class="mt-1 text-xs text-zinc-400">{{ video.viewCount | number }} vues</p>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- Sentinelle infinite scroll -->
|
||||
<div *ngIf="hasMore && !loading && !error" #sentinel class="flex justify-center py-6" aria-hidden="true">
|
||||
<div *ngIf="loadingMore" class="flex items-center gap-2 text-sm text-zinc-400">
|
||||
<span class="h-5 w-5 animate-spin rounded-full border-2 border-zinc-700 border-t-red-500"></span>
|
||||
Chargement…
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,69 @@
|
||||
import {
|
||||
AfterViewInit,
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
ElementRef,
|
||||
EventEmitter,
|
||||
Input,
|
||||
OnChanges,
|
||||
OnDestroy,
|
||||
Output,
|
||||
SimpleChanges,
|
||||
ViewChild,
|
||||
} from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { RouterLink } from '@angular/router';
|
||||
import type { VideoItem } from '../../../app/shared/models/video-item.model';
|
||||
import { VideoCardComponent } from '../../../app/shared/components/video-card/video-card.component';
|
||||
import { VideoCardSkeletonComponent } from '../../../app/shared/components/video-card-skeleton/video-card-skeleton.component';
|
||||
|
||||
@Component({
|
||||
selector: 'app-channel-video-grid',
|
||||
standalone: true,
|
||||
imports: [CommonModule, RouterLink, VideoCardComponent, VideoCardSkeletonComponent],
|
||||
templateUrl: './channel-video-grid.component.html',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class ChannelVideoGridComponent implements AfterViewInit, OnChanges, OnDestroy {
|
||||
@Input() videos: VideoItem[] = [];
|
||||
@Input() loading = false;
|
||||
@Input() loadingMore = false;
|
||||
@Input() hasMore = false;
|
||||
@Input() error: string | null = null;
|
||||
/** 'video' (16:9) ou 'short' (9:16) */
|
||||
@Input() layout: 'video' | 'short' = 'video';
|
||||
@Input() emptyMessage = 'Aucune vidéo trouvée.';
|
||||
@Output() loadMore = new EventEmitter<void>();
|
||||
@Output() retry = new EventEmitter<void>();
|
||||
|
||||
@ViewChild('sentinel', { static: false }) sentinel?: ElementRef<HTMLDivElement>;
|
||||
private observer: IntersectionObserver | null = null;
|
||||
|
||||
ngAfterViewInit(): void {
|
||||
this.observe();
|
||||
}
|
||||
|
||||
ngOnChanges(changes: SimpleChanges): void {
|
||||
if (changes['hasMore'] || changes['videos']) {
|
||||
// Ré-observe après chaque rendu (sentinelle recréée par *ngIf)
|
||||
queueMicrotask(() => this.observe());
|
||||
}
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
this.observer?.disconnect();
|
||||
}
|
||||
|
||||
private observe(): void {
|
||||
this.observer?.disconnect();
|
||||
const el = this.sentinel?.nativeElement;
|
||||
if (!el) return;
|
||||
this.observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
if (entries.some((e) => e.isIntersecting)) this.loadMore.emit();
|
||||
},
|
||||
{ rootMargin: '600px' },
|
||||
);
|
||||
this.observer.observe(el);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
<section class="mx-auto max-w-4xl px-4 py-6">
|
||||
<a routerLink="/library/downloads"
|
||||
class="inline-flex items-center gap-2 text-sm text-slate-300 hover:text-white transition">
|
||||
<span aria-hidden="true">←</span> {{ 'downloads.back' | t }}
|
||||
</a>
|
||||
|
||||
@if (loading()) {
|
||||
<div class="mt-6 text-sm text-slate-400">{{ 'loading.more' | t }}</div>
|
||||
} @else if (error(); as errKey) {
|
||||
<div class="mt-6 rounded-lg border border-red-700/60 bg-red-900/30 px-4 py-3 text-sm text-red-200">
|
||||
{{ errKey | t }}
|
||||
</div>
|
||||
} @else if (job(); as j) {
|
||||
<div class="mt-4 flex flex-wrap items-center gap-2">
|
||||
<app-provider-badge [provider]="j.provider" />
|
||||
<h1 class="min-w-0 flex-1 truncate text-xl font-bold text-slate-100" [title]="j.title || j.videoId">
|
||||
{{ j.title || j.videoId }}
|
||||
</h1>
|
||||
</div>
|
||||
<div class="mt-1 flex flex-wrap items-center gap-2 text-xs text-slate-400">
|
||||
@if (j.fileSize) {
|
||||
<span>{{ formatBytes(j.fileSize) }}</span>
|
||||
}
|
||||
@if (j.audioOnly) {
|
||||
<span>{{ 'downloads.audioOnly' | t }}</span>
|
||||
}
|
||||
</div>
|
||||
|
||||
@if (mediaUrl(); as src) {
|
||||
<div class="mt-4 overflow-hidden rounded-xl border border-slate-700/60 bg-black">
|
||||
@if (isAudio()) {
|
||||
<audio [src]="src" controls preload="metadata" class="w-full"></audio>
|
||||
} @else {
|
||||
<video [src]="src" controls preload="metadata" playsinline class="max-h-[70vh] w-full"></video>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
<div class="mt-4 flex flex-wrap gap-2">
|
||||
<a [routerLink]="['/watch', j.provider, j.videoId]" queryParamsHandling="merge"
|
||||
class="rounded-lg border border-slate-600 px-3 py-1.5 text-xs text-slate-200 hover:bg-slate-700 transition">
|
||||
Watch
|
||||
</a>
|
||||
<button type="button" (click)="downloadCopy()" [disabled]="downloading()"
|
||||
class="rounded-lg bg-emerald-600 px-3 py-1.5 text-xs font-semibold text-white hover:bg-emerald-500 disabled:opacity-50 transition">
|
||||
{{ 'downloads.getFile' | t }}
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
</section>
|
||||
@@ -0,0 +1,119 @@
|
||||
import { ChangeDetectionStrategy, Component, OnDestroy, inject, signal } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { ActivatedRoute, RouterLink } from '@angular/router';
|
||||
import { DownloadService, type DownloadJob } from '../../../services/download.service';
|
||||
import { AuthService } from '../../../services/auth.service';
|
||||
import { TranslatePipe } from '../../../pipes/translate.pipe';
|
||||
import { ProviderBadgeComponent } from '../../../app/shared/components/provider-badge/provider-badge.component';
|
||||
|
||||
const AUDIO_EXTS = new Set(['mp3', 'm4a', 'aac', 'opus', 'ogg', 'oga', 'wav', 'flac', 'weba']);
|
||||
|
||||
@Component({
|
||||
selector: 'app-download-play',
|
||||
standalone: true,
|
||||
imports: [CommonModule, RouterLink, TranslatePipe, ProviderBadgeComponent],
|
||||
templateUrl: './download-play.component.html',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class DownloadPlayComponent implements OnDestroy {
|
||||
private route = inject(ActivatedRoute);
|
||||
private downloads = inject(DownloadService);
|
||||
private auth = inject(AuthService);
|
||||
|
||||
readonly job = signal<DownloadJob | null>(null);
|
||||
readonly loading = signal<boolean>(true);
|
||||
readonly error = signal<string | null>(null);
|
||||
readonly mediaUrl = signal<string | null>(null);
|
||||
readonly isAudio = signal<boolean>(false);
|
||||
readonly downloading = signal<boolean>(false);
|
||||
|
||||
private objectUrl: string | null = null;
|
||||
|
||||
constructor() {
|
||||
const id = (this.route.snapshot.paramMap.get('id') || '').trim();
|
||||
if (!id) {
|
||||
this.error.set('downloads.loadError');
|
||||
this.loading.set(false);
|
||||
return;
|
||||
}
|
||||
if (!this.auth.currentUser()) {
|
||||
this.error.set('downloads.loadError');
|
||||
this.loading.set(false);
|
||||
return;
|
||||
}
|
||||
this.downloads.getJob(id).subscribe({
|
||||
next: (job) => {
|
||||
if (!job || job.state !== 'completed') {
|
||||
this.error.set('downloads.notReady');
|
||||
this.loading.set(false);
|
||||
return;
|
||||
}
|
||||
this.job.set(job);
|
||||
const ext = String((job as any)?.fileExt || '').toLowerCase();
|
||||
this.isAudio.set(Boolean((job as any)?.audioOnly) || AUDIO_EXTS.has(ext));
|
||||
this.downloads.downloadFileBlob(job.id).subscribe({
|
||||
next: ({ blob }) => {
|
||||
try {
|
||||
if (this.objectUrl) URL.revokeObjectURL(this.objectUrl);
|
||||
this.objectUrl = URL.createObjectURL(blob);
|
||||
this.mediaUrl.set(this.objectUrl);
|
||||
} catch {
|
||||
this.error.set('downloads.loadError');
|
||||
}
|
||||
this.loading.set(false);
|
||||
},
|
||||
error: (err) => {
|
||||
this.error.set(err?.status === 401 ? 'downloads.fileError' : 'downloads.loadError');
|
||||
this.loading.set(false);
|
||||
},
|
||||
});
|
||||
},
|
||||
error: (err) => {
|
||||
this.error.set(err?.status === 401 ? 'downloads.fileError' : 'downloads.loadError');
|
||||
this.loading.set(false);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
try {
|
||||
if (this.objectUrl) URL.revokeObjectURL(this.objectUrl);
|
||||
} catch {}
|
||||
this.objectUrl = null;
|
||||
}
|
||||
|
||||
downloadCopy(): void {
|
||||
const job = this.job();
|
||||
if (!job || this.downloading()) return;
|
||||
this.downloading.set(true);
|
||||
this.downloads.downloadFileBlob(job.id).subscribe({
|
||||
next: ({ blob, filename }) => {
|
||||
try {
|
||||
const a = document.createElement('a');
|
||||
const url = URL.createObjectURL(blob);
|
||||
a.href = url;
|
||||
a.download = filename;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
setTimeout(() => {
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
}, 0);
|
||||
} catch {}
|
||||
this.downloading.set(false);
|
||||
},
|
||||
error: () => {
|
||||
this.error.set('downloads.fileError');
|
||||
this.downloading.set(false);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
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.min(units.length - 1, Math.floor(Math.log(x) / Math.log(1024)));
|
||||
return `${(x / Math.pow(1024, i)).toFixed(1)} ${units[i]}`;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
<section class="mx-auto max-w-5xl px-4 py-6">
|
||||
<div class="flex flex-wrap items-center justify-between gap-3">
|
||||
<h1 class="text-2xl font-bold text-slate-100">{{ 'downloads.title' | t }}</h1>
|
||||
<button
|
||||
type="button"
|
||||
(click)="reload()"
|
||||
class="rounded-lg border border-slate-700 px-3 py-1.5 text-sm text-slate-200 hover:bg-slate-800 transition">
|
||||
{{ 'downloads.refresh' | t }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Storage quota -->
|
||||
@if (quota(); as q) {
|
||||
<div class="mt-4 rounded-xl border border-slate-700/60 bg-slate-800/40 p-4">
|
||||
<div class="flex items-center justify-between text-sm text-slate-300">
|
||||
<span>{{ 'downloads.quota' | t }}</span>
|
||||
<span>{{ formatBytes(q.usedBytes) }} / {{ formatBytes(q.quotaBytes) }}</span>
|
||||
</div>
|
||||
@if (quotaPct(); as pct) {
|
||||
<div class="mt-2 h-2 w-full overflow-hidden rounded-full bg-slate-700">
|
||||
<div class="h-full rounded-full bg-sky-500 transition-all" [style.width.%]="pct"></div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- State filters -->
|
||||
<div class="mt-4 flex flex-wrap gap-2">
|
||||
<button type="button" (click)="setFilter('all')"
|
||||
class="rounded-full px-3 py-1 text-sm transition"
|
||||
[ngClass]="stateFilter() === 'all' ? 'bg-sky-600 text-white' : 'bg-slate-800 text-slate-300 hover:bg-slate-700'">
|
||||
{{ 'downloads.filter.all' | t }}
|
||||
</button>
|
||||
<button type="button" (click)="setFilter('active')"
|
||||
class="rounded-full px-3 py-1 text-sm transition"
|
||||
[ngClass]="stateFilter() === 'active' ? 'bg-sky-600 text-white' : 'bg-slate-800 text-slate-300 hover:bg-slate-700'">
|
||||
{{ 'downloads.filter.active' | t }} ({{ activeCount() }})
|
||||
</button>
|
||||
<button type="button" (click)="setFilter('completed')"
|
||||
class="rounded-full px-3 py-1 text-sm transition"
|
||||
[ngClass]="stateFilter() === 'completed' ? 'bg-sky-600 text-white' : 'bg-slate-800 text-slate-300 hover:bg-slate-700'">
|
||||
{{ 'downloads.filter.completed' | t }}
|
||||
</button>
|
||||
<button type="button" (click)="setFilter('failed')"
|
||||
class="rounded-full px-3 py-1 text-sm transition"
|
||||
[ngClass]="stateFilter() === 'failed' ? 'bg-sky-600 text-white' : 'bg-slate-800 text-slate-300 hover:bg-slate-700'">
|
||||
{{ 'downloads.filter.failed' | t }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Error -->
|
||||
@if (error(); as err) {
|
||||
<div class="mt-4 rounded-lg border border-red-700/60 bg-red-900/30 px-4 py-3 text-sm text-red-200">{{ err }}</div>
|
||||
}
|
||||
@if (fileError(); as ferr) {
|
||||
<div class="mt-4 rounded-lg border border-red-700/60 bg-red-900/30 px-4 py-3 text-sm text-red-200">{{ ferr }}</div>
|
||||
}
|
||||
|
||||
<!-- Loading -->
|
||||
@if (loading()) {
|
||||
<div class="mt-6 text-sm text-slate-400">{{ 'loading.more' | t }}</div>
|
||||
}
|
||||
|
||||
<!-- Empty state -->
|
||||
@if (!loading() && filteredItems().length === 0) {
|
||||
<div class="mt-6 rounded-xl border border-dashed border-slate-700 p-8 text-center text-sm text-slate-400">
|
||||
{{ 'downloads.empty' | t }}
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- Job list -->
|
||||
<ul class="mt-4 space-y-3">
|
||||
@for (job of filteredItems(); track job.id) {
|
||||
<li class="rounded-xl border border-slate-700/60 bg-slate-800/40 p-4">
|
||||
<div class="flex flex-wrap items-start justify-between gap-3">
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="flex items-center gap-2">
|
||||
<app-provider-badge [provider]="job.provider" />
|
||||
<a [routerLink]="watchLink(job)" queryParamsHandling="merge"
|
||||
class="truncate text-sm font-semibold text-slate-100 hover:text-sky-400"
|
||||
[title]="job.title || job.videoId">
|
||||
{{ job.title || job.videoId }}
|
||||
</a>
|
||||
</div>
|
||||
<div class="mt-1 flex flex-wrap items-center gap-2 text-xs text-slate-400">
|
||||
<span class="rounded px-1.5 py-0.5 font-medium"
|
||||
[ngClass]="{
|
||||
'bg-sky-900/60 text-sky-300': isActive(job),
|
||||
'bg-emerald-900/60 text-emerald-300': job.state === 'completed',
|
||||
'bg-red-900/60 text-red-300': job.state === 'failed',
|
||||
'bg-amber-900/60 text-amber-300': job.state === 'interrupted'
|
||||
}">
|
||||
{{ 'downloads.state.' + job.state | t }}
|
||||
</span>
|
||||
@if (job.audioOnly) {
|
||||
<span>{{ 'downloads.audioOnly' | t }}</span>
|
||||
}
|
||||
@if (job.fileSize) {
|
||||
<span>{{ formatBytes(job.fileSize) }}</span>
|
||||
}
|
||||
@if (job.error) {
|
||||
<span class="truncate text-red-300" [title]="job.error">{{ job.error }}</span>
|
||||
}
|
||||
</div>
|
||||
@if (isActive(job)) {
|
||||
<div class="mt-2 h-1.5 w-full overflow-hidden rounded-full bg-slate-700">
|
||||
<div class="h-full rounded-full bg-sky-500 transition-all" [style.width.%]="job.progress"></div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="flex flex-shrink-0 items-center gap-2">
|
||||
@if (job.state === 'completed') {
|
||||
<a [routerLink]="playLink(job)"
|
||||
class="rounded-lg bg-sky-600 px-3 py-1.5 text-xs font-semibold text-white hover:bg-sky-500 transition">
|
||||
{{ 'downloads.play' | t }}
|
||||
</a>
|
||||
<button type="button" (click)="getFile(job)" [disabled]="fetchingFileIds().has(job.id)"
|
||||
class="rounded-lg bg-emerald-600 px-3 py-1.5 text-xs font-semibold text-white hover:bg-emerald-500 disabled:opacity-50 transition">
|
||||
{{ 'downloads.getFile' | t }}
|
||||
</button>
|
||||
}
|
||||
@if (job.state === 'failed' || job.state === 'interrupted') {
|
||||
<button type="button" (click)="retry(job)" [disabled]="busyIds().has(job.id)"
|
||||
class="rounded-lg bg-sky-600 px-3 py-1.5 text-xs font-semibold text-white hover:bg-sky-500 disabled:opacity-50 transition">
|
||||
{{ 'downloads.retry' | t }}
|
||||
</button>
|
||||
}
|
||||
<button type="button" (click)="cancel(job)" [disabled]="busyIds().has(job.id)"
|
||||
class="rounded-lg border border-slate-600 px-3 py-1.5 text-xs text-slate-300 hover:bg-slate-700 disabled:opacity-50 transition">
|
||||
{{ 'btn.remove' | t }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
</section>
|
||||
@@ -0,0 +1,208 @@
|
||||
import { ChangeDetectionStrategy, Component, computed, inject, signal } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { RouterLink } from '@angular/router';
|
||||
import { DownloadService, type DownloadJob, type DownloadQuota } from '../../../services/download.service';
|
||||
import { AuthService } from '../../../services/auth.service';
|
||||
import { TranslatePipe } from '../../../pipes/translate.pipe';
|
||||
import { ProviderBadgeComponent } from '../../../app/shared/components/provider-badge/provider-badge.component';
|
||||
|
||||
type StateFilter = 'all' | 'active' | 'completed' | 'failed';
|
||||
|
||||
const ACTIVE_STATES = new Set(['queued', 'running', 'merging']);
|
||||
|
||||
@Component({
|
||||
selector: 'app-library-downloads',
|
||||
standalone: true,
|
||||
imports: [CommonModule, RouterLink, TranslatePipe, ProviderBadgeComponent],
|
||||
templateUrl: './downloads.component.html',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class DownloadsComponent {
|
||||
private downloads = inject(DownloadService);
|
||||
private auth = inject(AuthService);
|
||||
|
||||
readonly loading = signal<boolean>(true);
|
||||
readonly error = signal<string | null>(null);
|
||||
readonly items = signal<DownloadJob[]>([]);
|
||||
readonly quota = signal<DownloadQuota | null>(null);
|
||||
readonly stateFilter = signal<StateFilter>('all');
|
||||
readonly busyIds = signal<Set<string>>(new Set());
|
||||
readonly fileError = signal<string | null>(null);
|
||||
readonly fetchingFileIds = signal<Set<string>>(new Set());
|
||||
|
||||
readonly filteredItems = computed(() => {
|
||||
const filter = this.stateFilter();
|
||||
const list = this.items();
|
||||
if (filter === 'all') return list;
|
||||
if (filter === 'active') return list.filter((j) => ACTIVE_STATES.has(j.state));
|
||||
return list.filter((j) => j.state === filter);
|
||||
});
|
||||
|
||||
readonly activeCount = computed(() => this.items().filter((j) => ACTIVE_STATES.has(j.state)).length);
|
||||
readonly quotaPct = computed(() => {
|
||||
const q = this.quota();
|
||||
if (!q || !q.quotaBytes) return null;
|
||||
return Math.min(100, Math.round((q.usedBytes / q.quotaBytes) * 100));
|
||||
});
|
||||
|
||||
// Poller keeps active job progress fresh while the page is open
|
||||
private poller: any = null;
|
||||
|
||||
constructor() {
|
||||
this.reload();
|
||||
}
|
||||
|
||||
isLoggedIn(): boolean {
|
||||
return this.auth.currentUser() !== null;
|
||||
}
|
||||
|
||||
reload(): void {
|
||||
this.loading.set(true);
|
||||
this.error.set(null);
|
||||
this.downloads.listJobs({ limit: 100 }).subscribe({
|
||||
next: (res) => {
|
||||
this.items.set(res.items || []);
|
||||
this.quota.set(res.quota ?? null);
|
||||
this.loading.set(false);
|
||||
this.syncPoller();
|
||||
},
|
||||
error: (err) => {
|
||||
if (err?.status === 401) {
|
||||
this.error.set('Connectez-vous pour voir vos téléchargements.');
|
||||
} else {
|
||||
this.error.set('Impossible de charger la file de téléchargement.');
|
||||
}
|
||||
this.loading.set(false);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
setFilter(f: StateFilter): void {
|
||||
this.stateFilter.set(f);
|
||||
}
|
||||
|
||||
isActive(job: DownloadJob): boolean {
|
||||
return ACTIVE_STATES.has(job.state);
|
||||
}
|
||||
|
||||
retry(job: DownloadJob): void {
|
||||
if (this.busyIds().has(job.id)) return;
|
||||
this.markBusy(job.id, true);
|
||||
this.downloads.retryJob(job.id).subscribe({
|
||||
next: () => {
|
||||
this.markBusy(job.id, false);
|
||||
this.reload();
|
||||
},
|
||||
error: () => {
|
||||
this.markBusy(job.id, false);
|
||||
this.error.set('Nouvel essai impossible pour le moment.');
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
cancel(job: DownloadJob): void {
|
||||
if (this.busyIds().has(job.id)) return;
|
||||
this.markBusy(job.id, true);
|
||||
this.downloads.cancelJob(job.id).subscribe({
|
||||
next: () => {
|
||||
this.markBusy(job.id, false);
|
||||
this.items.update((list) => list.filter((j) => j.id !== job.id));
|
||||
},
|
||||
error: () => {
|
||||
this.markBusy(job.id, false);
|
||||
this.error.set('Suppression impossible pour le moment.');
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Deep link back to the watch page for a job's video
|
||||
watchLink(job: DownloadJob): any[] {
|
||||
return ['/watch', job.provider, job.videoId];
|
||||
}
|
||||
|
||||
// Deep link to the local playback page for a completed job
|
||||
playLink(job: DownloadJob): any[] {
|
||||
return ['/library/downloads', job.id];
|
||||
}
|
||||
|
||||
/**
|
||||
* "Obtenir le fichier" via requête authentifiée (Bearer) puis blob :
|
||||
* ne dépend pas des cookies de session (liens <a href> directs -> 401).
|
||||
*/
|
||||
getFile(job: DownloadJob): void {
|
||||
if (this.fetchingFileIds().has(job.id)) return;
|
||||
this.fileError.set(null);
|
||||
this.fetchingFileIds.update((set) => new Set(set).add(job.id));
|
||||
this.downloads.downloadFileBlob(job.id).subscribe({
|
||||
next: ({ blob, filename }) => {
|
||||
try {
|
||||
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);
|
||||
}, 0);
|
||||
} catch {}
|
||||
this.fetchingFileIds.update((set) => {
|
||||
const next = new Set(set);
|
||||
next.delete(job.id);
|
||||
return next;
|
||||
});
|
||||
},
|
||||
error: (err) => {
|
||||
this.fetchingFileIds.update((set) => {
|
||||
const next = new Set(set);
|
||||
next.delete(job.id);
|
||||
return next;
|
||||
});
|
||||
this.fileError.set(
|
||||
err?.status === 401
|
||||
? 'Session expirée. Reconnectez-vous puis réessayez.'
|
||||
: 'Fichier inaccessible pour le moment.'
|
||||
);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
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.min(units.length - 1, Math.floor(Math.log(x) / Math.log(1024)));
|
||||
return `${(x / Math.pow(1024, i)).toFixed(1)} ${units[i]}`;
|
||||
}
|
||||
|
||||
private markBusy(id: string, busy: boolean): void {
|
||||
this.busyIds.update((set) => {
|
||||
const next = new Set(set);
|
||||
if (busy) next.add(id);
|
||||
else next.delete(id);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
private syncPoller(): void {
|
||||
const hasActive = this.items().some((j) => ACTIVE_STATES.has(j.state));
|
||||
if (hasActive && !this.poller) {
|
||||
this.poller = setInterval(() => this.refreshActive(), 3000);
|
||||
} else if (!hasActive && this.poller) {
|
||||
clearInterval(this.poller);
|
||||
this.poller = null;
|
||||
}
|
||||
}
|
||||
|
||||
private refreshActive(): void {
|
||||
this.downloads.listJobs({ limit: 100 }).subscribe({
|
||||
next: (res) => {
|
||||
this.items.set(res.items || []);
|
||||
this.quota.set(res.quota ?? null);
|
||||
this.syncPoller();
|
||||
},
|
||||
error: () => {},
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -136,6 +136,10 @@ export class PlaylistsComponent {
|
||||
|
||||
savePlaylist() {
|
||||
if (!this.formTitle().trim()) return;
|
||||
if (!this.currentUser()) {
|
||||
this.error.set('Connectez-vous pour créer une playlist.');
|
||||
return;
|
||||
}
|
||||
this.busySave.set(true);
|
||||
const body = {
|
||||
title: this.formTitle().trim(),
|
||||
@@ -149,7 +153,13 @@ export class PlaylistsComponent {
|
||||
: this.http.post(`${this.apiBase()}/playlists`, body, { withCredentials: true });
|
||||
req.subscribe({
|
||||
next: () => { this.busySave.set(false); this.modalOpen.set(false); this.reload(this.searchQuery().trim() || undefined); },
|
||||
error: (err) => { this.error.set(err?.error?.error || err?.message || 'Échec de l’enregistrement'); this.busySave.set(false); }
|
||||
error: (err) => {
|
||||
this.error.set(
|
||||
err?.status === 401 ? 'Session expirée. Reconnectez-vous puis réessayez.'
|
||||
: (err?.error?.error || err?.message || 'Échec de l’enregistrement')
|
||||
);
|
||||
this.busySave.set(false);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,146 @@
|
||||
<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 class="mb-6 flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
|
||||
<div>
|
||||
<h2 class="text-3xl font-bold text-slate-100">Abonnements</h2>
|
||||
<p class="text-sm text-slate-400">Gérez vos chaînes suivies multi-fournisseurs</p>
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<button
|
||||
class="rounded-md border border-slate-700 bg-slate-800 px-3 py-1.5 text-sm text-slate-200 transition hover:border-slate-500 hover:text-white"
|
||||
(click)="refresh()"
|
||||
type="button"
|
||||
>
|
||||
Rafraîchir
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-4 lg:grid-cols-[minmax(0,1fr)_280px]">
|
||||
<div class="bg-slate-900/60 rounded-xl border border-slate-800 p-4 shadow-lg shadow-black/30">
|
||||
<div class="flex flex-wrap items-center gap-2 border-b border-slate-800 pb-4">
|
||||
<span class="text-xs uppercase tracking-wide text-slate-400">Filtres</span>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<button
|
||||
*ngFor="let filter of filters"
|
||||
type="button"
|
||||
(click)="setFilter(filter.id)"
|
||||
class="rounded-full border px-3 py-1 text-xs font-semibold transition"
|
||||
[class.bg-red-500/20]="activeFilter() === filter.id"
|
||||
[class.text-red-200]="activeFilter() === filter.id"
|
||||
[class.border-red-500/40]="activeFilter() === filter.id"
|
||||
[class.border-slate-700]="activeFilter() !== filter.id"
|
||||
[class.text-slate-300]="activeFilter() !== filter.id"
|
||||
[class.bg-slate-800/70]="activeFilter() !== filter.id"
|
||||
>
|
||||
{{ filter.label }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-between py-4">
|
||||
<div class="flex items-center gap-2 text-sm text-slate-400">
|
||||
<span>Trier par :</span>
|
||||
<button
|
||||
*ngFor="let sort of sorts"
|
||||
type="button"
|
||||
(click)="setSort(sort.key)"
|
||||
class="rounded-md px-3 py-1 text-xs transition"
|
||||
[class.bg-emerald-500/20]="activeSort() === sort.key"
|
||||
[class.text-emerald-200]="activeSort() === sort.key"
|
||||
[class.bg-slate-800/60]="activeSort() !== sort.key"
|
||||
[class.text-slate-300]="activeSort() !== sort.key"
|
||||
>
|
||||
{{ sort.label }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ng-container *ngIf="filteredItems().length > 0; else emptyState">
|
||||
<div class="divide-y divide-slate-800">
|
||||
<div
|
||||
*ngFor="let item of filteredItems()"
|
||||
class="flex flex-col gap-4 py-4 md:flex-row md:items-center md:justify-between"
|
||||
>
|
||||
<ng-container *ngIf="item.channel as channel; else subscriptionFallback">
|
||||
<div class="flex items-center gap-4">
|
||||
<img
|
||||
[src]="channel.avatarUrl || 'https://placehold.co/80x80/png'"
|
||||
[alt]="channel.title || channel.handle || 'Avatar'"
|
||||
class="h-16 w-16 rounded-full border border-slate-800 object-cover"
|
||||
/>
|
||||
<div class="space-y-1">
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<h3 class="text-lg font-semibold text-slate-100">
|
||||
{{ channel.title || channel.handle || 'Chaîne inconnue' }}
|
||||
</h3>
|
||||
<app-provider-badge [provider]="channel.provider"></app-provider-badge>
|
||||
<span *ngIf="channel.verified" class="text-xs text-emerald-300">✓ Vérifiée</span>
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-3 text-sm text-slate-400">
|
||||
<span *ngIf="channel.handle">{{ channel.handle }}</span>
|
||||
<span *ngIf="channel.subsCount != null">
|
||||
{{ channel.subsCount | number }} abonnés
|
||||
</span>
|
||||
<span class="text-slate-500">
|
||||
Abonné depuis {{ item.createdAt | date:'mediumDate' }}
|
||||
</span>
|
||||
</div>
|
||||
<a
|
||||
*ngIf="channel.url"
|
||||
class="text-xs text-blue-300 hover:text-blue-200"
|
||||
[href]="channel.url"
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
>
|
||||
Voir la chaîne externe
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col items-stretch gap-2 text-sm md:text-right">
|
||||
<app-subscribe-button
|
||||
[provider]="channel.provider"
|
||||
[externalId]="channel.externalId"
|
||||
[channelMeta]="channel"
|
||||
(unsubscribed)="refresh()"
|
||||
(subscribed)="refresh()"
|
||||
></app-subscribe-button>
|
||||
<button
|
||||
class="rounded-md border border-slate-700 bg-slate-800/60 px-3 py-1 text-sm text-slate-300 transition hover:border-slate-500 hover:text-white"
|
||||
type="button"
|
||||
(click)="openChannel(item)"
|
||||
>
|
||||
Voir la chaîne
|
||||
</button>
|
||||
</div>
|
||||
</ng-container>
|
||||
<ng-template #subscriptionFallback>
|
||||
<div class="flex-1 text-sm text-slate-400">
|
||||
<p class="font-medium text-slate-200">Chaîne non disponible</p>
|
||||
<p class="text-xs">Essayez de rafraîchir l'abonnement plus tard.</p>
|
||||
</div>
|
||||
</ng-template>
|
||||
</div>
|
||||
</div>
|
||||
</ng-container>
|
||||
|
||||
<ng-template #emptyState>
|
||||
<div class="flex flex-col items-center justify-center gap-3 rounded-lg border border-dashed border-slate-700 bg-slate-900/60 p-10 text-center">
|
||||
<h3 class="text-lg font-semibold text-slate-100">Aucune chaîne suivie</h3>
|
||||
<p class="text-sm text-slate-400">Explorez la page Trending ou effectuez une recherche pour commencer à vous abonner.</p>
|
||||
</div>
|
||||
</ng-template>
|
||||
</div>
|
||||
|
||||
<aside class="rounded-xl border border-slate-800 bg-slate-900/50 p-4 shadow-lg shadow-black/30">
|
||||
<h3 class="text-lg font-semibold text-slate-100">Astuce</h3>
|
||||
<p class="mt-2 text-sm text-slate-400">
|
||||
Utilisez le raccourci <code class="rounded bg-slate-800 px-2 py-0.5 text-xs">S</code> sur une carte vidéo pour activer
|
||||
rapidement le bouton d'abonnement. Les abonnements sont synchronisés sur tous vos appareils.
|
||||
</p>
|
||||
<p class="mt-4 text-sm text-slate-500">
|
||||
Cache TTL: {{ hasCache() ? '6h' : '—' }}.
|
||||
</p>
|
||||
</aside>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,11 +1,101 @@
|
||||
import { ChangeDetectionStrategy, Component } from '@angular/core';
|
||||
import { ChangeDetectionStrategy, Component, computed, inject, signal } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { DatePipe, DecimalPipe } from '@angular/common';
|
||||
import { Router } from '@angular/router';
|
||||
import { SubscriptionsService, type SubscriptionItem } from '../../../services/subscriptions.service';
|
||||
import type { ProviderId } from '../../../app/shared/models/channel.model';
|
||||
import { ProviderBadgeComponent } from '../../../app/shared/components/provider-badge/provider-badge.component';
|
||||
import { SubscribeButtonComponent } from '../../../app/shared/components/subscribe-button/subscribe-button.component';
|
||||
|
||||
interface FilterChip {
|
||||
id: 'all' | ProviderId;
|
||||
label: string;
|
||||
}
|
||||
|
||||
type SortKey = 'alpha' | 'recent' | 'subs';
|
||||
|
||||
@Component({
|
||||
selector: 'app-library-subscriptions',
|
||||
standalone: true,
|
||||
imports: [CommonModule],
|
||||
imports: [CommonModule, DatePipe, DecimalPipe, ProviderBadgeComponent, SubscribeButtonComponent],
|
||||
templateUrl: './subscriptions.component.html',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class SubscriptionsComponent { }
|
||||
export class SubscriptionsComponent {
|
||||
private subs = inject(SubscriptionsService);
|
||||
private router = inject(Router);
|
||||
|
||||
filters: FilterChip[] = [
|
||||
{ id: 'all', label: 'Tous' },
|
||||
{ id: 'yt', label: 'YouTube' },
|
||||
{ id: 'dm', label: 'Dailymotion' },
|
||||
{ id: 'tw', label: 'Twitch' },
|
||||
{ id: 'pt', label: 'PeerTube' },
|
||||
{ id: 'od', label: 'Odysee' },
|
||||
{ id: 'ru', label: 'Rumble' },
|
||||
];
|
||||
|
||||
sorts: { key: SortKey; label: string }[] = [
|
||||
{ key: 'alpha', label: 'A → Z' },
|
||||
{ key: 'recent', label: 'Récents' },
|
||||
{ key: 'subs', label: 'Abonnés' },
|
||||
];
|
||||
|
||||
activeFilter = signal<FilterChip['id']>('all');
|
||||
activeSort = signal<SortKey>('alpha');
|
||||
|
||||
readonly items = computed(() => this.subs.items());
|
||||
readonly filteredItems = computed(() => this.applyFilters(this.items()));
|
||||
readonly hasCache = computed(() => this.items().length > 0);
|
||||
|
||||
constructor() {
|
||||
this.refresh();
|
||||
}
|
||||
|
||||
refresh(): void {
|
||||
this.subs.list().subscribe();
|
||||
}
|
||||
|
||||
setFilter(id: FilterChip['id']): void {
|
||||
this.activeFilter.set(id);
|
||||
}
|
||||
|
||||
setSort(key: SortKey): void {
|
||||
this.activeSort.set(key);
|
||||
}
|
||||
|
||||
openChannel(item: SubscriptionItem): void {
|
||||
if (!item?.channel?.provider || !item?.channel?.externalId) {
|
||||
return;
|
||||
}
|
||||
this.router.navigate(['/channel', item.channel.provider, item.channel.externalId]);
|
||||
}
|
||||
|
||||
private applyFilters(list: SubscriptionItem[]): SubscriptionItem[] {
|
||||
const filter = this.activeFilter();
|
||||
const sort = this.activeSort();
|
||||
let filtered = list;
|
||||
if (filter !== 'all') {
|
||||
filtered = filtered.filter((item) => item.channel?.provider === filter);
|
||||
}
|
||||
switch (sort) {
|
||||
case 'recent':
|
||||
filtered = [...filtered].sort((a, b) => (b.createdAt || 0) - (a.createdAt || 0));
|
||||
break;
|
||||
case 'subs':
|
||||
filtered = [...filtered].sort(
|
||||
(a, b) => (b.channel?.subsCount ?? 0) - (a.channel?.subsCount ?? 0)
|
||||
);
|
||||
break;
|
||||
case 'alpha':
|
||||
default:
|
||||
filtered = [...filtered].sort((a, b) => {
|
||||
const ta = (a.channel?.title || a.channel?.handle || '').toLowerCase();
|
||||
const tb = (b.channel?.title || b.channel?.handle || '').toLowerCase();
|
||||
return ta.localeCompare(tb);
|
||||
});
|
||||
break;
|
||||
}
|
||||
return filtered;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,22 +1,22 @@
|
||||
<div class="fixed inset-0 z-[9999] bg-black/60 flex justify-center items-center" (click)="close.emit()">
|
||||
<div class="fixed inset-0 z-[9999] bg-black/60" (click)="close.emit()">
|
||||
<div class="fixed inset-0 grid place-items-center">
|
||||
<div #dialog
|
||||
class="w-[90%] max-w-lg bg-slate-900 rounded-xl shadow-2xl border border-slate-700"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="providersTitle"
|
||||
(click)="$event.stopPropagation()"
|
||||
tabindex="0"
|
||||
<div #dialog
|
||||
class="w-[90%] max-w-lg bg-slate-900 rounded-xl shadow-2xl border border-slate-700"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="providersTitle"
|
||||
(click)="$event.stopPropagation()"
|
||||
tabindex="0"
|
||||
(keydown)="onDialogKeydown($event)">
|
||||
<div class="p-4 border-b border-slate-700 flex items-center justify-between">
|
||||
<h2 id="providersTitle" class="text-slate-100 font-semibold">Providers</h2>
|
||||
<button (click)="close.emit()" class="px-2 py-1 rounded bg-slate-700 hover:bg-slate-600 text-slate-100">Esc</button>
|
||||
<button type="button" (click)="close.emit()" class="px-2 py-1 rounded bg-slate-700 hover:bg-slate-600 text-slate-100" aria-label="Fermer (Esc)">Esc</button>
|
||||
</div>
|
||||
<div class="p-4 space-y-3">
|
||||
<input type="text" [ngModel]="filter()" (ngModelChange)="filter.set($event)" placeholder="filter providers…" class="w-full px-3 py-2 rounded bg-slate-800 text-slate-100 placeholder-slate-400 border border-slate-700"/>
|
||||
<input type="text" [ngModel]="filter()" (ngModelChange)="filter.set($event)" placeholder="filter providers…" aria-label="Filtrer les fournisseurs" class="w-full px-3 py-2 rounded bg-slate-800 text-slate-100 placeholder-slate-400 border border-slate-700"/>
|
||||
<div class="flex items-center gap-2">
|
||||
<label class="inline-flex items-center gap-2 text-slate-200">
|
||||
<input type="checkbox" [checked]="all()" (change)="toggleAll()"/>
|
||||
<input type="checkbox" [checked]="allMode()" (change)="toggleAllMode()"/>
|
||||
<span>All</span>
|
||||
</label>
|
||||
</div>
|
||||
@@ -33,8 +33,9 @@
|
||||
</label>
|
||||
</div>
|
||||
<div class="p-4 border-t border-slate-700 flex items-center justify-end gap-2">
|
||||
<button (click)="close.emit()" class="px-3 py-2 rounded bg-slate-700 hover:bg-slate-600 text-slate-100">Cancel</button>
|
||||
<button (click)="onApply()" class="px-3 py-2 rounded bg-red-600 hover:bg-red-500 text-white">Apply filters</button>
|
||||
<button type="button" (click)="close.emit()" class="px-3 py-2 rounded bg-slate-700 hover:bg-slate-600 text-slate-100">Cancel</button>
|
||||
<button type="button" (click)="onApply()" class="px-3 py-2 rounded bg-red-600 hover:bg-red-500 text-white">Apply filters</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Component, EventEmitter, Output, Input, signal, computed, ElementRef, ViewChild, AfterViewInit } from '@angular/core';
|
||||
import { Component, EventEmitter, Output, Input, signal, computed, ElementRef, ViewChild, AfterViewInit, OnDestroy } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { ProviderId, PROVIDERS } from '../../app/core/providers/provider-registry';
|
||||
import { ProviderId, PROVIDERS, PROVIDER_MAP } from '../../app/core/providers/provider-registry';
|
||||
|
||||
@Component({
|
||||
selector: 'app-provider-picker',
|
||||
@@ -9,45 +9,66 @@ import { ProviderId, PROVIDERS } from '../../app/core/providers/provider-registr
|
||||
imports: [CommonModule, FormsModule],
|
||||
templateUrl: './provider-picker.component.html'
|
||||
})
|
||||
export class ProviderPickerComponent implements AfterViewInit {
|
||||
export class ProviderPickerComponent implements AfterViewInit, OnDestroy {
|
||||
@Input() open = false;
|
||||
@Input() selected: ProviderId[] | 'all' = 'all';
|
||||
// Incoming selection: 'all' or a list of provider ids
|
||||
@Input() set selected(value: ProviderId[] | 'all') {
|
||||
// Reset the draft each time the picker opens with a new incoming selection
|
||||
this._draft.set(value === 'all' ? [] : Array.isArray(value) ? [...value] : []);
|
||||
this._allMode.set(value === 'all');
|
||||
}
|
||||
@Output() close = new EventEmitter<void>();
|
||||
@Output() apply = new EventEmitter<{ selection: ProviderId[] | 'all'; rememberDefault: boolean }>();
|
||||
|
||||
filter = signal('');
|
||||
rememberDefault = signal(false);
|
||||
|
||||
// Draft state: working copy while the modal is open
|
||||
private _draft = signal<ProviderId[]>([]);
|
||||
private _allMode = signal(true);
|
||||
readonly draft = computed(() => this._draft());
|
||||
readonly allMode = computed(() => this._allMode() && this._draft().length === 0);
|
||||
|
||||
@ViewChild('dialog', { static: false }) dialogRef?: ElementRef<HTMLDivElement>;
|
||||
|
||||
readonly all = computed(() => this.selected === 'all');
|
||||
readonly providers = computed(() => {
|
||||
const q = this.filter().toLowerCase();
|
||||
return PROVIDERS.filter(p => !q || p.displayName.toLowerCase().includes(q) || p.shortLabel.toLowerCase().includes(q));
|
||||
});
|
||||
|
||||
toggleAll() {
|
||||
this.apply.emit({ selection: 'all', rememberDefault: this.rememberDefault() });
|
||||
toggleAllMode() {
|
||||
this._allMode.set(true);
|
||||
this._draft.set([]);
|
||||
}
|
||||
|
||||
isChecked(id: ProviderId): boolean {
|
||||
if (this.selected === 'all') return true;
|
||||
return Array.isArray(this.selected) && this.selected.includes(id);
|
||||
if (this.allMode()) return true;
|
||||
return this._draft().includes(id);
|
||||
}
|
||||
|
||||
toggle(id: ProviderId) {
|
||||
if (this.selected === 'all') {
|
||||
this.apply.emit({ selection: [id], rememberDefault: this.rememberDefault() });
|
||||
if (this.allMode()) {
|
||||
// Leaving All mode by unchecking one provider: start from every provider
|
||||
// then remove the unchecked one ("all except this one")
|
||||
const set = new Set(PROVIDERS.map(p => p.id));
|
||||
set.delete(id);
|
||||
this._draft.set(Array.from(set));
|
||||
this._allMode.set(false);
|
||||
return;
|
||||
}
|
||||
const set = new Set(this.selected || []);
|
||||
if (set.has(id)) set.delete(id); else set.add(id);
|
||||
const next = Array.from(set) as ProviderId[];
|
||||
this.selected = next;
|
||||
const set = new Set(this._draft());
|
||||
if (set.has(id)) {
|
||||
set.delete(id);
|
||||
} else {
|
||||
set.add(id);
|
||||
}
|
||||
this._draft.set(Array.from(set));
|
||||
this._allMode.set(false);
|
||||
}
|
||||
|
||||
onApply() {
|
||||
this.apply.emit({ selection: this.selected, rememberDefault: this.rememberDefault() });
|
||||
const selection: ProviderId[] | 'all' = this.allMode() ? 'all' : this._draft();
|
||||
this.apply.emit({ selection, rememberDefault: this.rememberDefault() });
|
||||
}
|
||||
|
||||
ngAfterViewInit() {
|
||||
@@ -57,8 +78,17 @@ export class ProviderPickerComponent implements AfterViewInit {
|
||||
});
|
||||
}
|
||||
|
||||
// Basic focus trap within the dialog
|
||||
ngOnDestroy() {
|
||||
try { document.body.style.overflow = ''; } catch {}
|
||||
}
|
||||
|
||||
// Focus trap within the dialog (Tab / Shift+Tab)
|
||||
onDialogKeydown(ev: KeyboardEvent) {
|
||||
if (ev.key === 'Escape') {
|
||||
ev.preventDefault();
|
||||
this.close.emit();
|
||||
return;
|
||||
}
|
||||
if (ev.key !== 'Tab') return;
|
||||
const root = this.dialogRef?.nativeElement;
|
||||
if (!root) return;
|
||||
@@ -71,7 +101,7 @@ export class ProviderPickerComponent implements AfterViewInit {
|
||||
if (!ev.shiftKey && active === last) {
|
||||
ev.preventDefault();
|
||||
first.focus();
|
||||
} else if (ev.shiftKey && active === first) {
|
||||
} else if (ev.shiftKey && (active === first || active === root)) {
|
||||
ev.preventDefault();
|
||||
last.focus();
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
<form class="w-full relative" novalidate (ngSubmit)="onSubmit($event)" (submit)="onSubmit($event)" (keydown)="handleKeydown($event)" role="search" aria-label="Recherche">
|
||||
<form class="w-full relative" novalidate (submit)="onSubmit($event)" role="search" aria-label="Recherche">
|
||||
<!-- Input -->
|
||||
<div class="flex items-center gap-2 rounded-lg ring-1 ring-slate-600/50 bg-slate-800/70 px-3 py-2">
|
||||
<!-- Provider chips -->
|
||||
@@ -9,6 +9,7 @@
|
||||
'bg-slate-200 text-slate-800 border-slate-300': providerMode()==='all',
|
||||
'bg-slate-700 text-slate-100 border-slate-600': providerMode()!=='all'
|
||||
}"
|
||||
[attr.aria-pressed]="providerMode()==='all'"
|
||||
title="Tous les fournisseurs">
|
||||
All
|
||||
</button>
|
||||
@@ -19,20 +20,25 @@
|
||||
'bg-slate-700 text-slate-100 border-slate-600': !(selected().includes(c.id)),
|
||||
'bg-slate-200 text-slate-800 border-slate-300': selected().includes(c.id)
|
||||
}"
|
||||
[title]="'Inclure ' + c.label">
|
||||
[attr.aria-pressed]="selected().includes(c.id)"
|
||||
[title]="'Inclure ' + c.label + ' (Alt+' + (i + 1) + ')'">
|
||||
<span [class]="c.colorClass">{{ c.label }}</span>
|
||||
</button>
|
||||
</ng-container>
|
||||
|
||||
<!-- Text input -->
|
||||
<!-- Text input: combobox pattern for the inline '@' autocomplete -->
|
||||
<input #qInput type="search"
|
||||
class="flex-1 bg-transparent outline-none text-slate-100 placeholder-slate-400 px-2"
|
||||
[placeholder]="placeholder"
|
||||
[ngModel]="query()" (ngModelChange)="queryUpdate($event)" (keydown)="handleKeydown($event)" (keydown.enter)="onSubmit($event)" name="q"
|
||||
autocomplete="off" aria-label="Rechercher des vidéos"/>
|
||||
autocomplete="off" aria-label="Rechercher des vidéos"
|
||||
role="combobox" aria-expanded="false" aria-autocomplete="list"
|
||||
[attr.aria-expanded]="atOpen()"
|
||||
[attr.aria-controls]="atOpen() ? 'atOptionsList' : null"
|
||||
[attr.aria-activedescendant]="atOpen() && atOptions().length > 0 ? 'atOption-' + atIndex() : null"/>
|
||||
|
||||
<!-- Actions -->
|
||||
<button type="button" (click)="openPicker()" class="text-xs px-2 py-1 rounded bg-slate-700 hover:bg-slate-600" aria-label="Choisir des fournisseurs">@</button>
|
||||
<button type="button" (click)="openPicker()" class="text-xs px-2 py-1 rounded bg-slate-700 hover:bg-slate-600" aria-label="Choisir des fournisseurs (@)">@</button>
|
||||
<button type="submit" class="ml-1 px-3 py-1 rounded bg-red-600 hover:bg-red-500 text-white"
|
||||
(click)="onSubmit($event)"
|
||||
[attr.title]="(query() || '').trim().length < 2 ? 'Tapez au moins 2 caractères' : null">
|
||||
@@ -51,10 +57,13 @@
|
||||
<div *ngIf="atOpen() && atOptions().length > 0"
|
||||
class="absolute z-50 mt-1 left-3 right-3 rounded-lg shadow-xl overflow-hidden"
|
||||
style="top: 100%">
|
||||
<ul class="max-h-64 overflow-y-auto"
|
||||
<ul id="atOptionsList" role="listbox" aria-label="Fournisseurs"
|
||||
class="max-h-64 overflow-y-auto"
|
||||
[ngClass]="{ 'bg-slate-900 border border-slate-700 text-slate-200': true }">
|
||||
<li *ngFor="let opt of atOptions(); let i = index">
|
||||
<button type="button" (click)="toggleChip(opt.id); atOpen.set(false)"
|
||||
<li *ngFor="let opt of atOptions(); let i = index" role="option"
|
||||
[attr.aria-selected]="i === atIndex()"
|
||||
[id]="'atOption-' + i">
|
||||
<button type="button" (click)="applyAtOption(opt.id)"
|
||||
class="w-full text-left px-3 py-2 text-sm"
|
||||
[ngClass]="{
|
||||
'bg-slate-800': i === atIndex(),
|
||||
@@ -67,11 +76,11 @@
|
||||
</div>
|
||||
|
||||
<!-- Quick Menu (Ctrl/Cmd+K) -->
|
||||
<div *ngIf="quickOpen()" style="position: fixed; top: 0; left: 0; right: 0; bottom: 0; z-index: 9999; background-color: rgba(0, 0, 0, 0.6);" role="dialog" aria-modal="true" (click)="closeQuickMenu()">
|
||||
<div style="position: absolute; top: 50%; left: 50%; transform: translate(-50%, -0%); width: 90%; max-width: 56rem; max-height: 85vh; border-radius: 0.75rem; box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.25); overflow: hidden; background-color: #0f172a; border: 1px solid #334155; display: flex; flex-direction: column;" role="document" aria-labelledby="quickMenuTitle" (click)="$event.stopPropagation()" tabindex="0">
|
||||
<div *ngIf="quickOpen()" style="position: fixed; top: 0; left: 0; right: 0; bottom: 0; z-index: 9999; background-color: rgba(0, 0, 0, 0.6);" role="presentation" (click)="closeQuickMenu()">
|
||||
<div #quickDialog style="position: absolute; top: 50%; left: 50%; transform: translate(-50%, -0%); width: 90%; max-width: 56rem; max-height: 85vh; border-radius: 0.75rem; box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.25); overflow: hidden; background-color: #0f172a; border: 1px solid #334155; display: flex; flex-direction: column;" role="dialog" aria-modal="true" aria-labelledby="quickMenuTitle" (click)="$event.stopPropagation()" (keydown)="onQuickMenuKeydown($event)">
|
||||
<div class="p-3 border-b border-slate-700 flex items-center justify-between">
|
||||
<h2 id="quickMenuTitle" class="text-slate-100 font-semibold">Quick Menu</h2>
|
||||
<button (click)="closeQuickMenu()" class="px-2 py-1 rounded bg-slate-700 hover:bg-slate-600 text-slate-100" autofocus>Esc</button>
|
||||
<button type="button" (click)="closeQuickMenu()" class="px-2 py-1 rounded bg-slate-700 hover:bg-slate-600 text-slate-100" aria-label="Fermer le menu (Esc)">Esc</button>
|
||||
</div>
|
||||
<div class="grid grid-cols-2 gap-0 overflow-y-auto">
|
||||
<!-- History column -->
|
||||
@@ -80,7 +89,7 @@
|
||||
<ul class="space-y-1 max-h-80 overflow-y-auto">
|
||||
<li *ngFor="let it of recentSearches()">
|
||||
<button type="button" class="w-full text-left px-2 py-1 rounded hover:bg-slate-800 text-slate-200"
|
||||
(click)="queryUpdate(it.query || '')">{{ it.query }}</button>
|
||||
(click)="queryUpdate(it.query || ''); closeQuickMenu()">{{ it.query }}</button>
|
||||
</li>
|
||||
<li *ngIf="(recentSearches()?.length || 0) === 0" class="text-slate-500 text-sm">No recent searches.</li>
|
||||
</ul>
|
||||
|
||||
@@ -7,17 +7,20 @@ import { ProviderPickerComponent } from './provider-picker.component';
|
||||
import { SearchService } from '../../app/search/search.service';
|
||||
import { UserService, type UserPreferences } from '../../services/user.service';
|
||||
import { HistoryService, type SearchHistoryItem } from '../../services/history.service';
|
||||
import { TelemetryService } from '../../services/telemetry.service';
|
||||
import { Subject } from 'rxjs';
|
||||
import { debounceTime, distinctUntilChanged } from 'rxjs/operators';
|
||||
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
|
||||
// No overlays; we render fixed-position modals with high z-index
|
||||
|
||||
const AT_QUERY_RE = /(^|\s)@([a-z]{1,24})$/i;
|
||||
|
||||
@Component({
|
||||
selector: 'app-search-box',
|
||||
standalone: true,
|
||||
imports: [
|
||||
CommonModule,
|
||||
FormsModule,
|
||||
CommonModule,
|
||||
FormsModule,
|
||||
ProviderPickerComponent,
|
||||
HttpClientModule // Nécessaire pour SearchService
|
||||
],
|
||||
@@ -32,7 +35,9 @@ export class SearchBoxComponent {
|
||||
private search = inject(SearchService);
|
||||
private users = inject(UserService);
|
||||
private history = inject(HistoryService);
|
||||
private telemetry = inject(TelemetryService);
|
||||
@ViewChild('qInput', { static: false }) qInputRef?: ElementRef<HTMLInputElement>;
|
||||
@ViewChild('quickDialog', { static: false }) quickDialogRef?: ElementRef<HTMLDivElement>;
|
||||
|
||||
// Input/Output bindings
|
||||
@Input() placeholder: string = 'Search videos…';
|
||||
@@ -46,25 +51,24 @@ export class SearchBoxComponent {
|
||||
selected = signal<ProviderId[]>([]);
|
||||
pickerOpen = signal(false);
|
||||
suggestionsOpen = signal(false);
|
||||
// Inline @autocomplete state
|
||||
// Inline @autocomplete state (atOpen is derived from the query, atOptions is pure)
|
||||
atOpen = signal(false);
|
||||
atIndex = signal(0);
|
||||
atOptions = computed(() => {
|
||||
const q = this.query();
|
||||
const m = /(^|\s)@([a-z]{1,24})$/i.exec(q);
|
||||
const m = AT_QUERY_RE.exec(this.query());
|
||||
if (!m) return [] as { id: ProviderId; label: string }[];
|
||||
const term = (m[2] || '').toLowerCase();
|
||||
const opts = PROVIDERS.filter(p =>
|
||||
p.shortLabel.toLowerCase().includes(term) || p.displayName.toLowerCase().includes(term)
|
||||
).map(p => ({ id: p.id, label: p.displayName }));
|
||||
this.atOpen.set(opts.length > 0);
|
||||
if (this.atIndex() >= opts.length) this.atIndex.set(0);
|
||||
return opts as any;
|
||||
return PROVIDERS
|
||||
.filter(p => p.shortLabel.toLowerCase().includes(term) || p.displayName.toLowerCase().includes(term))
|
||||
.map(p => ({ id: p.id, label: p.displayName }));
|
||||
});
|
||||
// Quick menu modal (Ctrl/Cmd+K)
|
||||
quickOpen = signal(false);
|
||||
recentSearches = signal<SearchHistoryItem[]>([]);
|
||||
|
||||
// Element to restore focus to when a modal closes (a11y)
|
||||
private lastFocused: HTMLElement | null = null;
|
||||
|
||||
readonly chips = PROVIDERS.map(p => ({ id: p.id, label: p.shortLabel, colorClass: p.colorClass }));
|
||||
|
||||
// Derived state
|
||||
@@ -100,39 +104,68 @@ export class SearchBoxComponent {
|
||||
hydratedFromService = true;
|
||||
});
|
||||
|
||||
// Apply default providers from user preferences if none explicitly set
|
||||
// Apply default providers from user preferences if none explicitly set (Step 9 client hook)
|
||||
effect(() => {
|
||||
const prefs: UserPreferences | null = this.users.preferences();
|
||||
if (!prefs) return;
|
||||
const list = (prefs as any).defaultProviders as string[] | undefined;
|
||||
if (Array.isArray(list) && list.length > 0) {
|
||||
// Keep only known provider ids
|
||||
const valid = list.filter(id => PROVIDERS.some(p => p.id === (id as ProviderId))) as ProviderId[];
|
||||
if (valid.length === 0) return;
|
||||
// Only apply if current mode is 'all' and nothing selected
|
||||
if (this.providerMode() === 'all' && this.selected().length === 0) {
|
||||
// Cast to ProviderId[] while trusting server-provided list
|
||||
this.providerMode.set('custom');
|
||||
this.selected.set(list as any);
|
||||
this.selected.set(valid);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Focus the first focusable element of the quick menu dialog when it opens (a11y)
|
||||
effect(() => {
|
||||
if (this.quickOpen()) {
|
||||
setTimeout(() => {
|
||||
try {
|
||||
const root = this.quickDialogRef?.nativeElement;
|
||||
const first = root?.querySelector<HTMLElement>('button');
|
||||
(first || root)?.focus();
|
||||
} catch {}
|
||||
}, 0);
|
||||
}
|
||||
});
|
||||
|
||||
// Lock body scroll when a modal is open (a11y UX)
|
||||
effect(() => {
|
||||
const open = this.quickOpen() || this.pickerOpen();
|
||||
try {
|
||||
document.body.style.overflow = open ? 'hidden' : '';
|
||||
} catch {}
|
||||
});
|
||||
}
|
||||
|
||||
// Update query and handle inline '@provider' prefix to open filtered picker
|
||||
// Update query and refresh inline '@provider' autocomplete state
|
||||
queryUpdate(value: string) {
|
||||
try { console.log('[SearchBox] queryUpdate', { value }); } catch {}
|
||||
this.query.set(value);
|
||||
// Keep SearchService in sync with current text to avoid stale overwrite
|
||||
try { this.search.q$.next(value); } catch {}
|
||||
this._qChanges.next(value);
|
||||
const m = /(^|\s)@([a-z]{1,12})$/i.exec(value);
|
||||
const hasOptions = this.atOptions().length > 0;
|
||||
this.atOpen.set(AT_QUERY_RE.test(value) && hasOptions);
|
||||
if (this.atIndex() >= this.atOptions().length) this.atIndex.set(0);
|
||||
}
|
||||
|
||||
// Pick an option from the inline '@' popover: strip the '@term' and toggle the chip
|
||||
applyAtOption(id: ProviderId) {
|
||||
const q = this.query();
|
||||
const m = AT_QUERY_RE.exec(q);
|
||||
if (m) {
|
||||
const term = m[2] || '';
|
||||
this.pickerOpen.set(true);
|
||||
const lower = term.toLowerCase();
|
||||
const matched = PROVIDERS.filter(p => p.shortLabel.toLowerCase().startsWith(lower) || p.displayName.toLowerCase().includes(lower)).map(p => p.id);
|
||||
if (matched.length === 1) {
|
||||
this.toggleChip(matched[0]);
|
||||
}
|
||||
const start = (m.index || 0) + (m[1] || '').length;
|
||||
this.queryUpdate(q.slice(0, start).trimEnd());
|
||||
}
|
||||
this.atOpen.set(false);
|
||||
this.toggleChip(id);
|
||||
this.telemetry.track('at_autocomplete_use', { providers: [id] });
|
||||
try { this.qInputRef?.nativeElement?.focus(); } catch {}
|
||||
}
|
||||
|
||||
// Toggle All chip
|
||||
@@ -152,43 +185,37 @@ export class SearchBoxComponent {
|
||||
if (this.providerMode() === 'all') this.providerMode.set('custom');
|
||||
const cur = new Set(this.selected());
|
||||
if (cur.has(id)) cur.delete(id); else cur.add(id);
|
||||
const next = Array.from(cur);
|
||||
this.selected.set(next);
|
||||
if (next.length === 0) {
|
||||
// Empty selection warning UX could be handled by parent; keep custom mode
|
||||
}
|
||||
this.selected.set(Array.from(cur));
|
||||
}
|
||||
|
||||
// Keyboard shortcuts: Alt+1..6 map to chips by order
|
||||
// Single keyboard handler for the whole form (input events bubble here)
|
||||
handleKeydown(ev: KeyboardEvent) {
|
||||
if (ev.key === '@') {
|
||||
ev.preventDefault();
|
||||
this.pickerOpen.set(true);
|
||||
return;
|
||||
}
|
||||
// Inline @autocomplete navigation
|
||||
// Inline @autocomplete navigation (highest priority)
|
||||
if (this.atOpen()) {
|
||||
const opts = this.atOptions();
|
||||
if (ev.key === 'ArrowDown') { ev.preventDefault(); this.atIndex.set(Math.min(opts.length - 1, this.atIndex() + 1)); return; }
|
||||
if (ev.key === 'ArrowUp') { ev.preventDefault(); this.atIndex.set(Math.max(0, this.atIndex() - 1)); return; }
|
||||
if (ev.key === 'Enter') {
|
||||
// Prevent the sibling (keydown.enter) submit handler from also firing
|
||||
ev.stopImmediatePropagation();
|
||||
ev.preventDefault();
|
||||
const pick = opts[this.atIndex()];
|
||||
if (pick) this.toggleChip(pick.id);
|
||||
this.atOpen.set(false);
|
||||
if (pick) this.applyAtOption(pick.id);
|
||||
return;
|
||||
}
|
||||
if (ev.key === 'Escape') { this.atOpen.set(false); }
|
||||
if (ev.key === 'Escape') { this.atOpen.set(false); return; }
|
||||
}
|
||||
// Alt+1..6 map to chips by registry order
|
||||
if (ev.altKey) {
|
||||
const map: Record<string, ProviderId | undefined> = { '1': 'yt', '2': 'dm', '3': 'tw', '4': 'pt', '5': 'od', '6': 'ru' };
|
||||
const pid = map[ev.key];
|
||||
if (pid) { ev.preventDefault(); this.toggleChip(pid); }
|
||||
return;
|
||||
}
|
||||
if (ev.key === 'Escape') {
|
||||
// Escape clears transient UI; if nothing is open, also clear the query
|
||||
// Escape closes transient UI; if nothing is open, clear the query
|
||||
const hadAny = this.pickerOpen() || this.suggestionsOpen() || this.atOpen() || this.quickOpen();
|
||||
this.pickerOpen.set(false);
|
||||
this.closePicker();
|
||||
this.suggestionsOpen.set(false);
|
||||
this.atOpen.set(false);
|
||||
if (!hadAny && (this.query() || '').length > 0) {
|
||||
@@ -197,19 +224,13 @@ export class SearchBoxComponent {
|
||||
}
|
||||
}
|
||||
|
||||
// Global shortcut Ctrl/Cmd+K to open the Providers picker
|
||||
// Global shortcut Ctrl/Cmd+K to open the Providers quick menu
|
||||
@HostListener('document:keydown', ['$event'])
|
||||
onGlobalKey(ev: KeyboardEvent) {
|
||||
if ((ev.key.toLowerCase() === 'k') && (ev.ctrlKey || ev.metaKey)) {
|
||||
ev.preventDefault();
|
||||
this.quickOpen.set(true);
|
||||
// Load last 15 searches
|
||||
try { this.history.getSearchHistory(15).subscribe({ next: (items) => this.recentSearches.set(items || []), error: () => {} }); } catch {}
|
||||
}
|
||||
// Also support Ctrl+Shift+K explicitly
|
||||
if ((ev.key.toLowerCase() === 'k') && ev.ctrlKey && ev.shiftKey) {
|
||||
ev.preventDefault();
|
||||
this.quickOpen.set(true);
|
||||
this.openQuickMenu();
|
||||
return;
|
||||
}
|
||||
if (ev.key === 'Escape') {
|
||||
if (this.quickOpen()) {
|
||||
@@ -234,35 +255,68 @@ export class SearchBoxComponent {
|
||||
try {
|
||||
const raw = this.qInputRef.nativeElement.value || '';
|
||||
q = String(raw).trim();
|
||||
console.log('[SearchBox] fallback native value used', { q, qLength: q.length });
|
||||
} catch {}
|
||||
}
|
||||
// Debug: minimal console trace to confirm handler runs
|
||||
try {
|
||||
console.log('[SearchBox] onSubmit called', {
|
||||
q,
|
||||
qLength: q.length,
|
||||
hasParentHandler: !!this.submitted.observers.length
|
||||
});
|
||||
} catch(e) {
|
||||
console.error('Error in SearchBox onSubmit:', e);
|
||||
}
|
||||
|
||||
if (q.length < 2) {
|
||||
console.log('[SearchBox] Query too short, not submitting');
|
||||
return;
|
||||
}
|
||||
|
||||
if (q.length < 2) return;
|
||||
|
||||
const prov = this.providersForRequest();
|
||||
console.log('[SearchBox] Emitting submit event', { q, providers: prov });
|
||||
|
||||
this.telemetry.track('search_submit', { providers: Array.isArray(prov) ? prov : ['all'], queryLength: q.length });
|
||||
// Emit payload to parent (Header/Search page)
|
||||
this.submitted.emit({ q, providers: prov });
|
||||
console.log('[SearchBox] Submit event emitted');
|
||||
}
|
||||
|
||||
openPicker() { this.pickerOpen.set(true); }
|
||||
closePicker() { this.pickerOpen.set(false); }
|
||||
openPicker() {
|
||||
this.saveFocus();
|
||||
this.pickerOpen.set(true);
|
||||
this.telemetry.track('provider_picker_open');
|
||||
}
|
||||
closePicker() {
|
||||
if (!this.pickerOpen()) return;
|
||||
this.pickerOpen.set(false);
|
||||
this.restoreFocus();
|
||||
}
|
||||
|
||||
// Quick menu open/close with focus management (a11y)
|
||||
openQuickMenu() {
|
||||
this.saveFocus();
|
||||
this.quickOpen.set(true);
|
||||
this.telemetry.track('quick_menu_open');
|
||||
// Load last 15 searches
|
||||
try { this.history.getSearchHistory(15).subscribe({ next: (items) => this.recentSearches.set(items || []), error: () => {} }); } catch {}
|
||||
}
|
||||
closeQuickMenu() {
|
||||
if (!this.quickOpen()) return;
|
||||
this.quickOpen.set(false);
|
||||
this.restoreFocus();
|
||||
}
|
||||
private saveFocus() {
|
||||
try { this.lastFocused = document.activeElement as HTMLElement | null; } catch { this.lastFocused = null; }
|
||||
}
|
||||
private restoreFocus() {
|
||||
try { this.lastFocused?.focus(); } catch {}
|
||||
this.lastFocused = null;
|
||||
}
|
||||
|
||||
// Generic focus trap used by the quick menu dialog (Tab / Shift+Tab)
|
||||
trapFocus(ev: KeyboardEvent, root: HTMLElement | null | undefined) {
|
||||
if (ev.key !== 'Tab' || !root) return;
|
||||
const focusables = Array.from(root.querySelectorAll<HTMLElement>('button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'))
|
||||
.filter(el => !el.hasAttribute('disabled'));
|
||||
if (focusables.length === 0) return;
|
||||
const first = focusables[0];
|
||||
const last = focusables[focusables.length - 1];
|
||||
const active = document.activeElement as HTMLElement | null;
|
||||
if (!ev.shiftKey && active === last) {
|
||||
ev.preventDefault();
|
||||
first.focus();
|
||||
} else if (ev.shiftKey && (active === first || active === root)) {
|
||||
ev.preventDefault();
|
||||
last.focus();
|
||||
}
|
||||
}
|
||||
onQuickMenuKeydown(ev: KeyboardEvent) {
|
||||
this.trapFocus(ev, this.quickDialogRef?.nativeElement);
|
||||
}
|
||||
|
||||
// Accept both legacy payload (array|'all') and new object payload; use any to satisfy strict template binder
|
||||
applyPicker(payload: any) {
|
||||
@@ -272,20 +326,20 @@ export class SearchBoxComponent {
|
||||
if (prov === 'all') {
|
||||
this.providerMode.set('all');
|
||||
this.selected.set([]);
|
||||
} else {
|
||||
} else if (Array.isArray(prov)) {
|
||||
this.providerMode.set('custom');
|
||||
this.selected.set(prov);
|
||||
}
|
||||
// Persist default selection if requested and user is logged-in
|
||||
// Persist default selection if requested and user is logged-in (Step 9)
|
||||
if (rememberDefault) {
|
||||
const body: any = { defaultProviders: (prov === 'all') ? [] : prov };
|
||||
try { this.users.updatePreferences(body).subscribe({ next: () => {}, error: () => {} }); } catch {}
|
||||
}
|
||||
this.telemetry.track('provider_apply', { providers: Array.isArray(prov) ? prov : ['all'], rememberDefault });
|
||||
this.closePicker();
|
||||
}
|
||||
|
||||
// Quick menu close/apply (re-using same payload shape as picker)
|
||||
closeQuickMenu() { this.quickOpen.set(false); }
|
||||
applyQuickMenu(payload: any) {
|
||||
this.applyPicker(payload);
|
||||
this.closeQuickMenu();
|
||||
@@ -299,14 +353,6 @@ export class SearchBoxComponent {
|
||||
this.selected.set(Array.from(set));
|
||||
}
|
||||
|
||||
// Lock body scroll when a modal is open (a11y UX)
|
||||
private _scrollLock = effect(() => {
|
||||
const open = this.quickOpen() || this.pickerOpen();
|
||||
try {
|
||||
document.body.style.overflow = open ? 'hidden' : '';
|
||||
} catch {}
|
||||
});
|
||||
|
||||
// Internal subject for debouncing query changes
|
||||
private _qChanges = new Subject<string>();
|
||||
}
|
||||
|
||||
@@ -1,28 +1,43 @@
|
||||
<div class="mt-3 space-y-4">
|
||||
<!-- Grouped suggestion sections by provider -->
|
||||
<div *ngFor="let entry of (groups | keyvalue)" class="border border-slate-700 rounded-lg overflow-hidden">
|
||||
<div class="px-3 py-2 bg-slate-800 text-slate-200 text-sm font-semibold">
|
||||
{{ entry.key | uppercase }}
|
||||
</div>
|
||||
<ul class="divide-y divide-slate-700">
|
||||
<li *ngFor="let s of (entry.value | slice:0:3); let i = index" class="p-3 flex items-start gap-3">
|
||||
<img *ngIf="s.thumbnail" [src]="s.thumbnail" class="h-12 w-20 object-cover rounded" alt=""/>
|
||||
<div class="min-w-0">
|
||||
<div class="text-slate-100 truncate">{{ s.title }}</div>
|
||||
<div class="text-slate-400 text-xs">{{ s.uploaderName }}</div>
|
||||
<div class="mt-3 space-y-4" role="region" aria-label="Suggestions par fournisseur">
|
||||
@for (s of sections(); track s.id) {
|
||||
<section class="border border-slate-700 rounded-lg overflow-hidden" [attr.aria-label]="'Résultats ' + s.name">
|
||||
<header class="px-3 py-2 bg-slate-800 text-slate-200 text-sm font-semibold flex items-center gap-2">
|
||||
<span [class]="s.colorClass">{{ s.shortLabel }}</span>
|
||||
<span>{{ s.name }}</span>
|
||||
</header>
|
||||
|
||||
@if (!s.supported) {
|
||||
<!-- Explicit unsupported-provider row (Step 6) -->
|
||||
<div class="p-3 text-slate-500 text-sm italic flex items-center gap-2">
|
||||
<span aria-hidden="true">⚠</span>
|
||||
{{ s.name }} ne supporte pas la recherche pour le moment.
|
||||
</div>
|
||||
</li>
|
||||
<li *ngIf="(entry.value?.length || 0) === 0" class="p-3 text-slate-500 text-sm">
|
||||
Aucun résultat pour ce fournisseur.
|
||||
</li>
|
||||
</ul>
|
||||
<div class="px-3 py-2 bg-slate-900 text-right">
|
||||
<a class="text-xs text-red-300 hover:text-red-200"
|
||||
[routerLink]="['/search']"
|
||||
[queryParams]="{ q: query, providers: entry.key }"
|
||||
[attr.aria-label]="'View all results on ' + (entry.key | uppercase)">
|
||||
View all on {{ entry.key | uppercase }}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
} @else {
|
||||
<ul class="divide-y divide-slate-700">
|
||||
@for (item of s.items | slice:0:3; track item.id) {
|
||||
<li class="p-3 flex items-start gap-3">
|
||||
@if (item.thumbnail) {
|
||||
<img [src]="item.thumbnail" class="h-12 w-20 object-cover rounded" alt=""/>
|
||||
}
|
||||
<div class="min-w-0">
|
||||
<div class="text-slate-100 truncate">{{ item.title }}</div>
|
||||
<div class="text-slate-400 text-xs">{{ item.uploaderName }}</div>
|
||||
</div>
|
||||
</li>
|
||||
}
|
||||
@empty {
|
||||
<li class="p-3 text-slate-500 text-sm">Aucun résultat pour ce fournisseur.</li>
|
||||
}
|
||||
</ul>
|
||||
<div class="px-3 py-2 bg-slate-900 text-right">
|
||||
<a class="text-xs text-red-300 hover:text-red-200"
|
||||
[routerLink]="['/search']"
|
||||
[queryParams]="{ q: query, providers: s.deepLinkProviders }"
|
||||
[attr.aria-label]="'Voir tous les résultats sur ' + s.name">
|
||||
View all on {{ s.name }}
|
||||
</a>
|
||||
</div>
|
||||
}
|
||||
</section>
|
||||
}
|
||||
</div>
|
||||
|
||||
@@ -1,9 +1,19 @@
|
||||
import { Component, Input } from '@angular/core';
|
||||
import { Component, Input, computed } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { RouterLink } from '@angular/router';
|
||||
import type { ProviderId } from '../../app/core/providers/provider-registry';
|
||||
import { PROVIDERS, PROVIDER_MAP, type ProviderId } from '../../app/core/providers/provider-registry';
|
||||
import type { SuggestionItem } from '../../app/search/search.service';
|
||||
|
||||
interface SuggestionSection {
|
||||
id: ProviderId;
|
||||
name: string;
|
||||
shortLabel: string;
|
||||
colorClass: string;
|
||||
items: SuggestionItem[];
|
||||
supported: boolean;
|
||||
deepLinkProviders: string;
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'app-search-suggestions',
|
||||
standalone: true,
|
||||
@@ -15,4 +25,26 @@ export class SearchSuggestionsComponent {
|
||||
yt: [], dm: [], tw: [], pt: [], od: [], ru: []
|
||||
} as any;
|
||||
@Input() query: string = '';
|
||||
// Providers that were actually requested; drives which sections render and
|
||||
// which unsupported providers get an explicit row (Step 6)
|
||||
@Input() activeProviders: ProviderId[] | 'all' = 'all';
|
||||
|
||||
readonly sections = computed<SuggestionSection[]>(() => {
|
||||
const groups = this.groups || ({} as Record<ProviderId, SuggestionItem[]>);
|
||||
const active = this.activeProviders === 'all'
|
||||
? PROVIDERS.map(p => p.id)
|
||||
: (this.activeProviders || []).filter(id => PROVIDER_MAP.has(id));
|
||||
return active.map(id => {
|
||||
const spec = PROVIDER_MAP.get(id)!;
|
||||
return {
|
||||
id,
|
||||
name: spec.displayName,
|
||||
shortLabel: spec.shortLabel,
|
||||
colorClass: spec.colorClass,
|
||||
items: Array.isArray(groups[id]) ? groups[id] : [],
|
||||
supported: !!spec.supports?.search,
|
||||
deepLinkProviders: id,
|
||||
} as SuggestionSection;
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -11,7 +11,8 @@ import { TranslatePipe } from '../../pipes/translate.pipe';
|
||||
import { SearchResultGridComponent } from 'src/app/shared/components/search-result-grid/search-result-grid.component';
|
||||
import { VideoItem } from 'src/app/shared/models/video-item.model';
|
||||
import { SearchService, type SearchResponse } from '../../app/search/search.service';
|
||||
import type { ProviderId } from '../../app/core/providers/provider-registry';
|
||||
import { UserService, type UserPreferences } from '../../services/user.service';
|
||||
import { PROVIDERS, type ProviderId } from '../../app/core/providers/provider-registry';
|
||||
import { Observable, Subscription } from 'rxjs';
|
||||
|
||||
@Component({
|
||||
@@ -29,6 +30,7 @@ export class SearchComponent implements AfterViewInit {
|
||||
private history = inject(HistoryService);
|
||||
private title = inject(Title);
|
||||
private unified = inject(SearchService);
|
||||
private users = inject(UserService);
|
||||
|
||||
q = signal<string>('');
|
||||
loading = signal<boolean>(false);
|
||||
@@ -36,6 +38,8 @@ export class SearchComponent implements AfterViewInit {
|
||||
providerParam = signal<Provider | null>(null);
|
||||
themeParam = signal<string | null>(null);
|
||||
providersListParam = signal<ProviderId[] | 'all' | null>(null);
|
||||
// Step 7: default providers loaded from user preferences (server), applied when URL has none
|
||||
private defaultProvidersPref = signal<ProviderId[] | null>(null);
|
||||
pageParam = signal<number>(1);
|
||||
sortParam = signal<'relevance' | 'date' | 'views'>('relevance');
|
||||
|
||||
@@ -258,6 +262,20 @@ export class SearchComponent implements AfterViewInit {
|
||||
this.title.setTitle(this.pageHeading());
|
||||
});
|
||||
|
||||
// Step 7: load default providers preference once (server) for deep-link fallback
|
||||
try {
|
||||
this.users.loadPreferences().subscribe({
|
||||
next: (prefs: UserPreferences) => {
|
||||
const list = (prefs as any)?.defaultProviders;
|
||||
if (Array.isArray(list)) {
|
||||
const valid = list.filter((id: string) => PROVIDERS.some(p => p.id === id)) as ProviderId[];
|
||||
if (valid.length > 0) this.defaultProvidersPref.set(valid);
|
||||
}
|
||||
},
|
||||
error: () => {} // anonymous users: no preference
|
||||
});
|
||||
} catch {}
|
||||
|
||||
// Subscribe once to unified multi-provider results and reflect to UI
|
||||
const subscription: Subscription = this.unified.request$
|
||||
.pipe(takeUntilDestroyed())
|
||||
@@ -330,8 +348,8 @@ export class SearchComponent implements AfterViewInit {
|
||||
const provider = prov || this.instances.selectedProvider();
|
||||
this.filterTag.set(provider === 'twitch' ? 'twitch_all' : 'all');
|
||||
|
||||
// Use unified search in all cases
|
||||
const providersToUse = providersList || [provider as ProviderId];
|
||||
// Use unified search in all cases (URL providers > user default preference > single provider)
|
||||
const providersToUse = providersList || this.defaultProvidersPref() || [provider as ProviderId];
|
||||
this.unified.setQuery(q);
|
||||
this.unified.setProviders(providersToUse);
|
||||
|
||||
|
||||
@@ -1,10 +1,22 @@
|
||||
<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)">
|
||||
<div class="w-full flex flex-col items-center gap-3 p-2 sm:p-4">
|
||||
<!-- Sélecteur de fournisseur : la page n'est plus bloquée sur un seul provider -->
|
||||
<div class="flex flex-wrap items-center justify-center gap-2" role="tablist" aria-label="Fournisseur des Shorts">
|
||||
<button *ngFor="let p of providersList()"
|
||||
(click)="selectProvider(p.id)"
|
||||
[attr.aria-pressed]="provider() === p.id"
|
||||
class="px-3 py-1.5 rounded-full text-sm border transition-colors"
|
||||
[class]="provider() === p.id ? 'bg-red-600 border-red-500 text-white' : 'bg-slate-800 border-slate-700 text-slate-300 hover:bg-slate-700'">
|
||||
{{ p.label }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="h-[calc(100vh-8rem)] w-full flex items-center justify-center"
|
||||
(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;">
|
||||
style="height: calc(100vh - 10rem); max-height: calc(100vh - 10rem); 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"
|
||||
@@ -12,8 +24,9 @@
|
||||
} @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 class="w-full h-full flex flex-col gap-3 items-center justify-center text-slate-300 p-6 text-center">
|
||||
<span>{{ error() }}</span>
|
||||
<button (click)="retry()" class="px-4 py-2 rounded bg-slate-700 hover:bg-slate-600 text-white text-sm">Réessayer</button>
|
||||
</div>
|
||||
}
|
||||
|
||||
@@ -22,10 +35,12 @@
|
||||
<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="flex-1 min-w-0">
|
||||
<div class="font-semibold line-clamp-1">{{ v.title }}</div>
|
||||
<div class="opacity-80 line-clamp-1">@{{ v.uploaderName }}</div>
|
||||
</div>
|
||||
<a *ngIf="watchLink()" [routerLink]="watchLink()" [queryParams]="watchQueryParams()"
|
||||
class="shrink-0 px-2 py-1 rounded bg-white/15 hover:bg-white/25 text-xs">Ouvrir</a>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
@@ -43,10 +58,19 @@
|
||||
<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>
|
||||
|
||||
<!-- Position indicator -->
|
||||
@if (items().length > 0) {
|
||||
<div class="absolute -left-14 top-6 hidden md:flex items-center justify-center px-2 py-1 rounded-full bg-slate-800/70 text-white text-xs ring-1 ring-slate-700/60">
|
||||
{{ index() + 1 }} / {{ items().length }}
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- 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>
|
||||
<span *ngIf="items().length > 0" class="text-slate-400 text-sm">{{ index() + 1 }} / {{ items().length }}</span>
|
||||
<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>
|
||||
</div>
|
||||
|
||||
@@ -1,16 +1,20 @@
|
||||
import { ChangeDetectionStrategy, Component, computed, inject, signal, HostListener } from '@angular/core';
|
||||
import { ChangeDetectionStrategy, Component, computed, effect, inject, signal, HostListener } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { RouterLink } from '@angular/router';
|
||||
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 { InstanceService, Provider } from '../../services/instance.service';
|
||||
import { Video } from '../../models/video.model';
|
||||
import { isShortVideo } from '../../app/shared/utils/video-kind';
|
||||
|
||||
const SHORTS_QUERY = 'shorts';
|
||||
|
||||
@Component({
|
||||
selector: 'app-watch-short',
|
||||
standalone: true,
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
imports: [CommonModule],
|
||||
imports: [CommonModule, RouterLink],
|
||||
templateUrl: './watch-short.component.html'
|
||||
})
|
||||
export class WatchShortComponent {
|
||||
@@ -29,8 +33,10 @@ export class WatchShortComponent {
|
||||
private wheelAccum = 0;
|
||||
private lastSwipeY: number | null = null;
|
||||
private lastScrollTs = 0;
|
||||
private lastProvider: string | null = null;
|
||||
|
||||
readonly provider = computed(() => this.instances.selectedProvider());
|
||||
readonly providersList = computed(() => this.instances.providers());
|
||||
readonly current = computed<Video | null>(() => {
|
||||
const list = this.items();
|
||||
const i = this.index();
|
||||
@@ -38,7 +44,7 @@ export class WatchShortComponent {
|
||||
});
|
||||
|
||||
readonly canPrev = computed(() => this.index() > 0);
|
||||
readonly canNext = computed(() => this.index() < this.items().length - 1);
|
||||
readonly canNext = computed(() => this.index() < this.items().length - 1 || !!this.nextCursor());
|
||||
|
||||
readonly embedUrl = computed<SafeResourceUrl | null>(() => {
|
||||
const v = this.current();
|
||||
@@ -47,8 +53,18 @@ export class WatchShortComponent {
|
||||
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}`;
|
||||
// Même base que la page /watch : youtube-nocookie + origin/enablejsapi.
|
||||
// L'embed youtube.com sans origin provoque l'erreur 153
|
||||
// ("Video configuration error") sur certaines vidéos.
|
||||
let origin = '';
|
||||
let ref = '';
|
||||
try {
|
||||
origin = window.location.origin || '';
|
||||
ref = window.location.href || '';
|
||||
} catch {}
|
||||
let u = `https://www.youtube-nocookie.com/embed/${encodeURIComponent(v.videoId)}?autoplay=1&mute=1&rel=0&modestbranding=1&playsinline=1&controls=1&enablejsapi=1`;
|
||||
if (origin) u += `&origin=${encodeURIComponent(origin)}`;
|
||||
if (ref) u += `&widget_referrer=${encodeURIComponent(ref)}`;
|
||||
return this.sanitizer.bypassSecurityTrustResourceUrl(u);
|
||||
}
|
||||
if (p === 'dailymotion' && v.videoId) {
|
||||
@@ -88,23 +104,35 @@ export class WatchShortComponent {
|
||||
}
|
||||
}
|
||||
if (p === 'rumble' && v.videoId) {
|
||||
const u = `https://rumble.com/embed/${encodeURIComponent(v.videoId)}/?autoplay=2&muted=1`;
|
||||
// L'embed officiel attend l'ID court (vXXXX) ; le videoId peut contenir le slug complet.
|
||||
const m = /^v[0-9A-Za-z]+/.exec(String(v.videoId));
|
||||
const id = m ? m[0] : v.videoId;
|
||||
const u = `https://rumble.com/embed/${encodeURIComponent(id)}/?autoplay=2&muted=1`;
|
||||
return this.sanitizer.bypassSecurityTrustResourceUrl(u);
|
||||
}
|
||||
} catch {}
|
||||
return null;
|
||||
});
|
||||
|
||||
/** Lien "voir en page Watch" pour le short courant (secours si l'embed échoue). */
|
||||
readonly watchLink = computed<any[] | null>(() => {
|
||||
const v = this.current();
|
||||
if (!v?.videoId) return null;
|
||||
return ['/watch', v.videoId];
|
||||
});
|
||||
|
||||
readonly watchQueryParams = computed<Record<string, any> | null>(() => {
|
||||
const v = this.current();
|
||||
const p = this.provider();
|
||||
if (!v) return null;
|
||||
const qp: any = { p };
|
||||
if (p === 'twitch' && v.type === 'channel' && v.videoId) qp.channel = v.videoId;
|
||||
return qp;
|
||||
});
|
||||
|
||||
// Called when a query returns no usable items; falls back to Trending
|
||||
private afterNoResults(): void {
|
||||
// For Shorts page, do NOT fallback to trending; display an appropriate message instead.
|
||||
const p = this.provider();
|
||||
const unsupported = this.isProviderUnsupportedForShorts(p);
|
||||
if (unsupported) {
|
||||
this.error.set('Ce fournisseur ne supporte pas les shorts');
|
||||
} else {
|
||||
this.error.set('Aucun Short trouvé pour ce fournisseur.');
|
||||
}
|
||||
this.error.set('Aucun Short trouvé pour ce fournisseur. Essayez un autre fournisseur ou réessayez.');
|
||||
}
|
||||
|
||||
// Filter YouTube results to real Shorts using the Videos API to get durations.
|
||||
@@ -123,13 +151,32 @@ export class WatchShortComponent {
|
||||
}
|
||||
|
||||
constructor() {
|
||||
// Initialize feed for Shorts according to current provider
|
||||
// Recharger le flux quand le provider global change (ex. sélecteur du header).
|
||||
effect(() => {
|
||||
const p = this.provider();
|
||||
if (this.lastProvider !== p) {
|
||||
this.lastProvider = p;
|
||||
this.loadFeed();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
selectProvider(p: Provider): void {
|
||||
if (p === this.provider()) return;
|
||||
this.instances.setSelectedProvider(p);
|
||||
// L'effect ci-dessus déclenche loadFeed().
|
||||
}
|
||||
|
||||
retry(): void {
|
||||
this.loadFeed();
|
||||
}
|
||||
|
||||
loadFeed(): void {
|
||||
this.loading.set(true);
|
||||
this.error.set(null);
|
||||
this.items.set([]);
|
||||
this.index.set(0);
|
||||
this.nextCursor.set(null);
|
||||
const ready = this.instances.getProviderReadiness(this.provider());
|
||||
if (!ready.ready) {
|
||||
this.loading.set(false);
|
||||
@@ -137,10 +184,10 @@ export class WatchShortComponent {
|
||||
return;
|
||||
}
|
||||
const provider = this.provider();
|
||||
// Use provider-specific source for Shorts
|
||||
// Twitch : les Clips sont l'équivalent Shorts. La recherche Helix rejette
|
||||
// les requêtes vides → on part des top streams (getTwitchClipsFeed).
|
||||
if (provider === 'twitch') {
|
||||
// Use Twitch Clips as Shorts
|
||||
this.api.searchTwitchClipsPage('')?.subscribe({
|
||||
this.api.getTwitchClipsFeed()?.subscribe({
|
||||
next: (res: { items: Video[]; nextCursor?: string | null }) => {
|
||||
const raw: Video[] = (res.items || []).filter((v: Video) => !!v.videoId);
|
||||
const list: Video[] = raw.filter((v: Video) => this.isShort(v));
|
||||
@@ -157,72 +204,76 @@ export class WatchShortComponent {
|
||||
});
|
||||
return;
|
||||
}
|
||||
// Default: search for shorts keywords via generic search
|
||||
this.api.searchVideosPage('shorts OR #shorts').subscribe({
|
||||
// Default: search for shorts keywords via generic search, then filter.
|
||||
// Si la recherche ne donne aucun Short, repli sur le trending filtré
|
||||
// (utile pour Rumble/Odysee/PeerTube/Dailymotion dont la recherche
|
||||
// plein-texte "shorts" est approximative).
|
||||
this.api.searchVideosPage(SHORTS_QUERY).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.filter(v => this.isShort(v)));
|
||||
this.index.set(0);
|
||||
if (list.length === 0) this.afterNoResults();
|
||||
else this.error.set(null);
|
||||
this.loading.set(false);
|
||||
});
|
||||
const shorts = list.filter(v => this.isShort(v));
|
||||
if (shorts.length > 0) {
|
||||
this.items.set(shorts);
|
||||
this.index.set(0);
|
||||
this.error.set(null);
|
||||
this.loading.set(false);
|
||||
} else {
|
||||
this.loadTrendingFallback();
|
||||
}
|
||||
}).catch(() => this.loadTrendingFallback());
|
||||
return;
|
||||
}
|
||||
// Non-YouTube: apply provider-specific minimal filtering
|
||||
const list = raw.filter(v => this.isShort(v));
|
||||
this.items.set(list);
|
||||
this.index.set(0);
|
||||
if (list.length === 0) this.afterNoResults(); else this.error.set(null);
|
||||
this.loading.set(false);
|
||||
if (list.length > 0) {
|
||||
this.items.set(list);
|
||||
this.index.set(0);
|
||||
this.error.set(null);
|
||||
this.loading.set(false);
|
||||
} else {
|
||||
this.loadTrendingFallback();
|
||||
}
|
||||
},
|
||||
error: () => {
|
||||
this.error.set('Failed to load Shorts.');
|
||||
this.loadTrendingFallback();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/** Repli : trending du provider filtré sur les Shorts (jamais de contenu long). */
|
||||
private loadTrendingFallback(): void {
|
||||
this.api.getTrendingPage(null).subscribe({
|
||||
next: (res) => {
|
||||
const raw = (res.items || []).filter(v => !!v.videoId);
|
||||
const p = this.provider();
|
||||
const apply = (list: Video[]) => {
|
||||
this.items.set(list);
|
||||
this.index.set(0);
|
||||
this.nextCursor.set(null);
|
||||
if (list.length === 0) this.afterNoResults(); else this.error.set(null);
|
||||
this.loading.set(false);
|
||||
};
|
||||
if (p === 'youtube' && raw.length) {
|
||||
this.filterYouTubeShorts(raw).then(list => apply(list.filter(v => this.isShort(v)))).catch(() => apply([]));
|
||||
} else {
|
||||
apply(raw.filter(v => this.isShort(v)));
|
||||
}
|
||||
},
|
||||
error: () => {
|
||||
this.afterNoResults();
|
||||
this.loading.set(false);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Trending fallback removed for Shorts page to avoid showing non-short content
|
||||
private loadFallbackTrending(_hint?: string): void { this.error.set('Ce fournisseur ne supporte pas les shorts'); this.loading.set(false); }
|
||||
|
||||
// Generic Shorts check based on provider
|
||||
// Generic Shorts check — classification partagée (une durée 0/absente
|
||||
// ne qualifie JAMAIS un Short : les scrapers renvoient 0 quand inconnue).
|
||||
private isShort(v: Video): boolean {
|
||||
const p = (v as any).provider || this.provider();
|
||||
const url = (v as any).url || '';
|
||||
switch (p) {
|
||||
case 'youtube':
|
||||
return (v as any).isShort === true || (typeof url === 'string' && url.includes('/shorts/')) || ((v.duration ?? 0) > 0 && (v.duration ?? 0) <= 70);
|
||||
case 'dailymotion':
|
||||
return (v as any).isShort === true || (((v.duration ?? 0) <= 60) && ((v as any).isVertical === true));
|
||||
case 'twitch':
|
||||
return (v as any).kind === 'clip';
|
||||
case 'rumble':
|
||||
return (v as any).isShort === true;
|
||||
case 'odysee':
|
||||
// Odysee: use duration <= 60s as shorts indicator if native isShort unavailable
|
||||
return (v as any).isShort === true || ((v.duration ?? 0) <= 60);
|
||||
case 'peertube':
|
||||
return (v as any).isShort === true || (((v.duration ?? 0) <= 60) && ((v as any).isVertical === true));
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private isProviderUnsupportedForShorts(p: string | undefined | null): boolean {
|
||||
switch (p) {
|
||||
case 'rumble':
|
||||
return true; // Rumble requires native isShort indicator
|
||||
case 'twitch':
|
||||
case 'youtube':
|
||||
return false;
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
return isShortVideo({ ...(v as object), provider: p } as Parameters<typeof isShortVideo>[0]);
|
||||
}
|
||||
|
||||
private fetchNextPage(autoAdvance = false) {
|
||||
@@ -250,14 +301,14 @@ export class WatchShortComponent {
|
||||
};
|
||||
|
||||
if (provider === 'twitch') {
|
||||
this.api.searchTwitchClipsPage('', cursor || undefined)?.subscribe({
|
||||
this.api.getTwitchClipsFeed(cursor || undefined)?.subscribe({
|
||||
next: handle,
|
||||
error: () => { this.busyMore.set(false); }
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
this.api.searchVideosPage('shorts OR #shorts', cursor || undefined).subscribe({
|
||||
this.api.searchVideosPage(SHORTS_QUERY, cursor || undefined).subscribe({
|
||||
next: (res) => {
|
||||
handle(res);
|
||||
},
|
||||
@@ -268,7 +319,7 @@ export class WatchShortComponent {
|
||||
}
|
||||
|
||||
next(): void {
|
||||
if (this.canNext()) {
|
||||
if (this.canNext() && this.index() < this.items().length - 1) {
|
||||
this.index.update(i => i + 1);
|
||||
} else if (this.nextCursor()) {
|
||||
this.fetchNextPage(true);
|
||||
|
||||
@@ -53,6 +53,12 @@
|
||||
<span [class.hidden]="collapsed">{{ 'nav.liked' | t }}</span>
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a routerLink="/library/downloads" 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.downloads' | 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 3v10.17l3.59-3.58L17 11l-5 5-5-5 1.41-1.41L12 13.17V3h0zm-7 15h14v2H5v-2z"/></svg>
|
||||
<span [class.hidden]="collapsed">{{ 'nav.downloads' | t }}</span>
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
}
|
||||
|
||||
@@ -24,6 +24,25 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Filtre rapide Vidéos / Shorts -->
|
||||
<div class="flex items-center gap-2 mb-4" role="tablist" aria-label="Filtrer vidéos ou shorts">
|
||||
<button (click)="setContentFilter('all')" [attr.aria-pressed]="contentFilter() === 'all'"
|
||||
class="px-3 py-1.5 rounded-full text-sm border transition-colors"
|
||||
[class]="contentFilter() === 'all' ? 'bg-red-600 border-red-500 text-white' : 'bg-slate-800 border-slate-700 text-slate-300 hover:bg-slate-700'">
|
||||
Tout
|
||||
</button>
|
||||
<button (click)="setContentFilter('videos')" [attr.aria-pressed]="contentFilter() === 'videos'"
|
||||
class="px-3 py-1.5 rounded-full text-sm border transition-colors"
|
||||
[class]="contentFilter() === 'videos' ? 'bg-red-600 border-red-500 text-white' : 'bg-slate-800 border-slate-700 text-slate-300 hover:bg-slate-700'">
|
||||
Vidéos
|
||||
</button>
|
||||
<button (click)="setContentFilter('shorts')" [attr.aria-pressed]="contentFilter() === 'shorts'"
|
||||
class="px-3 py-1.5 rounded-full text-sm border transition-colors"
|
||||
[class]="contentFilter() === 'shorts' ? 'bg-red-600 border-red-500 text-white' : 'bg-slate-800 border-slate-700 text-slate-300 hover:bg-slate-700'">
|
||||
Shorts<span *ngIf="allVideos().length"> ({{ shortsCount() }})</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Filters -->
|
||||
<div class="bg-slate-800/50 p-4 rounded-xl border border-slate-700 mb-8">
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-5 gap-4">
|
||||
@@ -107,6 +126,10 @@
|
||||
<div class="absolute top-2 right-2">
|
||||
<app-provider-badge [provider]="provider()"></app-provider-badge>
|
||||
</div>
|
||||
<div class="absolute top-2 left-2 flex gap-1">
|
||||
<span *ngIf="kindOf(v) === 'short'" class="bg-red-600 text-white text-[11px] font-bold px-1.5 py-0.5 rounded">SHORT</span>
|
||||
<span *ngIf="kindOf(v) === 'live'" class="bg-purple-600 text-white text-[11px] font-bold px-1.5 py-0.5 rounded">LIVE</span>
|
||||
</div>
|
||||
<div *ngIf="v.duration && v.duration > 0" class="absolute bottom-2 right-2 bg-black/75 text-white text-xs px-2 py-1 rounded-md font-mono">
|
||||
{{ v.duration | duration }}
|
||||
</div>
|
||||
|
||||
@@ -10,7 +10,9 @@ 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 { map, of, switchMap } from 'rxjs';
|
||||
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'],
|
||||
@@ -141,18 +143,32 @@ export class ProviderThemePageComponent implements OnDestroy {
|
||||
const themeSlug = this.theme();
|
||||
const isLiveTheme = themeSlug === 'live';
|
||||
|
||||
let apiCall;
|
||||
let apiCall: Observable<PagedResult<Video>>;
|
||||
if (provider === 'twitch') {
|
||||
if (isLiveTheme) {
|
||||
apiCall = this.api.searchTwitchChannelsPage(query || 'live', cursor || undefined);
|
||||
// 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 || [])] }))
|
||||
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))
|
||||
);
|
||||
})
|
||||
);
|
||||
})
|
||||
);
|
||||
@@ -164,11 +180,11 @@ export class ProviderThemePageComponent implements OnDestroy {
|
||||
}
|
||||
|
||||
apiCall.subscribe({
|
||||
next: (res) => {
|
||||
next: (res: PagedResult<Video>) => {
|
||||
if (snapshotVersion !== this.version) return;
|
||||
|
||||
const existingIds = new Set(this.allVideos().map(v => v.videoId));
|
||||
const newVideos = (res.items || []).filter(v => !existingIds.has(v.videoId));
|
||||
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);
|
||||
@@ -199,11 +215,14 @@ export class ProviderThemePageComponent implements OnDestroy {
|
||||
|
||||
const typeFilter = this.type();
|
||||
if (typeFilter !== 'any') {
|
||||
const p = this.provider();
|
||||
arr = arr.filter(v => {
|
||||
if (typeFilter === 'video') return v.type === 'video';
|
||||
if (typeFilter === 'live') return v.type === 'live' || v.type === 'channel';
|
||||
if (typeFilter === 'recorded') return v.type === 'recorded';
|
||||
if (typeFilter === 'short') return v.type === 'short' || v.duration < 60;
|
||||
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;
|
||||
});
|
||||
}
|
||||
@@ -252,6 +271,27 @@ export class ProviderThemePageComponent implements OnDestroy {
|
||||
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); }
|
||||
|
||||
|
||||
@@ -1,8 +1,27 @@
|
||||
<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">
|
||||
<h2 class="text-3xl font-bold mb-4 text-slate-100 border-l-4 border-red-500 pl-4 flex items-center gap-2">
|
||||
<span>{{ themeLabel() }}</span>
|
||||
</h2>
|
||||
|
||||
<!-- Filtre Vidéos / Shorts : les deux formats ne sont plus mélangés sans contrôle -->
|
||||
<div class="flex items-center gap-2 mb-6" role="tablist" aria-label="Filtrer vidéos ou shorts">
|
||||
<button (click)="setContentFilter('all')" [attr.aria-pressed]="contentFilter() === 'all'"
|
||||
class="px-3 py-1.5 rounded-full text-sm border transition-colors"
|
||||
[class]="contentFilter() === 'all' ? 'bg-red-600 border-red-500 text-white' : 'bg-slate-800 border-slate-700 text-slate-300 hover:bg-slate-700'">
|
||||
Tout
|
||||
</button>
|
||||
<button (click)="setContentFilter('videos')" [attr.aria-pressed]="contentFilter() === 'videos'"
|
||||
class="px-3 py-1.5 rounded-full text-sm border transition-colors"
|
||||
[class]="contentFilter() === 'videos' ? 'bg-red-600 border-red-500 text-white' : 'bg-slate-800 border-slate-700 text-slate-300 hover:bg-slate-700'">
|
||||
Vidéos
|
||||
</button>
|
||||
<button (click)="setContentFilter('shorts')" [attr.aria-pressed]="contentFilter() === 'shorts'"
|
||||
class="px-3 py-1.5 rounded-full text-sm border transition-colors"
|
||||
[class]="contentFilter() === 'shorts' ? 'bg-red-600 border-red-500 text-white' : 'bg-slate-800 border-slate-700 text-slate-300 hover:bg-slate-700'">
|
||||
Shorts
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="space-y-10">
|
||||
<section *ngFor="let b of blocks()">
|
||||
<div class="flex items-center justify-between mb-3">
|
||||
@@ -18,10 +37,13 @@
|
||||
<!-- 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>
|
||||
<div *ngIf="!b.loading && !b.error && b.items.length > 0 && filteredItems(b).length === 0" class="text-slate-400">
|
||||
Aucun {{ contentFilter() === 'shorts' ? 'Short' : 'vidéo' }} pour ce fournisseur — essayez l'autre filtre.
|
||||
</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"
|
||||
<div *ngIf="!b.loading && !b.error && filteredItems(b).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 filteredItems(b)"
|
||||
[routerLink]="['/watch', v.videoId]"
|
||||
[queryParams]="watchQueryParams(b.provider, v)"
|
||||
[state]="{ video: v }"
|
||||
@@ -31,6 +53,10 @@
|
||||
<div class="absolute top-2 right-2">
|
||||
<app-provider-badge [provider]="b.provider"></app-provider-badge>
|
||||
</div>
|
||||
<div class="absolute top-2 left-2 flex gap-1">
|
||||
<span *ngIf="kindOf(v, b.provider) === 'short'" class="bg-red-600 text-white text-[11px] font-bold px-1.5 py-0.5 rounded">SHORT</span>
|
||||
<span *ngIf="kindOf(v, b.provider) === 'live'" class="bg-purple-600 text-white text-[11px] font-bold px-1.5 py-0.5 rounded">LIVE</span>
|
||||
</div>
|
||||
<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 > 0">
|
||||
{{ v.duration | duration }}
|
||||
</div>
|
||||
|
||||
@@ -9,6 +9,7 @@ import { TranslatePipe } from '../../pipes/translate.pipe';
|
||||
import { LikeButtonComponent } from '../shared/components/like-button/like-button.component';
|
||||
import { ProviderBadgeComponent } from '../../app/shared/components/provider-badge/provider-badge.component';
|
||||
import { DurationPipe } from '../../app/shared/pipes/duration.pipe';
|
||||
import { classifyVideo, isShortVideo } from '../../app/shared/utils/video-kind';
|
||||
|
||||
interface ProviderBlock {
|
||||
provider: Provider;
|
||||
@@ -39,6 +40,30 @@ export class ThemePageComponent {
|
||||
|
||||
blocks = signal<ProviderBlock[]>([]);
|
||||
|
||||
// Filtre d'affichage Vidéos / Shorts (les Shorts ne sont plus mélangés sans contrôle).
|
||||
readonly contentFilter = signal<'all' | 'videos' | 'shorts'>('all');
|
||||
|
||||
setContentFilter(v: 'all' | 'videos' | 'shorts'): void {
|
||||
this.contentFilter.set(v);
|
||||
}
|
||||
|
||||
/** Items du bloc après application du filtre Vidéos/Shorts. */
|
||||
filteredItems(b: ProviderBlock): Video[] {
|
||||
const f = this.contentFilter();
|
||||
if (f === 'all') return b.items;
|
||||
return b.items.filter(v =>
|
||||
f === 'shorts' ? isShortVideo({ ...v, provider: (v.provider as string) || b.provider }) : !isShortVideo({ ...v, provider: (v.provider as string) || b.provider })
|
||||
);
|
||||
}
|
||||
|
||||
kindOf(v: Video, provider: Provider): string {
|
||||
return classifyVideo({ ...v, provider: (v.provider as string) || provider });
|
||||
}
|
||||
|
||||
shortsCount(b: ProviderBlock): number {
|
||||
return b.items.filter(v => isShortVideo({ ...v, provider: (v.provider as string) || b.provider })).length;
|
||||
}
|
||||
|
||||
constructor() {
|
||||
this.route.paramMap.subscribe(pm => {
|
||||
const slug = pm.get('theme') || pm.get('slug') || 'trending';
|
||||
@@ -115,6 +140,35 @@ export class ThemePageComponent {
|
||||
});
|
||||
return;
|
||||
}
|
||||
// Twitch / live : les top streams live garantissent un bloc rempli
|
||||
// (la recherche de chaînes "live" est trop restrictive).
|
||||
if (block.provider === 'twitch' && slug === 'live') {
|
||||
this.api.getTwitchTopStreamsPage(null).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: 'twitch_top_streams' });
|
||||
this.blocks.update(a => [...a]);
|
||||
},
|
||||
error: () => { block.error = 'Erreur de chargement'; block.loading = false; this.blocks.update(a => [...a]); }
|
||||
});
|
||||
return;
|
||||
}
|
||||
// Twitch hors live/trending : VODs + clips + fallback top streams.
|
||||
if (block.provider === 'twitch') {
|
||||
const q = tokens.join(' ');
|
||||
const call = q ? this.api.searchTwitchThemePage(q, null) : this.api.getTwitchTopStreamsPage(null);
|
||||
call.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: () => { block.error = 'Erreur de chargement'; block.loading = false; this.blocks.update(a => [...a]); }
|
||||
});
|
||||
return;
|
||||
}
|
||||
const q = tokens.join(' ');
|
||||
if (!q) {
|
||||
// fallback to trending
|
||||
|
||||
@@ -59,14 +59,45 @@
|
||||
{{ 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 flex-col sm:flex-row sm:items-center sm:justify-between mt-3 text-slate-400 gap-3">
|
||||
<div class="flex items-center space-x-3">
|
||||
<img [src]="v.uploaderAvatar" [alt]="v.uploaderName" class="w-10 h-10 rounded-full">
|
||||
@if (channelAvatarUrl(); as avatar) {
|
||||
<img [src]="avatar" [alt]="v.uploaderName" (error)="onAvatarError()"
|
||||
class="w-10 h-10 rounded-full object-cover bg-slate-700" loading="lazy" referrerpolicy="no-referrer">
|
||||
} @else {
|
||||
<div class="w-10 h-10 rounded-full bg-slate-700 flex items-center justify-center text-slate-200 font-bold"
|
||||
[attr.aria-label]="v.uploaderName">{{ channelInitial() }}</div>
|
||||
}
|
||||
<div>
|
||||
<div class="font-semibold text-slate-200">{{ v.uploaderName }}</div>
|
||||
<div>{{ formatNumber(v.subscribers) }} subscribers</div>
|
||||
<div class="font-semibold text-slate-200">{{ v.uploaderName || channelMeta()?.title || 'Chaîne inconnue' }}</div>
|
||||
@if (channelSubsLabel(); as subs) {
|
||||
<div>{{ subs }} abonnés</div>
|
||||
}
|
||||
</div>
|
||||
@if (canSubscribe() && isLoggedIn()) {
|
||||
<app-subscribe-button
|
||||
[provider]="subscribeProvider()!"
|
||||
[externalId]="subscribeExternalId()!"
|
||||
[channelMeta]="channelMeta()"
|
||||
(subscribed)="onSubscribedFromWatch($event)"
|
||||
(unsubscribed)="onUnsubscribedFromWatch()">
|
||||
</app-subscribe-button>
|
||||
} @else if (canSubscribe() && !isLoggedIn()) {
|
||||
<a routerLink="/auth/login" title="Connectez-vous pour vous abonner"
|
||||
class="inline-flex items-center gap-2 rounded-md border border-slate-600 bg-slate-800/70 px-3 py-1.5 text-sm font-semibold text-slate-200">
|
||||
S'abonner
|
||||
</a>
|
||||
}
|
||||
</div>
|
||||
@if (subscribeError(); as err) {
|
||||
<div class="text-sm text-red-300">{{ err }}</div>
|
||||
}
|
||||
@if (subServiceError(); as serr) {
|
||||
<div class="text-sm text-red-300">{{ serr }}</div>
|
||||
}
|
||||
@if (canSubscribe() && isLoggedIn()) {
|
||||
<div class="text-xs text-slate-500" title="Raccourci clavier">Astuce : touche « s » pour (dé)s'abonner</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"
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { ChangeDetectionStrategy, Component, inject, signal, computed, OnDestroy, AfterViewInit, ViewChild, ElementRef } from '@angular/core';
|
||||
import { ChangeDetectionStrategy, Component, inject, signal, computed, OnDestroy, AfterViewInit, ViewChild, ElementRef, HostListener } from '@angular/core';
|
||||
import { HttpClient } from '@angular/common/http';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { ActivatedRoute, Router, RouterLink } from '@angular/router';
|
||||
@@ -13,6 +13,10 @@ import { HistoryService } from '../../services/history.service';
|
||||
import { InstanceService, Provider } from '../../services/instance.service';
|
||||
import { DownloadService, DownloadFormat, DownloadJob } from '../../services/download.service';
|
||||
import { AuthService } from '../../services/auth.service';
|
||||
import { ChannelsService } from '../../services/channels.service';
|
||||
import { SubscriptionsService } from '../../services/subscriptions.service';
|
||||
import type { ChannelMeta, ProviderId } from '../../app/shared/models/channel.model';
|
||||
import { SubscribeButtonComponent } from '../../app/shared/components/subscribe-button/subscribe-button.component';
|
||||
import { DomSanitizer, SafeResourceUrl } from '@angular/platform-browser';
|
||||
import { IframeProgressService } from '../../services/iframe-progress.service';
|
||||
import { formatAbsoluteFr, formatNumberFr } from '../../utils/date.util';
|
||||
@@ -24,7 +28,7 @@ import { DurationPipe } from '../../app/shared/pipes/duration.pipe';
|
||||
templateUrl: './watch.component.html',
|
||||
standalone: true,
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
imports: [CommonModule, VideoPlayerComponent, RouterLink, AddToPlaylistComponent, ProviderBadgeComponent, DurationPipe]
|
||||
imports: [CommonModule, VideoPlayerComponent, RouterLink, AddToPlaylistComponent, ProviderBadgeComponent, DurationPipe, SubscribeButtonComponent]
|
||||
})
|
||||
export class WatchComponent implements OnDestroy, AfterViewInit {
|
||||
private route = inject(ActivatedRoute);
|
||||
@@ -39,10 +43,69 @@ export class WatchComponent implements OnDestroy, AfterViewInit {
|
||||
private auth = inject(AuthService);
|
||||
private http = inject(HttpClient);
|
||||
private iframeProgress = inject(IframeProgressService);
|
||||
private channels = inject(ChannelsService);
|
||||
private subs = inject(SubscriptionsService);
|
||||
private routeSubscription!: Subscription;
|
||||
@ViewChild('embedFrame', { static: false }) embedFrame?: ElementRef<HTMLIFrameElement>;
|
||||
private attachTryCount = 0;
|
||||
|
||||
// --- Chaîne / abonnement ---
|
||||
channelMeta = signal<ChannelMeta | null>(null);
|
||||
channelAvatarBroken = signal(false);
|
||||
subscribeError = signal<string | null>(null);
|
||||
subscribeBusy = signal(false);
|
||||
|
||||
/** Mappe le provider d'affichage ('youtube') vers l'id canal ('yt'). */
|
||||
subscribeProvider = computed<ProviderId | null>(() => {
|
||||
const map: Record<string, ProviderId> = {
|
||||
youtube: 'yt', dailymotion: 'dm', twitch: 'tw',
|
||||
peertube: 'pt', odysee: 'od', rumble: 'ru',
|
||||
};
|
||||
return map[this.provider()] || null;
|
||||
});
|
||||
|
||||
/** ExternalId de la chaîne : meta résolue > détails vidéo > néant. */
|
||||
subscribeExternalId = computed<string | null>(() => {
|
||||
const meta = this.channelMeta();
|
||||
if (meta?.externalId) return meta.externalId;
|
||||
const v: any = this.video();
|
||||
const ext = v?.channelExternalId || v?.channelId || null;
|
||||
return ext ? String(ext) : null;
|
||||
});
|
||||
|
||||
canSubscribe = computed(() => !!this.subscribeProvider() && !!this.subscribeExternalId());
|
||||
|
||||
/** Erreur remontée par le bouton S'abonner (ex. session expirée). */
|
||||
subServiceError = computed(() => this.subs.lastError());
|
||||
|
||||
/** Avatar à afficher : meta chaîne > détails vidéo > néant (initiales). */
|
||||
channelAvatarUrl = computed<string | null>(() => {
|
||||
if (this.channelAvatarBroken()) return null;
|
||||
const metaAvatar = this.channelMeta()?.avatarUrl;
|
||||
if (metaAvatar) return metaAvatar;
|
||||
const v: any = this.video();
|
||||
const direct = v?.channelAvatarUrl || v?.uploaderAvatar;
|
||||
if (direct && /^https?:\/\//i.test(String(direct))) {
|
||||
// Évite d'afficher la miniature vidéo comme logo (cas YouTube historique).
|
||||
if (v?.thumbnail && String(direct) === String(v.thumbnail)) return null;
|
||||
return String(direct);
|
||||
}
|
||||
return null;
|
||||
});
|
||||
|
||||
channelInitial = computed(() => {
|
||||
const name = (this.video()?.uploaderName || this.channelMeta()?.title || this.channelMeta()?.handle || 'C').trim();
|
||||
return name ? name.charAt(0).toUpperCase() : 'C';
|
||||
});
|
||||
|
||||
channelSubsLabel = computed(() => {
|
||||
const fromMeta = this.channelMeta()?.subsCount;
|
||||
if (typeof fromMeta === 'number') return this.formatNumber(fromMeta);
|
||||
const v = this.video()?.subscribers;
|
||||
if (typeof v === 'number' && v > 0) return this.formatNumber(v);
|
||||
return null;
|
||||
});
|
||||
|
||||
// Choose correct API base: use dev proxy when UI runs on a different port than the backend
|
||||
private apiBase(): string {
|
||||
try {
|
||||
@@ -264,8 +327,11 @@ export class WatchComponent implements OnDestroy, AfterViewInit {
|
||||
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= exige un id de VOD numérique ; un login (non numérique) sans
|
||||
// ?channel= doit être traité comme une chaîne (résultats de recherche).
|
||||
const chan = ch || (!/^\d+$/.test(String(id || '')) && id ? id : null);
|
||||
const qs = chan
|
||||
? `?channel=${encodeURIComponent(chan)}&${parentParams}&autoplay=false`
|
||||
: `?video=${encodeURIComponent(id)}&${parentParams}&autoplay=false`;
|
||||
const u = base + qs;
|
||||
return this.sanitizer.bypassSecurityTrustResourceUrl(u);
|
||||
@@ -316,7 +382,78 @@ export class WatchComponent implements OnDestroy, AfterViewInit {
|
||||
return null;
|
||||
});
|
||||
|
||||
@HostListener('document:keydown', ['$event'])
|
||||
onGlobalKey(ev: KeyboardEvent): void {
|
||||
try {
|
||||
const target = ev.target as HTMLElement | null;
|
||||
if (target?.closest?.('input, textarea, select, [contenteditable="true"]')) return;
|
||||
if (ev.ctrlKey || ev.metaKey || ev.altKey) return;
|
||||
if (ev.key !== 's' && ev.key !== 'S') return;
|
||||
if (!this.canSubscribe() || !this.isLoggedIn() || this.subscribeBusy()) return;
|
||||
ev.preventDefault();
|
||||
this.toggleSubscribeShortcut();
|
||||
} catch {}
|
||||
}
|
||||
|
||||
onAvatarError(): void {
|
||||
this.channelAvatarBroken.set(true);
|
||||
}
|
||||
|
||||
onSubscribedFromWatch(meta: ChannelMeta | null): void {
|
||||
this.subscribeError.set(null);
|
||||
if (meta) this.channelMeta.set(meta);
|
||||
}
|
||||
|
||||
onUnsubscribedFromWatch(): void {
|
||||
this.subscribeError.set(null);
|
||||
}
|
||||
|
||||
/** Fallback quand le bouton n'est pas utilisable : même logique que SubscribeButton. */
|
||||
toggleSubscribeShortcut(): void {
|
||||
const provider = this.subscribeProvider();
|
||||
const externalId = this.subscribeExternalId();
|
||||
if (!provider || !externalId || this.subscribeBusy()) return;
|
||||
this.subscribeBusy.set(true);
|
||||
this.subscribeError.set(null);
|
||||
const isSub = this.subs.isSubscribed(provider, externalId);
|
||||
if (isSub) {
|
||||
const current = this.subs.items().find((i) => i.channel.provider === provider && i.channel.externalId === externalId);
|
||||
if (!current) { this.subscribeBusy.set(false); return; }
|
||||
this.subs.unsubscribe(current.subscriptionId).subscribe({
|
||||
next: (ok) => { if (!ok) this.subscribeError.set("Désabonnement impossible. Réessayez."); },
|
||||
error: () => this.subscribeError.set("Désabonnement impossible. Réessayez."),
|
||||
complete: () => this.subscribeBusy.set(false),
|
||||
});
|
||||
} else {
|
||||
this.subs.subscribe(provider, externalId, { refresh: true }).subscribe({
|
||||
next: (item) => {
|
||||
if (item?.channel) this.channelMeta.set(item.channel);
|
||||
else this.subscribeError.set("Abonnement impossible. Réessayez.");
|
||||
},
|
||||
error: () => this.subscribeError.set("Abonnement impossible. Réessayez."),
|
||||
complete: () => this.subscribeBusy.set(false),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private resolveChannelMeta(): void {
|
||||
const provider = this.subscribeProvider();
|
||||
// subscribeExternalId dépend de video(), donc on le lit après chaque merge.
|
||||
const externalId = this.subscribeExternalId();
|
||||
if (!provider || !externalId) return;
|
||||
try {
|
||||
this.channels.get(provider, externalId).subscribe({
|
||||
next: (meta) => { if (meta) this.channelMeta.set(meta); },
|
||||
error: () => {},
|
||||
});
|
||||
} catch {}
|
||||
}
|
||||
|
||||
constructor() {
|
||||
// Charge le cache d'abonnements pour que isSubscribed() soit fiable sur /watch.
|
||||
try {
|
||||
if (this.subs.items().length === 0) this.subs.list().subscribe();
|
||||
} catch {}
|
||||
this.routeSubscription = this.route.paramMap.subscribe(params => {
|
||||
const id = params.get('id');
|
||||
const providerFromPath = params.get('provider');
|
||||
@@ -398,7 +535,14 @@ export class WatchComponent implements OnDestroy, AfterViewInit {
|
||||
// Clear any previous Rumble embed URL to avoid stale iframes during fast navigation
|
||||
this.rumbleEmbed.set(null);
|
||||
// Prepare placeholder from router state if available for richer UI
|
||||
const stateVideo = (history.state && (history.state as any).video) as Video | undefined;
|
||||
const stateVideo = (history.state && (history.state as any).video) as (Video & { channelAvatarUrl?: string }) | undefined;
|
||||
const placeholderAvatar = ((): string => {
|
||||
const a = stateVideo?.uploaderAvatar || '';
|
||||
const thumb = stateVideo?.thumbnail || '';
|
||||
// N'affiche jamais la miniature vidéo comme logo chaîne.
|
||||
if (a && thumb && a === thumb) return '';
|
||||
return a;
|
||||
})();
|
||||
this.video.set({
|
||||
url: stateVideo?.url || '',
|
||||
type: stateVideo?.type || 'video',
|
||||
@@ -406,7 +550,10 @@ export class WatchComponent implements OnDestroy, AfterViewInit {
|
||||
thumbnail: stateVideo?.thumbnail || '',
|
||||
uploaderName: stateVideo?.uploaderName || '',
|
||||
uploaderUrl: stateVideo?.uploaderUrl || '',
|
||||
uploaderAvatar: stateVideo?.uploaderAvatar || '',
|
||||
uploaderAvatar: placeholderAvatar,
|
||||
channelId: (stateVideo as any)?.channelId,
|
||||
channelExternalId: (stateVideo as any)?.channelExternalId,
|
||||
channelAvatarUrl: (stateVideo as any)?.channelAvatarUrl,
|
||||
uploadedDate: stateVideo?.uploadedDate || '',
|
||||
shortDescription: stateVideo?.shortDescription || '',
|
||||
duration: stateVideo?.duration || 0,
|
||||
@@ -421,7 +568,14 @@ export class WatchComponent implements OnDestroy, AfterViewInit {
|
||||
audioStreams: [],
|
||||
relatedStreams: [],
|
||||
comments: []
|
||||
});
|
||||
} as VideoDetail);
|
||||
// Reset état chaîne à chaque navigation.
|
||||
this.channelMeta.set(null);
|
||||
this.channelAvatarBroken.set(false);
|
||||
this.subscribeError.set(null);
|
||||
this.subscribeBusy.set(false);
|
||||
// Si le state contient déjà un channelId, pré-résout l'avatar sans attendre /details.
|
||||
this.resolveChannelMeta();
|
||||
this.showFullDescription.set(false);
|
||||
this.summary.set(null);
|
||||
this.isSummarizing.set(false);
|
||||
@@ -462,13 +616,20 @@ export class WatchComponent implements OnDestroy, AfterViewInit {
|
||||
title: data?.title || v.title,
|
||||
thumbnail: data?.thumbnail || v.thumbnail,
|
||||
uploaderName: data?.uploaderName || v.uploaderName,
|
||||
uploaderUrl: (data as any)?.uploaderUrl || (v as any).uploaderUrl,
|
||||
uploaderAvatar: data?.uploaderAvatar || v.uploaderAvatar,
|
||||
channelId: (data as any)?.channelId || (v as any)?.channelId,
|
||||
channelExternalId: (data as any)?.channelExternalId || (v as any)?.channelExternalId,
|
||||
channelAvatarUrl: (data as any)?.uploaderAvatar || (v as any)?.channelAvatarUrl,
|
||||
uploadedDate: data?.uploadedDate || v.uploadedDate,
|
||||
views: typeof data?.views === 'number' ? data.views : v.views,
|
||||
subscribers: typeof (data as any)?.subscribers === 'number' && (data as any).subscribers > 0 ? (data as any).subscribers : v.subscribers,
|
||||
duration: typeof data?.duration === 'number' ? data.duration : v.duration,
|
||||
description: data?.description || v.description,
|
||||
url: data?.url || v.url,
|
||||
} : v);
|
||||
this.channelAvatarBroken.set(false);
|
||||
this.resolveChannelMeta();
|
||||
this.loading.set(false);
|
||||
this.loadRelatedSuggestions();
|
||||
// Update watch history with enriched title/thumbnail/duration
|
||||
@@ -523,12 +684,20 @@ export class WatchComponent implements OnDestroy, AfterViewInit {
|
||||
title: data?.title || v.title,
|
||||
thumbnail: data?.thumbnail || v.thumbnail,
|
||||
uploaderName: data?.uploaderName || v.uploaderName,
|
||||
uploaderUrl: (data as any)?.uploaderUrl || (v as any).uploaderUrl,
|
||||
uploaderAvatar: (data as any)?.uploaderAvatar || v.uploaderAvatar,
|
||||
channelId: (data as any)?.channelId || (v as any)?.channelId,
|
||||
channelExternalId: (data as any)?.channelExternalId || (v as any)?.channelExternalId,
|
||||
channelAvatarUrl: (data as any)?.uploaderAvatar || (v as any)?.channelAvatarUrl,
|
||||
subscribers: typeof (data as any)?.subscribers === 'number' && (data as any).subscribers > 0 ? (data as any).subscribers : v.subscribers,
|
||||
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 || v.shortDescription || ''),
|
||||
url: data?.url || v.url,
|
||||
} : v);
|
||||
this.channelAvatarBroken.set(false);
|
||||
this.resolveChannelMeta();
|
||||
this.loading.set(false);
|
||||
this.loadRelatedSuggestions();
|
||||
// Update watch history with enriched title/thumbnail/duration
|
||||
@@ -883,9 +1052,9 @@ export class WatchComponent implements OnDestroy, AfterViewInit {
|
||||
if (slug.startsWith('/')) slug = slug.slice(1);
|
||||
qp.slug = slug;
|
||||
}
|
||||
if (p === 'twitch' && v.type === 'channel') {
|
||||
if (p === 'twitch' && (v.type === 'channel' || (v as any).type === 'live')) {
|
||||
// Prefer explicit videoId as channel login; fallback to parsing URL
|
||||
if (v.videoId) {
|
||||
if (v.videoId && !/^\d+$/.test(String(v.videoId))) {
|
||||
qp.channel = v.videoId;
|
||||
} else {
|
||||
const url = v.uploaderUrl || v.url || '';
|
||||
|
||||
@@ -7,6 +7,10 @@ export interface Video {
|
||||
uploaderName: string;
|
||||
uploaderUrl?: string;
|
||||
uploaderAvatar: string;
|
||||
channelId?: string;
|
||||
channelHandle?: string;
|
||||
channelExternalId?: string;
|
||||
channelAvatarUrl?: string;
|
||||
uploadedDate: string;
|
||||
shortDescription?: string | null;
|
||||
duration: number;
|
||||
|
||||
@@ -44,6 +44,46 @@ export class AuthService {
|
||||
this._accessToken.set(token);
|
||||
}
|
||||
|
||||
/**
|
||||
* Restaure la session au démarrage de l'app (F5) : refresh via cookies
|
||||
* httpOnly puis chargement du profil. Échoue silencieusement si aucune
|
||||
* session (cookies absents/expirés) : l'utilisateur reste déconnecté.
|
||||
*/
|
||||
initSession(): Observable<boolean> {
|
||||
if (this._currentUser() && this._accessToken()) return of(true);
|
||||
return this.refresh().pipe(
|
||||
map((ok) => {
|
||||
if (!ok || !this._accessToken()) {
|
||||
this._currentUser.set(null);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}),
|
||||
catchError(() => {
|
||||
this._currentUser.set(null);
|
||||
return of(false);
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
/** Charge /user/me avec fallback dev/prod, sans jamais lever. */
|
||||
fetchMe(): Observable<AuthUser | null> {
|
||||
return this.http.get<AuthUser>('/api/user/me', { withCredentials: true }).pipe(
|
||||
catchError((err: HttpErrorResponse) => {
|
||||
if (!err || err.status === 0 || err.status === 404) {
|
||||
return this.http.get<AuthUser>('/proxy/api/user/me', { withCredentials: true });
|
||||
}
|
||||
throw err;
|
||||
}),
|
||||
map((u) => u || null),
|
||||
tap((u) => this._currentUser.set(u)),
|
||||
catchError(() => {
|
||||
this._currentUser.set(null);
|
||||
return of(null);
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
login(username: string, password: string, rememberMe = false): Observable<AuthUser> {
|
||||
const body = { username, password, rememberMe };
|
||||
return this.http.post<any>('/api/auth/login', body, { withCredentials: true }).pipe(
|
||||
|
||||
@@ -0,0 +1,149 @@
|
||||
import { Injectable, inject, signal } from '@angular/core';
|
||||
import { Observable, catchError, finalize, map, of, tap } from 'rxjs';
|
||||
import type { ProviderId } from '../app/shared/models/channel.model';
|
||||
import type {
|
||||
ChannelContentPage,
|
||||
ChannelContentType,
|
||||
ChannelPlaylist,
|
||||
ChannelSort,
|
||||
} from '../app/shared/models/channel-detail.model';
|
||||
import type { VideoItem } from '../app/shared/models/video-item.model';
|
||||
import { ChannelProviderFactory } from '../app/core/providers/channel/channel-provider.factory';
|
||||
|
||||
export interface ChannelListState<T> {
|
||||
items: T[];
|
||||
nextPage: number | null;
|
||||
total: number | null;
|
||||
loading: boolean;
|
||||
loadingMore: boolean;
|
||||
error: string | null;
|
||||
fetchedAt: number | null;
|
||||
}
|
||||
|
||||
function emptyState<T>(): ChannelListState<T> {
|
||||
return { items: [], nextPage: null, total: null, loading: false, loadingMore: false, error: null, fetchedAt: null };
|
||||
}
|
||||
|
||||
const TTL_MS = 5 * 60 * 1000; // 5 min pour les listes (meta reste 6h dans ChannelsService)
|
||||
const PAGE_SIZE = 24;
|
||||
|
||||
/**
|
||||
* Cache mémoire par (provider, channelId, type, sort, q).
|
||||
* Évite de re-fetcher à chaque retour sur la page / changement d'onglet.
|
||||
*/
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class ChannelContentService {
|
||||
private factory = inject(ChannelProviderFactory);
|
||||
private states = signal(new Map<string, ChannelListState<any>>());
|
||||
|
||||
private key(provider: ProviderId, channelId: string, type: ChannelContentType, sort: ChannelSort, q: string): string {
|
||||
return `${provider}::${channelId}::${type}::${sort}::${q.trim().toLowerCase()}`;
|
||||
}
|
||||
|
||||
snapshot<T>(provider: ProviderId, channelId: string, type: ChannelContentType, sort: ChannelSort, q = ''): ChannelListState<T> {
|
||||
return (this.states().get(this.key(provider, channelId, type, sort, q)) as ChannelListState<T>) ?? emptyState<T>();
|
||||
}
|
||||
|
||||
private patch(key: string, partial: Partial<ChannelListState<any>>): void {
|
||||
const next = new Map(this.states());
|
||||
next.set(key, { ...(next.get(key) ?? emptyState()), ...partial });
|
||||
this.states.set(next);
|
||||
}
|
||||
|
||||
private isFresh<T>(s: ChannelListState<T>): boolean {
|
||||
return Boolean(s.fetchedAt && Date.now() - s.fetchedAt < TTL_MS && s.items.length > 0);
|
||||
}
|
||||
|
||||
loadVideos(provider: ProviderId, channelId: string, opts?: { sort?: ChannelSort; q?: string; refresh?: boolean }): Observable<VideoItem[]> {
|
||||
return this.loadList<VideoItem>(provider, channelId, 'videos', opts);
|
||||
}
|
||||
|
||||
loadShorts(provider: ProviderId, channelId: string, opts?: { sort?: ChannelSort; q?: string; refresh?: boolean }): Observable<VideoItem[]> {
|
||||
return this.loadList<VideoItem>(provider, channelId, 'shorts', opts);
|
||||
}
|
||||
|
||||
loadStreams(provider: ProviderId, channelId: string, opts?: { q?: string; refresh?: boolean }): Observable<VideoItem[]> {
|
||||
return this.loadList<VideoItem>(provider, channelId, 'live', { ...opts, sort: 'recent' });
|
||||
}
|
||||
|
||||
loadPlaylists(provider: ProviderId, channelId: string, opts?: { q?: string; refresh?: boolean }): Observable<ChannelPlaylist[]> {
|
||||
return this.loadList<ChannelPlaylist>(provider, channelId, 'playlists', { ...opts, sort: 'recent' });
|
||||
}
|
||||
|
||||
loadMore(provider: ProviderId, channelId: string, type: ChannelContentType, opts?: { sort?: ChannelSort; q?: string }): Observable<boolean> {
|
||||
const sort = opts?.sort ?? 'recent';
|
||||
const q = opts?.q ?? '';
|
||||
const key = this.key(provider, channelId, type, sort, q);
|
||||
const current = (this.states().get(key) as ChannelListState<any>) ?? emptyState();
|
||||
if (current.loadingMore || current.loading || current.nextPage == null) return of(false);
|
||||
this.patch(key, { loadingMore: true, error: null });
|
||||
return this.fetchPage(provider, channelId, type, current.nextPage, sort, q).pipe(
|
||||
tap((page) => {
|
||||
const seen = new Set(current.items.map((i: any) => i?.id ?? i?.title));
|
||||
const merged = [...current.items];
|
||||
for (const item of page.items) {
|
||||
const id = (item as any)?.id ?? (item as any)?.title;
|
||||
if (!seen.has(id)) { seen.add(id); merged.push(item); }
|
||||
}
|
||||
this.patch(key, { items: merged, nextPage: page.nextPage, total: page.total ?? current.total, fetchedAt: Date.now() });
|
||||
}),
|
||||
map(() => true),
|
||||
catchError(() => {
|
||||
this.patch(key, { error: 'channel_content_failed' });
|
||||
return of(false);
|
||||
}),
|
||||
finalize(() => this.patch(key, { loadingMore: false })),
|
||||
);
|
||||
}
|
||||
|
||||
clear(provider: ProviderId, channelId: string): void {
|
||||
const prefix = `${provider}::${channelId}::`;
|
||||
const next = new Map(this.states());
|
||||
for (const k of [...next.keys()]) if (k.startsWith(prefix)) next.delete(k);
|
||||
this.states.set(next);
|
||||
}
|
||||
|
||||
private loadList<T>(provider: ProviderId, channelId: string, type: ChannelContentType, opts?: { sort?: ChannelSort; q?: string; refresh?: boolean }): Observable<T[]> {
|
||||
const sort = opts?.sort ?? 'recent';
|
||||
const q = opts?.q ?? '';
|
||||
const key = this.key(provider, channelId, type, sort, q);
|
||||
const current = (this.states().get(key) as ChannelListState<T>) ?? emptyState<T>();
|
||||
if (!opts?.refresh && (current.loading || this.isFresh(current))) return of(current.items);
|
||||
this.patch(key, { loading: true, error: null });
|
||||
return this.fetchPage(provider, channelId, type, 1, sort, q).pipe(
|
||||
tap((page) => this.patch(key, { items: page.items as T[], nextPage: page.nextPage, total: page.total ?? null, fetchedAt: Date.now() })),
|
||||
map((page) => page.items as T[]),
|
||||
catchError(() => {
|
||||
this.patch(key, { error: 'channel_content_failed' });
|
||||
return of(current.items);
|
||||
}),
|
||||
finalize(() => this.patch(key, { loading: false })),
|
||||
);
|
||||
}
|
||||
|
||||
private fetchPage(
|
||||
provider: ProviderId,
|
||||
channelId: string,
|
||||
type: ChannelContentType,
|
||||
page: number,
|
||||
sort: ChannelSort,
|
||||
q: string,
|
||||
): Observable<ChannelContentPage<any>> {
|
||||
const p = this.factory.get(provider);
|
||||
const query = { page, limit: PAGE_SIZE, sort, q: q || undefined };
|
||||
if (type === 'videos') {
|
||||
if (q && p.searchInChannel) return p.searchInChannel(channelId, q, query);
|
||||
return p.getChannelVideos(channelId, query);
|
||||
}
|
||||
if (type === 'shorts') {
|
||||
const fn = p.getChannelShorts?.bind(p);
|
||||
return fn ? fn(channelId, query) : of({ items: [], nextPage: null });
|
||||
}
|
||||
if (type === 'playlists') {
|
||||
const fn = p.getChannelPlaylists?.bind(p);
|
||||
return fn ? fn(channelId, query) : of({ items: [], nextPage: null });
|
||||
}
|
||||
const fn = p.getChannelStreams?.bind(p);
|
||||
return fn ? fn(channelId, query) : of({ items: [], nextPage: null });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
import { Injectable, computed, inject, signal } from '@angular/core';
|
||||
import { HttpClient, HttpParams } from '@angular/common/http';
|
||||
import { Observable, of } from 'rxjs';
|
||||
import { catchError, map, shareReplay, tap } from 'rxjs/operators';
|
||||
import type { ChannelMeta, ProviderId } from '../app/shared/models/channel.model';
|
||||
|
||||
interface CacheEntry {
|
||||
meta: ChannelMeta;
|
||||
fetchedAt: number;
|
||||
}
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class ChannelsService {
|
||||
private http = inject(HttpClient);
|
||||
private cache = signal(new Map<string, CacheEntry>());
|
||||
private readonly ttlMs = 6 * 60 * 60 * 1000; // 6h
|
||||
|
||||
private key(provider: ProviderId, externalId: string): string {
|
||||
return `${provider}::${externalId}`;
|
||||
}
|
||||
|
||||
private upsertCache(provider: ProviderId, externalId: string, meta: ChannelMeta): void {
|
||||
const next = new Map(this.cache());
|
||||
next.set(this.key(provider, externalId), { meta, fetchedAt: Date.now() });
|
||||
this.cache.set(next);
|
||||
}
|
||||
|
||||
private getFreshMeta(provider: ProviderId, externalId: string): ChannelMeta | null {
|
||||
const key = this.key(provider, externalId);
|
||||
const entry = this.cache().get(key);
|
||||
if (!entry) return null;
|
||||
if ((Date.now() - entry.fetchedAt) > this.ttlMs) {
|
||||
const next = new Map(this.cache());
|
||||
next.delete(key);
|
||||
this.cache.set(next);
|
||||
return null;
|
||||
}
|
||||
return entry.meta;
|
||||
}
|
||||
|
||||
readonly snapshot = computed(() => this.cache());
|
||||
|
||||
get(provider: ProviderId, externalId: string, opts?: { refresh?: boolean }): Observable<ChannelMeta | null> {
|
||||
const normalizedExtId = String(externalId || '').trim();
|
||||
if (!normalizedExtId) return of(null);
|
||||
|
||||
const cachedMeta = this.getFreshMeta(provider, normalizedExtId);
|
||||
if (cachedMeta && !opts?.refresh) {
|
||||
return of(cachedMeta);
|
||||
}
|
||||
|
||||
let params = new HttpParams();
|
||||
if (opts?.refresh) params = params.set('refresh', '1');
|
||||
|
||||
return this.http
|
||||
.get<ChannelMeta>(`/api/channels/${provider}/${encodeURIComponent(normalizedExtId)}`, { params })
|
||||
.pipe(
|
||||
tap((meta) => {
|
||||
if (meta) this.upsertCache(provider, normalizedExtId, meta);
|
||||
}),
|
||||
map((meta) => meta || cachedMeta || null),
|
||||
catchError((err) => {
|
||||
console.error('Failed to fetch channel meta', provider, normalizedExtId, err);
|
||||
return cachedMeta ? of(cachedMeta) : of(null);
|
||||
}),
|
||||
shareReplay(1)
|
||||
);
|
||||
}
|
||||
|
||||
resolve(provider: ProviderId, externalId: string, opts?: { refresh?: boolean }): Observable<ChannelMeta | null> {
|
||||
const normalizedExtId = String(externalId || '').trim();
|
||||
if (!normalizedExtId) return of(null);
|
||||
|
||||
const body: any = { provider, externalId: normalizedExtId };
|
||||
if (opts?.refresh) body.refresh = true;
|
||||
|
||||
return this.http.post<ChannelMeta>('/api/channels/resolve', body).pipe(
|
||||
tap((meta) => {
|
||||
if (meta) this.upsertCache(provider, normalizedExtId, meta);
|
||||
}),
|
||||
map((meta) => meta || this.getFreshMeta(provider, normalizedExtId)),
|
||||
catchError((err) => {
|
||||
console.error('Failed to resolve channel meta', provider, normalizedExtId, err);
|
||||
const cachedMeta = this.getFreshMeta(provider, normalizedExtId);
|
||||
return cachedMeta ? of(cachedMeta) : of(null);
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
channelSignal(provider: ProviderId, externalId: string) {
|
||||
const key = this.key(provider, externalId);
|
||||
return computed(() => this.cache().get(key)?.meta || null);
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Injectable } from '@angular/core';
|
||||
import { HttpClient, HttpParams } from '@angular/common/http';
|
||||
import { Observable } from 'rxjs';
|
||||
import { HttpClient, HttpParams, HttpResponse } from '@angular/common/http';
|
||||
import { Observable, map } from 'rxjs';
|
||||
|
||||
export interface DownloadFormat {
|
||||
id: string;
|
||||
@@ -13,22 +13,36 @@ export interface DownloadFormat {
|
||||
label: string;
|
||||
}
|
||||
|
||||
export type DownloadState = 'queued' | 'running' | 'merging' | 'completed' | 'failed';
|
||||
export type DownloadState = 'queued' | 'running' | 'merging' | 'completed' | 'failed' | 'interrupted';
|
||||
|
||||
export interface DownloadQuota {
|
||||
usedBytes: number;
|
||||
quotaBytes: number;
|
||||
}
|
||||
|
||||
export interface DownloadJob {
|
||||
id: string;
|
||||
userId: string;
|
||||
provider: string;
|
||||
videoId: string;
|
||||
title?: string | null;
|
||||
state: DownloadState;
|
||||
progress: number;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
filePath?: string | null;
|
||||
formatId?: string | null;
|
||||
audioOnly?: number | boolean;
|
||||
url?: string | null;
|
||||
fileName?: string | null;
|
||||
fileExt?: string | null;
|
||||
fileSize?: number | null;
|
||||
error?: string | null;
|
||||
url?: string;
|
||||
createdAt: number | string;
|
||||
updatedAt: number | string;
|
||||
completedAt?: number | string | null;
|
||||
}
|
||||
|
||||
export interface DownloadJobsResponse {
|
||||
items: DownloadJob[];
|
||||
quota?: DownloadQuota | null;
|
||||
}
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
@@ -66,6 +80,22 @@ export class DownloadService {
|
||||
);
|
||||
}
|
||||
|
||||
listJobs(opts?: { limit?: number; offset?: number; state?: string }): Observable<DownloadJobsResponse> {
|
||||
let params = new HttpParams();
|
||||
if (opts?.limit != null) params = params.set('limit', String(opts.limit));
|
||||
if (opts?.offset != null) params = params.set('offset', String(opts.offset));
|
||||
if (opts?.state) params = params.set('state', opts.state);
|
||||
return this.http.get<DownloadJobsResponse>('/proxy/api/download/jobs', { params, withCredentials: true });
|
||||
}
|
||||
|
||||
retryJob(id: string): Observable<{ jobId: string }> {
|
||||
return this.http.post<{ jobId: string }>(
|
||||
`/proxy/api/download/jobs/${encodeURIComponent(id)}/retry`,
|
||||
{},
|
||||
{ withCredentials: true }
|
||||
);
|
||||
}
|
||||
|
||||
getJob(id: string): Observable<DownloadJob> {
|
||||
return this.http.get<DownloadJob>(`/proxy/api/download/jobs/${encodeURIComponent(id)}`, { withCredentials: true });
|
||||
}
|
||||
@@ -77,4 +107,26 @@ export class DownloadService {
|
||||
fileUrl(id: string): string {
|
||||
return `/proxy/api/download/jobs/${encodeURIComponent(id)}/file`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Télécharge le fichier via HttpClient (header Authorization joint par
|
||||
* l'intercepteur) : fonctionne même quand les cookies de session ne sont
|
||||
* pas envoyés (liens directs, <video>, http local sans Secure).
|
||||
*/
|
||||
downloadFileBlob(id: string): Observable<{ blob: Blob; filename: string }> {
|
||||
return this.http
|
||||
.get(this.fileUrl(id), { responseType: 'blob', observe: 'response', withCredentials: true })
|
||||
.pipe(
|
||||
map((res: HttpResponse<Blob>) => {
|
||||
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 {}
|
||||
return { blob, filename };
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,76 +1,54 @@
|
||||
import { Injectable } from '@angular/core';
|
||||
import { GoogleGenAI } from '@google/genai';
|
||||
import { HttpClient } from '@angular/common/http';
|
||||
import { firstValueFrom } from 'rxjs';
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root',
|
||||
})
|
||||
export class GeminiService {
|
||||
private ai: GoogleGenAI;
|
||||
private readyFlag = false;
|
||||
private readinessMsg: string | undefined;
|
||||
private statusChecked = false;
|
||||
|
||||
constructor() {
|
||||
const apiKey = this.resolveApiKey();
|
||||
if (!apiKey) {
|
||||
// This error will be caught in the console if the API key is missing.
|
||||
// The app continues to work – only comment summarization is disabled.
|
||||
console.warn('Gemini API key not found. Set GEMINI_API_KEY to enable summarization.');
|
||||
// Create a dummy object to avoid crashing if key is not set.
|
||||
this.ai = {} as GoogleGenAI;
|
||||
this.readyFlag = false;
|
||||
this.readinessMsg = 'La fonctionnalité de résumé IA est désactivée : configurez GEMINI_API_KEY dans assets/config.local.js puis rechargez la page.';
|
||||
return;
|
||||
}
|
||||
this.ai = new GoogleGenAI({ apiKey });
|
||||
this.readyFlag = true;
|
||||
this.readinessMsg = undefined;
|
||||
constructor(private http: HttpClient) {
|
||||
this.refreshStatus();
|
||||
}
|
||||
|
||||
/**
|
||||
* Query the server for AI availability. The GEMINI_API_KEY stays server-side.
|
||||
*/
|
||||
refreshStatus(): void {
|
||||
this.http.get<{ ready: boolean; reason?: string }>('/api/ai/status').subscribe({
|
||||
next: (s) => {
|
||||
this.readyFlag = Boolean(s?.ready);
|
||||
this.readinessMsg = s?.ready ? undefined : (s?.reason || 'Résumé IA indisponible (non configuré sur le serveur).');
|
||||
this.statusChecked = true;
|
||||
},
|
||||
error: () => {
|
||||
this.readyFlag = false;
|
||||
this.readinessMsg = 'Résumé IA indisponible (serveur injoignable).';
|
||||
this.statusChecked = true;
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async summarizeComments(comments: string[]): Promise<string> {
|
||||
// If API key was not supplied, the client will be a dummy object.
|
||||
if (!(this.ai as any).models) {
|
||||
throw new Error(this.readinessMsg || 'Gemini API client is not initialized. Is the API key missing?');
|
||||
}
|
||||
|
||||
if (comments.length === 0) {
|
||||
return 'There are no comments to summarize.';
|
||||
return 'There are no comments to summarize.';
|
||||
}
|
||||
|
||||
const commentsText = comments.join('\n- ');
|
||||
const prompt = `Please summarize the following YouTube comments. Provide a concise, neutral overview of the general sentiment and the main topics discussed. Do not add any preamble or sign-off. Just provide the summary. Here are the comments:\n\n- ${commentsText}`;
|
||||
|
||||
try {
|
||||
const response = await (this.ai as any).models.generateContent({
|
||||
model: 'gemini-2.5-flash',
|
||||
contents: prompt,
|
||||
});
|
||||
return response.text;
|
||||
} catch (error) {
|
||||
console.error('Error summarizing comments:', error);
|
||||
throw new Error('Failed to generate summary from comments.');
|
||||
const res = await firstValueFrom(
|
||||
this.http.post<{ summary: string }>('/api/ai/summarize', { comments })
|
||||
);
|
||||
return res?.summary || '';
|
||||
} catch (error: any) {
|
||||
console.error('Error summarizing comments:', error);
|
||||
const details = error?.error?.error || error?.message;
|
||||
throw new Error(typeof details === 'string' && details ? details : 'Failed to generate summary from comments.');
|
||||
}
|
||||
}
|
||||
|
||||
// Attempt to resolve the API key from multiple sources so the app works across tools/builds.
|
||||
private resolveApiKey(): string | undefined {
|
||||
// 1) Expose a global variable via <script> on window or globalThis
|
||||
const fromGlobal = (globalThis as any)?.GEMINI_API_KEY || (window as any)?.GEMINI_API_KEY;
|
||||
|
||||
// 2) Vite-style env (if present in a different toolchain)
|
||||
const fromImportMeta = (typeof import.meta !== 'undefined' && (import.meta as any).env)
|
||||
? ((import.meta as any).env.GEMINI_API_KEY || (import.meta as any).env.VITE_GEMINI_API_KEY)
|
||||
: undefined;
|
||||
|
||||
// 3) Node/bundler style replacement (rare in browser-only setup)
|
||||
const fromProcess = (globalThis as any)?.process?.env
|
||||
? ((globalThis as any).process.env.GEMINI_API_KEY || (globalThis as any).process.env.API_KEY)
|
||||
: undefined;
|
||||
|
||||
return fromGlobal || fromImportMeta || fromProcess || undefined;
|
||||
}
|
||||
|
||||
// Public readiness helpers for UI
|
||||
isReady(): boolean { return this.readyFlag; }
|
||||
readinessReason(): string | undefined { return this.readinessMsg; }
|
||||
}
|
||||
|
||||
|
||||
@@ -51,6 +51,7 @@ const EN: Record<string, string> = {
|
||||
'nav.history': 'History',
|
||||
'nav.playlists': 'Playlists',
|
||||
'nav.liked': 'Liked videos',
|
||||
'nav.downloads': 'Downloads',
|
||||
'nav.explore': 'Explore',
|
||||
'explore.music': 'Music',
|
||||
'explore.gaming': 'Gaming',
|
||||
@@ -92,6 +93,29 @@ const EN: Record<string, string> = {
|
||||
'theme.gaming': 'Gaming',
|
||||
'theme.news': 'News',
|
||||
'theme.sports': 'Sports',
|
||||
// Downloads
|
||||
'downloads.title': 'Downloads',
|
||||
'downloads.refresh': 'Refresh',
|
||||
'downloads.quota': 'Storage used',
|
||||
'downloads.filter.all': 'All',
|
||||
'downloads.filter.active': 'Active',
|
||||
'downloads.filter.completed': 'Completed',
|
||||
'downloads.filter.failed': 'Failed',
|
||||
'downloads.empty': 'No downloads yet. Start one from a video page.',
|
||||
'downloads.audioOnly': 'Audio only',
|
||||
'downloads.getFile': 'Get file',
|
||||
'downloads.play': 'Play',
|
||||
'downloads.fileError': 'Unable to fetch the file. Please sign in again and retry.',
|
||||
'downloads.back': 'Back to downloads',
|
||||
'downloads.notReady': 'This download is not completed yet.',
|
||||
'downloads.loadError': 'Unable to load this download.',
|
||||
'downloads.retry': 'Retry',
|
||||
'downloads.state.queued': 'Queued',
|
||||
'downloads.state.running': 'Downloading',
|
||||
'downloads.state.merging': 'Merging',
|
||||
'downloads.state.completed': 'Completed',
|
||||
'downloads.state.failed': 'Failed',
|
||||
'downloads.state.interrupted': 'Interrupted',
|
||||
};
|
||||
|
||||
const FR: Record<string, string> = {
|
||||
@@ -142,6 +166,7 @@ const FR: Record<string, string> = {
|
||||
'nav.history': 'Historique',
|
||||
'nav.playlists': 'Listes de lecture',
|
||||
'nav.liked': 'Vidéos que vous aimez',
|
||||
'nav.downloads': 'Téléchargements',
|
||||
'nav.explore': 'Explorer',
|
||||
'explore.music': 'Musique',
|
||||
'explore.gaming': 'Jeux vidéo',
|
||||
@@ -183,6 +208,29 @@ const FR: Record<string, string> = {
|
||||
'theme.gaming': 'Jeux vidéo',
|
||||
'theme.news': 'Actualités',
|
||||
'theme.sports': 'Sports',
|
||||
// Downloads
|
||||
'downloads.title': 'Téléchargements',
|
||||
'downloads.refresh': 'Rafraîchir',
|
||||
'downloads.quota': 'Stockage utilisé',
|
||||
'downloads.filter.all': 'Tous',
|
||||
'downloads.filter.active': 'Actifs',
|
||||
'downloads.filter.completed': 'Terminés',
|
||||
'downloads.filter.failed': 'Échoués',
|
||||
'downloads.empty': 'Aucun téléchargement. Lancez-en un depuis une page vidéo.',
|
||||
'downloads.audioOnly': 'Audio seul',
|
||||
'downloads.getFile': 'Obtenir le fichier',
|
||||
'downloads.play': 'Lire',
|
||||
'downloads.fileError': 'Fichier inaccessible. Reconnectez-vous puis réessayez.',
|
||||
'downloads.back': 'Retour aux téléchargements',
|
||||
'downloads.notReady': 'Ce téléchargement n’est pas encore terminé.',
|
||||
'downloads.loadError': 'Impossible de charger ce téléchargement.',
|
||||
'downloads.retry': 'Réessayer',
|
||||
'downloads.state.queued': 'En attente',
|
||||
'downloads.state.running': 'Téléchargement',
|
||||
'downloads.state.merging': 'Fusion',
|
||||
'downloads.state.completed': 'Terminé',
|
||||
'downloads.state.failed': 'Échoué',
|
||||
'downloads.state.interrupted': 'Interrompu',
|
||||
};
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Injectable, computed, signal } from '@angular/core';
|
||||
import { HttpClient } from '@angular/common/http';
|
||||
|
||||
const LS_REGION_KEY = 'newtube.region';
|
||||
const LS_PROVIDER_KEY = 'newtube.provider';
|
||||
@@ -23,6 +24,9 @@ export class InstanceService {
|
||||
{ id: 'odysee', label: 'Odysee' },
|
||||
]);
|
||||
|
||||
// Disponibilité Twitch connue via le serveur (le secret reste côté back)
|
||||
private twitchReadySignal = signal<boolean | null>(null); // null = inconnu
|
||||
|
||||
private storedRegion = ((): string | null => {
|
||||
try { return localStorage.getItem(LS_REGION_KEY); } catch { return null; }
|
||||
})();
|
||||
@@ -33,6 +37,14 @@ export class InstanceService {
|
||||
readonly region = signal<string>(this.storedRegion || 'US');
|
||||
readonly selectedProvider = signal<Provider>(this.storedProvider || 'youtube');
|
||||
|
||||
constructor(private http: HttpClient) {
|
||||
// Demande au serveur si Twitch est configuré (TWITCH_CLIENT_ID/SECRET côté back)
|
||||
this.http.get<any>('/api/twitch-token').subscribe({
|
||||
next: (res) => this.twitchReadySignal.set(Boolean(res?.accessToken)),
|
||||
error: () => this.twitchReadySignal.set(false),
|
||||
});
|
||||
}
|
||||
|
||||
readonly selectedProviderLabel = computed(() => {
|
||||
const cur = this.selectedProvider();
|
||||
return this.providers().find(p => p.id === cur)?.label || cur;
|
||||
@@ -59,13 +71,11 @@ export class InstanceService {
|
||||
}
|
||||
|
||||
if (p === 'twitch') {
|
||||
const im: any = (typeof import.meta !== 'undefined' && (import.meta as any).env) ? (import.meta as any).env : undefined;
|
||||
const hasClientId = !!(g?.TWITCH_CLIENT_ID || im?.TWITCH_CLIENT_ID || g?.process?.env?.TWITCH_CLIENT_ID);
|
||||
const hasSecret = !!(g?.TWITCH_CLIENT_SECRET || im?.TWITCH_CLIENT_SECRET || g?.process?.env?.TWITCH_CLIENT_SECRET);
|
||||
const hasCreds = hasClientId && hasSecret;
|
||||
if (!hasCreds) {
|
||||
return { ready: false, reason: 'Twitch requires TWITCH_CLIENT_ID and TWITCH_CLIENT_SECRET in assets/config.local.js.' };
|
||||
const status = this.twitchReadySignal();
|
||||
if (status === false) {
|
||||
return { ready: false, reason: 'Twitch requires TWITCH_CLIENT_ID and TWITCH_CLIENT_SECRET to be configured on the server (env).' };
|
||||
}
|
||||
// null = serveur pas encore répondu : on considère prêt (le token échouera proprement sinon)
|
||||
return { ready: true };
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,172 @@
|
||||
import 'zone.js/node';
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { HttpClientTestingModule, HttpTestingController } from '@angular/common/http/testing';
|
||||
import { DOCUMENT } from '@angular/common';
|
||||
import { BrowserDynamicTestingModule, platformBrowserDynamicTesting } from '@angular/platform-browser-dynamic/testing';
|
||||
import { SubscriptionsService, type SubscriptionItem } from './subscriptions.service';
|
||||
import type { ChannelMeta, ProviderId } from '../app/shared/models/channel.model';
|
||||
|
||||
const mockDocument = {
|
||||
cookie: '',
|
||||
createElement: () => ({
|
||||
setAttribute() {},
|
||||
}),
|
||||
querySelector: () => null,
|
||||
body: {
|
||||
appendChild() {},
|
||||
removeChild() {},
|
||||
},
|
||||
} as unknown as Document;
|
||||
|
||||
TestBed.resetTestEnvironment();
|
||||
TestBed.initTestEnvironment(BrowserDynamicTestingModule, platformBrowserDynamicTesting(), {
|
||||
teardown: { destroyAfterEach: true, rethrowErrors: true },
|
||||
});
|
||||
|
||||
function setup() {
|
||||
TestBed.resetTestingModule();
|
||||
TestBed.configureTestingModule({
|
||||
imports: [HttpClientTestingModule],
|
||||
providers: [
|
||||
SubscriptionsService,
|
||||
{ provide: DOCUMENT, useValue: mockDocument },
|
||||
],
|
||||
});
|
||||
const service = TestBed.inject(SubscriptionsService);
|
||||
const httpMock = TestBed.inject(HttpTestingController);
|
||||
return { service, httpMock };
|
||||
}
|
||||
|
||||
function assertEqual(actual: unknown, expected: unknown, message: string): void {
|
||||
const a = JSON.stringify(actual);
|
||||
const b = JSON.stringify(expected);
|
||||
if (a !== b) {
|
||||
throw new Error(`${message} (expected ${b}, got ${a})`);
|
||||
}
|
||||
}
|
||||
|
||||
function assert(condition: unknown, message: string): void {
|
||||
if (!condition) {
|
||||
throw new Error(message);
|
||||
}
|
||||
}
|
||||
|
||||
function makeChannel(provider: ProviderId, externalId: string, overrides: Partial<ChannelMeta> = {}): ChannelMeta {
|
||||
return {
|
||||
provider,
|
||||
externalId,
|
||||
id: overrides.id ?? 1,
|
||||
title: overrides.title ?? 'Channel Title',
|
||||
handle: overrides.handle ?? '@channel',
|
||||
avatarUrl: overrides.avatarUrl ?? 'https://example.com/avatar.jpg',
|
||||
url: overrides.url ?? 'https://example.com',
|
||||
subsCount: overrides.subsCount ?? 1234,
|
||||
verified: overrides.verified ?? true,
|
||||
lastRefreshedAt: overrides.lastRefreshedAt ?? Date.now(),
|
||||
};
|
||||
}
|
||||
|
||||
function testListCachesResults(): void {
|
||||
const { service, httpMock } = setup();
|
||||
const channel = makeChannel('yt', 'abc');
|
||||
const subscription: SubscriptionItem = {
|
||||
subscriptionId: 101,
|
||||
createdAt: Date.now(),
|
||||
channel,
|
||||
};
|
||||
|
||||
let received: SubscriptionItem[] | undefined;
|
||||
service.list().subscribe(items => (received = items));
|
||||
|
||||
const req = httpMock.expectOne('/api/subscriptions');
|
||||
assert(req.request.method === 'GET', 'list() should perform GET request');
|
||||
req.flush({ items: [subscription], ttl: 600000 });
|
||||
|
||||
assertEqual(received, [subscription], 'list() should emit fetched subscriptions');
|
||||
assertEqual(service.items(), [subscription], 'items signal should reflect fetched subscriptions');
|
||||
httpMock.verify();
|
||||
}
|
||||
|
||||
function testSubscribeAppendsToCache(): void {
|
||||
const { service, httpMock } = setup();
|
||||
|
||||
const baseChannel = makeChannel('yt', 'abc');
|
||||
const base: SubscriptionItem = {
|
||||
subscriptionId: 1,
|
||||
createdAt: Date.now() - 1000,
|
||||
channel: baseChannel,
|
||||
};
|
||||
|
||||
service.list().subscribe();
|
||||
httpMock.expectOne('/api/subscriptions').flush({ items: [base] });
|
||||
|
||||
const newChannel = makeChannel('dm', 'xyz');
|
||||
const newSubscription: SubscriptionItem = {
|
||||
subscriptionId: 2,
|
||||
createdAt: Date.now(),
|
||||
channel: newChannel,
|
||||
};
|
||||
|
||||
let subscribed: SubscriptionItem | null = null;
|
||||
service.subscribe('dm', 'xyz').subscribe(result => (subscribed = result));
|
||||
|
||||
const req = httpMock.expectOne('/api/subscriptions');
|
||||
assert(req.request.method === 'POST', 'subscribe() should perform POST request');
|
||||
assertEqual(req.request.body, { provider: 'dm', externalId: 'xyz' }, 'subscribe() should send provider/externalId payload');
|
||||
req.flush(newSubscription);
|
||||
|
||||
assertEqual(subscribed, newSubscription, 'subscribe() should emit created subscription');
|
||||
assertEqual(
|
||||
service.items().map(item => item.subscriptionId),
|
||||
[newSubscription.subscriptionId, base.subscriptionId],
|
||||
'subscribe() should prepend new subscription into cache'
|
||||
);
|
||||
httpMock.verify();
|
||||
}
|
||||
|
||||
function testUnsubscribeRemovesFromCache(): void {
|
||||
const { service, httpMock } = setup();
|
||||
|
||||
const channel = makeChannel('pt', 'foo');
|
||||
const item: SubscriptionItem = {
|
||||
subscriptionId: 44,
|
||||
createdAt: Date.now(),
|
||||
channel,
|
||||
};
|
||||
|
||||
service.list().subscribe();
|
||||
httpMock.expectOne('/api/subscriptions').flush({ items: [item] });
|
||||
|
||||
let result: boolean | null = null;
|
||||
service.unsubscribe(item.subscriptionId).subscribe(ok => (result = ok));
|
||||
|
||||
const req = httpMock.expectOne(`/api/subscriptions/${item.subscriptionId}`);
|
||||
assert(req.request.method === 'DELETE', 'unsubscribe() should perform DELETE request');
|
||||
req.flush({}, { status: 204, statusText: 'No Content' });
|
||||
|
||||
assert(result === true, 'unsubscribe() should emit true when deletion succeeds');
|
||||
assert(service.items().length === 0, 'unsubscribe() should remove subscription from cache');
|
||||
httpMock.verify();
|
||||
}
|
||||
|
||||
const tests: Array<[string, () => void]> = [
|
||||
['list() caches fetched subscriptions', testListCachesResults],
|
||||
['subscribe() appends to cache', testSubscribeAppendsToCache],
|
||||
['unsubscribe() removes from cache', testUnsubscribeRemovesFromCache],
|
||||
];
|
||||
|
||||
let failures = 0;
|
||||
for (const [name, fn] of tests) {
|
||||
try {
|
||||
fn();
|
||||
console.log(`✓ ${name}`);
|
||||
} catch (error) {
|
||||
failures += 1;
|
||||
console.error(`✗ ${name}`);
|
||||
console.error(error);
|
||||
}
|
||||
}
|
||||
|
||||
if (failures > 0) {
|
||||
process.exitCode = 1;
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
import { Injectable, computed, inject, signal } from '@angular/core';
|
||||
import { HttpClient } from '@angular/common/http';
|
||||
import { catchError, map, tap } from 'rxjs/operators';
|
||||
import { Observable, of } from 'rxjs';
|
||||
import type { ChannelMeta, ProviderId } from '../app/shared/models/channel.model';
|
||||
|
||||
export interface SubscriptionItem {
|
||||
subscriptionId: number;
|
||||
createdAt: number;
|
||||
channel: ChannelMeta;
|
||||
}
|
||||
|
||||
interface ListResponse {
|
||||
items: SubscriptionItem[];
|
||||
ttl?: number;
|
||||
}
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class SubscriptionsService {
|
||||
private http = inject(HttpClient);
|
||||
private subscriptions = signal<SubscriptionItem[]>([]);
|
||||
private ttlMs = signal<number | null>(null);
|
||||
private loading = signal<boolean>(false);
|
||||
private lastErrorMsg = signal<string | null>(null);
|
||||
|
||||
readonly items = computed(() => this.subscriptions());
|
||||
readonly isLoading = computed(() => this.loading());
|
||||
/** Dernière erreur d'abonnement/désabonnement (affichée par les pages). */
|
||||
readonly lastError = computed(() => this.lastErrorMsg());
|
||||
|
||||
clearError(): void {
|
||||
this.lastErrorMsg.set(null);
|
||||
}
|
||||
|
||||
list(): Observable<SubscriptionItem[]> {
|
||||
this.loading.set(true);
|
||||
return this.http.get<ListResponse>('/api/subscriptions').pipe(
|
||||
tap((resp) => {
|
||||
const items = (resp.items || []).filter((item): item is SubscriptionItem => Boolean(item?.channel));
|
||||
this.subscriptions.set(items);
|
||||
this.ttlMs.set(typeof resp.ttl === 'number' ? resp.ttl : null);
|
||||
this.loading.set(false);
|
||||
}),
|
||||
map((resp) => resp.items || []),
|
||||
catchError((err) => {
|
||||
console.error('Failed to load subscriptions', err);
|
||||
this.loading.set(false);
|
||||
return of([]);
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
subscribe(provider: ProviderId, externalId: string, opts?: { refresh?: boolean }): Observable<SubscriptionItem | null> {
|
||||
const body: any = { provider, externalId };
|
||||
if (opts?.refresh) body.refresh = true;
|
||||
this.lastErrorMsg.set(null);
|
||||
return this.http.post<SubscriptionItem>('/api/subscriptions', body).pipe(
|
||||
tap((sub) => {
|
||||
if (!sub || !sub.channel) return;
|
||||
const current = this.subscriptions();
|
||||
const exists = current.some((item) => item.subscriptionId === sub.subscriptionId);
|
||||
if (!exists) this.subscriptions.set([sub, ...current]);
|
||||
}),
|
||||
catchError((err) => {
|
||||
console.error('Failed to subscribe', err);
|
||||
this.lastErrorMsg.set(
|
||||
err?.status === 401
|
||||
? 'Session expirée. Reconnectez-vous pour vous abonner.'
|
||||
: "Abonnement impossible. Réessayez."
|
||||
);
|
||||
return of(null);
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
unsubscribe(subscriptionId: number): Observable<boolean> {
|
||||
this.lastErrorMsg.set(null);
|
||||
return this.http.delete(`/api/subscriptions/${subscriptionId}`).pipe(
|
||||
tap(() => {
|
||||
const current = this.subscriptions();
|
||||
this.subscriptions.set(current.filter((item) => item.subscriptionId !== subscriptionId));
|
||||
}),
|
||||
map(() => true),
|
||||
catchError((err) => {
|
||||
console.error('Failed to unsubscribe', err);
|
||||
this.lastErrorMsg.set(
|
||||
err?.status === 401
|
||||
? 'Session expirée. Reconnectez-vous pour gérer vos abonnements.'
|
||||
: 'Désabonnement impossible. Réessayez.'
|
||||
);
|
||||
return of(false);
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
isSubscribed(provider: ProviderId, externalId: string): boolean {
|
||||
const current = this.subscriptions();
|
||||
return current.some((item) => item.channel?.provider === provider && item.channel?.externalId === externalId);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
import { Injectable, inject } from '@angular/core';
|
||||
import { HttpClient } from '@angular/common/http';
|
||||
import { catchError, of } from 'rxjs';
|
||||
|
||||
export type TelemetryEvent =
|
||||
| 'search_submit'
|
||||
| 'provider_picker_open'
|
||||
| 'provider_apply'
|
||||
| 'at_autocomplete_use'
|
||||
| 'quick_menu_open';
|
||||
|
||||
export interface TelemetryMeta {
|
||||
providers?: string[];
|
||||
queryLength?: number;
|
||||
resultCount?: number;
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
const MAX_META_KEYS = 20;
|
||||
const MAX_STRING_LENGTH = 200;
|
||||
|
||||
/**
|
||||
* Step 13 — Minimal telemetry hooks for the unified search UX.
|
||||
*
|
||||
* Fire-and-forget: never blocks or breaks the UI (errors swallowed, bounded payload).
|
||||
* Events are whitelisted server-side; unknown names are rejected with 400 and ignored.
|
||||
*/
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class TelemetryService {
|
||||
private http = inject(HttpClient);
|
||||
// De-dup guard: avoid double-emitting identical events within a short window
|
||||
private recent = new Map<string, number>();
|
||||
|
||||
track(event: TelemetryEvent, meta?: TelemetryMeta): void {
|
||||
try {
|
||||
const key = `${event}|${JSON.stringify(meta ?? {})}`;
|
||||
const now = Date.now();
|
||||
// Suppress identical event bursts within 500ms (double-click / double-handler)
|
||||
const last = this.recent.get(key);
|
||||
if (last && now - last < 500) return;
|
||||
// Keep the map bounded
|
||||
if (this.recent.size > 100) this.recent.clear();
|
||||
this.recent.set(key, now);
|
||||
|
||||
const safeMeta = this.sanitizeMeta(meta);
|
||||
this.http
|
||||
.post('/api/telemetry/events', { event, meta: safeMeta }, { withCredentials: true })
|
||||
.pipe(catchError(() => of(null)))
|
||||
.subscribe();
|
||||
} catch {
|
||||
// Telemetry must never break the UI
|
||||
}
|
||||
}
|
||||
|
||||
private sanitizeMeta(meta?: TelemetryMeta): TelemetryMeta | null {
|
||||
if (!meta || typeof meta !== 'object') return null;
|
||||
const out: TelemetryMeta = {};
|
||||
let n = 0;
|
||||
for (const [k, v] of Object.entries(meta)) {
|
||||
if (n >= MAX_META_KEYS) break;
|
||||
if (typeof v === 'string') {
|
||||
out[k] = v.slice(0, MAX_STRING_LENGTH);
|
||||
n += 1;
|
||||
} else if (typeof v === 'number' && Number.isFinite(v)) {
|
||||
out[k] = v;
|
||||
n += 1;
|
||||
} else if (Array.isArray(v)) {
|
||||
out[k] = v.slice(0, 10).map(x => (typeof x === 'string' ? x.slice(0, MAX_STRING_LENGTH) : x));
|
||||
n += 1;
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
}
|
||||
@@ -14,6 +14,8 @@ export interface PagedResult<T> {
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class YoutubeApiService {
|
||||
private twitchAuthToken$ = new BehaviorSubject<string | null>(null);
|
||||
// Client ID renvoyé par le serveur avec le token (source de vérité côté back)
|
||||
private twitchServerClientId: string | null = null;
|
||||
// Quota exceeded flag to surface to UI components
|
||||
public quotaExceeded$ = new BehaviorSubject<boolean>(false);
|
||||
|
||||
@@ -284,11 +286,15 @@ export class YoutubeApiService {
|
||||
return out;
|
||||
}),
|
||||
catchError((e) => {
|
||||
// Surface quotaExceeded if present
|
||||
// Surface quotaExceeded if present + ban dead keys (quota ou clé expirée/invalide)
|
||||
try {
|
||||
if (e?.status === 403 && (e?.error?.error?.errors?.[0]?.reason === 'quotaExceeded' || /quota/i.test(String(e?.error?.error?.message || e?.message || '')))) {
|
||||
const reason = e?.error?.error?.errors?.[0]?.reason || '';
|
||||
const msg = String(e?.error?.error?.message || e?.message || '');
|
||||
if (e?.status === 403 && (reason === 'quotaExceeded' || /quota/i.test(msg))) {
|
||||
this.quotaExceeded$.next(true);
|
||||
this.banYouTubeKey(key);
|
||||
} else if (e?.status === 400 && (reason === 'API_KEY_INVALID' || /api key (expired|invalid)/i.test(msg))) {
|
||||
this.banYouTubeKey(key);
|
||||
}
|
||||
} catch {}
|
||||
console.error('YouTube trending error', e);
|
||||
@@ -329,7 +335,7 @@ export class YoutubeApiService {
|
||||
limit: String(limit),
|
||||
page: String(page),
|
||||
country: String(region).toLowerCase(),
|
||||
fields: 'id,title,thumbnail_url,thumbnail_480_url,thumbnail_720_url,duration,views_total,owner.screenname,owner.avatar_80_url,created_time',
|
||||
fields: 'id,title,thumbnail_url,thumbnail_480_url,thumbnail_720_url,duration,views_total,owner.id,owner.screenname,owner.avatar_80_url,created_time',
|
||||
});
|
||||
return this.fetchDailymotion('/videos', params).pipe(
|
||||
map(res => {
|
||||
@@ -346,23 +352,7 @@ export class YoutubeApiService {
|
||||
}
|
||||
case 'twitch': {
|
||||
// Use Helix Streams for a "trending" like experience (popular live streams)
|
||||
return this.getTwitchAuthToken().pipe(
|
||||
switchMap(token => {
|
||||
if (!token) return of({ items: [], nextCursor: null });
|
||||
const headers = new HttpHeaders({ 'Client-ID': this.resolveTwitchClientId()!, 'Authorization': `Bearer ${token}` });
|
||||
const after = cursor ? `&after=${encodeURIComponent(cursor)}` : '';
|
||||
const primary = `/api/twitch-api/helix/streams?first=24${after}`;
|
||||
const fallback = `/proxy/twitch-api/helix/streams?first=24${after}`;
|
||||
return this.http.get<any>(primary, { headers }).pipe(
|
||||
catchError(() => this.http.get<any>(fallback, { headers })),
|
||||
map(res => ({
|
||||
items: (res.data || []).map((i: any) => this.mapTwitchStreamToVideo(i)),
|
||||
nextCursor: res.pagination?.cursor || null,
|
||||
} as PagedResult<Video>)),
|
||||
catchError((e) => { console.error('Twitch trending error', e); return of({ items: [], nextCursor: null }); })
|
||||
);
|
||||
})
|
||||
);
|
||||
return this.getTwitchTopStreamsPage(cursor);
|
||||
}
|
||||
case 'rumble': {
|
||||
// Rumble utilise maintenant le web scraping côté serveur, pas d'API key requise
|
||||
@@ -423,6 +413,8 @@ export class YoutubeApiService {
|
||||
regionCode: String(region),
|
||||
// Add safeSearch for better content filtering
|
||||
safeSearch: 'moderate',
|
||||
// Only return videos playable in an iframe (avoids player error 153).
|
||||
videoEmbeddable: 'true',
|
||||
key: String(key),
|
||||
});
|
||||
// Prefer results matching user's language when available
|
||||
@@ -452,7 +444,11 @@ export class YoutubeApiService {
|
||||
map((stats) => {
|
||||
const enriched = out.items.map(v => {
|
||||
const s = stats[v.videoId];
|
||||
return s ? { ...v, duration: s.duration, views: s.views } : v;
|
||||
if (!s) return v;
|
||||
// duration -1 = non-embeddable (erreur 153) : on garde la
|
||||
// vidéo en recherche (avec durée 0) mais les Shorts la filtrent.
|
||||
const duration = typeof s.duration === 'number' && s.duration >= 0 ? s.duration : 0;
|
||||
return { ...v, duration, views: s.views };
|
||||
});
|
||||
const next = { ...out, items: enriched } as PagedResult<Video>;
|
||||
this.searchCache.set(cacheKey, { ts: now, data: next });
|
||||
@@ -461,11 +457,15 @@ export class YoutubeApiService {
|
||||
);
|
||||
}),
|
||||
catchError((e) => {
|
||||
// Surface quotaExceeded if present
|
||||
// Surface quotaExceeded if present + ban dead keys (quota ou clé expirée/invalide)
|
||||
try {
|
||||
if (e?.status === 403 && (e?.error?.error?.errors?.[0]?.reason === 'quotaExceeded' || /quota/i.test(String(e?.error?.error?.message || e?.message || '')))) {
|
||||
const reason = e?.error?.error?.errors?.[0]?.reason || '';
|
||||
const msg = String(e?.error?.error?.message || e?.message || '');
|
||||
if (e?.status === 403 && (reason === 'quotaExceeded' || /quota/i.test(msg))) {
|
||||
this.quotaExceeded$.next(true);
|
||||
this.banYouTubeKey(key);
|
||||
} else if (e?.status === 400 && (reason === 'API_KEY_INVALID' || /api key (expired|invalid)/i.test(msg))) {
|
||||
this.banYouTubeKey(key);
|
||||
}
|
||||
} catch {}
|
||||
console.error('YouTube search error', e);
|
||||
@@ -551,10 +551,27 @@ export class YoutubeApiService {
|
||||
nextCursor: next,
|
||||
} as PagedResult<Video>;
|
||||
}),
|
||||
// Si la recherche est vide (scraper challengé / page changée),
|
||||
// repli sur le flux trending pour ne jamais afficher un bloc vide.
|
||||
switchMap(out => {
|
||||
if ((out?.items?.length || 0) > 0) return of(out);
|
||||
const page = 1;
|
||||
const bParams = new URLSearchParams({ sort: 'trending', limit: '24', page: String(page) });
|
||||
return this.fetchRumble('/browse', bParams).pipe(
|
||||
map(res => {
|
||||
const list = (res?.items || res?.result || res?.data || []) as any[];
|
||||
return { items: list.map(i => this.mapRumbleItemToVideo(i)), nextCursor: null } as PagedResult<Video>;
|
||||
}),
|
||||
catchError(() => of(out))
|
||||
);
|
||||
}),
|
||||
catchError((e) => { console.error('Rumble search error', e); return of({ items: [], nextCursor: null }); })
|
||||
);
|
||||
}
|
||||
case 'twitch': {
|
||||
// Recherche thématique robuste : chaînes live d'abord, puis VODs +
|
||||
// clips, avec fallback top streams (évite les blocs vides sur les
|
||||
// pages de thèmes où la recherche Helix pure ne matche rien).
|
||||
return this.getTwitchAuthToken().pipe(
|
||||
switchMap(token => {
|
||||
if (!token) return of({ items: [], nextCursor: null });
|
||||
@@ -571,6 +588,18 @@ export class YoutubeApiService {
|
||||
nextCursor: res.pagination?.cursor || null,
|
||||
} as PagedResult<Video>;
|
||||
}),
|
||||
// Si la recherche live est vide/maigre, compléter avec VODs+clips puis top streams.
|
||||
switchMap(liveRes => {
|
||||
if ((liveRes?.items?.length || 0) >= 4) return of(liveRes);
|
||||
return this.searchTwitchThemePage(query, cursor).pipe(
|
||||
map(themeRes => {
|
||||
const seen = new Set((liveRes.items || []).map(v => v.videoId));
|
||||
const extra = (themeRes?.items || []).filter(v => !seen.has(v.videoId));
|
||||
return { items: [...(liveRes.items || []), ...extra], nextCursor: themeRes?.nextCursor || liveRes.nextCursor } as PagedResult<Video>;
|
||||
}),
|
||||
catchError(() => of(liveRes))
|
||||
);
|
||||
}),
|
||||
catchError((e) => { console.error('Twitch search error', e); return of({ items: [], nextCursor: null }); })
|
||||
);
|
||||
})
|
||||
@@ -603,6 +632,7 @@ export class YoutubeApiService {
|
||||
const cd = i.contentDetails || {};
|
||||
const thumb = sn.thumbnails?.high?.url || sn.thumbnails?.medium?.url || sn.thumbnails?.default?.url || '';
|
||||
const publishedAt = sn.publishedAt || '';
|
||||
const channelId = sn.channelId || '';
|
||||
return {
|
||||
url: `/watch/${id}`,
|
||||
videoId: id,
|
||||
@@ -611,8 +641,13 @@ export class YoutubeApiService {
|
||||
title: sn.title || '',
|
||||
thumbnail: thumb,
|
||||
uploaderName: sn.channelTitle || '',
|
||||
uploaderUrl: '',
|
||||
uploaderAvatar: thumb, // placeholder; channel avatar would need another call
|
||||
uploaderUrl: channelId ? `https://www.youtube.com/channel/${channelId}` : '',
|
||||
// Ne jamais utiliser la miniature vidéo comme avatar chaîne : la page /watch
|
||||
// résout le vrai avatar via /api/details puis /api/channels, avec fallback initiales.
|
||||
uploaderAvatar: '',
|
||||
channelId: channelId || undefined,
|
||||
channelExternalId: channelId || undefined,
|
||||
channelAvatarUrl: undefined,
|
||||
uploadedDate: publishedAt,
|
||||
duration: this.parseISODurationToSeconds(cd.duration || 'PT0S'),
|
||||
views: Number(stats.viewCount || 0),
|
||||
@@ -625,6 +660,7 @@ export class YoutubeApiService {
|
||||
const sn = i.snippet || {};
|
||||
const thumb = sn.thumbnails?.high?.url || sn.thumbnails?.medium?.url || sn.thumbnails?.default?.url || '';
|
||||
const publishedAt = sn.publishedAt || '';
|
||||
const channelId = sn.channelId || '';
|
||||
return {
|
||||
url: `/watch/${id}`,
|
||||
videoId: id,
|
||||
@@ -633,8 +669,11 @@ export class YoutubeApiService {
|
||||
title: sn.title || '',
|
||||
thumbnail: thumb,
|
||||
uploaderName: sn.channelTitle || '',
|
||||
uploaderUrl: '',
|
||||
uploaderAvatar: thumb,
|
||||
uploaderUrl: channelId ? `https://www.youtube.com/channel/${channelId}` : '',
|
||||
uploaderAvatar: '',
|
||||
channelId: channelId || undefined,
|
||||
channelExternalId: channelId || undefined,
|
||||
channelAvatarUrl: undefined,
|
||||
uploadedDate: publishedAt,
|
||||
duration: 0,
|
||||
views: 0,
|
||||
@@ -653,6 +692,9 @@ export class YoutubeApiService {
|
||||
uploaderName: i['owner.screenname'] || '',
|
||||
uploaderUrl: '',
|
||||
uploaderAvatar: i['owner.avatar_80_url'] || i.thumbnail_480_url || i.thumbnail_url || '',
|
||||
channelId: i['owner.id'] || undefined,
|
||||
channelExternalId: i['owner.id'] || undefined,
|
||||
channelAvatarUrl: i['owner.avatar_80_url'] || undefined,
|
||||
uploadedDate: i.created_time ? new Date(i.created_time * 1000).toISOString() : '',
|
||||
duration: Number(i.duration || 0),
|
||||
views: Number(i.views_total || 0),
|
||||
@@ -681,6 +723,9 @@ export class YoutubeApiService {
|
||||
uploaderName,
|
||||
uploaderUrl,
|
||||
uploaderAvatar: uploaderAvatar || thumbnail,
|
||||
channelId: i.account?.name || i.channel?.name || undefined,
|
||||
channelExternalId: i.account?.name || i.channel?.name || undefined,
|
||||
channelAvatarUrl: uploaderAvatar || undefined,
|
||||
uploadedDate: i.publishedAt || '',
|
||||
duration: Number(i.duration || 0),
|
||||
views: Number(i.views || 0),
|
||||
@@ -706,6 +751,9 @@ export class YoutubeApiService {
|
||||
const duration = this.normalizeDuration(rawDuration);
|
||||
const views = (i?.meta?.view_count != null) ? Number(i.meta.view_count) : 0;
|
||||
const uploaderName = channelVal?.title || channel?.name || '';
|
||||
const channelClaimId = channel?.claim_id || channel?.claimId || '';
|
||||
const channelHandle = channel?.name || '';
|
||||
const channelExternalId = channelClaimId || channelHandle.replace(/^@/, '') || '';
|
||||
|
||||
return {
|
||||
url: webUrl,
|
||||
@@ -717,6 +765,10 @@ export class YoutubeApiService {
|
||||
uploaderName,
|
||||
uploaderUrl: channel?.permanent_url ? this.lbryToOdyseeUrl(channel.permanent_url) : '',
|
||||
uploaderAvatar: this.proxiedOdyseeThumb(channelVal?.thumbnail?.url) || thumb,
|
||||
channelId: channelClaimId || undefined,
|
||||
channelHandle: channelHandle || undefined,
|
||||
channelExternalId: channelExternalId || undefined,
|
||||
channelAvatarUrl: this.proxiedOdyseeThumb(channelVal?.thumbnail?.url) || undefined,
|
||||
uploadedDate: publishedAt,
|
||||
duration,
|
||||
views: views,
|
||||
@@ -731,6 +783,8 @@ export class YoutubeApiService {
|
||||
let thumb = i?.thumbnail || i?.thumbnail_url || i?.image || '';
|
||||
const uploaderName = i?.author || i?.uploader || i?.channel?.name || i?.uploaderName || '';
|
||||
const uploaderAvatar = i?.author_avatar || i?.uploader_avatar || i?.channel?.avatar || i?.uploaderAvatar || thumb || '';
|
||||
const channelSlug = i?.channel?.slug || i?.channel_slug || (i?.channel?.url ? i.channel.url.replace(/^https?:\/\/rumble.com\//, '').replace(/\/$/, '') : '') || '';
|
||||
const channelExternalId = channelSlug || (uploaderName ? uploaderName.replace(/\s+/g, '').toLowerCase() : '');
|
||||
const uploadedDate = i?.published_at || i?.created_at || i?.uploadedDate || '';
|
||||
const views = Number(i?.views || i?.view_count || 0);
|
||||
const duration = this.normalizeDuration(
|
||||
@@ -748,6 +802,7 @@ export class YoutubeApiService {
|
||||
// Best-effort: construct a thumbnail CDN path if present in URL pattern (fallback to uploader avatar)
|
||||
thumb = uploaderAvatar || '';
|
||||
}
|
||||
const nativeShort = (i as any)?.isShort === true;
|
||||
return {
|
||||
url,
|
||||
videoId: id,
|
||||
@@ -758,10 +813,15 @@ export class YoutubeApiService {
|
||||
uploaderName,
|
||||
uploaderUrl: i?.channel_url || '',
|
||||
uploaderAvatar,
|
||||
channelId: channelExternalId || undefined,
|
||||
channelExternalId: channelExternalId || undefined,
|
||||
channelAvatarUrl: uploaderAvatar || undefined,
|
||||
uploadedDate,
|
||||
duration,
|
||||
views,
|
||||
uploaded: uploadedDate ? Date.parse(uploadedDate) : 0,
|
||||
// Flag natif propagé du scraper serveur ; sinon durée courte ⇒ Short.
|
||||
isShort: nativeShort || (duration > 0 && duration <= 75),
|
||||
} as Video;
|
||||
};
|
||||
|
||||
@@ -776,6 +836,9 @@ export class YoutubeApiService {
|
||||
uploaderName: i.user_name || '',
|
||||
uploaderUrl: `https://www.twitch.tv/${i.user_login}`,
|
||||
uploaderAvatar: '', // Requires another API call
|
||||
channelId: i.user_id || undefined,
|
||||
channelHandle: i.user_login || undefined,
|
||||
channelExternalId: i.user_login || undefined,
|
||||
uploadedDate: i.created_at || '',
|
||||
duration: this.parseTwitchDurationToSeconds(i.duration || '0s'),
|
||||
views: i.view_count || 0,
|
||||
@@ -793,6 +856,10 @@ export class YoutubeApiService {
|
||||
uploaderName: i.user_name || '',
|
||||
uploaderUrl: `https://www.twitch.tv/${i.user_login}`,
|
||||
uploaderAvatar: (i.thumbnail_url || '').replace('{width}', '70').replace('{height}', '70'),
|
||||
channelId: i.user_id || undefined,
|
||||
channelHandle: i.user_login || undefined,
|
||||
channelExternalId: i.user_login || undefined,
|
||||
channelAvatarUrl: (i.thumbnail_url || '').replace('{width}', '70').replace('{height}', '70') || undefined,
|
||||
uploadedDate: i.started_at || '',
|
||||
duration: 0,
|
||||
views: Number(i.viewer_count || 0),
|
||||
@@ -877,6 +944,107 @@ export class YoutubeApiService {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Top live streams Twitch (Helix /streams) — utilisé comme "trending" Twitch
|
||||
* et comme filet de sécurité quand une recherche thématique ne retourne rien
|
||||
* (la recherche de chaînes Helix exige des noms proches de la requête et
|
||||
* filtre souvent tout pour des thèmes génériques comme "sports" ou "tech").
|
||||
*/
|
||||
public getTwitchTopStreamsPage(cursor?: string | null, limit = 24): Observable<PagedResult<Video>> {
|
||||
return this.getTwitchAuthToken().pipe(
|
||||
switchMap(token => {
|
||||
if (!token) return of({ items: [], nextCursor: null });
|
||||
const headers = new HttpHeaders({ 'Client-ID': this.resolveTwitchClientId()!, 'Authorization': `Bearer ${token}` });
|
||||
const after = cursor ? `&after=${encodeURIComponent(cursor)}` : '';
|
||||
const primary = `/api/twitch-api/helix/streams?first=${limit}${after}`;
|
||||
const fallback = `/proxy/twitch-api/helix/streams?first=${limit}${after}`;
|
||||
return this.http.get<any>(primary, { headers }).pipe(
|
||||
catchError(() => this.http.get<any>(fallback, { headers })),
|
||||
map(res => ({
|
||||
items: (res.data || []).map((i: any) => this.mapTwitchStreamToVideo(i)),
|
||||
nextCursor: res.pagination?.cursor || null,
|
||||
} as PagedResult<Video>)),
|
||||
catchError((e) => { console.error('Twitch top streams error', e); return of({ items: [], nextCursor: null }); })
|
||||
);
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Recherche thématique Twitch robuste : VODs puis clips, avec fallback vers
|
||||
* les top streams live si les deux sont quasi vides (évite les blocs vides
|
||||
* sur les pages de thèmes). `minResults` = seuil sous lequel on complète.
|
||||
*/
|
||||
public searchTwitchThemePage(query: string, cursor?: string | null, minResults = 4): Observable<PagedResult<Video>> {
|
||||
return this.searchTwitchVodsPage(query, cursor).pipe(
|
||||
switchMap(vods => {
|
||||
if ((vods?.items?.length || 0) >= minResults) return of(vods);
|
||||
return this.searchTwitchClipsPage(query, cursor).pipe(
|
||||
map(clips => ({
|
||||
items: [...(vods?.items || []), ...(clips?.items || [])],
|
||||
nextCursor: clips?.nextCursor || vods?.nextCursor || null,
|
||||
} as PagedResult<Video>)),
|
||||
switchMap(merged => {
|
||||
if ((merged?.items?.length || 0) >= minResults) return of(merged);
|
||||
// Filet de sécurité : top streams live (jamais vide quand Twitch est configuré)
|
||||
return this.getTwitchTopStreamsPage(cursor).pipe(
|
||||
map(top => {
|
||||
if (!top.items.length) return merged;
|
||||
const seen = new Set((merged.items || []).map(v => v.videoId));
|
||||
const extra = top.items.filter(v => !seen.has(v.videoId));
|
||||
return { items: [...(merged.items || []), ...extra], nextCursor: top.nextCursor } as PagedResult<Video>;
|
||||
}),
|
||||
catchError(() => of(merged))
|
||||
);
|
||||
})
|
||||
);
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Clips Twitch sans requête (page Shorts) : la recherche de chaînes Helix
|
||||
* rejette les requêtes vides, on part donc des top streams pour obtenir des
|
||||
* broadcaster_id valides puis on récupère leurs clips récents.
|
||||
*/
|
||||
public getTwitchClipsFeed(cursor?: string | null): Observable<PagedResult<Video>> {
|
||||
return this.getTwitchAuthToken().pipe(
|
||||
switchMap(token => {
|
||||
if (!token) return of({ items: [], nextCursor: null });
|
||||
const headers = new HttpHeaders({ 'Client-ID': this.resolveTwitchClientId()!, 'Authorization': `Bearer ${token}` });
|
||||
const after = cursor ? `&after=${encodeURIComponent(cursor)}` : '';
|
||||
const sPrimary = `/api/twitch-api/helix/streams?first=10${after}`;
|
||||
const sFallback = `/proxy/twitch-api/helix/streams?first=10${after}`;
|
||||
return this.http.get<any>(sPrimary, { headers }).pipe(
|
||||
catchError(() => this.http.get<any>(sFallback, { headers })),
|
||||
switchMap(sRes => {
|
||||
const streams = Array.isArray(sRes?.data) ? sRes.data : [];
|
||||
const userIds: string[] = streams.map((s: any) => s?.user_id).filter(Boolean).slice(0, 10);
|
||||
if (!userIds.length) return of({ items: [], nextCursor: null } as PagedResult<Video>);
|
||||
const since = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000).toISOString();
|
||||
const perUser = userIds.map(uid => {
|
||||
const cPrimary = `/api/twitch-api/helix/clips?broadcaster_id=${encodeURIComponent(uid)}&first=5&started_at=${encodeURIComponent(since)}`;
|
||||
const cFallback = `/proxy/twitch-api/helix/clips?broadcaster_id=${encodeURIComponent(uid)}&first=5&started_at=${encodeURIComponent(since)}`;
|
||||
return this.http.get<any>(cPrimary, { headers }).pipe(
|
||||
catchError(() => this.http.get<any>(cFallback, { headers })),
|
||||
map(res => Array.isArray(res?.data) ? res.data : [])
|
||||
);
|
||||
});
|
||||
return forkJoin(perUser).pipe(
|
||||
map((lists: any[][]) => lists.flat().slice(0, 24)),
|
||||
map((clips: any[]) => ({
|
||||
items: clips.map((i: any) => this.mapTwitchClipToVideo(i)),
|
||||
nextCursor: sRes?.pagination?.cursor || null,
|
||||
} as PagedResult<Video>)),
|
||||
catchError(() => of({ items: [], nextCursor: null } as PagedResult<Video>))
|
||||
);
|
||||
}),
|
||||
catchError((e) => { console.error('Twitch clips feed error', e); return of({ items: [], nextCursor: null }); })
|
||||
);
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
private parseISODurationToSeconds(iso: string): number {
|
||||
// ... (rest of the code remains the same)
|
||||
const m = /^PT(?:(\d+)H)?(?:(\d+)M)?(?:(\d+)S)?$/i.exec(iso || '');
|
||||
@@ -960,23 +1128,20 @@ export class YoutubeApiService {
|
||||
|
||||
private resolveTwitchClientId(): string | undefined {
|
||||
const g: any = globalThis as any;
|
||||
return g?.TWITCH_CLIENT_ID || (typeof import.meta !== 'undefined' && (import.meta as any).env?.TWITCH_CLIENT_ID) || g?.process?.env?.TWITCH_CLIENT_ID;
|
||||
return this.twitchServerClientId || g?.TWITCH_CLIENT_ID || (typeof import.meta !== 'undefined' && (import.meta as any).env?.TWITCH_CLIENT_ID) || g?.process?.env?.TWITCH_CLIENT_ID;
|
||||
}
|
||||
|
||||
private getTwitchAuthToken(): Observable<string | null> {
|
||||
const clientId = this.resolveTwitchClientId();
|
||||
const clientSecret = (globalThis as any)?.TWITCH_CLIENT_SECRET || (typeof import.meta !== 'undefined' && (import.meta as any).env?.TWITCH_CLIENT_SECRET) || (globalThis as any)?.process?.env?.TWITCH_CLIENT_SECRET;
|
||||
|
||||
if (!clientId || !clientSecret) {
|
||||
console.warn('Twitch Client ID or Secret not found.');
|
||||
return of(null);
|
||||
}
|
||||
|
||||
const body = `client_id=${clientId}&client_secret=${clientSecret}&grant_type=client_credentials`;
|
||||
const headers = new HttpHeaders({ 'Content-Type': 'application/x-www-form-urlencoded' });
|
||||
|
||||
return this.http.post<any>('/proxy/twitch-auth/oauth2/token', body, { headers }).pipe(
|
||||
map(res => res.access_token),
|
||||
// Le token applicatif est obtenu via notre serveur (/api/twitch-token) :
|
||||
// TWITCH_CLIENT_SECRET ne quitte jamais le backend.
|
||||
return this.http.get<any>('/api/twitch-token').pipe(
|
||||
map(res => {
|
||||
if (res?.accessToken) {
|
||||
if (res?.clientId) this.twitchServerClientId = String(res.clientId);
|
||||
return String(res.accessToken);
|
||||
}
|
||||
return null;
|
||||
}),
|
||||
shareReplay(1),
|
||||
catchError(() => of(null))
|
||||
);
|
||||
@@ -1041,6 +1206,8 @@ export class YoutubeApiService {
|
||||
|
||||
/**
|
||||
* Fetch durations (in seconds) for a list of YouTube video IDs.
|
||||
* Non-embeddable videos (status.embeddable === false) are excluded via
|
||||
* a -1 marker so Shorts/feed callers can drop them (player error 153).
|
||||
*/
|
||||
public getYouTubeDurations(ids: string[]): Observable<Record<string, number>> {
|
||||
const apiKey = this.getActiveYouTubeKey();
|
||||
@@ -1050,7 +1217,7 @@ export class YoutubeApiService {
|
||||
|
||||
const params = new URLSearchParams({
|
||||
key: apiKey,
|
||||
part: 'contentDetails',
|
||||
part: 'contentDetails,status',
|
||||
id: ids.join(','),
|
||||
});
|
||||
|
||||
@@ -1059,7 +1226,13 @@ export class YoutubeApiService {
|
||||
map((items: any[]) => items.reduce((acc: Record<string, number>, item: any) => {
|
||||
const duration = item?.contentDetails?.duration || 'PT0S';
|
||||
const id = String(item?.id || '');
|
||||
if (id) acc[id] = this.parseISODurationToSeconds(duration);
|
||||
if (!id) return acc;
|
||||
// -1 = non-embeddable (à exclure côté Shorts pour éviter l'erreur 153)
|
||||
if (item?.status && item.status.embeddable === false) {
|
||||
acc[id] = -1;
|
||||
return acc;
|
||||
}
|
||||
acc[id] = this.parseISODurationToSeconds(duration);
|
||||
return acc;
|
||||
}, {} as Record<string, number>)),
|
||||
catchError(() => of({}))
|
||||
@@ -1068,13 +1241,14 @@ export class YoutubeApiService {
|
||||
|
||||
/**
|
||||
* Fetch minimal details (duration seconds + viewCount) for a list of YouTube IDs.
|
||||
* Non-embeddable entries get duration -1 so callers can drop them (error 153).
|
||||
*/
|
||||
private getYouTubeContentAndStats(ids: string[]): Observable<Record<string, { duration: number; views: number }>> {
|
||||
const apiKey = this.getActiveYouTubeKey();
|
||||
if (!apiKey || !ids.length) return of({});
|
||||
const params = new URLSearchParams({
|
||||
key: apiKey,
|
||||
part: 'contentDetails,statistics',
|
||||
part: 'contentDetails,statistics,status',
|
||||
id: ids.join(','),
|
||||
maxResults: String(Math.min(50, ids.length)),
|
||||
// Add safeSearch for consistency
|
||||
@@ -1087,6 +1261,10 @@ export class YoutubeApiService {
|
||||
for (const it of items) {
|
||||
const id = String(it?.id || '');
|
||||
if (!id) continue;
|
||||
if (it?.status && it.status.embeddable === false) {
|
||||
out[id] = { duration: -1, views: Number(it?.statistics?.viewCount || 0) };
|
||||
continue;
|
||||
}
|
||||
const duration = this.parseISODurationToSeconds(it?.contentDetails?.duration || 'PT0S');
|
||||
const views = Number(it?.statistics?.viewCount || 0);
|
||||
out[id] = { duration, views };
|
||||
|
||||
Reference in New Issue
Block a user