fix(m3u): the hero decides its layout per entry, keyed by id and name

M3U entries can share an id and differ by name, which is why the TMDB
lookup keys by both. The hero's one-time layout was keyed by the id
alone, so zapping between such entries kept the previous entry's
stage or compact layout. The hero key now carries the same identity.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
4grayandClaude Fable 5.1 committed 2026-10-03 18:11:10 +02:00
1 parent 392ce5cb43
commit 9d4c7785f8
4 files changed
+39 -5

No files matched your search

@@ -1,6 +1,6 @@
<app-portal-detail-shell
[backAvailable]="false"
[contentKey]="'m3u:' + channel().id"
[contentKey]="heroKey()"
[title]="title()"
[kindLabel]="kindLabel()"
[description]="overview()"
@@ -291,6 +291,26 @@ describe('M3uVodDetailComponent', () => {
expect(component.playbackActive()).toBe(true);
});
it('keys the hero by id and name, like the TMDB lookup', async () => {
await create({ channel: channel() });
const shell = fixture.debugElement.query(
By.directive(StubPortalDetailShellComponent)
).componentInstance as StubPortalDetailShellComponent;
const first = shell.contentKey();
// Entries can share an id and differ by name; the next one may have
// a backdrop where this one had none, so the hero must decide its
// layout again.
fixture.componentRef.setInput(
'channel',
channel({ name: 'Dune Part Two (2024) 1080p' })
);
fixture.detectChanges();
expect(first).toContain('ch1');
expect(shell.contentKey()).not.toBe(first);
});
it('restarts the payload identity when zapping to another movie', async () => {
await create({ channel: channel(), playback: playback() });
const first = fixture.componentInstance.inlinePlayback();
@@ -35,7 +35,10 @@ import {
} from '@iptvnator/ui/components';
import { formatDurationLabel } from '@iptvnator/portal/shared/util';
import { PortalInlinePlayerComponent } from '@iptvnator/ui/playback';
import { M3uVodMetadataService } from './m3u-vod-metadata.service';
import {
M3uVodMetadataService,
m3uVodEntryKey,
} from './m3u-vod-metadata.service';
/**
* VOD detail experience for an M3U entry recognized as a movie: the same
@@ -115,6 +118,13 @@ export class M3uVodDetailComponent {
readonly tmdb = computed(() => this.metadata.state().details);
/**
* What the hero decides its layout for, once per entry. Keyed like the
* TMDB lookup, by id AND name: entries sharing an id differ by name, and
* the next one may have a backdrop where this one had none.
*/
readonly heroKey = computed(() => `m3u:${m3uVodEntryKey(this.channel())}`);
readonly title = computed(
() => this.tmdb()?.title?.trim() || this.channel().name
);
@@ -23,8 +23,12 @@ const IDLE_STATE: M3uVodMetadataState = {
details: null,
};
/** NUL keeps the two parts unambiguous whatever an entry is named. */
function lookupKeyOf(channel: Pick<Channel, 'id' | 'name'>): string {
/**
* The identity of an M3U entry: entries can share an id and differ by
* name, so both parts count. NUL keeps them unambiguous whatever an entry
* is named.
*/
export function m3uVodEntryKey(channel: Pick<Channel, 'id' | 'name'>): string {
return `${channel.id}\u0000${channel.name ?? ''}`;
}
@@ -50,7 +54,7 @@ export class M3uVodMetadataService {
* only the response for the channel the state currently tracks lands.
*/
load(channel: Pick<Channel, 'id' | 'name'>): void {
const lookupKey = lookupKeyOf(channel);
const lookupKey = m3uVodEntryKey(channel);
if (this.stateSignal().lookupKey === lookupKey) {
return;
}