diff --git a/libs/portal/downloads/feature/src/lib/offline-detail/download-offline-detail.component.html b/libs/portal/downloads/feature/src/lib/offline-detail/download-offline-detail.component.html index 2af873533..2e1dfd900 100644 --- a/libs/portal/downloads/feature/src/lib/offline-detail/download-offline-detail.component.html +++ b/libs/portal/downloads/feature/src/lib/offline-detail/download-offline-detail.component.html @@ -1,5 +1,6 @@ @if (detail(); as offlineDetail) { - + {{ 'DOWNLOADS.OFFLINE_DETAIL.AVAILABLE' | translate }} - + @if (metadata()?.year; as year) { @if (positiveFinite(year)) { - {{ year }} + {{ year }} } } @if (durationLabel(); as duration) { - {{ duration }} + {{ duration }} } @for (genre of genres(); track genre) { - {{ genre }} + {{ genre }} } @if (metadata()?.rating; as rating) { @if (positiveFinite(rating)) { - + {{ rating }} - + } } @if (metadata()?.status; as status) { @if (status.trim()) { - - {{ status }} - + {{ status }} } } - @if (cast().length) { -
- {{ 'XTREAM.ACTORS' | translate }} -
- @for (person of cast(); track personTrackKey(person)) { - - @if (person.profileUrl) { - - } @else { - - } - {{ - person.name - }} - - } -
-
- } - @if (creators().length) { -
- {{ - 'XTREAM.DIRECTOR' | translate - }} -
- @for ( - person of creators(); - track personTrackKey(person) - ) { - - @if (person.profileUrl) { - - } @else { - - } - {{ - person.name - }} - - } -
-
- } + @if (movieFileSize(); as size) {
@@ -455,6 +386,14 @@ } } +
+ + +
} @else { { expect(text()).toContain('Released'); expect(text()).toContain('Ada Actor'); expect(text()).toContain('Cora Creator'); + // No actor page offline: the shared cast row renders plain entries. + const person = (fixture.nativeElement as HTMLElement).querySelector( + 'app-cast-crew-row .person' + ); + expect(person).not.toBeNull(); + expect(person?.getAttribute('role')).toBeNull(); expect(text()).toContain('2 KB'); expect(text()).not.toContain('{{size}}'); expect(downloads.formatBytes).toHaveBeenCalledWith(2_048); diff --git a/libs/portal/downloads/feature/src/lib/offline-detail/download-offline-detail.component.ts b/libs/portal/downloads/feature/src/lib/offline-detail/download-offline-detail.component.ts index e55c22fc1..63280655c 100644 --- a/libs/portal/downloads/feature/src/lib/offline-detail/download-offline-detail.component.ts +++ b/libs/portal/downloads/feature/src/lib/offline-detail/download-offline-detail.component.ts @@ -12,10 +12,14 @@ import { ActivatedRoute } from '@angular/router'; import { TranslatePipe } from '@ngx-translate/core'; import { DownloadsService, SettingsStore } from '@iptvnator/services'; import { + CastCrewRowComponent, DetailActionsTemplateDirective, + DetailCreditsComponent, DetailMetaTemplateDirective, DetailTagsTemplateDirective, + MetaChipComponent, PortalDetailShellComponent, + scrollToCastCrewRow, } from '@iptvnator/ui/components'; import { DownloadLibraryNavigationService } from '../download-library-navigation.service'; import { DownloadManagerActionsService } from '../download-manager-actions.service'; @@ -32,7 +36,7 @@ import { offlineEpisodeTitle, offlineFileByteCount, offlineMetadataResolutionKey, - offlinePersonTrackKey, + offlinePeopleAsCast, offlinePositiveFinite, offlineSeasonTestId, type OfflineDetailItem, @@ -47,9 +51,12 @@ import { DownloadOfflineSeasonSelectionService } from './download-offline-season @Component({ selector: 'app-download-offline-detail', imports: [ + CastCrewRowComponent, DetailActionsTemplateDirective, + DetailCreditsComponent, DetailMetaTemplateDirective, DetailTagsTemplateDirective, + MetaChipComponent, MatButtonModule, MatIconModule, MatMenuModule, @@ -156,6 +163,21 @@ export class DownloadOfflineDetailComponent { readonly creators = computed(() => boundedOfflinePeople(this.metadata()?.creators) ); + readonly castMembers = computed(() => offlinePeopleAsCast(this.cast())); + readonly creatorMembers = computed(() => + offlinePeopleAsCast(this.creators()) + ); + readonly castNames = computed(() => + this.cast().map((person) => person.name) + ); + readonly creatorNames = computed(() => + this.creators().map((person) => person.name) + ); + /** The hero decides its layout once per download, not per title: two downloads can share one. */ + readonly heroKey = computed(() => { + const downloadId = this.downloadId(); + return downloadId === undefined ? null : `download:${downloadId}`; + }); readonly providerState = this.providerCoordinator.state; readonly canOpenInPortal = computed( () => this.providerState().status === 'available' @@ -208,7 +230,7 @@ export class DownloadOfflineDetailComponent { readonly episodeCoordinate = offlineEpisodeCoordinate; readonly episodeTitle = offlineEpisodeTitle; readonly positiveFinite = offlinePositiveFinite; - readonly personTrackKey = offlinePersonTrackKey; + readonly scrollToCast = scrollToCastCrewRow; constructor() { void this.downloadsService.loadDownloads(); diff --git a/libs/portal/downloads/feature/src/lib/offline-detail/download-offline-detail.presentation.spec.ts b/libs/portal/downloads/feature/src/lib/offline-detail/download-offline-detail.presentation.spec.ts index 697622078..e425fc9f7 100644 --- a/libs/portal/downloads/feature/src/lib/offline-detail/download-offline-detail.presentation.spec.ts +++ b/libs/portal/downloads/feature/src/lib/offline-detail/download-offline-detail.presentation.spec.ts @@ -1,9 +1,36 @@ import { boundedOfflinePeople, + offlinePeopleAsCast, offlinePersonTrackKey, } from './download-offline-detail.presentation'; describe('offline detail people presentation', () => { + it('hands people to the shared cast row with the role as the character', () => { + expect( + offlinePeopleAsCast([ + { + name: 'Ada Actor', + role: 'Mara', + profileUrl: 'https://img/ada.jpg', + }, + { name: 'Cora Creator', tmdbPersonId: 7 }, + ]) + ).toEqual([ + { + name: 'Ada Actor', + character: 'Mara', + profileUrl: 'https://img/ada.jpg', + tmdbPersonId: undefined, + }, + { + name: 'Cora Creator', + character: undefined, + profileUrl: null, + tmdbPersonId: 7, + }, + ]); + }); + it('deduplicates people and supplies unique stable composite track keys', () => { const people = boundedOfflinePeople([ { tmdbPersonId: 7, name: 'Ada Actor', role: 'Lead' }, diff --git a/libs/portal/downloads/feature/src/lib/offline-detail/download-offline-detail.presentation.ts b/libs/portal/downloads/feature/src/lib/offline-detail/download-offline-detail.presentation.ts index de6946bec..a267a8826 100644 --- a/libs/portal/downloads/feature/src/lib/offline-detail/download-offline-detail.presentation.ts +++ b/libs/portal/downloads/feature/src/lib/offline-detail/download-offline-detail.presentation.ts @@ -2,6 +2,7 @@ import type { DownloadItem } from '@iptvnator/services'; import type { DownloadMetadataPerson, DownloadMetadataSnapshot, + TmdbEnrichedCastMember, } from '@iptvnator/shared/interfaces'; import type { DeepReadonly, @@ -144,6 +145,18 @@ export function boundedOfflinePeople( return people; } +/** The shared cast row's shape: the role becomes the character line. */ +export function offlinePeopleAsCast( + people: readonly DownloadMetadataPerson[] +): TmdbEnrichedCastMember[] { + return people.map((person) => ({ + name: person.name, + character: person.role, + profileUrl: person.profileUrl ?? null, + tmdbPersonId: person.tmdbPersonId, + })); +} + export function offlinePersonTrackKey(person: DownloadMetadataPerson): string { return [person.tmdbPersonId ?? 'local', offlinePersonNameKey(person)].join( ':' diff --git a/libs/portal/downloads/feature/src/lib/recording-detail/recording-detail.component.html b/libs/portal/downloads/feature/src/lib/recording-detail/recording-detail.component.html index 11dbb1bf1..89af5fb8a 100644 --- a/libs/portal/downloads/feature/src/lib/recording-detail/recording-detail.component.html +++ b/libs/portal/downloads/feature/src/lib/recording-detail/recording-detail.component.html @@ -1,5 +1,6 @@ @if (item(); as recording) { - + {{ 'DOWNLOADS.REMOVE_FROM_MANAGER' | translate }}
} @else { - + {{ 'DOWNLOADS.RECORDING_BADGE' | translate }} - - {{ recording.channelName }} + + {{ recording.channelName }} @if (durationLabel()) { - {{ durationLabel() }} + {{ durationLabel() }} } @if (sizeLabel()) { - {{ sizeLabel() }} + {{ sizeLabel() }} } - MPEG-TS + MPEG-TS @if (recording.status === 'interrupted') { - + {{ 'DOWNLOADS.STATUS.INTERRUPTED' | translate }} - + } @@ -129,9 +132,7 @@ type="button" data-testid="recording-overflow" [matMenuTriggerFor]="detailMenu" - [attr.aria-label]=" - 'DOWNLOADS.MORE_ACTIONS' | translate - " + [attr.aria-label]="'DOWNLOADS.MORE_ACTIONS' | translate" > more_vert @@ -167,17 +168,22 @@ @for (program of coveredPrograms(); track program.start) {
  • {{ program.title }}
    @if (program.description) { -
    +
    {{ program.description }}
    } diff --git a/libs/portal/downloads/feature/src/lib/recording-detail/recording-detail.component.scss b/libs/portal/downloads/feature/src/lib/recording-detail/recording-detail.component.scss index e2a5dcccf..06022da70 100644 --- a/libs/portal/downloads/feature/src/lib/recording-detail/recording-detail.component.scss +++ b/libs/portal/downloads/feature/src/lib/recording-detail/recording-detail.component.scss @@ -4,9 +4,6 @@ } .recording-detail__badge { - display: inline-flex; - align-items: center; - gap: 4px; background: color-mix(in srgb, var(--mat-sys-error) 18%, transparent); color: var(--mat-sys-error); font-weight: 700; diff --git a/libs/portal/downloads/feature/src/lib/recording-detail/recording-detail.component.ts b/libs/portal/downloads/feature/src/lib/recording-detail/recording-detail.component.ts index 62f7a8b6d..9c64c952a 100644 --- a/libs/portal/downloads/feature/src/lib/recording-detail/recording-detail.component.ts +++ b/libs/portal/downloads/feature/src/lib/recording-detail/recording-detail.component.ts @@ -18,6 +18,7 @@ import { DetailActionsTemplateDirective, DetailMetaTemplateDirective, DetailTagsTemplateDirective, + MetaChipComponent, PortalDetailShellComponent, } from '@iptvnator/ui/components'; import { map } from 'rxjs'; @@ -52,6 +53,7 @@ import { MatButtonModule, MatIcon, MatMenuModule, + MetaChipComponent, PortalDetailShellComponent, TranslatePipe, ],