chore: update Angular cache and TypeScript build info
This commit is contained in:
parent
9a3983a253
commit
fe35340948
2
.angular/cache/20.2.2/app/.tsbuildinfo
vendored
2
.angular/cache/20.2.2/app/.tsbuildinfo
vendored
File diff suppressed because one or more lines are too long
BIN
db/newtube.db
BIN
db/newtube.db
Binary file not shown.
@ -0,0 +1,80 @@
|
||||
import { ChangeDetectionStrategy, Component, Input } from '@angular/core';
|
||||
import { NgClass, NgIf } from '@angular/common';
|
||||
|
||||
export type ProviderKey = 'youtube' | 'dailymotion' | 'twitch' | 'peertube' | 'rumble' | 'odysee';
|
||||
|
||||
type ProviderPreset = { label: string; classes: string };
|
||||
|
||||
const PROVIDER_PRESETS: Record<ProviderKey, ProviderPreset> = {
|
||||
youtube: {
|
||||
label: 'YouTube',
|
||||
classes: 'bg-gradient-to-r from-red-600/90 to-red-500/90 text-red-50 border border-red-400/60'
|
||||
},
|
||||
dailymotion: {
|
||||
label: 'Dailymotion',
|
||||
classes: 'bg-gradient-to-r from-sky-600/90 to-sky-500/90 text-sky-50 border border-sky-400/60'
|
||||
},
|
||||
twitch: {
|
||||
label: 'Twitch',
|
||||
classes: 'bg-gradient-to-r from-violet-600/90 to-purple-500/90 text-violet-50 border border-purple-400/60'
|
||||
},
|
||||
peertube: {
|
||||
label: 'PeerTube',
|
||||
classes: 'bg-gradient-to-r from-amber-500/90 to-yellow-400/90 text-amber-50 border border-amber-300/60'
|
||||
},
|
||||
rumble: {
|
||||
label: 'Rumble',
|
||||
classes: 'bg-gradient-to-r from-emerald-600/90 to-green-500/90 text-emerald-50 border border-emerald-400/60'
|
||||
},
|
||||
odysee: {
|
||||
label: 'Odysee',
|
||||
classes: 'bg-gradient-to-r from-pink-500/90 to-fuchsia-500/90 text-pink-50 border border-pink-300/60'
|
||||
}
|
||||
};
|
||||
|
||||
@Component({
|
||||
selector: 'app-provider-badge',
|
||||
standalone: true,
|
||||
imports: [NgIf, NgClass],
|
||||
template: `
|
||||
<span *ngIf="badgeLabel"
|
||||
class="inline-flex items-center gap-1 rounded-full px-2.5 py-1 text-xs font-semibold shadow-sm backdrop-blur-sm"
|
||||
[ngClass]="badgeClasses"
|
||||
[attr.aria-label]="badgeLabel">
|
||||
<svg class="h-3.5 w-3.5" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
|
||||
<path d="M4 4a2 2 0 012-2h8a2 2 0 012 2v14h-3v-4H7v4H4V4zm5 12h2v2H9v-2z" />
|
||||
</svg>
|
||||
<span>{{ badgeLabel }}</span>
|
||||
</span>
|
||||
`,
|
||||
changeDetection: ChangeDetectionStrategy.OnPush
|
||||
})
|
||||
export class ProviderBadgeComponent {
|
||||
@Input() provider: string | null | undefined;
|
||||
@Input() label: string | null | undefined;
|
||||
|
||||
private normalizeProvider(): ProviderKey | null {
|
||||
if (!this.provider) return null;
|
||||
const key = this.provider.toLowerCase() as ProviderKey;
|
||||
return (key in PROVIDER_PRESETS) ? key : null;
|
||||
}
|
||||
|
||||
get badgeLabel(): string | null {
|
||||
const presetKey = this.normalizeProvider();
|
||||
if (presetKey) {
|
||||
return PROVIDER_PRESETS[presetKey].label;
|
||||
}
|
||||
if (this.label && this.label.trim()) {
|
||||
return this.label.trim();
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
get badgeClasses(): string {
|
||||
const presetKey = this.normalizeProvider();
|
||||
if (presetKey) {
|
||||
return PROVIDER_PRESETS[presetKey].classes;
|
||||
}
|
||||
return 'bg-slate-800/80 text-slate-100 border border-slate-700';
|
||||
}
|
||||
}
|
@ -1,6 +1,9 @@
|
||||
<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>
|
||||
|
@ -3,13 +3,14 @@ import { VideoItem } from '../../models/video-item.model';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { RouterLink } from '@angular/router';
|
||||
import { DurationPipe } from '../../pipes/duration.pipe';
|
||||
import { ProviderBadgeComponent } from '../provider-badge/provider-badge.component';
|
||||
|
||||
@Component({
|
||||
selector: 'app-video-card',
|
||||
templateUrl: './video-card.component.html',
|
||||
styleUrls: ['./video-card.component.scss'],
|
||||
standalone: true,
|
||||
imports: [CommonModule, RouterLink, DurationPipe],
|
||||
imports: [CommonModule, RouterLink, DurationPipe, ProviderBadgeComponent],
|
||||
})
|
||||
export class VideoCardComponent {
|
||||
@Input() video!: VideoItem;
|
||||
|
@ -24,11 +24,14 @@
|
||||
<a [routerLink]="['/watch', video.videoId]" [queryParams]="watchQueryParams(video)" [state]="{ video }" class="group flex flex-col bg-slate-800/50 rounded-lg overflow-hidden hover:bg-slate-700/50 transition-all duration-300 transform hover:-translate-y-1">
|
||||
<div class="relative">
|
||||
<img [src]="video.thumbnail" [alt]="video.title" class="w-full h-48 object-cover transition-transform duration-300 group-hover:scale-105">
|
||||
<div class="absolute bottom-2 right-2 bg-black/75 text-white text-xs px-2 py-1 rounded">
|
||||
{{ video.duration / 60 | number:'1.0-0' }}:{{ (video.duration % 60) | number:'2.0-0' }}
|
||||
<div class="absolute top-2 right-2">
|
||||
<app-provider-badge [provider]="video.provider || instances.selectedProvider()"></app-provider-badge>
|
||||
</div>
|
||||
<div *ngIf="video.duration && video.duration > 0" class="absolute bottom-2 right-2 bg-black/75 text-white text-xs px-2 py-1 rounded">
|
||||
{{ video.duration | duration }}
|
||||
</div>
|
||||
<div class="absolute bottom-2 left-2">
|
||||
<app-like-button [videoId]="video.videoId" [provider]="instances.selectedProvider()" [title]="video.title" [thumbnail]="video.thumbnail"></app-like-button>
|
||||
<app-like-button [videoId]="video.videoId" [provider]="instances.selectedProvider()" [title]="video.title" [thumbnail]="video.thumbnail"></app-like-button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="p-4 flex-grow flex flex-col">
|
||||
|
@ -9,6 +9,8 @@ import { InfiniteAnchorComponent } from '../shared/infinite-anchor/infinite-anch
|
||||
import { formatRelativeFr } from '../../utils/date.util';
|
||||
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';
|
||||
|
||||
@Component({
|
||||
selector: 'app-home',
|
||||
@ -20,7 +22,9 @@ import { LikeButtonComponent } from '../shared/components/like-button/like-butto
|
||||
RouterLink,
|
||||
InfiniteAnchorComponent,
|
||||
TranslatePipe,
|
||||
LikeButtonComponent
|
||||
LikeButtonComponent,
|
||||
ProviderBadgeComponent,
|
||||
DurationPipe
|
||||
]
|
||||
})
|
||||
export class HomeComponent {
|
||||
|
@ -33,7 +33,6 @@
|
||||
|
||||
<!-- 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 *ngIf="(query() || '').length > 0" type="button" (click)="queryUpdate('')" class="text-xs px-2 py-1 rounded hover:bg-slate-700/70" aria-label="Effacer la recherche">×</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">
|
||||
|
@ -182,79 +182,21 @@ export class SearchComponent implements AfterViewInit {
|
||||
|
||||
// Helper computed: filtered list based on active tag
|
||||
filteredResults = computed(() => {
|
||||
const tag = this.filterTag();
|
||||
const list = this.allVideos();
|
||||
const provider = this.selectedProviderForView();
|
||||
if (!Array.isArray(list) || list.length === 0) return [];
|
||||
|
||||
if (provider === 'twitch') return list; // Not used for Twitch (separate sections)
|
||||
if (tag === 'all') return list;
|
||||
|
||||
const now = Date.now();
|
||||
const oneDay = 24 * 60 * 60 * 1000;
|
||||
const sevenDays = 7 * oneDay;
|
||||
const thirtyDays = 30 * oneDay;
|
||||
const oneYear = 365 * oneDay;
|
||||
|
||||
// Duration filters
|
||||
if (tag === 'short') return list.filter(v => {
|
||||
const d = Number(v.durationSec || 0);
|
||||
return d > 0 && d < 4 * 60;
|
||||
});
|
||||
if (tag === 'medium') return list.filter(v => {
|
||||
const d = Number(v.durationSec || 0);
|
||||
return d >= 4 * 60 && d < 20 * 60;
|
||||
});
|
||||
if (tag === 'long') return list.filter(v => {
|
||||
const d = Number(v.durationSec || 0);
|
||||
return d >= 20 * 60;
|
||||
});
|
||||
|
||||
// Date filters
|
||||
if (tag === 'today') return list.filter(v => {
|
||||
const uploadTime = v.publishedAt ? new Date(v.publishedAt).getTime() : 0;
|
||||
return uploadTime > 0 && (now - uploadTime) <= oneDay;
|
||||
});
|
||||
if (tag === 'this_week') return list.filter(v => {
|
||||
const uploadTime = v.publishedAt ? new Date(v.publishedAt).getTime() : 0;
|
||||
return uploadTime > 0 && (now - uploadTime) <= sevenDays;
|
||||
});
|
||||
if (tag === 'this_month') return list.filter(v => {
|
||||
const uploadTime = v.publishedAt ? new Date(v.publishedAt).getTime() : 0;
|
||||
return uploadTime > 0 && (now - uploadTime) <= thirtyDays;
|
||||
});
|
||||
if (tag === 'this_year') return list.filter(v => {
|
||||
const uploadTime = v.publishedAt ? new Date(v.publishedAt).getTime() : 0;
|
||||
return uploadTime > 0 && (now - uploadTime) <= oneYear;
|
||||
});
|
||||
|
||||
return list;
|
||||
const filtered = this.applyTagFilter(list);
|
||||
return this.sortVideos(filtered);
|
||||
});
|
||||
|
||||
// Filter each provider group separately for sectioned rendering
|
||||
filteredGroups = computed(() => {
|
||||
const tag = this.filterTag();
|
||||
const predicate = this.buildTagPredicate();
|
||||
const g = this.groups();
|
||||
const result: Record<string, any[]> = {};
|
||||
const now = Date.now();
|
||||
const oneDay = 24 * 60 * 60 * 1000;
|
||||
const sevenDays = 7 * oneDay;
|
||||
const thirtyDays = 30 * oneDay;
|
||||
const oneYear = 365 * oneDay;
|
||||
|
||||
const applyFilter = (list: any[]): any[] => {
|
||||
if (tag === 'all' || this.selectedProviderForView() === 'twitch') return list;
|
||||
if (tag === 'short') return list.filter(v => { const d = Number(v.durationSec || 0); return d > 0 && d < 4 * 60; });
|
||||
if (tag === 'medium') return list.filter(v => { const d = Number(v.durationSec || 0); return d >= 4 * 60 && d < 20 * 60; });
|
||||
if (tag === 'long') return list.filter(v => { const d = Number(v.durationSec || 0); return d >= 20 * 60; });
|
||||
if (tag === 'today') return list.filter(v => { const t = v.publishedAt ? new Date(v.publishedAt).getTime() : 0; return t > 0 && (now - t) <= oneDay; });
|
||||
if (tag === 'this_week') return list.filter(v => { const t = v.publishedAt ? new Date(v.publishedAt).getTime() : 0; return t > 0 && (now - t) <= sevenDays; });
|
||||
if (tag === 'this_month') return list.filter(v => { const t = v.publishedAt ? new Date(v.publishedAt).getTime() : 0; return t > 0 && (now - t) <= thirtyDays; });
|
||||
if (tag === 'this_year') return list.filter(v => { const t = v.publishedAt ? new Date(v.publishedAt).getTime() : 0; return t > 0 && (now - t) <= oneYear; });
|
||||
return list;
|
||||
};
|
||||
|
||||
Object.entries(g || {}).forEach(([pid, list]) => {
|
||||
result[pid] = Array.isArray(list) ? applyFilter(list) : [];
|
||||
result[pid] = Array.isArray(list) ? list.filter(predicate) : [];
|
||||
});
|
||||
return result;
|
||||
});
|
||||
@ -431,6 +373,115 @@ export class SearchComponent implements AfterViewInit {
|
||||
|
||||
}
|
||||
|
||||
private durationSeconds(item: any): number {
|
||||
const raw = item?.durationSec ?? item?.duration ?? item?.length ?? 0;
|
||||
if (typeof raw === 'number') {
|
||||
return Number.isFinite(raw) ? raw : 0;
|
||||
}
|
||||
const parsed = Number(raw);
|
||||
return Number.isFinite(parsed) ? parsed : 0;
|
||||
}
|
||||
|
||||
private publishedTimestamp(item: any): number {
|
||||
const raw = item?.publishedAt ?? item?.uploadedDate ?? item?.uploadDate ?? item?.published_at ?? item?.uploaded ?? null;
|
||||
if (typeof raw === 'number') {
|
||||
return Number.isFinite(raw) ? raw : 0;
|
||||
}
|
||||
if (typeof raw === 'string' && raw.trim()) {
|
||||
const parsed = Date.parse(raw);
|
||||
if (!Number.isNaN(parsed)) {
|
||||
return parsed;
|
||||
}
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
private viewCount(item: any): number {
|
||||
const raw = item?.viewCount ?? item?.views ?? item?.stats?.views ?? item?.metrics?.views ?? 0;
|
||||
if (typeof raw === 'number') {
|
||||
return Number.isFinite(raw) ? raw : 0;
|
||||
}
|
||||
const parsed = Number(raw);
|
||||
return Number.isFinite(parsed) ? parsed : 0;
|
||||
}
|
||||
|
||||
private buildTagPredicate(): (item: any) => boolean {
|
||||
const tag = this.filterTag();
|
||||
const provider = this.selectedProviderForView();
|
||||
if (provider === 'twitch' || tag === 'all' || tag?.startsWith('twitch')) {
|
||||
return () => true;
|
||||
}
|
||||
|
||||
const now = Date.now();
|
||||
const oneDay = 24 * 60 * 60 * 1000;
|
||||
const sevenDays = 7 * oneDay;
|
||||
const thirtyDays = 30 * oneDay;
|
||||
const oneYear = 365 * oneDay;
|
||||
|
||||
switch (tag) {
|
||||
case 'short':
|
||||
return (item) => {
|
||||
const d = this.durationSeconds(item);
|
||||
return d > 0 && d < 4 * 60;
|
||||
};
|
||||
case 'medium':
|
||||
return (item) => {
|
||||
const d = this.durationSeconds(item);
|
||||
return d >= 4 * 60 && d < 20 * 60;
|
||||
};
|
||||
case 'long':
|
||||
return (item) => {
|
||||
const d = this.durationSeconds(item);
|
||||
return d >= 20 * 60;
|
||||
};
|
||||
case 'today':
|
||||
return (item) => {
|
||||
const ts = this.publishedTimestamp(item);
|
||||
return ts > 0 && (now - ts) <= oneDay;
|
||||
};
|
||||
case 'this_week':
|
||||
return (item) => {
|
||||
const ts = this.publishedTimestamp(item);
|
||||
return ts > 0 && (now - ts) <= sevenDays;
|
||||
};
|
||||
case 'this_month':
|
||||
return (item) => {
|
||||
const ts = this.publishedTimestamp(item);
|
||||
return ts > 0 && (now - ts) <= thirtyDays;
|
||||
};
|
||||
case 'this_year':
|
||||
return (item) => {
|
||||
const ts = this.publishedTimestamp(item);
|
||||
return ts > 0 && (now - ts) <= oneYear;
|
||||
};
|
||||
default:
|
||||
return () => true;
|
||||
}
|
||||
}
|
||||
|
||||
private applyTagFilter(list: any[]): any[] {
|
||||
const predicate = this.buildTagPredicate();
|
||||
return Array.isArray(list) ? list.filter(predicate) : [];
|
||||
}
|
||||
|
||||
private sortVideos(list: any[]): any[] {
|
||||
if (!Array.isArray(list)) {
|
||||
return [];
|
||||
}
|
||||
const mode = this.sortParam();
|
||||
if (mode === 'relevance') {
|
||||
return [...list];
|
||||
}
|
||||
|
||||
const arr = [...list];
|
||||
if (mode === 'date') {
|
||||
arr.sort((a, b) => this.publishedTimestamp(b) - this.publishedTimestamp(a));
|
||||
} else if (mode === 'views') {
|
||||
arr.sort((a, b) => this.viewCount(b) - this.viewCount(a));
|
||||
}
|
||||
return arr;
|
||||
}
|
||||
|
||||
|
||||
formatViews(views: number): string {
|
||||
if (views >= 1_000_000_000) return (views / 1_000_000_000).toFixed(1) + 'B';
|
||||
|
@ -104,6 +104,9 @@
|
||||
<a [routerLink]="['/watch', v.videoId]" [queryParams]="watchQueryParams(v)" [state]="{ video: v }" class="block bg-slate-800/50 rounded-lg overflow-hidden hover:bg-slate-700/50 transition-all duration-300 transform hover:-translate-y-1 shadow-lg hover:shadow-xl">
|
||||
<div class="relative">
|
||||
<img [src]="v.thumbnail" [alt]="v.title" loading="lazy" decoding="async" class="w-full aspect-video object-cover transition-transform duration-300 group-hover:scale-105">
|
||||
<div class="absolute top-2 right-2">
|
||||
<app-provider-badge [provider]="provider()"></app-provider-badge>
|
||||
</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>
|
||||
|
@ -9,6 +9,7 @@ import { Video } from '../../models/video.model';
|
||||
import { TranslatePipe } from '../../pipes/translate.pipe';
|
||||
import { LikeButtonComponent } from '../shared/components/like-button/like-button.component';
|
||||
import { DurationPipe } from '../../app/shared/pipes/duration.pipe';
|
||||
import { ProviderBadgeComponent } from '../../app/shared/components/provider-badge/provider-badge.component';
|
||||
import { map, of, switchMap } from 'rxjs';
|
||||
|
||||
const TWITCH_THEME_KEYWORDS: Record<string, string[]> = {
|
||||
@ -43,7 +44,7 @@ const TWITCH_THEME_KEYWORDS: Record<string, string[]> = {
|
||||
standalone: true,
|
||||
templateUrl: './provider-theme-page.component.html',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
imports: [CommonModule, FormsModule, RouterLink, TranslatePipe, LikeButtonComponent, DurationPipe]
|
||||
imports: [CommonModule, FormsModule, RouterLink, TranslatePipe, LikeButtonComponent, DurationPipe, ProviderBadgeComponent]
|
||||
})
|
||||
export class ProviderThemePageComponent implements OnDestroy {
|
||||
private route = inject(ActivatedRoute);
|
||||
|
@ -1,6 +1,5 @@
|
||||
<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">
|
||||
<span>{{ themes.bySlug(themeSlug())?.emoji }}</span>
|
||||
<span>{{ themeLabel() }}</span>
|
||||
</h2>
|
||||
|
||||
@ -29,8 +28,11 @@
|
||||
class="group bg-slate-800/50 rounded-lg overflow-hidden hover:bg-slate-700/50 transition-all duration-300">
|
||||
<div class="relative">
|
||||
<img [src]="v.thumbnail" [alt]="v.title" loading="lazy" decoding="async" class="w-full h-40 object-cover">
|
||||
<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 / 60 | number:'1.0-0' }}:{{ (v.duration % 60) | number:'2.0-0' }}
|
||||
<div class="absolute top-2 right-2">
|
||||
<app-provider-badge [provider]="b.provider"></app-provider-badge>
|
||||
</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>
|
||||
<div class="absolute bottom-2 left-2">
|
||||
<app-like-button [videoId]="v.videoId" [provider]="b.provider" [title]="v.title" [thumbnail]="v.thumbnail"></app-like-button>
|
||||
|
@ -7,6 +7,8 @@ import { Provider, InstanceService } from '../../services/instance.service';
|
||||
import { Video } from '../../models/video.model';
|
||||
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';
|
||||
|
||||
interface ProviderBlock {
|
||||
provider: Provider;
|
||||
@ -21,7 +23,7 @@ interface ProviderBlock {
|
||||
standalone: true,
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
templateUrl: './theme-page.component.html',
|
||||
imports: [CommonModule, RouterLink, TranslatePipe, LikeButtonComponent]
|
||||
imports: [CommonModule, RouterLink, TranslatePipe, LikeButtonComponent, ProviderBadgeComponent, DurationPipe]
|
||||
})
|
||||
export class ThemePageComponent {
|
||||
private route = inject(ActivatedRoute);
|
||||
|
@ -287,8 +287,14 @@
|
||||
<div class="space-y-4">
|
||||
@for (related of v.relatedStreams; track related.videoId) {
|
||||
<a [routerLink]="['/watch', related.videoId]" [queryParams]="watchQueryParams(related)" [state]="{ video: related }" class="flex items-start space-x-3 group hover:bg-slate-800/50 p-2 rounded-lg transition">
|
||||
<div class="w-40 flex-shrink-0">
|
||||
<div class="relative w-40 flex-shrink-0">
|
||||
<img [src]="related.thumbnail" [alt]="related.title" class="w-full h-24 object-cover rounded">
|
||||
<div class="absolute top-1.5 right-1.5">
|
||||
<app-provider-badge [provider]="related.provider"></app-provider-badge>
|
||||
</div>
|
||||
<div *ngIf="related.duration && related.duration > 0" class="absolute bottom-1.5 right-1.5 bg-black/75 text-white text-[10px] px-1.5 py-0.5 rounded">
|
||||
{{ related.duration | duration }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
<h3 class="font-semibold text-sm line-clamp-2 group-hover:text-red-400">{{ related.title }}</h3>
|
||||
|
@ -16,13 +16,15 @@ import { AuthService } from '../../services/auth.service';
|
||||
import { DomSanitizer, SafeResourceUrl } from '@angular/platform-browser';
|
||||
import { IframeProgressService } from '../../services/iframe-progress.service';
|
||||
import { formatAbsoluteFr, formatNumberFr } from '../../utils/date.util';
|
||||
import { ProviderBadgeComponent } from '../../app/shared/components/provider-badge/provider-badge.component';
|
||||
import { DurationPipe } from '../../app/shared/pipes/duration.pipe';
|
||||
|
||||
@Component({
|
||||
selector: 'app-watch',
|
||||
templateUrl: './watch.component.html',
|
||||
standalone: true,
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
imports: [CommonModule, VideoPlayerComponent, RouterLink, AddToPlaylistComponent]
|
||||
imports: [CommonModule, VideoPlayerComponent, RouterLink, AddToPlaylistComponent, ProviderBadgeComponent, DurationPipe]
|
||||
})
|
||||
export class WatchComponent implements OnDestroy, AfterViewInit {
|
||||
private route = inject(ActivatedRoute);
|
||||
|
Loading…
x
Reference in New Issue
Block a user