fix(portals): a Stalker episode start waits for a watched batch to settle

The central episode start only waited for an external launch, so the
hero and the episode cards could start an episode from the pre-batch
position while a season/series watched or reset batch was still
rewriting that row. The last choice made during a batch is held and
goes through the usual gates once the batch settled; the hero button is
held meanwhile.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
4grayandClaude Fable 5.1 committed 2026-10-03 19:20:20 +02:00
1 parent bfa04001c9
commit e0b6de8372
2 files changed
+76 -28

No files matched your search

@@ -293,6 +293,11 @@ export class StalkerSeriesViewComponent implements OnDestroy {
private readonly pendingStartSeriesIds = signal<readonly string[]>([]);
/** 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<StalkerQuickStartButton | null>(() => {
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();
}
}
@@ -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<void>((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
);
});
});
});