chore: update Angular cache and TypeScript build info

This commit is contained in:
Bruno Charest 2025-09-23 22:45:57 -04:00
parent 9a3983a253
commit fe35340948
15 changed files with 234 additions and 77 deletions

File diff suppressed because one or more lines are too long

Binary file not shown.

View File

@ -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';
}
}

View File

@ -1,6 +1,9 @@
<a [routerLink]="['/watch', video.id]" [queryParams]="buildQueryParams(video)" [state]="{ video }" class="group 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"> <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" /> <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"> <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 }} {{ video.durationSec | duration }}
</span> </span>

View File

@ -3,13 +3,14 @@ import { VideoItem } from '../../models/video-item.model';
import { CommonModule } from '@angular/common'; import { CommonModule } from '@angular/common';
import { RouterLink } from '@angular/router'; import { RouterLink } from '@angular/router';
import { DurationPipe } from '../../pipes/duration.pipe'; import { DurationPipe } from '../../pipes/duration.pipe';
import { ProviderBadgeComponent } from '../provider-badge/provider-badge.component';
@Component({ @Component({
selector: 'app-video-card', selector: 'app-video-card',
templateUrl: './video-card.component.html', templateUrl: './video-card.component.html',
styleUrls: ['./video-card.component.scss'], styleUrls: ['./video-card.component.scss'],
standalone: true, standalone: true,
imports: [CommonModule, RouterLink, DurationPipe], imports: [CommonModule, RouterLink, DurationPipe, ProviderBadgeComponent],
}) })
export class VideoCardComponent { export class VideoCardComponent {
@Input() video!: VideoItem; @Input() video!: VideoItem;

View File

@ -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"> <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"> <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"> <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"> <div class="absolute top-2 right-2">
{{ video.duration / 60 | number:'1.0-0' }}:{{ (video.duration % 60) | number:'2.0-0' }} <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>
<div class="absolute bottom-2 left-2"> <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> </div>
<div class="p-4 flex-grow flex flex-col"> <div class="p-4 flex-grow flex flex-col">

View File

@ -9,6 +9,8 @@ import { InfiniteAnchorComponent } from '../shared/infinite-anchor/infinite-anch
import { formatRelativeFr } from '../../utils/date.util'; import { formatRelativeFr } from '../../utils/date.util';
import { TranslatePipe } from '../../pipes/translate.pipe'; import { TranslatePipe } from '../../pipes/translate.pipe';
import { LikeButtonComponent } from '../shared/components/like-button/like-button.component'; 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({ @Component({
selector: 'app-home', selector: 'app-home',
@ -20,7 +22,9 @@ import { LikeButtonComponent } from '../shared/components/like-button/like-butto
RouterLink, RouterLink,
InfiniteAnchorComponent, InfiniteAnchorComponent,
TranslatePipe, TranslatePipe,
LikeButtonComponent LikeButtonComponent,
ProviderBadgeComponent,
DurationPipe
] ]
}) })
export class HomeComponent { export class HomeComponent {

View File

@ -33,7 +33,6 @@
<!-- Actions --> <!-- 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 *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" <button type="submit" class="ml-1 px-3 py-1 rounded bg-red-600 hover:bg-red-500 text-white"
(click)="onSubmit($event)" (click)="onSubmit($event)"
[attr.title]="(query() || '').trim().length < 2 ? 'Tapez au moins 2 caractères' : null"> [attr.title]="(query() || '').trim().length < 2 ? 'Tapez au moins 2 caractères' : null">

View File

@ -182,79 +182,21 @@ export class SearchComponent implements AfterViewInit {
// Helper computed: filtered list based on active tag // Helper computed: filtered list based on active tag
filteredResults = computed(() => { filteredResults = computed(() => {
const tag = this.filterTag();
const list = this.allVideos(); 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) const filtered = this.applyTagFilter(list);
if (tag === 'all') return list; return this.sortVideos(filtered);
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;
}); });
// Filter each provider group separately for sectioned rendering // Filter each provider group separately for sectioned rendering
filteredGroups = computed(() => { filteredGroups = computed(() => {
const tag = this.filterTag(); const predicate = this.buildTagPredicate();
const g = this.groups(); const g = this.groups();
const result: Record<string, any[]> = {}; 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]) => { Object.entries(g || {}).forEach(([pid, list]) => {
result[pid] = Array.isArray(list) ? applyFilter(list) : []; result[pid] = Array.isArray(list) ? list.filter(predicate) : [];
}); });
return result; 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 { formatViews(views: number): string {
if (views >= 1_000_000_000) return (views / 1_000_000_000).toFixed(1) + 'B'; if (views >= 1_000_000_000) return (views / 1_000_000_000).toFixed(1) + 'B';

View File

@ -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"> <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"> <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"> <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"> <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 }} {{ v.duration | duration }}
</div> </div>

View File

@ -9,6 +9,7 @@ import { Video } from '../../models/video.model';
import { TranslatePipe } from '../../pipes/translate.pipe'; import { TranslatePipe } from '../../pipes/translate.pipe';
import { LikeButtonComponent } from '../shared/components/like-button/like-button.component'; import { LikeButtonComponent } from '../shared/components/like-button/like-button.component';
import { DurationPipe } from '../../app/shared/pipes/duration.pipe'; 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 { map, of, switchMap } from 'rxjs';
const TWITCH_THEME_KEYWORDS: Record<string, string[]> = { const TWITCH_THEME_KEYWORDS: Record<string, string[]> = {
@ -43,7 +44,7 @@ const TWITCH_THEME_KEYWORDS: Record<string, string[]> = {
standalone: true, standalone: true,
templateUrl: './provider-theme-page.component.html', templateUrl: './provider-theme-page.component.html',
changeDetection: ChangeDetectionStrategy.OnPush, changeDetection: ChangeDetectionStrategy.OnPush,
imports: [CommonModule, FormsModule, RouterLink, TranslatePipe, LikeButtonComponent, DurationPipe] imports: [CommonModule, FormsModule, RouterLink, TranslatePipe, LikeButtonComponent, DurationPipe, ProviderBadgeComponent]
}) })
export class ProviderThemePageComponent implements OnDestroy { export class ProviderThemePageComponent implements OnDestroy {
private route = inject(ActivatedRoute); private route = inject(ActivatedRoute);

View File

@ -1,6 +1,5 @@
<div class="container mx-auto p-4 sm:p-6"> <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-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> <span>{{ themeLabel() }}</span>
</h2> </h2>
@ -29,8 +28,11 @@
class="group bg-slate-800/50 rounded-lg overflow-hidden hover:bg-slate-700/50 transition-all duration-300"> class="group bg-slate-800/50 rounded-lg overflow-hidden hover:bg-slate-700/50 transition-all duration-300">
<div class="relative"> <div class="relative">
<img [src]="v.thumbnail" [alt]="v.title" loading="lazy" decoding="async" class="w-full h-40 object-cover"> <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"> <div class="absolute top-2 right-2">
{{ v.duration / 60 | number:'1.0-0' }}:{{ (v.duration % 60) | number:'2.0-0' }} <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>
<div class="absolute bottom-2 left-2"> <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> <app-like-button [videoId]="v.videoId" [provider]="b.provider" [title]="v.title" [thumbnail]="v.thumbnail"></app-like-button>

View File

@ -7,6 +7,8 @@ import { Provider, InstanceService } from '../../services/instance.service';
import { Video } from '../../models/video.model'; import { Video } from '../../models/video.model';
import { TranslatePipe } from '../../pipes/translate.pipe'; import { TranslatePipe } from '../../pipes/translate.pipe';
import { LikeButtonComponent } from '../shared/components/like-button/like-button.component'; 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 { interface ProviderBlock {
provider: Provider; provider: Provider;
@ -21,7 +23,7 @@ interface ProviderBlock {
standalone: true, standalone: true,
changeDetection: ChangeDetectionStrategy.OnPush, changeDetection: ChangeDetectionStrategy.OnPush,
templateUrl: './theme-page.component.html', templateUrl: './theme-page.component.html',
imports: [CommonModule, RouterLink, TranslatePipe, LikeButtonComponent] imports: [CommonModule, RouterLink, TranslatePipe, LikeButtonComponent, ProviderBadgeComponent, DurationPipe]
}) })
export class ThemePageComponent { export class ThemePageComponent {
private route = inject(ActivatedRoute); private route = inject(ActivatedRoute);

View File

@ -287,8 +287,14 @@
<div class="space-y-4"> <div class="space-y-4">
@for (related of v.relatedStreams; track related.videoId) { @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"> <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"> <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>
<div class="flex-1"> <div class="flex-1">
<h3 class="font-semibold text-sm line-clamp-2 group-hover:text-red-400">{{ related.title }}</h3> <h3 class="font-semibold text-sm line-clamp-2 group-hover:text-red-400">{{ related.title }}</h3>

View File

@ -16,13 +16,15 @@ import { AuthService } from '../../services/auth.service';
import { DomSanitizer, SafeResourceUrl } from '@angular/platform-browser'; import { DomSanitizer, SafeResourceUrl } from '@angular/platform-browser';
import { IframeProgressService } from '../../services/iframe-progress.service'; import { IframeProgressService } from '../../services/iframe-progress.service';
import { formatAbsoluteFr, formatNumberFr } from '../../utils/date.util'; 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({ @Component({
selector: 'app-watch', selector: 'app-watch',
templateUrl: './watch.component.html', templateUrl: './watch.component.html',
standalone: true, standalone: true,
changeDetection: ChangeDetectionStrategy.OnPush, changeDetection: ChangeDetectionStrategy.OnPush,
imports: [CommonModule, VideoPlayerComponent, RouterLink, AddToPlaylistComponent] imports: [CommonModule, VideoPlayerComponent, RouterLink, AddToPlaylistComponent, ProviderBadgeComponent, DurationPipe]
}) })
export class WatchComponent implements OnDestroy, AfterViewInit { export class WatchComponent implements OnDestroy, AfterViewInit {
private route = inject(ActivatedRoute); private route = inject(ActivatedRoute);