feat(channel): identite chaine partagee cliquable partout + logos fiabilises
CI / build-and-test (push) Successful in 13m25s
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:
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user