feat(channel): identite chaine partagee cliquable partout + logos fiabilises
CI / build-and-test (push) Successful in 13m25s

- Nouveau app-channel-identity (avatar+nom -> /channel, fallback resolve /api/channels, initiales sinon)
- ProviderBadge: accepte les ids courts yt/dm/tw/pt/od/ru
- Search adapters yt/dm/pt/od: conservent channelExternalId/channelAvatarUrl (pt derive instance|channel)
- video-card/themes/watch/related/suggestions/subscriptions/channel-header uniformises sur l'identite partagee
This commit is contained in:
2026-09-25 10:37:36 -04:00
parent 97e5de74f5
commit 579354cdb3
20 changed files with 355 additions and 159 deletions
+2
View File
@@ -22,6 +22,8 @@ export class DmAdapter implements ProviderAdapter<VideoItem> {
thumbnailUrl: it.thumbnail,
viewCount: undefined, // Dailymotion API does not provide view count in search results
publishedAt: undefined, // Dailymotion API does not provide published date in search results
channelExternalId: it.channelId || undefined,
channelAvatarUrl: it.uploaderAvatar || undefined,
}));
return { items, total: Array.isArray(res?.groups?.dm) ? res.groups.dm.length : 0 };
}
+3
View File
@@ -30,6 +30,9 @@ export class OdAdapter implements ProviderAdapter<VideoItem> {
viewCount: undefined,
publishedAt: undefined,
slug,
// Nom de chaîne LBRY (ex. @MaChaine) : le backend /api/channels/od
// sait le résoudre avec ou sans préfixe @.
channelExternalId: it.uploaderName || undefined,
} as VideoItem;
});
return { items, total: Array.isArray(res?.groups?.od) ? res.groups.od.length : 0 };
+20 -10
View File
@@ -12,16 +12,26 @@ export class PtAdapter implements ProviderAdapter<VideoItem> {
if (params.pageToken) httpParams = httpParams.set('page', params.pageToken);
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?.pt || []).map((it: any) => ({
id: it.id,
provider: 'peertube',
title: it.title,
channelName: it.uploaderName,
durationSec: it.duration,
thumbnailUrl: it.thumbnail,
viewCount: undefined,
publishedAt: undefined,
}));
const items: VideoItem[] = (res?.groups?.pt || []).map((it: any) => {
// PeerTube identifie une chaîne par `instance|channel` : l'instance est
// dérivée de l'URL vidéo, le nom de chaîne de `channelId`.
let channelExternalId: string | undefined;
try {
const host = it?.url ? new URL(String(it.url)).hostname : '';
if (host && it?.channelId) channelExternalId = `${host}|${it.channelId}`;
} catch {}
return {
id: it.id,
provider: 'peertube',
title: it.title,
channelName: it.uploaderName,
durationSec: it.duration,
thumbnailUrl: it.thumbnail,
viewCount: undefined,
publishedAt: undefined,
channelExternalId,
};
});
return { items, total: Array.isArray(res?.groups?.pt) ? res.groups.pt.length : 0 };
}
}
+3
View File
@@ -21,6 +21,9 @@ export class YtAdapter implements ProviderAdapter<VideoItem> {
thumbnailUrl: it.thumbnail,
viewCount: typeof it.views === 'number' ? it.views : (typeof it.viewCount === 'number' ? it.viewCount : undefined),
publishedAt: it.publishedAt,
// ID chaîne conservé : rend le logo/nom cliquables vers /channel
// (l'avatar est résolu via /api/channels, l'API search n'en fournit pas).
channelExternalId: it.channelExternalId || it.channelId || undefined,
}));
return { items, total: Array.isArray(res?.groups?.yt) ? res.groups.yt.length : 0 };
}
@@ -0,0 +1,33 @@
<a *ngIf="hasDestination; else plainBlock"
[routerLink]="['/channel', shortProvider, externalId]"
class="flex min-w-0 flex-1 items-center gap-2"
title="Ouvrir la page de la chaîne">
<span class="flex shrink-0 items-center justify-center overflow-hidden rounded-full border border-zinc-700 bg-zinc-800"
[class.h-6]="size === 'sm'" [class.w-6]="size === 'sm'"
[class.h-8]="size === 'md'" [class.w-8]="size === 'md'">
<img *ngIf="resolvedAvatar; else linkedInitials"
[src]="resolvedAvatar" [alt]="resolvedName" loading="lazy"
class="h-full w-full object-cover" referrerpolicy="no-referrer" />
<ng-template #linkedInitials>
<span class="font-semibold text-zinc-200" [class.text-xs]="size === 'sm'" [class.text-sm]="size === 'md'">{{ initial }}</span>
</ng-template>
</span>
<span *ngIf="showName" class="min-w-0 truncate font-medium text-zinc-200 hover:text-red-300">{{ resolvedName }}</span>
<span *ngIf="showName && resolvedVerified" class="shrink-0 text-xs text-emerald-300" title="Chaîne vérifiée">✓</span>
</a>
<ng-template #plainBlock>
<span class="flex min-w-0 flex-1 items-center gap-2">
<span class="flex shrink-0 items-center justify-center overflow-hidden rounded-full border border-zinc-700 bg-zinc-800"
[class.h-6]="size === 'sm'" [class.w-6]="size === 'sm'"
[class.h-8]="size === 'md'" [class.w-8]="size === 'md'">
<img *ngIf="resolvedAvatar; else plainInitials"
[src]="resolvedAvatar" [alt]="resolvedName" loading="lazy"
class="h-full w-full object-cover" referrerpolicy="no-referrer" />
<ng-template #plainInitials>
<span class="font-semibold text-zinc-200" [class.text-xs]="size === 'sm'" [class.text-sm]="size === 'md'">{{ initial }}</span>
</ng-template>
</span>
<span *ngIf="showName" class="min-w-0 truncate font-medium text-zinc-200">{{ resolvedName }}</span>
<span *ngIf="showName && resolvedVerified" class="shrink-0 text-xs text-emerald-300">✓</span>
</span>
</ng-template>
@@ -0,0 +1,98 @@
import { ChangeDetectionStrategy, Component, Input, OnChanges, OnInit, inject } from '@angular/core';
import { CommonModule } from '@angular/common';
import { RouterLink } from '@angular/router';
import type { ChannelMeta, ProviderId } from '../../models/channel.model';
import { normalizeChannelProvider } from '../../../core/providers/channel/channel-provider.factory';
import { ChannelsService } from '../../../../services/channels.service';
/**
* Identité chaîne partagée : avatar + nom, cliquable vers /channel/:provider/:externalId.
* - Affiche l'avatar fourni, sinon tente une résolution via ChannelsService (cache 6h),
* sinon les initiales (jamais d'image cassée).
* - Le lien n'est rendu que si provider + externalId sont connus ; sinon simple label.
* Utilisé par : video-card, pages thèmes, watch, suggestions, subscriptions, channel-header.
*/
@Component({
selector: 'app-channel-identity',
standalone: true,
imports: [CommonModule, RouterLink],
templateUrl: './channel-identity.component.html',
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class ChannelIdentityComponent implements OnInit, OnChanges {
private readonly channels = inject(ChannelsService);
/** Provider id court ('yt') ou nom long ('youtube'). */
@Input() provider: ProviderId | string | null | undefined = null;
@Input() externalId: string | null = null;
/** Avatar direct (prioritaire). */
@Input() avatarUrl: string | null = null;
/** Meta déjà résolue (prioritaire pour nom + avatar + verified). */
@Input() channelMeta: ChannelMeta | null = null;
@Input() name: string | null = null;
@Input() handle: string | null = null;
@Input() verified = false;
@Input() size: 'sm' | 'md' = 'md';
@Input() showName = true;
/** Si false, rend un <span> non cliquable même avec une destination. */
@Input() link = true;
resolvedAvatar: string | null = null;
resolvedName = '';
resolvedVerified = false;
private fetched = false;
get shortProvider(): ProviderId | null {
return normalizeChannelProvider(this.provider);
}
get hasDestination(): boolean {
return this.link && Boolean(this.shortProvider && this.externalId);
}
ngOnInit(): void {
this.refresh();
}
ngOnChanges(): void {
this.fetched = false;
this.refresh();
}
get initial(): string {
const n = (this.resolvedName || '').trim();
return n ? n.charAt(0).toUpperCase() : 'C';
}
private refresh(): void {
const meta = this.channelMeta;
const direct = this.asUrl(meta?.avatarUrl ?? this.avatarUrl);
this.resolvedName =
(meta?.title || meta?.handle || this.name || this.handle || meta?.externalId || this.externalId || 'Chaîne').trim();
this.resolvedVerified = Boolean(meta?.verified ?? this.verified);
if (direct) {
this.resolvedAvatar = direct;
return;
}
this.resolvedAvatar = null;
// Fallback : résolution serveur (cachée 6h) pour obtenir le vrai logo.
const provider = this.shortProvider;
const externalId = (this.externalId || '').trim();
if (!provider || !externalId || this.fetched) return;
this.fetched = true;
this.channels.get(provider, externalId).subscribe((resolved) => {
const url = this.asUrl(resolved?.avatarUrl);
if (url) this.resolvedAvatar = url;
if (resolved && !this.name && !this.handle) {
const label = (resolved.title || resolved.handle || '').trim();
if (label) this.resolvedName = label;
}
if (resolved?.verified) this.resolvedVerified = true;
});
}
private asUrl(value: string | null | undefined): string | null {
const v = (value || '').trim();
return /^https?:\/\//i.test(v) ? v : null;
}
}
@@ -55,7 +55,18 @@ export class ProviderBadgeComponent {
private normalizeProvider(): ProviderKey | null {
if (!this.provider) return null;
const key = this.provider.toLowerCase() as ProviderKey;
const raw = this.provider.toLowerCase();
// Accepte les ids courts (yt/dm/tw/pt/od/ru) utilisés par ChannelMeta
// en plus des noms longs (youtube/...).
const shortMap: Record<string, ProviderKey> = {
yt: 'youtube',
dm: 'dailymotion',
tw: 'twitch',
pt: 'peertube',
od: 'odysee',
ru: 'rumble',
};
const key = (shortMap[raw] ?? raw) as ProviderKey;
return (key in PROVIDER_PRESETS) ? key : null;
}
@@ -22,73 +22,20 @@
</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>
<app-channel-identity
[provider]="channelProviderId"
[externalId]="channelExternalId()"
[avatarUrl]="channelAvatarUrl()"
[channelMeta]="video.channelMeta ?? null"
[name]="video.channelName ?? null"
></app-channel-identity>
<app-subscribe-button
*ngIf="showSubscribe"
class="shrink-0"
[provider]="channelProviderId!"
[externalId]="channelExternalId()!"
[channelMeta]="video.channelMeta!"
[optimistic]="false"
></app-subscribe-button>
</div>
</div>
@@ -5,6 +5,7 @@ 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 { ChannelIdentityComponent } from '../channel-identity/channel-identity.component';
import type { ChannelMeta, ProviderId } from '../../models/channel.model';
@Component({
@@ -12,7 +13,7 @@ import type { ChannelMeta, ProviderId } from '../../models/channel.model';
templateUrl: './video-card.component.html',
styleUrls: ['./video-card.component.scss'],
standalone: true,
imports: [CommonModule, RouterLink, DurationPipe, ProviderBadgeComponent, SubscribeButtonComponent],
imports: [CommonModule, RouterLink, DurationPipe, ProviderBadgeComponent, SubscribeButtonComponent, ChannelIdentityComponent],
})
export class VideoCardComponent {
@Input() video!: VideoItem;
@@ -52,8 +53,7 @@ export class VideoCardComponent {
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';
get showSubscribe(): boolean {
return Boolean(this.video.channelMeta && this.channelProviderId && this.channelExternalId());
}
}
@@ -19,10 +19,16 @@
<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'"
*ngIf="channel.avatarUrl; else headerInitials"
[src]="channel.avatarUrl"
[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"
/>
<ng-template #headerInitials>
<div class="flex h-24 w-24 items-center justify-center rounded-full border-4 border-zinc-950 bg-zinc-800 text-3xl font-bold text-zinc-200 sm:h-28 sm:w-28">
{{ (channel.title || channel.handle || '?').charAt(0).toUpperCase() }}
</div>
</ng-template>
</div>
<div class="min-w-0 flex-1">
<h1 class="truncate text-2xl font-bold text-white sm:text-3xl">
@@ -63,16 +63,19 @@
>
<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"
/>
<a [routerLink]="['/channel', channel.provider, channel.externalId]" title="Ouvrir la page de la chaîne">
<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 hover:ring-2 hover:ring-red-500/60"
/>
</a>
<div class="space-y-1">
<div class="flex flex-wrap items-center gap-2">
<h3 class="text-lg font-semibold text-slate-100">
<a [routerLink]="['/channel', channel.provider, channel.externalId]"
class="text-lg font-semibold text-slate-100 hover:text-red-300">
{{ channel.title || channel.handle || 'Chaîne inconnue' }}
</h3>
</a>
<app-provider-badge [provider]="channel.provider"></app-provider-badge>
<span *ngIf="channel.verified" class="text-xs text-emerald-300">✓ Vérifiée</span>
</div>
@@ -1,7 +1,7 @@
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 { Router, RouterLink } 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';
@@ -17,7 +17,7 @@ type SortKey = 'alpha' | 'recent' | 'subs';
@Component({
selector: 'app-library-subscriptions',
standalone: true,
imports: [CommonModule, DatePipe, DecimalPipe, ProviderBadgeComponent, SubscribeButtonComponent],
imports: [CommonModule, DatePipe, DecimalPipe, RouterLink, ProviderBadgeComponent, SubscribeButtonComponent],
templateUrl: './subscriptions.component.html',
changeDetection: ChangeDetectionStrategy.OnPush,
})
@@ -19,9 +19,15 @@
@if (item.thumbnail) {
<img [src]="item.thumbnail" class="h-12 w-20 object-cover rounded" alt=""/>
}
<div class="min-w-0">
<div class="min-w-0 flex-1">
<div class="text-slate-100 truncate">{{ item.title }}</div>
<div class="text-slate-400 text-xs">{{ item.uploaderName }}</div>
<app-channel-identity
size="sm"
[provider]="s.id"
[externalId]="channelExternalId(s, item)"
[avatarUrl]="channelAvatar(item)"
[name]="item.uploaderName || null"
></app-channel-identity>
</div>
</li>
}
@@ -3,6 +3,7 @@ import { CommonModule } from '@angular/common';
import { RouterLink } from '@angular/router';
import { PROVIDERS, PROVIDER_MAP, type ProviderId } from '../../app/core/providers/provider-registry';
import type { SuggestionItem } from '../../app/search/search.service';
import { ChannelIdentityComponent } from '../../app/shared/components/channel-identity/channel-identity.component';
interface SuggestionSection {
id: ProviderId;
@@ -17,7 +18,7 @@ interface SuggestionSection {
@Component({
selector: 'app-search-suggestions',
standalone: true,
imports: [CommonModule, RouterLink],
imports: [CommonModule, RouterLink, ChannelIdentityComponent],
templateUrl: './search-suggestions.component.html'
})
export class SearchSuggestionsComponent {
@@ -29,8 +30,7 @@ export class SearchSuggestionsComponent {
// 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[]>);
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));
@@ -47,4 +47,30 @@ export class SearchSuggestionsComponent {
} as SuggestionSection;
});
});
/** ExternalId chaîne pour le lien /channel (null = non cliquable). */
channelExternalId(section: { id: ProviderId }, item: SuggestionItem): string | null {
const raw = item as unknown as Record<string, unknown>;
if (section.id === 'pt') {
// PeerTube : `instance|channel`, instance dérivée de l'URL vidéo.
try {
const url = String(raw?.['url'] || '');
const host = url ? new URL(url).hostname : '';
const channel = String(raw?.['channelId'] || '');
if (host && channel) return `${host}|${channel}`;
} catch {}
return null;
}
if (section.id === 'od') {
const name = String(raw?.['uploaderName'] || '');
return name || null;
}
const ext = String(raw?.['channelExternalId'] || raw?.['channelId'] || '');
return ext || null;
}
channelAvatar(item: SuggestionItem): string | null {
const raw = item as unknown as Record<string, unknown>;
return String(raw?.['uploaderAvatar'] || '') || null;
}
}
@@ -119,8 +119,8 @@
</div>
<div class="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 gap-x-6 gap-y-8">
<div *ngFor="let v of videos(); trackBy: trackByVideo" class="group">
<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 *ngFor="let v of videos(); trackBy: trackByVideo" class="group 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">
<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">
@@ -137,17 +137,22 @@
<app-like-button [videoId]="v.videoId" [provider]="provider()" [title]="v.title" [thumbnail]="v.thumbnail"></app-like-button>
</div>
</div>
<div class="p-3">
<div class="p-3 pb-1">
<h3 class="font-semibold text-slate-100 group-hover:text-red-400 transition-colors duration-200 text-sm line-clamp-2 pr-2">{{ v.title }}</h3>
<div class="mt-2 flex items-center space-x-2 text-xs text-slate-400">
<img [src]="v.uploaderAvatar" [alt]="v.uploaderName" class="w-6 h-6 rounded-full">
<span>{{ v.uploaderName }}</span>
</div>
<div class="mt-1.5 text-xs text-slate-400">
<span>{{ v.views | number }} visionnements</span> &bull; <span>{{ v.uploadedDate | date:'shortDate' }}</span>
</div>
</div>
</a>
<div class="px-3 pb-3">
<app-channel-identity
size="sm"
[provider]="v.provider || provider()"
[externalId]="v.channelExternalId ?? null"
[avatarUrl]="v.channelAvatarUrl || v.uploaderAvatar || null"
[name]="v.uploaderName || null"
></app-channel-identity>
<div class="mt-1.5 text-xs text-slate-400">
<span>{{ v.views | number }} visionnements</span> &bull; <span>{{ v.uploadedDate | date:'shortDate' }}</span>
</div>
</div>
</div>
</div>
@@ -10,6 +10,7 @@ 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 { ChannelIdentityComponent } from '../../app/shared/components/channel-identity/channel-identity.component';
import { classifyVideo, isShortVideo } from '../../app/shared/utils/video-kind';
import { map, of, switchMap, catchError, Observable } from 'rxjs';
import { PagedResult } from '../../services/youtube-api.service';
@@ -46,7 +47,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, ProviderBadgeComponent]
imports: [CommonModule, FormsModule, RouterLink, TranslatePipe, LikeButtonComponent, DurationPipe, ProviderBadgeComponent, ChannelIdentityComponent]
})
export class ProviderThemePageComponent implements OnDestroy {
private route = inject(ActivatedRoute);
+33 -23
View File
@@ -43,32 +43,42 @@
<!-- Grid -->
<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 }"
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 top-2 right-2">
<app-provider-badge [provider]="b.provider"></app-provider-badge>
<div *ngFor="let v of filteredItems(b)"
class="group bg-slate-800/50 rounded-lg overflow-hidden hover:bg-slate-700/50 transition-all duration-300">
<a [routerLink]="['/watch', v.videoId]"
[queryParams]="watchQueryParams(b.provider, v)"
[state]="{ video: v }"
class="block">
<div class="relative">
<img [src]="v.thumbnail" [alt]="v.title" loading="lazy" decoding="async" class="w-full h-40 object-cover">
<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>
<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>
</div>
</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>
<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>
<div class="p-3 pb-1">
<h4 class="font-semibold line-clamp-2">{{ v.title }}</h4>
</div>
</a>
<div class="px-3 pb-3">
<app-channel-identity
size="sm"
[provider]="v.provider || b.provider"
[externalId]="v.channelExternalId ?? null"
[avatarUrl]="v.channelAvatarUrl || v.uploaderAvatar || null"
[name]="v.uploaderName || null"
></app-channel-identity>
</div>
<div class="p-3">
<h4 class="font-semibold line-clamp-2">{{ v.title }}</h4>
<div class="text-sm text-slate-400 mt-1">{{ v.uploaderName }}</div>
</div>
</a>
</div>
</div>
</section>
</div>
@@ -8,6 +8,7 @@ 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 { ChannelIdentityComponent } from '../../app/shared/components/channel-identity/channel-identity.component';
import { DurationPipe } from '../../app/shared/pipes/duration.pipe';
import { classifyVideo, isShortVideo } from '../../app/shared/utils/video-kind';
@@ -24,7 +25,7 @@ interface ProviderBlock {
standalone: true,
changeDetection: ChangeDetectionStrategy.OnPush,
templateUrl: './theme-page.component.html',
imports: [CommonModule, RouterLink, TranslatePipe, LikeButtonComponent, ProviderBadgeComponent, DurationPipe]
imports: [CommonModule, RouterLink, TranslatePipe, LikeButtonComponent, ProviderBadgeComponent, DurationPipe, ChannelIdentityComponent]
})
export class ThemePageComponent {
private route = inject(ActivatedRoute);
+55 -25
View File
@@ -61,19 +61,39 @@
<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">
@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 || channelMeta()?.title || 'Chaîne inconnue' }}</div>
@if (channelSubsLabel(); as subs) {
<div>{{ subs }} abonnés</div>
<a *ngIf="canSubscribe(); else plainIdentity"
[routerLink]="['/channel', subscribeProvider(), subscribeExternalId()]"
class="flex items-center space-x-3 rounded-lg"
title="Ouvrir la page de la chaîne">
@if (channelAvatarUrl(); as avatar) {
<img [src]="avatar" [alt]="v.uploaderName" (error)="onAvatarError()"
class="w-10 h-10 rounded-full object-cover bg-slate-700 hover:ring-2 hover:ring-red-500/60" 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 hover:ring-2 hover:ring-red-500/60"
[attr.aria-label]="v.uploaderName">{{ channelInitial() }}</div>
}
</div>
<div>
<div class="font-semibold text-slate-200 hover:text-red-300">{{ v.uploaderName || channelMeta()?.title || 'Chaîne inconnue' }}</div>
@if (channelSubsLabel(); as subs) {
<div>{{ subs }} abonnés</div>
}
</div>
</a>
<ng-template #plainIdentity>
@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 || channelMeta()?.title || 'Chaîne inconnue' }}</div>
@if (channelSubsLabel(); as subs) {
<div>{{ subs }} abonnés</div>
}
</div>
</ng-template>
@if (canSubscribe() && isLoggedIn()) {
<app-subscribe-button
[provider]="subscribeProvider()!"
@@ -317,22 +337,32 @@
<h2 class="text-xl font-bold mb-4">Up next</h2>
<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="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 class="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">
<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 *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 class="flex-1 min-w-0">
<h3 class="font-semibold text-sm line-clamp-2 group-hover:text-red-400">{{ related.title }}</h3>
<div class="text-xs text-slate-400">{{ formatNumber(related.views) }} views</div>
</div>
</a>
<div class="mt-1 pl-1">
<app-channel-identity
size="sm"
[provider]="related.provider"
[externalId]="related.channelExternalId ?? null"
[avatarUrl]="related.channelAvatarUrl || related.uploaderAvatar || null"
[name]="related.uploaderName || null"
></app-channel-identity>
</div>
<div class="flex-1">
<h3 class="font-semibold text-sm line-clamp-2 group-hover:text-red-400">{{ related.title }}</h3>
<div class="text-xs text-slate-400 mt-1">{{ related.uploaderName }}</div>
<div class="text-xs text-slate-400">{{ formatNumber(related.views) }} views</div>
</div>
</a>
</div>
}
</div>
</div>
+2 -1
View File
@@ -21,6 +21,7 @@ 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 { ChannelIdentityComponent } from '../../app/shared/components/channel-identity/channel-identity.component';
import { DurationPipe } from '../../app/shared/pipes/duration.pipe';
@Component({
@@ -28,7 +29,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, SubscribeButtonComponent]
imports: [CommonModule, VideoPlayerComponent, RouterLink, AddToPlaylistComponent, ProviderBadgeComponent, DurationPipe, SubscribeButtonComponent, ChannelIdentityComponent]
})
export class WatchComponent implements OnDestroy, AfterViewInit {
private route = inject(ActivatedRoute);