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:
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user