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:
4grayandClaude Fable 5.1 committed 2026-10-03 10:32:22 +02:00
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);
}