fix(portals): fifth review round on the cinematic details

- Stalker movie and series menu launches in MPV/VLC close the title's
  own running external session first through the new shared
  replaceOwnedExternalSession(), cancelling when teardown is not
  confirmed or the selection moved on.
- The reset-progress row is disabled while an external player owns the
  position, on the shared and the Xtream movie menus.
- Every ISO comma-bearing country name stays whole in provider lists.
- The dashboard hero spec competes a favorite movie for a slot so the
  movie-title case is actually exercised.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
4grayandClaude Fable 5.1 committed 2026-10-03 11:19:22 +02:00
1 parent 026a69c14e
commit 87cc85a54f
14 files changed
+245 -20

No files matched your search

+1
View File
@@ -37,6 +37,7 @@ export * from './lib/navigation/workspace-portal-navigation';
export * from './lib/collection';
export * from './lib/xtream-catchup.util';
export * from './lib/external-playback-button-state';
export * from './lib/replace-owned-external-session';
export * from './lib/inline-playback-position-writer';
export * from './lib/downloads/download-metadata-snapshot';
export * from './lib/downloads/episode-download-identity';
@@ -26,6 +26,11 @@ describe('shortCountryName', () => {
'France',
'Taiwan',
]);
expect(
shortCountryList(
'Iran, Islamic Republic of, Moldova, Republic of; Bolivia, Plurinational State of'
)
).toEqual(['Iran', 'Moldova', 'Bolivia']);
});
it('splits provider lists', () => {
@@ -38,6 +38,24 @@ const SHORT_BY_NAME: Record<string, string> = {
сша: 'США',
'republic of korea': 'South Korea',
'korea, republic of': 'South Korea',
"korea, democratic people's republic of": 'North Korea',
'korea, democratic people’s republic of': 'North Korea',
'iran, islamic republic of': 'Iran',
'bolivia, plurinational state of': 'Bolivia',
'venezuela, bolivarian republic of': 'Venezuela',
'tanzania, united republic of': 'Tanzania',
'moldova, republic of': 'Moldova',
'micronesia, federated states of': 'Micronesia',
'congo, democratic republic of the': 'DR Congo',
'congo, the democratic republic of the': 'DR Congo',
'macedonia, the former yugoslav republic of': 'North Macedonia',
'palestine, state of': 'Palestine',
'virgin islands, british': 'British Virgin Islands',
'virgin islands, u.s.': 'US Virgin Islands',
'bonaire, sint eustatius and saba': 'Bonaire',
'saint helena, ascension and tristan da cunha': 'Saint Helena',
'gambia, the': 'Gambia',
'bahamas, the': 'Bahamas',
'czech republic': 'Czechia',
'islamic republic of iran': 'Iran',
'syrian arab republic': 'Syria',
@@ -0,0 +1,61 @@
import type { ExternalPlayerSession } from '@iptvnator/shared/interfaces';
import { replaceOwnedExternalSession } from './replace-owned-external-session';
function session(
overrides: Partial<ExternalPlayerSession> = {}
): ExternalPlayerSession {
return {
id: 'mpv-1',
player: 'mpv',
status: 'opened',
canClose: true,
contentInfo: {
playlistId: 'portal-1',
contentXtreamId: 42,
contentType: 'vod',
},
...overrides,
} as ExternalPlayerSession;
}
describe('replaceOwnedExternalSession', () => {
function playback(active: ExternalPlayerSession | null) {
return {
activeSession: () => active,
closeSession: jest.fn().mockResolvedValue(undefined),
};
}
it('closes the owned session and reports the replacement as safe', async () => {
const running = session();
const external = playback(running);
await expect(
replaceOwnedExternalSession(
external,
(info) => info.contentXtreamId === 42
)
).resolves.toBe(true);
expect(external.closeSession).toHaveBeenCalledWith(running);
});
it('leaves a session the page does not own alone', async () => {
const external = playback(session());
await expect(
replaceOwnedExternalSession(
external,
(info) => info.contentXtreamId === 7
)
).resolves.toBe(true);
expect(external.closeSession).not.toHaveBeenCalled();
});
it('cancels the replacement when the close fails', async () => {
const external = playback(session());
external.closeSession.mockRejectedValue(new Error('busy'));
const warn = jest.fn();
await expect(
replaceOwnedExternalSession(external, () => true, warn)
).resolves.toBe(false);
expect(warn).toHaveBeenCalledTimes(1);
});
});
@@ -0,0 +1,43 @@
import type {
ExternalPlayerSession,
PlayerContentInfo,
} from '@iptvnator/shared/interfaces';
import { isLiveExternalPlayerSession } from './external-playback-button-state';
import type { PortalExternalPlayback } from './portal-external-playback';
/**
* Closes the external session a page owns before it launches a replacement
* (with instance reuse off a second detached player would start beside it).
* `owns` names the sessions the page may replace; anything else is left
* alone. False when teardown could not be confirmed: the caller keeps the
* running player and cancels the launch.
*/
export async function replaceOwnedExternalSession(
externalPlayback: Pick<
PortalExternalPlayback,
'activeSession' | 'closeSession'
>,
owns: (info: PlayerContentInfo) => boolean,
warn: (message: string, error: unknown) => void = (message, error) =>
console.warn(message, error)
): Promise<boolean> {
const session: ExternalPlayerSession | null =
externalPlayback.activeSession();
const info = session?.contentInfo;
if (!session || !info || session.status === 'closed' || !owns(info)) {
return true;
}
if (isLiveExternalPlayerSession(session) && !session.canClose) {
return false;
}
try {
await externalPlayback.closeSession(session);
return true;
} catch (error) {
warn(
'Closing the previous external player failed; cancelling the replacement.',
error
);
return false;
}
}
@@ -234,12 +234,13 @@ export class StalkerCatalogDetailComponent implements OnDestroy {
) ?? null;
}
onVodPlay(item: VodDetailsItem): void {
onVodPlay(item: VodDetailsItem, positionSeconds?: number): void {
if (item.type === 'stalker') {
void this.startStalkerVodPlayback(
item.cmd,
item.data.info?.name,
item.data.info?.movie_image
item.data.info?.movie_image,
positionSeconds
);
}
}
@@ -248,14 +249,7 @@ export class StalkerCatalogDetailComponent implements OnDestroy {
item: VodDetailsItem;
positionSeconds: number;
}): void {
if (event.item.type === 'stalker') {
void this.startStalkerVodPlayback(
event.item.cmd,
event.item.data.info?.name,
event.item.data.info?.movie_image,
event.positionSeconds
);
}
this.onVodPlay(event.item, event.positionSeconds);
}
onVodFavoriteToggled(event: {
@@ -281,6 +275,7 @@ export class StalkerCatalogDetailComponent implements OnDestroy {
resolvePlayback: (cmd, title, thumbnail, startTime) =>
this.catalog.resolveVodPlayback(cmd, title, thumbnail, startTime),
portalPlayer: this.portalPlayer,
externalPlayback: this.externalPlayback,
playbackPositions: this.playbackPositions,
playlistId: () => this.catalog.playlist()?.id,
selectedVodId: () => Number(this.selectedItem()?.id) || null,
@@ -910,6 +910,7 @@ export class StalkerSearchComponent {
startTime
),
portalPlayer: this.portalPlayer,
externalPlayback: this.externalPlayback,
playbackPositions: this.playbackPositions,
playlistId: () => this.stalkerStore.currentPlaylist()?._id,
selectedVodId: () => Number(this.itemDetails()?.id) || null,
@@ -55,6 +55,7 @@ import {
} from '@iptvnator/shared/interfaces';
import {
isLiveExternalPlayerSession,
replaceOwnedExternalSession,
isPortalPlaybackWatched,
PORTAL_EXTERNAL_PLAYBACK,
PORTAL_PLAYBACK_POSITIONS,
@@ -393,7 +394,9 @@ export class StalkerSeriesViewComponent implements OnDestroy {
this.menu.bind({
quickStart: this.quickStartAction,
seasonContainer: this.seasonContainerRef,
hasProgress: computed(() => this.episodePlaybackPositions().size > 0),
hasProgress: computed(
() => this.episodePlaybackPositions().size > 0
),
resetProgress: () => this.resetProgress(),
openExternal: (player) => this.openQuickStartExternally(player),
});
@@ -1202,6 +1205,29 @@ export class StalkerSeriesViewComponent implements OnDestroy {
this.playNextEpisode();
}
/**
* The "…" menu's MPV/VLC launch: an episode of this series still running
* externally is closed first, never doubled; a failed close or a page
* that moved on meanwhile keeps the running player.
*/
private async openEpisodeExternally(
playback: ResolvedPortalPlayback,
player: ExternalPlayerName,
request: StalkerSeriesPlaybackRequestContext
): Promise<void> {
const own = playback.contentInfo;
const replaced = await replaceOwnedExternalSession(
this.externalPlayback,
(info) =>
info.contentType === 'episode' &&
info.playlistId === own?.playlistId &&
info.seriesXtreamId === own?.seriesXtreamId,
(message, error) => this.logger.warn(message, error)
);
if (!replaced || !this.isPlaybackRequestCurrent(request)) return;
await this.portalPlayer.openExternalPlayback(playback, player);
}
private async startPlayback(
cmd: string | undefined,
title: string | undefined,
@@ -1257,11 +1283,15 @@ export class StalkerSeriesViewComponent implements OnDestroy {
this.closeInlinePlayer();
void (forcePlayer
? this.portalPlayer.openExternalPlayback(
? this.openEpisodeExternally(
resolvedPlayback,
forcePlayer
forcePlayer,
request
)
: this.portalPlayer.openResolvedPlayback(resolvedPlayback, true));
: this.portalPlayer.openResolvedPlayback(
resolvedPlayback,
true
));
} catch (error) {
if (!this.isPlaybackRequestCurrent(request)) return;
this.logger.error('Failed to start inline series playback', error);
@@ -1,5 +1,6 @@
import { signal } from '@angular/core';
import type {
ExternalPlayerSession,
PlaybackPositionData,
VodDetailsItem,
} from '@iptvnator/shared/interfaces';
@@ -28,9 +29,12 @@ describe('createStalkerVodDetailActions openExternal', () => {
);
const openExternalPlayback = jest.fn().mockResolvedValue(undefined);
const beforeExternalLaunch = jest.fn();
const closeSession = jest.fn().mockResolvedValue(undefined);
let running: ExternalPlayerSession | null = null;
const actions = createStalkerVodDetailActions({
resolvePlayback: resolvePlayback as never,
portalPlayer: { openExternalPlayback },
externalPlayback: { activeSession: () => running, closeSession },
playbackPositions: { clearPlaybackPositionOrThrow: jest.fn() },
playlistId: () => 'portal-1',
selectedVodId,
@@ -44,10 +48,41 @@ describe('createStalkerVodDetailActions openExternal', () => {
actions,
openExternalPlayback,
beforeExternalLaunch,
closeSession,
setRunning: (session: ExternalPlayerSession | null) =>
(running = session),
resolveLink: () => resolveLink({ streamUrl: 'http://cdn/42.mp4' }),
};
}
it('closes the movie that is already playing externally before relaunching', async () => {
const t = setup(() => 42);
const running = {
id: 'mpv-1',
player: 'mpv',
status: 'opened',
canClose: true,
contentInfo: {
playlistId: 'portal-1',
contentXtreamId: 42,
contentType: 'vod',
},
} as ExternalPlayerSession;
t.setRunning(running);
const launch = t.actions.openExternal({
item: MOVIE,
player: 'mpv',
positionSeconds: null,
});
t.resolveLink();
await launch;
expect(t.closeSession).toHaveBeenCalledWith(running);
expect(t.closeSession.mock.invocationCallOrder[0]).toBeLessThan(
t.openExternalPlayback.mock.invocationCallOrder[0]
);
});
it('launches the resolved stream while the movie is still selected', async () => {
const t = setup(() => 42);
const launch = t.actions.openExternal({
@@ -98,6 +133,10 @@ describe('createStalkerVodDetailActions resetProgress', () => {
const actions = createStalkerVodDetailActions({
resolvePlayback: jest.fn(),
portalPlayer: { openExternalPlayback: jest.fn() },
externalPlayback: {
activeSession: () => null,
closeSession: jest.fn(),
},
playbackPositions: { clearPlaybackPositionOrThrow },
playlistId: () => 'portal-1',
selectedVodId,
@@ -1,9 +1,11 @@
import type { WritableSignal } from '@angular/core';
import type { MatSnackBar } from '@angular/material/snack-bar';
import type { TranslateService } from '@ngx-translate/core';
import type {
PortalPlaybackPositions,
PortalPlayer,
import {
replaceOwnedExternalSession,
type PortalExternalPlayback,
type PortalPlaybackPositions,
type PortalPlayer,
} from '@iptvnator/portal/shared/util';
import type {
ExternalPlayerName,
@@ -27,6 +29,11 @@ interface StalkerVodDetailActionsDeps {
startTime: number | undefined
) => Promise<ResolvedPortalPlayback>;
readonly portalPlayer: Pick<PortalPlayer, 'openExternalPlayback'>;
/** The running external session; this movie's own is replaced, never doubled. */
readonly externalPlayback: Pick<
PortalExternalPlayback,
'activeSession' | 'closeSession'
>;
/** Download of the movie file; absent for hosts without downloads. */
readonly download?: (item: VodDetailsItem) => Promise<void>;
readonly playbackPositions: Pick<
@@ -101,6 +108,17 @@ export function createStalkerVodDetailActions(
if (!stillSelected()) {
return;
}
const replaced = await replaceOwnedExternalSession(
deps.externalPlayback,
(info) =>
info.contentType === 'vod' &&
info.playlistId === playlistId &&
Number(info.contentXtreamId) === vodId,
deps.logError
);
if (!replaced || !stillSelected()) {
return;
}
deps.beforeExternalLaunch?.();
await deps.portalPlayer.openExternalPlayback(
playback,
@@ -111,6 +111,11 @@ export class VodDetailsMenuService {
id: VOD_MENU_ACTION.ResetProgress,
labelKey: 'PORTALS.DETAIL.RESET_PROGRESS',
icon: 'history_toggle_off',
// A running player would write the position right back.
disabled:
this.playback.isExternalStopAction() ||
this.playback.playbackStartPending() ||
this.playback.inlinePlayback() !== null,
testId: 'vod-menu-reset-progress',
});
}
@@ -114,6 +114,9 @@ export function createVodDetailsHeroState(deps: VodDetailsHeroStateDeps) {
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',
})
),
};
@@ -164,6 +164,8 @@ export function buildVodMenuSections(input: {
externalPlayerHint: 'MPV' | 'VLC';
hasPlaybackPosition: boolean;
hasStoredProgress: boolean;
/** An external player owns the row: its next tick would undo a reset. */
playbackActive: boolean;
}): VodMoreMenuSection[] {
const sourceRows: VodMoreMenuSection['items'][number][] = [];
if (input.externalPlayerAvailable) {
@@ -189,6 +191,7 @@ export function buildVodMenuSections(input: {
id: VOD_DETAILS_MENU_ACTION.ResetProgress,
labelKey: 'PORTALS.DETAIL.RESET_PROGRESS',
icon: 'history_toggle_off',
disabled: input.playbackActive,
testId: 'vod-menu-reset-progress',
});
}
@@ -231,10 +231,13 @@ describe('DashboardHeroSlidesPresenter', () => {
});
it('drops a season marker from a series title and leaves movies and live titles alone', () => {
recentItems.set([
{ ...series, title: 'Big Pharma (1 сезон)' },
{ ...watchedMovie, title: 'Film Season 2' },
// The rotation holds four slides: only the series, the live channel
// and a favorite movie compete here (the finished film never does).
recentItems.set([{ ...series, title: 'Big Pharma (1 сезон)' }]);
favorites.set([
{ ...favoriteFilm, id: 9, type: 'movie', title: 'Film Season 2' },
]);
addedItems.set([]);
candidates.set([
{ origin: 'favorite', item: { ...channel, title: 'Sport S01' } },
]);