mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-14 05:06:16 -08:00
fix(portals): second review round on the cinematic details
- The movie menu's MPV/VLC launch is a regular route-owned start with the player forced: a running external session is replaced through the same guard instead of a second player starting beside it. - The trailer-backdrop setting is shown for every player; it is a details-page feature, not a web-player control. - The series menu matches the recent list by content type as well, so a movie sharing the provider id does not stand in for the series row, and the hide action follows the refreshed list instead of a permanent hidden marker. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
1 parent
36ce98e18c
commit
efd70b75e6
6 files changed
+104
-78
No files matched your search
@@ -114,33 +114,6 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="setting-item"
|
||||
data-setting-id="detail-trailer-backdrop"
|
||||
data-test-id="detail-trailer-backdrop-setting"
|
||||
>
|
||||
<div class="setting-item__meta">
|
||||
<h4>
|
||||
{{ 'SETTINGS.PLAY_TRAILERS_IN_BACKGROUND' | translate }}
|
||||
</h4>
|
||||
<p>
|
||||
{{
|
||||
'SETTINGS.PLAY_TRAILERS_IN_BACKGROUND_DESCRIPTION'
|
||||
| translate
|
||||
}}
|
||||
</p>
|
||||
</div>
|
||||
<div class="setting-item__control setting-item__toggle">
|
||||
<mat-checkbox
|
||||
[aria-label]="
|
||||
'SETTINGS.PLAY_TRAILERS_IN_BACKGROUND' | translate
|
||||
"
|
||||
formControlName="detailTrailerBackdrop"
|
||||
data-test-id="detail-trailer-backdrop-toggle"
|
||||
></mat-checkbox>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="setting-item"
|
||||
data-setting-id="player-up-next-rail"
|
||||
@@ -164,6 +137,34 @@
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- A details-page feature: shown for every player, not only the web ones. -->
|
||||
<div
|
||||
class="setting-item"
|
||||
data-setting-id="detail-trailer-backdrop"
|
||||
data-test-id="detail-trailer-backdrop-setting"
|
||||
>
|
||||
<div class="setting-item__meta">
|
||||
<h4>
|
||||
{{ 'SETTINGS.PLAY_TRAILERS_IN_BACKGROUND' | translate }}
|
||||
</h4>
|
||||
<p>
|
||||
{{
|
||||
'SETTINGS.PLAY_TRAILERS_IN_BACKGROUND_DESCRIPTION'
|
||||
| translate
|
||||
}}
|
||||
</p>
|
||||
</div>
|
||||
<div class="setting-item__control setting-item__toggle">
|
||||
<mat-checkbox
|
||||
[aria-label]="
|
||||
'SETTINGS.PLAY_TRAILERS_IN_BACKGROUND' | translate
|
||||
"
|
||||
formControlName="detailTrailerBackdrop"
|
||||
data-test-id="detail-trailer-backdrop-toggle"
|
||||
></mat-checkbox>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (supportsUpNextCard()) {
|
||||
<div
|
||||
class="setting-item"
|
||||
|
||||
@@ -63,8 +63,6 @@ export class SerialDetailsMenuService {
|
||||
private readonly translate = inject(TranslateService);
|
||||
private readonly logger = createLogger('SerialDetailsMenu');
|
||||
private readonly bindings = signal<SerialDetailsMenuBindings | null>(null);
|
||||
/** Set once "Hide from Continue Watching" ran for the current series. */
|
||||
private readonly hiddenSeriesIds = signal<ReadonlySet<number>>(new Set());
|
||||
|
||||
bind(bindings: SerialDetailsMenuBindings): void {
|
||||
this.bindings.set(bindings);
|
||||
@@ -111,18 +109,20 @@ export class SerialDetailsMenuService {
|
||||
/** Whether the series row sits in this playlist's recently viewed list. */
|
||||
private readonly inContinueWatching = computed(() => {
|
||||
const seriesId = this.seriesId();
|
||||
if (
|
||||
!seriesId ||
|
||||
!this.dataSource ||
|
||||
this.hiddenSeriesIds().has(seriesId)
|
||||
) {
|
||||
if (!seriesId || !this.dataSource) {
|
||||
return false;
|
||||
}
|
||||
// Xtream ids collide across live, movies and series: the row has to
|
||||
// be the series' own.
|
||||
return (
|
||||
this.hasProgress() &&
|
||||
this.xtreamStore
|
||||
.recentItems()
|
||||
.some((item) => Number(item.xtream_id) === seriesId)
|
||||
.some(
|
||||
(item) =>
|
||||
item.type === 'series' &&
|
||||
Number(item.xtream_id) === seriesId
|
||||
)
|
||||
);
|
||||
});
|
||||
|
||||
@@ -281,7 +281,8 @@ export class SerialDetailsMenuService {
|
||||
return;
|
||||
}
|
||||
await this.dataSource.removeRecentItem(content.id, playlistId);
|
||||
this.hiddenSeriesIds.update((ids) => new Set(ids).add(seriesId));
|
||||
// The refreshed list drops the row, and brings it back once the
|
||||
// series is played again.
|
||||
this.xtreamStore.loadRecentItems({ id: playlistId });
|
||||
this.notify('PORTALS.DETAIL.HIDDEN_FROM_CONTINUE_WATCHING');
|
||||
} catch (error) {
|
||||
|
||||
@@ -819,44 +819,29 @@ describe('SerialDetailsComponent', () => {
|
||||
expect(quickStartButton()?.textContent).toContain('S01E01');
|
||||
});
|
||||
|
||||
it('records history and the launch position when the menu opens the next episode in MPV', async () => {
|
||||
openExternalPlayback.mockResolvedValue({
|
||||
id: 'mpv-session-2',
|
||||
player: 'mpv',
|
||||
status: 'opened',
|
||||
});
|
||||
it('records history when the menu opens the next episode in MPV', async () => {
|
||||
const streamUrl = 'http://xtream.example/series/1001.mp4';
|
||||
fixture.detectChanges();
|
||||
await fixture.whenStable();
|
||||
|
||||
const menu = fixture.debugElement.injector.get(
|
||||
SerialDetailsMenuService
|
||||
);
|
||||
await menu.run('external-player');
|
||||
await fixture.whenStable();
|
||||
|
||||
// The regular episode start with the player forced: same history
|
||||
// and launch-position bookkeeping as the Play button.
|
||||
expect(openResolvedPlayback).not.toHaveBeenCalled();
|
||||
expect(openExternalPlayback).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
streamUrl: 'http://xtream.example/series/1001.mp4',
|
||||
}),
|
||||
expect.objectContaining({ streamUrl }),
|
||||
'mpv'
|
||||
);
|
||||
// Same bookkeeping as the Play button: the view lands once the
|
||||
// stream plays, the launch position is written right away.
|
||||
TestBed.inject(PlaybackHistoryGate).confirm({
|
||||
streamUrls: ['http://xtream.example/series/1001.mp4'],
|
||||
streamUrls: [streamUrl],
|
||||
});
|
||||
expect(addRecentItem).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ xtreamId: '103', contentType: 'series' })
|
||||
);
|
||||
expect(savePlaybackPosition).toHaveBeenCalledWith(
|
||||
'xtream-1',
|
||||
expect.objectContaining({
|
||||
contentXtreamId: 1001,
|
||||
contentType: 'episode',
|
||||
positionSeconds: 0,
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('persists the launched episode after an external fallback succeeds', async () => {
|
||||
|
||||
@@ -136,7 +136,7 @@ export class VodDetailsMenuService {
|
||||
this.msUi.hasPlaybackPosition(),
|
||||
this.externalPlayer()
|
||||
)
|
||||
?.catch((error) =>
|
||||
.catch((error) =>
|
||||
this.logger.warn('External launch failed', error)
|
||||
);
|
||||
return;
|
||||
|
||||
@@ -194,6 +194,43 @@ describe('VodDetailsPlaybackService — external session ownership', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('replaces a running external session when the menu relaunches the movie', async () => {
|
||||
const launched = sessionFor(ROUTE_PLAYLIST, ROUTE_VOD_ID);
|
||||
await service.startResolvedPlayback({
|
||||
streamUrl: 'https://example.com/route.mkv',
|
||||
title: 'Route movie',
|
||||
contentInfo: launched.contentInfo,
|
||||
});
|
||||
activeSession.set(launched);
|
||||
closeSession.mockClear();
|
||||
openExternalPlayback.mockClear();
|
||||
|
||||
await service.openInExternalPlayer(
|
||||
{
|
||||
info: {},
|
||||
movie_data: {
|
||||
stream_id: ROUTE_VOD_ID,
|
||||
name: 'Route movie',
|
||||
container_extension: 'mkv',
|
||||
},
|
||||
} as never,
|
||||
false,
|
||||
'mpv'
|
||||
);
|
||||
|
||||
// Replaced, never doubled: the running player closes first.
|
||||
expect(closeSession).toHaveBeenCalledWith(launched);
|
||||
expect(closeSession.mock.invocationCallOrder[0]).toBeLessThan(
|
||||
openExternalPlayback.mock.invocationCallOrder[0]
|
||||
);
|
||||
expect(openExternalPlayback).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
streamUrl: 'https://example.com/route.mkv',
|
||||
}),
|
||||
'mpv'
|
||||
);
|
||||
});
|
||||
|
||||
it('records the movie as recently viewed only once its stream played', async () => {
|
||||
await service.startResolvedPlayback({
|
||||
streamUrl: 'https://example.com/broken.mkv',
|
||||
|
||||
@@ -307,25 +307,19 @@ export class VodDetailsPlaybackService {
|
||||
}
|
||||
|
||||
/**
|
||||
* The "…" menu's explicit MPV/VLC launch. It counts as a view like any
|
||||
* start; `launchExternal` alone serves the inline player's fallback,
|
||||
* whose start already recorded the item.
|
||||
* The "…" menu's explicit MPV/VLC launch: a regular route-owned start
|
||||
* with the player forced, so the view is recorded and a running
|
||||
* session is replaced rather than doubled.
|
||||
*/
|
||||
openInExternalPlayer(
|
||||
vodItem: XtreamVodDetails | null,
|
||||
resume: boolean,
|
||||
player: ExternalPlayerName
|
||||
): Promise<ExternalPlayerSession | void> | null {
|
||||
): Promise<boolean> {
|
||||
const playback = this.buildVodPlayback(vodItem, resume);
|
||||
if (!playback) {
|
||||
return null;
|
||||
}
|
||||
this.recordRecentItem(playback.streamUrl, {
|
||||
xtreamId: this.bindings()?.vodId() ?? NaN,
|
||||
contentType: 'movie',
|
||||
backdropUrl: this.bindings()?.vodInfo()?.backdrop_path?.[0],
|
||||
});
|
||||
return this.launchExternal(playback, player);
|
||||
return playback
|
||||
? this.startPlayback(playback, player)
|
||||
: Promise.resolve(false);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -423,7 +417,8 @@ export class VodDetailsPlaybackService {
|
||||
|
||||
async startResolvedPlayback(
|
||||
playback: ResolvedPortalPlayback,
|
||||
isCurrent: () => boolean = () => true
|
||||
isCurrent: () => boolean = () => true,
|
||||
player?: ExternalPlayerName
|
||||
): Promise<boolean> {
|
||||
if (this.externalLaunchGeneration() !== null) {
|
||||
return false;
|
||||
@@ -472,21 +467,26 @@ export class VodDetailsPlaybackService {
|
||||
contentType: 'movie',
|
||||
backdropUrl: this.bindings()?.vodInfo()?.backdrop_path?.[0],
|
||||
});
|
||||
return await this.applyPlayback(playback, isCurrent);
|
||||
return await this.applyPlayback(playback, isCurrent, player);
|
||||
} finally {
|
||||
this.pendingStart.settle(startId);
|
||||
}
|
||||
}
|
||||
|
||||
private startPlayback(playback: ResolvedPortalPlayback): Promise<boolean> {
|
||||
private startPlayback(
|
||||
playback: ResolvedPortalPlayback,
|
||||
player?: ExternalPlayerName
|
||||
): Promise<boolean> {
|
||||
return startRouteOwnedPlayback(this.externalLaunchOwner, (isCurrent) =>
|
||||
this.startResolvedPlayback(playback, isCurrent)
|
||||
this.startResolvedPlayback(playback, isCurrent, player)
|
||||
);
|
||||
}
|
||||
|
||||
/** `player` forces MPV/VLC regardless of the configured player. */
|
||||
private async applyPlayback(
|
||||
playback: ResolvedPortalPlayback,
|
||||
isCurrent: () => boolean = () => true
|
||||
isCurrent: () => boolean = () => true,
|
||||
player?: ExternalPlayerName
|
||||
): Promise<boolean> {
|
||||
// EVERY start claims the generation, not just the switch path. Play,
|
||||
// Resume and Restart reach here directly, and a switch still waiting
|
||||
@@ -494,7 +494,7 @@ export class VodDetailsPlaybackService {
|
||||
// launch on top of what the user just chose.
|
||||
const generation = ++this.startGeneration;
|
||||
this.positionWriter.reset();
|
||||
if (this.portalPlayer.isEmbeddedPlayer()) {
|
||||
if (!player && this.portalPlayer.isEmbeddedPlayer()) {
|
||||
this.inlinePlayback.set(playback);
|
||||
this.externalLaunchOwner.clear();
|
||||
this.externalLaunchGeneration.set(null);
|
||||
@@ -503,7 +503,9 @@ export class VodDetailsPlaybackService {
|
||||
|
||||
this.closeInlinePlayer();
|
||||
this.claimExternalLaunch(playback, generation);
|
||||
const launch = this.portalPlayer.openResolvedPlayback(playback, true);
|
||||
const launch = player
|
||||
? this.portalPlayer.openExternalPlayback(playback, player)
|
||||
: this.portalPlayer.openResolvedPlayback(playback, true);
|
||||
return await this.settleExternalLaunch(generation, isCurrent, launch);
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user