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 24d8c9210..04178964f 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 @@ -293,6 +293,11 @@ export class StalkerSeriesViewComponent implements OnDestroy { private readonly pendingStartSeriesIds = signal([]); /** Episode choices made while a forced MPV/VLC launch is mid-flight. */ private readonly launchQueue = new StalkerSeriesLaunchQueue(); + /** + * The episode chosen while a watched/reset batch still rewrote the rows + * a start resumes from; the last choice plays once the batch settled. + */ + private choiceHeldForBatch: (() => void) | null = null; /** `playlist:series` of the series on screen; provider ids collide across playlists. */ readonly currentSeriesKey = computed( () => @@ -772,7 +777,7 @@ export class StalkerSeriesViewComponent implements OnDestroy { /** The hero's button: held while a start is pending, a second press would double it. */ readonly quickStartButton = computed(() => { const button = this.quickStartAction(); - return button && this.startPending() + return button && (this.startPending() || this.seasonWatchBatchRunning()) ? { ...button, disabled: true } : button; }); @@ -1032,6 +1037,13 @@ export class StalkerSeriesViewComponent implements OnDestroy { startTimeOverride?: number, forcePlayer?: ExternalPlayerName ) { + if (this.seasonWatchBatchRunning()) { + // The batch rewrites the very rows a start resumes from: the + // choice waits for it, like the Reset and watched rows do. + this.choiceHeldForBatch = () => + this.onEpisodeClicked(episode, startTimeOverride, forcePlayer); + return; + } const seriesKey = this.currentSeriesKey(); if (this.launchQueue.isLaunching(seriesKey)) { // The launch cannot be cancelled: the choice replaces its player @@ -1044,6 +1056,14 @@ export class StalkerSeriesViewComponent implements OnDestroy { this.startEpisode(episode, startTimeOverride, forcePlayer); } + /** The batch settled: the choice held meanwhile goes through the usual gates. */ + private endWatchBatch(): void { + this.seasonWatchBatchRunning.set(false); + const choice = this.choiceHeldForBatch; + this.choiceHeldForBatch = null; + choice?.(); + } + private startEpisode( episode: XtreamSerieEpisode, startTimeOverride?: number, @@ -1522,7 +1542,7 @@ export class StalkerSeriesViewComponent implements OnDestroy { SEASON_WATCH_FEEDBACK ); } finally { - this.seasonWatchBatchRunning.set(false); + this.endWatchBatch(); } } @@ -1646,7 +1666,7 @@ export class StalkerSeriesViewComponent implements OnDestroy { SERIES_WATCH_FEEDBACK ); } finally { - this.seasonWatchBatchRunning.set(false); + this.endWatchBatch(); } } diff --git a/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-view.season-watch.spec.ts b/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-view.season-watch.spec.ts index e6a25f66a..a1ca00c77 100644 --- a/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-view.season-watch.spec.ts +++ b/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-view.season-watch.spec.ts @@ -387,6 +387,39 @@ describe('StalkerSeriesViewComponent season watched toggle', () => { expect(fixture.componentInstance.seasonWatchBatchRunning()).toBe(false); }); + it('holds an episode start until the watched batch settled', async () => { + const [firstId, secondId] = await startWithTwoLoadedEpisodes(); + const store = TestBed.inject(StalkerStore); + const resolvePlayback = jest + .spyOn(store, 'resolveVodPlayback') + .mockResolvedValue(null as never); + let finishSave: () => void = () => undefined; + savePlaybackPositionOrThrow.mockImplementationOnce( + () => new Promise((resolve) => (finishSave = resolve)) + ); + const batch = + fixture.componentInstance.handleSeasonPlaybackToggleRequested( + seasonToggleRequest([firstId, secondId], true) + ); + await Promise.resolve(); + expect(fixture.componentInstance.seasonWatchBatchRunning()).toBe(true); + + // The batch rewrites the rows a start resumes from: the episode + // chosen meanwhile waits for it instead of resuming from a row + // the batch is about to mark. + const [first] = fixture.componentInstance.mappedSeasons()['1']; + fixture.componentInstance.onEpisodeClicked(first); + expect(resolvePlayback).not.toHaveBeenCalled(); + expect(fixture.componentInstance.quickStartButton()?.disabled).toBe( + true + ); + + finishSave(); + await batch; + expect(fixture.componentInstance.seasonWatchBatchRunning()).toBe(false); + expect(resolvePlayback).toHaveBeenCalledTimes(1); + }); + it('keeps surviving clears and reports a partial season unwatch failure', async () => { const [firstId, secondId] = await startWithTwoLoadedEpisodes(); repositoryRows = [firstId, secondId].map((contentXtreamId, index) => @@ -493,9 +526,7 @@ describe('StalkerSeriesViewComponent season watched toggle', () => { createSecondSeason(), ]); await settle(); - return Number( - fixture.componentInstance.mappedSeasons()['1'][0].id - ); + return Number(fixture.componentInstance.mappedSeasons()['1'][0].id); } function mockSecondSeasonFetch(): jest.Mock { @@ -518,9 +549,9 @@ describe('StalkerSeriesViewComponent season watched toggle', () => { ]); expectSeasonToggleSnackbar('XTREAM.SEASON_MARKED_WATCHED'); expect(refreshPositions).toHaveBeenCalledWith(PLAYLIST_ID); - expect( - fixture.componentInstance.seasonWatchBatchRunning() - ).toBe(false); + expect(fixture.componentInstance.seasonWatchBatchRunning()).toBe( + false + ); }); it('hydrates lazy seasons, then marks them with legacy-row cleanup', async () => { @@ -542,10 +573,7 @@ describe('StalkerSeriesViewComponent season watched toggle', () => { seriesToggleRequest([firstId], true) ); - expect(fetch).toHaveBeenCalledWith( - String(SERIES_A_ID), - 'season-2' - ); + expect(fetch).toHaveBeenCalledWith(String(SERIES_A_ID), 'season-2'); expect(repositoryOrder).toEqual([ `save:${firstId}`, `save:${S2_SCOPED_ID}`, @@ -577,9 +605,9 @@ describe('StalkerSeriesViewComponent season watched toggle', () => { expect(fetch).toHaveBeenCalledTimes(1); expect(repositoryOrder).toEqual([`save:${firstId}`]); expectSeasonToggleSnackbar('XTREAM.SEASON_MARKED_WATCHED'); - expect( - fixture.componentInstance.hasUnloadedVodSeasons() - ).toBe(false); + expect(fixture.componentInstance.hasUnloadedVodSeasons()).toBe( + false + ); // Everything loaded is now watched and nothing is pending — an // empty follow-up mark request is a no-op, not another fetch. @@ -637,9 +665,9 @@ describe('StalkerSeriesViewComponent season watched toggle', () => { expect(repositoryOrder).toEqual([]); expectSeasonToggleSnackbar('XTREAM.SERIES_WATCH_UPDATE_FAILED'); expect(refreshPositions).not.toHaveBeenCalled(); - expect( - fixture.componentInstance.seasonWatchBatchRunning() - ).toBe(false); + expect(fixture.componentInstance.seasonWatchBatchRunning()).toBe( + false + ); }); it('aborts silently when the series changes during hydration', async () => { @@ -665,9 +693,9 @@ describe('StalkerSeriesViewComponent season watched toggle', () => { expect(repositoryOrder).toEqual([]); expect(snackBarCalls().length).toBe(callsBefore); - expect( - fixture.componentInstance.seasonWatchBatchRunning() - ).toBe(false); + expect(fixture.componentInstance.seasonWatchBatchRunning()).toBe( + false + ); }); it('reports a zero count when hydration reveals nothing to mark', async () => { @@ -715,9 +743,9 @@ describe('StalkerSeriesViewComponent season watched toggle', () => { fixture.componentInstance.handleSeriesPlaybackToggleRequested( seriesToggleRequest([firstId], true) ); - expect( - fixture.componentInstance.seasonWatchBatchRunning() - ).toBe(true); + expect(fixture.componentInstance.seasonWatchBatchRunning()).toBe( + true + ); // A season toggle during hydration must not enqueue anything. await fixture.componentInstance.handleSeasonPlaybackToggleRequested( seasonToggleRequest([firstId], true) @@ -731,9 +759,9 @@ describe('StalkerSeriesViewComponent season watched toggle', () => { `save:${firstId}`, `save:${S2_SCOPED_ID}`, ]); - expect( - fixture.componentInstance.seasonWatchBatchRunning() - ).toBe(false); + expect(fixture.componentInstance.seasonWatchBatchRunning()).toBe( + false + ); }); }); });