mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-10 01:56:16 -08:00
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:
1 parent
26b820cc29
commit
e1c150e7bb
12 files changed
+399
-135
No files matched your search
@@ -453,7 +453,12 @@ which replace whatever timecode an alternative left in the controller.
|
||||
While the pinned copy is the one playing, its live position wins over the row
|
||||
that was stored before this session started.
|
||||
|
||||
A pin means the button plays a copy the page did not load a position for.
|
||||
A pin means the button plays a copy the page did not load a position for. A
|
||||
watched-through copy resolves to zero rather than its stored seconds, through
|
||||
the same `isResumablePosition` rule the label uses — otherwise the button reads
|
||||
Play and then seeks back to where the film ended. Restart follows the pin for
|
||||
the same reason: with Resume honouring a pinned copy, a Restart that started
|
||||
the route copy would silently switch the user's playlist.
|
||||
`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
|
||||
@@ -468,6 +473,11 @@ object carrying `codec_name`/`width`/`height`. `readStreamInfo` accepts both —
|
||||
reading only the object silently lost the codec on every array response, and
|
||||
with it the "dub may differ" warning, which compares stated audio tracks.
|
||||
|
||||
Switching sources through `startResolvedPlayback` closes a matched external
|
||||
session first. It REPLACES what is playing — with MPV or VLC and instance
|
||||
reuse off, the backend would otherwise spawn a second detached player, leaving
|
||||
both sources running and Stop owning only the newer one.
|
||||
|
||||
## Claims about the present
|
||||
|
||||
`isActive` means "the source a switch or Play would use" — selection, not
|
||||
|
||||
+17
-2
@@ -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());
|
||||
|
||||
+21
-1
@@ -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);
|
||||
|
||||
+92
@@ -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. */
|
||||
|
||||
Reference in new issue
Block a user