fix(portals): twenty-fourth review round on the cinematic details

- A forced Xtream episode launch returns its promise to the menu, which
  reports a rejection; the bookkeeping consumes a settled copy.
- Launch bookkeeping is guarded by a page token (series + visit), checked
  again after the asynchronous save, so neither a return to the same
  series nor a navigation during the save contaminates another page.
- A Stalker launch re-anchors to the host's request id after the host's
  own inline teardown: only a later start supersedes it, and that still
  silences the old launch's failure.
- The shared movie page disables Play and Start Over while a start is
  pending, so nothing starts beside an in-flight launch.
- Episode sublines honour duration_secs before the formatted duration.
- The external session → episode ids derivation moves into the launch
  helper.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
4grayandClaude Fable 5.1 committed 2026-10-03 14:57:51 +02:00
1 parent d71a92cec8
commit d087cf4d1c
15 files changed
+209 -71

No files matched your search

@@ -51,3 +51,13 @@ export function formatEpisodePositionText(
.join(':');
return `${formatted}${suffix}`;
}
/** The episode runtime: the provider's `duration_secs` first, then its formatted `duration`. */
export function episodeRuntimeSeconds(
info: { duration_secs?: number; duration?: string } | null | undefined
): number {
const seconds = Number(info?.duration_secs);
return Number.isFinite(seconds) && seconds > 0
? Math.floor(seconds)
: parseDuration(info?.duration);
}
@@ -1,8 +1,23 @@
import {
episodeRuntimeSeconds,
formatEpisodePositionText,
parseDuration,
} from './episode-progress.util';
describe('episodeRuntimeSeconds', () => {
it('prefers the provider seconds over the formatted string', () => {
expect(episodeRuntimeSeconds({ duration_secs: 2520 })).toBe(2520);
expect(
episodeRuntimeSeconds({ duration_secs: 2520, duration: '00:10:00' })
).toBe(2520);
expect(episodeRuntimeSeconds({ duration: '00:42:00' })).toBe(2520);
expect(
episodeRuntimeSeconds({ duration_secs: 0, duration: '00:42:00' })
).toBe(2520);
expect(episodeRuntimeSeconds(undefined)).toBe(0);
});
});
describe('episode-progress.util', () => {
it('parses duration strings', () => {
expect(parseDuration('01:00:30')).toBe(3630);
@@ -26,7 +26,6 @@ import {
getPortalPlaybackProgressPercent,
isPortalPlaybackInProgress,
isPortalPlaybackWatched,
parseDurationSeconds,
} from '@iptvnator/portal/shared/util';
import {
PlaybackPositionData,
@@ -39,7 +38,10 @@ import {
EpisodeInfoDialogComponent,
buildEpisodeInfoDialogData,
} from './episode-info-dialog.component';
import { formatEpisodePositionText } from './episode-progress.util';
import {
formatEpisodePositionText,
episodeRuntimeSeconds,
} from './episode-progress.util';
import { resolveAutoSelectedSeason } from './season-auto-select.util';
import { SeasonDownloadPresenter } from './season-download-presenter';
import { SeasonTabsComponent } from './season-tabs.component';
@@ -433,8 +435,7 @@ export class SeasonContainerComponent implements OnInit {
/** "42 min · 18m left", "42 min · watched", "42 min" — or null. */
getEpisodeSubline(episode: XtreamSerieEpisode): string | null {
const info = this.getEpisodeInfo(episode);
const seconds = parseDurationSeconds(info?.duration);
const duration = formatDurationLabel(seconds);
const duration = formatDurationLabel(episodeRuntimeSeconds(info));
const position = this.playbackPositions().get(Number(episode.id));
const remaining = formatRemainingLabel(position);
const parts = [