diff --git a/apps/electron-backend/native/src/embedded_mpv.mm b/apps/electron-backend/native/src/embedded_mpv.mm index d9c7d6e9a..d44e3332f 100644 --- a/apps/electron-backend/native/src/embedded_mpv.mm +++ b/apps/electron-backend/native/src/embedded_mpv.mm @@ -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) 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) property->data) { session->paused = *static_cast(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 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 diff --git a/apps/electron-backend/src/app/services/embedded-mpv-native-source.spec.ts b/apps/electron-backend/src/app/services/embedded-mpv-native-source.spec.ts index 6b17a5e6d..89f14f60b 100644 --- a/apps/electron-backend/src/app/services/embedded-mpv-native-source.spec.ts +++ b/apps/electron-backend/src/app/services/embedded-mpv-native-source.spec.ts @@ -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'); + }); }); diff --git a/apps/electron-backend/src/app/services/embedded-mpv-native.service.spec.ts b/apps/electron-backend/src/app/services/embedded-mpv-native.service.spec.ts index 5da8d4101..5850ac657 100644 --- a/apps/electron-backend/src/app/services/embedded-mpv-native.service.spec.ts +++ b/apps/electron-backend/src/app/services/embedded-mpv-native.service.spec.ts @@ -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')); diff --git a/docs/architecture/embedded-mpv-native.md b/docs/architecture/embedded-mpv-native.md index 7002f5f62..df9353af7 100644 --- a/docs/architecture/embedded-mpv-native.md +++ b/docs/architecture/embedded-mpv-native.md @@ -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. === '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 diff --git a/docs/architecture/stalker-portal.md b/docs/architecture/stalker-portal.md index 4516ce294..cd26918b3 100644 --- a/docs/architecture/stalker-portal.md +++ b/docs/architecture/stalker-portal.md @@ -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` diff --git a/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-view.component.html b/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-view.component.html index 25e2a5d69..7653fb690 100644 --- a/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-view.component.html +++ b/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-view.component.html @@ -75,9 +75,14 @@ @if (inlinePlayback(); as playback) { (null); + readonly episodeMetadata = input(null); + readonly seriesNavigation = input(null); readonly timeUpdate = output(); readonly closed = output(); readonly streamUrlCopied = output(); readonly externalFallbackRequested = output(); + readonly playbackEnded = output(); + readonly previousEpisodeRequested = output(); + readonly nextEpisodeRequested = output(); } @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({ diff --git a/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-view.component.ts b/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-view.component.ts index bdba58275..3e324f13a 100644 --- a/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-view.component.ts +++ b/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-view.component.ts @@ -46,8 +46,12 @@ import { StalkerVodSource, } from '@iptvnator/portal/stalker/data-access'; import { + getSeriesEpisodeMetadata, + getSeriesPlaybackNavigation, type PlaybackFallbackRequest, PortalInlinePlayerComponent, + resolveSeriesPlaybackEpisodeState, + type SeriesPlaybackEpisodeState, } from '@iptvnator/ui/playback'; import { DownloadsService, @@ -272,6 +276,16 @@ export class StalkerSeriesViewComponent implements OnDestroy { vodSeriesSeasons: this.vodSeriesSeasons(), }); }); + readonly inlineEpisodeState = + computed | null>(() => + this.getInlineEpisodeState() + ); + readonly inlineEpisodeMetadata = computed(() => + getSeriesEpisodeMetadata(this.inlineEpisodeState()) + ); + readonly inlineSeriesNavigation = computed(() => + getSeriesPlaybackNavigation(this.inlineEpisodeState()) + ); /** * Handles season selection from the container. @@ -516,6 +530,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 +594,23 @@ export class StalkerSeriesViewComponent implements OnDestroy { } } + private getInlineEpisodeState(): SeriesPlaybackEpisodeState | null { + const playback = this.inlinePlayback(); + const currentEpisodeId = playback?.contentInfo?.contentXtreamId; + + if ( + playback?.contentInfo?.contentType !== 'episode' || + currentEpisodeId === undefined + ) { + return null; + } + + return resolveSeriesPlaybackEpisodeState({ + episodesBySeason: this.mappedSeasons(), + currentEpisodeId, + }); + } + ngOnDestroy(): void { this.unsubscribePositionUpdates?.(); } diff --git a/libs/portal/xtream/feature/src/lib/serial-details/serial-details.component.html b/libs/portal/xtream/feature/src/lib/serial-details/serial-details.component.html index 64787faff..8fdd86066 100644 --- a/libs/portal/xtream/feature/src/lib/serial-details/serial-details.component.html +++ b/libs/portal/xtream/feature/src/lib/serial-details/serial-details.component.html @@ -88,9 +88,14 @@ @if (inlinePlayback(); as playback) { (null); + readonly episodeMetadata = input(null); + readonly seriesNavigation = input(null); readonly timeUpdate = output(); readonly closed = output(); readonly streamUrlCopied = output(); readonly externalFallbackRequested = output(); + readonly playbackEnded = output(); + readonly previousEpisodeRequested = output(); + readonly nextEpisodeRequested = output(); } @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>; + 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' }) + ); + }); }); diff --git a/libs/portal/xtream/feature/src/lib/serial-details/serial-details.component.ts b/libs/portal/xtream/feature/src/lib/serial-details/serial-details.component.ts index 893f73fa8..ae77bb116 100644 --- a/libs/portal/xtream/feature/src/lib/serial-details/serial-details.component.ts +++ b/libs/portal/xtream/feature/src/lib/serial-details/serial-details.component.ts @@ -26,8 +26,12 @@ import { } from '@iptvnator/portal/shared/util'; import { XtreamStore } from '@iptvnator/portal/xtream/data-access'; import { + getSeriesEpisodeMetadata, + getSeriesPlaybackNavigation, type PlaybackFallbackRequest, PortalInlinePlayerComponent, + resolveSeriesPlaybackEpisodeState, + type SeriesPlaybackEpisodeState, } from '@iptvnator/ui/playback'; import { PlaybackPositionRuntimeBridgeService } from '@iptvnator/services'; import { @@ -110,6 +114,16 @@ export class SerialDetailsComponent implements OnInit, OnDestroy { playbackPositions: this.episodePlaybackPositions(), }); }); + readonly inlineEpisodeState = + computed | null>(() => + this.getInlineEpisodeState() + ); + readonly inlineEpisodeMetadata = computed(() => + getSeriesEpisodeMetadata(this.inlineEpisodeState()) + ); + readonly inlineSeriesNavigation = computed(() => + getSeriesPlaybackNavigation(this.inlineEpisodeState()) + ); constructor() { effect(() => { @@ -364,6 +378,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 +422,27 @@ export class SerialDetailsComponent implements OnInit, OnDestroy { void this.portalPlayer.openResolvedPlayback(playback, true); } + private getInlineEpisodeState(): SeriesPlaybackEpisodeState | 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; + } + + return resolveSeriesPlaybackEpisodeState({ + episodesBySeason, + currentEpisodeId, + fallbackSeasonNumber: playback.contentInfo.seasonNumber, + fallbackEpisodeNumber: playback.contentInfo.episodeNumber, + }); + } + async handlePlaybackToggleRequested( request: SeasonContainerPlaybackToggleRequest ): Promise { diff --git a/libs/shared/interfaces/src/lib/embedded-mpv-session.interface.ts b/libs/shared/interfaces/src/lib/embedded-mpv-session.interface.ts index d682107cd..474189c28 100644 --- a/libs/shared/interfaces/src/lib/embedded-mpv-session.interface.ts +++ b/libs/shared/interfaces/src/lib/embedded-mpv-session.interface.ts @@ -3,6 +3,7 @@ export type EmbeddedMpvSessionStatus = | 'loading' | 'playing' | 'paused' + | 'ended' | 'error' | 'closed'; diff --git a/libs/ui/playback/src/index.ts b/libs/ui/playback/src/index.ts index 2055907c0..42d2800c3 100644 --- a/libs/ui/playback/src/index.ts +++ b/libs/ui/playback/src/index.ts @@ -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'; diff --git a/libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-player.component.html b/libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-player.component.html index e4b7c017e..4bc119bec 100644 --- a/libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-player.component.html +++ b/libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-player.component.html @@ -100,6 +100,34 @@ }} + + + +