mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-08 17:06:15 -08:00
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:
1 parent
026a69c14e
commit
87cc85a54f
14 files changed
+245
-20
No files matched your search
@@ -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;
|
||||
}
|
||||
}
|
||||
+5
-10
@@ -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,
|
||||
|
||||
+34
-4
@@ -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',
|
||||
});
|
||||
}
|
||||
|
||||
+6
-3
@@ -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' } },
|
||||
]);
|
||||
|
||||
Reference in new issue
Block a user