diff --git a/libs/portal/stalker/feature/src/lib/stalker-catalog-detail/stalker-catalog-detail.component.html b/libs/portal/stalker/feature/src/lib/stalker-catalog-detail/stalker-catalog-detail.component.html index 147c42bc3..c5ec915dc 100644 --- a/libs/portal/stalker/feature/src/lib/stalker-catalog-detail/stalker-catalog-detail.component.html +++ b/libs/portal/stalker/feature/src/lib/stalker-catalog-detail/stalker-catalog-detail.component.html @@ -22,7 +22,7 @@ [watchedToggleReady]="positionLoaded()" [playbackStartPending]="playbackStartPending()" (playClicked)="onVodPlay($event)" - (resumeClicked)="onVodResume($event)" + (resumeClicked)="onVodPlay($event.item, $event.positionSeconds)" (favoriteToggled)="onVodFavoriteToggled($event)" (watchedToggled)="onVodWatchedToggled($event)" (downloadRequested)="vodDetailActions.download($event)" 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 b3978f291..37d267c7c 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 @@ -245,13 +245,6 @@ export class StalkerCatalogDetailComponent implements OnDestroy { } } - onVodResume(event: { - item: VodDetailsItem; - positionSeconds: number; - }): void { - this.onVodPlay(event.item, event.positionSeconds); - } - onVodFavoriteToggled(event: { item: VodDetailsItem; isFavorite: boolean; @@ -285,6 +278,10 @@ export class StalkerCatalogDetailComponent implements OnDestroy { selectedVodPosition: this.selectedVodPosition, discardPendingPositionLoad: () => ++this.positionLoadGeneration, beforeExternalLaunch: () => this.closeInlinePlayer(), + beginPendingStart: () => { + const startId = this.pendingStart.begin(this.playbackOwnerKey()); + return () => this.pendingStart.settle(startId); + }, afterProgressReset: (playlistId) => void this.catalog.refreshPositions(playlistId), download: (item) => diff --git a/libs/portal/stalker/feature/src/lib/stalker-search/stalker-search.component.ts b/libs/portal/stalker/feature/src/lib/stalker-search/stalker-search.component.ts index 5575e2626..cd4c1b982 100644 --- a/libs/portal/stalker/feature/src/lib/stalker-search/stalker-search.component.ts +++ b/libs/portal/stalker/feature/src/lib/stalker-search/stalker-search.component.ts @@ -922,6 +922,7 @@ export class StalkerSearchComponent { selectedVodPosition: this.selectedVodPosition, discardPendingPositionLoad: () => this.vodPlayback.discardPendingPositionLoad(), + beginPendingStart: () => this.vodPlayback.beginPendingStart(), snackBar: this.snackBar, translate: this.translateService, logError: () => undefined, diff --git a/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-menu.service.ts b/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-menu.service.ts index adbf12eba..d5c5b9190 100644 --- a/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-menu.service.ts +++ b/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-menu.service.ts @@ -1,5 +1,5 @@ import { computed, inject, Injectable, Signal, signal } from '@angular/core'; -import { DownloadsService, SettingsStore } from '@iptvnator/services'; +import { RuntimeCapabilitiesService, SettingsStore } from '@iptvnator/services'; import { VideoPlayer, type ExternalPlayerName, @@ -28,7 +28,7 @@ interface StalkerSeriesMenuBindings { */ @Injectable() export class StalkerSeriesMenuService { - private readonly downloadsService = inject(DownloadsService); + private readonly runtime = inject(RuntimeCapabilitiesService); private readonly settingsStore = inject(SettingsStore); private readonly bindings = signal(null); @@ -62,7 +62,8 @@ export class StalkerSeriesMenuService { playbackActive: this.bindings()?.playbackActive() ?? false, sourcesCount: 0, externalPlayerHint: - quickStart?.action && this.downloadsService.isAvailable() + quickStart?.action && + this.runtime.supportsManagedExternalPlayers ? this.externalPlayer() === 'vlc' ? 'VLC' : 'MPV' diff --git a/libs/portal/stalker/feature/src/lib/stalker-vod-detail-actions.spec.ts b/libs/portal/stalker/feature/src/lib/stalker-vod-detail-actions.spec.ts index 6de2fb3ed..cd42ec6a2 100644 --- a/libs/portal/stalker/feature/src/lib/stalker-vod-detail-actions.spec.ts +++ b/libs/portal/stalker/feature/src/lib/stalker-vod-detail-actions.spec.ts @@ -31,10 +31,13 @@ describe('createStalkerVodDetailActions openExternal', () => { const beforeExternalLaunch = jest.fn(); const closeSession = jest.fn().mockResolvedValue(undefined); let running: ExternalPlayerSession | null = null; + const settlePendingStart = jest.fn(); + const beginPendingStart = jest.fn().mockReturnValue(settlePendingStart); const actions = createStalkerVodDetailActions({ resolvePlayback: resolvePlayback as never, portalPlayer: { openExternalPlayback }, externalPlayback: { activeSession: () => running, closeSession }, + beginPendingStart, playbackPositions: { clearPlaybackPositionOrThrow: jest.fn() }, playlistId: () => 'portal-1', selectedVodId, @@ -49,6 +52,8 @@ describe('createStalkerVodDetailActions openExternal', () => { openExternalPlayback, beforeExternalLaunch, closeSession, + beginPendingStart, + settlePendingStart, setRunning: (session: ExternalPlayerSession | null) => (running = session), resolveLink: () => resolveLink({ streamUrl: 'http://cdn/42.mp4' }), @@ -98,6 +103,13 @@ describe('createStalkerVodDetailActions openExternal', () => { { streamUrl: 'http://cdn/42.mp4' }, 'mpv' ); + // Pending from the click until the launch settled: a reset meanwhile + // would be undone by the start. + expect(t.beginPendingStart).toHaveBeenCalledTimes(1); + expect(t.settlePendingStart).toHaveBeenCalledTimes(1); + expect(t.beginPendingStart.mock.invocationCallOrder[0]).toBeLessThan( + t.openExternalPlayback.mock.invocationCallOrder[0] + ); }); it('drops the stream once another movie was selected meanwhile', async () => { diff --git a/libs/portal/stalker/feature/src/lib/stalker-vod-detail-actions.ts b/libs/portal/stalker/feature/src/lib/stalker-vod-detail-actions.ts index 7e9ee5cb4..35798d12e 100644 --- a/libs/portal/stalker/feature/src/lib/stalker-vod-detail-actions.ts +++ b/libs/portal/stalker/feature/src/lib/stalker-vod-detail-actions.ts @@ -51,6 +51,8 @@ interface StalkerVodDetailActionsDeps { /** Retires a stored-position read in flight, which would restore the row. */ readonly discardPendingPositionLoad?: () => void; readonly beforeExternalLaunch?: () => void; + /** Marks the movie's start pending while the stream resolves; returns the settle. */ + readonly beginPendingStart?: () => () => void; readonly afterProgressReset?: (playlistId: string) => void; readonly snackBar: Pick; readonly translate: Pick; @@ -102,6 +104,7 @@ export function createStalkerVodDetailActions( const stillSelected = () => deps.selectedVodId() === vodId && deps.playlistId() === playlistId; + const settlePendingStart = deps.beginPendingStart?.(); try { const playback = await deps.resolvePlayback( event.item.cmd, @@ -133,6 +136,8 @@ export function createStalkerVodDetailActions( if (stillSelected()) { notify('PORTALS.PLAYBACK_ERROR'); } + } finally { + settlePendingStart?.(); } }, diff --git a/libs/portal/stalker/feature/src/lib/stalker-vod-playback-controller.ts b/libs/portal/stalker/feature/src/lib/stalker-vod-playback-controller.ts index 36ba0ae1f..bee6075ab 100644 --- a/libs/portal/stalker/feature/src/lib/stalker-vod-playback-controller.ts +++ b/libs/portal/stalker/feature/src/lib/stalker-vod-playback-controller.ts @@ -43,6 +43,14 @@ export class StalkerVodPlaybackController { constructor(private readonly config: StalkerVodPlaybackControllerConfig) {} + /** An explicit launch resolving its stream; the closure settles it. */ + beginPendingStart(): () => void { + const startId = this.pendingStart.begin( + this.config.playbackOwnerKey?.() + ); + return () => this.pendingStart.settle(startId); + } + async startVodPlayback( resolvePlayback: () => Promise ): Promise { diff --git a/libs/portal/xtream/feature/src/lib/serial-details/serial-details-menu.service.ts b/libs/portal/xtream/feature/src/lib/serial-details/serial-details-menu.service.ts index 5c18cb1d8..c97cea292 100644 --- a/libs/portal/xtream/feature/src/lib/serial-details/serial-details-menu.service.ts +++ b/libs/portal/xtream/feature/src/lib/serial-details/serial-details-menu.service.ts @@ -11,7 +11,7 @@ import { XTREAM_DATA_SOURCE, XtreamStore, } from '@iptvnator/portal/xtream/data-access'; -import { DownloadsService, SettingsStore } from '@iptvnator/services'; +import { RuntimeCapabilitiesService, SettingsStore } from '@iptvnator/services'; import { VideoPlayer, type ExternalPlayerName, @@ -58,7 +58,7 @@ export class SerialDetailsMenuService { private readonly dataSource = inject(XTREAM_DATA_SOURCE, { optional: true, }); - private readonly downloadsService = inject(DownloadsService); + private readonly runtime = inject(RuntimeCapabilitiesService); private readonly settingsStore = inject(SettingsStore); private readonly router = inject(Router); private readonly snackBar = inject(MatSnackBar); @@ -157,7 +157,7 @@ export class SerialDetailsMenuService { playbackActive: this.bindings()?.playbackActive() ?? false, sourcesCount: 0, externalPlayerHint: - quickStart && this.downloadsService.isAvailable() + quickStart && this.runtime.supportsManagedExternalPlayers ? this.externalPlayer() === 'vlc' ? 'VLC' : 'MPV' diff --git a/libs/portal/xtream/feature/src/lib/vod-details/vod-details-menu.service.ts b/libs/portal/xtream/feature/src/lib/vod-details/vod-details-menu.service.ts index 540ff2ea1..f3ad99c59 100644 --- a/libs/portal/xtream/feature/src/lib/vod-details/vod-details-menu.service.ts +++ b/libs/portal/xtream/feature/src/lib/vod-details/vod-details-menu.service.ts @@ -7,7 +7,7 @@ import { PORTAL_PLAYBACK_POSITIONS, } from '@iptvnator/portal/shared/util'; import { XtreamStore } from '@iptvnator/portal/xtream/data-access'; -import { DownloadsService, SettingsStore } from '@iptvnator/services'; +import { RuntimeCapabilitiesService, SettingsStore } from '@iptvnator/services'; import { VideoPlayer, type ExternalPlayerName, @@ -48,7 +48,7 @@ export class VodDetailsMenuService { private readonly msUi = inject(VodDetailsMultiSourceUiService); private readonly multiSource = inject(VodMultiSourceHostService); private readonly playbackPositions = inject(PORTAL_PLAYBACK_POSITIONS); - private readonly downloadsService = inject(DownloadsService); + private readonly runtime = inject(RuntimeCapabilitiesService); private readonly settingsStore = inject(SettingsStore); private readonly router = inject(Router); private readonly snackBar = inject(MatSnackBar); @@ -80,7 +80,7 @@ export class VodDetailsMenuService { testId: 'vod-menu-sources', }); } - if (item && this.downloadsService.isAvailable()) { + if (item && this.runtime.supportsManagedExternalPlayers) { sourceRows.push({ id: VOD_MENU_ACTION.ExternalPlayer, labelKey: 'PORTALS.DETAIL.OPEN_IN_EXTERNAL_PLAYER', diff --git a/libs/ui/playback/src/lib/vod-details/vod-details-hero.state.ts b/libs/ui/playback/src/lib/vod-details/vod-details-hero.state.ts index 274d65250..a95d55ae1 100644 --- a/libs/ui/playback/src/lib/vod-details/vod-details-hero.state.ts +++ b/libs/ui/playback/src/lib/vod-details/vod-details-hero.state.ts @@ -21,6 +21,7 @@ import { buildVodMenuSections, labelOf, vodDurationSeconds, + formatPlaybackClock, } from './vod-details-presentation'; export interface VodDetailsHeroStateDeps { @@ -29,9 +30,9 @@ export interface VodDetailsHeroStateDeps { readonly playbackPosition: Signal; readonly playbackDurationSeconds: Signal; readonly hasPlaybackPosition: Signal; - readonly formattedPosition: Signal; readonly isWatched: Signal; - readonly isElectron: Signal; + /** Managed MPV/VLC launches: the bridge serves openInMpv/openInVlc. */ + readonly supportsExternalPlayers: () => boolean; /** Play/Resume clicked, stream still resolving (Stalker `create_link`). */ readonly playbackStartPending: Signal; readonly isOfflinePrimary: Signal; @@ -98,7 +99,9 @@ export function createVodDetailsHeroState(deps: VodDetailsHeroStateDeps) { hasPlaybackPosition: deps.hasPlaybackPosition(), positionSeconds: deps.playbackPosition(), durationSeconds: durationSeconds(), - formattedPosition: deps.formattedPosition(), + formattedPosition: formatPlaybackClock( + deps.playbackPosition() + ), }, translate ) @@ -111,7 +114,7 @@ export function createVodDetailsHeroState(deps: VodDetailsHeroStateDeps) { buildVodMenuSections({ // Provider-only mode hides local/download controls, not the // provider's own stream in MPV/VLC. - externalPlayerAvailable: deps.isElectron(), + externalPlayerAvailable: deps.supportsExternalPlayers(), externalPlayerHint: externalPlayer() === 'vlc' ? 'VLC' : 'MPV', hasPlaybackPosition: deps.hasPlaybackPosition(), hasStoredProgress: diff --git a/libs/ui/playback/src/lib/vod-details/vod-details-presentation.spec.ts b/libs/ui/playback/src/lib/vod-details/vod-details-presentation.spec.ts index 8e31f1fd9..ce0880ccd 100644 --- a/libs/ui/playback/src/lib/vod-details/vod-details-presentation.spec.ts +++ b/libs/ui/playback/src/lib/vod-details/vod-details-presentation.spec.ts @@ -1,8 +1,19 @@ import { buildVodMenuSections, + formatPlaybackClock, VOD_DETAILS_MENU_ACTION, } from './vod-details-presentation'; +describe('formatPlaybackClock', () => { + it('formats a resume point as a clock', () => { + expect(formatPlaybackClock(754)).toBe('12:34'); + expect(formatPlaybackClock(5025)).toBe('1:23:45'); + expect(formatPlaybackClock(65)).toBe('1:05'); + expect(formatPlaybackClock(0)).toBe(''); + expect(formatPlaybackClock(null)).toBe(''); + }); +}); + const BASE = { externalPlayerAvailable: true, externalPlayerHint: 'MPV' as const, diff --git a/libs/ui/playback/src/lib/vod-details/vod-details-presentation.ts b/libs/ui/playback/src/lib/vod-details/vod-details-presentation.ts index 5c97a3b07..79cab76e0 100644 --- a/libs/ui/playback/src/lib/vod-details/vod-details-presentation.ts +++ b/libs/ui/playback/src/lib/vod-details/vod-details-presentation.ts @@ -159,6 +159,20 @@ export function buildPrimaryAction( }; } +/** "12:34" or "1:23:45" for a resume point; empty without one. */ +export function formatPlaybackClock( + seconds: number | null | undefined +): string { + const total = Math.floor(seconds ?? 0); + if (total <= 0) { + return ''; + } + const minutes = Math.floor((total % 3600) / 60); + const rest = `${minutes.toString().padStart(2, '0')}:${(total % 60).toString().padStart(2, '0')}`; + const hours = Math.floor(total / 3600); + return hours > 0 ? `${hours}:${rest}` : `${minutes}:${rest.slice(3)}`; +} + export function buildVodMenuSections(input: { externalPlayerAvailable: boolean; externalPlayerHint: 'MPV' | 'VLC'; 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 fdee94c4d..921ec2150 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 @@ -47,6 +47,7 @@ import { CrossPortalSimilarItem, CrossPortalSimilarService, DownloadsService, + RuntimeCapabilitiesService, SettingsStore, TmdbEnrichmentService, } from '@iptvnator/services'; @@ -194,6 +195,8 @@ export class VodDetailsComponent { // ============ Services ============ private readonly downloadsService = inject(DownloadsService); + + private readonly runtime = inject(RuntimeCapabilitiesService); private readonly crossPortalSimilar = inject(CrossPortalSimilarService); private readonly externalPlaybackActions = inject(PORTAL_EXTERNAL_PLAYBACK); private readonly router = inject(Router); @@ -264,25 +267,9 @@ export class VodDetailsComponent { * Whether there's a playback position to resume from. A watched movie * shows Play, not "Resume 1:32:00" from its final seconds. */ - readonly hasPlaybackPosition = computed(() => { - const pos = this.playbackPosition(); - return pos !== null && pos > 0 && !this.isWatched(); - }); - - /** Formatted playback position (e.g., "12:34" or "1:23:45") */ - readonly formattedPosition = computed(() => { - const pos = this.playbackPosition(); - if (!pos || pos <= 0) return ''; - - const hours = Math.floor(pos / 3600); - const minutes = Math.floor((pos % 3600) / 60); - const seconds = Math.floor(pos % 60); - - if (hours > 0) { - return `${hours}:${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}`; - } - return `${minutes}:${seconds.toString().padStart(2, '0')}`; - }); + readonly hasPlaybackPosition = computed( + () => (this.playbackPosition() ?? 0) > 0 && !this.isWatched() + ); private readonly downloadState = createVodDownloadState( this.downloadsService, @@ -322,9 +309,9 @@ export class VodDetailsComponent { playbackPosition: this.playbackPosition, playbackDurationSeconds: this.playbackDurationSeconds, hasPlaybackPosition: this.hasPlaybackPosition, - formattedPosition: this.formattedPosition, isWatched: this.isWatched, - isElectron: this.isElectron, + supportsExternalPlayers: () => + this.runtime.supportsManagedExternalPlayers, playbackStartPending: this.playbackStartPending, isOfflinePrimary: this.isOfflinePrimary, externalLabel: this.externalPrimaryLabel,