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. */