From 7ee4c5644f77ceacb9c8cb12fe0160e7631c6f56 Mon Sep 17 00:00:00 2001 From: 4gray Date: Tue, 28 Jul 2026 11:31:10 +0200 Subject: [PATCH] fix(portals): keep the route's own resume point, and honour a closed pin MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two more from review, both variations on "selected is not playing". `vodPlaybackPosition` followed whichever copy last reported — so after an alternative played, Resume and its label described that copy's row while starting the route's stream, jumping it to a timecode nobody reached in it. It now splits: `vodPlaybackPosition` stays the last position seen (the progress bar and the switch handoff want the stream on screen), and `routePlaybackPosition` holds the route copy's own row for everything that acts on the route's stream. `pinnedSourceAwaitingPlay` skipped the pin whenever its row was active, but `isActive` means selected — the pinned row stays selected after its player is closed, so the next Play went to the route copy and ignored the stored preference until the page was reopened. It now takes `playbackLive` too. The host service crossed the 400-line cap on the way, so the four derived alternative counts moved into `vod-multi-source-counts.ts`. Co-Authored-By: Claude Opus 5 --- docs/architecture/vod-multi-source.md | 12 ++++- .../vod-details-playback.service.spec.ts | 20 ++++++++ .../vod-details-playback.service.ts | 50 ++++++++++++++++--- .../vod-details-route.component.ts | 3 +- .../vod-details/vod-multi-source-counts.ts | 36 +++++++++++++ .../vod-multi-source-host-pin-writes.spec.ts | 6 ++- .../vod-multi-source-host-pin.spec.ts | 28 ++++++++++- .../vod-multi-source-host-races.spec.ts | 6 ++- .../vod-multi-source-host-session.spec.ts | 6 ++- .../vod-multi-source-host.service.spec.ts | 6 ++- .../vod-multi-source-host.service.ts | 30 +++++------ .../lib/vod-details/vod-multi-source-pin.ts | 14 +++++- 12 files changed, 187 insertions(+), 30 deletions(-) create mode 100644 libs/portal/xtream/feature/src/lib/vod-details/vod-multi-source-counts.ts diff --git a/docs/architecture/vod-multi-source.md b/docs/architecture/vod-multi-source.md index 84c8ab86b..d7c138929 100644 --- a/docs/architecture/vod-multi-source.md +++ b/docs/architecture/vod-multi-source.md @@ -425,6 +425,13 @@ was never established, so there is nothing to correct. ## What the primary button describes +Two position signals, not one. `vodPlaybackPosition` is the LAST position +seen — whichever copy produced it — and feeds the progress bar and the switch +handoff. `routePlaybackPosition` is the route copy's own row, and everything +that acts on the route's stream reads that: Resume, its label, its timecode. +Collapsing them lets an alternative's progress resume the route copy at a +timecode nobody reached in it. + A pin means the button plays a copy the page did not load a position for. `createPrimaryActionPosition` therefore looks that copy's row up and lets it govern the label, the timecode and the Restart affordance — including when the @@ -434,7 +441,10 @@ route's own row changes nothing; the loaded position already IS that copy's. ## Claims about the present -`isActive` means "the source a switch or Play would use". Discovery sets it the +`isActive` means "the source a switch or Play would use" — selection, not +playback. `pinnedSourceAwaitingPlay` therefore takes `playbackLive` as well: +a pinned row stays selected after its player is closed, and skipping the pin +on selection alone would send the next Play to the route copy. Discovery sets it the moment the page opens, and it survives closing the player — so it cannot, on its own, back a statement in the present tense. diff --git a/libs/portal/xtream/feature/src/lib/vod-details/vod-details-playback.service.spec.ts b/libs/portal/xtream/feature/src/lib/vod-details/vod-details-playback.service.spec.ts index 097a07515..c3c73ff43 100644 --- a/libs/portal/xtream/feature/src/lib/vod-details/vod-details-playback.service.spec.ts +++ b/libs/portal/xtream/feature/src/lib/vod-details/vod-details-playback.service.spec.ts @@ -206,6 +206,26 @@ describe('VodDetailsPlaybackService — external session ownership', () => { }); } + it('keeps the route’s own resume point when an alternative plays', () => { + activeSource.set({ + playlistId: 'playlist-2', + contentXtreamId: 991, + contentType: 'vod', + }); + + // An alternative's position arrives under ITS ids. Letting it stand in + // for the route copy's row would have Resume start the route stream at + // a timecode nobody ever reached in it. + emit('playlist-2', 991, 4200); + + expect(service.vodPlaybackPosition()?.positionSeconds).toBe(4200); + expect(service.routePlaybackPosition()).toBeNull(); + expect(service.hasPlaybackPosition()).toBe(false); + + emit(ROUTE_PLAYLIST, ROUTE_VOD_ID, 60); + expect(service.routePlaybackPosition()?.positionSeconds).toBe(60); + }); + it('takes the route stream’s progress', () => { emit(ROUTE_PLAYLIST, ROUTE_VOD_ID, 120); diff --git a/libs/portal/xtream/feature/src/lib/vod-details/vod-details-playback.service.ts b/libs/portal/xtream/feature/src/lib/vod-details/vod-details-playback.service.ts index 51e25385c..45878bf27 100644 --- a/libs/portal/xtream/feature/src/lib/vod-details/vod-details-playback.service.ts +++ b/libs/portal/xtream/feature/src/lib/vod-details/vod-details-playback.service.ts @@ -63,8 +63,26 @@ export class VodDetailsPlaybackService { ); readonly inlinePlayback = signal(null); + /** + * The LAST position seen, whichever copy produced it. + * + * Multi-source can put playback on a copy in another playlist, and this + * follows it — the progress bar and the switch feed both want the stream + * on screen, not the one the route happens to address. + */ readonly vodPlaybackPosition = signal(null); + /** + * The ROUTE copy's own row. + * + * Everything that acts on the route's stream — Resume, its label, its + * timecode — has to read this instead. Positions are keyed by (playlist, + * stream), so once an alternative has played, `vodPlaybackPosition` names + * a different film's row entirely and resuming from it would jump the + * route copy to a timecode nobody reached in it. + */ + readonly routePlaybackPosition = signal(null); + private readonly externalButton = createExternalPlaybackButtonState({ session: this.externalPlayback.activeSession, playlistId: computed(() => this.xtreamStore.currentPlaylist()?.id), @@ -82,9 +100,28 @@ export class VodDetailsPlaybackService { getPortalPlaybackProgressPercent(this.vodPlaybackPosition()) ); + /** Mirrors an incoming position into the route's row when it owns it. */ + private trackPosition(position: PlaybackPositionData | null): void { + this.vodPlaybackPosition.set(position); + if (this.isRouteContent(position)) { + this.routePlaybackPosition.set(position); + } + } + + private isRouteContent(position: PlaybackPositionData | null): boolean { + return ( + !!position && + position.playlistId === this.xtreamStore.currentPlaylist()?.id && + position.contentXtreamId === this.bindings()?.vodId() + ); + } + + /** Whether the ROUTE copy has somewhere to resume from. */ readonly hasPlaybackPosition = computed(() => { - const inProgress = - this.vodPlaybackProgress() > 0 && this.vodPlaybackProgress() < 90; + const progress = getPortalPlaybackProgressPercent( + this.routePlaybackPosition() + ); + const inProgress = progress > 0 && progress < 90; this.logger.debug('hasPlaybackPosition check', { vodId: this.bindings()?.vodId(), inProgress, @@ -99,7 +136,7 @@ export class VodDetailsPlaybackService { // An external player on an ALTERNATIVE reports under // that playlist's ids; dropping those rewinds a switch. if (this.ownsContent(data)) { - this.vodPlaybackPosition.set(data); + this.trackPosition(data); } } ) ?? null; @@ -196,7 +233,7 @@ export class VodDetailsPlaybackService { const info = getXtreamVodInfo(vodItem); this.addToRecentlyViewed(); const vodId = this.bindings()?.vodId() ?? NaN; - const position = this.vodPlaybackPosition(); + const position = this.routePlaybackPosition(); const streamUrl = this.xtreamStore.constructVodStreamUrl(vodItem); const contentInfo: PlayerContentInfo = { @@ -240,7 +277,7 @@ export class VodDetailsPlaybackService { } formatPosition(): string { - return formatPlaybackPosition(this.vodPlaybackPosition()); + return formatPlaybackPosition(this.routePlaybackPosition()); } closeInlinePlayer(): void { @@ -255,7 +292,7 @@ export class VodDetailsPlaybackService { playlistId, position ), - onSaved: (position) => this.vodPlaybackPosition.set(position), + onSaved: (position) => this.trackPosition(position), }); /** @@ -284,6 +321,7 @@ export class VodDetailsPlaybackService { 'vod' ); this.vodPlaybackPosition.set(position); + this.routePlaybackPosition.set(position); } private addToRecentlyViewed(): void { diff --git a/libs/portal/xtream/feature/src/lib/vod-details/vod-details-route.component.ts b/libs/portal/xtream/feature/src/lib/vod-details/vod-details-route.component.ts index be2ed8b8b..aa09e489f 100644 --- a/libs/portal/xtream/feature/src/lib/vod-details/vod-details-route.component.ts +++ b/libs/portal/xtream/feature/src/lib/vod-details/vod-details-route.component.ts @@ -244,7 +244,7 @@ export class VodDetailsRouteComponent implements OnInit, OnDestroy { sources: this.multiSource.sources, routePlaylistId: computed(() => this.xtreamStore.currentPlaylist()?.id), routeContentId: this.selectedVodId, - routePosition: this.playback.vodPlaybackPosition, + routePosition: this.playback.routePlaybackPosition, load: (source) => this.positionFor(source), }); readonly hasPlaybackPosition = this.primaryAction.hasPosition; @@ -394,6 +394,7 @@ export class VodDetailsRouteComponent implements OnInit, OnDestroy { this.playback.startResolvedPlayback(playback); }, movie: this.multiSourceMovie, + playbackLive: this.playbackLive, }); // Initializes on first render and RE-initializes when the route diff --git a/libs/portal/xtream/feature/src/lib/vod-details/vod-multi-source-counts.ts b/libs/portal/xtream/feature/src/lib/vod-details/vod-multi-source-counts.ts new file mode 100644 index 000000000..c95ae76ed --- /dev/null +++ b/libs/portal/xtream/feature/src/lib/vod-details/vod-multi-source-counts.ts @@ -0,0 +1,36 @@ +import { Signal, computed } from '@angular/core'; +import type { VodSourceDescriptor } from '@iptvnator/shared/interfaces'; + +/** + * What the chip and the caption count. + * + * Kept apart because the two numbers differ and the distinction is easy to + * lose: the chip counts STREAMS, while the caption's "also found in N others" + * counts PLAYLISTS — the popover groups a playlist's copies under it, so three + * copies inside one portal must not read as three portals. + */ +export interface VodSourceCounts { + /** Every source except the one in use. */ + alternatives: Signal; + /** The chip appears only when there is genuinely somewhere else to go. */ + hasAlternatives: Signal; + alternativeCount: Signal; + alternativePlaylistCount: Signal; +} + +export function createVodSourceCounts( + sources: Signal +): VodSourceCounts { + const alternatives = computed(() => + sources().filter((source) => !source.isActive) + ); + + return { + alternatives, + hasAlternatives: computed(() => alternatives().length > 0), + alternativeCount: computed(() => alternatives().length), + alternativePlaylistCount: computed( + () => new Set(alternatives().map((s) => s.playlistId)).size + ), + }; +} diff --git a/libs/portal/xtream/feature/src/lib/vod-details/vod-multi-source-host-pin-writes.spec.ts b/libs/portal/xtream/feature/src/lib/vod-details/vod-multi-source-host-pin-writes.spec.ts index 619d54e71..9ed7f3b49 100644 --- a/libs/portal/xtream/feature/src/lib/vod-details/vod-multi-source-host-pin-writes.spec.ts +++ b/libs/portal/xtream/feature/src/lib/vod-details/vod-multi-source-host-pin-writes.spec.ts @@ -34,6 +34,10 @@ describe('VodMultiSourceHostService — pin persistence', () => { let service: VodMultiSourceHostService; const movie = signal(null); + + // Whatever is on screen; the pin path distinguishes it from selection. + + const playbackLive = signal(false); const vodAutoFailover = signal(false); const startPlayback = jest.fn(); const discovery = { isAvailable: true, discover: jest.fn() }; @@ -84,7 +88,7 @@ describe('VodMultiSourceHostService — pin persistence', () => { service = TestBed.inject(VodMultiSourceHostService); TestBed.runInInjectionContext(() => - service.bind({ startPlayback, movie }) + service.bind({ startPlayback, movie, playbackLive }) ); }); diff --git a/libs/portal/xtream/feature/src/lib/vod-details/vod-multi-source-host-pin.spec.ts b/libs/portal/xtream/feature/src/lib/vod-details/vod-multi-source-host-pin.spec.ts index 25849947d..cb26145a2 100644 --- a/libs/portal/xtream/feature/src/lib/vod-details/vod-multi-source-host-pin.spec.ts +++ b/libs/portal/xtream/feature/src/lib/vod-details/vod-multi-source-host-pin.spec.ts @@ -34,6 +34,10 @@ describe('VodMultiSourceHostService — pinning', () => { let service: VodMultiSourceHostService; const movie = signal(null); + + // Whatever is on screen; the pin path distinguishes it from selection. + + const playbackLive = signal(false); const vodAutoFailover = signal(false); const startPlayback = jest.fn(); const discovery = { isAvailable: true, discover: jest.fn() }; @@ -84,7 +88,7 @@ describe('VodMultiSourceHostService — pinning', () => { service = TestBed.inject(VodMultiSourceHostService); TestBed.runInInjectionContext(() => - service.bind({ startPlayback, movie }) + service.bind({ startPlayback, movie, playbackLive }) ); }); @@ -186,6 +190,28 @@ describe('VodMultiSourceHostService — pinning', () => { ); }); + it('honours the pin again after its player is closed', async () => { + pins.get.mockResolvedValue({ + matchKey: 'title:the matrix:1999', + playlistId: ALT_TWO.playlistId, + contentId: ALT_TWO.contentId, + portalType: 'xtream', + }); + await loadMovie([ALT_TWO]); + await service.play(ALT_TWO.id); + + // Playing it makes its row active AND live: Play must not relaunch + // what is already on screen. + playbackLive.set(true); + expect(service.pendingPinnedSourceId()).toBeNull(); + + // Closing the player leaves the row selected. Reading `isActive` + // alone here sends the next Play to the route copy and ignores the + // stored preference until the page is reopened. + playbackLive.set(false); + expect(service.pendingPinnedSourceId()).toBe(ALT_TWO.id); + }); + it('starts a pinned source that was never watched from the beginning', async () => { pins.get.mockResolvedValue({ matchKey: 'title:the matrix:1999', diff --git a/libs/portal/xtream/feature/src/lib/vod-details/vod-multi-source-host-races.spec.ts b/libs/portal/xtream/feature/src/lib/vod-details/vod-multi-source-host-races.spec.ts index b08acfaad..fbbccac95 100644 --- a/libs/portal/xtream/feature/src/lib/vod-details/vod-multi-source-host-races.spec.ts +++ b/libs/portal/xtream/feature/src/lib/vod-details/vod-multi-source-host-races.spec.ts @@ -28,6 +28,10 @@ describe('VodMultiSourceHostService — stale resolutions', () => { let service: VodMultiSourceHostService; const movie = signal(null); + + // Whatever is on screen; the pin path distinguishes it from selection. + + const playbackLive = signal(false); const vodAutoFailover = signal(false); const startPlayback = jest.fn(); const discovery = { isAvailable: true, discover: jest.fn() }; @@ -82,7 +86,7 @@ describe('VodMultiSourceHostService — stale resolutions', () => { service = TestBed.inject(VodMultiSourceHostService); TestBed.runInInjectionContext(() => - service.bind({ startPlayback, movie }) + service.bind({ startPlayback, movie, playbackLive }) ); }); diff --git a/libs/portal/xtream/feature/src/lib/vod-details/vod-multi-source-host-session.spec.ts b/libs/portal/xtream/feature/src/lib/vod-details/vod-multi-source-host-session.spec.ts index 79c6551e5..482a3e7dc 100644 --- a/libs/portal/xtream/feature/src/lib/vod-details/vod-multi-source-host-session.spec.ts +++ b/libs/portal/xtream/feature/src/lib/vod-details/vod-multi-source-host-session.spec.ts @@ -36,6 +36,10 @@ describe('VodMultiSourceHostService — session lifecycle', () => { let service: VodMultiSourceHostService; const movie = signal(null); + + // Whatever is on screen; the pin path distinguishes it from selection. + + const playbackLive = signal(false); const vodAutoFailover = signal(false); const startPlayback = jest.fn(); const discovery = { isAvailable: true, discover: jest.fn() }; @@ -94,7 +98,7 @@ describe('VodMultiSourceHostService — session lifecycle', () => { service = TestBed.inject(VodMultiSourceHostService); TestBed.runInInjectionContext(() => - service.bind({ startPlayback, movie }) + service.bind({ startPlayback, movie, playbackLive }) ); }); diff --git a/libs/portal/xtream/feature/src/lib/vod-details/vod-multi-source-host.service.spec.ts b/libs/portal/xtream/feature/src/lib/vod-details/vod-multi-source-host.service.spec.ts index 9bf0e0096..365d5db70 100644 --- a/libs/portal/xtream/feature/src/lib/vod-details/vod-multi-source-host.service.spec.ts +++ b/libs/portal/xtream/feature/src/lib/vod-details/vod-multi-source-host.service.spec.ts @@ -32,6 +32,10 @@ describe('VodMultiSourceHostService', () => { let service: VodMultiSourceHostService; const movie = signal(null); + + // Whatever is on screen; the pin path distinguishes it from selection. + + const playbackLive = signal(false); const vodAutoFailover = signal(false); const startPlayback = jest.fn(); const discovery = { isAvailable: true, discover: jest.fn() }; @@ -104,7 +108,7 @@ describe('VodMultiSourceHostService', () => { service = TestBed.inject(VodMultiSourceHostService); TestBed.runInInjectionContext(() => - service.bind({ startPlayback, movie }) + service.bind({ startPlayback, movie, playbackLive }) ); }); diff --git a/libs/portal/xtream/feature/src/lib/vod-details/vod-multi-source-host.service.ts b/libs/portal/xtream/feature/src/lib/vod-details/vod-multi-source-host.service.ts index c52557e79..89c01c09b 100644 --- a/libs/portal/xtream/feature/src/lib/vod-details/vod-multi-source-host.service.ts +++ b/libs/portal/xtream/feature/src/lib/vod-details/vod-multi-source-host.service.ts @@ -28,6 +28,7 @@ import { type SwitchOutcome, } from './vod-multi-source-session'; import type { VodMultiSourceSwitchNotice } from './vod-multi-source-notice'; +import { createVodSourceCounts } from './vod-multi-source-counts'; import { currentSourceRow } from './vod-multi-source-current-row'; import { probeSource } from './vod-multi-source-probe'; import { @@ -59,6 +60,11 @@ export interface VodMultiSourceBindings { startPlayback: (playback: ResolvedPortalPlayback) => void; /** The movie on screen, or null while its identity is not yet knowable. */ movie: Signal; + /** + * Whether a stream is on screen right now — NOT whether a row is selected. + * A pinned row stays selected after its player is closed. + */ + playbackLive: Signal; } export type { VodMultiSourceSwitchNotice }; @@ -107,20 +113,11 @@ export class VodMultiSourceHostService { readonly lastSwitch = this._lastSwitch.asReadonly(); readonly previousSourceId = this._previousSourceId.asReadonly(); - readonly alternatives = computed(() => - this._sources().filter((source) => !source.isActive) - ); - /** The chip appears only when there is genuinely somewhere else to go. */ - readonly hasAlternatives = computed(() => this.alternatives().length > 0); - /** Alternative STREAMS — what the "Sources N" chip counts. */ - readonly alternativeCount = computed(() => this.alternatives().length); - /** - * Alternative PLAYLISTS: the popover groups a playlist's copies under it, - * so "also found in N other playlists" counts portals, not copies. - */ - readonly alternativePlaylistCount = computed( - () => new Set(this.alternatives().map((s) => s.playlistId)).size - ); + private readonly counts = createVodSourceCounts(this._sources); + readonly alternatives = this.counts.alternatives; + readonly hasAlternatives = this.counts.hasAlternatives; + readonly alternativeCount = this.counts.alternativeCount; + readonly alternativePlaylistCount = this.counts.alternativePlaylistCount; readonly matchKind = computed(() => this.controller.matchKind()); /** Opt-in and off by default — a silent switch is never acceptable. */ @@ -232,7 +229,10 @@ export class VodMultiSourceHostService { /** The pinned source, when it is not the one the route already plays. */ readonly pendingPinnedSourceId = computed(() => - pinnedSourceAwaitingPlay(this._sources()) + pinnedSourceAwaitingPlay( + this._sources(), + this.bindings?.playbackLive() === true + ) ); /** diff --git a/libs/portal/xtream/feature/src/lib/vod-details/vod-multi-source-pin.ts b/libs/portal/xtream/feature/src/lib/vod-details/vod-multi-source-pin.ts index 2c7208633..c5e1a69d0 100644 --- a/libs/portal/xtream/feature/src/lib/vod-details/vod-multi-source-pin.ts +++ b/libs/portal/xtream/feature/src/lib/vod-details/vod-multi-source-pin.ts @@ -37,12 +37,22 @@ export function pinnedCopyInPlaylist( * "Make this the main source" has to survive reopening the movie, or the * persisted preference is just an icon. The host consults this before its * normal Play, so the pin decides where playback starts. + * + * `isActive` alone cannot answer this: it means SELECTED, and the pinned row + * stays selected after its player is closed. Skipping the pin then would send + * the next Play to the route copy and quietly ignore the stored preference + * until the page is reopened. */ export function pinnedSourceAwaitingPlay( - sources: readonly VodSourceDescriptor[] + sources: readonly VodSourceDescriptor[], + playbackLive: boolean ): string | null { const pinned = sources.find((source) => source.isPinned); - return pinned && !pinned.isActive ? pinned.id : null; + if (!pinned) { + return null; + } + + return pinned.isActive && playbackLive ? null : pinned.id; } /** The row id the controller uses, derived from a stored pin. */