fix(portals): describe the copy the primary button will actually play

Two gaps found by review.

A pin makes the primary button play a copy the page never loaded a position
for — positions are keyed by (playlist, stream). The label, timecode and
Restart affordance still came from the route copy's row, so the button could
read "Resume 42:18" and start an unwatched copy at zero, or read "Play" and
jump into the middle of one already watched. `createPrimaryActionPosition`
lets the pinned copy's row govern, including when that row is absent: never
watched is an answer, not a fallback to someone else's progress.

A manual source switch also mounts a DIFFERENT stream in the same host while
marking the new source active at once, so the previous stream's timeupdate was
still vouching for it — the caption and the badge claimed the new source while
it was still opening. That path now clears the latch like Play and Restart do.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
4grayandClaude Opus 5 committed 2026-07-28 10:58:05 +02:00
1 parent 2d8b6857ce
commit b6e3a6752e
6 files changed
+391 -18

No files matched your search

+12 -1
View File
@@ -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
@@ -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 {
@@ -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;
@@ -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<number | null> => {
const position = await this.playbackPositions.getPlaybackPosition(
private readonly positionFor = (
source: VodSourceCandidate | VodSourceDescriptor
): Promise<PlaybackPositionData | null> =>
this.playbackPositions.getPlaybackPosition(
source.playlistId,
source.contentId,
'vod'
);
return position?.positionSeconds ?? null;
};
private readonly resumeSecondsFor = async (
source: VodSourceCandidate
): Promise<number | null> =>
(await this.positionFor(source))?.positionSeconds ?? null;
stopExternalPlayback(): Promise<void> {
return this.playback.stopExternalPlayback();
}
formatPosition(): string {
return this.playback.formatPosition();
return formatPlaybackPosition(this.primaryAction.position());
}
toggleFavorite(): void {
@@ -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> = {}
): 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<PlaybackPositionData | null>
) {
const sourcesSignal = signal(sources);
const api = TestBed.runInInjectionContext(() =>
createPrimaryActionPosition({
sources: sourcesSignal,
routePlaylistId: signal<string | undefined>(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<PlaybackPositionData | null>((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('');
});
});
@@ -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<VodSourceDescriptor[]>;
/** The route's own (playlist, stream), for telling "another copy" apart. */
routePlaylistId: Signal<string | undefined>;
routeContentId: Signal<number>;
/** The route copy's position, as loaded by the page. */
routePosition: Signal<PlaybackPositionData | null>;
load: (source: VodSourceDescriptor) => Promise<PlaybackPositionData | null>;
}
export interface PrimaryActionPosition {
position: Signal<PlaybackPositionData | null>;
/** Whether the button should read Resume rather than Play. */
hasPosition: Signal<boolean>;
}
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<PlaybackPositionData | null>(null);
/** Distinguishes "not looked up yet" from "looked up, never watched". */
const pinnedLoadedFor = signal<string | null>(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;
}