322 lines
12 KiB
TypeScript
322 lines
12 KiB
TypeScript
import { ChangeDetectionStrategy, Component, computed, inject, signal, HostListener } from '@angular/core';
|
|
import { CommonModule } from '@angular/common';
|
|
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 { Video } from '../../models/video.model';
|
|
|
|
@Component({
|
|
selector: 'app-watch-short',
|
|
standalone: true,
|
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
|
imports: [CommonModule],
|
|
templateUrl: './watch-short.component.html'
|
|
})
|
|
export class WatchShortComponent {
|
|
private api = inject(YoutubeApiService);
|
|
private instances = inject(InstanceService);
|
|
private sanitizer = inject(DomSanitizer);
|
|
|
|
loading = signal(true);
|
|
error = signal<string | null>(null);
|
|
items = signal<Video[]>([]);
|
|
index = signal(0);
|
|
nextCursor = signal<string | null>(null);
|
|
busyMore = signal(false);
|
|
|
|
// scroll/swipe helpers
|
|
private wheelAccum = 0;
|
|
private lastSwipeY: number | null = null;
|
|
private lastScrollTs = 0;
|
|
|
|
readonly provider = computed(() => this.instances.selectedProvider());
|
|
readonly current = computed<Video | null>(() => {
|
|
const list = this.items();
|
|
const i = this.index();
|
|
return (i >= 0 && i < list.length) ? list[i] : null;
|
|
});
|
|
|
|
readonly canPrev = computed(() => this.index() > 0);
|
|
readonly canNext = computed(() => this.index() < this.items().length - 1);
|
|
|
|
readonly embedUrl = computed<SafeResourceUrl | null>(() => {
|
|
const v = this.current();
|
|
if (!v) return null;
|
|
const p = this.provider();
|
|
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}`;
|
|
return this.sanitizer.bypassSecurityTrustResourceUrl(u);
|
|
}
|
|
if (p === 'dailymotion' && v.videoId) {
|
|
const u = `https://www.dailymotion.com/embed/video/${encodeURIComponent(v.videoId)}?autoplay=1&mute=1`;
|
|
return this.sanitizer.bypassSecurityTrustResourceUrl(u);
|
|
}
|
|
if (p === 'twitch' && v.videoId) {
|
|
const parentsSet = new Set<string>([host, 'localhost', '127.0.0.1']);
|
|
const parentParams = Array.from(parentsSet).map(h => `parent=${encodeURIComponent(h)}`).join('&');
|
|
// If it's a clip, use the Clips embed endpoint
|
|
if ((v as any).kind === 'clip') {
|
|
const u = `https://clips.twitch.tv/embed?clip=${encodeURIComponent(v.videoId)}&${parentParams}&autoplay=true`;
|
|
return this.sanitizer.bypassSecurityTrustResourceUrl(u);
|
|
}
|
|
// Otherwise treat it as a channel embed (live stream)
|
|
const base = 'https://player.twitch.tv/';
|
|
const qs = `?channel=${encodeURIComponent(v.videoId)}&${parentParams}&autoplay=false`;
|
|
const u = base + qs;
|
|
return this.sanitizer.bypassSecurityTrustResourceUrl(u);
|
|
}
|
|
if (p === 'peertube' && v.videoId) {
|
|
const inst = this.instances.activePeerTubeInstance();
|
|
const u = `https://${inst}/videos/embed/${encodeURIComponent(v.videoId)}?autoplay=1`;
|
|
return this.sanitizer.bypassSecurityTrustResourceUrl(u);
|
|
}
|
|
if (p === 'odysee') {
|
|
// Try to derive slug from URL
|
|
let slug: string | null = null;
|
|
try {
|
|
const url = new URL(v.url || '');
|
|
slug = url.pathname.startsWith('/') ? url.pathname.slice(1) : url.pathname;
|
|
} catch {}
|
|
if (slug || v.videoId) {
|
|
const target = slug || v.videoId;
|
|
const u = `https://odysee.com/$/embed/${target}?autoplay=1&muted=1`;
|
|
return this.sanitizer.bypassSecurityTrustResourceUrl(u);
|
|
}
|
|
}
|
|
if (p === 'rumble' && v.videoId) {
|
|
const u = `https://rumble.com/embed/${encodeURIComponent(v.videoId)}/?autoplay=2&muted=1`;
|
|
return this.sanitizer.bypassSecurityTrustResourceUrl(u);
|
|
}
|
|
} catch {}
|
|
return null;
|
|
});
|
|
|
|
// 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.');
|
|
}
|
|
}
|
|
|
|
// Filter YouTube results to real Shorts using the Videos API to get durations.
|
|
private async filterYouTubeShorts(list: Video[]): Promise<Video[]> {
|
|
const ids = Array.from(new Set(list.map(v => v.videoId))).slice(0, 50);
|
|
try {
|
|
const durations = await firstValueFrom(this.api.getYouTubeDurations(ids));
|
|
const maxShort = 70; // seconds (include some margin)
|
|
return list.filter(v => {
|
|
const d = durations?.[v.videoId] ?? 0;
|
|
return d > 0 && d <= maxShort;
|
|
});
|
|
} catch {
|
|
return [];
|
|
}
|
|
}
|
|
|
|
constructor() {
|
|
// Initialize feed for Shorts according to current provider
|
|
this.loadFeed();
|
|
}
|
|
|
|
loadFeed(): void {
|
|
this.loading.set(true);
|
|
this.error.set(null);
|
|
const ready = this.instances.getProviderReadiness(this.provider());
|
|
if (!ready.ready) {
|
|
this.loading.set(false);
|
|
this.error.set(ready.reason || 'Provider not ready.');
|
|
return;
|
|
}
|
|
const provider = this.provider();
|
|
// Use provider-specific source for Shorts
|
|
if (provider === 'twitch') {
|
|
// Use Twitch Clips as Shorts
|
|
this.api.searchTwitchClipsPage('')?.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));
|
|
this.items.set(list);
|
|
this.index.set(0);
|
|
this.nextCursor.set(res.nextCursor || null);
|
|
if (list.length === 0) this.afterNoResults(); else this.error.set(null);
|
|
this.loading.set(false);
|
|
},
|
|
error: () => {
|
|
this.error.set('Failed to load Shorts.');
|
|
this.loading.set(false);
|
|
}
|
|
});
|
|
return;
|
|
}
|
|
// Default: search for shorts keywords via generic search
|
|
this.api.searchVideosPage('shorts OR #shorts').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);
|
|
});
|
|
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);
|
|
},
|
|
error: () => {
|
|
this.error.set('Failed to load Shorts.');
|
|
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
|
|
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':
|
|
return (v as any).isShort === true;
|
|
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':
|
|
case 'odysee':
|
|
return true; // Only if provider exposes isShort; otherwise unsupported
|
|
case 'twitch':
|
|
case 'youtube':
|
|
return false;
|
|
default:
|
|
return false;
|
|
}
|
|
}
|
|
|
|
private fetchNextPage(autoAdvance = false) {
|
|
if (this.busyMore() || !this.nextCursor()) return;
|
|
this.busyMore.set(true);
|
|
const cursor = this.nextCursor();
|
|
const provider = this.provider();
|
|
const handle = (res: any) => {
|
|
const raw: Video[] = (res.items || []).filter((v: Video) => !!v.videoId);
|
|
const p = this.provider();
|
|
const apply = (more: Video[]) => {
|
|
const merged = this.items().concat(more);
|
|
this.items.set(merged);
|
|
this.nextCursor.set(res.nextCursor || null);
|
|
if (autoAdvance && more.length > 0) {
|
|
this.index.update(i => Math.min(i + 1, merged.length - 1));
|
|
}
|
|
this.busyMore.set(false);
|
|
};
|
|
if (p === 'youtube' && raw.length) {
|
|
this.filterYouTubeShorts(raw).then((list: Video[]) => apply(list.filter((v: Video) => this.isShort(v)))).catch(() => { apply([]); });
|
|
} else {
|
|
apply(raw.filter((v: Video) => this.isShort(v)));
|
|
}
|
|
};
|
|
|
|
if (provider === 'twitch') {
|
|
this.api.searchTwitchClipsPage('', cursor || undefined)?.subscribe({
|
|
next: handle,
|
|
error: () => { this.busyMore.set(false); }
|
|
});
|
|
return;
|
|
}
|
|
|
|
this.api.searchVideosPage('shorts OR #shorts', cursor || undefined).subscribe({
|
|
next: (res) => {
|
|
handle(res);
|
|
},
|
|
error: () => {
|
|
this.busyMore.set(false);
|
|
}
|
|
});
|
|
}
|
|
|
|
next(): void {
|
|
if (this.canNext()) {
|
|
this.index.update(i => i + 1);
|
|
} else if (this.nextCursor()) {
|
|
this.fetchNextPage(true);
|
|
}
|
|
}
|
|
prev(): void { if (this.canPrev()) this.index.update(i => i - 1); }
|
|
|
|
@HostListener('document:keydown', ['$event'])
|
|
onKey(ev: KeyboardEvent) {
|
|
if (this.items().length === 0) return;
|
|
if (ev.key === 'ArrowDown') { ev.preventDefault(); this.next(); }
|
|
if (ev.key === 'ArrowUp') { ev.preventDefault(); this.prev(); }
|
|
}
|
|
|
|
// Also listen on window to catch events when the cursor is over the iframe
|
|
@HostListener('window:wheel', ['$event'])
|
|
onWindowWheel(ev: WheelEvent) { this.onWheel(ev); }
|
|
@HostListener('window:touchstart', ['$event'])
|
|
onWindowTouchStart(ev: TouchEvent) { this.onTouchStart(ev); }
|
|
@HostListener('window:touchend', ['$event'])
|
|
onWindowTouchEnd(ev: TouchEvent) { this.onTouchEnd(ev); }
|
|
|
|
// Handle mouse wheel on container (bound in template to allow preventDefault)
|
|
onWheel(ev: WheelEvent) {
|
|
if (this.items().length === 0) return;
|
|
try { ev.preventDefault(); ev.stopPropagation(); } catch {}
|
|
const now = Date.now();
|
|
if (now - this.lastScrollTs < 250) return; // cooldown to avoid rapid fire
|
|
this.wheelAccum += ev.deltaY;
|
|
const threshold = 40; // more sensitive for trackpads
|
|
if (this.wheelAccum > threshold) { this.wheelAccum = 0; this.lastScrollTs = now; this.next(); }
|
|
else if (this.wheelAccum < -threshold) { this.wheelAccum = 0; this.lastScrollTs = now; this.prev(); }
|
|
}
|
|
|
|
onTouchStart(ev: TouchEvent) {
|
|
if (this.items().length === 0) return;
|
|
this.lastSwipeY = (ev.changedTouches?.[0]?.clientY ?? null);
|
|
}
|
|
|
|
onTouchEnd(ev: TouchEvent) {
|
|
if (this.items().length === 0) return;
|
|
const y = ev.changedTouches?.[0]?.clientY;
|
|
if (this.lastSwipeY == null || y == null) return;
|
|
const dy = y - this.lastSwipeY;
|
|
const threshold = 50;
|
|
if (dy < -threshold) this.next();
|
|
else if (dy > threshold) this.prev();
|
|
this.lastSwipeY = null;
|
|
}
|
|
}
|