Merge remote-tracking branch 'origin/master' into agent/series-quick-start

# Conflicts:
#	docs/architecture/embedded-inline-playback.md
#	docs/architecture/stalker-portal.md
#	libs/portal/shared/util/src/lib/series-quick-start.spec.ts
#	libs/portal/shared/util/src/lib/series-quick-start.ts
#	libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-view.component.spec.ts
#	libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-view.component.ts
#	libs/portal/stalker/feature/src/lib/styles/detail-view.scss
#	libs/ui/components/src/lib/content-hero/content-hero.component.scss
#	libs/ui/components/src/lib/styles/detail-view.scss
#	libs/ui/playback/src/lib/styles/detail-view.scss
This commit is contained in:
4gray committed 2026-05-12 19:36:31 +02:00
commit 28ffa65aa6
24 files changed
+1087 -221

No files matched your search

@@ -541,6 +541,8 @@ test.describe('Electron Favorites', () => {
title: movieTitle,
playlistTitle: portalTitle,
});
await playCurrentDetail(app.mainWindow);
await expectInlinePlayerWithoutDialog(app.mainWindow);
await goBackFromDetail(app.mainWindow);
await expectPathname(app.mainWindow, /\/workspace\/global-favorites$/);
@@ -556,6 +558,8 @@ test.describe('Electron Favorites', () => {
title: seriesTitle,
playlistTitle: portalTitle,
});
await playFirstSeriesEpisode(app.mainWindow);
await expectInlinePlayerWithoutDialog(app.mainWindow);
await goBackFromDetail(app.mainWindow);
await expectPathname(app.mainWindow, /\/workspace\/global-favorites$/);
@@ -618,6 +622,47 @@ async function expectInlineCollectionDetail(
).toBeVisible({ timeout: 20000 });
}
async function expectInlinePlayerWithoutDialog(page: Page): Promise<void> {
await expect(
page.locator('app-portal-inline-player app-web-player-view').first()
).toBeVisible({ timeout: 20000 });
await expect(
page.locator('mat-dialog-container app-web-player-view')
).toHaveCount(0);
}
async function playCurrentDetail(page: Page): Promise<void> {
const playButton = page.locator('button.play-btn').first();
await expect(playButton).toBeVisible({ timeout: 20000 });
await playButton.click();
}
async function playFirstSeriesEpisode(page: Page): Promise<void> {
const seasonCard = page.locator('.season-card').first();
const episodeCard = page
.locator('.episode-card, .episode-list-item')
.first();
await expect
.poll(
async () =>
(await seasonCard.count()) + (await episodeCard.count()),
{ timeout: 20000 }
)
.toBeGreaterThan(0);
if ((await seasonCard.count()) > 0) {
await seasonCard.scrollIntoViewIfNeeded();
await expect(seasonCard).toBeVisible({ timeout: 20000 });
await seasonCard.click();
}
await episodeCard.scrollIntoViewIfNeeded();
await expect(episodeCard).toBeVisible({ timeout: 20000 });
await episodeCard.click();
}
async function toggleFavoriteForChannel(
page: Page,
title: string
@@ -591,6 +591,8 @@ test.describe('Electron Recently Viewed', () => {
title: movieTitle,
playlistTitle: portalTitle,
});
await playCurrentDetail(app.mainWindow);
await expectInlinePlayerWithoutDialog(app.mainWindow);
await goBackFromDetail(app.mainWindow);
await expectPathname(app.mainWindow, /\/workspace\/global-recent$/);
@@ -606,6 +608,8 @@ test.describe('Electron Recently Viewed', () => {
title: seriesTitle,
playlistTitle: portalTitle,
});
await playFirstSeriesEpisode(app.mainWindow);
await expectInlinePlayerWithoutDialog(app.mainWindow);
await goBackFromDetail(app.mainWindow);
await expectPathname(app.mainWindow, /\/workspace\/global-recent$/);
@@ -677,6 +681,15 @@ async function expectInlineCollectionDetail(
).toBeVisible({ timeout: 20000 });
}
async function expectInlinePlayerWithoutDialog(page: Page): Promise<void> {
await expect(
page.locator('app-portal-inline-player app-web-player-view').first()
).toBeVisible({ timeout: 20000 });
await expect(
page.locator('mat-dialog-container app-web-player-view')
).toHaveCount(0);
}
async function playCurrentDetail(page: Page): Promise<void> {
const playButton = page.locator('button.play-btn').first();
@@ -905,6 +905,16 @@ test.describe('Electron Workspace Search', () => {
await expect(
contentCardByTitle(app.mainWindow, sample.targetTitle).first()
).toBeVisible({ timeout: 20000 });
await contentCardByTitle(app.mainWindow, sample.targetTitle)
.first()
.click();
await expect(
app.mainWindow.locator('app-content-hero')
).toContainText(flexibleTextPattern(sample.targetTitle), {
timeout: 20000,
});
await playCurrentDetail(app.mainWindow);
await expectInlinePlayerWithoutDialog(app.mainWindow);
} finally {
await closeElectronApp(app);
}
@@ -1535,6 +1545,22 @@ async function goBackFromDetail(page: Page): Promise<void> {
await backButton.click();
}
async function playCurrentDetail(page: Page): Promise<void> {
const playButton = page.locator('button.play-btn').first();
await expect(playButton).toBeVisible({ timeout: 20000 });
await playButton.click();
}
async function expectInlinePlayerWithoutDialog(page: Page): Promise<void> {
await expect(
page.locator('app-portal-inline-player app-web-player-view').first()
).toBeVisible({ timeout: 20000 });
await expect(
page.locator('mat-dialog-container app-web-player-view')
).toHaveCount(0);
}
async function pickPreferredCategory(categories: Locator): Promise<Locator> {
const count = await categories.count();
let fallback = categories.first();
-2
View File
@@ -24,8 +24,6 @@ export default {
snapshotSerializers: angularEsmPreset.snapshotSerializers,
moduleNameMapper: {
...(angularEsmPreset.moduleNameMapper ?? {}),
'^@iptvnator/portal/xtream/feature$':
'<rootDir>/src/test-stubs/xtream-feature.stub.ts',
tslib: 'tslib/tslib.es6.js',
'^iptv-playlist-parser$':
'<rootDir>/src/test-stubs/iptv-playlist-parser.mjs',
@@ -52,25 +52,14 @@ describe('PlayerService', () => {
expect(service.isEmbeddedPlayer(VideoPlayer.VLC)).toBe(false);
});
it('uses the dialog fallback for embedded players', async () => {
await service.openResolvedPlayback({
it('does not open a dialog or IPC for embedded players', async () => {
const result = await service.openResolvedPlayback({
streamUrl: 'https://example.com/video.mp4',
title: 'Example Video',
});
expect(dialog.open).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({
data: expect.objectContaining({
streamUrl: 'https://example.com/video.mp4',
title: 'Example Video',
playback: expect.objectContaining({
streamUrl: 'https://example.com/video.mp4',
title: 'Example Video',
}),
}),
})
);
expect(result).toBeUndefined();
expect(dialog.open).not.toHaveBeenCalled();
expect(dataService.sendIpcEvent).not.toHaveBeenCalled();
});
+1 -17
View File
@@ -67,7 +67,6 @@ export class PlayerService {
hideExternalInfoDialog = true
): Promise<ExternalPlayerSession | void> {
const player = this.settingsStore.player() ?? VideoPlayer.VideoJs;
const { streamUrl, title, contentInfo, startTime } = playback;
if (player === VideoPlayer.MPV) {
if (!hideExternalInfoDialog) {
@@ -81,22 +80,7 @@ export class PlayerService {
return await this.openExternalPlayback(playback, 'vlc');
}
return import('@iptvnator/portal/xtream/feature').then(
({ PlayerDialogComponent }) => {
this.dialog.open(PlayerDialogComponent, {
data: {
streamUrl,
title,
contentInfo,
startTime,
playback,
},
width: '80%',
maxWidth: '1200px',
maxHeight: '90vh',
});
}
);
return;
}
async openExternalPlayback(
@@ -1 +0,0 @@
export class PlayerDialogComponent {}
@@ -0,0 +1,209 @@
---
title: Experimental embedded MPV support on macOS
description: "A closer look at IPTVnator's experimental embedded MPV player for macOS: why it exists, how it is wired into Electron, and where to share feedback."
featured: false
pubDate: 2026-05-11
author: 4gray
heroImage: /iptvnator/blog/v0-21/embedded-mpv.webp
tags:
- macos
- mpv
- playback
- experimental
draft: false
---
import Alert from '../../components/blog/Alert.astro';
import BlogImageSlider from '../../components/blog/BlogImageSlider.astro';
import LinkCards from '../../components/blog/LinkCards.astro';
import StatusPill from '../../components/blog/StatusPill.astro';
IPTVnator **v0.21** introduced the first public experiment with an **embedded MPV player for macOS**.
The short version: on supported macOS builds, IPTVnator can use MPV-quality playback without requiring a separate MPV app installation, a custom executable path, or an external player window.
The feature is still marked **experimental**. Real-world feedback is especially useful for different Mac models, macOS versions, display setups, and media formats.
<BlogImageSlider
images={[
{
src: '/iptvnator/blog/v0-21/embedded-mpv.webp',
alt: 'Embedded MPV player rendering inside the IPTVnator window on macOS',
caption:
'Embedded MPV runs inside the IPTVnator window on macOS — no separate MPV installation required.',
},
]}
/>
<Alert type="info" title="IPTVnator is a player, not a content provider">
IPTVnator does not provide, host, sell, curate, or recommend TV channels,
playlists, streams, EPG data, portals, subscriptions, or any other media
content. The app is a general-purpose IPTV player for user-provided sources.
Users are responsible for making sure they have the legal right to access
any content, playlist, portal, or stream they open in the app.
</Alert>
## Why embedded MPV?
Playback sources vary a lot in the real world. Some media works fine in browser-based players, while other files or streams need specific headers, containers, codecs, subtitle handling, or timing behavior that native playback engines often handle better.
IPTVnator has supported external MPV and VLC playback for a long time, and that remains useful. But the external-player flow has trade-offs:
- users need to install MPV or VLC separately;
- the app needs to know the correct executable path;
- playback opens in another window;
- controls, focus, fullscreen, and progress tracking have to cross process boundaries.
The embedded MPV experiment tries a different approach: keep MPV's playback engine, but render it **inside IPTVnator**.
## How it works
<StatusPill type="beta" />
The macOS embedded player does **not** launch the normal `mpv` application. It also does not use MPV's classic `--wid` window embedding path.
Instead, IPTVnator bundles `libmpv` and loads it through a native Node addon in the Electron backend. When playback starts, the app creates an MPV session and renders frames into an IPTVnator-owned native macOS view inside the Electron window.
The simplified flow looks like this:
1. The Angular UI receives the same resolved playback payload used by the rest of IPTVnator.
2. The renderer asks Electron's preload bridge to create an embedded MPV session.
3. The Electron main process loads the native addon and owns the MPV session lifecycle.
4. The native addon creates an `mpv_handle`, configures `vo=libmpv`, and renders through MPV's render API into a macOS `NSOpenGLView`.
5. IPTVnator keeps its own controls in Angular: play/pause, seek, volume, fullscreen, audio track selection, subtitles, speed, aspect ratio, and live recording where supported.
6. Resize, scroll, fullscreen changes, and modal dialogs are measured by the UI and sent back to the native layer so the MPV view stays aligned with the app layout.
The renderer never talks to the native module directly. Everything goes through a controlled IPC surface: create session, load playback, set bounds, pause/resume, seek, volume, track selection, recording, and cleanup.
## Why macOS only for now?
This first version is intentionally narrow. The current native integration is macOS-only because it uses AppKit and an app-owned native view inside the Electron window.
The older `--wid`-style approach was not reliable enough on macOS: it could produce audio with a black video surface inside Electron. The current path avoids that by rendering through `libmpv` directly, but it also means IPTVnator has to handle more details itself.
For example, because MPV is not owning the normal player window, IPTVnator explicitly keeps the display awake while an embedded MPV session is playing and releases that blocker when playback is paused, disposed, or the app shuts down.
## What is already included
The experimental macOS player is designed to feel like part of IPTVnator, not like a separate app glued on the side.
Current capabilities include:
- playback inside the IPTVnator window;
- no standalone MPV app required in packaged macOS builds;
- IPTVnator-owned controls and fullscreen button;
- resume offsets for VOD and series;
- volume control and keyboard shortcuts;
- audio track selection through MPV's `aid` property;
- subtitle selection where the bundled native addon supports it;
- playback speed and aspect ratio controls where supported;
- live-stream recording for live playback sessions;
- fallback to the existing inline players or external MPV/VLC if embedded MPV is unavailable.
<Alert type="warning" title="Still experimental">
Embedded MPV is not the default player. It is a macOS-only experimental
option, and the existing Video.js/HTML5 players plus external MPV/VLC
support are still available. Native playback can expose platform-specific
issues, especially around graphics, fullscreen, hardware decoding, external
displays, and unusual media formats.
</Alert>
## Content and copyright notes
IPTVnator is only playback software. It does not include any built-in channel catalog, playlist marketplace, paid content access, hosted streams, or recommendations for where to obtain media.
Embedded MPV does not change that model. It only changes how user-provided media is rendered inside the app on supported macOS builds.
Please use IPTVnator only with content and sources that are authorized for your use. Do not share private playlist URLs, credentials, tokens, portals, or subscription details in public bug reports, screenshots, GitHub issues, Telegram messages, or Reddit posts.
## Packaging and safety notes
For release builds, IPTVnator is intended to ship a bundled `libmpv` runtime instead of depending on Homebrew or a user-installed MPV app.
That runtime has to be staged for both Apple Silicon and Intel macOS builds, linked app-relatively, code-signed, notarized, and checked so packaged apps do not accidentally depend on `/opt/homebrew` or `/usr/local` libraries from a developer machine.
If the bundled addon or `libmpv` runtime is missing or fails support detection, IPTVnator hides the embedded MPV option and keeps the normal player choices available.
## Useful links
<LinkCards
links={[
{
label: 'Download v0.21.0',
href: 'https://github.com/4gray/iptvnator/releases/tag/v0.21.0',
hint: 'Official binaries for macOS, Windows and Linux.',
icon: 'download',
},
{
label: 'Full changelog',
href: 'https://github.com/4gray/iptvnator/compare/v0.20.0...v0.21.0',
hint: 'Every commit between v0.20.0 and v0.21.0.',
icon: 'github',
},
{
label: 'GitHub Sponsors',
href: 'https://github.com/sponsors/4gray',
hint: 'Support continued development.',
icon: 'sponsor',
},
{
label: 'Ko-fi',
href: 'https://ko-fi.com/4gray',
hint: 'One-time tip jar.',
icon: 'kofi',
},
]}
/>
## What feedback is useful?
Real-world testing matters more than a single local setup. If embedded MPV has been tried on macOS, useful details include:
- Mac model and CPU — Apple Silicon or Intel;
- macOS version;
- IPTVnator version;
- selected player setting;
- media type tested — live TV, VOD, series, catch-up, HLS, MPEG-TS, MP4;
- whether playback starts reliably;
- whether fullscreen, resize, and route changes behave normally;
- whether audio tracks, subtitles, speed, or aspect ratio controls work as expected;
- whether the app stays stable after switching channels or closing playback;
- whether embedded MPV behaves better than the browser-based player for the same authorized source;
- whether external MPV, VLC, or IINA still works better for a particular setup.
Even short feedback like “works on M1 + macOS 15 with an authorized HLS source” or “crashes when entering fullscreen on Intel” is useful.
## Where to share feedback
<LinkCards
links={[
{
label: 'GitHub issue tracker',
href: 'https://github.com/4gray/iptvnator/issues',
hint: 'Best for reproducible bugs, crash reports, and feature requests.',
icon: 'github',
},
{
label: 'Telegram group',
href: 'https://t.me/iptvnator',
hint: 'Best for quick feedback and community discussion.',
icon: 'telegram',
},
{
label: 'Embedded MPV tracking issue',
href: 'https://github.com/4gray/iptvnator/issues/877',
hint: 'Original GitHub issue for the macOS embedded MPV experiment.',
icon: 'github',
},
]}
/>
When reporting a problem, include the Mac model/CPU, macOS version, IPTVnator version, selected player setting, and what happened. Please do **not** share private playlist URLs or credentials publicly.
## Closing thoughts
Embedded MPV is a bigger architectural change than it may look from the UI. Under the hood it touches Electron, native macOS views, `libmpv`, IPC, packaging, code signing, power management, and the Angular playback shell.
That is why the feature is rolling out carefully as an experiment first.
If it works well across more macOS setups, embedded MPV could become the easiest way to get stronger playback compatibility in IPTVnator without losing the integrated app experience.
+29 -20
View File
@@ -9,25 +9,30 @@ This document records the current contract for embedded playback in portal detai
- External players are `mpv` and `vlc`.
- Flatpak launches external players on the host via `flatpak-spawn --host`.
- Live playback stays inline in dedicated live layouts.
- VOD and series detail playback now also stays inline on canonical detail surfaces.
- VOD and series detail playback stays inline on canonical detail, collection,
favorites, recent, and search surfaces.
- Xtream and Stalker series detail heroes expose a quick-start CTA driven by
saved episode playback positions.
- Material dialog playback remains only as a fallback for older non-detail callers.
- Embedded playback UI is always hosted by the current view. `PlayerService`
launches MPV/VLC only and does not open an embedded-player dialog.
- Browser-player failures are diagnosed client-side and can offer explicit MPV/VLC fallback actions without changing the saved player setting.
## Scope
The first pass is intentionally limited:
Inline embedded playback is required for these VOD/series entry points:
- Xtream VOD detail route
- Xtream series detail route
- Stalker VOD detail view
- Stalker series detail view
- unified favorites collection details
- unified recently viewed collection details
- Stalker advanced search result details
Not migrated in this pass:
- Generic non-detail playback entry points that still call `PlayerService.openPlayer(...)`
- Any collection/search surface that does not host a canonical detail surface of its own
Collection/search VOD surfaces that expose embedded playback must host
`ResolvedPortalPlayback` inline state locally. They must not call
`PlayerService.openPlayer(...)` or `PlayerService.openResolvedPlayback(...)` to
create embedded UI.
## Embedded MPV Harness
@@ -70,7 +75,7 @@ Current limitation:
Shared inline player shell:
- `/Users/4gray/Code/iptvnator/libs/ui/components/src/lib/portal-inline-player/portal-inline-player.component.ts`
- `/Users/4gray/Code/iptvnator/libs/ui/playback/src/lib/portal-inline-player/portal-inline-player.component.ts`
Xtream detail hosts:
@@ -79,13 +84,14 @@ Xtream detail hosts:
Stalker detail hosts:
- `/Users/4gray/Code/iptvnator/libs/portal/catalog/feature/src/lib/category-content-view/category-content-view.component.ts`
- `/Users/4gray/Code/iptvnator/libs/ui/components/src/lib/stalker-series-view/stalker-series-view.component.ts`
- `/Users/4gray/Code/iptvnator/libs/portal/stalker/feature/src/lib/stalker-catalog-detail/stalker-catalog-detail.component.ts`
- `/Users/4gray/Code/iptvnator/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-view.component.ts`
- `/Users/4gray/Code/iptvnator/libs/portal/stalker/feature/src/lib/stalker-collection-detail.component.ts`
- `/Users/4gray/Code/iptvnator/libs/portal/stalker/feature/src/lib/stalker-search/stalker-search.component.ts`
Fallback dialog path:
- `/Users/4gray/Code/iptvnator/apps/web/src/app/services/player.service.ts`
- `/Users/4gray/Code/iptvnator/libs/portal/xtream/feature/src/lib/player-dialog/player-dialog.component.ts`
Embedded playback does not have a fallback dialog path.
`PlayerService.openResolvedPlayback(...)` remains the MPV/VLC external launch
entry point; for embedded players it returns without creating UI.
Diagnostics and fallback UI:
@@ -101,7 +107,8 @@ When a detail view starts playback:
3. If the player is embedded, render the inline player inside the current detail view.
4. If the player is external, hand the same payload to `PlayerService` for MPV/VLC playback.
The detail host owns inline state. `PlayerService` is no longer the primary owner of UI playback state for canonical VOD/series detail screens.
The detail or collection/search host owns inline state. `PlayerService` is not
an owner of embedded UI playback state.
## Series Quick Start CTA
@@ -199,18 +206,20 @@ Stalker previously resolved playback and opened UI in the same method.
Current contract:
- `resolveVodPlayback(...)` returns a `ResolvedPortalPlayback`
- `createLinkToPlayVod(...)` remains as a compatibility wrapper for untouched callers
- canonical Stalker detail views use the resolver directly and decide inline vs external locally
- `createLinkToPlayVod(...)` remains as a compatibility API but collection,
search, and canonical detail views use the resolver directly
- Stalker detail, collection, and search views decide inline vs external locally
This keeps:
- inline/store-state detail navigation intact
- series and VOD-as-series support intact
- non-detail callers working until they are migrated
- external MPV/VLC launches unchanged
## Playback Position Saving
The old dialog path saved playback positions from inside `PlayerDialogComponent`.
The old dialog path saved playback positions from inside the removed Xtream
player dialog.
The new contract is:
@@ -222,7 +231,7 @@ This avoids coupling inline UI state to a global dialog.
## Future Migration Rule
If a non-detail surface is converted away from dialog playback:
If a non-detail surface needs embedded playback:
- give that surface a canonical inline host
- switch it to `ResolvedPortalPlayback`
+5 -3
View File
@@ -144,9 +144,11 @@ Stalker has multiple real-world data shapes. The current implementation supports
- The series quick-start CTA can load the first unloaded VOD-series season
before playback. Unloaded seasons are considered unplayed in full season
order, so an earlier unloaded season is not skipped just because a later
season was loaded manually. After a lazy load, quick start is recomputed from
the mapped episodes before playback so provider episode ordering cannot start
the wrong episode.
season was loaded manually. If all currently loaded episodes are watched and
more season metadata exists, quick start loads the next unloaded season
instead of showing the completed state. After a lazy load, quick start is
recomputed from the mapped episodes before playback so provider episode
ordering cannot start the wrong episode.
- For unloaded VOD-series seasons, the CTA target label is derived from season
metadata and rendered as `SxxE01` until episode details are loaded.
- Uses unique generated tracking IDs for episode playback position compatibility.
@@ -202,6 +202,49 @@ describe('withStalkerPlayer', () => {
});
});
it('persists regular series recent metadata without marking it as a VOD series', async () => {
store.setSelectedContentType('series');
store.setSelectedItem({
id: '30000',
cmd: '',
title: 'Regular Series',
category_id: 'series',
});
dataService.sendIpcEvent.mockResolvedValueOnce({
js: {
cmd: 'ffmpeg http://cdn.example/episode_30000_1.mpg',
},
});
const playback = await store.resolveVodPlayback(
'ffrt4://series/30000/season/1',
'Episode One',
'series.jpg',
1,
3000001
);
expect(playlistService.addPortalRecentlyViewed).toHaveBeenCalledWith(
PLAYLIST._id,
expect.objectContaining({
id: '30000',
title: 'Episode One',
category_id: 'series',
cover: 'series.jpg',
added_at: expect.any(Number),
})
);
expect(
playlistService.addPortalRecentlyViewed.mock.calls[0][1]
).not.toHaveProperty('is_series');
expect(playback.contentInfo).toEqual({
playlistId: PLAYLIST._id,
contentXtreamId: 3000001,
contentType: 'episode',
seriesXtreamId: 30000,
});
});
it('resolves direct radio stream commands without external player side effects', async () => {
store.setSelectedContentType('radio');
store.setSelectedItem({
@@ -27,7 +27,6 @@ export function getStalkerSeriesRecentMetadata(
selectedContentType: StalkerContentType
): {
category_id?: 'series';
is_series?: true;
} {
if (selectedContentType !== 'series') {
return {};
@@ -35,7 +34,6 @@ export function getStalkerSeriesRecentMetadata(
return {
category_id: 'series',
is_series: true,
};
}
@@ -1,10 +1,22 @@
import { Component, input, output, signal } from '@angular/core';
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { MatSnackBar } from '@angular/material/snack-bar';
import { TranslateService } from '@ngx-translate/core';
import { ContentHeroComponent } from 'components';
import { UnifiedCollectionItem } from '@iptvnator/portal/shared/util';
import {
PORTAL_EXTERNAL_PLAYBACK,
PORTAL_PLAYBACK_POSITIONS,
PORTAL_PLAYER,
UnifiedCollectionItem,
} from '@iptvnator/portal/shared/util';
import { StalkerStore } from '@iptvnator/portal/stalker/data-access';
import { PlaylistsService } from 'services';
import { Playlist } from 'shared-interfaces';
import {
Playlist,
ResolvedPortalPlayback,
VodDetailsItem,
createStalkerVodItem,
} from 'shared-interfaces';
import { of } from 'rxjs';
import { StalkerCollectionDetailComponent } from './stalker-collection-detail.component';
import { StalkerInlineDetailComponent } from './stalker-inline-detail/stalker-inline-detail.component';
@@ -27,11 +39,19 @@ class StubStalkerInlineDetailComponent {
readonly categoryId = input<'vod' | 'series' | null>(null);
readonly seriesItem = input<unknown>(null);
readonly isSeries = input(false);
readonly vodDetailsItem = input<unknown>(null);
readonly vodDetailsItem = input<VodDetailsItem | null>(null);
readonly isFavorite = input(false);
readonly playbackPosition = input<number | null>(null);
readonly inlinePlayback = input<ResolvedPortalPlayback | null>(null);
readonly externalPlayback = input<unknown>(null);
readonly backClicked = output<void>();
readonly playClicked = output<unknown>();
readonly playClicked = output<VodDetailsItem>();
readonly resumeClicked = output<unknown>();
readonly favoriteToggled = output<unknown>();
readonly inlineTimeUpdated = output<unknown>();
readonly inlinePlaybackClosed = output<void>();
readonly streamUrlCopied = output<void>();
readonly inlineExternalFallbackRequested = output<unknown>();
}
describe('StalkerCollectionDetailComponent', () => {
@@ -54,6 +74,19 @@ describe('StalkerCollectionDetailComponent', () => {
addToFavorites: jest.Mock;
removeFromFavorites: jest.Mock;
createLinkToPlayVod: jest.Mock;
resolveVodPlayback: jest.Mock;
};
let portalPlayer: {
isEmbeddedPlayer: jest.Mock;
openResolvedPlayback: jest.Mock;
openExternalPlayback: jest.Mock;
};
let playbackPositions: {
savePlaybackPosition: jest.Mock;
getPlaybackPosition: jest.Mock;
getSeriesPlaybackPositions: jest.Mock;
getAllPlaybackPositions: jest.Mock;
clearPlaybackPosition: jest.Mock;
};
const playlist = {
@@ -91,6 +124,19 @@ describe('StalkerCollectionDetailComponent', () => {
addToFavorites: jest.fn(),
removeFromFavorites: jest.fn(),
createLinkToPlayVod: jest.fn(),
resolveVodPlayback: jest.fn(),
};
portalPlayer = {
isEmbeddedPlayer: jest.fn(() => true),
openResolvedPlayback: jest.fn(),
openExternalPlayback: jest.fn(),
};
playbackPositions = {
savePlaybackPosition: jest.fn(),
getPlaybackPosition: jest.fn(async () => null),
getSeriesPlaybackPositions: jest.fn(),
getAllPlaybackPositions: jest.fn(),
clearPlaybackPosition: jest.fn(),
};
await TestBed.configureTestingModule({
@@ -100,6 +146,40 @@ describe('StalkerCollectionDetailComponent', () => {
provide: StalkerStore,
useValue: stalkerStore,
},
{
provide: PORTAL_PLAYER,
useValue: portalPlayer,
},
{
provide: PORTAL_PLAYBACK_POSITIONS,
useValue: playbackPositions,
},
{
provide: PORTAL_EXTERNAL_PLAYBACK,
useValue: {
activeSession: signal(null),
visibleSession: signal(null),
dismissActiveSession: jest.fn(),
closeSession: jest.fn(),
},
},
{
provide: MatSnackBar,
useValue: {
open: jest.fn(),
},
},
{
provide: TranslateService,
useValue: {
instant: (key: string) => key,
get: (key: string) => of(key),
stream: (key: string) => of(key),
onLangChange: of(null),
onTranslationChange: of(null),
onDefaultLangChange: of(null),
},
},
{
provide: PlaylistsService,
useValue: {
@@ -111,7 +191,10 @@ describe('StalkerCollectionDetailComponent', () => {
})
.overrideComponent(StalkerCollectionDetailComponent, {
remove: {
imports: [ContentHeroComponent, StalkerInlineDetailComponent],
imports: [
ContentHeroComponent,
StalkerInlineDetailComponent,
],
},
add: {
imports: [
@@ -150,7 +233,9 @@ describe('StalkerCollectionDetailComponent', () => {
expect(stalkerStore.setSelectedContentType).toHaveBeenLastCalledWith(
'vod'
);
expect(stalkerStore.setSelectedCategory).toHaveBeenLastCalledWith('vod');
expect(stalkerStore.setSelectedCategory).toHaveBeenLastCalledWith(
'vod'
);
expect(stalkerStore.setSelectedItem).toHaveBeenLastCalledWith(
expect.objectContaining({
id: '1507',
@@ -227,6 +312,83 @@ describe('StalkerCollectionDetailComponent', () => {
fixture.componentInstance.inlineDetail().seriesItem?.series
).toEqual([1, 2]);
});
it('plays regular VOD collection details inline for embedded players instead of using the legacy play wrapper', async () => {
const sourceItem = {
id: '1701',
title: 'Collection Movie',
category_id: 'vod',
cmd: '/media/file_1701.mpg',
info: {
name: 'Collection Movie',
movie_image: 'movie.jpg',
},
};
const playback: ResolvedPortalPlayback = {
streamUrl: 'https://streams.example.test/movie.mp4',
title: 'Collection Movie',
thumbnail: 'movie.jpg',
};
stalkerStore.resolveVodPlayback.mockResolvedValue(playback);
fixture.componentRef.setInput(
'item',
buildCollectionItem({
contentType: 'movie',
categoryId: 'vod',
stalkerItem: sourceItem,
})
);
await settleDetail(fixture);
fixture.componentInstance.onVodPlay(
createStalkerVodItem(sourceItem, playlist._id)
);
await settleDetail(fixture);
expect(stalkerStore.resolveVodPlayback).toHaveBeenCalledWith(
'/media/file_1701.mpg',
'Collection Movie',
'movie.jpg'
);
expect(stalkerStore.createLinkToPlayVod).not.toHaveBeenCalled();
expect(portalPlayer.openResolvedPlayback).not.toHaveBeenCalled();
expect(fixture.componentInstance.inlinePlayback()).toEqual(playback);
});
it('does not load VOD playback position when the playlist id is missing', async () => {
const playlistsService = TestBed.inject(PlaylistsService) as {
getPlaylistById: jest.Mock;
};
playlistsService.getPlaylistById.mockReturnValue(
of({
...playlist,
_id: '',
})
);
fixture.componentRef.setInput(
'item',
buildCollectionItem({
contentType: 'movie',
categoryId: 'vod',
stalkerItem: {
id: '1702',
title: 'Collection Movie Without Playlist',
category_id: 'vod',
cmd: '/media/file_1702.mpg',
},
})
);
await settleDetail(fixture);
expect(playbackPositions.getPlaybackPosition).not.toHaveBeenCalled();
expect(fixture.componentInstance.selectedVodPlaybackPosition()).toBe(
null
);
});
});
function buildCollectionItem(
@@ -9,9 +9,15 @@ import {
signal,
untracked,
} from '@angular/core';
import { MatSnackBar } from '@angular/material/snack-bar';
import { TranslateService } from '@ngx-translate/core';
import { ContentHeroComponent } from 'components';
import {
buildStalkerStateItem,
createLogger,
PORTAL_EXTERNAL_PLAYBACK,
PORTAL_PLAYBACK_POSITIONS,
PORTAL_PLAYER,
toStalkerCategoryId,
UnifiedCollectionItem,
} from '@iptvnator/portal/shared/util';
@@ -29,10 +35,18 @@ import {
StalkerStore,
toggleStalkerVodFavorite,
} from '@iptvnator/portal/stalker/data-access';
import type { PlaybackFallbackRequest } from '@iptvnator/ui/playback';
import { PlaylistsService } from 'services';
import { Playlist, StalkerPortalItem, VodDetailsItem } from 'shared-interfaces';
import {
PlaybackPositionData,
Playlist,
ResolvedPortalPlayback,
StalkerPortalItem,
VodDetailsItem,
} from 'shared-interfaces';
import { firstValueFrom } from 'rxjs';
import { StalkerInlineDetailComponent } from './stalker-inline-detail/stalker-inline-detail.component';
import { StalkerVodPlaybackController } from './stalker-vod-playback-controller';
interface StalkerCollectionStateSnapshot {
currentPlaylist: Playlist | undefined;
@@ -61,9 +75,19 @@ interface StalkerCollectionDetailMode {
[isSeries]="inlineDetail().isSeries"
[vodDetailsItem]="inlineDetail().vodDetailsItem"
[isFavorite]="isSelectedVodFavorite()"
[playbackPosition]="selectedVodPlaybackPosition()"
[inlinePlayback]="inlinePlayback()"
[externalPlayback]="externalPlayback.activeSession()"
(backClicked)="closeRequested.emit()"
(playClicked)="onVodPlay($event)"
(resumeClicked)="onVodResume($event)"
(favoriteToggled)="onVodFavoriteToggled($event)"
(inlineTimeUpdated)="handleInlineTimeUpdate($event)"
(inlinePlaybackClosed)="closeInlinePlayer()"
(streamUrlCopied)="showCopyNotification()"
(inlineExternalFallbackRequested)="
handleExternalFallbackRequest($event)
"
/>
} @else {
<app-content-hero [isLoading]="true" />
@@ -87,11 +111,21 @@ export class StalkerCollectionDetailComponent {
private readonly playlistsService = inject(PlaylistsService);
private readonly stalkerStore = inject(StalkerStore);
readonly externalPlayback = inject(PORTAL_EXTERNAL_PLAYBACK);
private readonly playbackPositions = inject(PORTAL_PLAYBACK_POSITIONS);
private readonly portalPlayer = inject(PORTAL_PLAYER);
private readonly snackBar = inject(MatSnackBar);
private readonly translateService = inject(TranslateService);
private readonly logger = createLogger('StalkerCollectionDetail');
private readonly originalState = this.captureStoreState();
private readonly favoritesRefresh = createRefreshTrigger();
readonly itemDetails = signal<StalkerSelectedVodItem | null>(null);
readonly vodDetailsItem = signal<VodDetailsItem | null>(null);
readonly inlinePlayback = signal<ResolvedPortalPlayback | null>(null);
private readonly selectedVodPosition = signal<PlaybackPositionData | null>(
null
);
readonly isSelectedVodFavorite = signal(false);
readonly detailCategoryOverride = signal<StalkerDetailCategory | null>(
null
@@ -109,8 +143,21 @@ export class StalkerCollectionDetailComponent {
() => this.stalkerStore.currentPlaylist()?._id,
() => this.favoritesRefresh.refreshVersion()
);
readonly selectedVodPlaybackPosition = computed<number | null>(
() => this.selectedVodPosition()?.positionSeconds ?? null
);
private initRequestId = 0;
private readonly vodPlayback = new StalkerVodPlaybackController({
inlinePlayback: this.inlinePlayback,
selectedVodPosition: this.selectedVodPosition,
playbackPositions: this.playbackPositions,
portalPlayer: this.portalPlayer,
snackBar: this.snackBar,
translateService: this.translateService,
logger: this.logger,
playbackErrorLogMessage: 'Failed to start collection VOD playback',
});
constructor() {
effect(() => {
@@ -139,11 +186,12 @@ export class StalkerCollectionDetailComponent {
this.stalkerStore.setSelectedItem(
this.originalState.selectedItem as never
);
this.closeInlinePlayer();
}
onVodPlay(item: VodDetailsItem): void {
if (item.type === 'stalker') {
this.stalkerStore.createLinkToPlayVod(
void this.startStalkerVodPlayback(
item.cmd,
item.data.info?.name,
item.data.info?.movie_image
@@ -151,6 +199,20 @@ export class StalkerCollectionDetailComponent {
}
}
onVodResume(event: {
item: VodDetailsItem;
positionSeconds: number;
}): void {
if (event.item.type === 'stalker') {
void this.startStalkerVodPlayback(
event.item.cmd,
event.item.data.info?.name,
event.item.data.info?.movie_image,
event.positionSeconds
);
}
}
onVodFavoriteToggled(event: {
item: VodDetailsItem;
isFavorite: boolean;
@@ -170,6 +232,25 @@ export class StalkerCollectionDetailComponent {
});
}
handleInlineTimeUpdate(event: {
currentTime: number;
duration: number;
}): void {
this.vodPlayback.handleInlineTimeUpdate(event);
}
closeInlinePlayer(): void {
this.vodPlayback.closeInlinePlayer();
}
showCopyNotification(): void {
this.vodPlayback.showCopyNotification();
}
handleExternalFallbackRequest(request: PlaybackFallbackRequest): void {
this.vodPlayback.handleExternalFallbackRequest(request);
}
private async prepareDetail(
item: UnifiedCollectionItem | null
): Promise<void> {
@@ -177,6 +258,7 @@ export class StalkerCollectionDetailComponent {
if (!item) {
this.clearLocalDetailState();
this.closeInlinePlayer();
return;
}
@@ -203,7 +285,9 @@ export class StalkerCollectionDetailComponent {
);
this.detailCategoryOverride.set(detailMode.category);
this.stalkerStore.setSelectedContentType(detailMode.selectedContentType);
this.stalkerStore.setSelectedContentType(
detailMode.selectedContentType
);
this.stalkerStore.setSelectedCategory(
this.resolveSelectedCategory(item, stalkerItem, detailMode)
);
@@ -222,12 +306,17 @@ export class StalkerCollectionDetailComponent {
this.itemDetails.set(detailViewState.itemDetails);
this.vodDetailsItem.set(detailViewState.vodDetailsItem);
this.syncSelectedVodFavorite();
void this.loadSelectedVodPosition(
playlist._id,
Number(detailViewState.itemDetails?.id)
);
return;
}
const cleared = clearStalkerDetailViewState();
this.vodDetailsItem.set(cleared.vodDetailsItem);
this.isSelectedVodFavorite.set(false);
this.selectedVodPosition.set(null);
}
private captureStoreState(): StalkerCollectionStateSnapshot {
@@ -273,11 +362,8 @@ export class StalkerCollectionDetailComponent {
item: UnifiedCollectionItem,
stalkerItem: StalkerPortalItem
): StalkerCollectionDetailMode {
const hasEmbeddedSeries = Array.isArray(
(stalkerItem as { series?: unknown[] }).series
)
? (stalkerItem as { series?: unknown[] }).series!.length > 0
: false;
const series = (stalkerItem as { series?: unknown[] }).series;
const hasEmbeddedSeries = Array.isArray(series) && series.length > 0;
const isVodSeries = isStalkerSeriesFlag(
(stalkerItem as { is_series?: unknown }).is_series
);
@@ -314,7 +400,9 @@ export class StalkerCollectionDetailComponent {
return 'vod';
}
return categoryId ?? toStalkerCategoryId(detailMode.selectedContentType);
return (
categoryId ?? toStalkerCategoryId(detailMode.selectedContentType)
);
}
private syncSelectedVodFavorite(): void {
@@ -332,5 +420,33 @@ export class StalkerCollectionDetailComponent {
this.vodDetailsItem.set(cleared.vodDetailsItem);
this.detailCategoryOverride.set(null);
this.isSelectedVodFavorite.set(false);
this.selectedVodPosition.set(null);
}
private async startStalkerVodPlayback(
cmd?: string,
title?: string,
thumbnail?: string,
startTime?: number
): Promise<void> {
await this.vodPlayback.startVodPlayback(() =>
startTime === undefined
? this.stalkerStore.resolveVodPlayback(cmd, title, thumbnail)
: this.stalkerStore.resolveVodPlayback(
cmd,
title,
thumbnail,
undefined,
undefined,
startTime
)
);
}
private async loadSelectedVodPosition(
playlistId: string,
vodId: number
): Promise<void> {
await this.vodPlayback.loadSelectedVodPosition(playlistId, vodId);
}
}
@@ -13,10 +13,19 @@
<app-vod-details
[item]="vodDetailsItem()"
[isFavorite]="isFavorite()"
[externalPlayback]="externalPlayback.activeSession()"
[playbackPosition]="playbackPosition()"
[inlinePlayback]="inlinePlayback()"
[externalPlayback]="externalPlayback()"
(playClicked)="onPlayClicked($event)"
(resumeClicked)="onResumeClicked($event)"
(favoriteToggled)="onFavoriteToggled($event)"
(backClicked)="onBackClicked()"
(inlineTimeUpdated)="onInlineTimeUpdated($event)"
(inlinePlaybackClosed)="onInlinePlaybackClosed()"
(streamUrlCopied)="onStreamUrlCopied()"
(inlineExternalFallbackRequested)="
onInlineExternalFallbackRequested($event)
"
/>
}
} @else if (categoryId() === 'series') {
@@ -1,14 +1,19 @@
import {
ChangeDetectionStrategy,
Component,
inject,
input,
output,
} from '@angular/core';
import { PORTAL_EXTERNAL_PLAYBACK } from '@iptvnator/portal/shared/util';
import { StalkerSelectedVodItem } from '@iptvnator/portal/stalker/data-access';
import { VodDetailsComponent } from '@iptvnator/ui/playback';
import { VodDetailsItem } from 'shared-interfaces';
import {
type PlaybackFallbackRequest,
VodDetailsComponent,
} from '@iptvnator/ui/playback';
import {
ExternalPlayerSession,
ResolvedPortalPlayback,
VodDetailsItem,
} from 'shared-interfaces';
import { StalkerSeriesViewComponent } from '../stalker-series-view/stalker-series-view.component';
@Component({
@@ -27,19 +32,33 @@ import { StalkerSeriesViewComponent } from '../stalker-series-view/stalker-serie
],
})
export class StalkerInlineDetailComponent {
readonly externalPlayback = inject(PORTAL_EXTERNAL_PLAYBACK);
readonly categoryId = input<'vod' | 'series' | null>(null);
readonly seriesItem = input<StalkerSelectedVodItem | null>(null);
readonly isSeries = input<boolean>(false);
readonly vodDetailsItem = input<VodDetailsItem | null>(null);
readonly isFavorite = input<boolean>(false);
readonly playbackPosition = input<number | null>(null);
readonly inlinePlayback = input<ResolvedPortalPlayback | null>(null);
readonly externalPlayback = input<ExternalPlayerSession | null>(null);
readonly backClicked = output<void>();
readonly playClicked = output<VodDetailsItem>();
readonly resumeClicked = output<{
item: VodDetailsItem;
positionSeconds: number;
}>();
readonly favoriteToggled = output<{
item: VodDetailsItem;
isFavorite: boolean;
}>();
readonly inlineTimeUpdated = output<{
currentTime: number;
duration: number;
}>();
readonly inlinePlaybackClosed = output<void>();
readonly streamUrlCopied = output<void>();
readonly inlineExternalFallbackRequested =
output<PlaybackFallbackRequest>();
onBackClicked() {
this.backClicked.emit();
@@ -49,7 +68,30 @@ export class StalkerInlineDetailComponent {
this.playClicked.emit(item);
}
onResumeClicked(event: {
item: VodDetailsItem;
positionSeconds: number;
}) {
this.resumeClicked.emit(event);
}
onFavoriteToggled(event: { item: VodDetailsItem; isFavorite: boolean }) {
this.favoriteToggled.emit(event);
}
onInlineTimeUpdated(event: { currentTime: number; duration: number }) {
this.inlineTimeUpdated.emit(event);
}
onInlinePlaybackClosed() {
this.inlinePlaybackClosed.emit();
}
onStreamUrlCopied() {
this.streamUrlCopied.emit();
}
onInlineExternalFallbackRequested(request: PlaybackFallbackRequest) {
this.inlineExternalFallbackRequested.emit(request);
}
}
@@ -15,7 +15,15 @@
[isSeries]="inlineDetail().isSeries"
[vodDetailsItem]="inlineDetail().vodDetailsItem"
[isFavorite]="isSelectedVodFavorite()"
[playbackPosition]="selectedVodPlaybackPosition()"
[inlinePlayback]="inlinePlayback()"
[externalPlayback]="externalPlayback.activeSession()"
(playClicked)="onVodPlay($event)"
(resumeClicked)="onVodResume($event)"
(inlineTimeUpdated)="handleInlineTimeUpdate($event)"
(inlinePlaybackClosed)="closeInlinePlayer()"
(streamUrlCopied)="showCopyNotification()"
(inlineExternalFallbackRequested)="handleExternalFallbackRequest($event)"
(favoriteToggled)="onVodFavoriteToggled($event)"
(backClicked)="onVodBack()"
/>
@@ -9,16 +9,20 @@ import {
} from '@angular/core';
import { FormsModule } from '@angular/forms';
import { MatCheckboxModule } from '@angular/material/checkbox';
import { MatSnackBar } from '@angular/material/snack-bar';
import { ActivatedRoute } from '@angular/router';
import { TranslatePipe } from '@ngx-translate/core';
import { TranslatePipe, TranslateService } from '@ngx-translate/core';
import { StalkerSessionService } from '@iptvnator/portal/stalker/data-access';
import { DataService, PlaylistsService } from 'services';
import {
PlaybackPositionData,
Playlist,
ResolvedPortalPlayback,
STALKER_REQUEST,
StalkerPortalActions,
VodDetailsItem,
} from 'shared-interfaces';
import type { PlaybackFallbackRequest } from '@iptvnator/ui/playback';
import { ContentCardComponent } from '@iptvnator/portal/shared/ui';
import { SearchLayoutComponent } from '@iptvnator/portal/shared/ui';
import { StalkerInlineDetailComponent } from '../stalker-inline-detail/stalker-inline-detail.component';
@@ -27,6 +31,9 @@ import { StalkerStore } from '@iptvnator/portal/stalker/data-access';
import { PlaylistContextFacade } from '@iptvnator/playlist/shared/util';
import {
isWorkspaceLayoutRoute,
PORTAL_EXTERNAL_PLAYBACK,
PORTAL_PLAYBACK_POSITIONS,
PORTAL_PLAYER,
queryParamSignal,
} from '@iptvnator/portal/shared/util';
import { createLogger } from '@iptvnator/portal/shared/util';
@@ -45,6 +52,7 @@ import {
isStalkerSeriesFlag,
toggleStalkerVodFavorite,
} from '@iptvnator/portal/stalker/data-access';
import { StalkerVodPlaybackController } from '../stalker-vod-playback-controller';
interface StalkerFilter {
key: string;
@@ -79,8 +87,13 @@ export class StalkerSearchComponent {
private readonly dataService = inject(DataService);
private readonly playlistContext = inject(PlaylistContextFacade);
private readonly playlistService = inject(PlaylistsService);
readonly externalPlayback = inject(PORTAL_EXTERNAL_PLAYBACK);
private readonly playbackPositions = inject(PORTAL_PLAYBACK_POSITIONS);
private readonly portalPlayer = inject(PORTAL_PLAYER);
private readonly stalkerStore = inject(StalkerStore);
private readonly stalkerSession = inject(StalkerSessionService);
private readonly snackBar = inject(MatSnackBar);
private readonly translateService = inject(TranslateService);
private readonly logger = createLogger('StalkerSearch');
readonly filters = signal({
@@ -119,6 +132,21 @@ export class StalkerSearchComponent {
itemDetails: StalkerSelectedVodItem | null = null;
vodDetailsItem: VodDetailsItem | null = null;
readonly inlinePlayback = signal<ResolvedPortalPlayback | null>(null);
readonly selectedVodPosition = signal<PlaybackPositionData | null>(null);
readonly selectedVodPlaybackPosition = computed<number | null>(
() => this.selectedVodPosition()?.positionSeconds ?? null
);
private readonly vodPlayback = new StalkerVodPlaybackController({
inlinePlayback: this.inlinePlayback,
selectedVodPosition: this.selectedVodPosition,
playbackPositions: this.playbackPositions,
portalPlayer: this.portalPlayer,
snackBar: this.snackBar,
translateService: this.translateService,
logger: this.logger,
playbackErrorLogMessage: 'Failed to start search VOD playback',
});
readonly portalFavorites = createPortalFavoritesResource(
this.playlistService,
@@ -211,10 +239,6 @@ export class StalkerSearchComponent {
return this.searchResultsResource.value()?.length ?? 0;
}
createLinkToPlayVodItv(cmd?: string, title?: string, thumbnail?: string) {
this.stalkerStore.createLinkToPlayVod(cmd, title, thumbnail);
}
updateSearchTerm(term: string) {
this.searchTerm.set(term);
}
@@ -234,6 +258,7 @@ export class StalkerSearchComponent {
}
selectItem(item: StalkerVodSource) {
this.closeInlinePlayer();
const hasEmbeddedSeries = item.series?.length > 0;
const needsSeriesFetch =
this.selectedFilterType() === 'vod' &&
@@ -255,10 +280,15 @@ export class StalkerSearchComponent {
this.itemDetails = detailViewState.itemDetails;
this.vodDetailsItem = detailViewState.vodDetailsItem;
this.syncSelectedVodFavorite();
void this.loadSelectedVodPosition(
this.currentPlaylist()?._id ?? '',
Number(detailViewState.itemDetails?.id)
);
} else {
const cleared = clearStalkerDetailViewState();
this.vodDetailsItem = cleared.vodDetailsItem;
this.isSelectedVodFavorite.set(false);
this.selectedVodPosition.set(null);
}
break;
case 'series':
@@ -271,7 +301,7 @@ export class StalkerSearchComponent {
onVodPlay(item: VodDetailsItem): void {
if (item.type === 'stalker') {
this.createLinkToPlayVodItv(
void this.startStalkerVodPlayback(
item.cmd,
item.data.info?.name,
item.data.info?.movie_image
@@ -279,6 +309,20 @@ export class StalkerSearchComponent {
}
}
onVodResume(event: {
item: VodDetailsItem;
positionSeconds: number;
}): void {
if (event.item.type === 'stalker') {
void this.startStalkerVodPlayback(
event.item.cmd,
event.item.data.info?.name,
event.item.data.info?.movie_image,
event.positionSeconds
);
}
}
onVodFavoriteToggled(event: {
item: VodDetailsItem;
isFavorite: boolean;
@@ -299,6 +343,27 @@ export class StalkerSearchComponent {
this.itemDetails = cleared.itemDetails;
this.vodDetailsItem = cleared.vodDetailsItem;
this.isSelectedVodFavorite.set(false);
this.selectedVodPosition.set(null);
this.closeInlinePlayer();
}
handleInlineTimeUpdate(event: {
currentTime: number;
duration: number;
}): void {
this.vodPlayback.handleInlineTimeUpdate(event);
}
closeInlinePlayer(): void {
this.vodPlayback.closeInlinePlayer();
}
showCopyNotification(): void {
this.vodPlayback.showCopyNotification();
}
handleExternalFallbackRequest(request: PlaybackFallbackRequest): void {
this.vodPlayback.handleExternalFallbackRequest(request);
}
removeFromFavorites(favoriteId: string, onDone?: () => void) {
@@ -361,4 +426,31 @@ export class StalkerSearchComponent {
return relativePath;
}
}
private async startStalkerVodPlayback(
cmd?: string,
title?: string,
thumbnail?: string,
startTime?: number
): Promise<void> {
await this.vodPlayback.startVodPlayback(() =>
startTime === undefined
? this.stalkerStore.resolveVodPlayback(cmd, title, thumbnail)
: this.stalkerStore.resolveVodPlayback(
cmd,
title,
thumbnail,
undefined,
undefined,
startTime
)
);
}
private async loadSelectedVodPosition(
playlistId: string,
vodId: number
): Promise<void> {
await this.vodPlayback.loadSelectedVodPosition(playlistId, vodId);
}
}
@@ -0,0 +1,118 @@
import { signal } from '@angular/core';
import type { MatSnackBar } from '@angular/material/snack-bar';
import type { TranslateService } from '@ngx-translate/core';
import type {
Logger,
PortalPlaybackPositions,
PortalPlayer,
} from '@iptvnator/portal/shared/util';
import type { PlaybackPositionData } from 'shared-interfaces';
import { StalkerVodPlaybackController } from './stalker-vod-playback-controller';
interface Deferred<T> {
promise: Promise<T>;
resolve: (value: T) => void;
}
function createDeferred<T>(): Deferred<T> {
let resolve!: (value: T) => void;
const promise = new Promise<T>((resolver) => {
resolve = resolver;
});
return {
promise,
resolve,
};
}
function createPosition(
contentXtreamId: number,
positionSeconds: number
): PlaybackPositionData {
return {
contentXtreamId,
contentType: 'vod',
positionSeconds,
durationSeconds: 100,
};
}
function createController() {
const inlinePlayback = signal(null);
const selectedVodPosition = signal<PlaybackPositionData | null>(null);
const playbackPositions = {
savePlaybackPosition: jest.fn(),
getPlaybackPosition: jest.fn(),
getSeriesPlaybackPositions: jest.fn(),
getAllPlaybackPositions: jest.fn(),
clearPlaybackPosition: jest.fn(),
} as unknown as jest.Mocked<PortalPlaybackPositions>;
const portalPlayer = {
isEmbeddedPlayer: jest.fn(() => true),
openPlayer: jest.fn(),
openResolvedPlayback: jest.fn(),
openExternalPlayback: jest.fn(),
} as unknown as jest.Mocked<PortalPlayer>;
const snackBar = {
open: jest.fn(),
} as unknown as MatSnackBar;
const translateService = {
instant: jest.fn((key: string) => key),
} as unknown as TranslateService;
const logger = {
debug: jest.fn(),
info: jest.fn(),
warn: jest.fn(),
error: jest.fn(),
} as unknown as Logger;
const controller = new StalkerVodPlaybackController({
inlinePlayback,
selectedVodPosition,
playbackPositions,
portalPlayer,
snackBar,
translateService,
logger,
playbackErrorLogMessage: 'Playback failed',
});
return {
controller,
playbackPositions,
selectedVodPosition,
};
}
describe('StalkerVodPlaybackController', () => {
it('ignores stale VOD position loads that resolve after a newer selection', async () => {
const { controller, playbackPositions, selectedVodPosition } =
createController();
const olderLoad = createDeferred<PlaybackPositionData | null>();
const newerLoad = createDeferred<PlaybackPositionData | null>();
playbackPositions.getPlaybackPosition.mockImplementation(
(_playlistId, vodId) =>
vodId === 101 ? olderLoad.promise : newerLoad.promise
);
const olderLoadPromise = controller.loadSelectedVodPosition(
'playlist-1',
101
);
const newerLoadPromise = controller.loadSelectedVodPosition(
'playlist-1',
202
);
newerLoad.resolve(createPosition(202, 20));
await newerLoadPromise;
expect(selectedVodPosition()?.contentXtreamId).toBe(202);
olderLoad.resolve(createPosition(101, 10));
await olderLoadPromise;
expect(selectedVodPosition()?.contentXtreamId).toBe(202);
expect(selectedVodPosition()?.positionSeconds).toBe(20);
});
});
@@ -0,0 +1,138 @@
import type { WritableSignal } from '@angular/core';
import type { MatSnackBar } from '@angular/material/snack-bar';
import type { TranslateService } from '@ngx-translate/core';
import type {
Logger,
PortalPlaybackPositions,
PortalPlayer,
} from '@iptvnator/portal/shared/util';
import type { PlaybackFallbackRequest } from '@iptvnator/ui/playback';
import {
PlaybackPositionData,
ResolvedPortalPlayback,
} from 'shared-interfaces';
interface StalkerVodPlaybackControllerConfig {
inlinePlayback: WritableSignal<ResolvedPortalPlayback | null>;
selectedVodPosition: WritableSignal<PlaybackPositionData | null>;
playbackPositions: PortalPlaybackPositions;
portalPlayer: PortalPlayer;
snackBar: MatSnackBar;
translateService: TranslateService;
logger: Logger;
playbackErrorLogMessage: string;
}
export class StalkerVodPlaybackController {
private lastInlineSaveTime = 0;
private loadSelectedVodPositionRequestId = 0;
constructor(private readonly config: StalkerVodPlaybackControllerConfig) {}
async startVodPlayback(
resolvePlayback: () => Promise<ResolvedPortalPlayback>
): Promise<void> {
try {
const playback = await resolvePlayback();
this.lastInlineSaveTime = 0;
if (this.config.portalPlayer.isEmbeddedPlayer()) {
this.config.inlinePlayback.set(playback);
return;
}
this.closeInlinePlayer();
void this.config.portalPlayer.openResolvedPlayback(playback, true);
} catch (error) {
this.config.logger.error(
this.config.playbackErrorLogMessage,
error
);
const errorMessage =
error instanceof Error && error.message === 'nothing_to_play'
? this.config.translateService.instant(
'PORTALS.CONTENT_NOT_AVAILABLE'
)
: this.config.translateService.instant(
'PORTALS.PLAYBACK_ERROR'
);
this.config.snackBar.open(errorMessage, null, {
duration: 3000,
});
}
}
async loadSelectedVodPosition(
playlistId: string,
vodId: number
): Promise<void> {
const requestId = ++this.loadSelectedVodPositionRequestId;
if (!playlistId || !Number.isFinite(vodId)) {
this.config.selectedVodPosition.set(null);
return;
}
const position =
await this.config.playbackPositions.getPlaybackPosition(
playlistId,
vodId,
'vod'
);
if (requestId !== this.loadSelectedVodPositionRequestId) {
return;
}
this.config.selectedVodPosition.set(position ?? null);
}
handleInlineTimeUpdate(event: {
currentTime: number;
duration: number;
}): void {
const playback = this.config.inlinePlayback();
if (!playback?.contentInfo) {
return;
}
const now = Date.now();
if (now - this.lastInlineSaveTime <= 15000) {
return;
}
this.lastInlineSaveTime = now;
const position: PlaybackPositionData = {
...playback.contentInfo,
positionSeconds: Math.floor(event.currentTime),
durationSeconds: Math.floor(event.duration),
};
void this.config.playbackPositions.savePlaybackPosition(
playback.contentInfo.playlistId,
position
);
this.config.selectedVodPosition.set(position);
}
closeInlinePlayer(): void {
this.config.inlinePlayback.set(null);
this.lastInlineSaveTime = 0;
}
showCopyNotification(): void {
this.config.snackBar.open(
this.config.translateService.instant('PORTALS.STREAM_URL_COPIED'),
null,
{
duration: 2000,
}
);
}
handleExternalFallbackRequest(request: PlaybackFallbackRequest): void {
void this.config.portalPlayer.openExternalPlayback(
request.playback,
request.player
);
}
}
-1
View File
@@ -3,7 +3,6 @@ export * from './lib/category-management-dialog/category-management-dialog.compo
export * from './lib/global-search-results/global-search-results.component';
export * from './lib/live-stream-layout/live-stream-layout.component';
export * from './lib/loading-overlay.component';
export * from './lib/player-dialog/player-dialog.component';
export * from './lib/portal-channels-list/portal-channels-list.component';
export * from './lib/recently-added/recently-added.component';
export * from './lib/search-results/search-results.component';
@@ -1,25 +0,0 @@
<h2 mat-dialog-title>{{ title }}</h2>
<mat-dialog-content class="content">
<app-web-player-view
[streamUrl]="streamUrl"
[title]="title"
[playback]="data.playback ?? null"
[startTime]="data.startTime || 0"
(timeUpdate)="handleTimeUpdate($event)"
(externalFallbackRequested)="handleExternalFallbackRequest($event)"
/>
</mat-dialog-content>
<mat-dialog-actions class="align-actions">
<button
mat-button
[cdkCopyToClipboard]="streamUrl"
(click)="showCopyNotification()"
[matTooltip]="streamUrl"
>
<mat-icon>content_copy</mat-icon>
{{ 'PORTALS.COPY_STREAM_URL' | translate }}
</button>
<button mat-button mat-dialog-close color="accent">
{{ 'CLOSE' | translate }}
</button>
</mat-dialog-actions>
@@ -1,16 +0,0 @@
@use '@angular/material' as mat;
.content {
overflow: hidden;
padding: 10px !important;
}
.align-actions {
justify-content: space-between;
}
mat-dialog-content {
.video-js {
height: 500px !important;
}
}
@@ -1,92 +0,0 @@
import { ClipboardModule } from '@angular/cdk/clipboard';
import { Component, inject, ViewEncapsulation } from '@angular/core';
import { MatButton } from '@angular/material/button';
import { MAT_DIALOG_DATA, MatDialogModule } from '@angular/material/dialog';
import { MatIcon } from '@angular/material/icon';
import { MatSnackBar } from '@angular/material/snack-bar';
import { MatTooltip } from '@angular/material/tooltip';
import { TranslatePipe, TranslateService } from '@ngx-translate/core';
import {
PORTAL_PLAYBACK_POSITIONS,
PORTAL_PLAYER,
} from '@iptvnator/portal/shared/util';
import { PlayerContentInfo, ResolvedPortalPlayback } from 'shared-interfaces';
import {
type PlaybackFallbackRequest,
WebPlayerViewComponent,
} from 'shared-portals';
export interface PlayerDialogData {
streamUrl: string;
title: string;
contentInfo?: PlayerContentInfo;
startTime?: number;
playback?: ResolvedPortalPlayback;
}
@Component({
templateUrl: './player-dialog.component.html',
imports: [
ClipboardModule,
MatButton,
MatDialogModule,
MatIcon,
MatTooltip,
TranslatePipe,
WebPlayerViewComponent,
],
styleUrl: './player-dialog.component.scss',
encapsulation: ViewEncapsulation.None,
})
export class PlayerDialogComponent {
readonly data = inject<PlayerDialogData>(MAT_DIALOG_DATA);
private snackBar = inject(MatSnackBar);
private translateService = inject(TranslateService);
private readonly playbackPositions = inject(PORTAL_PLAYBACK_POSITIONS);
private readonly portalPlayer = inject(PORTAL_PLAYER);
readonly title: string;
readonly streamUrl: string;
private lastSaveTime = 0;
constructor() {
this.streamUrl = this.data.playback?.streamUrl ?? this.data.streamUrl;
this.title = this.data.playback?.title ?? this.data.title;
}
handleTimeUpdate(event: { currentTime: number; duration: number }) {
if (!this.data.contentInfo) return;
const now = Date.now();
// Save every 15 seconds
if (now - this.lastSaveTime > 15000) {
this.lastSaveTime = now;
void this.playbackPositions.savePlaybackPosition(
this.data.contentInfo.playlistId,
{
...this.data.contentInfo,
positionSeconds: Math.floor(event.currentTime),
durationSeconds: Math.floor(event.duration),
}
);
}
}
showCopyNotification() {
this.snackBar.open(
this.translateService.instant('PORTALS.STREAM_URL_COPIED'),
null,
{
duration: 2000,
}
);
}
handleExternalFallbackRequest(request: PlaybackFallbackRequest): void {
void this.portalPlayer.openExternalPlayback(
request.playback,
request.player
);
}
}