refactor(portals): share external-button and position-writer logic (#1298)

The Xtream and Stalker VOD detail views each carried a private copy of two
behaviours: deriving the Play/Stop button state from the active external
(MPV/VLC) session, and throttled persistence of the inline player position.
A Play button or a resume point that behaves differently per portal is the
kind of divergence users notice, so both now read from one implementation.

Extracts `createExternalPlaybackButtonState` and
`createInlinePlaybackPositionWriter` into portal/shared/util, and lifts the
Stalker VOD download errand into its own helper. Behaviour is unchanged; the
shared helpers are deliberately identical to the copies they replace.

This also brings both hosts back under the 400-line ESLint limit, neither of
which was baselined:
  vod-details.component.ts          389 -> 333
  stalker-catalog-detail.component  394 -> 325
  vod-details-playback.service.ts   345 -> 275

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
4grayandClaude Opus 5 authored and GitHub committed 2026-07-28 09:50:59 +02:00
1 parent 72f8cebd2e
commit 80af9257a0
9 files changed
+622 -247

No files matched your search

+2
View File
@@ -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,138 @@
import { signal } from '@angular/core';
import type { ExternalPlayerSession } 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();
});
});
@@ -0,0 +1,121 @@
import { computed, type Signal } from '@angular/core';
import type { ExternalPlayerSession } 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';
}
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.playlistId !== config.playlistId() ||
info.contentType !== contentType ||
info.contentXtreamId !== config.contentId()
) {
return null;
}
return session;
});
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,113 @@
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,
})
);
});
});
@@ -0,0 +1,70 @@
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.
*
* Deliberately behaviour-identical to the two implementations it replaces.
*/
/** The player fires ~4x/second; persisting that often would hammer SQLite. */
const DEFAULT_THROTTLE_MS = 15000;
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 {
handleTimeUpdate(event: { currentTime: number; duration: number }): void;
/** Clears the throttle so the next update is written immediately. */
reset(): void;
}
export function createInlinePlaybackPositionWriter(
config: InlinePlaybackPositionWriterConfig
): InlinePlaybackPositionWriter {
const throttleMs = config.throttleMs ?? DEFAULT_THROTTLE_MS;
let lastSaveTime = 0;
return {
handleTimeUpdate(event) {
const playback = config.playback();
// Without contentInfo there is no key to store the position under.
if (!playback?.contentInfo) {
return;
}
const now = Date.now();
if (now - lastSaveTime <= throttleMs) {
return;
}
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);
},
reset() {
lastSaveTime = 0;
},
};
}
@@ -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';
@@ -52,79 +54,21 @@ export class VodDetailsPlaybackService {
private readonly bindings = signal<VodDetailsPlaybackBindings | null>(
null
);
private lastSaveTime = 0;
readonly inlinePlayback = signal<ResolvedPortalPlayback | null>(null);
readonly vodPlaybackPosition = signal<PlaybackPositionData | null>(null);
readonly matchedExternalPlayback = computed(() => {
const session = this.externalPlayback.activeSession();
const vodId = this.bindings()?.vodId();
const playlistId = this.xtreamStore.currentPlaylist()?.id;
if (
!session?.contentInfo ||
!playlistId ||
session.status === 'error' ||
session.status === 'closed'
) {
return null;
}
const contentInfo = session.contentInfo;
if (
contentInfo.playlistId !== playlistId ||
contentInfo.contentType !== 'vod' ||
contentInfo.contentXtreamId !== vodId
) {
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.activeSession,
playlistId: computed(() => this.xtreamStore.currentPlaylist()?.id),
contentId: computed(() => this.bindings()?.vodId()),
});
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())
);
@@ -273,30 +217,24 @@ export class VodDetailsPlaybackService {
closeInlinePlayer(): void {
this.inlinePlayback.set(null);
this.lastSaveTime = 0;
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),
});
handleInlineTimeUpdate(event: {
currentTime: number;
duration: number;
}): void {
const playback = this.inlinePlayback();
if (!playback?.contentInfo) return;
const now = Date.now();
if (now - this.lastSaveTime <= 15000) return;
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);
this.positionWriter.handleTimeUpdate(event);
}
handleExternalFallbackRequest(request: PlaybackFallbackRequest): void {
@@ -325,7 +263,7 @@ export class VodDetailsPlaybackService {
}
private startPlayback(playback: ResolvedPortalPlayback): void {
this.lastSaveTime = 0;
this.positionWriter.reset();
if (this.portalPlayer.isEmbeddedPlayer()) {
this.inlinePlayback.set(playback);
return;
@@ -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 ============