From 80af9257a074d840927c748ebc42680c9edbf20c Mon Sep 17 00:00:00 2001 From: 4gray <4gray@users.noreply.github.com> Date: Tue, 28 Jul 2026 09:50:59 +0200 Subject: [PATCH] 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 --- libs/portal/shared/util/src/index.ts | 2 + .../external-playback-button-state.spec.ts | 138 ++++++++++++++++++ .../src/lib/external-playback-button-state.ts | 121 +++++++++++++++ .../inline-playback-position-writer.spec.ts | 113 ++++++++++++++ .../lib/inline-playback-position-writer.ts | 70 +++++++++ .../stalker-catalog-detail.component.ts | 111 +++----------- .../stalker-vod-download.ts | 118 +++++++++++++++ .../vod-details-playback.service.ts | 112 ++++---------- .../lib/vod-details/vod-details.component.ts | 84 ++--------- 9 files changed, 622 insertions(+), 247 deletions(-) create mode 100644 libs/portal/shared/util/src/lib/external-playback-button-state.spec.ts create mode 100644 libs/portal/shared/util/src/lib/external-playback-button-state.ts create mode 100644 libs/portal/shared/util/src/lib/inline-playback-position-writer.spec.ts create mode 100644 libs/portal/shared/util/src/lib/inline-playback-position-writer.ts create mode 100644 libs/portal/stalker/feature/src/lib/stalker-catalog-detail/stalker-vod-download.ts diff --git a/libs/portal/shared/util/src/index.ts b/libs/portal/shared/util/src/index.ts index 425e617b2..f661cf4d9 100644 --- a/libs/portal/shared/util/src/index.ts +++ b/libs/portal/shared/util/src/index.ts @@ -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'; diff --git a/libs/portal/shared/util/src/lib/external-playback-button-state.spec.ts b/libs/portal/shared/util/src/lib/external-playback-button-state.spec.ts new file mode 100644 index 000000000..834a6200a --- /dev/null +++ b/libs/portal/shared/util/src/lib/external-playback-button-state.spec.ts @@ -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 { + 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(initial); + const api = createExternalPlaybackButtonState({ + session: current, + playlistId: signal('playlist-1'), + contentId: signal(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) + ); + + 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) + ); + + 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) + ); + + 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(session()), + playlistId: signal('playlist-1'), + contentId: signal(null), + }); + + expect(api.matchedSession()).toBeNull(); + }); +}); diff --git a/libs/portal/shared/util/src/lib/external-playback-button-state.ts b/libs/portal/shared/util/src/lib/external-playback-button-state.ts new file mode 100644 index 000000000..ea97ddfb6 --- /dev/null +++ b/libs/portal/shared/util/src/lib/external-playback-button-state.ts @@ -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; + /** Playlist the detail view is showing. */ + playlistId: Signal; + /** Provider-side id of the item on screen. */ + contentId: Signal; + /** 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; + primaryLabel: Signal; + primaryIcon: Signal; + isLaunchPending: Signal; + isStopAction: Signal; + buttonState: Signal; +} + +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(() => { + if (isLaunchPending()) { + return 'launching'; + } + return isStopAction() ? 'stop' : 'idle'; + }); + + return { + matchedSession, + primaryLabel, + primaryIcon, + isLaunchPending, + isStopAction, + buttonState, + }; +} diff --git a/libs/portal/shared/util/src/lib/inline-playback-position-writer.spec.ts b/libs/portal/shared/util/src/lib/inline-playback-position-writer.spec.ts new file mode 100644 index 000000000..16b3b023c --- /dev/null +++ b/libs/portal/shared/util/src/lib/inline-playback-position-writer.spec.ts @@ -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(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, + }) + ); + }); +}); diff --git a/libs/portal/shared/util/src/lib/inline-playback-position-writer.ts b/libs/portal/shared/util/src/lib/inline-playback-position-writer.ts new file mode 100644 index 000000000..049078094 --- /dev/null +++ b/libs/portal/shared/util/src/lib/inline-playback-position-writer.ts @@ -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; + 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; + }, + }; +} diff --git a/libs/portal/stalker/feature/src/lib/stalker-catalog-detail/stalker-catalog-detail.component.ts b/libs/portal/stalker/feature/src/lib/stalker-catalog-detail/stalker-catalog-detail.component.ts index 40905051e..4edc8b0f4 100644 --- a/libs/portal/stalker/feature/src/lib/stalker-catalog-detail/stalker-catalog-detail.component.ts +++ b/libs/portal/stalker/feature/src/lib/stalker-catalog-detail/stalker-catalog-detail.component.ts @@ -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( 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 { - 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; diff --git a/libs/portal/stalker/feature/src/lib/stalker-catalog-detail/stalker-vod-download.ts b/libs/portal/stalker/feature/src/lib/stalker-catalog-detail/stalker-vod-download.ts new file mode 100644 index 000000000..06ee93e7e --- /dev/null +++ b/libs/portal/stalker/feature/src/lib/stalker-catalog-detail/stalker-vod-download.ts @@ -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; +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; + fetchMovieFileId: (id: string) => Promise; + fetchLinkToPlay: ( + portalUrl: string, + macAddress: string, + cmd: string + ) => Promise; +} + +export async function startStalkerVodDownload( + item: VodDetailsItem, + deps: StalkerVodDownloadDeps +): Promise { + 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 { + 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; +} diff --git a/libs/portal/xtream/feature/src/lib/vod-details/vod-details-playback.service.ts b/libs/portal/xtream/feature/src/lib/vod-details/vod-details-playback.service.ts index c81f6b019..1b82f26c1 100644 --- a/libs/portal/xtream/feature/src/lib/vod-details/vod-details-playback.service.ts +++ b/libs/portal/xtream/feature/src/lib/vod-details/vod-details-playback.service.ts @@ -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( null ); - private lastSaveTime = 0; readonly inlinePlayback = signal(null); readonly vodPlaybackPosition = signal(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; diff --git a/libs/ui/playback/src/lib/vod-details/vod-details.component.ts b/libs/ui/playback/src/lib/vod-details/vod-details.component.ts index 030f7a666..e6ff8546a 100644 --- a/libs/ui/playback/src/lib/vod-details/vod-details.component.ts +++ b/libs/ui/playback/src/lib/vod-details/vod-details.component.ts @@ -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 ============