fix(portals): restart honours the pin, and a switch replaces the player

Three from review, all in the pinned-playback seam.

A pinned copy watched through resolved to its stored seconds, so the button
read Play — the label uses the in-progress rule — and then started near the
end. Both now go through one `isResumablePosition`, so the label and the start
point cannot disagree.

Restart sat beside a Resume that honours a foreign pin, but called `playVod`
and started the ROUTE copy — silently switching the user's playlist. It now
restarts whatever the primary button acts on, falling back to the route source
only when there is no usable pin.

Switching sources left a running external player alone. With MPV or VLC and
instance reuse off the backend spawns a second detached process, so both
sources kept playing and Stop owned only the newer one.

Also merges master, and puts the five host specs on a shared harness — they
each carried the same 31-line TestBed, which is what pushed two of them over
the file-size cap as cases were added.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
4grayandClaude Opus 5 committed 2026-07-29 00:26:53 +02:00
1 parent 26b820cc29
commit e1c150e7bb
12 files changed
+399 -135

No files matched your search

@@ -17,6 +17,7 @@ import { VodMultiSourceHostService } from './vod-multi-source-host.service';
import {
createPrimaryActionPosition,
formatPlaybackPosition,
isResumablePosition,
} from './vod-primary-action-position';
/**
@@ -145,10 +146,24 @@ export class VodDetailsMultiSourceUiService {
'vod'
);
/**
* The seconds a pinned play should resume from — `null` once the copy has
* been watched through, so a row the button offers as Play does not seek
* back to where it ended.
*/
readonly resumeSecondsFor = async (
source: VodSourceCandidate
): Promise<number | null> =>
(await this.positionFor(source))?.positionSeconds ?? null;
): Promise<number | null> => {
const position = await this.positionFor(source);
return isResumablePosition(position)
? (position?.positionSeconds ?? null)
: null;
};
/** True when the primary button acts on a pinned copy, not the route's. */
readonly primaryIsPinnedCopy = computed(
() => this.primaryAction.foreignPin() !== null
);
/** Title shown in the sources popover header. */
readonly multiSourceTitle = computed(() => this.movieTitle());
@@ -29,6 +29,7 @@ describe('VodDetailsPlaybackService — external session ownership', () => {
let positionListener: ((data: PlaybackPositionData) => void) | undefined;
const addRecentItem = jest.fn();
const activeSession = signal<unknown>(null);
const closeSession = jest.fn().mockResolvedValue(undefined);
const activeSource = signal<PlayerContentInfo | null>(null);
function sessionFor(playlistId: string, contentXtreamId: number) {
@@ -61,7 +62,7 @@ describe('VodDetailsPlaybackService — external session ownership', () => {
},
{
provide: PORTAL_EXTERNAL_PLAYBACK,
useValue: { activeSession, closeSession: jest.fn() },
useValue: { activeSession, closeSession },
},
{
provide: PORTAL_PLAYBACK_POSITIONS,
@@ -141,6 +142,25 @@ describe('VodDetailsPlaybackService — external session ownership', () => {
expect(service.matchedExternalPlayback()).toBeNull();
});
it('stops the running external player before switching sources', async () => {
activeSession.set(sessionFor(ROUTE_PLAYLIST, ROUTE_VOD_ID));
await service.startResolvedPlayback({
streamUrl: 'https://example.com/alt.mkv',
title: 'Example Movie',
contentInfo: {
playlistId: 'playlist-2',
contentXtreamId: 991,
contentType: 'vod',
},
});
// A switch REPLACES what is playing. With MPV/VLC and instance reuse
// off the backend spawns a second detached player otherwise: both
// sources keep running, and Stop owns only the newer one.
expect(closeSession).toHaveBeenCalled();
});
it('records a source started through multi-source as recently viewed', () => {
// Playing an alternative from the picker, or letting a pin decide the
// primary Play, is still watching the movie — it belongs in Recently
@@ -342,7 +342,17 @@ export class VodDetailsPlaybackService {
* template already renders through `@if`, so the player component and its
* engine survive and simply re-seek to `playback.startTime`.
*/
startResolvedPlayback(playback: ResolvedPortalPlayback): void {
async startResolvedPlayback(
playback: ResolvedPortalPlayback
): Promise<void> {
// A switch REPLACES what is playing. With MPV or VLC and instance
// reuse off, the backend spawns a second detached player otherwise —
// both sources keep running and Stop owns only the newer one.
const running = this.matchedExternalPlayback();
if (running) {
await this.externalPlayback.closeSession(running);
}
// Same movie, different source: still a view.
this.addToRecentlyViewed();
this.startPlayback(playback);
@@ -0,0 +1,92 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { VideoPlayer } from '@iptvnator/shared/interfaces';
import { VodDetailsRouteComponent } from './vod-details-route.component';
import {
configureVodDetailsRouteTestBed,
createVodDetailsRouteStubs,
resetVodDetailsRouteStubs,
silenceRouteLogging,
} from './vod-details-route.harness';
/**
* Who gets offered auto-failover, and what happens when the preference cannot
* be stored. Split from the playback-actions spec to keep both inside the
* repository's file-size rule.
*/
describe('VodDetailsRouteComponent — auto-failover', () => {
let fixture: ComponentFixture<VodDetailsRouteComponent>;
let restoreLogging: (() => void) | undefined;
const stubs = createVodDetailsRouteStubs();
const { selectedPlayer, snackBarOpen, updateSettings } = stubs;
beforeEach(async () => {
restoreLogging = silenceRouteLogging();
resetVodDetailsRouteStubs(stubs);
await configureVodDetailsRouteTestBed(stubs);
fixture = TestBed.createComponent(VodDetailsRouteComponent);
});
afterEach(() => {
restoreLogging?.();
});
beforeEach(() => {
selectedPlayer.set(VideoPlayer.Html5Player);
updateSettings.mockReset().mockResolvedValue(undefined);
snackBarOpen.mockClear();
});
it.each([VideoPlayer.MPV, VideoPlayer.VLC, VideoPlayer.EmbeddedMpv])(
'is not offered on %s',
(player) => {
// Those players never raise the playback diagnostic that
// calls onPlaybackFailed(), so the switch could never happen.
selectedPlayer.set(player);
expect(fixture.componentInstance.autoFailoverSupported()).toBe(
false
);
}
);
it.each([
VideoPlayer.Html5Player,
VideoPlayer.VideoJs,
VideoPlayer.ArtPlayer,
])('is offered on %s', (player) => {
selectedPlayer.set(player);
expect(fixture.componentInstance.autoFailoverSupported()).toBe(
true
);
});
it('tells the user when the preference could not be stored', async () => {
// updateSettings patches memory and REJECTS on a failed write, so
// without this the toggle looks saved and silently reverts on the
// next start — and the rejection is unhandled.
updateSettings.mockRejectedValue(new Error('disk full'));
fixture.componentInstance.setAutoFailover(true);
await Promise.resolve();
await Promise.resolve();
expect(snackBarOpen).toHaveBeenCalledWith(
'SETTINGS.SETTINGS_SAVE_FAILED',
'CLOSE',
expect.anything()
);
});
it('stays quiet when the write succeeds', async () => {
fixture.componentInstance.setAutoFailover(true);
await Promise.resolve();
await Promise.resolve();
expect(updateSettings).toHaveBeenCalledWith({
vodAutoFailover: true,
});
expect(snackBarOpen).not.toHaveBeenCalled();
});
});
@@ -291,64 +291,68 @@ describe('VodDetailsRouteComponent — playback actions', () => {
});
});
describe('auto-failover toggle', () => {
beforeEach(() => {
selectedPlayer.set(VideoPlayer.Html5Player);
updateSettings.mockReset().mockResolvedValue(undefined);
snackBarOpen.mockClear();
it('offers no resume point for a pinned copy watched through', async () => {
const component = fixture.componentInstance;
const resumeSecondsFor = component['msUi'].resumeSecondsFor;
getPlaybackPosition.mockResolvedValue({
playlistId: 'playlist-2',
contentXtreamId: 991,
contentType: 'vod',
positionSeconds: 6900,
durationSeconds: 7200,
});
it.each([VideoPlayer.MPV, VideoPlayer.VLC, VideoPlayer.EmbeddedMpv])(
'is not offered on %s',
(player) => {
// Those players never raise the playback diagnostic that
// calls onPlaybackFailed(), so the switch could never happen.
selectedPlayer.set(player);
// The button reads Play at 95%, so the start must mean zero — not a
// seek back to where the film ended.
await expect(
resumeSecondsFor({
playlistId: 'playlist-2',
contentId: 991,
} as never)
).resolves.toBeNull();
});
expect(fixture.componentInstance.autoFailoverSupported()).toBe(
false
);
}
);
it('still resumes a pinned copy left mid-film', async () => {
const component = fixture.componentInstance;
it.each([
VideoPlayer.Html5Player,
VideoPlayer.VideoJs,
VideoPlayer.ArtPlayer,
])('is offered on %s', (player) => {
selectedPlayer.set(player);
expect(fixture.componentInstance.autoFailoverSupported()).toBe(
true
);
getPlaybackPosition.mockResolvedValue({
playlistId: 'playlist-2',
contentXtreamId: 991,
contentType: 'vod',
positionSeconds: 2538,
durationSeconds: 7200,
});
it('tells the user when the preference could not be stored', async () => {
// updateSettings patches memory and REJECTS on a failed write, so
// without this the toggle looks saved and silently reverts on the
// next start — and the rejection is unhandled.
updateSettings.mockRejectedValue(new Error('disk full'));
await expect(
component['msUi'].resumeSecondsFor({
playlistId: 'playlist-2',
contentId: 991,
} as never)
).resolves.toBe(2538);
});
fixture.componentInstance.setAutoFailover(true);
await Promise.resolve();
await Promise.resolve();
expect(snackBarOpen).toHaveBeenCalledWith(
'SETTINGS.SETTINGS_SAVE_FAILED',
'CLOSE',
expect.anything()
);
it('restarts the pinned copy, not the route copy', async () => {
currentPlaylist.set({ id: 'playlist-1' });
const component = fixture.componentInstance;
withActiveSource('playlist-1', 650020);
// Resume honours the pin, so the Restart beside it must too —
// otherwise the button quietly switches the user's playlist.
Object.defineProperty(component['msUi'], 'primaryIsPinnedCopy', {
configurable: true,
value: () => true,
});
const pinnedPlay = jest
.spyOn(component.multiSource, 'playPinnedSource')
.mockResolvedValue('played');
it('stays quiet when the write succeeds', async () => {
fixture.componentInstance.setAutoFailover(true);
await Promise.resolve();
await Promise.resolve();
await component.restartVod({
movie_data: { stream_id: 650020, name: 'Example' },
} as never);
expect(updateSettings).toHaveBeenCalledWith({
vodAutoFailover: true,
});
expect(snackBarOpen).not.toHaveBeenCalled();
});
expect(pinnedPlay).toHaveBeenCalled();
// Restart means zero, whichever copy it starts.
const resumeFor = pinnedPlay.mock.calls[0][0];
await expect(resumeFor?.({} as never)).resolves.toBe(0);
});
});
@@ -158,7 +158,7 @@
@if (!isExternalStopAction()) {
<button
class="play-btn play-btn--restart"
(click)="playVod(item)"
(click)="restartVod(item)"
>
<mat-icon>replay</mat-icon>
{{ 'XTREAM.RESTART' | translate }}
@@ -315,7 +315,7 @@ export class VodDetailsRouteComponent implements OnInit, OnDestroy {
// about this one — without clearing it the caption and the
// badge would claim the new source while it is still opening.
this.msUi.reset();
this.playback.startResolvedPlayback(playback);
void this.playback.startResolvedPlayback(playback);
},
movie: this.multiSourceMovie,
playbackLive: this.playbackLive,
@@ -419,6 +419,26 @@ export class VodDetailsRouteComponent implements OnInit, OnDestroy {
this.playback.playVod(vodItem);
}
/**
* Restart from the beginning — of whatever the primary button acts on.
*
* When a pin points at another copy, Resume honours it, so Restart sitting
* beside it must too; calling `playVod` there would quietly switch the
* user to the route's playlist.
*/
async restartVod(vodItem: XtreamVodDetails | null): Promise<void> {
if (this.msUi.primaryIsPinnedCopy()) {
const outcome = await this.multiSource.playPinnedSource(async () =>
Promise.resolve(0)
);
if (outcome !== 'unavailable') {
return;
}
}
this.playVod(vodItem);
}
resumeVod(vodItem: XtreamVodDetails | null): void {
this.multiSource.markRouteSourceActive();
this.msUi.beginPlayback();
@@ -1,26 +1,10 @@
import { signal } from '@angular/core';
import { TestBed } from '@angular/core/testing';
import {
VodSourceDiscoveryService,
VodSourceResolverService,
} from '@iptvnator/portal/shared/data-access';
import {
SettingsStore,
StreamProbeService,
VodSourcePinService,
} from '@iptvnator/services';
import type { VodSourceCandidate } from '@iptvnator/shared/interfaces';
import { VodMultiSourceHostService } from './vod-multi-source-host.service';
import type { VodMultiSourceMovie } from './vod-multi-source-identity';
import {
ALT_THREE,
ALT_TWO,
CURRENT_A_ID,
MOVIE_A,
PROBE_OK,
createDeferred,
resolveWith,
setupVodMultiSourceHost,
} from './vod-multi-source-host.fixtures';
/**
@@ -31,65 +15,21 @@ import {
* actually holds — under every alias the movie can be looked up by.
*/
describe('VodMultiSourceHostService — pinning', () => {
let service: VodMultiSourceHostService;
const movie = signal<VodMultiSourceMovie | null>(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() };
const resolver = { resolve: jest.fn() };
const pins = { get: jest.fn(), set: jest.fn(), clear: jest.fn() };
const probes = { probe: jest.fn() };
async function loadMovie(
sources: VodSourceCandidate[],
target: VodMultiSourceMovie = MOVIE_A
): Promise<void> {
discovery.discover.mockResolvedValue({
sources,
matchKind: 'title-year',
});
await service.load(target);
}
function rowFor(sourceId: string) {
return service.sources().find((source) => source.id === sourceId);
}
const harness = setupVodMultiSourceHost();
const {
discovery,
pins,
playbackLive,
resolver,
startPlayback,
vodAutoFailover,
} = harness;
const loadMovie = harness.loadMovie;
const rowFor = harness.rowFor;
let service = harness.service;
beforeEach(() => {
jest.resetAllMocks();
movie.set(null);
vodAutoFailover.set(false);
discovery.isAvailable = true;
discovery.discover.mockResolvedValue({
sources: [],
matchKind: 'title-year',
});
resolver.resolve.mockImplementation(resolveWith());
pins.get.mockResolvedValue(null);
pins.set.mockResolvedValue(true);
pins.clear.mockResolvedValue(true);
probes.probe.mockResolvedValue(PROBE_OK);
TestBed.configureTestingModule({
providers: [
VodMultiSourceHostService,
{ provide: VodSourceDiscoveryService, useValue: discovery },
{ provide: VodSourceResolverService, useValue: resolver },
{ provide: VodSourcePinService, useValue: pins },
{ provide: StreamProbeService, useValue: probes },
{ provide: SettingsStore, useValue: { vodAutoFailover } },
],
});
service = TestBed.inject(VodMultiSourceHostService);
TestBed.runInInjectionContext(() =>
service.bind({ startPlayback, movie, playbackLive })
);
service = harness.reset();
});
it('plays the pinned source instead of the route playlist', async () => {
@@ -214,6 +154,29 @@ describe('VodMultiSourceHostService — pinning', () => {
expect(service.pendingPinnedSourceId()).toBe(ALT_TWO.id);
});
it('restarts a pinned copy that was watched through', async () => {
pins.get.mockResolvedValue({
matchKey: 'title:the matrix:1999',
playlistId: ALT_TWO.playlistId,
contentId: ALT_TWO.contentId,
portalType: 'xtream',
});
await loadMovie([ALT_TWO]);
service.seedResumePosition(2538);
// The caller applies the same in-progress rule the button uses, so a
// finished copy arrives here as null. Anything else would show Play
// and then start near the end.
await expect(
service.playPinnedSource(jest.fn().mockResolvedValue(null))
).resolves.toBe('played');
expect(resolver.resolve).toHaveBeenCalledWith(
expect.objectContaining({ id: ALT_TWO.id }),
{ startTime: 0 }
);
});
it('starts a pinned source that was never watched from the beginning', async () => {
pins.get.mockResolvedValue({
matchKey: 'title:the matrix:1999',
@@ -1,3 +1,15 @@
import { signal } from '@angular/core';
import { TestBed } from '@angular/core/testing';
import {
VodSourceDiscoveryService,
VodSourceResolverService,
} from '@iptvnator/portal/shared/data-access';
import {
SettingsStore,
StreamProbeService,
VodSourcePinService,
} from '@iptvnator/services';
import { VodMultiSourceHostService } from './vod-multi-source-host.service';
/**
* Shared fixtures for the multi-source host specs, extracted so the
* race-condition suite can live in its own file rather than duplicating setup.
@@ -82,3 +94,88 @@ export function createDeferred<T>() {
});
return { promise, resolve };
}
/**
* The TestBed every host spec needs.
*
* Five specs carried their own identical copy of this — the same five stubs,
* the same reset, the same bind. One harness so they cannot drift, and so a
* new case does not cost sixty lines of setup.
*/
export function setupVodMultiSourceHost() {
const movie = signal<VodMultiSourceMovie | null>(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() };
const resolver = { resolve: jest.fn() };
const pins = { get: jest.fn(), set: jest.fn(), clear: jest.fn() };
const probes = { probe: jest.fn() };
const harness = {
movie,
playbackLive,
vodAutoFailover,
startPlayback,
discovery,
resolver,
pins,
probes,
service: null as unknown as VodMultiSourceHostService,
/** Call from `beforeEach`; returns the freshly bound service. */
reset(): VodMultiSourceHostService {
jest.resetAllMocks();
movie.set(null);
playbackLive.set(false);
vodAutoFailover.set(false);
discovery.isAvailable = true;
discovery.discover.mockResolvedValue({
sources: [],
matchKind: 'title-year',
});
resolver.resolve.mockImplementation(resolveWith());
pins.get.mockResolvedValue(null);
pins.set.mockResolvedValue(true);
pins.clear.mockResolvedValue(true);
probes.probe.mockResolvedValue(PROBE_OK);
TestBed.configureTestingModule({
providers: [
VodMultiSourceHostService,
{ provide: VodSourceDiscoveryService, useValue: discovery },
{ provide: VodSourceResolverService, useValue: resolver },
{ provide: VodSourcePinService, useValue: pins },
{ provide: StreamProbeService, useValue: probes },
{ provide: SettingsStore, useValue: { vodAutoFailover } },
],
});
harness.service = TestBed.inject(VodMultiSourceHostService);
TestBed.runInInjectionContext(() =>
harness.service.bind({ startPlayback, movie, playbackLive })
);
return harness.service;
},
async loadMovie(
sources: VodSourceCandidate[],
target: VodMultiSourceMovie = MOVIE_A
): Promise<void> {
discovery.discover.mockResolvedValue({
sources,
matchKind: 'title-year',
});
await harness.service.load(target);
},
rowFor(sourceId: string) {
return harness.service
.sources()
.find((source) => source.id === sourceId);
},
};
return harness;
}
@@ -7,6 +7,7 @@ import type {
import {
createPrimaryActionPosition,
formatPlaybackPosition,
isResumablePosition,
} from './vod-primary-action-position';
/**
@@ -222,6 +223,25 @@ describe('createPrimaryActionPosition', () => {
});
});
describe('isResumablePosition', () => {
it('is false for a copy watched through', () => {
// The primary button reads Play at this point, so a pinned start must
// not seek back to where the film ended.
expect(
isResumablePosition({ ...position(6900), durationSeconds: 7200 })
).toBe(false);
});
it('is false at the very beginning', () => {
expect(isResumablePosition(position(0))).toBe(false);
expect(isResumablePosition(null)).toBe(false);
});
it('is true in the middle', () => {
expect(isResumablePosition(position(2538))).toBe(true);
});
});
describe('formatPlaybackPosition', () => {
it('drops the hour when there is none', () => {
expect(formatPlaybackPosition(position(123))).toBe('02:03');
@@ -39,6 +39,22 @@ export interface PrimaryActionPosition {
position: Signal<PlaybackPositionData | null>;
/** Whether the button should read Resume rather than Play. */
hasPosition: Signal<boolean>;
/** The pinned copy the button acts on, when it is not the route's own. */
foreignPin: Signal<VodSourceDescriptor | null>;
}
/**
* Whether a stored position is worth resuming from.
*
* Shared so the label and the start point cannot disagree: a copy watched to
* the end shows Play, and starting it must then mean starting it, not seeking
* back to the 95% mark it was left at.
*/
export function isResumablePosition(
position: PlaybackPositionData | null
): boolean {
const progress = getPortalPlaybackProgressPercent(position);
return progress > 0 && progress < 90;
}
export function createPrimaryActionPosition(
@@ -101,12 +117,9 @@ export function createPrimaryActionPosition(
return isPinnedCopy ? live : pinnedPosition();
});
const hasPosition = computed(() => {
const progress = getPortalPlaybackProgressPercent(position());
return progress > 0 && progress < 90;
});
const hasPosition = computed(() => isResumablePosition(position()));
return { position, hasPosition };
return { position, hasPosition, foreignPin };
}
/** `01:02:03`, or `02:03` for anything under an hour. */