From 87cc85a54f4d41b184062fa0d61a08e38eedc6ab Mon Sep 17 00:00:00 2001 From: 4gray Date: Sat, 3 Oct 2026 11:19:22 +0200 Subject: [PATCH] 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 --- libs/portal/shared/util/src/index.ts | 1 + .../src/lib/detail/country-short-name.spec.ts | 5 ++ .../util/src/lib/detail/country-short-name.ts | 18 ++++++ .../replace-owned-external-session.spec.ts | 61 +++++++++++++++++++ .../src/lib/replace-owned-external-session.ts | 43 +++++++++++++ .../stalker-catalog-detail.component.ts | 15 ++--- .../stalker-search.component.ts | 1 + .../stalker-series-view.component.ts | 38 ++++++++++-- .../lib/stalker-vod-detail-actions.spec.ts | 39 ++++++++++++ .../src/lib/stalker-vod-detail-actions.ts | 24 +++++++- .../vod-details/vod-details-menu.service.ts | 5 ++ .../lib/vod-details/vod-details-hero.state.ts | 3 + .../vod-details/vod-details-presentation.ts | 3 + .../dashboard-hero-slides.presenter.spec.ts | 9 ++- 14 files changed, 245 insertions(+), 20 deletions(-) create mode 100644 libs/portal/shared/util/src/lib/replace-owned-external-session.spec.ts create mode 100644 libs/portal/shared/util/src/lib/replace-owned-external-session.ts diff --git a/libs/portal/shared/util/src/index.ts b/libs/portal/shared/util/src/index.ts index 942d05976..a5bc439df 100644 --- a/libs/portal/shared/util/src/index.ts +++ b/libs/portal/shared/util/src/index.ts @@ -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'; diff --git a/libs/portal/shared/util/src/lib/detail/country-short-name.spec.ts b/libs/portal/shared/util/src/lib/detail/country-short-name.spec.ts index 0e771ce6c..1d7f89a85 100644 --- a/libs/portal/shared/util/src/lib/detail/country-short-name.spec.ts +++ b/libs/portal/shared/util/src/lib/detail/country-short-name.spec.ts @@ -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', () => { diff --git a/libs/portal/shared/util/src/lib/detail/country-short-name.ts b/libs/portal/shared/util/src/lib/detail/country-short-name.ts index ccfab04f6..8b0c56e78 100644 --- a/libs/portal/shared/util/src/lib/detail/country-short-name.ts +++ b/libs/portal/shared/util/src/lib/detail/country-short-name.ts @@ -38,6 +38,24 @@ const SHORT_BY_NAME: Record = { сша: 'США', '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', diff --git a/libs/portal/shared/util/src/lib/replace-owned-external-session.spec.ts b/libs/portal/shared/util/src/lib/replace-owned-external-session.spec.ts new file mode 100644 index 000000000..6b57833a2 --- /dev/null +++ b/libs/portal/shared/util/src/lib/replace-owned-external-session.spec.ts @@ -0,0 +1,61 @@ +import type { ExternalPlayerSession } from '@iptvnator/shared/interfaces'; +import { replaceOwnedExternalSession } from './replace-owned-external-session'; + +function session( + overrides: Partial = {} +): 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); + }); +}); diff --git a/libs/portal/shared/util/src/lib/replace-owned-external-session.ts b/libs/portal/shared/util/src/lib/replace-owned-external-session.ts new file mode 100644 index 000000000..cc3a52a79 --- /dev/null +++ b/libs/portal/shared/util/src/lib/replace-owned-external-session.ts @@ -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 { + 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; + } +} diff --git a/libs/portal/stalker/feature/src/lib/stalker-catalog-detail/stalker-catalog-detail.component.ts b/libs/portal/stalker/feature/src/lib/stalker-catalog-detail/stalker-catalog-detail.component.ts index fbc704536..f4d51426b 100644 --- a/libs/portal/stalker/feature/src/lib/stalker-catalog-detail/stalker-catalog-detail.component.ts +++ b/libs/portal/stalker/feature/src/lib/stalker-catalog-detail/stalker-catalog-detail.component.ts @@ -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, diff --git a/libs/portal/stalker/feature/src/lib/stalker-search/stalker-search.component.ts b/libs/portal/stalker/feature/src/lib/stalker-search/stalker-search.component.ts index 34b038909..17bf24a74 100644 --- a/libs/portal/stalker/feature/src/lib/stalker-search/stalker-search.component.ts +++ b/libs/portal/stalker/feature/src/lib/stalker-search/stalker-search.component.ts @@ -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, 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 49908dc96..f5d084d3b 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 @@ -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 { + 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); diff --git a/libs/portal/stalker/feature/src/lib/stalker-vod-detail-actions.spec.ts b/libs/portal/stalker/feature/src/lib/stalker-vod-detail-actions.spec.ts index 75aba49ec..6de2fb3ed 100644 --- a/libs/portal/stalker/feature/src/lib/stalker-vod-detail-actions.spec.ts +++ b/libs/portal/stalker/feature/src/lib/stalker-vod-detail-actions.spec.ts @@ -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, diff --git a/libs/portal/stalker/feature/src/lib/stalker-vod-detail-actions.ts b/libs/portal/stalker/feature/src/lib/stalker-vod-detail-actions.ts index 68190ae90..2aa1d0078 100644 --- a/libs/portal/stalker/feature/src/lib/stalker-vod-detail-actions.ts +++ b/libs/portal/stalker/feature/src/lib/stalker-vod-detail-actions.ts @@ -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; readonly portalPlayer: Pick; + /** 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; 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, diff --git a/libs/portal/xtream/feature/src/lib/vod-details/vod-details-menu.service.ts b/libs/portal/xtream/feature/src/lib/vod-details/vod-details-menu.service.ts index e7902851f..540ff2ea1 100644 --- a/libs/portal/xtream/feature/src/lib/vod-details/vod-details-menu.service.ts +++ b/libs/portal/xtream/feature/src/lib/vod-details/vod-details-menu.service.ts @@ -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', }); } 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 9a75f1e41..e0edda821 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 @@ -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', }) ), }; diff --git a/libs/ui/playback/src/lib/vod-details/vod-details-presentation.ts b/libs/ui/playback/src/lib/vod-details/vod-details-presentation.ts index 8db339e47..5c97a3b07 100644 --- a/libs/ui/playback/src/lib/vod-details/vod-details-presentation.ts +++ b/libs/ui/playback/src/lib/vod-details/vod-details-presentation.ts @@ -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', }); } diff --git a/libs/workspace/dashboard/feature/src/lib/rails/dashboard-hero-slides.presenter.spec.ts b/libs/workspace/dashboard/feature/src/lib/rails/dashboard-hero-slides.presenter.spec.ts index 155ecbacc..91c68ea53 100644 --- a/libs/workspace/dashboard/feature/src/lib/rails/dashboard-hero-slides.presenter.spec.ts +++ b/libs/workspace/dashboard/feature/src/lib/rails/dashboard-hero-slides.presenter.spec.ts @@ -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' } }, ]);