mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-11 02:46:16 -08:00
fix(portals): gate VOD auto-failover on engines that can report failures
Merges master and resolves the collision with its shared playback helpers, then closes two Codex findings. Master extracted the Play/Stop button state and the inline position writer that this branch had modified. Rather than forking private copies back out of Xtream, both behaviours move into the shared helpers: `alsoOwns` lets a page own an external session launched for a copy of the same film in another playlist, and the resume latch — which stops a timeupdate emitted before the engine reaches `startTime` from overwriting the point being resumed from — now protects Stalker too, which had the same bug. Auto-failover was offered on every engine, but only the built-in web players raise the playback diagnostic that reaches `onPlaybackFailed()`: Embedded MPV has its diagnostics suppressed and MPV/VLC play outside the app. The toggle is now hidden there, in settings and in the sources menu, instead of promising a switch that can never happen. `setAutoFailover` also ignored `updateSettings()`, which patches memory first and rejects if the write fails — the toggle looked saved, silently reverted on restart, and the rejection surfaced only as an unhandled promise. It now reports the failure like the settings form's own save paths. The three VOD-details route specs each carried a near-identical 150-line TestBed; they now share one harness, which is what makes room for the new cases (1012 -> 584 lines). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
commit
56733db1ec
37 files changed
+1795
-910
No files matched your search
@@ -26,3 +26,5 @@ export * from './lib/navigation/portal-rail-links';
|
||||
export * from './lib/navigation/portal-route.utils';
|
||||
export * from './lib/navigation/workspace-portal-navigation';
|
||||
export * from './lib/collection';
|
||||
export * from './lib/external-playback-button-state';
|
||||
export * from './lib/inline-playback-position-writer';
|
||||
@@ -0,0 +1,210 @@
|
||||
import { signal } from '@angular/core';
|
||||
import type {
|
||||
ExternalPlayerSession,
|
||||
PlayerContentInfo,
|
||||
} from '@iptvnator/shared/interfaces';
|
||||
import { createExternalPlaybackButtonState } from './external-playback-button-state';
|
||||
|
||||
function session(
|
||||
overrides: Partial<ExternalPlayerSession> = {}
|
||||
): ExternalPlayerSession {
|
||||
return {
|
||||
player: 'mpv',
|
||||
status: 'playing',
|
||||
contentInfo: {
|
||||
playlistId: 'playlist-1',
|
||||
contentXtreamId: 42,
|
||||
contentType: 'vod',
|
||||
},
|
||||
...overrides,
|
||||
} as ExternalPlayerSession;
|
||||
}
|
||||
|
||||
function setup(initial: ExternalPlayerSession | null = null) {
|
||||
const current = signal<ExternalPlayerSession | null>(initial);
|
||||
const api = createExternalPlaybackButtonState({
|
||||
session: current,
|
||||
playlistId: signal<string | null>('playlist-1'),
|
||||
contentId: signal<number | null>(42),
|
||||
});
|
||||
return { current, api };
|
||||
}
|
||||
|
||||
describe('createExternalPlaybackButtonState', () => {
|
||||
it('is idle with no session', () => {
|
||||
const { api } = setup(null);
|
||||
|
||||
expect(api.matchedSession()).toBeNull();
|
||||
expect(api.buttonState()).toBe('idle');
|
||||
expect(api.primaryIcon()).toBe('play_arrow');
|
||||
expect(api.primaryLabel()).toBeNull();
|
||||
});
|
||||
|
||||
it('matches a session for the item on screen', () => {
|
||||
const { api } = setup(session());
|
||||
|
||||
expect(api.matchedSession()).not.toBeNull();
|
||||
expect(api.buttonState()).toBe('stop');
|
||||
expect(api.primaryIcon()).toBe('stop_circle');
|
||||
expect(api.primaryLabel()).toBe('Stop MPV');
|
||||
expect(api.isStopAction()).toBe(true);
|
||||
});
|
||||
|
||||
it('ignores a session playing a different movie', () => {
|
||||
// The whole point of the match: another movie playing in MPV must not
|
||||
// turn this page's Play button into Stop.
|
||||
const { api } = setup(
|
||||
session({
|
||||
contentInfo: {
|
||||
playlistId: 'playlist-1',
|
||||
contentXtreamId: 999,
|
||||
contentType: 'vod',
|
||||
},
|
||||
} as Partial<ExternalPlayerSession>)
|
||||
);
|
||||
|
||||
expect(api.matchedSession()).toBeNull();
|
||||
expect(api.buttonState()).toBe('idle');
|
||||
});
|
||||
|
||||
it('ignores a session from a different playlist', () => {
|
||||
const { api } = setup(
|
||||
session({
|
||||
contentInfo: {
|
||||
playlistId: 'other-playlist',
|
||||
contentXtreamId: 42,
|
||||
contentType: 'vod',
|
||||
},
|
||||
} as Partial<ExternalPlayerSession>)
|
||||
);
|
||||
|
||||
expect(api.matchedSession()).toBeNull();
|
||||
});
|
||||
|
||||
it('ignores a session of a different content type', () => {
|
||||
const { api } = setup(
|
||||
session({
|
||||
contentInfo: {
|
||||
playlistId: 'playlist-1',
|
||||
contentXtreamId: 42,
|
||||
contentType: 'episode',
|
||||
},
|
||||
} as Partial<ExternalPlayerSession>)
|
||||
);
|
||||
|
||||
expect(api.matchedSession()).toBeNull();
|
||||
});
|
||||
|
||||
it.each(['closed', 'error'] as const)(
|
||||
'treats a %s session as nothing playing',
|
||||
(status) => {
|
||||
const { api } = setup(session({ status }));
|
||||
|
||||
expect(api.matchedSession()).toBeNull();
|
||||
expect(api.buttonState()).toBe('idle');
|
||||
}
|
||||
);
|
||||
|
||||
it('reports a launching session', () => {
|
||||
const { api } = setup(session({ status: 'launching' }));
|
||||
|
||||
expect(api.buttonState()).toBe('launching');
|
||||
expect(api.primaryIcon()).toBe('hourglass_top');
|
||||
expect(api.primaryLabel()).toBe('Opening in MPV...');
|
||||
expect(api.isLaunchPending()).toBe(true);
|
||||
expect(api.isStopAction()).toBe(false);
|
||||
});
|
||||
|
||||
it('reacts to the session changing', () => {
|
||||
const { current, api } = setup(null);
|
||||
expect(api.buttonState()).toBe('idle');
|
||||
|
||||
current.set(session({ status: 'launching' }));
|
||||
expect(api.buttonState()).toBe('launching');
|
||||
|
||||
current.set(session({ status: 'playing' }));
|
||||
expect(api.buttonState()).toBe('stop');
|
||||
|
||||
current.set(null);
|
||||
expect(api.buttonState()).toBe('idle');
|
||||
});
|
||||
|
||||
it('does not match while the item id is unknown', () => {
|
||||
const api = createExternalPlaybackButtonState({
|
||||
session: signal<ExternalPlayerSession | null>(session()),
|
||||
playlistId: signal<string | null>('playlist-1'),
|
||||
contentId: signal<number | null>(null),
|
||||
});
|
||||
|
||||
expect(api.matchedSession()).toBeNull();
|
||||
});
|
||||
|
||||
it('also owns a session launched for another playlist it switched to', () => {
|
||||
// Multi-source: same film, other playlist, other stream id. Without
|
||||
// this the page shows Play while its own switch is streaming, and
|
||||
// clicking it opens a second player instead of stopping the first.
|
||||
const api = createExternalPlaybackButtonState({
|
||||
session: signal<ExternalPlayerSession | null>(
|
||||
session({
|
||||
contentInfo: {
|
||||
playlistId: 'playlist-2',
|
||||
contentXtreamId: 991,
|
||||
contentType: 'vod',
|
||||
},
|
||||
})
|
||||
),
|
||||
playlistId: signal<string | null>('playlist-1'),
|
||||
contentId: signal<number | null>(42),
|
||||
alsoOwns: signal<PlayerContentInfo | null>({
|
||||
playlistId: 'playlist-2',
|
||||
contentXtreamId: 991,
|
||||
contentType: 'vod',
|
||||
}),
|
||||
});
|
||||
|
||||
expect(api.matchedSession()).not.toBeNull();
|
||||
expect(api.isStopAction()).toBe(true);
|
||||
});
|
||||
|
||||
it('disowns that session once playback moved back', () => {
|
||||
const api = createExternalPlaybackButtonState({
|
||||
session: signal<ExternalPlayerSession | null>(
|
||||
session({
|
||||
contentInfo: {
|
||||
playlistId: 'playlist-2',
|
||||
contentXtreamId: 991,
|
||||
contentType: 'vod',
|
||||
},
|
||||
})
|
||||
),
|
||||
playlistId: signal<string | null>('playlist-1'),
|
||||
contentId: signal<number | null>(42),
|
||||
alsoOwns: signal<PlayerContentInfo | null>(null),
|
||||
});
|
||||
|
||||
expect(api.matchedSession()).toBeNull();
|
||||
});
|
||||
|
||||
it('still checks the content type of an owned alternative', () => {
|
||||
const api = createExternalPlaybackButtonState({
|
||||
session: signal<ExternalPlayerSession | null>(
|
||||
session({
|
||||
contentInfo: {
|
||||
playlistId: 'playlist-2',
|
||||
contentXtreamId: 991,
|
||||
contentType: 'episode',
|
||||
},
|
||||
})
|
||||
),
|
||||
playlistId: signal<string | null>('playlist-1'),
|
||||
contentId: signal<number | null>(42),
|
||||
alsoOwns: signal<PlayerContentInfo | null>({
|
||||
playlistId: 'playlist-2',
|
||||
contentXtreamId: 991,
|
||||
contentType: 'vod',
|
||||
}),
|
||||
});
|
||||
|
||||
expect(api.matchedSession()).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,137 @@
|
||||
import { computed, type Signal } from '@angular/core';
|
||||
import type {
|
||||
ExternalPlayerSession,
|
||||
PlayerContentInfo,
|
||||
} from '@iptvnator/shared/interfaces';
|
||||
|
||||
/**
|
||||
* Derives the primary Play/Stop button's state from the active external
|
||||
* (MPV/VLC) session.
|
||||
*
|
||||
* Extracted because Xtream and Stalker each carried a near-identical private
|
||||
* copy of these six computeds. A single Play button that behaves differently
|
||||
* per portal is a bug waiting to happen, so both now read from here.
|
||||
*/
|
||||
|
||||
export type ExternalPlaybackButtonState = 'idle' | 'launching' | 'stop';
|
||||
|
||||
export interface ExternalPlaybackButtonStateConfig {
|
||||
/** The currently active external player session, if any. */
|
||||
session: Signal<ExternalPlayerSession | null>;
|
||||
/** Playlist the detail view is showing. */
|
||||
playlistId: Signal<string | null | undefined>;
|
||||
/** Provider-side id of the item on screen. */
|
||||
contentId: Signal<number | null | undefined>;
|
||||
/** Defaults to `'vod'`. */
|
||||
contentType?: 'vod' | 'episode';
|
||||
/**
|
||||
* A second pair of ids this page also owns, if any.
|
||||
*
|
||||
* Multi-source can launch an external player on a copy of the same film in
|
||||
* ANOTHER playlist, and the session then carries that playlist's ids.
|
||||
* Without this the page shows Play while its own switch is streaming, and
|
||||
* the user has no way to stop it from here.
|
||||
*/
|
||||
alsoOwns?: Signal<PlayerContentInfo | null>;
|
||||
}
|
||||
|
||||
export interface ExternalPlaybackButtonStateApi {
|
||||
/**
|
||||
* The session, but only when it belongs to the item on screen. A session
|
||||
* playing a different movie must not turn this page's Play into Stop.
|
||||
*/
|
||||
matchedSession: Signal<ExternalPlayerSession | null>;
|
||||
primaryLabel: Signal<string | null>;
|
||||
primaryIcon: Signal<string>;
|
||||
isLaunchPending: Signal<boolean>;
|
||||
isStopAction: Signal<boolean>;
|
||||
buttonState: Signal<ExternalPlaybackButtonState>;
|
||||
}
|
||||
|
||||
export function createExternalPlaybackButtonState(
|
||||
config: ExternalPlaybackButtonStateConfig
|
||||
): ExternalPlaybackButtonStateApi {
|
||||
const contentType = config.contentType ?? 'vod';
|
||||
|
||||
const matchedSession = computed(() => {
|
||||
const session = config.session();
|
||||
// A closed or errored session says nothing about what is playing now.
|
||||
if (
|
||||
!session?.contentInfo ||
|
||||
session.status === 'closed' ||
|
||||
session.status === 'error'
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const info = session.contentInfo;
|
||||
if (info.contentType !== contentType) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const alsoOwns = config.alsoOwns?.();
|
||||
const owned =
|
||||
(info.playlistId === config.playlistId() &&
|
||||
info.contentXtreamId === config.contentId()) ||
|
||||
(!!alsoOwns &&
|
||||
info.playlistId === alsoOwns.playlistId &&
|
||||
info.contentXtreamId === alsoOwns.contentXtreamId);
|
||||
|
||||
return owned ? session : null;
|
||||
});
|
||||
|
||||
const primaryLabel = computed(() => {
|
||||
const session = matchedSession();
|
||||
if (!session) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const player = session.player.toUpperCase();
|
||||
switch (session.status) {
|
||||
case 'launching':
|
||||
return `Opening in ${player}...`;
|
||||
case 'opened':
|
||||
case 'playing':
|
||||
return `Stop ${player}`;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
});
|
||||
|
||||
const primaryIcon = computed(() => {
|
||||
switch (matchedSession()?.status) {
|
||||
case 'launching':
|
||||
return 'hourglass_top';
|
||||
case 'opened':
|
||||
case 'playing':
|
||||
return 'stop_circle';
|
||||
default:
|
||||
return 'play_arrow';
|
||||
}
|
||||
});
|
||||
|
||||
const isLaunchPending = computed(
|
||||
() => matchedSession()?.status === 'launching'
|
||||
);
|
||||
|
||||
const isStopAction = computed(() => {
|
||||
const status = matchedSession()?.status;
|
||||
return status === 'opened' || status === 'playing';
|
||||
});
|
||||
|
||||
const buttonState = computed<ExternalPlaybackButtonState>(() => {
|
||||
if (isLaunchPending()) {
|
||||
return 'launching';
|
||||
}
|
||||
return isStopAction() ? 'stop' : 'idle';
|
||||
});
|
||||
|
||||
return {
|
||||
matchedSession,
|
||||
primaryLabel,
|
||||
primaryIcon,
|
||||
isLaunchPending,
|
||||
isStopAction,
|
||||
buttonState,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,185 @@
|
||||
import { signal } from '@angular/core';
|
||||
import type { ResolvedPortalPlayback } from '@iptvnator/shared/interfaces';
|
||||
import { createInlinePlaybackPositionWriter } from './inline-playback-position-writer';
|
||||
|
||||
function playbackWithInfo(): ResolvedPortalPlayback {
|
||||
return {
|
||||
streamUrl: 'http://example.com/movie.mkv',
|
||||
title: 'Dune',
|
||||
contentInfo: {
|
||||
playlistId: 'playlist-1',
|
||||
contentXtreamId: 42,
|
||||
contentType: 'vod',
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function setup(initial: ResolvedPortalPlayback | null = playbackWithInfo()) {
|
||||
const playback = signal<ResolvedPortalPlayback | null>(initial);
|
||||
const save = jest.fn();
|
||||
const onSaved = jest.fn();
|
||||
const writer = createInlinePlaybackPositionWriter({
|
||||
playback,
|
||||
save,
|
||||
onSaved,
|
||||
});
|
||||
return { playback, save, onSaved, writer };
|
||||
}
|
||||
|
||||
describe('createInlinePlaybackPositionWriter', () => {
|
||||
afterEach(() => {
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
it('writes the first update immediately', () => {
|
||||
const { writer, save, onSaved } = setup();
|
||||
|
||||
writer.handleTimeUpdate({ currentTime: 12.7, duration: 100.2 });
|
||||
|
||||
expect(save).toHaveBeenCalledWith('playlist-1', {
|
||||
playlistId: 'playlist-1',
|
||||
contentXtreamId: 42,
|
||||
contentType: 'vod',
|
||||
positionSeconds: 12,
|
||||
durationSeconds: 100,
|
||||
});
|
||||
expect(onSaved).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('throttles subsequent updates to one per 15s', () => {
|
||||
jest.useFakeTimers();
|
||||
const { writer, save } = setup();
|
||||
|
||||
writer.handleTimeUpdate({ currentTime: 1, duration: 100 });
|
||||
expect(save).toHaveBeenCalledTimes(1);
|
||||
|
||||
// The player fires ~4x/second; none of these may reach storage.
|
||||
jest.advanceTimersByTime(5000);
|
||||
writer.handleTimeUpdate({ currentTime: 6, duration: 100 });
|
||||
jest.advanceTimersByTime(5000);
|
||||
writer.handleTimeUpdate({ currentTime: 11, duration: 100 });
|
||||
expect(save).toHaveBeenCalledTimes(1);
|
||||
|
||||
jest.advanceTimersByTime(6000);
|
||||
writer.handleTimeUpdate({ currentTime: 17, duration: 100 });
|
||||
expect(save).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('reset makes the next update write immediately', () => {
|
||||
jest.useFakeTimers();
|
||||
const { writer, save } = setup();
|
||||
|
||||
writer.handleTimeUpdate({ currentTime: 1, duration: 100 });
|
||||
jest.advanceTimersByTime(1000);
|
||||
writer.handleTimeUpdate({ currentTime: 2, duration: 100 });
|
||||
expect(save).toHaveBeenCalledTimes(1);
|
||||
|
||||
writer.reset();
|
||||
writer.handleTimeUpdate({ currentTime: 3, duration: 100 });
|
||||
expect(save).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('writes nothing without a playback', () => {
|
||||
const { writer, save } = setup(null);
|
||||
|
||||
writer.handleTimeUpdate({ currentTime: 5, duration: 100 });
|
||||
|
||||
expect(save).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('writes nothing without contentInfo — there is no key to store under', () => {
|
||||
const { writer, save } = setup({
|
||||
streamUrl: 'http://example.com/movie.mkv',
|
||||
title: 'Dune',
|
||||
});
|
||||
|
||||
writer.handleTimeUpdate({ currentTime: 5, duration: 100 });
|
||||
|
||||
expect(save).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('floors fractional times', () => {
|
||||
const { writer, save } = setup();
|
||||
|
||||
writer.handleTimeUpdate({ currentTime: 9.99, duration: 42.99 });
|
||||
|
||||
expect(save.mock.calls[0][1]).toEqual(
|
||||
expect.objectContaining({
|
||||
positionSeconds: 9,
|
||||
durationSeconds: 42,
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
describe('resume latch', () => {
|
||||
function resuming(startTime: number): ResolvedPortalPlayback {
|
||||
return { ...playbackWithInfo(), startTime };
|
||||
}
|
||||
|
||||
it('drops updates emitted before the engine reaches startTime', () => {
|
||||
// A resuming engine ticks at ~0 while it is still seeking. Writing
|
||||
// one of those overwrites the very position being resumed from.
|
||||
const { writer, save } = setup(resuming(2538));
|
||||
|
||||
expect(
|
||||
writer.handleTimeUpdate({ currentTime: 12, duration: 7744 })
|
||||
).toBe(false);
|
||||
expect(save).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('releases once, so a later seek backwards is still saved', () => {
|
||||
jest.useFakeTimers();
|
||||
const { writer, save } = setup(resuming(2538));
|
||||
|
||||
writer.handleTimeUpdate({ currentTime: 12, duration: 7744 });
|
||||
// Reaching the resume point latches the guard open for good.
|
||||
writer.handleTimeUpdate({ currentTime: 2540, duration: 7744 });
|
||||
|
||||
jest.advanceTimersByTime(16000);
|
||||
expect(
|
||||
writer.handleTimeUpdate({ currentTime: 30, duration: 7744 })
|
||||
).toBe(true);
|
||||
expect(save).toHaveBeenLastCalledWith(
|
||||
'playlist-1',
|
||||
expect.objectContaining({ positionSeconds: 30 })
|
||||
);
|
||||
});
|
||||
|
||||
it('gives up on a resume point the source cannot reach', () => {
|
||||
// Two hours carried into a 90-minute cut: the engine can never
|
||||
// report that time, so latching on it would suppress every save
|
||||
// for the whole session.
|
||||
const { writer, save } = setup(resuming(7200));
|
||||
|
||||
expect(
|
||||
writer.handleTimeUpdate({ currentTime: 12, duration: 5400 })
|
||||
).toBe(true);
|
||||
expect(save).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('keeps the latch closed across an update with no contentInfo', () => {
|
||||
const { writer, playback } = setup(resuming(2538));
|
||||
|
||||
writer.handleTimeUpdate({ currentTime: 12, duration: 7744 });
|
||||
playback.set({ streamUrl: 'http://example.com/x.mkv', title: 'X' });
|
||||
|
||||
// Reporting `true` here would tell multi-source that a timecode of
|
||||
// ~0 can be carried to the next source.
|
||||
expect(
|
||||
writer.handleTimeUpdate({ currentTime: 13, duration: 7744 })
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('is cleared by reset, so a new resume waits again', () => {
|
||||
const { writer, playback } = setup();
|
||||
|
||||
writer.handleTimeUpdate({ currentTime: 40, duration: 7744 });
|
||||
playback.set(resuming(2538));
|
||||
writer.reset();
|
||||
|
||||
expect(
|
||||
writer.handleTimeUpdate({ currentTime: 12, duration: 7744 })
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,111 @@
|
||||
import type { Signal } from '@angular/core';
|
||||
import type {
|
||||
PlaybackPositionData,
|
||||
ResolvedPortalPlayback,
|
||||
} from '@iptvnator/shared/interfaces';
|
||||
|
||||
/**
|
||||
* Throttled persistence of the inline player's position.
|
||||
*
|
||||
* Extracted because Xtream and Stalker each carried their own copy of the same
|
||||
* "every timeupdate, but at most once per 15s" logic, and a resume point that
|
||||
* behaves differently per portal is exactly the kind of divergence users
|
||||
* notice.
|
||||
*
|
||||
* Beyond that throttle it holds one resume latch, because a position written
|
||||
* before the engine reaches `startTime` overwrites the point being resumed
|
||||
* from — a bug both copies had.
|
||||
*/
|
||||
|
||||
/** The player fires ~4x/second; persisting that often would hammer SQLite. */
|
||||
const DEFAULT_THROTTLE_MS = 15000;
|
||||
|
||||
/** Engines land near, not exactly on, the requested `startTime`. */
|
||||
const RESUME_TOLERANCE_SECONDS = 5;
|
||||
|
||||
export interface InlinePlaybackPositionWriterConfig {
|
||||
/** The playback currently mounted in the inline player. */
|
||||
playback: Signal<ResolvedPortalPlayback | null>;
|
||||
save: (playlistId: string, position: PlaybackPositionData) => void;
|
||||
/** Called with each persisted position, for local resume state. */
|
||||
onSaved?: (position: PlaybackPositionData) => void;
|
||||
throttleMs?: number;
|
||||
}
|
||||
|
||||
export interface InlinePlaybackPositionWriter {
|
||||
/**
|
||||
* @returns whether `currentTime` can be believed yet — false while the
|
||||
* engine is still on its way to `startTime`. Multi-source switching needs
|
||||
* the same answer to carry a timecode across, and one latch has to serve
|
||||
* both or the two disagree about where playback is.
|
||||
*/
|
||||
handleTimeUpdate(event: { currentTime: number; duration: number }): boolean;
|
||||
/** Clears the throttle and the resume latch for a new playback. */
|
||||
reset(): void;
|
||||
}
|
||||
|
||||
export function createInlinePlaybackPositionWriter(
|
||||
config: InlinePlaybackPositionWriterConfig
|
||||
): InlinePlaybackPositionWriter {
|
||||
const throttleMs = config.throttleMs ?? DEFAULT_THROTTLE_MS;
|
||||
let lastSaveTime = 0;
|
||||
/** Cleared on every start; latches once playback reaches `startTime`. */
|
||||
let resumeSettled = false;
|
||||
|
||||
return {
|
||||
handleTimeUpdate(event) {
|
||||
const playback = config.playback();
|
||||
// Without contentInfo there is no key to store the position under.
|
||||
// The latch never ran, so the answer is whatever it already was —
|
||||
// not a fresh "no".
|
||||
if (!playback?.contentInfo) {
|
||||
return resumeSettled;
|
||||
}
|
||||
|
||||
// A resuming engine can emit timeupdates at ~0 before it finishes
|
||||
// seeking to startTime. Writing one would overwrite the very
|
||||
// position being resumed from — which a source switch then
|
||||
// inherits, restarting the film from the beginning.
|
||||
//
|
||||
// This is a one-shot latch, not a filter: once playback has
|
||||
// reached the resume point the guard is done, so a deliberate seek
|
||||
// backwards is still saved normally.
|
||||
if (!resumeSettled) {
|
||||
const startTime = playback.startTime ?? 0;
|
||||
// A shorter cut can never reach a position carried into it,
|
||||
// and latching on that would suppress every save this session.
|
||||
const reachable =
|
||||
!(event.duration > 0) || startTime < event.duration;
|
||||
if (
|
||||
reachable &&
|
||||
startTime > 0 &&
|
||||
event.currentTime < startTime - RESUME_TOLERANCE_SECONDS
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
resumeSettled = true;
|
||||
}
|
||||
|
||||
const now = Date.now();
|
||||
if (now - lastSaveTime <= throttleMs) {
|
||||
return true;
|
||||
}
|
||||
lastSaveTime = now;
|
||||
|
||||
const position: PlaybackPositionData = {
|
||||
...playback.contentInfo,
|
||||
positionSeconds: Math.floor(event.currentTime),
|
||||
durationSeconds: Math.floor(event.duration),
|
||||
};
|
||||
|
||||
config.save(playback.contentInfo.playlistId, position);
|
||||
config.onSaved?.(position);
|
||||
return true;
|
||||
},
|
||||
|
||||
reset() {
|
||||
lastSaveTime = 0;
|
||||
resumeSettled = false;
|
||||
},
|
||||
};
|
||||
}
|
||||
+21
-90
@@ -15,13 +15,12 @@ import {
|
||||
PORTAL_PLAYBACK_POSITIONS,
|
||||
PORTAL_PLAYER,
|
||||
createLogger,
|
||||
createInlinePlaybackPositionWriter,
|
||||
} from '@iptvnator/portal/shared/util';
|
||||
import {
|
||||
createPortalFavoritesResource,
|
||||
createRefreshTrigger,
|
||||
isSelectedStalkerVodFavorite,
|
||||
normalizeStalkerEntityId,
|
||||
normalizeStalkerEntityIdAsNumber,
|
||||
StalkerSelectedVodItem,
|
||||
toggleStalkerVodFavorite,
|
||||
} from '@iptvnator/portal/stalker/data-access';
|
||||
@@ -44,15 +43,7 @@ import {
|
||||
import { StalkerCatalogFacadeService } from '../stalker-catalog-facade.service';
|
||||
import { StalkerSeriesViewComponent } from '../stalker-series-view/stalker-series-view.component';
|
||||
|
||||
interface DownloadVodData {
|
||||
id?: string | number;
|
||||
has_files?: unknown;
|
||||
info?: {
|
||||
name?: string;
|
||||
movie_image?: string;
|
||||
};
|
||||
title?: string;
|
||||
}
|
||||
import { startStalkerVodDownload } from './stalker-vod-download';
|
||||
|
||||
@Component({
|
||||
selector: 'app-stalker-catalog-detail',
|
||||
@@ -94,7 +85,6 @@ export class StalkerCatalogDetailComponent implements OnDestroy {
|
||||
private readonly selectedVodPosition = signal<PlaybackPositionData | null>(
|
||||
null
|
||||
);
|
||||
private lastInlineSaveTime = 0;
|
||||
private unsubscribePositionUpdates: (() => void) | null = null;
|
||||
|
||||
readonly isSeriesDetail = computed(() => {
|
||||
@@ -226,37 +216,26 @@ export class StalkerCatalogDetailComponent implements OnDestroy {
|
||||
}
|
||||
}
|
||||
|
||||
private readonly positionWriter = createInlinePlaybackPositionWriter({
|
||||
playback: this.inlinePlayback,
|
||||
save: (playlistId, position) =>
|
||||
void this.playbackPositions.savePlaybackPosition(
|
||||
playlistId,
|
||||
position
|
||||
),
|
||||
onSaved: (position) => this.selectedVodPosition.set(position),
|
||||
});
|
||||
|
||||
handleInlineTimeUpdate(event: {
|
||||
currentTime: number;
|
||||
duration: number;
|
||||
}): void {
|
||||
const playback = this.inlinePlayback();
|
||||
if (!playback?.contentInfo) {
|
||||
return;
|
||||
}
|
||||
|
||||
const now = Date.now();
|
||||
if (now - this.lastInlineSaveTime <= 15000) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.lastInlineSaveTime = now;
|
||||
const position: PlaybackPositionData = {
|
||||
...playback.contentInfo,
|
||||
positionSeconds: Math.floor(event.currentTime),
|
||||
durationSeconds: Math.floor(event.duration),
|
||||
};
|
||||
|
||||
void this.playbackPositions.savePlaybackPosition(
|
||||
playback.contentInfo.playlistId,
|
||||
position
|
||||
);
|
||||
this.selectedVodPosition.set(position);
|
||||
this.positionWriter.handleTimeUpdate(event);
|
||||
}
|
||||
|
||||
closeInlinePlayer(): void {
|
||||
this.inlinePlayback.set(null);
|
||||
this.lastInlineSaveTime = 0;
|
||||
this.positionWriter.reset();
|
||||
}
|
||||
|
||||
showCopyNotification(): void {
|
||||
@@ -277,60 +256,12 @@ export class StalkerCatalogDetailComponent implements OnDestroy {
|
||||
}
|
||||
|
||||
async onVodDownload(item: VodDetailsItem): Promise<void> {
|
||||
if (item.type !== 'stalker') {
|
||||
return;
|
||||
}
|
||||
|
||||
const playlist = this.catalog.playlist();
|
||||
if (!playlist || !playlist.portalUrl || !playlist.macAddress) {
|
||||
return;
|
||||
}
|
||||
|
||||
let cmdToUse = item.cmd;
|
||||
const itemData = item.data as DownloadVodData;
|
||||
const normalizedItemId = normalizeStalkerEntityId(itemData?.id);
|
||||
|
||||
if (
|
||||
itemData?.has_files !== undefined &&
|
||||
cmdToUse &&
|
||||
!cmdToUse.includes('://') &&
|
||||
cmdToUse.includes('/media/') &&
|
||||
!cmdToUse.includes('/media/file_')
|
||||
) {
|
||||
const fileId =
|
||||
await this.catalog.fetchMovieFileId(normalizedItemId);
|
||||
if (fileId) {
|
||||
cmdToUse = `/media/file_${fileId}.mpg`;
|
||||
}
|
||||
}
|
||||
|
||||
const url = await this.catalog.fetchLinkToPlay(
|
||||
playlist.portalUrl,
|
||||
playlist.macAddress,
|
||||
cmdToUse
|
||||
);
|
||||
if (!url) {
|
||||
return;
|
||||
}
|
||||
|
||||
const numericId = normalizeStalkerEntityIdAsNumber(itemData?.id) ?? 0;
|
||||
|
||||
await this.downloadsService.startDownload({
|
||||
playlistId: playlist.id,
|
||||
xtreamId: numericId,
|
||||
contentType: 'vod',
|
||||
title: itemData?.info?.name || itemData?.title || 'Unknown',
|
||||
url,
|
||||
posterUrl: itemData?.info?.movie_image,
|
||||
headers: {
|
||||
userAgent: playlist.userAgent,
|
||||
referer: playlist.referer,
|
||||
origin: playlist.origin,
|
||||
},
|
||||
playlistName: playlist.title || 'Stalker Portal',
|
||||
playlistType: 'stalker',
|
||||
portalUrl: playlist.portalUrl,
|
||||
macAddress: playlist.macAddress,
|
||||
await startStalkerVodDownload(item, {
|
||||
playlist: this.catalog.playlist(),
|
||||
downloadsService: this.downloadsService,
|
||||
fetchMovieFileId: (id) => this.catalog.fetchMovieFileId(id),
|
||||
fetchLinkToPlay: (portalUrl, macAddress, cmd) =>
|
||||
this.catalog.fetchLinkToPlay(portalUrl, macAddress, cmd),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -370,7 +301,7 @@ export class StalkerCatalogDetailComponent implements OnDestroy {
|
||||
startTime
|
||||
);
|
||||
|
||||
this.lastInlineSaveTime = 0;
|
||||
this.positionWriter.reset();
|
||||
if (this.portalPlayer.isEmbeddedPlayer()) {
|
||||
this.inlinePlayback.set(playback);
|
||||
return;
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
import type { DownloadsService } from '@iptvnator/services';
|
||||
import type { VodDetailsItem } from '@iptvnator/shared/interfaces';
|
||||
|
||||
/** The Stalker branch of the item union — the only one carrying `cmd`. */
|
||||
type StalkerVodDetailsItem = Extract<VodDetailsItem, { type: 'stalker' }>;
|
||||
import {
|
||||
normalizeStalkerEntityId,
|
||||
normalizeStalkerEntityIdAsNumber,
|
||||
} from '@iptvnator/portal/stalker/data-access';
|
||||
|
||||
/**
|
||||
* Starting a download of a Stalker VOD item.
|
||||
*
|
||||
* Split out of the detail component because it is a self-contained errand: it
|
||||
* resolves a playable link (Stalker hands out `create_link` URLs, not static
|
||||
* ones) and hands the result to the download manager. Nothing else in the
|
||||
* component needs it.
|
||||
*/
|
||||
|
||||
/** The provider payload carried on a Stalker VOD item. */
|
||||
export interface DownloadVodData {
|
||||
id?: string | number;
|
||||
has_files?: unknown;
|
||||
title?: string;
|
||||
info?: { name?: string; movie_image?: string };
|
||||
}
|
||||
|
||||
export interface StalkerVodDownloadPlaylist {
|
||||
id: string;
|
||||
portalUrl?: string;
|
||||
macAddress?: string;
|
||||
title?: string;
|
||||
userAgent?: string;
|
||||
referer?: string;
|
||||
origin?: string;
|
||||
}
|
||||
|
||||
export interface StalkerVodDownloadDeps {
|
||||
playlist: StalkerVodDownloadPlaylist | null | undefined;
|
||||
downloadsService: Pick<DownloadsService, 'startDownload'>;
|
||||
fetchMovieFileId: (id: string) => Promise<string | number | null>;
|
||||
fetchLinkToPlay: (
|
||||
portalUrl: string,
|
||||
macAddress: string,
|
||||
cmd: string
|
||||
) => Promise<string | null>;
|
||||
}
|
||||
|
||||
export async function startStalkerVodDownload(
|
||||
item: VodDetailsItem,
|
||||
deps: StalkerVodDownloadDeps
|
||||
): Promise<void> {
|
||||
if (item.type !== 'stalker') {
|
||||
return;
|
||||
}
|
||||
|
||||
const { playlist } = deps;
|
||||
if (!playlist?.portalUrl || !playlist.macAddress) {
|
||||
return;
|
||||
}
|
||||
|
||||
const itemData = item.data as DownloadVodData;
|
||||
const cmdToUse = await resolveDownloadCmd(item, itemData, deps);
|
||||
|
||||
const url = await deps.fetchLinkToPlay(
|
||||
playlist.portalUrl,
|
||||
playlist.macAddress,
|
||||
cmdToUse
|
||||
);
|
||||
if (!url) {
|
||||
return;
|
||||
}
|
||||
|
||||
await deps.downloadsService.startDownload({
|
||||
playlistId: playlist.id,
|
||||
xtreamId: normalizeStalkerEntityIdAsNumber(itemData?.id) ?? 0,
|
||||
contentType: 'vod',
|
||||
title: itemData?.info?.name || itemData?.title || 'Unknown',
|
||||
url,
|
||||
posterUrl: itemData?.info?.movie_image,
|
||||
headers: {
|
||||
userAgent: playlist.userAgent,
|
||||
referer: playlist.referer,
|
||||
origin: playlist.origin,
|
||||
},
|
||||
playlistName: playlist.title || 'Stalker Portal',
|
||||
playlistType: 'stalker',
|
||||
portalUrl: playlist.portalUrl,
|
||||
macAddress: playlist.macAddress,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Ministra portals expose a movie as a folder command that has to be turned
|
||||
* into a concrete file command before it can be linked.
|
||||
*/
|
||||
async function resolveDownloadCmd(
|
||||
item: StalkerVodDetailsItem,
|
||||
itemData: DownloadVodData,
|
||||
deps: StalkerVodDownloadDeps
|
||||
): Promise<string> {
|
||||
const cmd = item.cmd ?? '';
|
||||
const needsFileId =
|
||||
itemData?.has_files !== undefined &&
|
||||
cmd &&
|
||||
!cmd.includes('://') &&
|
||||
cmd.includes('/media/') &&
|
||||
!cmd.includes('/media/file_');
|
||||
|
||||
if (!needsFileId) {
|
||||
return cmd;
|
||||
}
|
||||
|
||||
const fileId = await deps.fetchMovieFileId(
|
||||
normalizeStalkerEntityId(itemData?.id)
|
||||
);
|
||||
return fileId ? `/media/file_${fileId}.mpg` : cmd;
|
||||
}
|
||||
@@ -10,6 +10,8 @@ import {
|
||||
PORTAL_EXTERNAL_PLAYBACK,
|
||||
PORTAL_PLAYBACK_POSITIONS,
|
||||
PORTAL_PLAYER,
|
||||
createExternalPlaybackButtonState,
|
||||
createInlinePlaybackPositionWriter,
|
||||
createLogger,
|
||||
getPortalPlaybackProgressPercent,
|
||||
} from '@iptvnator/portal/shared/util';
|
||||
@@ -55,103 +57,26 @@ export class VodDetailsPlaybackService {
|
||||
private readonly logger = createLogger('VodDetailsPlayback');
|
||||
|
||||
/** Signals bound from the host component via `bind()` */
|
||||
private readonly bindings = signal<VodDetailsPlaybackBindings | null>(null);
|
||||
private lastSaveTime = 0;
|
||||
/** Cleared on every start; latches once playback reaches startTime. */
|
||||
private resumeSettled = false;
|
||||
private readonly bindings = signal<VodDetailsPlaybackBindings | null>(
|
||||
null
|
||||
);
|
||||
|
||||
readonly inlinePlayback = signal<ResolvedPortalPlayback | null>(null);
|
||||
readonly vodPlaybackPosition = signal<PlaybackPositionData | null>(null);
|
||||
|
||||
readonly matchedExternalPlayback = computed(() => {
|
||||
const session = this.externalPlayback.activeSession();
|
||||
|
||||
if (
|
||||
!session?.contentInfo ||
|
||||
!this.xtreamStore.currentPlaylist()?.id ||
|
||||
session.status === 'error' ||
|
||||
session.status === 'closed'
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return this.ownsContent(session.contentInfo) ? session : null;
|
||||
private readonly externalButton = createExternalPlaybackButtonState({
|
||||
session: this.externalPlayback.activeSession,
|
||||
playlistId: computed(() => this.xtreamStore.currentPlaylist()?.id),
|
||||
contentId: computed(() => this.bindings()?.vodId()),
|
||||
alsoOwns: computed(() => this.bindings()?.activeSource?.() ?? null),
|
||||
});
|
||||
|
||||
/**
|
||||
* Whether this page owns the content an external session or a position
|
||||
* update refers to.
|
||||
*
|
||||
* Multi-source can put playback on a movie in ANOTHER playlist, whose ids
|
||||
* its session and position rows then carry. One predicate for both: when
|
||||
* they disagree the page shows Stop for a session whose progress it drops.
|
||||
*/
|
||||
private ownsContent(
|
||||
info:
|
||||
| {
|
||||
playlistId?: string;
|
||||
contentXtreamId?: number;
|
||||
contentType?: string;
|
||||
}
|
||||
| undefined
|
||||
): boolean {
|
||||
// An absent playlist id must never match an absent current playlist.
|
||||
if (!info?.playlistId || info.contentType !== 'vod') {
|
||||
return false;
|
||||
}
|
||||
|
||||
const active = this.bindings()?.activeSource?.();
|
||||
return (
|
||||
(info.playlistId === this.xtreamStore.currentPlaylist()?.id &&
|
||||
info.contentXtreamId === this.bindings()?.vodId()) ||
|
||||
(!!active &&
|
||||
info.playlistId === active.playlistId &&
|
||||
info.contentXtreamId === active.contentXtreamId)
|
||||
);
|
||||
}
|
||||
readonly externalPrimaryLabel = computed(() => {
|
||||
const session = this.matchedExternalPlayback();
|
||||
if (!session) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const player = session.player.toUpperCase();
|
||||
switch (session.status) {
|
||||
case 'launching':
|
||||
return `Opening in ${player}...`;
|
||||
case 'opened':
|
||||
case 'playing':
|
||||
return `Stop ${player}`;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
});
|
||||
readonly externalPrimaryIcon = computed(() => {
|
||||
const session = this.matchedExternalPlayback();
|
||||
switch (session?.status) {
|
||||
case 'launching':
|
||||
return 'hourglass_top';
|
||||
case 'opened':
|
||||
case 'playing':
|
||||
return 'stop_circle';
|
||||
default:
|
||||
return 'play_arrow';
|
||||
}
|
||||
});
|
||||
readonly isExternalLaunchPending = computed(
|
||||
() => this.matchedExternalPlayback()?.status === 'launching'
|
||||
);
|
||||
readonly isExternalStopAction = computed(() => {
|
||||
const status = this.matchedExternalPlayback()?.status;
|
||||
return status === 'opened' || status === 'playing';
|
||||
});
|
||||
readonly externalPrimaryButtonState = computed(() => {
|
||||
if (this.isExternalLaunchPending()) {
|
||||
return 'launching';
|
||||
}
|
||||
|
||||
return this.isExternalStopAction() ? 'stop' : 'idle';
|
||||
});
|
||||
readonly matchedExternalPlayback = this.externalButton.matchedSession;
|
||||
readonly externalPrimaryLabel = this.externalButton.primaryLabel;
|
||||
readonly externalPrimaryIcon = this.externalButton.primaryIcon;
|
||||
readonly isExternalLaunchPending = this.externalButton.isLaunchPending;
|
||||
readonly isExternalStopAction = this.externalButton.isStopAction;
|
||||
readonly externalPrimaryButtonState = this.externalButton.buttonState;
|
||||
readonly vodPlaybackProgress = computed(() =>
|
||||
getPortalPlaybackProgressPercent(this.vodPlaybackPosition())
|
||||
);
|
||||
@@ -181,6 +106,38 @@ export class VodDetailsPlaybackService {
|
||||
inject(DestroyRef).onDestroy(() => unsubscribePositionUpdates?.());
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether this page owns the content a position update refers to.
|
||||
*
|
||||
* Multi-source can put playback on a movie in ANOTHER playlist, whose ids
|
||||
* the incoming rows then carry, so this has to agree with the Play/Stop
|
||||
* button's `alsoOwns` — otherwise the page offers Stop for a session whose
|
||||
* progress it silently drops.
|
||||
*/
|
||||
private ownsContent(
|
||||
info:
|
||||
| {
|
||||
playlistId?: string;
|
||||
contentXtreamId?: number;
|
||||
contentType?: string;
|
||||
}
|
||||
| undefined
|
||||
): boolean {
|
||||
// An absent playlist id must never match an absent current playlist.
|
||||
if (!info?.playlistId || info.contentType !== 'vod') {
|
||||
return false;
|
||||
}
|
||||
|
||||
const active = this.bindings()?.activeSource?.();
|
||||
return (
|
||||
(info.playlistId === this.xtreamStore.currentPlaylist()?.id &&
|
||||
info.contentXtreamId === this.bindings()?.vodId()) ||
|
||||
(!!active &&
|
||||
info.playlistId === active.playlistId &&
|
||||
info.contentXtreamId === active.contentXtreamId)
|
||||
);
|
||||
}
|
||||
|
||||
/** Wires the host component's context signals. Call once at construction. */
|
||||
bind(bindings: VodDetailsPlaybackBindings): void {
|
||||
this.bindings.set(bindings);
|
||||
@@ -293,57 +250,29 @@ export class VodDetailsPlaybackService {
|
||||
|
||||
closeInlinePlayer(): void {
|
||||
this.inlinePlayback.set(null);
|
||||
this.lastSaveTime = 0;
|
||||
this.resumeSettled = false;
|
||||
this.positionWriter.reset();
|
||||
}
|
||||
|
||||
private readonly positionWriter = createInlinePlaybackPositionWriter({
|
||||
playback: this.inlinePlayback,
|
||||
save: (playlistId, position) =>
|
||||
void this.playbackPositions.savePlaybackPosition(
|
||||
playlistId,
|
||||
position
|
||||
),
|
||||
onSaved: (position) => this.vodPlaybackPosition.set(position),
|
||||
});
|
||||
|
||||
/**
|
||||
* @returns whether `currentTime` can be believed yet — false while the
|
||||
* engine is still on its way to `startTime`. Multi-source switching needs
|
||||
* the same answer, and one latch has to serve both or they disagree.
|
||||
* @returns whether `currentTime` can be believed yet — see the writer's
|
||||
* resume latch. Multi-source reads the same answer before carrying a
|
||||
* timecode to another source.
|
||||
*/
|
||||
handleInlineTimeUpdate(event: {
|
||||
currentTime: number;
|
||||
duration: number;
|
||||
}): boolean {
|
||||
const playback = this.inlinePlayback();
|
||||
// Nothing to persist against and the latch never ran, so the answer is
|
||||
// whatever it already was — not a fresh "no".
|
||||
if (!playback?.contentInfo) return this.resumeSettled;
|
||||
|
||||
// A resuming engine can emit timeupdates at ~0 before it finishes
|
||||
// seeking to startTime. Writing one would overwrite the very position
|
||||
// we are resuming from — which a source switch would then inherit.
|
||||
//
|
||||
// This is a one-shot latch, not a filter: once playback has reached
|
||||
// the resume point the guard is done, so a deliberate seek backwards
|
||||
// is still saved normally.
|
||||
if (!this.resumeSettled) {
|
||||
const startTime = playback.startTime ?? 0;
|
||||
// A shorter cut can never reach a position carried into it, and
|
||||
// latching on that would suppress every save for the session.
|
||||
const reachable =
|
||||
!(event.duration > 0) || startTime < event.duration;
|
||||
if (reachable && startTime > 0 && event.currentTime < startTime - 5)
|
||||
return false;
|
||||
this.resumeSettled = true;
|
||||
}
|
||||
|
||||
const now = Date.now();
|
||||
if (now - this.lastSaveTime <= 15000) return true;
|
||||
|
||||
this.lastSaveTime = now;
|
||||
const position: PlaybackPositionData = {
|
||||
...playback.contentInfo,
|
||||
positionSeconds: Math.floor(event.currentTime),
|
||||
durationSeconds: Math.floor(event.duration),
|
||||
};
|
||||
void this.playbackPositions.savePlaybackPosition(
|
||||
playback.contentInfo.playlistId,
|
||||
position
|
||||
);
|
||||
this.vodPlaybackPosition.set(position);
|
||||
return true;
|
||||
return this.positionWriter.handleTimeUpdate(event);
|
||||
}
|
||||
|
||||
handleExternalFallbackRequest(request: PlaybackFallbackRequest): void {
|
||||
@@ -387,8 +316,7 @@ export class VodDetailsPlaybackService {
|
||||
}
|
||||
|
||||
private startPlayback(playback: ResolvedPortalPlayback): void {
|
||||
this.lastSaveTime = 0;
|
||||
this.resumeSettled = false;
|
||||
this.positionWriter.reset();
|
||||
if (this.portalPlayer.isEmbeddedPlayer()) {
|
||||
this.inlinePlayback.set(playback);
|
||||
return;
|
||||
|
||||
@@ -1,24 +1,12 @@
|
||||
import { signal } from '@angular/core';
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
import { ActivatedRoute } from '@angular/router';
|
||||
import { TranslateService } from '@ngx-translate/core';
|
||||
import { of } from 'rxjs';
|
||||
import { Location } from '@angular/common';
|
||||
import {
|
||||
PORTAL_EXTERNAL_PLAYBACK,
|
||||
PORTAL_PLAYBACK_POSITIONS,
|
||||
PORTAL_PLAYER,
|
||||
} from '@iptvnator/portal/shared/util';
|
||||
import { XtreamStore } from '@iptvnator/portal/xtream/data-access';
|
||||
import {
|
||||
XtreamCategory,
|
||||
XtreamVodDetails,
|
||||
XtreamVodStream,
|
||||
} from '@iptvnator/shared/interfaces';
|
||||
import { DownloadsService, SettingsStore } from '@iptvnator/services';
|
||||
import { MatSnackBar } from '@angular/material/snack-bar';
|
||||
import { VodDetailsPlaybackService } from './vod-details-playback.service';
|
||||
import { VodDetailsRouteComponent } from './vod-details-route.component';
|
||||
import {
|
||||
configureVodDetailsRouteTestBed,
|
||||
createVodDetailsRouteStubs,
|
||||
resetVodDetailsRouteStubs,
|
||||
silenceRouteLogging,
|
||||
} from './vod-details-route.harness';
|
||||
|
||||
/**
|
||||
* What the page CLAIMS is playing.
|
||||
@@ -29,186 +17,37 @@ import { VodDetailsRouteComponent } from './vod-details-route.component';
|
||||
*/
|
||||
describe('VodDetailsRouteComponent — source caption', () => {
|
||||
let fixture: ComponentFixture<VodDetailsRouteComponent>;
|
||||
let consoleDebugSpy: jest.SpyInstance | undefined;
|
||||
let consoleWarnSpy: jest.SpyInstance | undefined;
|
||||
const selectedItem = signal<XtreamVodDetails | null>(null);
|
||||
const isLoadingDetails = signal(false);
|
||||
const detailsError = signal<string | null>(null);
|
||||
const isFavorite = signal(false);
|
||||
const currentPlaylist = signal<{
|
||||
id: string;
|
||||
userAgent?: string;
|
||||
referrer?: string;
|
||||
origin?: string;
|
||||
} | null>(null);
|
||||
const vodStreams = signal<Partial<XtreamVodStream>[]>([]);
|
||||
const vodCategories = signal<Partial<XtreamCategory>[]>([]);
|
||||
const fetchVodDetailsWithMetadata = jest.fn();
|
||||
const checkFavoriteStatus = jest.fn();
|
||||
const setSelectedItem = jest.fn();
|
||||
const toggleFavorite = jest.fn();
|
||||
const constructVodStreamUrl = jest
|
||||
.fn()
|
||||
.mockReturnValue('http://example.com/movie/650020.mp4');
|
||||
const addRecentItem = jest.fn();
|
||||
const downloads = signal([]);
|
||||
const getPlaybackPosition = jest.fn().mockResolvedValue(null);
|
||||
const activeSession = signal<unknown>(null);
|
||||
const closeSession = jest.fn();
|
||||
let restoreLogging: (() => void) | undefined;
|
||||
const stubs = createVodDetailsRouteStubs();
|
||||
const {
|
||||
activeSession,
|
||||
addRecentItem,
|
||||
checkFavoriteStatus,
|
||||
closeSession,
|
||||
constructVodStreamUrl,
|
||||
currentPlaylist,
|
||||
detailsError,
|
||||
fetchVodDetailsWithMetadata,
|
||||
getPlaybackPosition,
|
||||
isFavorite,
|
||||
isLoadingDetails,
|
||||
selectedItem,
|
||||
setSelectedItem,
|
||||
toggleFavorite,
|
||||
vodCategories,
|
||||
vodStreams,
|
||||
} = stubs;
|
||||
|
||||
beforeEach(async () => {
|
||||
const consoleDebug = console.debug.bind(console);
|
||||
const consoleWarn = console.warn.bind(console);
|
||||
consoleDebugSpy = jest
|
||||
.spyOn(console, 'debug')
|
||||
.mockImplementation((...args: unknown[]) => {
|
||||
if (
|
||||
args[0] === '[VodDetailsRoute]' ||
|
||||
args[0] === '[VodDetailsPlayback]'
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
consoleDebug(...args);
|
||||
});
|
||||
consoleWarnSpy = jest
|
||||
.spyOn(console, 'warn')
|
||||
.mockImplementation((...args: unknown[]) => {
|
||||
if (
|
||||
args[0] === '[VodDetailsRoute]' &&
|
||||
args[1] === 'Deferring VOD details init: playlist not ready'
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
consoleWarn(...args);
|
||||
});
|
||||
|
||||
selectedItem.set(null);
|
||||
isLoadingDetails.set(false);
|
||||
detailsError.set(null);
|
||||
isFavorite.set(false);
|
||||
currentPlaylist.set(null);
|
||||
vodStreams.set([]);
|
||||
vodCategories.set([]);
|
||||
fetchVodDetailsWithMetadata.mockClear();
|
||||
checkFavoriteStatus.mockClear();
|
||||
setSelectedItem.mockClear();
|
||||
toggleFavorite.mockClear();
|
||||
constructVodStreamUrl.mockClear();
|
||||
addRecentItem.mockClear();
|
||||
getPlaybackPosition.mockClear();
|
||||
activeSession.set(null);
|
||||
closeSession.mockClear();
|
||||
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [VodDetailsRouteComponent],
|
||||
providers: [
|
||||
{
|
||||
provide: ActivatedRoute,
|
||||
useValue: {
|
||||
params: of({
|
||||
vodId: '650020',
|
||||
categoryId: '235',
|
||||
}),
|
||||
snapshot: {
|
||||
params: {
|
||||
vodId: '650020',
|
||||
categoryId: '235',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: TranslateService,
|
||||
useValue: {
|
||||
instant: (key: string) => key,
|
||||
get: (key: string) => of(key),
|
||||
stream: (key: string) => of(key),
|
||||
onLangChange: of(null),
|
||||
onTranslationChange: of(null),
|
||||
onDefaultLangChange: of(null),
|
||||
currentLang: 'en',
|
||||
defaultLang: 'en',
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: XtreamStore,
|
||||
useValue: {
|
||||
selectedItem,
|
||||
isLoadingDetails,
|
||||
detailsError,
|
||||
isFavorite,
|
||||
currentPlaylist,
|
||||
vodStreams,
|
||||
vodCategories,
|
||||
fetchVodDetailsWithMetadata,
|
||||
checkFavoriteStatus,
|
||||
setSelectedItem,
|
||||
toggleFavorite,
|
||||
constructVodStreamUrl,
|
||||
addRecentItem,
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: SettingsStore,
|
||||
useValue: {
|
||||
theme: signal('dark'),
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: DownloadsService,
|
||||
useValue: {
|
||||
isAvailable: signal(false),
|
||||
downloads,
|
||||
isDownloaded: jest.fn().mockReturnValue(false),
|
||||
isDownloading: jest.fn().mockReturnValue(false),
|
||||
startDownload: jest.fn(),
|
||||
getDownloadedFilePath: jest.fn(),
|
||||
playDownload: jest.fn(),
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: PORTAL_EXTERNAL_PLAYBACK,
|
||||
useValue: { activeSession, closeSession },
|
||||
},
|
||||
{
|
||||
provide: PORTAL_PLAYBACK_POSITIONS,
|
||||
useValue: {
|
||||
getPlaybackPosition,
|
||||
savePlaybackPosition: jest
|
||||
.fn()
|
||||
.mockResolvedValue(undefined),
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: PORTAL_PLAYER,
|
||||
useValue: {
|
||||
isEmbeddedPlayer: jest.fn().mockReturnValue(false),
|
||||
openResolvedPlayback: jest.fn(),
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: MatSnackBar,
|
||||
useValue: {
|
||||
open: jest.fn(),
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: Location,
|
||||
useValue: {
|
||||
back: jest.fn(),
|
||||
},
|
||||
},
|
||||
],
|
||||
}).compileComponents();
|
||||
restoreLogging = silenceRouteLogging();
|
||||
resetVodDetailsRouteStubs(stubs);
|
||||
await configureVodDetailsRouteTestBed(stubs);
|
||||
|
||||
fixture = TestBed.createComponent(VodDetailsRouteComponent);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
consoleDebugSpy?.mockRestore();
|
||||
consoleWarnSpy?.mockRestore();
|
||||
restoreLogging?.();
|
||||
});
|
||||
|
||||
/**
|
||||
|
||||
+85
-191
@@ -1,24 +1,13 @@
|
||||
import { signal } from '@angular/core';
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
import { ActivatedRoute } from '@angular/router';
|
||||
import { TranslateService } from '@ngx-translate/core';
|
||||
import { of } from 'rxjs';
|
||||
import { Location } from '@angular/common';
|
||||
import {
|
||||
PORTAL_EXTERNAL_PLAYBACK,
|
||||
PORTAL_PLAYBACK_POSITIONS,
|
||||
PORTAL_PLAYER,
|
||||
} from '@iptvnator/portal/shared/util';
|
||||
import { XtreamStore } from '@iptvnator/portal/xtream/data-access';
|
||||
import {
|
||||
XtreamCategory,
|
||||
XtreamVodDetails,
|
||||
XtreamVodStream,
|
||||
} from '@iptvnator/shared/interfaces';
|
||||
import { DownloadsService, SettingsStore } from '@iptvnator/services';
|
||||
import { MatSnackBar } from '@angular/material/snack-bar';
|
||||
import { VideoPlayer } from '@iptvnator/shared/interfaces';
|
||||
import { VodDetailsPlaybackService } from './vod-details-playback.service';
|
||||
import { VodDetailsRouteComponent } from './vod-details-route.component';
|
||||
import {
|
||||
configureVodDetailsRouteTestBed,
|
||||
createVodDetailsRouteStubs,
|
||||
resetVodDetailsRouteStubs,
|
||||
silenceRouteLogging,
|
||||
} from './vod-details-route.harness';
|
||||
|
||||
/**
|
||||
* What the primary button and the resume point do, as opposed to what the
|
||||
@@ -27,186 +16,30 @@ import { VodDetailsRouteComponent } from './vod-details-route.component';
|
||||
*/
|
||||
describe('VodDetailsRouteComponent — playback actions', () => {
|
||||
let fixture: ComponentFixture<VodDetailsRouteComponent>;
|
||||
let consoleDebugSpy: jest.SpyInstance | undefined;
|
||||
let consoleWarnSpy: jest.SpyInstance | undefined;
|
||||
const selectedItem = signal<XtreamVodDetails | null>(null);
|
||||
const isLoadingDetails = signal(false);
|
||||
const detailsError = signal<string | null>(null);
|
||||
const isFavorite = signal(false);
|
||||
const currentPlaylist = signal<{
|
||||
id: string;
|
||||
userAgent?: string;
|
||||
referrer?: string;
|
||||
origin?: string;
|
||||
} | null>(null);
|
||||
const vodStreams = signal<Partial<XtreamVodStream>[]>([]);
|
||||
const vodCategories = signal<Partial<XtreamCategory>[]>([]);
|
||||
const fetchVodDetailsWithMetadata = jest.fn();
|
||||
const checkFavoriteStatus = jest.fn();
|
||||
const setSelectedItem = jest.fn();
|
||||
const toggleFavorite = jest.fn();
|
||||
const constructVodStreamUrl = jest
|
||||
.fn()
|
||||
.mockReturnValue('http://example.com/movie/650020.mp4');
|
||||
const addRecentItem = jest.fn();
|
||||
const downloads = signal([]);
|
||||
const getPlaybackPosition = jest.fn().mockResolvedValue(null);
|
||||
const activeSession = signal<unknown>(null);
|
||||
const closeSession = jest.fn();
|
||||
let restoreLogging: (() => void) | undefined;
|
||||
const stubs = createVodDetailsRouteStubs();
|
||||
const {
|
||||
currentPlaylist,
|
||||
activeSession,
|
||||
addRecentItem,
|
||||
closeSession,
|
||||
getPlaybackPosition,
|
||||
selectedItem,
|
||||
selectedPlayer,
|
||||
snackBarOpen,
|
||||
updateSettings,
|
||||
} = stubs;
|
||||
|
||||
beforeEach(async () => {
|
||||
const consoleDebug = console.debug.bind(console);
|
||||
const consoleWarn = console.warn.bind(console);
|
||||
consoleDebugSpy = jest
|
||||
.spyOn(console, 'debug')
|
||||
.mockImplementation((...args: unknown[]) => {
|
||||
if (
|
||||
args[0] === '[VodDetailsRoute]' ||
|
||||
args[0] === '[VodDetailsPlayback]'
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
consoleDebug(...args);
|
||||
});
|
||||
consoleWarnSpy = jest
|
||||
.spyOn(console, 'warn')
|
||||
.mockImplementation((...args: unknown[]) => {
|
||||
if (
|
||||
args[0] === '[VodDetailsRoute]' &&
|
||||
args[1] === 'Deferring VOD details init: playlist not ready'
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
consoleWarn(...args);
|
||||
});
|
||||
|
||||
selectedItem.set(null);
|
||||
isLoadingDetails.set(false);
|
||||
detailsError.set(null);
|
||||
isFavorite.set(false);
|
||||
currentPlaylist.set(null);
|
||||
vodStreams.set([]);
|
||||
vodCategories.set([]);
|
||||
fetchVodDetailsWithMetadata.mockClear();
|
||||
checkFavoriteStatus.mockClear();
|
||||
setSelectedItem.mockClear();
|
||||
toggleFavorite.mockClear();
|
||||
constructVodStreamUrl.mockClear();
|
||||
addRecentItem.mockClear();
|
||||
getPlaybackPosition.mockClear();
|
||||
activeSession.set(null);
|
||||
closeSession.mockClear();
|
||||
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [VodDetailsRouteComponent],
|
||||
providers: [
|
||||
{
|
||||
provide: ActivatedRoute,
|
||||
useValue: {
|
||||
params: of({
|
||||
vodId: '650020',
|
||||
categoryId: '235',
|
||||
}),
|
||||
snapshot: {
|
||||
params: {
|
||||
vodId: '650020',
|
||||
categoryId: '235',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: TranslateService,
|
||||
useValue: {
|
||||
instant: (key: string) => key,
|
||||
get: (key: string) => of(key),
|
||||
stream: (key: string) => of(key),
|
||||
onLangChange: of(null),
|
||||
onTranslationChange: of(null),
|
||||
onDefaultLangChange: of(null),
|
||||
currentLang: 'en',
|
||||
defaultLang: 'en',
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: XtreamStore,
|
||||
useValue: {
|
||||
selectedItem,
|
||||
isLoadingDetails,
|
||||
detailsError,
|
||||
isFavorite,
|
||||
currentPlaylist,
|
||||
vodStreams,
|
||||
vodCategories,
|
||||
fetchVodDetailsWithMetadata,
|
||||
checkFavoriteStatus,
|
||||
setSelectedItem,
|
||||
toggleFavorite,
|
||||
constructVodStreamUrl,
|
||||
addRecentItem,
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: SettingsStore,
|
||||
useValue: {
|
||||
theme: signal('dark'),
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: DownloadsService,
|
||||
useValue: {
|
||||
isAvailable: signal(false),
|
||||
downloads,
|
||||
isDownloaded: jest.fn().mockReturnValue(false),
|
||||
isDownloading: jest.fn().mockReturnValue(false),
|
||||
startDownload: jest.fn(),
|
||||
getDownloadedFilePath: jest.fn(),
|
||||
playDownload: jest.fn(),
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: PORTAL_EXTERNAL_PLAYBACK,
|
||||
useValue: { activeSession, closeSession },
|
||||
},
|
||||
{
|
||||
provide: PORTAL_PLAYBACK_POSITIONS,
|
||||
useValue: {
|
||||
getPlaybackPosition,
|
||||
savePlaybackPosition: jest
|
||||
.fn()
|
||||
.mockResolvedValue(undefined),
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: PORTAL_PLAYER,
|
||||
useValue: {
|
||||
isEmbeddedPlayer: jest.fn().mockReturnValue(false),
|
||||
openResolvedPlayback: jest.fn(),
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: MatSnackBar,
|
||||
useValue: {
|
||||
open: jest.fn(),
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: Location,
|
||||
useValue: {
|
||||
back: jest.fn(),
|
||||
},
|
||||
},
|
||||
],
|
||||
}).compileComponents();
|
||||
restoreLogging = silenceRouteLogging();
|
||||
resetVodDetailsRouteStubs(stubs);
|
||||
await configureVodDetailsRouteTestBed(stubs);
|
||||
|
||||
fixture = TestBed.createComponent(VodDetailsRouteComponent);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
consoleDebugSpy?.mockRestore();
|
||||
consoleWarnSpy?.mockRestore();
|
||||
restoreLogging?.();
|
||||
});
|
||||
|
||||
/**
|
||||
@@ -367,4 +200,65 @@ describe('VodDetailsRouteComponent — playback actions', () => {
|
||||
component.handleInlineTimeUpdate({ currentTime: 12, duration: 7744 });
|
||||
expect(reported).toHaveBeenLastCalledWith(12);
|
||||
});
|
||||
|
||||
describe('auto-failover toggle', () => {
|
||||
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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -197,6 +197,7 @@
|
||||
[matchKind]="multiSource.matchKind()"
|
||||
[count]="multiSource.alternativeCount()"
|
||||
[autoFailoverEnabled]="multiSource.autoFailoverEnabled()"
|
||||
[autoFailoverSupported]="autoFailoverSupported()"
|
||||
(playRequested)="playFromSource($event)"
|
||||
(pinRequested)="pinSource($event)"
|
||||
(checkRequested)="checkSource($event)"
|
||||
@@ -287,6 +288,7 @@
|
||||
[sourcesAutoFailoverEnabled]="
|
||||
multiSource.autoFailoverEnabled()
|
||||
"
|
||||
[sourcesAutoFailoverSupported]="autoFailoverSupported()"
|
||||
(sourcesAutoFailoverToggled)="setAutoFailover($event)"
|
||||
(alternativeSourceRequested)="playFromSource($event)"
|
||||
(sourcePinRequested)="pinSource($event)"
|
||||
|
||||
+30
-191
@@ -1,209 +1,48 @@
|
||||
import { signal } from '@angular/core';
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
import { By } from '@angular/platform-browser';
|
||||
import { ActivatedRoute } from '@angular/router';
|
||||
import { TranslateService } from '@ngx-translate/core';
|
||||
import { of } from 'rxjs';
|
||||
import { Location } from '@angular/common';
|
||||
import { ContentHeroComponent } from '@iptvnator/ui/components';
|
||||
import {
|
||||
PORTAL_EXTERNAL_PLAYBACK,
|
||||
PORTAL_PLAYBACK_POSITIONS,
|
||||
PORTAL_PLAYER,
|
||||
} from '@iptvnator/portal/shared/util';
|
||||
import { XtreamStore } from '@iptvnator/portal/xtream/data-access';
|
||||
import {
|
||||
XtreamCategory,
|
||||
XtreamVodDetails,
|
||||
XtreamVodStream,
|
||||
} from '@iptvnator/shared/interfaces';
|
||||
import { DownloadsService, SettingsStore } from '@iptvnator/services';
|
||||
import { MatSnackBar } from '@angular/material/snack-bar';
|
||||
import { VodDetailsPlaybackService } from './vod-details-playback.service';
|
||||
import { VodDetailsRouteComponent } from './vod-details-route.component';
|
||||
import {
|
||||
configureVodDetailsRouteTestBed,
|
||||
createVodDetailsRouteStubs,
|
||||
resetVodDetailsRouteStubs,
|
||||
silenceRouteLogging,
|
||||
} from './vod-details-route.harness';
|
||||
|
||||
describe('VodDetailsRouteComponent', () => {
|
||||
let fixture: ComponentFixture<VodDetailsRouteComponent>;
|
||||
let consoleDebugSpy: jest.SpyInstance | undefined;
|
||||
let consoleWarnSpy: jest.SpyInstance | undefined;
|
||||
const selectedItem = signal<XtreamVodDetails | null>(null);
|
||||
const isLoadingDetails = signal(false);
|
||||
const detailsError = signal<string | null>(null);
|
||||
const isFavorite = signal(false);
|
||||
const currentPlaylist = signal<{
|
||||
id: string;
|
||||
userAgent?: string;
|
||||
referrer?: string;
|
||||
origin?: string;
|
||||
} | null>(null);
|
||||
const vodStreams = signal<Partial<XtreamVodStream>[]>([]);
|
||||
const vodCategories = signal<Partial<XtreamCategory>[]>([]);
|
||||
const fetchVodDetailsWithMetadata = jest.fn();
|
||||
const checkFavoriteStatus = jest.fn();
|
||||
const setSelectedItem = jest.fn();
|
||||
const toggleFavorite = jest.fn();
|
||||
const constructVodStreamUrl = jest
|
||||
.fn()
|
||||
.mockReturnValue('http://example.com/movie/650020.mp4');
|
||||
const addRecentItem = jest.fn();
|
||||
const downloads = signal([]);
|
||||
const getPlaybackPosition = jest.fn().mockResolvedValue(null);
|
||||
const activeSession = signal<unknown>(null);
|
||||
const closeSession = jest.fn();
|
||||
let restoreLogging: (() => void) | undefined;
|
||||
const stubs = createVodDetailsRouteStubs();
|
||||
const {
|
||||
activeSession,
|
||||
addRecentItem,
|
||||
checkFavoriteStatus,
|
||||
closeSession,
|
||||
constructVodStreamUrl,
|
||||
currentPlaylist,
|
||||
detailsError,
|
||||
fetchVodDetailsWithMetadata,
|
||||
getPlaybackPosition,
|
||||
isFavorite,
|
||||
isLoadingDetails,
|
||||
selectedItem,
|
||||
setSelectedItem,
|
||||
toggleFavorite,
|
||||
vodCategories,
|
||||
vodStreams,
|
||||
} = stubs;
|
||||
|
||||
beforeEach(async () => {
|
||||
const consoleDebug = console.debug.bind(console);
|
||||
const consoleWarn = console.warn.bind(console);
|
||||
consoleDebugSpy = jest
|
||||
.spyOn(console, 'debug')
|
||||
.mockImplementation((...args: unknown[]) => {
|
||||
if (
|
||||
args[0] === '[VodDetailsRoute]' ||
|
||||
args[0] === '[VodDetailsPlayback]'
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
consoleDebug(...args);
|
||||
});
|
||||
consoleWarnSpy = jest
|
||||
.spyOn(console, 'warn')
|
||||
.mockImplementation((...args: unknown[]) => {
|
||||
if (
|
||||
args[0] === '[VodDetailsRoute]' &&
|
||||
args[1] === 'Deferring VOD details init: playlist not ready'
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
consoleWarn(...args);
|
||||
});
|
||||
|
||||
selectedItem.set(null);
|
||||
isLoadingDetails.set(false);
|
||||
detailsError.set(null);
|
||||
isFavorite.set(false);
|
||||
currentPlaylist.set(null);
|
||||
vodStreams.set([]);
|
||||
vodCategories.set([]);
|
||||
fetchVodDetailsWithMetadata.mockClear();
|
||||
checkFavoriteStatus.mockClear();
|
||||
setSelectedItem.mockClear();
|
||||
toggleFavorite.mockClear();
|
||||
constructVodStreamUrl.mockClear();
|
||||
addRecentItem.mockClear();
|
||||
getPlaybackPosition.mockClear();
|
||||
activeSession.set(null);
|
||||
closeSession.mockClear();
|
||||
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [VodDetailsRouteComponent],
|
||||
providers: [
|
||||
{
|
||||
provide: ActivatedRoute,
|
||||
useValue: {
|
||||
params: of({
|
||||
vodId: '650020',
|
||||
categoryId: '235',
|
||||
}),
|
||||
snapshot: {
|
||||
params: {
|
||||
vodId: '650020',
|
||||
categoryId: '235',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: TranslateService,
|
||||
useValue: {
|
||||
instant: (key: string) => key,
|
||||
get: (key: string) => of(key),
|
||||
stream: (key: string) => of(key),
|
||||
onLangChange: of(null),
|
||||
onTranslationChange: of(null),
|
||||
onDefaultLangChange: of(null),
|
||||
currentLang: 'en',
|
||||
defaultLang: 'en',
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: XtreamStore,
|
||||
useValue: {
|
||||
selectedItem,
|
||||
isLoadingDetails,
|
||||
detailsError,
|
||||
isFavorite,
|
||||
currentPlaylist,
|
||||
vodStreams,
|
||||
vodCategories,
|
||||
fetchVodDetailsWithMetadata,
|
||||
checkFavoriteStatus,
|
||||
setSelectedItem,
|
||||
toggleFavorite,
|
||||
constructVodStreamUrl,
|
||||
addRecentItem,
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: SettingsStore,
|
||||
useValue: {
|
||||
theme: signal('dark'),
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: DownloadsService,
|
||||
useValue: {
|
||||
isAvailable: signal(false),
|
||||
downloads,
|
||||
isDownloaded: jest.fn().mockReturnValue(false),
|
||||
isDownloading: jest.fn().mockReturnValue(false),
|
||||
startDownload: jest.fn(),
|
||||
getDownloadedFilePath: jest.fn(),
|
||||
playDownload: jest.fn(),
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: PORTAL_EXTERNAL_PLAYBACK,
|
||||
useValue: { activeSession, closeSession },
|
||||
},
|
||||
{
|
||||
provide: PORTAL_PLAYBACK_POSITIONS,
|
||||
useValue: {
|
||||
getPlaybackPosition,
|
||||
savePlaybackPosition: jest
|
||||
.fn()
|
||||
.mockResolvedValue(undefined),
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: PORTAL_PLAYER,
|
||||
useValue: {
|
||||
isEmbeddedPlayer: jest.fn().mockReturnValue(false),
|
||||
openResolvedPlayback: jest.fn(),
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: MatSnackBar,
|
||||
useValue: {
|
||||
open: jest.fn(),
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: Location,
|
||||
useValue: {
|
||||
back: jest.fn(),
|
||||
},
|
||||
},
|
||||
],
|
||||
}).compileComponents();
|
||||
restoreLogging = silenceRouteLogging();
|
||||
resetVodDetailsRouteStubs(stubs);
|
||||
await configureVodDetailsRouteTestBed(stubs);
|
||||
|
||||
fixture = TestBed.createComponent(VodDetailsRouteComponent);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
consoleDebugSpy?.mockRestore();
|
||||
consoleWarnSpy?.mockRestore();
|
||||
restoreLogging?.();
|
||||
});
|
||||
|
||||
it('renders an informational fallback without playback controls when Xtream returns empty metadata', () => {
|
||||
|
||||
@@ -40,8 +40,9 @@ import {
|
||||
} from '@iptvnator/services';
|
||||
import {
|
||||
getXtreamVodInfo,
|
||||
playlistDisplayLabel,
|
||||
normalizeTitleKeys,
|
||||
playlistDisplayLabel,
|
||||
reportsPlaybackFailures,
|
||||
TmdbEnrichedCastMember,
|
||||
XtreamCategory,
|
||||
XtreamVodDetails,
|
||||
@@ -606,8 +607,31 @@ export class VodDetailsRouteComponent implements OnInit, OnDestroy {
|
||||
void this.multiSource.check(sourceId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Only the built-in web players raise a playback diagnostic, so on MPV,
|
||||
* VLC or Embedded MPV nothing would ever call `onPlaybackFailed()`. The
|
||||
* toggle is hidden there rather than left promising a switch that cannot
|
||||
* happen.
|
||||
*/
|
||||
readonly autoFailoverSupported = computed(() =>
|
||||
reportsPlaybackFailures(this.settingsStore.player?.())
|
||||
);
|
||||
|
||||
setAutoFailover(enabled: boolean): void {
|
||||
this.settingsStore.updateSettings({ vodAutoFailover: enabled });
|
||||
// `updateSettings` patches memory first and REJECTS if the write
|
||||
// fails, so without this the toggle looks saved, reverts on restart,
|
||||
// and the rejection surfaces only as an unhandled promise.
|
||||
this.settingsStore
|
||||
.updateSettings({ vodAutoFailover: enabled })
|
||||
.catch(() =>
|
||||
this.snackBar.open(
|
||||
this.translateService.instant(
|
||||
'SETTINGS.SETTINGS_SAVE_FAILED'
|
||||
),
|
||||
this.translateService.instant('CLOSE'),
|
||||
{ duration: 10000 }
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,223 @@
|
||||
import { Location } from '@angular/common';
|
||||
import { signal } from '@angular/core';
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { MatSnackBar } from '@angular/material/snack-bar';
|
||||
import { ActivatedRoute } from '@angular/router';
|
||||
import {
|
||||
PORTAL_EXTERNAL_PLAYBACK,
|
||||
PORTAL_PLAYBACK_POSITIONS,
|
||||
PORTAL_PLAYER,
|
||||
} from '@iptvnator/portal/shared/util';
|
||||
import { XtreamStore } from '@iptvnator/portal/xtream/data-access';
|
||||
import { DownloadsService, SettingsStore } from '@iptvnator/services';
|
||||
import {
|
||||
VideoPlayer,
|
||||
XtreamCategory,
|
||||
XtreamVodDetails,
|
||||
XtreamVodStream,
|
||||
} from '@iptvnator/shared/interfaces';
|
||||
import { TranslateService } from '@ngx-translate/core';
|
||||
import { of } from 'rxjs';
|
||||
import { VodDetailsRouteComponent } from './vod-details-route.component';
|
||||
|
||||
/**
|
||||
* The TestBed every VOD-details route spec needs.
|
||||
*
|
||||
* The route pulls in the store, both playback ports, downloads, settings and
|
||||
* the router, so standing it up costs ~150 lines. Three specs carried their
|
||||
* own near-identical copy of that, which is how they drifted and how each one
|
||||
* ran into the file-size rule. One harness, one set of stubs.
|
||||
*/
|
||||
|
||||
/** Every stub the harness installs, so specs can drive and assert on them. */
|
||||
export function createVodDetailsRouteStubs() {
|
||||
return {
|
||||
selectedItem: signal<XtreamVodDetails | null>(null),
|
||||
isLoadingDetails: signal(false),
|
||||
detailsError: signal<string | null>(null),
|
||||
isFavorite: signal(false),
|
||||
currentPlaylist: signal<{
|
||||
id: string;
|
||||
userAgent?: string;
|
||||
referrer?: string;
|
||||
origin?: string;
|
||||
} | null>(null),
|
||||
vodStreams: signal<Partial<XtreamVodStream>[]>([]),
|
||||
vodCategories: signal<Partial<XtreamCategory>[]>([]),
|
||||
fetchVodDetailsWithMetadata: jest.fn(),
|
||||
checkFavoriteStatus: jest.fn(),
|
||||
setSelectedItem: jest.fn(),
|
||||
toggleFavorite: jest.fn(),
|
||||
constructVodStreamUrl: jest
|
||||
.fn()
|
||||
.mockReturnValue('http://example.com/movie/650020.mp4'),
|
||||
addRecentItem: jest.fn(),
|
||||
downloads: signal([]),
|
||||
getPlaybackPosition: jest.fn().mockResolvedValue(null),
|
||||
savePlaybackPosition: jest.fn().mockResolvedValue(undefined),
|
||||
activeSession: signal<unknown>(null),
|
||||
closeSession: jest.fn(),
|
||||
isEmbeddedPlayer: jest.fn().mockReturnValue(false),
|
||||
openResolvedPlayback: jest.fn(),
|
||||
snackBarOpen: jest.fn(),
|
||||
locationBack: jest.fn(),
|
||||
selectedPlayer: signal<VideoPlayer>(VideoPlayer.Html5Player),
|
||||
updateSettings: jest.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
}
|
||||
|
||||
export type VodDetailsRouteStubs = ReturnType<
|
||||
typeof createVodDetailsRouteStubs
|
||||
>;
|
||||
|
||||
/** Back to the state a fresh `beforeEach` expects. */
|
||||
export function resetVodDetailsRouteStubs(stubs: VodDetailsRouteStubs): void {
|
||||
stubs.selectedItem.set(null);
|
||||
stubs.isLoadingDetails.set(false);
|
||||
stubs.detailsError.set(null);
|
||||
stubs.isFavorite.set(false);
|
||||
stubs.currentPlaylist.set(null);
|
||||
stubs.vodStreams.set([]);
|
||||
stubs.vodCategories.set([]);
|
||||
stubs.activeSession.set(null);
|
||||
stubs.selectedPlayer.set(VideoPlayer.Html5Player);
|
||||
stubs.updateSettings.mockClear().mockResolvedValue(undefined);
|
||||
|
||||
for (const value of Object.values(stubs)) {
|
||||
if (jest.isMockFunction(value) && value !== stubs.updateSettings) {
|
||||
value.mockClear();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Silences the route's own debug chatter without hiding anything else — a
|
||||
* genuine warning from the component under test must still reach the console.
|
||||
*/
|
||||
export function silenceRouteLogging(): () => void {
|
||||
const consoleDebug = console.debug.bind(console);
|
||||
const consoleWarn = console.warn.bind(console);
|
||||
const debugSpy = jest
|
||||
.spyOn(console, 'debug')
|
||||
.mockImplementation((...args: unknown[]) => {
|
||||
if (
|
||||
args[0] === '[VodDetailsRoute]' ||
|
||||
args[0] === '[VodDetailsPlayback]'
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
consoleDebug(...args);
|
||||
});
|
||||
const warnSpy = jest
|
||||
.spyOn(console, 'warn')
|
||||
.mockImplementation((...args: unknown[]) => {
|
||||
if (
|
||||
args[0] === '[VodDetailsRoute]' &&
|
||||
args[1] === 'Deferring VOD details init: playlist not ready'
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
consoleWarn(...args);
|
||||
});
|
||||
|
||||
return () => {
|
||||
debugSpy.mockRestore();
|
||||
warnSpy.mockRestore();
|
||||
};
|
||||
}
|
||||
|
||||
export async function configureVodDetailsRouteTestBed(
|
||||
stubs: VodDetailsRouteStubs
|
||||
): Promise<void> {
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [VodDetailsRouteComponent],
|
||||
providers: [
|
||||
{
|
||||
provide: ActivatedRoute,
|
||||
useValue: {
|
||||
params: of({ vodId: '650020', categoryId: '235' }),
|
||||
snapshot: {
|
||||
params: { vodId: '650020', categoryId: '235' },
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: TranslateService,
|
||||
useValue: {
|
||||
instant: (key: string) => key,
|
||||
get: (key: string) => of(key),
|
||||
stream: (key: string) => of(key),
|
||||
onLangChange: of(null),
|
||||
onTranslationChange: of(null),
|
||||
onDefaultLangChange: of(null),
|
||||
currentLang: 'en',
|
||||
defaultLang: 'en',
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: XtreamStore,
|
||||
useValue: {
|
||||
selectedItem: stubs.selectedItem,
|
||||
isLoadingDetails: stubs.isLoadingDetails,
|
||||
detailsError: stubs.detailsError,
|
||||
isFavorite: stubs.isFavorite,
|
||||
currentPlaylist: stubs.currentPlaylist,
|
||||
vodStreams: stubs.vodStreams,
|
||||
vodCategories: stubs.vodCategories,
|
||||
fetchVodDetailsWithMetadata:
|
||||
stubs.fetchVodDetailsWithMetadata,
|
||||
checkFavoriteStatus: stubs.checkFavoriteStatus,
|
||||
setSelectedItem: stubs.setSelectedItem,
|
||||
toggleFavorite: stubs.toggleFavorite,
|
||||
constructVodStreamUrl: stubs.constructVodStreamUrl,
|
||||
addRecentItem: stubs.addRecentItem,
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: SettingsStore,
|
||||
useValue: {
|
||||
theme: signal('dark'),
|
||||
player: stubs.selectedPlayer,
|
||||
updateSettings: stubs.updateSettings,
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: DownloadsService,
|
||||
useValue: {
|
||||
isAvailable: signal(false),
|
||||
downloads: stubs.downloads,
|
||||
isDownloaded: jest.fn().mockReturnValue(false),
|
||||
isDownloading: jest.fn().mockReturnValue(false),
|
||||
startDownload: jest.fn(),
|
||||
getDownloadedFilePath: jest.fn(),
|
||||
playDownload: jest.fn(),
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: PORTAL_EXTERNAL_PLAYBACK,
|
||||
useValue: {
|
||||
activeSession: stubs.activeSession,
|
||||
closeSession: stubs.closeSession,
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: PORTAL_PLAYBACK_POSITIONS,
|
||||
useValue: {
|
||||
getPlaybackPosition: stubs.getPlaybackPosition,
|
||||
savePlaybackPosition: stubs.savePlaybackPosition,
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: PORTAL_PLAYER,
|
||||
useValue: {
|
||||
isEmbeddedPlayer: stubs.isEmbeddedPlayer,
|
||||
openResolvedPlayback: stubs.openResolvedPlayback,
|
||||
},
|
||||
},
|
||||
{ provide: MatSnackBar, useValue: { open: stubs.snackBarOpen } },
|
||||
{ provide: Location, useValue: { back: stubs.locationBack } },
|
||||
],
|
||||
}).compileComponents();
|
||||
}
|
||||
@@ -15,6 +15,25 @@ export enum VideoPlayer {
|
||||
ArtPlayer = 'artplayer',
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a failed stream on this player reaches the app at all.
|
||||
*
|
||||
* Only the built-in web players raise a playback diagnostic: Embedded MPV has
|
||||
* its diagnostics suppressed, and MPV/VLC play outside the app entirely. On
|
||||
* those, anything keyed off a playback failure — VOD auto-failover above all —
|
||||
* can never fire, so offering the control there promises a feature that does
|
||||
* nothing.
|
||||
*/
|
||||
export function reportsPlaybackFailures(
|
||||
player: VideoPlayer | null | undefined
|
||||
): boolean {
|
||||
return (
|
||||
player === VideoPlayer.VideoJs ||
|
||||
player === VideoPlayer.Html5Player ||
|
||||
player === VideoPlayer.ArtPlayer
|
||||
);
|
||||
}
|
||||
|
||||
export enum StartupBehavior {
|
||||
FirstView = 'first-view',
|
||||
RestoreLastView = 'restore-last-view',
|
||||
|
||||
@@ -39,6 +39,7 @@
|
||||
[title]="title()"
|
||||
[matchKind]="matchKind()"
|
||||
[autoFailoverEnabled]="autoFailoverEnabled()"
|
||||
[autoFailoverSupported]="autoFailoverSupported()"
|
||||
[resumeLabel]="resumeLabel()"
|
||||
[showPin]="showPin()"
|
||||
(playRequested)="onPlay($event)"
|
||||
|
||||
@@ -42,6 +42,8 @@ export class VodSourcesChipComponent {
|
||||
readonly title = input('');
|
||||
readonly matchKind = input<VodSourceMatchKind>('title-year');
|
||||
readonly autoFailoverEnabled = input(false);
|
||||
/** See `VodSourcesMenuComponent.autoFailoverSupported`. */
|
||||
readonly autoFailoverSupported = input(true);
|
||||
readonly resumeLabel = input<string | null>(null);
|
||||
readonly showPin = input(true);
|
||||
/** Falls back to the number of sources when the host does not override it. */
|
||||
|
||||
@@ -120,16 +120,20 @@
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="sources-menu__footer">
|
||||
<span class="sources-menu__footer-label">{{
|
||||
'PORTALS.MULTI_SOURCE.AUTO_FAILOVER' | translate
|
||||
}}</span>
|
||||
<mat-slide-toggle
|
||||
class="sources-menu__toggle"
|
||||
[checked]="autoFailoverEnabled()"
|
||||
(change)="onAutoFailoverChange($event)"
|
||||
(click)="$event.stopPropagation()"
|
||||
[attr.aria-label]="'PORTALS.MULTI_SOURCE.AUTO_FAILOVER' | translate"
|
||||
/>
|
||||
</div>
|
||||
@if (autoFailoverSupported()) {
|
||||
<div class="sources-menu__footer">
|
||||
<span class="sources-menu__footer-label">{{
|
||||
'PORTALS.MULTI_SOURCE.AUTO_FAILOVER' | translate
|
||||
}}</span>
|
||||
<mat-slide-toggle
|
||||
class="sources-menu__toggle"
|
||||
[checked]="autoFailoverEnabled()"
|
||||
(change)="onAutoFailoverChange($event)"
|
||||
(click)="$event.stopPropagation()"
|
||||
[attr.aria-label]="
|
||||
'PORTALS.MULTI_SOURCE.AUTO_FAILOVER' | translate
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
@@ -243,6 +243,22 @@ describe('VodSourcesMenuComponent', () => {
|
||||
expect(emitted).toEqual([true]);
|
||||
});
|
||||
|
||||
it('drops the footer switch on a player that cannot report failures', () => {
|
||||
// MPV, VLC and Embedded MPV never raise the playback diagnostic that
|
||||
// drives failover, so the switch there promises something that can
|
||||
// never happen.
|
||||
render([createSource()]);
|
||||
fixture.componentRef.setInput('autoFailoverSupported', false);
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(
|
||||
fixture.debugElement.query(By.css('.sources-menu__toggle'))
|
||||
).toBeNull();
|
||||
expect(fixture.nativeElement.textContent).not.toContain(
|
||||
'Auto-switch on error'
|
||||
);
|
||||
});
|
||||
|
||||
it('shows the match kind in the header, or the resume label when given', () => {
|
||||
fixture.componentRef.setInput('sources', [createSource()]);
|
||||
fixture.componentRef.setInput('matchKind', 'tmdb');
|
||||
|
||||
@@ -56,6 +56,12 @@ export class VodSourcesMenuComponent {
|
||||
readonly title = input('');
|
||||
readonly matchKind = input<VodSourceMatchKind>('title-year');
|
||||
readonly autoFailoverEnabled = input(false);
|
||||
/**
|
||||
* False on players that never report a playback failure (MPV, VLC,
|
||||
* Embedded MPV). The toggle would then promise a switch that can never
|
||||
* happen, so it is not offered at all.
|
||||
*/
|
||||
readonly autoFailoverSupported = input(true);
|
||||
/**
|
||||
* In-player variant: replaces the match kind with e.g.
|
||||
* "timecode will be kept · 0:42:18". Already formatted by the host.
|
||||
|
||||
@@ -35,6 +35,9 @@
|
||||
[matchKind]="sourcesMatchKind()"
|
||||
[resumeLabel]="sourcesResumeLabel()"
|
||||
[autoFailoverEnabled]="sourcesAutoFailoverEnabled()"
|
||||
[autoFailoverSupported]="
|
||||
sourcesAutoFailoverSupported()
|
||||
"
|
||||
(playRequested)="
|
||||
alternativeSourceRequested.emit($event)
|
||||
"
|
||||
|
||||
@@ -221,6 +221,8 @@ export class PortalInlinePlayerComponent {
|
||||
* nothing.
|
||||
*/
|
||||
readonly sourcesAutoFailoverEnabled = input(false);
|
||||
/** See `VodSourcesMenuComponent.autoFailoverSupported`. */
|
||||
readonly sourcesAutoFailoverSupported = input(true);
|
||||
readonly sourcesAutoFailoverToggled = output<boolean>();
|
||||
readonly sourcePinRequested = output<string>();
|
||||
readonly sourceCheckRequested = output<string>();
|
||||
|
||||
@@ -2,7 +2,10 @@ import { Component, computed, effect, inject, input, output, signal, untracked }
|
||||
import { MatIcon } from '@angular/material/icon';
|
||||
import { TranslatePipe } from '@ngx-translate/core';
|
||||
import { SafePipe } from '@iptvnator/pipes';
|
||||
import { PORTAL_EXTERNAL_PLAYBACK } from '@iptvnator/portal/shared/util';
|
||||
import {
|
||||
PORTAL_EXTERNAL_PLAYBACK,
|
||||
createExternalPlaybackButtonState,
|
||||
} from '@iptvnator/portal/shared/util';
|
||||
import {
|
||||
DetailActionsTemplateDirective,
|
||||
DetailMetaTemplateDirective,
|
||||
@@ -198,76 +201,17 @@ export class VodDetailsComponent {
|
||||
readonly isDownloading = this.downloadState.isDownloading;
|
||||
readonly isPausedDownload = this.downloadState.isPausedDownload;
|
||||
|
||||
readonly matchedExternalPlayback = computed(() => {
|
||||
const session = this.externalPlayback();
|
||||
const item = this.item();
|
||||
if (
|
||||
!session?.contentInfo ||
|
||||
session.status === 'closed' ||
|
||||
session.status === 'error'
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const contentInfo = session.contentInfo;
|
||||
if (
|
||||
contentInfo.playlistId !== item.playlistId ||
|
||||
contentInfo.contentType !== 'vod' ||
|
||||
contentInfo.contentXtreamId !== getVodNumericId(item)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return session;
|
||||
});
|
||||
|
||||
readonly externalPrimaryLabel = computed(() => {
|
||||
const session = this.matchedExternalPlayback();
|
||||
if (!session) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const player = session.player.toUpperCase();
|
||||
switch (session.status) {
|
||||
case 'launching':
|
||||
return `Opening in ${player}...`;
|
||||
case 'opened':
|
||||
case 'playing':
|
||||
return `Stop ${player}`;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
});
|
||||
|
||||
readonly externalPrimaryIcon = computed(() => {
|
||||
const session = this.matchedExternalPlayback();
|
||||
switch (session?.status) {
|
||||
case 'launching':
|
||||
return 'hourglass_top';
|
||||
case 'opened':
|
||||
case 'playing':
|
||||
return 'stop_circle';
|
||||
default:
|
||||
return 'play_arrow';
|
||||
}
|
||||
});
|
||||
|
||||
readonly isExternalLaunchPending = computed(
|
||||
() => this.matchedExternalPlayback()?.status === 'launching'
|
||||
);
|
||||
|
||||
readonly isExternalStopAction = computed(() => {
|
||||
const status = this.matchedExternalPlayback()?.status;
|
||||
return status === 'opened' || status === 'playing';
|
||||
});
|
||||
|
||||
readonly externalPrimaryButtonState = computed(() => {
|
||||
if (this.isExternalLaunchPending()) {
|
||||
return 'launching';
|
||||
}
|
||||
|
||||
return this.isExternalStopAction() ? 'stop' : 'idle';
|
||||
private readonly externalButton = createExternalPlaybackButtonState({
|
||||
session: this.externalPlayback,
|
||||
playlistId: computed(() => this.item().playlistId),
|
||||
contentId: computed(() => getVodNumericId(this.item())),
|
||||
});
|
||||
readonly matchedExternalPlayback = this.externalButton.matchedSession;
|
||||
readonly externalPrimaryLabel = this.externalButton.primaryLabel;
|
||||
readonly externalPrimaryIcon = this.externalButton.primaryIcon;
|
||||
readonly isExternalLaunchPending = this.externalButton.isLaunchPending;
|
||||
readonly isExternalStopAction = this.externalButton.isStopAction;
|
||||
readonly externalPrimaryButtonState = this.externalButton.buttonState;
|
||||
|
||||
// ============ Actions ============
|
||||
|
||||
|
||||
Reference in new issue
Block a user