diff --git a/docs/architecture/vod-multi-source.md b/docs/architecture/vod-multi-source.md index b002e452e..84c8ab86b 100644 --- a/docs/architecture/vod-multi-source.md +++ b/docs/architecture/vod-multi-source.md @@ -423,6 +423,15 @@ first save would write that timecode under the pinned source's key. When no lookup function is supplied at all, nothing is applied: "never watched" was never established, so there is nothing to correct. +## What the primary button describes + +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 +lookup comes back empty, because "never watched" is an answer: the button must +read Play, not `Resume 42:18` on a stream that starts at zero. A pin on the +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 @@ -433,7 +442,9 @@ its own, back a statement in the present tense. `timeupdate`: `inlinePlayback()` is only the REQUEST to play, non-null while the engine is still opening the stream and still non-null after it fails, while a timeupdate is the engine reporting frames. External, it needs the session -past `launching`. +past `launching`. Every path that mounts a stream clears the latch first — +Play, Restart, and a source switch, which puts a DIFFERENT stream into the +same host and so cannot inherit the old one's evidence. Two things read it, and they must agree: the "Playing from" caption, and the source row's badge — which reads `Current` when a source is merely selected and 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 4c4df91b8..51e25385c 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 @@ -26,6 +26,7 @@ import { getXtreamVodInfo, } from '@iptvnator/shared/interfaces'; import type { PlaybackFallbackRequest } from '@iptvnator/ui/playback'; +import { formatPlaybackPosition } from './vod-primary-action-position'; export interface VodDetailsPlaybackBindings { /** Current vod id resolved from the route */ @@ -239,13 +240,7 @@ export class VodDetailsPlaybackService { } formatPosition(): string { - const position = this.vodPlaybackPosition(); - if (!position) return ''; - - const date = new Date(0); - date.setSeconds(position.positionSeconds); - const timeString = date.toISOString().substr(11, 8); - return timeString.startsWith('00:') ? timeString.substr(3) : timeString; + return formatPlaybackPosition(this.vodPlaybackPosition()); } closeInlinePlayer(): void { diff --git a/libs/portal/xtream/feature/src/lib/vod-details/vod-details-route-caption.spec.ts b/libs/portal/xtream/feature/src/lib/vod-details/vod-details-route-caption.spec.ts index 786dc03d0..95e4c889e 100644 --- a/libs/portal/xtream/feature/src/lib/vod-details/vod-details-route-caption.spec.ts +++ b/libs/portal/xtream/feature/src/lib/vod-details/vod-details-route-caption.spec.ts @@ -1,4 +1,5 @@ import { ComponentFixture, TestBed } from '@angular/core/testing'; +import type { ResolvedPortalPlayback } from '@iptvnator/shared/interfaces'; import { VodDetailsPlaybackService } from './vod-details-playback.service'; import { VodDetailsRouteComponent } from './vod-details-route.component'; import { @@ -137,6 +138,48 @@ describe('VodDetailsRouteComponent — source caption', () => { expect(component.activeSourceCaption()).toBeNull(); }); + it('waits again after a manual source switch', () => { + currentPlaylist.set({ id: 'playlist-1' }); + const component = fixture.componentInstance; + const playback = fixture.debugElement.injector.get( + VodDetailsPlaybackService + ); + stubs.isEmbeddedPlayer.mockReturnValue(true); + withActiveSource('playlist-1', 650020); + playback.inlinePlayback.set({ + streamUrl: 'http://example.com/movie.mkv', + title: 'Example', + }); + component.handleInlineTimeUpdate({ currentTime: 3, duration: 90 }); + expect(component.activeSourceCaption()).not.toBeNull(); + + // A switch mounts a DIFFERENT stream in the same host and marks the + // new source active at once, so the old stream's timeupdate must not + // vouch for it — it may still be opening, or never start at all. + // The exact callback `switchToSource` invokes once it has resolved a + // new source. Standing up the resolver here would test the host, not + // the route's half of the seam. + ( + component.multiSource as unknown as { + bindings: { + startPlayback: (playback: ResolvedPortalPlayback) => void; + }; + } + ).bindings.startPlayback({ + streamUrl: 'http://example.com/alt.mkv', + title: 'Example', + startTime: 3, + }); + + expect(playback.inlinePlayback()?.streamUrl).toBe( + 'http://example.com/alt.mkv' + ); + expect(component.activeSourceCaption()).toBeNull(); + + component.handleInlineTimeUpdate({ currentTime: 4, duration: 90 }); + expect(component.activeSourceCaption()).not.toBeNull(); + }); + it('says nothing while an external player is still launching', () => { currentPlaylist.set({ id: 'playlist-1' }); const component = fixture.componentInstance; 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 37e8910b3..be2ed8b8b 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 @@ -49,7 +49,9 @@ import { XtreamVodInfo, XtreamVodStream, youtubeEmbedUrl, + type PlaybackPositionData, type VodSourceCandidate, + type VodSourceDescriptor, } from '@iptvnator/shared/interfaces'; import { SimilarCatalogItem, @@ -62,6 +64,10 @@ import { import { VodDetailsPlaybackService } from './vod-details-playback.service'; import { VodMultiSourceHostService } from './vod-multi-source-host.service'; import { resolveVodMultiSourceMovie } from './vod-multi-source-identity'; +import { + createPrimaryActionPosition, + formatPlaybackPosition, +} from './vod-primary-action-position'; @Component({ templateUrl: './vod-details-route.component.html', @@ -229,7 +235,19 @@ export class VodDetailsRouteComponent implements OnInit, OnDestroy { readonly externalPrimaryButtonState = this.playback.externalPrimaryButtonState; readonly vodPlaybackProgress = this.playback.vodPlaybackProgress; - readonly hasPlaybackPosition = this.playback.hasPlaybackPosition; + + /** + * What the primary button acts on — the PINNED copy's position when a pin + * names another copy, since that is the stream the button will play. + */ + private readonly primaryAction = createPrimaryActionPosition({ + sources: this.multiSource.sources, + routePlaylistId: computed(() => this.xtreamStore.currentPlaylist()?.id), + routeContentId: this.selectedVodId, + routePosition: this.playback.vodPlaybackPosition, + load: (source) => this.positionFor(source), + }); + readonly hasPlaybackPosition = this.primaryAction.hasPosition; readonly isDownloaded = computed(() => { const vodId = this.selectedVodId(); @@ -367,8 +385,14 @@ export class VodDetailsRouteComponent implements OnInit, OnDestroy { this.multiSource.bind({ // Route every switch through the same inline-vs-external fork a // normal Play uses, so the two paths cannot drift apart. - startPlayback: (playback) => - this.playback.startResolvedPlayback(playback), + startPlayback: (playback) => { + // A switch mounts a DIFFERENT stream in the same host, so the + // evidence that the previous one was playing says nothing + // about this one — without clearing it the caption and the + // badge would claim the new source while it is still opening. + this.inlineTimeSeen.set(false); + this.playback.startResolvedPlayback(playback); + }, movie: this.multiSourceMovie, }); @@ -500,23 +524,26 @@ export class VodDetailsRouteComponent implements OnInit, OnDestroy { * Positions are keyed by (playlist, stream), so a pinned alternative has * its own row — the one this page loaded belongs to the route's copy. */ - private readonly resumeSecondsFor = async ( - source: VodSourceCandidate - ): Promise => { - const position = await this.playbackPositions.getPlaybackPosition( + private readonly positionFor = ( + source: VodSourceCandidate | VodSourceDescriptor + ): Promise => + this.playbackPositions.getPlaybackPosition( source.playlistId, source.contentId, 'vod' ); - return position?.positionSeconds ?? null; - }; + + private readonly resumeSecondsFor = async ( + source: VodSourceCandidate + ): Promise => + (await this.positionFor(source))?.positionSeconds ?? null; stopExternalPlayback(): Promise { return this.playback.stopExternalPlayback(); } formatPosition(): string { - return this.playback.formatPosition(); + return formatPlaybackPosition(this.primaryAction.position()); } toggleFavorite(): void { diff --git a/libs/portal/xtream/feature/src/lib/vod-details/vod-primary-action-position.spec.ts b/libs/portal/xtream/feature/src/lib/vod-details/vod-primary-action-position.spec.ts new file mode 100644 index 000000000..7319050bc --- /dev/null +++ b/libs/portal/xtream/feature/src/lib/vod-details/vod-primary-action-position.spec.ts @@ -0,0 +1,188 @@ +import { signal } from '@angular/core'; +import { TestBed } from '@angular/core/testing'; +import type { + PlaybackPositionData, + VodSourceDescriptor, +} from '@iptvnator/shared/interfaces'; +import { + createPrimaryActionPosition, + formatPlaybackPosition, +} from './vod-primary-action-position'; + +/** + * Which position the primary button describes. + * + * The page loads the ROUTE copy's row, but a pin means the button plays a + * different copy — with its own row, or none at all. Reading the wrong one + * makes the button lie about where it will start. + */ + +const ROUTE_PLAYLIST = 'playlist-1'; +const ROUTE_CONTENT = 650020; + +function source( + overrides: Partial = {} +): VodSourceDescriptor { + return { + id: `${ROUTE_PLAYLIST}:xtream:${ROUTE_CONTENT}`, + playlistId: ROUTE_PLAYLIST, + playlistName: 'Portal One', + portalType: 'xtream', + contentId: ROUTE_CONTENT, + rawTitle: 'Example', + matchConfidence: 'exact', + year: null, + isActive: true, + isPinned: false, + isTried: false, + probe: { status: 'idle' }, + ...overrides, + } as VodSourceDescriptor; +} + +function position(seconds: number): PlaybackPositionData { + return { + playlistId: 'any', + contentXtreamId: 1, + contentType: 'vod', + positionSeconds: seconds, + durationSeconds: 7200, + }; +} + +const ALT = source({ + id: 'playlist-2:xtream:991', + playlistId: 'playlist-2', + contentId: 991, + isPinned: true, + isActive: false, +}); + +function setup( + sources: VodSourceDescriptor[], + routePosition: PlaybackPositionData | null, + load: (source: VodSourceDescriptor) => Promise +) { + const sourcesSignal = signal(sources); + const api = TestBed.runInInjectionContext(() => + createPrimaryActionPosition({ + sources: sourcesSignal, + routePlaylistId: signal(ROUTE_PLAYLIST), + routeContentId: signal(ROUTE_CONTENT), + routePosition: signal(routePosition), + load, + }) + ); + return { api, sourcesSignal }; +} + +describe('createPrimaryActionPosition', () => { + it('describes the route copy when nothing is pinned', () => { + const load = jest.fn(); + const { api } = setup([source()], position(2538), load); + TestBed.tick(); + + expect(api.hasPosition()).toBe(true); + expect(api.position()?.positionSeconds).toBe(2538); + expect(load).not.toHaveBeenCalled(); + }); + + it('describes the pinned copy once its row is loaded', async () => { + const { api } = setup([source(), ALT], position(2538), async () => + position(4200) + ); + TestBed.tick(); + await Promise.resolve(); + TestBed.tick(); + + // Otherwise the button offers to resume at the ROUTE copy's timecode + // and then starts the pinned copy somewhere else entirely. + expect(api.position()?.positionSeconds).toBe(4200); + }); + + it('reads Play for a pinned copy that was never watched', async () => { + const { api } = setup( + [source(), ALT], + position(2538), + async () => null + ); + TestBed.tick(); + await Promise.resolve(); + TestBed.tick(); + + // "Never watched" is an answer, not an absence: falling back to the + // route copy here would show "Resume 42:18" on a copy that starts at 0. + expect(api.position()).toBeNull(); + expect(api.hasPosition()).toBe(false); + }); + + it('ignores a pin on the route’s own copy', () => { + const load = jest.fn(); + const { api } = setup( + [source({ isPinned: true })], + position(2538), + load + ); + TestBed.tick(); + + // Same row: the position the page already loaded IS this copy's. + expect(api.position()?.positionSeconds).toBe(2538); + expect(load).not.toHaveBeenCalled(); + }); + + it('drops a lookup the pin outran', async () => { + let resolveFirst: (value: PlaybackPositionData | null) => void = () => { + /* replaced below */ + }; + const load = jest + .fn() + .mockImplementationOnce( + () => + new Promise((resolve) => { + resolveFirst = resolve; + }) + ) + .mockResolvedValueOnce(position(60)); + + const { api, sourcesSignal } = setup( + [source(), ALT], + position(2538), + load + ); + TestBed.tick(); + + const other = source({ + id: 'playlist-3:xtream:77', + playlistId: 'playlist-3', + contentId: 77, + isPinned: true, + isActive: false, + }); + sourcesSignal.set([source(), other]); + TestBed.tick(); + await Promise.resolve(); + TestBed.tick(); + + // The first lookup lands late, describing a copy the button no longer + // plays. + resolveFirst(position(4200)); + await Promise.resolve(); + TestBed.tick(); + + expect(api.position()?.positionSeconds).toBe(60); + }); +}); + +describe('formatPlaybackPosition', () => { + it('drops the hour when there is none', () => { + expect(formatPlaybackPosition(position(123))).toBe('02:03'); + }); + + it('keeps the hour when there is one', () => { + expect(formatPlaybackPosition(position(3723))).toBe('01:02:03'); + }); + + it('is empty without a position', () => { + expect(formatPlaybackPosition(null)).toBe(''); + }); +}); diff --git a/libs/portal/xtream/feature/src/lib/vod-details/vod-primary-action-position.ts b/libs/portal/xtream/feature/src/lib/vod-details/vod-primary-action-position.ts new file mode 100644 index 000000000..ed212b65b --- /dev/null +++ b/libs/portal/xtream/feature/src/lib/vod-details/vod-primary-action-position.ts @@ -0,0 +1,109 @@ +import { Signal, computed, effect, signal } from '@angular/core'; +import { getPortalPlaybackProgressPercent } from '@iptvnator/portal/shared/util'; +import type { + PlaybackPositionData, + VodSourceDescriptor, +} from '@iptvnator/shared/interfaces'; + +/** + * The position the PRIMARY button acts on. + * + * The page loads the route copy's position, but a pin means the button plays + * a different copy — and positions are keyed by (playlist, stream), so that + * copy has its own row. Reading the route's would let the button say + * "Resume 42:00" and then start an unwatched copy from zero, or say "Play" + * and jump into the middle of one that was already watched. + * + * When a pin names another copy its row governs, INCLUDING when there is no + * row: never watched means Play, not Resume. + */ + +export interface PrimaryActionPositionDeps { + /** Discovered sources; the pinned one, if any, carries `isPinned`. */ + sources: Signal; + /** The route's own (playlist, stream), for telling "another copy" apart. */ + routePlaylistId: Signal; + routeContentId: Signal; + /** The route copy's position, as loaded by the page. */ + routePosition: Signal; + load: (source: VodSourceDescriptor) => Promise; +} + +export interface PrimaryActionPosition { + position: Signal; + /** Whether the button should read Resume rather than Play. */ + hasPosition: Signal; +} + +export function createPrimaryActionPosition( + deps: PrimaryActionPositionDeps +): PrimaryActionPosition { + /** The pinned copy, only while it is NOT the route's own row. */ + const foreignPin = computed(() => { + const pinned = deps.sources().find((source) => source.isPinned); + if ( + !pinned || + (pinned.playlistId === deps.routePlaylistId() && + pinned.contentId === deps.routeContentId()) + ) { + return null; + } + + return pinned; + }); + + const pinnedPosition = signal(null); + /** Distinguishes "not looked up yet" from "looked up, never watched". */ + const pinnedLoadedFor = signal(null); + + effect(() => { + const pinned = foreignPin(); + if (!pinned) { + pinnedPosition.set(null); + pinnedLoadedFor.set(null); + return; + } + + if (pinnedLoadedFor() === pinned.id) { + return; + } + + void deps.load(pinned).then((position) => { + // The pin can change across the lookup — applying a stale answer + // would describe a copy the button no longer plays. + if (foreignPin()?.id !== pinned.id) { + return; + } + + pinnedPosition.set(position); + pinnedLoadedFor.set(pinned.id); + }); + }); + + const position = computed(() => + foreignPin() && pinnedLoadedFor() + ? pinnedPosition() + : deps.routePosition() + ); + + const hasPosition = computed(() => { + const progress = getPortalPlaybackProgressPercent(position()); + return progress > 0 && progress < 90; + }); + + return { position, hasPosition }; +} + +/** `01:02:03`, or `02:03` for anything under an hour. */ +export function formatPlaybackPosition( + position: PlaybackPositionData | null +): string { + if (!position) { + return ''; + } + + const date = new Date(0); + date.setSeconds(position.positionSeconds); + const timeString = date.toISOString().substring(11, 19); + return timeString.startsWith('00:') ? timeString.substring(3) : timeString; +}