mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-12 20:16:15 -08:00
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:
1 parent
72f8cebd2e
commit
80af9257a0
9 files changed
+622
-247
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,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;
|
||||
},
|
||||
};
|
||||
}
|
||||
+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';
|
||||
@@ -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 ============
|
||||
|
||||
|
||||
Reference in new issue
Block a user