mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-11 02:46:16 -08:00
feat(playback): add embedded mpv series navigation
This commit is contained in:
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'));
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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`
|
||||
|
||||
+5
@@ -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)
|
||||
"
|
||||
|
||||
+166
-5
@@ -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({
|
||||
|
||||
+99
@@ -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)
|
||||
"
|
||||
|
||||
+101
-3
@@ -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';
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user