From aaae2b0572973b505089e8097345a2f94e77cc72 Mon Sep 17 00:00:00 2001 From: 4gray Date: Sat, 3 Oct 2026 11:48:59 +0200 Subject: [PATCH] fix(portals): seventh review round on the cinematic details - The shared movie menu offers the external player in provider-only mode too; that mode hides local/download controls, not the provider's stream in MPV/VLC. - The movie and Stalker series reset-progress rows are also disabled while a start is still resolving its stream, so a slow create_link cannot recreate a just-cleared row. Co-Authored-By: Claude Fable 5.1 --- .../stalker-series-view.component.ts | 6 +++ .../lib/vod-details/vod-details-hero.state.ts | 17 ++++++--- .../vod-details-presentation.spec.ts | 38 +++++++++++++++++++ .../lib/vod-details/vod-details.component.ts | 2 +- 4 files changed, 56 insertions(+), 7 deletions(-) create mode 100644 libs/ui/playback/src/lib/vod-details/vod-details-presentation.spec.ts diff --git a/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-view.component.ts b/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-view.component.ts index d436da0bb..2fad986bb 100644 --- a/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-view.component.ts +++ b/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-view.component.ts @@ -283,6 +283,8 @@ export class StalkerSeriesViewComponent implements OnDestroy { private unsubscribePositionUpdates: (() => void) | null = null; readonly openingEpisodeId = signal(null); readonly activeEpisodeId = signal(null); + /** Starts still resolving their stream (`create_link` round trips). */ + private readonly pendingStartCount = signal(0); readonly seasonWatchBatchRunning = signal(false); /** @@ -399,6 +401,7 @@ export class StalkerSeriesViewComponent implements OnDestroy { ), playbackActive: computed( () => + this.pendingStartCount() > 0 || this.inlinePlayback() !== null || this.openingEpisodeId() !== null || this.activeEpisodeId() !== null @@ -1266,6 +1269,7 @@ export class StalkerSeriesViewComponent implements OnDestroy { }; if (request.usesEmbeddedPlayer && !request.identity) return; + this.pendingStartCount.update((count) => count + 1); try { const playback = await this.stalkerStore.resolveVodPlayback( cmd, @@ -1318,6 +1322,8 @@ export class StalkerSeriesViewComponent implements OnDestroy { this.snackBar.open(errorMessage, undefined, { duration: 3000, }); + } finally { + this.pendingStartCount.update((count) => count - 1); } } 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 e0edda821..274d65250 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 @@ -32,7 +32,8 @@ export interface VodDetailsHeroStateDeps { readonly formattedPosition: Signal; readonly isWatched: Signal; readonly isElectron: Signal; - readonly providerOnly: Signal; + /** Play/Resume clicked, stream still resolving (Stalker `create_link`). */ + readonly playbackStartPending: Signal; readonly isOfflinePrimary: Signal; readonly externalLabel: Signal; readonly externalIcon: Signal; @@ -108,15 +109,19 @@ export function createVodDetailsHeroState(deps: VodDetailsHeroStateDeps) { externalPlayer, menuSections: computed(() => buildVodMenuSections({ - externalPlayerAvailable: - deps.isElectron() && !deps.providerOnly(), + // Provider-only mode hides local/download controls, not the + // provider's own stream in MPV/VLC. + externalPlayerAvailable: deps.isElectron(), externalPlayerHint: externalPlayer() === 'vlc' ? 'VLC' : 'MPV', hasPlaybackPosition: deps.hasPlaybackPosition(), hasStoredProgress: (deps.playbackPosition() ?? 0) > 0 || deps.isWatched(), - // Inline playback collapses the hero; only an external - // player can still own the row while the menu is reachable. - playbackActive: deps.externalState() !== 'idle', + // Inline playback collapses the hero; an external player, or + // a start still resolving its stream, can still own the row + // while the menu is reachable. + playbackActive: + deps.externalState() !== 'idle' || + deps.playbackStartPending(), }) ), }; 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 new file mode 100644 index 000000000..8e31f1fd9 --- /dev/null +++ b/libs/ui/playback/src/lib/vod-details/vod-details-presentation.spec.ts @@ -0,0 +1,38 @@ +import { + buildVodMenuSections, + VOD_DETAILS_MENU_ACTION, +} from './vod-details-presentation'; + +const BASE = { + externalPlayerAvailable: true, + externalPlayerHint: 'MPV' as const, + hasPlaybackPosition: true, + hasStoredProgress: true, + playbackActive: false, +}; + +function rows(input: Partial) { + return buildVodMenuSections({ ...BASE, ...input }).flatMap( + (section) => section.items + ); +} + +describe('buildVodMenuSections', () => { + it('offers the external player and the progress reset while nothing plays', () => { + const ids = rows({}).map((row) => row.id); + expect(ids).toContain(VOD_DETAILS_MENU_ACTION.ExternalPlayer); + const reset = rows({}).find( + (row) => row.id === VOD_DETAILS_MENU_ACTION.ResetProgress + ); + expect(reset?.disabled).toBeFalsy(); + }); + + it('disables the progress reset while playback owns the position', () => { + // A running player, or a start still resolving its stream, would + // write the position right back. + const reset = rows({ playbackActive: true }).find( + (row) => row.id === VOD_DETAILS_MENU_ACTION.ResetProgress + ); + expect(reset?.disabled).toBe(true); + }); +}); 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 6b16594a4..fdee94c4d 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 @@ -325,7 +325,7 @@ export class VodDetailsComponent { formattedPosition: this.formattedPosition, isWatched: this.isWatched, isElectron: this.isElectron, - providerOnly: this.providerOnly, + playbackStartPending: this.playbackStartPending, isOfflinePrimary: this.isOfflinePrimary, externalLabel: this.externalPrimaryLabel, externalIcon: this.externalPrimaryIcon,