diff --git a/src/app/search/adapters/dm.ts b/src/app/search/adapters/dm.ts index a8a0178..d539298 100644 --- a/src/app/search/adapters/dm.ts +++ b/src/app/search/adapters/dm.ts @@ -22,6 +22,8 @@ export class DmAdapter implements ProviderAdapter { 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 }; } diff --git a/src/app/search/adapters/od.ts b/src/app/search/adapters/od.ts index 2c41659..f6966a1 100644 --- a/src/app/search/adapters/od.ts +++ b/src/app/search/adapters/od.ts @@ -30,6 +30,9 @@ export class OdAdapter implements ProviderAdapter { 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 }; diff --git a/src/app/search/adapters/pt.ts b/src/app/search/adapters/pt.ts index 3073ce6..0027a50 100644 --- a/src/app/search/adapters/pt.ts +++ b/src/app/search/adapters/pt.ts @@ -12,16 +12,26 @@ export class PtAdapter implements ProviderAdapter { 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(`/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 }; } } diff --git a/src/app/search/adapters/yt.ts b/src/app/search/adapters/yt.ts index 03ce94e..6fc4058 100644 --- a/src/app/search/adapters/yt.ts +++ b/src/app/search/adapters/yt.ts @@ -21,6 +21,9 @@ export class YtAdapter implements ProviderAdapter { 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 }; } diff --git a/src/app/shared/components/channel-identity/channel-identity.component.html b/src/app/shared/components/channel-identity/channel-identity.component.html new file mode 100644 index 0000000..b9b3024 --- /dev/null +++ b/src/app/shared/components/channel-identity/channel-identity.component.html @@ -0,0 +1,33 @@ + + + + + {{ initial }} + + + {{ resolvedName }} + ✓ + + + + + + + {{ initial }} + + + {{ resolvedName }} + ✓ + + diff --git a/src/app/shared/components/channel-identity/channel-identity.component.ts b/src/app/shared/components/channel-identity/channel-identity.component.ts new file mode 100644 index 0000000..71622e6 --- /dev/null +++ b/src/app/shared/components/channel-identity/channel-identity.component.ts @@ -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 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; + } +} diff --git a/src/app/shared/components/provider-badge/provider-badge.component.ts b/src/app/shared/components/provider-badge/provider-badge.component.ts index 3c008f6..7da4e9a 100644 --- a/src/app/shared/components/provider-badge/provider-badge.component.ts +++ b/src/app/shared/components/provider-badge/provider-badge.component.ts @@ -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 = { + 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; } diff --git a/src/app/shared/components/video-card/video-card.component.html b/src/app/shared/components/video-card/video-card.component.html index 1161f9f..ed7ff83 100644 --- a/src/app/shared/components/video-card/video-card.component.html +++ b/src/app/shared/components/video-card/video-card.component.html @@ -22,73 +22,20 @@
- - - -
- - - - {{ channelInitial(video.channelMeta ?? null, video.channelName) }} - - -
- - {{ channelDisplayName(video.channelMeta ?? null, video.channelName) }} - -
- - - -
- -
-
- - - - {{ channelInitial(video.channelMeta ?? null, video.channelName) }} - - -
- - {{ channelDisplayName(video.channelMeta ?? null, video.channelName) }} - -
-
-
- -
-
- - {{ channelInitial(null, video.channelName) }} - -
- - {{ video.channelName }} - -
-
+ +
diff --git a/src/app/shared/components/video-card/video-card.component.ts b/src/app/shared/components/video-card/video-card.component.ts index f285f02..7abe88e 100644 --- a/src/app/shared/components/video-card/video-card.component.ts +++ b/src/app/shared/components/video-card/video-card.component.ts @@ -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()); } } diff --git a/src/components/channel/channel-header/channel-header.component.html b/src/components/channel/channel-header/channel-header.component.html index f45930a..ed1325d 100644 --- a/src/components/channel/channel-header/channel-header.component.html +++ b/src/components/channel/channel-header/channel-header.component.html @@ -19,10 +19,16 @@
+ +
+ {{ (channel.title || channel.handle || '?').charAt(0).toUpperCase() }} +
+

diff --git a/src/components/library/subscriptions/subscriptions.component.html b/src/components/library/subscriptions/subscriptions.component.html index b22a3f9..6b43ed1 100644 --- a/src/components/library/subscriptions/subscriptions.component.html +++ b/src/components/library/subscriptions/subscriptions.component.html @@ -63,16 +63,19 @@ >
- + + +
diff --git a/src/components/library/subscriptions/subscriptions.component.ts b/src/components/library/subscriptions/subscriptions.component.ts index f7cf9bf..2e645b4 100644 --- a/src/components/library/subscriptions/subscriptions.component.ts +++ b/src/components/library/subscriptions/subscriptions.component.ts @@ -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, }) diff --git a/src/components/search/search-suggestions.component.html b/src/components/search/search-suggestions.component.html index 144e5f1..d00b02d 100644 --- a/src/components/search/search-suggestions.component.html +++ b/src/components/search/search-suggestions.component.html @@ -19,9 +19,15 @@ @if (item.thumbnail) { } -
+
{{ item.title }}
-
{{ item.uploaderName }}
+
} diff --git a/src/components/search/search-suggestions.component.ts b/src/components/search/search-suggestions.component.ts index fb9f9c0..76811ff 100644 --- a/src/components/search/search-suggestions.component.ts +++ b/src/components/search/search-suggestions.component.ts @@ -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(() => { - const groups = this.groups || ({} as Record); + readonly sections = computed(() => { const groups = this.groups || ({} as Record); 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; + 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; + return String(raw?.['uploaderAvatar'] || '') || null; + } } diff --git a/src/components/themes/provider-theme-page.component.html b/src/components/themes/provider-theme-page.component.html index e23b04a..c0f6641 100644 --- a/src/components/themes/provider-theme-page.component.html +++ b/src/components/themes/provider-theme-page.component.html @@ -119,8 +119,8 @@
-
- + diff --git a/src/components/themes/provider-theme-page.component.ts b/src/components/themes/provider-theme-page.component.ts index 0429577..cd4cfb5 100644 --- a/src/components/themes/provider-theme-page.component.ts +++ b/src/components/themes/provider-theme-page.component.ts @@ -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 = { 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); diff --git a/src/components/themes/theme-page.component.html b/src/components/themes/theme-page.component.html index b9e95d9..67deb37 100644 --- a/src/components/themes/theme-page.component.html +++ b/src/components/themes/theme-page.component.html @@ -43,32 +43,42 @@
- -
- - diff --git a/src/components/themes/theme-page.component.ts b/src/components/themes/theme-page.component.ts index e3268a4..02ebf07 100644 --- a/src/components/themes/theme-page.component.ts +++ b/src/components/themes/theme-page.component.ts @@ -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); diff --git a/src/components/watch/watch.component.html b/src/components/watch/watch.component.html index f196225..7432b18 100644 --- a/src/components/watch/watch.component.html +++ b/src/components/watch/watch.component.html @@ -61,19 +61,39 @@
- @if (channelAvatarUrl(); as avatar) { - - } @else { -
{{ channelInitial() }}
- } -
-
{{ v.uploaderName || channelMeta()?.title || 'Chaîne inconnue' }}
- @if (channelSubsLabel(); as subs) { -
{{ subs }} abonnés
+ + @if (channelAvatarUrl(); as avatar) { + + } @else { +
{{ channelInitial() }}
} -
+
+
{{ v.uploaderName || channelMeta()?.title || 'Chaîne inconnue' }}
+ @if (channelSubsLabel(); as subs) { +
{{ subs }} abonnés
+ } +
+
+ + @if (channelAvatarUrl(); as avatar) { + + } @else { +
{{ channelInitial() }}
+ } +
+
{{ v.uploaderName || channelMeta()?.title || 'Chaîne inconnue' }}
+ @if (channelSubsLabel(); as subs) { +
{{ subs }} abonnés
+ } +
+
@if (canSubscribe() && isLoggedIn()) { Up next

@for (related of v.relatedStreams; track related.videoId) { - -
- - diff --git a/src/components/watch/watch.component.ts b/src/components/watch/watch.component.ts index 6025b92..bcbabde 100644 --- a/src/components/watch/watch.component.ts +++ b/src/components/watch/watch.component.ts @@ -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);