fix(downloads): download and recording details use the shared chips and cast row

Both pages still projected the legacy tag and cast markup whose styles
the redesign removed, so chips rendered as plain text and cast photos at
their intrinsic size. The tags are meta chips now, the offline cast is
the shared credits line plus the cast row (plain entries, there is no
actor page offline), and each hero is keyed by the download or
recording id so two entries sharing a title decide their layout apart.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
4grayandClaude Fable 5.1 committed 2026-10-03 18:28:53 +02:00
1 parent 9d4c7785f8
commit 2a7fdd7c89
9 files changed
+117 -115

No files matched your search

@@ -1,5 +1,6 @@
@if (detail(); as offlineDetail) {
<app-portal-detail-shell
[contentKey]="heroKey()"
[title]="title()"
[description]="description()"
[posterUrl]="posterUrl()"
@@ -8,111 +9,41 @@
(backClicked)="goBack()"
>
<ng-template appDetailTags>
<span class="details__tag offline-detail__available">
<app-meta-chip class="offline-detail__available">
<mat-icon aria-hidden="true">download_done</mat-icon>
{{ 'DOWNLOADS.OFFLINE_DETAIL.AVAILABLE' | translate }}
</span>
</app-meta-chip>
@if (metadata()?.year; as year) {
@if (positiveFinite(year)) {
<span class="details__tag">{{ year }}</span>
<app-meta-chip>{{ year }}</app-meta-chip>
}
}
@if (durationLabel(); as duration) {
<span class="details__tag">{{ duration }}</span>
<app-meta-chip>{{ duration }}</app-meta-chip>
}
@for (genre of genres(); track genre) {
<span class="details__tag">{{ genre }}</span>
<app-meta-chip>{{ genre }}</app-meta-chip>
}
@if (metadata()?.rating; as rating) {
@if (positiveFinite(rating)) {
<span class="details__tag details__tag--rating">
<app-meta-chip variant="rating">
<span aria-hidden="true">★</span> {{ rating }}
</span>
</app-meta-chip>
}
}
@if (metadata()?.status; as status) {
@if (status.trim()) {
<span class="details__tag details__tag--status">
{{ status }}
</span>
<app-meta-chip variant="status">{{ status }}</app-meta-chip>
}
}
</ng-template>
<ng-template appDetailMeta>
@if (cast().length) {
<div class="details__meta-item offline-detail__people-group">
<span class="label">{{ 'XTREAM.ACTORS' | translate }}</span>
<div class="details__cast-strip">
@for (person of cast(); track personTrackKey(person)) {
<span
class="details__cast-chip offline-detail__person"
[title]="
person.role
? person.name + ' — ' + person.role
: person.name
"
>
@if (person.profileUrl) {
<img
class="details__cast-avatar"
[src]="person.profileUrl"
[alt]="person.name"
loading="lazy"
/>
} @else {
<span
class="details__cast-avatar details__cast-avatar--fallback"
aria-hidden="true"
>
{{ person.name.charAt(0) }}
</span>
}
<span class="details__cast-name">{{
person.name
}}</span>
</span>
}
</div>
</div>
}
@if (creators().length) {
<div class="details__meta-item offline-detail__people-group">
<span class="label">{{
'XTREAM.DIRECTOR' | translate
}}</span>
<div class="details__cast-strip">
@for (
person of creators();
track personTrackKey(person)
) {
<span
class="details__cast-chip offline-detail__person"
[title]="person.name"
>
@if (person.profileUrl) {
<img
class="details__cast-avatar"
[src]="person.profileUrl"
[alt]="person.name"
loading="lazy"
/>
} @else {
<span
class="details__cast-avatar details__cast-avatar--fallback"
aria-hidden="true"
>
{{ person.name.charAt(0) }}
</span>
}
<span class="details__cast-name">{{
person.name
}}</span>
</span>
}
</div>
</div>
}
<app-detail-credits
[cast]="castNames()"
[directors]="creatorNames()"
(moreRequested)="scrollToCast()"
/>
@if (movieFileSize(); as size) {
<div class="details__meta-item">
<span class="value">
@@ -455,6 +386,14 @@
}
</section>
}
<div detail-extras>
<!-- No actor page offline: the people stay plain entries -->
<app-cast-crew-row
[cast]="castMembers()"
[directors]="creatorMembers()"
[interactive]="false"
/>
</div>
</app-portal-detail-shell>
} @else {
<app-portal-detail-shell
@@ -22,16 +22,6 @@ button,
}
}
.offline-detail__people-group {
min-width: 0;
max-width: 100%;
}
.offline-detail__person {
cursor: default;
user-select: text;
}
.offline-detail__portal-action {
display: inline-flex;
}
@@ -317,6 +317,12 @@ describe('DownloadOfflineDetailComponent', () => {
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);
@@ -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();
@@ -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' },
@@ -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(
':'
@@ -1,5 +1,6 @@
@if (item(); as recording) {
<app-portal-detail-shell
[contentKey]="'recording:' + recordingId()"
[title]="title()"
[description]="recording.programDescription ?? ''"
[posterUrl]="recording.channelLogoUrl ?? ''"
@@ -36,28 +37,30 @@
[disabled]="isPending()"
(click)="runAction('remove')"
>
<mat-icon aria-hidden="true">remove_circle_outline</mat-icon>
<mat-icon aria-hidden="true"
>remove_circle_outline</mat-icon
>
{{ 'DOWNLOADS.REMOVE_FROM_MANAGER' | translate }}
</button>
</div>
} @else {
<ng-template appDetailTags>
<span class="details__tag recording-detail__badge">
<app-meta-chip class="recording-detail__badge">
<mat-icon aria-hidden="true">fiber_manual_record</mat-icon>
{{ 'DOWNLOADS.RECORDING_BADGE' | translate }}
</span>
<span class="details__tag">{{ recording.channelName }}</span>
</app-meta-chip>
<app-meta-chip>{{ recording.channelName }}</app-meta-chip>
@if (durationLabel()) {
<span class="details__tag">{{ durationLabel() }}</span>
<app-meta-chip>{{ durationLabel() }}</app-meta-chip>
}
@if (sizeLabel()) {
<span class="details__tag">{{ sizeLabel() }}</span>
<app-meta-chip>{{ sizeLabel() }}</app-meta-chip>
}
<span class="details__tag">MPEG-TS</span>
<app-meta-chip>MPEG-TS</app-meta-chip>
@if (recording.status === 'interrupted') {
<span class="details__tag details__tag--status">
<app-meta-chip variant="status">
{{ 'DOWNLOADS.STATUS.INTERRUPTED' | translate }}
</span>
</app-meta-chip>
}
</ng-template>
@@ -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"
>
<mat-icon>more_vert</mat-icon>
</button>
@@ -167,17 +168,22 @@
@for (program of coveredPrograms(); track program.start) {
<li class="recording-detail__program">
<time>
{{ programDisplayMs(program.start) | date: 'HH:mm' }} –
{{ programDisplayMs(program.stop) | date: 'HH:mm' }}
{{
programDisplayMs(program.start)
| date: 'HH:mm'
}}
–
{{
programDisplayMs(program.stop)
| date: 'HH:mm'
}}
</time>
<div>
<div class="recording-detail__program-title">
{{ program.title }}
</div>
@if (program.description) {
<div
class="recording-detail__program-desc"
>
<div class="recording-detail__program-desc">
{{ program.description }}
</div>
}
@@ -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;
@@ -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,
],