feat(playback): add embedded mpv series navigation

This commit is contained in:
4gray committed 2026-06-07 20:57:14 +02:00
1 parent c93b0b4a77
commit 4e30289293
24 files changed
+1045 -13

No files matched your search

@@ -70,6 +70,7 @@ enum class SessionStatus {
Loading,
Playing,
Paused,
Ended,
Error,
Closed,
};
@@ -157,6 +158,8 @@ std::string toStatusString(SessionStatus status)
return "playing";
case SessionStatus::Paused:
return "paused";
case SessionStatus::Ended:
return "ended";
case SessionStatus::Error:
return "error";
case SessionStatus::Closed:
@@ -1128,6 +1131,11 @@ void runEventLoop(const std::shared_ptr<Session>& session)
endFile->error < 0
? mpv_error_string(endFile->error)
: "Playback failed.";
} else if (
endFile &&
endFile->reason == MPV_END_FILE_REASON_EOF &&
session->running.load()) {
session->snapshot.status = SessionStatus::Ended;
} else if (session->running.load()) {
session->snapshot.status = SessionStatus::Idle;
}
@@ -1163,6 +1171,7 @@ void runEventLoop(const std::shared_ptr<Session>& session)
property->data) {
session->paused = *static_cast<int*>(property->data) != 0;
if (session->snapshot.status != SessionStatus::Loading &&
session->snapshot.status != SessionStatus::Ended &&
session->snapshot.status != SessionStatus::Error &&
session->loadedPath) {
session->snapshot.status = session->paused
@@ -1912,6 +1921,7 @@ Napi::Value SetPaused(const Napi::CallbackInfo& info)
std::lock_guard<std::mutex> lock(session->mutex);
session->paused = paused != 0;
if (session->loadedPath &&
session->snapshot.status != SessionStatus::Ended &&
session->snapshot.status != SessionStatus::Error) {
session->snapshot.status = session->paused
? SessionStatus::Paused
@@ -45,4 +45,26 @@ describe('Embedded MPV native source recording invariants', () => {
);
expect(body).toContain('stopRecordingRequestId,');
});
it('maps successful MPV end-file events to an ended session status', () => {
expect(nativeSource).toContain('Ended,');
expect(nativeSource).toContain('case SessionStatus::Ended:');
const eventLoopBodyStart = nativeSource.indexOf('void runEventLoop(');
expect(eventLoopBodyStart).toBeGreaterThanOrEqual(0);
const endFileCaseStart = nativeSource.indexOf(
'case MPV_EVENT_END_FILE',
eventLoopBodyStart
);
expect(endFileCaseStart).toBeGreaterThanOrEqual(0);
const nextCaseStart = nativeSource.indexOf(
'case MPV_EVENT_PROPERTY_CHANGE',
endFileCaseStart
);
expect(nextCaseStart).toBeGreaterThan(endFileCaseStart);
const endFileCase = nativeSource.slice(endFileCaseStart, nextCaseStart);
expect(endFileCase).toContain('SessionStatus::Ended');
expect(endFileCase).toContain('MPV_END_FILE_REASON_EOF');
});
});
@@ -197,6 +197,17 @@ describe('EmbeddedMpvNativeService power blocker', () => {
expect(powerSaveBlockerMock.stop).toHaveBeenCalledWith(1);
});
it('releases the blocker when MPV reports playback ended', () => {
startSession('s1', snapshot('playing'));
expect(powerSaveBlockerMock.start).toHaveBeenCalledTimes(1);
addon.getSessionSnapshot.mockReturnValueOnce(snapshot('ended'));
service.setPaused('s1', false);
expect(powerSaveBlockerMock.stop).toHaveBeenCalledTimes(1);
expect(powerSaveBlockerMock.stop).toHaveBeenCalledWith(1);
});
it('keeps the blocker held while any other session is still playing', () => {
startSession('s1', snapshot('playing'));
startSession('s2', snapshot('playing'));
+17 -1
View File
@@ -73,6 +73,21 @@ Subtitle tracks mirror the audio-track contract: same `track-list` source, same
The renderer learns which features the loaded addon binary supports through the `EmbeddedMpvSupport.capabilities` field returned from `getEmbeddedMpvSupport()`. The service probes `typeof addon.<method> === 'function'` for each optional native export. Older addon binaries with the original audio-only surface return `capabilities: { subtitles: false, playbackSpeed: false, aspectOverride: false, screenshot: false, recording: false }`, and the renderer hides the corresponding controls instead of throwing at runtime. After a native rebuild, the new buttons light up automatically without renderer changes.
## Session End And Series Navigation
`EmbeddedMpvSessionStatus` includes `ended` for successful EOF only. The native addon maps `MPV_EVENT_END_FILE` to:
- `ended` when the end-file reason is `MPV_END_FILE_REASON_EOF`
- `error` when MPV reports an end-file error
- `idle` for other successful end-file reasons such as replacement/stop
- `closed` only for dispose/manual teardown
Renderer autoplay must use `ended` only. It must not treat `closed`, `idle`, or `error` as a request to continue to the next episode.
Series episode navigation is owned by the portal feature components and passed through the shared inline player to `EmbeddedMpvPlayerComponent`. The embedded MPV controls show `skip_previous` and `skip_next` buttons only as part of the embedded player control surface. The shared navigation payload contains `canPrevious`, `canNext`, and `autoplayEnabled`; the component disables previous/next at the current-season boundaries and guards the output handlers as well as the button disabled state.
Autoplay is enabled by default for series playback in embedded MPV. On `ended`, Xtream and Stalker series detail views start the next episode only when the current episode has a next item in the same season. Playback stops on the last episode of the current season. Previous always switches to the previous episode in the current season; it does not implement a restart-threshold behavior.
## Live Stream Recording
Embedded MPV can record live streams through mpv's `stream-record` option. IPTVnator exposes this only for `ResolvedPortalPlayback.isLive === true`; VOD, episodes, catchup playback, radio audio playback, and non-embedded players do not show the recording control.
@@ -135,7 +150,7 @@ Every IPC call goes through a `guardIpc` helper that swallows addon-side throws
### Power management
The Electron main process holds an `electron.powerSaveBlocker` of type `prevent-display-sleep` whenever any embedded MPV session has status `playing`. Released on pause, dispose, or shutdown. Necessary because libmpv-rendered video does not own the windowing surface, so MPV's own screensaver inhibition does not apply. See `EmbeddedMpvNativeService.updatePowerBlocker()` for the implementation.
The Electron main process holds an `electron.powerSaveBlocker` of type `prevent-display-sleep` whenever any embedded MPV session has status `playing`. Released on pause, EOF (`ended`), dispose, or shutdown. Necessary because libmpv-rendered video does not own the windowing surface, so MPV's own screensaver inhibition does not apply. See `EmbeddedMpvNativeService.updatePowerBlocker()` for the implementation.
## Packaging State
@@ -257,6 +272,7 @@ Do not expose embedded MPV broadly until these pass on both Apple Silicon and In
- packaged `.app` starts without Homebrew installed
- bundled `libmpv` and dependent dylibs pass code signing and notarization
- VOD resume starts near the saved offset
- series EOF emits `ended` and embedded MPV auto-continues only inside the current season
- live HLS, MPEG-TS, MP4/VOD, headers, referrer, volume, seek, fullscreen, route changes, and cleanup work
- audio-track switching works on a stream with multiple audio tracks
- live stream recording starts, stops, writes a `.ts` file in Downloads/custom recording folder, and stops on route/playback changes
+9
View File
@@ -180,6 +180,15 @@ Stalker has multiple real-world data shapes. The current implementation supports
metadata and rendered as `SxxE01` until episode details are loaded.
- Uses unique generated tracking IDs for episode playback position compatibility.
Series inline playback behavior is shared across all three modes:
- `StalkerSeriesViewComponent` maps every mode into `mappedSeasons()` and derives the currently playing episode from `inlinePlayback.contentInfo.contentXtreamId`.
- The inline player header shows the current episode metadata below the title, for example `S01E03 - Episode title`.
- When experimental embedded MPV is active, the player receives previous/next episode state for the current season only.
- Embedded MPV autoplay is enabled by default. On MPV EOF (`ended`), Stalker starts the next episode only when it already exists in the current season's mapped episode list.
- Autoplay and Next stop at the last episode of the current season. They do not jump to the next season and do not lazy-load an unloaded `is_series=1` season. Quick start remains the only flow that may load another VOD-series season before playback.
- Previous is disabled on the first episode of the current season and otherwise switches directly to the previous episode.
Core decision logic and normalization are centralized in:
- `/Users/4gray/Code/iptvnator/libs/portal/stalker/data-access/src/lib/stalker-vod.utils.ts`
@@ -75,9 +75,14 @@
@if (inlinePlayback(); as playback) {
<app-portal-inline-player
[playback]="playback"
[episodeMetadata]="inlineEpisodeMetadata()"
[seriesNavigation]="inlineSeriesNavigation()"
(timeUpdate)="handleInlineTimeUpdate($event)"
(closed)="closeInlinePlayer()"
(streamUrlCopied)="showCopyNotification()"
(playbackEnded)="handleInlinePlaybackEnded()"
(previousEpisodeRequested)="playPreviousEpisode()"
(nextEpisodeRequested)="playNextEpisode()"
(externalFallbackRequested)="
handleExternalFallbackRequest($event)
"
@@ -1,5 +1,6 @@
import { Component, input, output, signal } from '@angular/core';
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { By } from '@angular/platform-browser';
import { Router } from '@angular/router';
import { MatSnackBar } from '@angular/material/snack-bar';
import { TranslatePipe, TranslateService } from '@ngx-translate/core';
@@ -61,10 +62,15 @@ class StubSeasonContainerComponent {
})
class StubPortalInlinePlayerComponent {
readonly playback = input<unknown>(null);
readonly episodeMetadata = input<unknown>(null);
readonly seriesNavigation = input<unknown>(null);
readonly timeUpdate = output<unknown>();
readonly closed = output<void>();
readonly streamUrlCopied = output<void>();
readonly externalFallbackRequested = output<unknown>();
readonly playbackEnded = output<void>();
readonly previousEpisodeRequested = output<void>();
readonly nextEpisodeRequested = output<void>();
}
@Component({
@@ -87,6 +93,7 @@ describe('StalkerSeriesViewComponent', () => {
const resolveVodPlayback = jest.fn();
const getSeriesPlaybackPositions = jest.fn().mockResolvedValue([]);
const openResolvedPlayback = jest.fn();
const isEmbeddedPlayer = jest.fn();
beforeEach(async () => {
selectedContentType.set('series');
@@ -110,14 +117,35 @@ describe('StalkerSeriesViewComponent', () => {
vodSeriesSeasonsResource.set([]);
fetchVodSeriesEpisodes.mockReset();
resolveVodPlayback.mockReset();
resolveVodPlayback.mockResolvedValue({
resolveVodPlayback.mockImplementation(
async (
_cmd?: string,
title?: string,
thumbnail?: string,
_episodeNum?: number,
episodeId?: number,
startTime?: number
) => ({
streamUrl: 'http://stalker.example/episode.mpg',
title: 'Regular Series',
thumbnail: 'poster.jpg',
});
title: title ?? 'Regular Series',
thumbnail: thumbnail ?? 'poster.jpg',
startTime,
contentInfo: {
playlistId: 'stalker-1',
contentXtreamId:
episodeId ?? Number(selectedItem()?.id ?? 0),
contentType: episodeId ? 'episode' : 'vod',
seriesXtreamId: episodeId
? Number(selectedItem()?.id ?? 0)
: undefined,
},
})
);
getSeriesPlaybackPositions.mockClear();
getSeriesPlaybackPositions.mockResolvedValue([]);
openResolvedPlayback.mockClear();
isEmbeddedPlayer.mockReset();
isEmbeddedPlayer.mockReturnValue(false);
await TestBed.configureTestingModule({
imports: [StalkerSeriesViewComponent],
@@ -156,7 +184,7 @@ describe('StalkerSeriesViewComponent', () => {
{
provide: PORTAL_PLAYER,
useValue: {
isEmbeddedPlayer: jest.fn().mockReturnValue(false),
isEmbeddedPlayer,
openResolvedPlayback,
},
},
@@ -426,6 +454,139 @@ describe('StalkerSeriesViewComponent', () => {
);
});
it('passes inline episode metadata and autoplays only loaded current-season episodes for VOD is_series playback', async () => {
selectedContentType.set('vod');
selectedItem.set({
id: '50001',
is_series: true,
info: {
name: 'VOD Flagged Series',
description: 'Lazy seasons',
movie_image: 'vod-series.jpg',
},
});
serialSeasonsResource.set([]);
vodSeriesSeasonsResource.set([
{
id: 'season-1',
video_id: '50001',
season_number: '1',
name: 'Season 1',
},
{
id: 'season-2',
video_id: '50001',
season_number: '2',
name: 'Season 2',
},
]);
isEmbeddedPlayer.mockReturnValue(true);
fixture.detectChanges();
await fixture.whenStable();
fixture.componentInstance.vodSeriesSeasons.set([
{
id: 'season-1',
video_id: '50001',
season_number: '1',
name: 'Season 1',
episodes: [
{
id: 'episode-1',
series_number: 1,
name: 'Pilot',
},
{
id: 'episode-2',
series_number: 2,
name: 'Second',
},
],
isLoading: false,
isExpanded: false,
},
{
id: 'season-2',
video_id: '50001',
season_number: '2',
name: 'Season 2',
episodes: [
{
id: 'episode-3',
series_number: 1,
name: 'Next Season',
},
],
isLoading: false,
isExpanded: false,
},
]);
fetchVodSeriesEpisodes.mockClear();
const seasonOneEpisodes = fixture.componentInstance.mappedSeasons()['1'];
const firstEpisode = seasonOneEpisodes[0];
const secondEpisode = seasonOneEpisodes[1];
const seasonTwoEpisode = fixture.componentInstance.mappedSeasons()['2'][0];
fixture.componentInstance.onEpisodeClicked(firstEpisode);
await fixture.whenStable();
fixture.detectChanges();
const inlinePlayer = fixture.debugElement.query(
By.directive(StubPortalInlinePlayerComponent)
).componentInstance as StubPortalInlinePlayerComponent;
expect(inlinePlayer.episodeMetadata()).toEqual({
label: 'S01E01',
title: 'Pilot',
seasonNumber: 1,
episodeNumber: 1,
});
expect(inlinePlayer.seriesNavigation()).toEqual({
canPrevious: false,
canNext: true,
autoplayEnabled: true,
});
inlinePlayer.playbackEnded.emit();
await fixture.whenStable();
fixture.detectChanges();
expect(resolveVodPlayback).toHaveBeenLastCalledWith(
'/media/file_episode-2.mpg',
'VOD Flagged Series - Second',
'vod-series.jpg',
2,
Number(secondEpisode.id),
undefined
);
expect(inlinePlayer.episodeMetadata()).toEqual({
label: 'S01E02',
title: 'Second',
seasonNumber: 1,
episodeNumber: 2,
});
expect(inlinePlayer.seriesNavigation()).toEqual({
canPrevious: true,
canNext: false,
autoplayEnabled: true,
});
inlinePlayer.playbackEnded.emit();
await fixture.whenStable();
expect(fetchVodSeriesEpisodes).not.toHaveBeenCalled();
expect(resolveVodPlayback).not.toHaveBeenCalledWith(
'/media/file_episode-3.mpg',
'VOD Flagged Series - Next Season',
'vod-series.jpg',
1,
Number(seasonTwoEpisode.id),
undefined
);
});
it('loads the next unloaded VOD-series season after the loaded season is watched', async () => {
selectedContentType.set('vod');
selectedItem.set({
@@ -46,8 +46,12 @@ import {
StalkerVodSource,
} from '@iptvnator/portal/stalker/data-access';
import {
formatSeriesEpisodeLabel,
type PlaybackFallbackRequest,
PortalInlinePlayerComponent,
type SeriesEpisodeMetadata,
type SeriesPlaybackEpisodeState,
type SeriesPlaybackNavigation,
} from '@iptvnator/ui/playback';
import {
DownloadsService,
@@ -272,6 +276,39 @@ export class StalkerSeriesViewComponent implements OnDestroy {
vodSeriesSeasons: this.vodSeriesSeasons(),
});
});
readonly inlineEpisodeState =
computed<SeriesPlaybackEpisodeState<XtreamSerieEpisode> | null>(() =>
this.getInlineEpisodeState()
);
readonly inlineEpisodeMetadata = computed<SeriesEpisodeMetadata | null>(() => {
const state = this.inlineEpisodeState();
if (!state) {
return null;
}
return {
label: formatSeriesEpisodeLabel(
state.seasonNumber,
state.episodeNumber
),
seasonNumber: state.seasonNumber,
episodeNumber: state.episodeNumber,
title: state.episode.title,
};
});
readonly inlineSeriesNavigation =
computed<SeriesPlaybackNavigation | null>(() => {
const state = this.inlineEpisodeState();
if (!state) {
return null;
}
return {
canPrevious: Boolean(state.previous),
canNext: Boolean(state.next),
autoplayEnabled: true,
};
});
/**
* Handles season selection from the container.
@@ -516,6 +553,30 @@ export class StalkerSeriesViewComponent implements OnDestroy {
);
}
playPreviousEpisode(): void {
const previous = this.inlineEpisodeState()?.previous;
if (!previous) {
return;
}
this.onEpisodeClicked(previous);
}
playNextEpisode(): void {
const next = this.inlineEpisodeState()?.next;
if (!next) {
return;
}
this.onEpisodeClicked(next);
}
handleInlinePlaybackEnded(): void {
const navigation = this.inlineSeriesNavigation();
if (!navigation?.autoplayEnabled || !navigation.canNext) {
return;
}
this.playNextEpisode();
}
private async startPlayback(
cmd?: string,
title?: string,
@@ -556,6 +617,44 @@ export class StalkerSeriesViewComponent implements OnDestroy {
}
}
private getInlineEpisodeState(): SeriesPlaybackEpisodeState<XtreamSerieEpisode> | null {
const playback = this.inlinePlayback();
const currentEpisodeId = playback?.contentInfo?.contentXtreamId;
if (
playback?.contentInfo?.contentType !== 'episode' ||
currentEpisodeId === undefined
) {
return null;
}
for (const [seasonKey, episodes] of Object.entries(this.mappedSeasons())) {
const episodeIndex = episodes.findIndex(
(episode) => Number(episode.id) === Number(currentEpisodeId)
);
if (episodeIndex < 0) {
continue;
}
const episode = episodes[episodeIndex];
const seasonNumber =
Number(episode.season) || Number(seasonKey) || 0;
const episodeNumber =
Number(episode.episode_num) || episodeIndex + 1;
return {
seasonKey,
seasonNumber,
episodeNumber,
episode,
previous: episodes[episodeIndex - 1] ?? null,
next: episodes[episodeIndex + 1] ?? null,
};
}
return null;
}
ngOnDestroy(): void {
this.unsubscribePositionUpdates?.();
}
@@ -88,9 +88,14 @@
@if (inlinePlayback(); as playback) {
<app-portal-inline-player
[playback]="playback"
[episodeMetadata]="inlineEpisodeMetadata()"
[seriesNavigation]="inlineSeriesNavigation()"
(timeUpdate)="handleInlineTimeUpdate($event)"
(closed)="closeInlinePlayer()"
(streamUrlCopied)="showCopyNotification()"
(playbackEnded)="handleInlinePlaybackEnded()"
(previousEpisodeRequested)="playPreviousEpisode()"
(nextEpisodeRequested)="playNextEpisode()"
(externalFallbackRequested)="
handleExternalFallbackRequest($event)
"
@@ -61,10 +61,15 @@ class StubSeasonContainerComponent {
})
class StubPortalInlinePlayerComponent {
readonly playback = input<unknown>(null);
readonly episodeMetadata = input<unknown>(null);
readonly seriesNavigation = input<unknown>(null);
readonly timeUpdate = output<unknown>();
readonly closed = output<void>();
readonly streamUrlCopied = output<void>();
readonly externalFallbackRequested = output<unknown>();
readonly playbackEnded = output<void>();
readonly previousEpisodeRequested = output<void>();
readonly nextEpisodeRequested = output<void>();
}
@Component({
@@ -92,6 +97,7 @@ describe('SerialDetailsComponent', () => {
const constructEpisodeStreamUrl = jest.fn();
const addRecentItem = jest.fn();
const openResolvedPlayback = jest.fn();
const isEmbeddedPlayer = jest.fn();
const getSeriesPlaybackPositions = jest.fn().mockResolvedValue([]);
beforeEach(async () => {
@@ -112,6 +118,20 @@ describe('SerialDetailsComponent', () => {
title: 'Episode 1',
season: 1,
},
{
id: '1002',
episode_num: 2,
title: 'Episode 2',
season: 1,
},
],
'2': [
{
id: '2001',
episode_num: 1,
title: 'Season 2 Episode 1',
season: 2,
},
],
},
});
@@ -121,11 +141,14 @@ describe('SerialDetailsComponent', () => {
fetchSerialDetailsWithMetadata.mockClear();
checkFavoriteStatus.mockClear();
constructEpisodeStreamUrl.mockReset();
constructEpisodeStreamUrl.mockReturnValue(
'http://xtream.example/series/1001.mp4'
constructEpisodeStreamUrl.mockImplementation(
(episode: { id: string | number }) =>
`http://xtream.example/series/${episode.id}.mp4`
);
addRecentItem.mockClear();
openResolvedPlayback.mockClear();
isEmbeddedPlayer.mockReset();
isEmbeddedPlayer.mockReturnValue(false);
getSeriesPlaybackPositions.mockClear();
getSeriesPlaybackPositions.mockResolvedValue([]);
@@ -180,7 +203,7 @@ describe('SerialDetailsComponent', () => {
{
provide: PORTAL_PLAYER,
useValue: {
isEmbeddedPlayer: jest.fn().mockReturnValue(false),
isEmbeddedPlayer,
openResolvedPlayback,
},
},
@@ -274,6 +297,20 @@ describe('SerialDetailsComponent', () => {
title: 'Episode 1',
season: 1,
},
{
id: '1002',
episode_num: 2,
title: 'Episode 2',
season: 1,
},
],
'2': [
{
id: '2001',
episode_num: 1,
title: 'Season 2 Episode 1',
season: 2,
},
],
});
});
@@ -381,4 +418,65 @@ describe('SerialDetailsComponent', () => {
true
);
});
it('passes inline episode metadata and autoplays only inside the current season', async () => {
isEmbeddedPlayer.mockReturnValue(true);
fixture.detectChanges();
await fixture.whenStable();
const episodes = (fixture.componentInstance.selectedItem()?.episodes ??
{}) as Record<string, Array<{ id: string; title: string }>>;
fixture.componentInstance.playEpisode(episodes['1'][0] as never);
fixture.detectChanges();
let inlinePlayer = fixture.debugElement.query(
By.directive(StubPortalInlinePlayerComponent)
).componentInstance as StubPortalInlinePlayerComponent;
expect(inlinePlayer.episodeMetadata()).toEqual({
label: 'S01E01',
title: 'Episode 1',
seasonNumber: 1,
episodeNumber: 1,
});
expect(inlinePlayer.seriesNavigation()).toEqual({
canPrevious: false,
canNext: true,
autoplayEnabled: true,
});
inlinePlayer.playbackEnded.emit();
fixture.detectChanges();
inlinePlayer = fixture.debugElement.query(
By.directive(StubPortalInlinePlayerComponent)
).componentInstance as StubPortalInlinePlayerComponent;
expect(inlinePlayer.playback()).toEqual(
expect.objectContaining({
streamUrl: 'http://xtream.example/series/1002.mp4',
title: 'Episode 2',
contentInfo: expect.objectContaining({
contentXtreamId: 1002,
seasonNumber: 1,
episodeNumber: 2,
}),
})
);
expect(inlinePlayer.seriesNavigation()).toEqual({
canPrevious: true,
canNext: false,
autoplayEnabled: true,
});
inlinePlayer.playbackEnded.emit();
fixture.detectChanges();
expect(inlinePlayer.playback()).toEqual(
expect.objectContaining({
streamUrl: 'http://xtream.example/series/1002.mp4',
})
);
expect(constructEpisodeStreamUrl).not.toHaveBeenCalledWith(
expect.objectContaining({ id: '2001' })
);
});
});
@@ -26,8 +26,12 @@ import {
} from '@iptvnator/portal/shared/util';
import { XtreamStore } from '@iptvnator/portal/xtream/data-access';
import {
formatSeriesEpisodeLabel,
type PlaybackFallbackRequest,
PortalInlinePlayerComponent,
type SeriesEpisodeMetadata,
type SeriesPlaybackEpisodeState,
type SeriesPlaybackNavigation,
} from '@iptvnator/ui/playback';
import { PlaybackPositionRuntimeBridgeService } from '@iptvnator/services';
import {
@@ -110,6 +114,39 @@ export class SerialDetailsComponent implements OnInit, OnDestroy {
playbackPositions: this.episodePlaybackPositions(),
});
});
readonly inlineEpisodeState =
computed<SeriesPlaybackEpisodeState<XtreamSerieEpisode> | null>(() =>
this.getInlineEpisodeState()
);
readonly inlineEpisodeMetadata = computed<SeriesEpisodeMetadata | null>(() => {
const state = this.inlineEpisodeState();
if (!state) {
return null;
}
return {
label: formatSeriesEpisodeLabel(
state.seasonNumber,
state.episodeNumber
),
seasonNumber: state.seasonNumber,
episodeNumber: state.episodeNumber,
title: state.episode.title,
};
});
readonly inlineSeriesNavigation =
computed<SeriesPlaybackNavigation | null>(() => {
const state = this.inlineEpisodeState();
if (!state) {
return null;
}
return {
canPrevious: Boolean(state.previous),
canNext: Boolean(state.next),
autoplayEnabled: true,
};
});
constructor() {
effect(() => {
@@ -364,6 +401,30 @@ export class SerialDetailsComponent implements OnInit, OnDestroy {
);
}
playPreviousEpisode(): void {
const previous = this.inlineEpisodeState()?.previous;
if (!previous) {
return;
}
this.playEpisode(previous);
}
playNextEpisode(): void {
const next = this.inlineEpisodeState()?.next;
if (!next) {
return;
}
this.playEpisode(next);
}
handleInlinePlaybackEnded(): void {
const navigation = this.inlineSeriesNavigation();
if (!navigation?.autoplayEnabled || !navigation.canNext) {
return;
}
this.playNextEpisode();
}
private addToRecentlyViewed(xtreamId: number): void {
this.xtreamStore.addRecentItem({
xtreamId,
@@ -384,6 +445,51 @@ export class SerialDetailsComponent implements OnInit, OnDestroy {
void this.portalPlayer.openResolvedPlayback(playback, true);
}
private getInlineEpisodeState(): SeriesPlaybackEpisodeState<XtreamSerieEpisode> | null {
const playback = this.inlinePlayback();
const episodesBySeason = this.selectedItem()?.episodes;
const currentEpisodeId = playback?.contentInfo?.contentXtreamId;
if (
!episodesBySeason ||
playback?.contentInfo?.contentType !== 'episode' ||
currentEpisodeId === undefined
) {
return null;
}
for (const [seasonKey, episodes] of Object.entries(episodesBySeason)) {
const episodeIndex = episodes.findIndex(
(episode) => Number(episode.id) === Number(currentEpisodeId)
);
if (episodeIndex < 0) {
continue;
}
const episode = episodes[episodeIndex];
const seasonNumber =
Number(episode.season) ||
Number(seasonKey) ||
playback.contentInfo.seasonNumber ||
0;
const episodeNumber =
Number(episode.episode_num) ||
playback.contentInfo.episodeNumber ||
episodeIndex + 1;
return {
seasonKey,
seasonNumber,
episodeNumber,
episode,
previous: episodes[episodeIndex - 1] ?? null,
next: episodes[episodeIndex + 1] ?? null,
};
}
return null;
}
async handlePlaybackToggleRequested(
request: SeasonContainerPlaybackToggleRequest
): Promise<void> {
@@ -3,6 +3,7 @@ export type EmbeddedMpvSessionStatus =
| 'loading'
| 'playing'
| 'paused'
| 'ended'
| 'error'
| 'closed';
+1
View File
@@ -7,6 +7,7 @@ export * from './lib/external-player-info-dialog/external-player-info-dialog.com
export * from './lib/html-video-player/html-video-player.component';
export * from './lib/playback-diagnostics/playback-diagnostics.util';
export * from './lib/portal-inline-player/portal-inline-player.component';
export * from './lib/portal-inline-player/series-playback-navigation';
export * from './lib/vjs-player/vjs-player.component';
export * from './lib/video-player/sidebar/sidebar.component';
export * from './lib/video-player/toolbar/toolbar.component';
@@ -100,6 +100,34 @@
}}</mat-icon>
</button>
<button
mat-icon-button
type="button"
class="embedded-mpv-player__control-button"
data-test-id="embedded-mpv-previous-episode"
[disabled]="isLoading() || !canPreviousEpisode()"
(click)="requestPreviousEpisode()"
aria-label="Previous episode"
matTooltip="Previous episode"
matTooltipPosition="above"
>
<mat-icon>skip_previous</mat-icon>
</button>
<button
mat-icon-button
type="button"
class="embedded-mpv-player__control-button"
data-test-id="embedded-mpv-next-episode"
[disabled]="isLoading() || !canNextEpisode()"
(click)="requestNextEpisode()"
aria-label="Next episode"
matTooltip="Next episode"
matTooltipPosition="above"
>
<mat-icon>skip_next</mat-icon>
</button>
<button
mat-icon-button
type="button"
@@ -0,0 +1,146 @@
import { Component } from '@angular/core';
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { By } from '@angular/platform-browser';
import { ResolvedPortalPlayback } from '@iptvnator/shared/interfaces';
import { EmbeddedMpvOverlayVisibilityService } from './embedded-mpv-overlay-visibility.service';
import { EmbeddedMpvPlayerComponent } from './embedded-mpv-player.component';
import { EmbeddedMpvSessionController } from './embedded-mpv-session-controller';
import { signal } from '@angular/core';
@Component({
imports: [EmbeddedMpvPlayerComponent],
template: `
<app-embedded-mpv-player
[playback]="playback"
[seriesNavigation]="seriesNavigation"
(playbackEnded)="endedCount = endedCount + 1"
(previousEpisodeRequested)="previousCount = previousCount + 1"
(nextEpisodeRequested)="nextCount = nextCount + 1"
/>
`,
})
class EmbeddedMpvPlayerHostComponent {
readonly playback: ResolvedPortalPlayback = {
streamUrl: 'https://example.test/series/1002.mp4',
title: 'Episode 2',
};
seriesNavigation = {
canPrevious: true,
canNext: false,
autoplayEnabled: true,
};
endedCount = 0;
previousCount = 0;
nextCount = 0;
}
describe('EmbeddedMpvPlayerComponent series navigation', () => {
let fixture: ComponentFixture<EmbeddedMpvPlayerHostComponent>;
let player: EmbeddedMpvPlayerComponent;
let controller: EmbeddedMpvSessionController;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [EmbeddedMpvPlayerHostComponent],
providers: [
{
provide: EmbeddedMpvOverlayVisibilityService,
useValue: { overlayActive: signal(false) },
},
],
}).compileComponents();
fixture = TestBed.createComponent(EmbeddedMpvPlayerHostComponent);
fixture.detectChanges();
const playerDebugElement = fixture.debugElement.query(
By.directive(EmbeddedMpvPlayerComponent)
);
player = playerDebugElement.componentInstance;
controller = playerDebugElement.injector.get(
EmbeddedMpvSessionController
);
controller.support.set({
supported: true,
platform: 'darwin',
capabilities: {
subtitles: false,
playbackSpeed: false,
aspectOverride: false,
screenshot: false,
recording: false,
},
});
controller.session.set({
id: 'session-1',
title: 'Episode 2',
streamUrl: 'https://example.test/series/1002.mp4',
status: 'playing',
positionSeconds: 30,
durationSeconds: 120,
volume: 1,
audioTracks: [],
selectedAudioTrackId: null,
subtitleTracks: [],
selectedSubtitleTrackId: null,
playbackSpeed: 1,
aspectOverride: 'no',
recording: { active: false },
startedAt: '2026-06-06T12:00:00Z',
updatedAt: '2026-06-06T12:00:00Z',
});
fixture.detectChanges();
});
afterEach(() => {
fixture.destroy();
});
it('renders previous and next episode controls with season boundary disabled state', () => {
const previousButton = fixture.debugElement.query(
By.css('[data-test-id="embedded-mpv-previous-episode"]')
);
const nextButton = fixture.debugElement.query(
By.css('[data-test-id="embedded-mpv-next-episode"]')
);
expect(previousButton).not.toBeNull();
expect(previousButton.nativeElement.disabled).toBe(false);
expect(nextButton).not.toBeNull();
expect(nextButton.nativeElement.disabled).toBe(true);
previousButton.nativeElement.click();
nextButton.nativeElement.click();
expect(fixture.componentInstance.previousCount).toBe(1);
expect(fixture.componentInstance.nextCount).toBe(0);
});
it('emits playbackEnded exactly once for an ended session snapshot', () => {
controller.session.update((session) =>
session
? {
...session,
status: 'ended',
positionSeconds: 120,
updatedAt: '2026-06-06T12:02:00Z',
}
: session
);
fixture.detectChanges();
controller.session.update((session) =>
session
? {
...session,
updatedAt: '2026-06-06T12:02:01Z',
}
: session
);
fixture.detectChanges();
expect(fixture.componentInstance.endedCount).toBe(1);
expect(player.isPlaying()).toBe(false);
});
});
@@ -21,6 +21,7 @@ import {
EmbeddedMpvAudioTrack,
ResolvedPortalPlayback,
} from '@iptvnator/shared/interfaces';
import type { SeriesPlaybackNavigation } from '../portal-inline-player/series-playback-navigation';
import { EmbeddedMpvOverlayVisibilityService } from './embedded-mpv-overlay-visibility.service';
import { EmbeddedMpvSessionController } from './embedded-mpv-session-controller';
import { EmbeddedMpvShortcuts } from './embedded-mpv-shortcuts';
@@ -69,11 +70,15 @@ export class EmbeddedMpvPlayerComponent implements OnDestroy {
readonly playback = input.required<ResolvedPortalPlayback>();
readonly showControls = input(true);
readonly recordingFolder = input('');
readonly seriesNavigation = input<SeriesPlaybackNavigation | null>(null);
@Output() timeUpdate = new EventEmitter<{
currentTime: number;
duration: number;
}>();
@Output() playbackEnded = new EventEmitter<void>();
@Output() previousEpisodeRequested = new EventEmitter<void>();
@Output() nextEpisodeRequested = new EventEmitter<void>();
private readonly overlayVisibility = inject(
EmbeddedMpvOverlayVisibilityService
@@ -117,7 +122,8 @@ export class EmbeddedMpvPlayerComponent implements OnDestroy {
readonly isPaused = computed(
() =>
this.session()?.status === 'paused' ||
this.session()?.status === 'idle'
this.session()?.status === 'idle' ||
this.session()?.status === 'ended'
);
readonly isPlaying = computed(() => this.session()?.status === 'playing');
readonly isErrored = computed(() => this.session()?.status === 'error');
@@ -198,6 +204,12 @@ export class EmbeddedMpvPlayerComponent implements OnDestroy {
readonly isRecording = computed(
() => this.session()?.recording?.active === true
);
readonly canPreviousEpisode = computed(
() => this.seriesNavigation()?.canPrevious === true
);
readonly canNextEpisode = computed(
() => this.seriesNavigation()?.canNext === true
);
readonly recordingElapsed = computed(() => {
const startedAt = this.session()?.recording?.startedAt;
this.recordingTick();
@@ -221,6 +233,7 @@ export class EmbeddedMpvPlayerComponent implements OnDestroy {
private controlsHideTimer: number | null = null;
private volumeCloseTimer: number | null = null;
private recordingMessageTimer: number | null = null;
private lastEndedSessionId: string | null = null;
private readonly recordingTick = signal(Date.now());
private readonly recordingMessage = signal<string | null>(null);
@@ -331,6 +344,7 @@ export class EmbeddedMpvPlayerComponent implements OnDestroy {
effect(() => {
const session = this.session();
if (!session) {
this.lastEndedSessionId = null;
return;
}
// Side effects must not pull in transitive signal deps via
@@ -347,6 +361,19 @@ export class EmbeddedMpvPlayerComponent implements OnDestroy {
});
});
effect(() => {
const session = this.session();
if (!session || session.status !== 'ended') {
return;
}
if (this.lastEndedSessionId === session.id) {
return;
}
this.lastEndedSessionId = session.id;
this.playbackEnded.emit();
});
effect((onCleanup) => {
if (!this.isRecording()) {
return;
@@ -432,6 +459,22 @@ export class EmbeddedMpvPlayerComponent implements OnDestroy {
}
}
requestPreviousEpisode(): void {
this.revealControls();
if (!this.canPreviousEpisode()) {
return;
}
this.previousEpisodeRequested.emit();
}
requestNextEpisode(): void {
this.revealControls();
if (!this.canNextEpisode()) {
return;
}
this.nextEpisodeRequested.emit();
}
async onTimelineInput(event: Event): Promise<void> {
this.revealControls();
const target = Number((event.target as HTMLInputElement).value);
@@ -3,9 +3,16 @@
<header class="player-shell__header">
<div class="player-shell__eyebrow">Now Playing</div>
<div class="player-shell__title-row">
<h3 class="player-shell__title" [matTooltip]="title()">
{{ title() }}
</h3>
<div class="player-shell__heading">
<h3 class="player-shell__title" [matTooltip]="title()">
{{ title() }}
</h3>
@if (episodeMetadataText(); as episodeText) {
<div class="player-shell__episode-meta">
{{ episodeText }}
</div>
}
</div>
<div class="player-shell__actions">
<button
mat-button
@@ -35,7 +42,11 @@
[title]="title()"
[playback]="playback()"
[startTime]="startTime()"
[seriesNavigation]="seriesNavigation()"
(timeUpdate)="onTimeUpdate($event)"
(playbackEnded)="onPlaybackEnded()"
(previousEpisodeRequested)="onPreviousEpisodeRequested()"
(nextEpisodeRequested)="onNextEpisodeRequested()"
(externalFallbackRequested)="
onExternalFallbackRequested($event)
"
@@ -39,6 +39,10 @@
justify-content: space-between;
}
.player-shell__heading {
min-width: 0;
}
.player-shell__title {
margin: 0;
font-size: clamp(1.05rem, 1vw + 0.9rem, 1.4rem);
@@ -47,6 +51,17 @@
color: #161616;
}
.player-shell__episode-meta {
margin-top: 4px;
overflow: hidden;
color: rgba(26, 26, 26, 0.64);
font-size: 0.9rem;
font-weight: 600;
line-height: 1.35;
text-overflow: ellipsis;
white-space: nowrap;
}
.player-shell__actions {
display: flex;
flex-wrap: wrap;
@@ -87,6 +102,10 @@
color: #fff;
}
:host-context(.dark-theme) .player-shell__episode-meta {
color: rgba(255, 255, 255, 0.68);
}
@media (max-width: 900px) {
.player-shell {
padding: 14px;
@@ -0,0 +1,123 @@
import { Component, input, output } from '@angular/core';
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { By } from '@angular/platform-browser';
import type { PortalInlinePlayerComponent as PortalInlinePlayerComponentInstance } from './portal-inline-player.component';
jest.unstable_mockModule('video.js', () => ({
default: jest.fn(),
}));
jest.unstable_mockModule('@yangkghjh/videojs-aspect-ratio-panel', () => ({}));
jest.unstable_mockModule('videojs-contrib-quality-levels', () => ({}));
jest.unstable_mockModule('videojs-quality-selector-hls', () => ({}));
@Component({
selector: 'app-web-player-view',
standalone: true,
template: '<div data-test-id="stub-web-player-view"></div>',
})
class StubWebPlayerViewComponent {
readonly streamUrl = input.required<string>();
readonly title = input('');
readonly playback = input<unknown>(null);
readonly startTime = input(0);
readonly seriesNavigation = input<unknown>(null);
readonly timeUpdate = output<{ currentTime: number; duration: number }>();
readonly externalFallbackRequested = output<unknown>();
readonly playbackEnded = output<void>();
readonly previousEpisodeRequested = output<void>();
readonly nextEpisodeRequested = output<void>();
}
describe('PortalInlinePlayerComponent', () => {
let PortalInlinePlayerComponent: typeof import('./portal-inline-player.component').PortalInlinePlayerComponent;
let WebPlayerViewComponent: typeof import('../web-player-view/web-player-view.component').WebPlayerViewComponent;
let fixture: ComponentFixture<PortalInlinePlayerComponentInstance>;
let component: PortalInlinePlayerComponentInstance;
beforeAll(async () => {
({ PortalInlinePlayerComponent } = await import(
'./portal-inline-player.component'
));
({ WebPlayerViewComponent } = await import(
'../web-player-view/web-player-view.component'
));
});
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [PortalInlinePlayerComponent],
})
.overrideComponent(PortalInlinePlayerComponent, {
remove: {
imports: [WebPlayerViewComponent],
},
add: {
imports: [StubWebPlayerViewComponent],
},
})
.compileComponents();
fixture = TestBed.createComponent(PortalInlinePlayerComponent);
component = fixture.componentInstance;
});
afterEach(() => {
fixture.destroy();
});
it('renders episode metadata below the title and forwards embedded MPV navigation events', () => {
const events: string[] = [];
const seriesNavigation = {
canPrevious: true,
canNext: false,
autoplayEnabled: true,
};
fixture.componentRef.setInput('playback', {
streamUrl: 'https://example.test/series/1002.mp4',
title: 'Episode 2',
startTime: 12,
});
fixture.componentRef.setInput('episodeMetadata', {
label: 'S01E02',
title: 'Episode 2',
seasonNumber: 1,
episodeNumber: 2,
});
fixture.componentRef.setInput('seriesNavigation', seriesNavigation);
(
component as unknown as {
playbackEnded: { subscribe: (fn: () => void) => void };
previousEpisodeRequested: { subscribe: (fn: () => void) => void };
nextEpisodeRequested: { subscribe: (fn: () => void) => void };
}
).playbackEnded.subscribe(() => events.push('ended'));
(
component as unknown as {
previousEpisodeRequested: { subscribe: (fn: () => void) => void };
}
).previousEpisodeRequested.subscribe(() => events.push('previous'));
(
component as unknown as {
nextEpisodeRequested: { subscribe: (fn: () => void) => void };
}
).nextEpisodeRequested.subscribe(() => events.push('next'));
fixture.detectChanges();
expect(fixture.nativeElement.textContent).toContain(
'S01E02 - Episode 2'
);
const webPlayer = fixture.debugElement.query(
By.directive(StubWebPlayerViewComponent)
).componentInstance as StubWebPlayerViewComponent;
expect(webPlayer.seriesNavigation()).toBe(seriesNavigation);
webPlayer.playbackEnded.emit();
webPlayer.previousEpisodeRequested.emit();
webPlayer.nextEpisodeRequested.emit();
expect(events).toEqual(['ended', 'previous', 'next']);
});
});
@@ -15,6 +15,10 @@ import {
} from '@iptvnator/shared/interfaces';
import type { PlaybackFallbackRequest } from '../playback-diagnostics/playback-diagnostics.util';
import { WebPlayerViewComponent } from '../web-player-view/web-player-view.component';
import type {
SeriesEpisodeMetadata,
SeriesPlaybackNavigation,
} from './series-playback-navigation';
@Component({
selector: 'app-portal-inline-player',
@@ -35,6 +39,8 @@ import { WebPlayerViewComponent } from '../web-player-view/web-player-view.compo
})
export class PortalInlinePlayerComponent {
readonly playback = input<ResolvedPortalPlayback | null>(null);
readonly episodeMetadata = input<SeriesEpisodeMetadata | null>(null);
readonly seriesNavigation = input<SeriesPlaybackNavigation | null>(null);
readonly title = computed(() => this.playback()?.title ?? '');
readonly streamUrl = computed(() => this.playback()?.streamUrl ?? '');
readonly startTime = computed(() => this.playback()?.startTime ?? 0);
@@ -42,6 +48,16 @@ export class PortalInlinePlayerComponent {
() => this.playback()?.contentInfo
);
readonly hasPlayback = computed(() => !!this.playback()?.streamUrl);
readonly episodeMetadataText = computed(() => {
const metadata = this.episodeMetadata();
if (!metadata) {
return '';
}
return metadata.title
? `${metadata.label} - ${metadata.title}`
: metadata.label;
});
readonly closed = output<void>();
readonly timeUpdate = output<{
@@ -50,6 +66,9 @@ export class PortalInlinePlayerComponent {
}>();
readonly streamUrlCopied = output<void>();
readonly externalFallbackRequested = output<PlaybackFallbackRequest>();
readonly playbackEnded = output<void>();
readonly previousEpisodeRequested = output<void>();
readonly nextEpisodeRequested = output<void>();
onClose(): void {
this.closed.emit();
@@ -66,4 +85,16 @@ export class PortalInlinePlayerComponent {
onExternalFallbackRequested(request: PlaybackFallbackRequest): void {
this.externalFallbackRequested.emit(request);
}
onPlaybackEnded(): void {
this.playbackEnded.emit();
}
onPreviousEpisodeRequested(): void {
this.previousEpisodeRequested.emit();
}
onNextEpisodeRequested(): void {
this.nextEpisodeRequested.emit();
}
}
@@ -0,0 +1,33 @@
export interface SeriesEpisodeMetadata {
label: string;
seasonNumber: number;
episodeNumber: number;
title?: string;
}
export interface SeriesPlaybackNavigation {
canPrevious: boolean;
canNext: boolean;
autoplayEnabled: boolean;
}
export interface SeriesPlaybackEpisodeState<TEpisode> {
seasonKey: string;
seasonNumber: number;
episodeNumber: number;
episode: TEpisode;
previous: TEpisode | null;
next: TEpisode | null;
}
export function formatSeriesEpisodeLabel(
seasonNumber: number,
episodeNumber: number
): string {
return `S${padSeriesNumber(seasonNumber)}E${padSeriesNumber(episodeNumber)}`;
}
function padSeriesNumber(value: number): string {
const normalized = Number.isFinite(value) && value > 0 ? value : 0;
return String(normalized).padStart(2, '0');
}
@@ -28,7 +28,11 @@
<app-embedded-mpv-player
[playback]="resolvedPlayback()"
[recordingFolder]="recordingFolder()"
[seriesNavigation]="seriesNavigation()"
(timeUpdate)="timeUpdate.emit($event)"
(playbackEnded)="playbackEnded.emit()"
(previousEpisodeRequested)="previousEpisodeRequested.emit()"
(nextEpisodeRequested)="nextEpisodeRequested.emit()"
/>
} @else {
<app-vjs-player
@@ -70,7 +70,11 @@ class StubArtPlayerComponent {
class StubEmbeddedMpvPlayerComponent {
readonly playback = input.required<unknown>();
readonly recordingFolder = input('');
readonly seriesNavigation = input<unknown>(null);
readonly timeUpdate = output<{ currentTime: number; duration: number }>();
readonly playbackEnded = output<void>();
readonly previousEpisodeRequested = output<void>();
readonly nextEpisodeRequested = output<void>();
}
describe('WebPlayerViewComponent', () => {
@@ -328,6 +332,47 @@ describe('WebPlayerViewComponent', () => {
expect(player.recordingFolder()).toBe('');
});
it('passes series navigation to embedded MPV and forwards episode navigation events', () => {
const events: string[] = [];
const seriesNavigation = {
canPrevious: true,
canNext: false,
autoplayEnabled: true,
};
fixture.componentRef.setInput('playerOverride', VideoPlayer.EmbeddedMpv);
fixture.componentRef.setInput('seriesNavigation', seriesNavigation);
(
component as unknown as {
playbackEnded: { subscribe: (fn: () => void) => void };
previousEpisodeRequested: { subscribe: (fn: () => void) => void };
nextEpisodeRequested: { subscribe: (fn: () => void) => void };
}
).playbackEnded.subscribe(() => events.push('ended'));
(
component as unknown as {
previousEpisodeRequested: { subscribe: (fn: () => void) => void };
}
).previousEpisodeRequested.subscribe(() => events.push('previous'));
(
component as unknown as {
nextEpisodeRequested: { subscribe: (fn: () => void) => void };
}
).nextEpisodeRequested.subscribe(() => events.push('next'));
fixture.detectChanges();
const player = fixture.debugElement.query(
By.directive(StubEmbeddedMpvPlayerComponent)
).componentInstance as StubEmbeddedMpvPlayerComponent;
expect(player.seriesNavigation()).toBe(seriesNavigation);
player.playbackEnded.emit();
player.previousEpisodeRequested.emit();
player.nextEpisodeRequested.emit();
expect(events).toEqual(['ended', 'previous', 'next']);
});
it('uses the PWA browser access diagnostic description key outside desktop', () => {
const issue = createBrowserAccessDiagnostic();
@@ -36,6 +36,7 @@ import {
getLikelyBrowserUnsupportedCodecLabels,
getPlaybackMediaExtensionFromUrl,
} from '../playback-diagnostics/playback-diagnostics.util';
import type { SeriesPlaybackNavigation } from '../portal-inline-player/series-playback-navigation';
import { VjsPlayerComponent } from '../vjs-player/vjs-player.component';
type PlaybackDiagnosticDetail = {
@@ -74,11 +75,15 @@ export class WebPlayerViewComponent {
volume = input<number>(1);
showCaptions = input<boolean>(false);
playerOverride = input<VideoPlayer | null>(null);
seriesNavigation = input<SeriesPlaybackNavigation | null>(null);
readonly timeUpdate = output<{
currentTime: number;
duration: number;
}>();
readonly externalFallbackRequested = output<PlaybackFallbackRequest>();
readonly playbackEnded = output<void>();
readonly previousEpisodeRequested = output<void>();
readonly nextEpisodeRequested = output<void>();
settings = toSignal(this.storage.get(STORE_KEY.Settings)) as Signal<
Settings | undefined