From ce0e61a92dbae4bfc43387faf2fd48f9b6d96c48 Mon Sep 17 00:00:00 2001 From: 4gray Date: Mon, 11 May 2026 20:44:01 +0200 Subject: [PATCH 1/2] docs: publish embedded MPV macOS blog post --- .../blog/embedded-mpv-macos-experiment.mdx | 209 ++++++++++++++++++ 1 file changed, 209 insertions(+) create mode 100644 apps/website/src/content/blog/embedded-mpv-macos-experiment.mdx diff --git a/apps/website/src/content/blog/embedded-mpv-macos-experiment.mdx b/apps/website/src/content/blog/embedded-mpv-macos-experiment.mdx new file mode 100644 index 000000000..86dec1797 --- /dev/null +++ b/apps/website/src/content/blog/embedded-mpv-macos-experiment.mdx @@ -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. + + + + + 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. + + +## 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 + + + +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. + + + 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. + + +## 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 + + + +## 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 + + + +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. From 6ae1251ad1fe0352ef963d45b0c1995ae813fc8b Mon Sep 17 00:00:00 2001 From: 4gray <4gray@users.noreply.github.com> Date: Tue, 12 May 2026 19:26:40 +0200 Subject: [PATCH 2/2] fix(playback): use inline player for collection VOD (#928) * feat(portal): add series quick start CTA Entire-Checkpoint: f957cd9849e0 * fix(playback): use inline player for collection vod * fix(playback): address inline collection review feedback * fix(playback): ignore stale vod position loads --- .../electron-backend-e2e/src/favorites.e2e.ts | 45 ++ apps/electron-backend-e2e/src/recent.e2e.ts | 13 + apps/electron-backend-e2e/src/search.e2e.ts | 26 ++ apps/web/jest.config.ts | 2 - .../src/app/services/player.service.spec.ts | 19 +- apps/web/src/app/services/player.service.ts | 18 +- apps/web/src/assets/i18n/ar.json | 4 + apps/web/src/assets/i18n/ary.json | 4 + apps/web/src/assets/i18n/by.json | 4 + apps/web/src/assets/i18n/de.json | 4 + apps/web/src/assets/i18n/el.json | 4 + apps/web/src/assets/i18n/en.json | 4 + apps/web/src/assets/i18n/es.json | 4 + apps/web/src/assets/i18n/fr.json | 4 + apps/web/src/assets/i18n/it.json | 4 + apps/web/src/assets/i18n/ja.json | 4 + apps/web/src/assets/i18n/ko.json | 4 + apps/web/src/assets/i18n/nl.json | 4 + apps/web/src/assets/i18n/pl.json | 4 + apps/web/src/assets/i18n/pt.json | 4 + apps/web/src/assets/i18n/ru.json | 4 + apps/web/src/assets/i18n/tr.json | 4 + apps/web/src/assets/i18n/zh.json | 4 + apps/web/src/assets/i18n/zhtw.json | 4 + .../web/src/test-stubs/xtream-feature.stub.ts | 1 - docs/architecture/embedded-inline-playback.md | 73 ++- docs/architecture/stalker-portal.md | 6 + libs/portal/shared/util/src/index.ts | 1 + .../util/src/lib/series-quick-start.spec.ts | 261 +++++++++++ .../shared/util/src/lib/series-quick-start.ts | 197 ++++++++ .../with-stalker-player.feature.spec.ts | 43 ++ .../stalker-collection-persistence.utils.ts | 2 - ...talker-collection-detail.component.spec.ts | 174 ++++++- .../stalker-collection-detail.component.ts | 134 +++++- .../stalker-favorites-button.component.scss | 15 +- .../stalker-inline-detail.component.html | 11 +- .../stalker-inline-detail.component.ts | 52 ++- .../stalker-search.component.html | 8 + .../stalker-search.component.ts | 104 ++++- .../stalker-series-view.component.html | 24 + .../stalker-series-view.component.spec.ts | 427 ++++++++++++++++++ .../stalker-series-view.component.ts | 176 +++++++- .../stalker-vod-playback-controller.spec.ts | 118 +++++ .../lib/stalker-vod-playback-controller.ts | 138 ++++++ .../feature/src/lib/styles/detail-view.scss | 53 ++- libs/portal/xtream/feature/src/index.ts | 1 - .../player-dialog.component.html | 25 - .../player-dialog.component.scss | 16 - .../player-dialog/player-dialog.component.ts | 92 ---- .../serial-details.component.html | 22 + .../serial-details.component.spec.ts | 104 ++++- .../serial-details.component.ts | 22 + .../content-hero/content-hero.component.scss | 92 +++- .../src/lib/styles/detail-view.scss | 53 ++- .../playback/src/lib/styles/detail-view.scss | 53 ++- 55 files changed, 2421 insertions(+), 272 deletions(-) delete mode 100644 apps/web/src/test-stubs/xtream-feature.stub.ts create mode 100644 libs/portal/shared/util/src/lib/series-quick-start.spec.ts create mode 100644 libs/portal/shared/util/src/lib/series-quick-start.ts create mode 100644 libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-view.component.spec.ts create mode 100644 libs/portal/stalker/feature/src/lib/stalker-vod-playback-controller.spec.ts create mode 100644 libs/portal/stalker/feature/src/lib/stalker-vod-playback-controller.ts delete mode 100644 libs/portal/xtream/feature/src/lib/player-dialog/player-dialog.component.html delete mode 100644 libs/portal/xtream/feature/src/lib/player-dialog/player-dialog.component.scss delete mode 100644 libs/portal/xtream/feature/src/lib/player-dialog/player-dialog.component.ts diff --git a/apps/electron-backend-e2e/src/favorites.e2e.ts b/apps/electron-backend-e2e/src/favorites.e2e.ts index 74a887a61..ddd37a443 100644 --- a/apps/electron-backend-e2e/src/favorites.e2e.ts +++ b/apps/electron-backend-e2e/src/favorites.e2e.ts @@ -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 { + 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 { + const playButton = page.locator('button.play-btn').first(); + + await expect(playButton).toBeVisible({ timeout: 20000 }); + await playButton.click(); +} + +async function playFirstSeriesEpisode(page: Page): Promise { + 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 diff --git a/apps/electron-backend-e2e/src/recent.e2e.ts b/apps/electron-backend-e2e/src/recent.e2e.ts index c58ca5894..37a1329d0 100644 --- a/apps/electron-backend-e2e/src/recent.e2e.ts +++ b/apps/electron-backend-e2e/src/recent.e2e.ts @@ -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 { + 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 { const playButton = page.locator('button.play-btn').first(); diff --git a/apps/electron-backend-e2e/src/search.e2e.ts b/apps/electron-backend-e2e/src/search.e2e.ts index 14ebc2914..08a6b2ee4 100644 --- a/apps/electron-backend-e2e/src/search.e2e.ts +++ b/apps/electron-backend-e2e/src/search.e2e.ts @@ -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 { await backButton.click(); } +async function playCurrentDetail(page: Page): Promise { + const playButton = page.locator('button.play-btn').first(); + + await expect(playButton).toBeVisible({ timeout: 20000 }); + await playButton.click(); +} + +async function expectInlinePlayerWithoutDialog(page: Page): Promise { + 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 { const count = await categories.count(); let fallback = categories.first(); diff --git a/apps/web/jest.config.ts b/apps/web/jest.config.ts index af4800355..5cfd40cac 100644 --- a/apps/web/jest.config.ts +++ b/apps/web/jest.config.ts @@ -24,8 +24,6 @@ export default { snapshotSerializers: angularEsmPreset.snapshotSerializers, moduleNameMapper: { ...(angularEsmPreset.moduleNameMapper ?? {}), - '^@iptvnator/portal/xtream/feature$': - '/src/test-stubs/xtream-feature.stub.ts', tslib: 'tslib/tslib.es6.js', '^iptv-playlist-parser$': '/src/test-stubs/iptv-playlist-parser.mjs', diff --git a/apps/web/src/app/services/player.service.spec.ts b/apps/web/src/app/services/player.service.spec.ts index d731743a6..8fa7de9db 100644 --- a/apps/web/src/app/services/player.service.spec.ts +++ b/apps/web/src/app/services/player.service.spec.ts @@ -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(); }); diff --git a/apps/web/src/app/services/player.service.ts b/apps/web/src/app/services/player.service.ts index 71734fb0b..25cd161db 100644 --- a/apps/web/src/app/services/player.service.ts +++ b/apps/web/src/app/services/player.service.ts @@ -67,7 +67,6 @@ export class PlayerService { hideExternalInfoDialog = true ): Promise { 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( diff --git a/apps/web/src/assets/i18n/ar.json b/apps/web/src/assets/i18n/ar.json index 296463bcc..bc3e4e2a3 100644 --- a/apps/web/src/assets/i18n/ar.json +++ b/apps/web/src/assets/i18n/ar.json @@ -575,6 +575,10 @@ "RESUME": "استئناف", "RESTART": "البدء من جديد", "CONTINUE_WATCHING": "متابعة المشاهدة", + "PLAY_FIRST_EPISODE": "تشغيل الحلقة الأولى", + "RESUME_EPISODE": "استئناف الحلقة", + "PLAY_NEXT_EPISODE": "تشغيل الحلقة التالية", + "SERIES_WATCHED": "تمت مشاهدة المسلسل", "WATCHED": "تمت المشاهدة", "IN_PROGRESS": "قيد التقدم", "MARK_WATCHED": "وضع علامة كمشاهد", diff --git a/apps/web/src/assets/i18n/ary.json b/apps/web/src/assets/i18n/ary.json index 15e69f71c..d610e18e8 100644 --- a/apps/web/src/assets/i18n/ary.json +++ b/apps/web/src/assets/i18n/ary.json @@ -575,6 +575,10 @@ "RESUME": "كمل", "RESTART": "بدا من الأول", "CONTINUE_WATCHING": "كمل المشاهدة", + "PLAY_FIRST_EPISODE": "شغل الحلقة الأولى", + "RESUME_EPISODE": "كمل الحلقة", + "PLAY_NEXT_EPISODE": "شغل الحلقة الجاية", + "SERIES_WATCHED": "السلسلة متشافة", "WATCHED": "متشاف", "IN_PROGRESS": "جاري", "MARK_WATCHED": "علم كمتشاف", diff --git a/apps/web/src/assets/i18n/by.json b/apps/web/src/assets/i18n/by.json index 5b4bd1a26..1326d4742 100644 --- a/apps/web/src/assets/i18n/by.json +++ b/apps/web/src/assets/i18n/by.json @@ -575,6 +575,10 @@ "RESUME": "Працягнуць", "RESTART": "Спачатку", "CONTINUE_WATCHING": "Працягнуць прагляд", + "PLAY_FIRST_EPISODE": "Прайграць першы эпізод", + "RESUME_EPISODE": "Працягнуць эпізод", + "PLAY_NEXT_EPISODE": "Прайграць наступны эпізод", + "SERIES_WATCHED": "Серыял прагледжаны", "WATCHED": "Прагледжана", "IN_PROGRESS": "У працэсе", "MARK_WATCHED": "Адзначыць як прагледжанае", diff --git a/apps/web/src/assets/i18n/de.json b/apps/web/src/assets/i18n/de.json index c974a6b16..12134643c 100644 --- a/apps/web/src/assets/i18n/de.json +++ b/apps/web/src/assets/i18n/de.json @@ -575,6 +575,10 @@ "RESUME": "Fortsetzen", "RESTART": "Neu starten", "CONTINUE_WATCHING": "Weiter schauen", + "PLAY_FIRST_EPISODE": "Erste Folge abspielen", + "RESUME_EPISODE": "Folge fortsetzen", + "PLAY_NEXT_EPISODE": "Nächste Folge abspielen", + "SERIES_WATCHED": "Serie angesehen", "WATCHED": "Angesehen", "IN_PROGRESS": "In Wiedergabe", "MARK_WATCHED": "Als angesehen markieren", diff --git a/apps/web/src/assets/i18n/el.json b/apps/web/src/assets/i18n/el.json index 3018d0f98..e98dbcb92 100644 --- a/apps/web/src/assets/i18n/el.json +++ b/apps/web/src/assets/i18n/el.json @@ -575,6 +575,10 @@ "RESUME": "Συνέχιση", "RESTART": "Από την αρχή", "CONTINUE_WATCHING": "Συνέχιση παρακολούθησης", + "PLAY_FIRST_EPISODE": "Αναπαραγωγή πρώτου επεισοδίου", + "RESUME_EPISODE": "Συνέχιση επεισοδίου", + "PLAY_NEXT_EPISODE": "Αναπαραγωγή επόμενου επεισοδίου", + "SERIES_WATCHED": "Η σειρά προβλήθηκε", "WATCHED": "Έχει προβληθεί", "IN_PROGRESS": "Σε εξέλιξη", "MARK_WATCHED": "Σήμανση ως προβληθέν", diff --git a/apps/web/src/assets/i18n/en.json b/apps/web/src/assets/i18n/en.json index d280c81e3..9f6ace9f0 100644 --- a/apps/web/src/assets/i18n/en.json +++ b/apps/web/src/assets/i18n/en.json @@ -581,6 +581,10 @@ "RESUME": "Resume", "RESTART": "Start Over", "CONTINUE_WATCHING": "Continue Watching", + "PLAY_FIRST_EPISODE": "Play first episode", + "RESUME_EPISODE": "Resume episode", + "PLAY_NEXT_EPISODE": "Play next episode", + "SERIES_WATCHED": "Series watched", "WATCHED": "Watched", "IN_PROGRESS": "In Progress", "MARK_WATCHED": "Mark as Watched", diff --git a/apps/web/src/assets/i18n/es.json b/apps/web/src/assets/i18n/es.json index 71251fb1d..76b8a66ea 100644 --- a/apps/web/src/assets/i18n/es.json +++ b/apps/web/src/assets/i18n/es.json @@ -575,6 +575,10 @@ "RESUME": "Continuar", "RESTART": "Reiniciar", "CONTINUE_WATCHING": "Continuar viendo", + "PLAY_FIRST_EPISODE": "Reproducir primer episodio", + "RESUME_EPISODE": "Continuar episodio", + "PLAY_NEXT_EPISODE": "Reproducir siguiente episodio", + "SERIES_WATCHED": "Serie vista", "WATCHED": "Visto", "IN_PROGRESS": "En progreso", "MARK_WATCHED": "Marcar como visto", diff --git a/apps/web/src/assets/i18n/fr.json b/apps/web/src/assets/i18n/fr.json index 4e3072e3d..db4fc35a6 100644 --- a/apps/web/src/assets/i18n/fr.json +++ b/apps/web/src/assets/i18n/fr.json @@ -575,6 +575,10 @@ "RESUME": "Reprendre", "RESTART": "Recommencer", "CONTINUE_WATCHING": "Reprendre la lecture", + "PLAY_FIRST_EPISODE": "Lire le premier épisode", + "RESUME_EPISODE": "Reprendre l'épisode", + "PLAY_NEXT_EPISODE": "Lire l'épisode suivant", + "SERIES_WATCHED": "Série vue", "WATCHED": "Vu", "IN_PROGRESS": "En cours", "MARK_WATCHED": "Marquer comme vu", diff --git a/apps/web/src/assets/i18n/it.json b/apps/web/src/assets/i18n/it.json index 5030c8afe..91b11a273 100644 --- a/apps/web/src/assets/i18n/it.json +++ b/apps/web/src/assets/i18n/it.json @@ -575,6 +575,10 @@ "RESUME": "Riprendi", "RESTART": "Ricomincia", "CONTINUE_WATCHING": "Continua a guardare", + "PLAY_FIRST_EPISODE": "Riproduci primo episodio", + "RESUME_EPISODE": "Riprendi episodio", + "PLAY_NEXT_EPISODE": "Riproduci episodio successivo", + "SERIES_WATCHED": "Serie vista", "WATCHED": "Visto", "IN_PROGRESS": "In corso", "MARK_WATCHED": "Segna come visto", diff --git a/apps/web/src/assets/i18n/ja.json b/apps/web/src/assets/i18n/ja.json index ab979fc7f..b22801e33 100644 --- a/apps/web/src/assets/i18n/ja.json +++ b/apps/web/src/assets/i18n/ja.json @@ -575,6 +575,10 @@ "RESUME": "続きから再生", "RESTART": "最初から再生", "CONTINUE_WATCHING": "視聴を再開", + "PLAY_FIRST_EPISODE": "第1話を再生", + "RESUME_EPISODE": "エピソードを再開", + "PLAY_NEXT_EPISODE": "次のエピソードを再生", + "SERIES_WATCHED": "シリーズ視聴済み", "WATCHED": "視聴済み", "IN_PROGRESS": "視聴中", "MARK_WATCHED": "視聴済みにする", diff --git a/apps/web/src/assets/i18n/ko.json b/apps/web/src/assets/i18n/ko.json index 7184acdff..dcf07fe03 100644 --- a/apps/web/src/assets/i18n/ko.json +++ b/apps/web/src/assets/i18n/ko.json @@ -575,6 +575,10 @@ "RESUME": "이어 보기", "RESTART": "처음부터 보기", "CONTINUE_WATCHING": "이어서 시청", + "PLAY_FIRST_EPISODE": "첫 에피소드 재생", + "RESUME_EPISODE": "에피소드 이어 보기", + "PLAY_NEXT_EPISODE": "다음 에피소드 재생", + "SERIES_WATCHED": "시리즈 시청 완료", "WATCHED": "시청 완료", "IN_PROGRESS": "시청 중", "MARK_WATCHED": "시청한 것으로 표시", diff --git a/apps/web/src/assets/i18n/nl.json b/apps/web/src/assets/i18n/nl.json index 5037188d2..6e96e2741 100644 --- a/apps/web/src/assets/i18n/nl.json +++ b/apps/web/src/assets/i18n/nl.json @@ -575,6 +575,10 @@ "RESUME": "Hervatten", "RESTART": "Opnieuw beginnen", "CONTINUE_WATCHING": "Verder kijken", + "PLAY_FIRST_EPISODE": "Eerste aflevering afspelen", + "RESUME_EPISODE": "Aflevering hervatten", + "PLAY_NEXT_EPISODE": "Volgende aflevering afspelen", + "SERIES_WATCHED": "Serie bekeken", "WATCHED": "Bekeken", "IN_PROGRESS": "Bezig", "MARK_WATCHED": "Markeren als bekeken", diff --git a/apps/web/src/assets/i18n/pl.json b/apps/web/src/assets/i18n/pl.json index 03cb1f9d0..a16a0b5dd 100644 --- a/apps/web/src/assets/i18n/pl.json +++ b/apps/web/src/assets/i18n/pl.json @@ -575,6 +575,10 @@ "RESUME": "Wznów", "RESTART": "Zacznij od nowa", "CONTINUE_WATCHING": "Kontynuuj oglądanie", + "PLAY_FIRST_EPISODE": "Odtwórz pierwszy odcinek", + "RESUME_EPISODE": "Wznów odcinek", + "PLAY_NEXT_EPISODE": "Odtwórz następny odcinek", + "SERIES_WATCHED": "Serial obejrzany", "WATCHED": "Obejrzane", "IN_PROGRESS": "W trakcie", "MARK_WATCHED": "Oznacz jako obejrzane", diff --git a/apps/web/src/assets/i18n/pt.json b/apps/web/src/assets/i18n/pt.json index 4f4f38509..6e9f9c098 100644 --- a/apps/web/src/assets/i18n/pt.json +++ b/apps/web/src/assets/i18n/pt.json @@ -575,6 +575,10 @@ "RESUME": "Continuar", "RESTART": "Recomeçar", "CONTINUE_WATCHING": "Continuar assistindo", + "PLAY_FIRST_EPISODE": "Reproduzir primeiro episódio", + "RESUME_EPISODE": "Continuar episódio", + "PLAY_NEXT_EPISODE": "Reproduzir próximo episódio", + "SERIES_WATCHED": "Série assistida", "WATCHED": "Assistido", "IN_PROGRESS": "Em andamento", "MARK_WATCHED": "Marcar como assistido", diff --git a/apps/web/src/assets/i18n/ru.json b/apps/web/src/assets/i18n/ru.json index 9c2e41b81..0b6b0a169 100644 --- a/apps/web/src/assets/i18n/ru.json +++ b/apps/web/src/assets/i18n/ru.json @@ -579,6 +579,10 @@ "RESUME": "Продолжить", "RESTART": "С начала", "CONTINUE_WATCHING": "Продолжить просмотр", + "PLAY_FIRST_EPISODE": "Воспроизвести первую серию", + "RESUME_EPISODE": "Продолжить серию", + "PLAY_NEXT_EPISODE": "Следующая серия", + "SERIES_WATCHED": "Сериал просмотрен", "WATCHED": "Просмотрено", "IN_PROGRESS": "В процессе", "MARK_WATCHED": "Отметить как просмотренное", diff --git a/apps/web/src/assets/i18n/tr.json b/apps/web/src/assets/i18n/tr.json index 4d89ad146..ce0f0e818 100644 --- a/apps/web/src/assets/i18n/tr.json +++ b/apps/web/src/assets/i18n/tr.json @@ -575,6 +575,10 @@ "RESUME": "Devam Et", "RESTART": "Baştan Başla", "CONTINUE_WATCHING": "İzlemeye Devam Et", + "PLAY_FIRST_EPISODE": "İlk bölümü oynat", + "RESUME_EPISODE": "Bölüme devam et", + "PLAY_NEXT_EPISODE": "Sonraki bölümü oynat", + "SERIES_WATCHED": "Dizi izlendi", "WATCHED": "İzlendi", "IN_PROGRESS": "Devam Ediyor", "MARK_WATCHED": "İzlendi Olarak İşaretle", diff --git a/apps/web/src/assets/i18n/zh.json b/apps/web/src/assets/i18n/zh.json index 4defd7261..24e1e0643 100644 --- a/apps/web/src/assets/i18n/zh.json +++ b/apps/web/src/assets/i18n/zh.json @@ -575,6 +575,10 @@ "RESUME": "继续观看", "RESTART": "从头开始", "CONTINUE_WATCHING": "继续观看", + "PLAY_FIRST_EPISODE": "播放第一集", + "RESUME_EPISODE": "继续播放本集", + "PLAY_NEXT_EPISODE": "播放下一集", + "SERIES_WATCHED": "剧集已看完", "WATCHED": "已观看", "IN_PROGRESS": "观看中", "MARK_WATCHED": "标记为已观看", diff --git a/apps/web/src/assets/i18n/zhtw.json b/apps/web/src/assets/i18n/zhtw.json index 2942169a1..fb89c9b4d 100644 --- a/apps/web/src/assets/i18n/zhtw.json +++ b/apps/web/src/assets/i18n/zhtw.json @@ -575,6 +575,10 @@ "RESUME": "繼續", "RESTART": "從頭開始", "CONTINUE_WATCHING": "繼續觀看", + "PLAY_FIRST_EPISODE": "播放第一集", + "RESUME_EPISODE": "繼續播放本集", + "PLAY_NEXT_EPISODE": "播放下一集", + "SERIES_WATCHED": "劇集已看完", "WATCHED": "已觀看", "IN_PROGRESS": "觀看中", "MARK_WATCHED": "標記為已觀看", diff --git a/apps/web/src/test-stubs/xtream-feature.stub.ts b/apps/web/src/test-stubs/xtream-feature.stub.ts deleted file mode 100644 index 0dd1ce38a..000000000 --- a/apps/web/src/test-stubs/xtream-feature.stub.ts +++ /dev/null @@ -1 +0,0 @@ -export class PlayerDialogComponent {} diff --git a/docs/architecture/embedded-inline-playback.md b/docs/architecture/embedded-inline-playback.md index f75a3ed84..e80715b8e 100644 --- a/docs/architecture/embedded-inline-playback.md +++ b/docs/architecture/embedded-inline-playback.md @@ -9,23 +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. -- Material dialog playback remains only as a fallback for older non-detail callers. +- 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. +- 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 @@ -68,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: @@ -77,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: @@ -99,7 +107,30 @@ 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 + +Xtream and Stalker series detail views share the quick-start decision helper in +`/Users/4gray/Code/iptvnator/libs/portal/shared/util/src/lib/series-quick-start.ts`. +The helper flattens the loaded season/episode map, sorts seasons and episodes in +natural order, and returns the hero CTA state. + +Current contract: + +- the CTA shows the action label plus a compact episode target such as + `S01E02 · Episode title` +- if an episode is in progress, resume the latest updated in-progress episode + with its saved offset +- if no episode is in progress, play the first unwatched episode in season order +- if watched episodes end at a season boundary, play the first episode of the + next loaded season +- if every loaded episode is watched, render a disabled completed state + +The click path must continue through each detail host's normal episode playback +method so recent-item updates, inline/external player selection, resume offsets, +and playback-position saving keep the same behavior as manual episode clicks. ## Codec And Container Diagnostics @@ -175,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: @@ -198,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` diff --git a/docs/architecture/stalker-portal.md b/docs/architecture/stalker-portal.md index b45f909fc..4a75c2aac 100644 --- a/docs/architecture/stalker-portal.md +++ b/docs/architecture/stalker-portal.md @@ -141,6 +141,12 @@ Stalker has multiple real-world data shapes. The current implementation supports - Treated as series flow from VOD context. - Seasons are fetched lazily. - Episodes are fetched on season select. +- The series quick-start CTA can load the first unloaded VOD-series season + before playback, then starts its first episode. 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. +- 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. Core decision logic and normalization are centralized in: diff --git a/libs/portal/shared/util/src/index.ts b/libs/portal/shared/util/src/index.ts index 7b8455579..fa636d2e8 100644 --- a/libs/portal/shared/util/src/index.ts +++ b/libs/portal/shared/util/src/index.ts @@ -6,6 +6,7 @@ export * from './lib/portal-catalog-facade'; export * from './lib/portal-external-playback'; export * from './lib/portal-player'; export * from './lib/portal-playback-positions'; +export * from './lib/series-quick-start'; export * from './lib/portal-navigation-actions'; export * from './lib/portal-shell-actions'; export * from './lib/portal-collection-context'; diff --git a/libs/portal/shared/util/src/lib/series-quick-start.spec.ts b/libs/portal/shared/util/src/lib/series-quick-start.spec.ts new file mode 100644 index 000000000..4fa794a61 --- /dev/null +++ b/libs/portal/shared/util/src/lib/series-quick-start.spec.ts @@ -0,0 +1,261 @@ +import { PlaybackPositionData, XtreamSerieEpisode } from 'shared-interfaces'; +import { + SERIES_QUICK_START_ACTION_KIND, + getSeriesQuickStartAction, + getPositiveInteger, + padEpisodePart, +} from './series-quick-start'; + +function episode( + id: number, + season: number, + episodeNum: number, + title = `Episode ${episodeNum}` +): XtreamSerieEpisode { + return { + id: String(id), + episode_num: episodeNum, + title, + container_extension: 'mp4', + info: [], + custom_sid: '', + added: '', + season, + direct_source: '', + }; +} + +function position( + contentXtreamId: number, + overrides: Partial = {} +): PlaybackPositionData { + return { + contentXtreamId, + contentType: 'episode', + seriesXtreamId: 10, + positionSeconds: 0, + durationSeconds: 100, + ...overrides, + }; +} + +describe('getSeriesQuickStartAction', () => { + it('returns the first episode when there are no playback positions', () => { + const firstEpisode = episode(101, 1, 1); + + const action = getSeriesQuickStartAction({ + seasons: { + '1': [firstEpisode, episode(102, 1, 2)], + }, + playbackPositions: new Map(), + }); + + expect(action).toEqual({ + kind: SERIES_QUICK_START_ACTION_KIND.PlayFirst, + labelKey: 'XTREAM.PLAY_FIRST_EPISODE', + episodeLabel: 'S01E01 · Episode 1', + icon: 'play_arrow', + episode: firstEpisode, + position: null, + startTime: undefined, + disabled: false, + }); + }); + + it('resumes the latest in-progress episode by updatedAt', () => { + const olderEpisode = episode(101, 1, 1); + const latestEpisode = episode(102, 1, 2); + + const action = getSeriesQuickStartAction({ + seasons: { + '1': [olderEpisode, latestEpisode], + }, + playbackPositions: new Map([ + [ + 101, + position(101, { + positionSeconds: 20, + updatedAt: '2026-05-10T10:00:00.000Z', + }), + ], + [ + 102, + position(102, { + positionSeconds: 30, + updatedAt: '2026-05-10T11:00:00.000Z', + }), + ], + ]), + }); + + expect(action?.kind).toBe(SERIES_QUICK_START_ACTION_KIND.Resume); + expect(action?.labelKey).toBe('XTREAM.RESUME_EPISODE'); + expect(action?.episodeLabel).toBe('S01E02 · Episode 2'); + expect(action?.episode).toBe(latestEpisode); + expect(action?.startTime).toBe(30); + }); + + it('plays the next unwatched episode after watched episodes', () => { + const nextEpisode = episode(102, 1, 2); + + const action = getSeriesQuickStartAction({ + seasons: { + '1': [episode(101, 1, 1), nextEpisode], + }, + playbackPositions: new Map([ + [ + 101, + position(101, { + positionSeconds: 95, + durationSeconds: 100, + }), + ], + ]), + }); + + expect(action?.kind).toBe(SERIES_QUICK_START_ACTION_KIND.PlayNext); + expect(action?.labelKey).toBe('XTREAM.PLAY_NEXT_EPISODE'); + expect(action?.episodeLabel).toBe('S01E02 · Episode 2'); + expect(action?.episode).toBe(nextEpisode); + }); + + it('plays the first episode of the next season when the current season is watched', () => { + const nextSeasonEpisode = episode(201, 2, 1); + + const action = getSeriesQuickStartAction({ + seasons: { + '1': [episode(101, 1, 1)], + '2': [nextSeasonEpisode], + }, + playbackPositions: new Map([ + [ + 101, + position(101, { + positionSeconds: 100, + durationSeconds: 100, + }), + ], + ]), + }); + + expect(action?.kind).toBe(SERIES_QUICK_START_ACTION_KIND.PlayNext); + expect(action?.episodeLabel).toBe('S02E01 · Episode 1'); + expect(action?.episode).toBe(nextSeasonEpisode); + }); + + it('returns a disabled completed action when every episode is watched', () => { + const finalEpisode = episode(102, 1, 2); + + const action = getSeriesQuickStartAction({ + seasons: { + '1': [episode(101, 1, 1), finalEpisode], + }, + playbackPositions: new Map([ + [ + 101, + position(101, { + positionSeconds: 100, + durationSeconds: 100, + }), + ], + [ + 102, + position(102, { + positionSeconds: 90, + durationSeconds: 100, + }), + ], + ]), + }); + + expect(action).toEqual({ + kind: SERIES_QUICK_START_ACTION_KIND.Completed, + labelKey: 'XTREAM.SERIES_WATCHED', + episodeLabel: 'S01E02 · Episode 2', + icon: 'check_circle', + episode: finalEpisode, + position: expect.any(Object), + startTime: undefined, + disabled: true, + }); + }); + + it('sorts nonnumeric season keys naturally', () => { + const seasonTwoEpisode = episode(201, 2, 1); + + const action = getSeriesQuickStartAction({ + seasons: { + 'Season 10': [episode(1001, 10, 1)], + 'Season 2': [seasonTwoEpisode], + }, + playbackPositions: new Map(), + }); + + expect(action?.episode).toBe(seasonTwoEpisode); + }); + + it('falls back to series order when in-progress positions have no updatedAt', () => { + const laterEpisode = episode(102, 1, 2); + + const action = getSeriesQuickStartAction({ + seasons: { + '1': [episode(101, 1, 1), laterEpisode], + }, + playbackPositions: new Map([ + [ + 101, + position(101, { + positionSeconds: 20, + }), + ], + [ + 102, + position(102, { + positionSeconds: 30, + }), + ], + ]), + }); + + expect(action?.kind).toBe(SERIES_QUICK_START_ACTION_KIND.Resume); + expect(action?.episode).toBe(laterEpisode); + }); + + it('omits the title separator when an episode title is empty', () => { + const firstEpisode = episode(101, 1, 1, ''); + + const action = getSeriesQuickStartAction({ + seasons: { + '1': [firstEpisode], + }, + playbackPositions: new Map(), + }); + + expect(action?.episodeLabel).toBe('S01E01'); + }); + + it('returns null when no playable episodes exist', () => { + const action = getSeriesQuickStartAction({ + seasons: { + '1': [], + }, + playbackPositions: new Map(), + }); + + expect(action).toBeNull(); + }); +}); + +describe('episode label helpers', () => { + it('normalizes only positive integers', () => { + expect(getPositiveInteger(1)).toBe(1); + expect(getPositiveInteger(0)).toBeNull(); + expect(getPositiveInteger(1.5)).toBeNull(); + expect(getPositiveInteger(Number.NaN)).toBeNull(); + }); + + it('pads single digit episode label parts', () => { + expect(padEpisodePart(1)).toBe('01'); + expect(padEpisodePart(10)).toBe('10'); + }); +}); diff --git a/libs/portal/shared/util/src/lib/series-quick-start.ts b/libs/portal/shared/util/src/lib/series-quick-start.ts new file mode 100644 index 000000000..50998f5d6 --- /dev/null +++ b/libs/portal/shared/util/src/lib/series-quick-start.ts @@ -0,0 +1,197 @@ +import { PlaybackPositionData, XtreamSerieEpisode } from 'shared-interfaces'; +import { + isPortalPlaybackInProgress, + isPortalPlaybackWatched, +} from './portal-playback-positions'; + +export const SERIES_QUICK_START_ACTION_KIND = { + PlayFirst: 'play-first', + Resume: 'resume', + PlayNext: 'play-next', + Completed: 'completed', +} as const; + +export type SeriesQuickStartActionKind = + (typeof SERIES_QUICK_START_ACTION_KIND)[keyof typeof SERIES_QUICK_START_ACTION_KIND]; + +export interface SeriesQuickStartAction { + kind: SeriesQuickStartActionKind; + labelKey: string; + episodeLabel: string; + icon: string; + episode: XtreamSerieEpisode; + position: PlaybackPositionData | null; + startTime?: number; + disabled: boolean; +} + +interface SeriesQuickStartRequest { + seasons: Record; + playbackPositions: Map; +} + +interface OrderedEpisode { + episode: XtreamSerieEpisode; + position: PlaybackPositionData | null; + order: number; +} + +const naturalCollator = new Intl.Collator(undefined, { + numeric: true, + sensitivity: 'base', +}); + +export function getSeriesQuickStartAction( + request: SeriesQuickStartRequest +): SeriesQuickStartAction | null { + const orderedEpisodes = getOrderedEpisodes(request); + + if (orderedEpisodes.length === 0) { + return null; + } + + const inProgress = orderedEpisodes + .filter(({ position }) => isPortalPlaybackInProgress(position)) + .sort(compareInProgressPositions); + + const resumeEpisode = inProgress[inProgress.length - 1]; + if (resumeEpisode?.position) { + return createQuickStartAction({ + kind: SERIES_QUICK_START_ACTION_KIND.Resume, + labelKey: 'XTREAM.RESUME_EPISODE', + icon: 'play_arrow', + episode: resumeEpisode.episode, + position: resumeEpisode.position, + startTime: resumeEpisode.position.positionSeconds, + disabled: false, + }); + } + + const firstUnwatchedEpisode = orderedEpisodes.find( + ({ position }) => !isPortalPlaybackWatched(position) + ); + + if (firstUnwatchedEpisode) { + const hasWatchedEpisode = orderedEpisodes.some(({ position }) => + isPortalPlaybackWatched(position) + ); + + return createQuickStartAction({ + kind: hasWatchedEpisode + ? SERIES_QUICK_START_ACTION_KIND.PlayNext + : SERIES_QUICK_START_ACTION_KIND.PlayFirst, + labelKey: hasWatchedEpisode + ? 'XTREAM.PLAY_NEXT_EPISODE' + : 'XTREAM.PLAY_FIRST_EPISODE', + icon: 'play_arrow', + episode: firstUnwatchedEpisode.episode, + position: firstUnwatchedEpisode.position, + startTime: undefined, + disabled: false, + }); + } + + const finalEpisode = orderedEpisodes[orderedEpisodes.length - 1]; + if (!finalEpisode) { + return null; + } + + return createQuickStartAction({ + kind: SERIES_QUICK_START_ACTION_KIND.Completed, + labelKey: 'XTREAM.SERIES_WATCHED', + icon: 'check_circle', + episode: finalEpisode.episode, + position: finalEpisode.position, + startTime: undefined, + disabled: true, + }); +} + +function createQuickStartAction( + action: Omit +): SeriesQuickStartAction { + return { + ...action, + episodeLabel: getEpisodeLabel(action.episode), + }; +} + +function getOrderedEpisodes( + request: SeriesQuickStartRequest +): OrderedEpisode[] { + const orderedEpisodes: OrderedEpisode[] = []; + + Object.entries(request.seasons) + .sort(([seasonA], [seasonB]) => + naturalCollator.compare(seasonA, seasonB) + ) + .forEach(([, episodes]) => { + [...episodes].sort(compareEpisodes).forEach((episode) => { + orderedEpisodes.push({ + episode, + position: + request.playbackPositions.get(Number(episode.id)) ?? + null, + order: orderedEpisodes.length, + }); + }); + }); + + return orderedEpisodes; +} + +function compareEpisodes( + episodeA: XtreamSerieEpisode, + episodeB: XtreamSerieEpisode +): number { + const episodeNumberDelta = + Number(episodeA.episode_num) - Number(episodeB.episode_num); + + if (Number.isFinite(episodeNumberDelta) && episodeNumberDelta !== 0) { + return episodeNumberDelta; + } + + return naturalCollator.compare(episodeA.title ?? '', episodeB.title ?? ''); +} + +function compareInProgressPositions( + episodeA: OrderedEpisode, + episodeB: OrderedEpisode +): number { + const timeA = getTimestamp(episodeA.position?.updatedAt); + const timeB = getTimestamp(episodeB.position?.updatedAt); + + if (timeA !== timeB) { + return timeA - timeB; + } + + return episodeA.order - episodeB.order; +} + +function getTimestamp(value: string | undefined): number { + if (!value) { + return 0; + } + + const timestamp = Date.parse(value); + return Number.isFinite(timestamp) ? timestamp : 0; +} + +function getEpisodeLabel(episode: XtreamSerieEpisode): string { + const seasonNumber = getPositiveInteger(episode.season) ?? 1; + const episodeNumber = getPositiveInteger(episode.episode_num) ?? 1; + const episodeCode = `S${padEpisodePart(seasonNumber)}E${padEpisodePart( + episodeNumber + )}`; + const title = episode.title.trim(); + + return title ? `${episodeCode} · ${title}` : episodeCode; +} + +export function getPositiveInteger(value: number): number | null { + return Number.isInteger(value) && value > 0 ? value : null; +} + +export function padEpisodePart(value: number): string { + return value < 10 ? `0${value}` : String(value); +} diff --git a/libs/portal/stalker/data-access/src/lib/stores/features/with-stalker-player.feature.spec.ts b/libs/portal/stalker/data-access/src/lib/stores/features/with-stalker-player.feature.spec.ts index 79587af21..2e5794fbd 100644 --- a/libs/portal/stalker/data-access/src/lib/stores/features/with-stalker-player.feature.spec.ts +++ b/libs/portal/stalker/data-access/src/lib/stores/features/with-stalker-player.feature.spec.ts @@ -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({ diff --git a/libs/portal/stalker/data-access/src/lib/stores/utils/stalker-collection-persistence.utils.ts b/libs/portal/stalker/data-access/src/lib/stores/utils/stalker-collection-persistence.utils.ts index 959a77d0e..66a69e2a1 100644 --- a/libs/portal/stalker/data-access/src/lib/stores/utils/stalker-collection-persistence.utils.ts +++ b/libs/portal/stalker/data-access/src/lib/stores/utils/stalker-collection-persistence.utils.ts @@ -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, }; } diff --git a/libs/portal/stalker/feature/src/lib/stalker-collection-detail.component.spec.ts b/libs/portal/stalker/feature/src/lib/stalker-collection-detail.component.spec.ts index c1f9f983a..da8218ae5 100644 --- a/libs/portal/stalker/feature/src/lib/stalker-collection-detail.component.spec.ts +++ b/libs/portal/stalker/feature/src/lib/stalker-collection-detail.component.spec.ts @@ -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(null); readonly isSeries = input(false); - readonly vodDetailsItem = input(null); + readonly vodDetailsItem = input(null); readonly isFavorite = input(false); + readonly playbackPosition = input(null); + readonly inlinePlayback = input(null); + readonly externalPlayback = input(null); readonly backClicked = output(); - readonly playClicked = output(); + readonly playClicked = output(); + readonly resumeClicked = output(); readonly favoriteToggled = output(); + readonly inlineTimeUpdated = output(); + readonly inlinePlaybackClosed = output(); + readonly streamUrlCopied = output(); + readonly inlineExternalFallbackRequested = output(); } 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( diff --git a/libs/portal/stalker/feature/src/lib/stalker-collection-detail.component.ts b/libs/portal/stalker/feature/src/lib/stalker-collection-detail.component.ts index 55d2f5654..a38f26a68 100644 --- a/libs/portal/stalker/feature/src/lib/stalker-collection-detail.component.ts +++ b/libs/portal/stalker/feature/src/lib/stalker-collection-detail.component.ts @@ -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 { @@ -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(null); readonly vodDetailsItem = signal(null); + readonly inlinePlayback = signal(null); + private readonly selectedVodPosition = signal( + null + ); readonly isSelectedVodFavorite = signal(false); readonly detailCategoryOverride = signal( null @@ -109,8 +143,21 @@ export class StalkerCollectionDetailComponent { () => this.stalkerStore.currentPlaylist()?._id, () => this.favoritesRefresh.refreshVersion() ); + readonly selectedVodPlaybackPosition = computed( + () => 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 { @@ -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 { + 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 { + await this.vodPlayback.loadSelectedVodPosition(playlistId, vodId); } } diff --git a/libs/portal/stalker/feature/src/lib/stalker-favorites-button/stalker-favorites-button.component.scss b/libs/portal/stalker/feature/src/lib/stalker-favorites-button/stalker-favorites-button.component.scss index 679630b10..8a45e4141 100644 --- a/libs/portal/stalker/feature/src/lib/stalker-favorites-button/stalker-favorites-button.component.scss +++ b/libs/portal/stalker/feature/src/lib/stalker-favorites-button/stalker-favorites-button.component.scss @@ -2,13 +2,14 @@ display: inline-flex; align-items: center; gap: 8px; - padding: 12px 20px; - font-size: 0.9375rem; - font-weight: 500; + min-height: 64px; + padding: 0 24px; + font-size: 1rem; + font-weight: 600; background: rgba(109, 109, 110, 0.7); color: #fff; border: none; - border-radius: 4px; + border-radius: 16px; cursor: pointer; transition: all 0.2s ease; @@ -25,8 +26,8 @@ } mat-icon { - font-size: 20px; - width: 20px; - height: 20px; + font-size: 24px; + width: 24px; + height: 24px; } } diff --git a/libs/portal/stalker/feature/src/lib/stalker-inline-detail/stalker-inline-detail.component.html b/libs/portal/stalker/feature/src/lib/stalker-inline-detail/stalker-inline-detail.component.html index c69bb55aa..fa933a512 100644 --- a/libs/portal/stalker/feature/src/lib/stalker-inline-detail/stalker-inline-detail.component.html +++ b/libs/portal/stalker/feature/src/lib/stalker-inline-detail/stalker-inline-detail.component.html @@ -13,10 +13,19 @@ } } @else if (categoryId() === 'series') { diff --git a/libs/portal/stalker/feature/src/lib/stalker-inline-detail/stalker-inline-detail.component.ts b/libs/portal/stalker/feature/src/lib/stalker-inline-detail/stalker-inline-detail.component.ts index c7a18178e..51fde5c2b 100644 --- a/libs/portal/stalker/feature/src/lib/stalker-inline-detail/stalker-inline-detail.component.ts +++ b/libs/portal/stalker/feature/src/lib/stalker-inline-detail/stalker-inline-detail.component.ts @@ -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(null); readonly isSeries = input(false); readonly vodDetailsItem = input(null); readonly isFavorite = input(false); + readonly playbackPosition = input(null); + readonly inlinePlayback = input(null); + readonly externalPlayback = input(null); readonly backClicked = output(); readonly playClicked = output(); + readonly resumeClicked = output<{ + item: VodDetailsItem; + positionSeconds: number; + }>(); readonly favoriteToggled = output<{ item: VodDetailsItem; isFavorite: boolean; }>(); + readonly inlineTimeUpdated = output<{ + currentTime: number; + duration: number; + }>(); + readonly inlinePlaybackClosed = output(); + readonly streamUrlCopied = output(); + readonly inlineExternalFallbackRequested = + output(); 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); + } } diff --git a/libs/portal/stalker/feature/src/lib/stalker-search/stalker-search.component.html b/libs/portal/stalker/feature/src/lib/stalker-search/stalker-search.component.html index 778dde0a9..985dbfb51 100644 --- a/libs/portal/stalker/feature/src/lib/stalker-search/stalker-search.component.html +++ b/libs/portal/stalker/feature/src/lib/stalker-search/stalker-search.component.html @@ -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()" /> diff --git a/libs/portal/stalker/feature/src/lib/stalker-search/stalker-search.component.ts b/libs/portal/stalker/feature/src/lib/stalker-search/stalker-search.component.ts index a3ec875f7..d2b473625 100644 --- a/libs/portal/stalker/feature/src/lib/stalker-search/stalker-search.component.ts +++ b/libs/portal/stalker/feature/src/lib/stalker-search/stalker-search.component.ts @@ -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(null); + readonly selectedVodPosition = signal(null); + readonly selectedVodPlaybackPosition = computed( + () => 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 { + 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 { + await this.vodPlayback.loadSelectedVodPosition(playlistId, vodId); + } } 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 70ef5b16f..7bcb88687 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 @@ -51,6 +51,30 @@ + @if (quickStartAction(); as action) { + + } ', +}) +class StubContentHeroComponent { + readonly title = input(undefined); + readonly description = input(undefined); + readonly posterUrl = input(undefined); + readonly backClicked = output(); +} + +@Component({ + selector: 'app-season-container', + standalone: true, + template: '
', +}) +class StubSeasonContainerComponent { + readonly seasons = input(null); + readonly seriesId = input(null); + readonly playlistId = input(''); + readonly seriesTitle = input(undefined); + readonly playbackPositions = input(null); + readonly openingEpisodeId = input(null); + readonly activeEpisodeId = input(null); + readonly isLoading = input(false); + readonly seasonSelected = output(); + readonly episodeClicked = output(); + readonly episodeDownloadRequested = output(); + readonly playbackToggleRequested = output(); + selectedSeason: string | undefined; +} + +@Component({ + selector: 'app-portal-inline-player', + standalone: true, + template: '', +}) +class StubPortalInlinePlayerComponent { + readonly playback = input(null); + readonly timeUpdate = output(); + readonly closed = output(); + readonly streamUrlCopied = output(); + readonly externalFallbackRequested = output(); +} + +@Component({ + selector: 'app-favorites-button', + standalone: true, + template: '', +}) +class StubFavoritesButtonComponent { + readonly itemId = input(undefined); + readonly item = input(null); +} + +describe('StalkerSeriesViewComponent', () => { + let fixture: ComponentFixture; + const selectedContentType = signal<'series' | 'vod'>('series'); + const selectedItem = signal(null); + const serialSeasonsResource = signal([]); + const vodSeriesSeasonsResource = signal([]); + const fetchVodSeriesEpisodes = jest.fn(); + const resolveVodPlayback = jest.fn(); + const getSeriesPlaybackPositions = jest.fn().mockResolvedValue([]); + const openResolvedPlayback = jest.fn(); + + beforeEach(async () => { + selectedContentType.set('series'); + selectedItem.set({ + id: '30001', + cmd: '/media/file_30001.mpg', + info: { + name: 'Regular Series', + description: 'Series description', + movie_image: 'poster.jpg', + }, + }); + serialSeasonsResource.set([ + { + id: 'season-1', + name: 'Season 1', + cmd: '/media/file_30001.mpg', + series: [1, 2], + }, + ]); + vodSeriesSeasonsResource.set([]); + fetchVodSeriesEpisodes.mockReset(); + resolveVodPlayback.mockReset(); + resolveVodPlayback.mockResolvedValue({ + streamUrl: 'http://stalker.example/episode.mpg', + title: 'Regular Series', + thumbnail: 'poster.jpg', + }); + getSeriesPlaybackPositions.mockClear(); + getSeriesPlaybackPositions.mockResolvedValue([]); + openResolvedPlayback.mockClear(); + + await TestBed.configureTestingModule({ + imports: [StalkerSeriesViewComponent], + providers: [ + { + provide: StalkerStore, + useValue: { + selectedItem, + selectedContentType, + currentPlaylist: signal({ _id: 'stalker-1' }), + getSerialSeasonsResource: () => + serialSeasonsResource(), + getVodSeriesSeasonsResource: () => + vodSeriesSeasonsResource(), + isVodSeriesSeasonsLoading: signal(false), + isSerialSeasonsLoading: signal(false), + fetchVodSeriesEpisodes, + resolveVodPlayback, + fetchLinkToPlay: jest.fn(), + clearSelectedItem: jest.fn(), + }, + }, + { + provide: PORTAL_EXTERNAL_PLAYBACK, + useValue: { + activeSession: signal(null), + }, + }, + { + provide: PORTAL_PLAYBACK_POSITIONS, + useValue: { + getSeriesPlaybackPositions, + savePlaybackPosition: jest.fn(), + clearPlaybackPosition: jest.fn(), + }, + }, + { + provide: PORTAL_PLAYER, + useValue: { + isEmbeddedPlayer: jest.fn().mockReturnValue(false), + openResolvedPlayback, + }, + }, + { + provide: Router, + useValue: { + navigateByUrl: jest.fn(), + }, + }, + { + provide: DownloadsService, + useValue: { + startDownload: 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), + }, + }, + ], + }) + .overrideComponent(StalkerSeriesViewComponent, { + remove: { + imports: [ + ContentHeroComponent, + FavoritesButtonComponent, + PortalInlinePlayerComponent, + SeasonContainerComponent, + TranslatePipe, + ], + }, + add: { + imports: [ + StubContentHeroComponent, + StubFavoritesButtonComponent, + StubPortalInlinePlayerComponent, + StubSeasonContainerComponent, + MockPipe( + TranslatePipe, + (value: string | null | undefined) => value ?? '' + ), + ], + }, + }) + .compileComponents(); + + fixture = TestBed.createComponent(StalkerSeriesViewComponent); + }); + + afterEach(() => { + fixture?.destroy(); + }); + + it('renders quick start for regular series and starts the first episode', async () => { + fixture.detectChanges(); + await fixture.whenStable(); + fixture.detectChanges(); + + const quickStartButton: HTMLButtonElement | null = + fixture.nativeElement.querySelector( + '[data-testid="series-quick-start"]' + ); + + expect(quickStartButton).not.toBeNull(); + expect(quickStartButton?.textContent).toContain( + 'XTREAM.PLAY_FIRST_EPISODE' + ); + expect(quickStartButton?.textContent).toContain( + 'S01E01 · Episode 1' + ); + + quickStartButton?.click(); + await fixture.whenStable(); + + expect(resolveVodPlayback).toHaveBeenCalledWith( + '/media/file_30001.mpg', + 'Regular Series', + 'poster.jpg', + 1, + expect.any(Number), + undefined + ); + expect(openResolvedPlayback).toHaveBeenCalledWith( + expect.objectContaining({ + streamUrl: 'http://stalker.example/episode.mpg', + }), + true + ); + }); + + it('loads the first VOD-series season and starts its first episode from quick start', 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', + }, + ]); + fetchVodSeriesEpisodes.mockResolvedValue([ + { + id: 'episode-1', + series_number: 1, + name: 'Pilot', + }, + ]); + + fixture.detectChanges(); + await fixture.whenStable(); + fixture.detectChanges(); + + const quickStartButton: HTMLButtonElement | null = + fixture.nativeElement.querySelector( + '[data-testid="series-quick-start"]' + ); + + expect(quickStartButton).not.toBeNull(); + expect(quickStartButton?.textContent).toContain('S01E01'); + + quickStartButton?.click(); + await fixture.whenStable(); + + expect(fetchVodSeriesEpisodes).toHaveBeenCalledWith( + '50001', + 'season-1' + ); + expect(resolveVodPlayback).toHaveBeenCalledWith( + '/media/file_episode-1.mpg', + 'VOD Flagged Series - Pilot', + 'vod-series.jpg', + 1, + expect.any(Number), + undefined + ); + }); + + it('loads the next unloaded VOD-series season after the loaded season is watched', 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', + }, + ]); + fetchVodSeriesEpisodes.mockResolvedValue([ + { + id: 'episode-2', + series_number: 1, + name: 'Second Season Pilot', + }, + ]); + + 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', + }, + ], + isLoading: false, + isExpanded: false, + }, + { + id: 'season-2', + video_id: '50001', + season_number: '2', + name: 'Season 2', + episodes: [], + isLoading: false, + isExpanded: false, + }, + ]); + const watchedEpisode = fixture.componentInstance.mappedSeasons()['1'][0]; + const watchedPosition: PlaybackPositionData = { + contentXtreamId: Number(watchedEpisode.id), + contentType: 'episode', + seriesXtreamId: 50001, + positionSeconds: 95, + durationSeconds: 100, + }; + fixture.componentInstance.episodePlaybackPositions.set( + new Map([[Number(watchedEpisode.id), watchedPosition]]) + ); + fixture.detectChanges(); + + const quickStartButton: HTMLButtonElement | null = + fixture.nativeElement.querySelector( + '[data-testid="series-quick-start"]' + ); + + expect(quickStartButton).not.toBeNull(); + expect(quickStartButton?.textContent).toContain( + 'XTREAM.PLAY_NEXT_EPISODE' + ); + expect(quickStartButton?.textContent).toContain('S02E01'); + + quickStartButton?.click(); + await fixture.whenStable(); + + expect(fetchVodSeriesEpisodes).toHaveBeenCalledWith( + '50001', + 'season-2' + ); + expect(resolveVodPlayback).toHaveBeenCalledWith( + '/media/file_episode-2.mpg', + 'VOD Flagged Series - Second Season Pilot', + 'vod-series.jpg', + 1, + expect.any(Number), + undefined + ); + }); +}); 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 b41ec3918..ca7ed2d88 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 @@ -8,6 +8,7 @@ import { output, signal, } from '@angular/core'; +import { MatIcon } from '@angular/material/icon'; import { MatSnackBar } from '@angular/material/snack-bar'; import { Router } from '@angular/router'; import { TranslatePipe, TranslateService } from '@ngx-translate/core'; @@ -26,8 +27,13 @@ import { PORTAL_EXTERNAL_PLAYBACK, PORTAL_PLAYBACK_POSITIONS, PORTAL_PLAYER, + SERIES_QUICK_START_ACTION_KIND, + SeriesQuickStartAction, createLogger, + getPositiveInteger, + getSeriesQuickStartAction, getStalkerReturnToState, + padEpisodePart, } from '@iptvnator/portal/shared/util'; import { getVodSeriesSeasonKey, @@ -50,6 +56,15 @@ import { } from '@iptvnator/ui/playback'; import { DownloadsService } from 'services'; +interface StalkerQuickStartButton { + labelKey: string; + episodeLabel: string | null; + icon: string; + disabled: boolean; + action: SeriesQuickStartAction | null; + lazySeason: VodSeriesSeasonVm | null; +} + /** * Component for displaying series/episodes for Stalker portal content. * Supports three modes: @@ -67,6 +82,7 @@ import { DownloadsService } from 'services'; PortalInlinePlayerComponent, TranslatePipe, SeasonContainerComponent, + MatIcon, ], }) export class StalkerSeriesViewComponent implements OnDestroy { @@ -142,14 +158,11 @@ export class StalkerSeriesViewComponent implements OnDestroy { const playlist = this.stalkerStore.currentPlaylist(); if (item && playlist?._id) { const normalizedSeriesId = this.toSeriesId(item.id); - this.logger.debug( - 'Loading positions for series', - { - id: item.id, - seriesId: normalizedSeriesId, - isSeries: item.is_series, - } - ); + this.logger.debug('Loading positions for series', { + id: item.id, + seriesId: normalizedSeriesId, + isSeries: item.is_series, + }); if (!isNaN(normalizedSeriesId)) { void this.loadSeriesPositions( playlist._id, @@ -258,6 +271,41 @@ export class StalkerSeriesViewComponent implements OnDestroy { } ); + readonly quickStartAction = computed(() => { + const action = getSeriesQuickStartAction({ + seasons: this.mappedSeasons(), + playbackPositions: this.episodePlaybackPositions(), + }); + + const lazySeason = this.getQuickStartLazyVodSeriesSeason(action); + if (lazySeason) { + return { + labelKey: + action?.kind === SERIES_QUICK_START_ACTION_KIND.Completed + ? 'XTREAM.PLAY_NEXT_EPISODE' + : 'XTREAM.PLAY_FIRST_EPISODE', + episodeLabel: this.getLazyVodSeriesEpisodeLabel(lazySeason), + icon: 'play_arrow', + disabled: lazySeason.isLoading, + action: null, + lazySeason, + }; + } + + if (!action) { + return null; + } + + return { + labelKey: action.labelKey, + episodeLabel: action.episodeLabel, + icon: action.icon, + disabled: action.disabled, + action, + lazySeason: null, + }; + }); + /** * Handles season selection from the container. * For VOD Series, triggers lazy loading of episodes. @@ -357,6 +405,22 @@ export class StalkerSeriesViewComponent implements OnDestroy { } } + async playQuickStartEpisode(): Promise { + const quickStart = this.quickStartAction(); + if (!quickStart || quickStart.disabled) { + return; + } + + if (quickStart.action) { + this.onEpisodeClicked(quickStart.action.episode); + return; + } + + if (quickStart.lazySeason) { + await this.loadAndPlayVodSeriesSeason(quickStart.lazySeason); + } + } + /** * Play episode - handles regular series and vclub mode */ @@ -513,8 +577,7 @@ export class StalkerSeriesViewComponent implements OnDestroy { } catch (error) { this.logger.error('Failed to start inline series playback', error); const errorMessage = - error instanceof Error && - error.message === 'nothing_to_play' + error instanceof Error && error.message === 'nothing_to_play' ? this.translateService.instant( 'PORTALS.CONTENT_NOT_AVAILABLE' ) @@ -589,7 +652,8 @@ export class StalkerSeriesViewComponent implements OnDestroy { const posterUrl = episodeInfo?.movie_image; const seasonNum = Number(episode.season || 1); const episodeNum = episode.episode_num || 1; - const seriesTitle = item.info?.name || this.displayItem()?.info?.name || 'Series'; + const seriesTitle = + item.info?.name || this.displayItem()?.info?.name || 'Series'; const episodeTitle = `${seriesTitle} - S${String(seasonNum).padStart( 2, '0' @@ -621,10 +685,11 @@ export class StalkerSeriesViewComponent implements OnDestroy { playlistId: string, seriesXtreamId: number ): Promise { - const positions = await this.playbackPositions.getSeriesPlaybackPositions( - playlistId, - seriesXtreamId - ); + const positions = + await this.playbackPositions.getSeriesPlaybackPositions( + playlistId, + seriesXtreamId + ); const positionsMap = new Map(); positions.forEach((position) => { positionsMap.set(position.contentXtreamId, position); @@ -632,7 +697,86 @@ export class StalkerSeriesViewComponent implements OnDestroy { this.episodePlaybackPositions.set(positionsMap); } - private updateEpisodePlaybackPosition(position: PlaybackPositionData): void { + private async loadAndPlayVodSeriesSeason( + season: VodSeriesSeasonVm + ): Promise { + if (season.episodes.length === 0) { + await this.loadEpisodesForSeason(season); + } + + const loadedSeason = + this.vodSeriesSeasons().find((item) => item.id === season.id) ?? + season; + const seasonKey = getVodSeriesSeasonKey(loadedSeason); + const episode = this.mappedSeasons()[seasonKey]?.[0]; + + if (episode) { + this.onEpisodeClicked(episode); + } + } + + private getQuickStartLazyVodSeriesSeason( + action: SeriesQuickStartAction | null + ): VodSeriesSeasonVm | null { + if (!this.isVodSeries()) { + return null; + } + + const seasons = this.vodSeriesSeasons(); + if (!action) { + return this.getFirstUnloadedVodSeriesSeason(seasons); + } + + if (action.kind !== SERIES_QUICK_START_ACTION_KIND.Completed) { + return null; + } + + const completedSeasonIndex = this.findVodSeriesSeasonIndexForEpisode( + action.episode, + seasons + ); + if (completedSeasonIndex === -1) { + return null; + } + + return this.getFirstUnloadedVodSeriesSeason( + seasons.slice(completedSeasonIndex + 1) + ); + } + + private getFirstUnloadedVodSeriesSeason( + seasons: ReadonlyArray + ): VodSeriesSeasonVm | null { + return seasons.find((season) => season.episodes.length === 0) ?? null; + } + + private findVodSeriesSeasonIndexForEpisode( + episode: XtreamSerieEpisode, + seasons: ReadonlyArray + ): number { + const mappedSeasons = this.mappedSeasons(); + + return seasons.findIndex((season) => + (mappedSeasons[getVodSeriesSeasonKey(season)] ?? []).some( + (mappedEpisode) => mappedEpisode.id === episode.id + ) + ); + } + + private getLazyVodSeriesEpisodeLabel(season: VodSeriesSeasonVm): string { + const seasonIndex = this.vodSeriesSeasons().findIndex( + (item) => item.id === season.id + ); + const seasonNumber = + getPositiveInteger(Number(season.season_number)) ?? + (seasonIndex >= 0 ? seasonIndex + 1 : 1); + + return `S${padEpisodePart(seasonNumber)}E01`; + } + + private updateEpisodePlaybackPosition( + position: PlaybackPositionData + ): void { const updated = new Map(this.episodePlaybackPositions()); updated.set(position.contentXtreamId, position); this.episodePlaybackPositions.set(updated); diff --git a/libs/portal/stalker/feature/src/lib/stalker-vod-playback-controller.spec.ts b/libs/portal/stalker/feature/src/lib/stalker-vod-playback-controller.spec.ts new file mode 100644 index 000000000..c9b2a1b13 --- /dev/null +++ b/libs/portal/stalker/feature/src/lib/stalker-vod-playback-controller.spec.ts @@ -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 { + promise: Promise; + resolve: (value: T) => void; +} + +function createDeferred(): Deferred { + let resolve!: (value: T) => void; + const promise = new Promise((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(null); + const playbackPositions = { + savePlaybackPosition: jest.fn(), + getPlaybackPosition: jest.fn(), + getSeriesPlaybackPositions: jest.fn(), + getAllPlaybackPositions: jest.fn(), + clearPlaybackPosition: jest.fn(), + } as unknown as jest.Mocked; + const portalPlayer = { + isEmbeddedPlayer: jest.fn(() => true), + openPlayer: jest.fn(), + openResolvedPlayback: jest.fn(), + openExternalPlayback: jest.fn(), + } as unknown as jest.Mocked; + 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(); + const newerLoad = createDeferred(); + + 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); + }); +}); diff --git a/libs/portal/stalker/feature/src/lib/stalker-vod-playback-controller.ts b/libs/portal/stalker/feature/src/lib/stalker-vod-playback-controller.ts new file mode 100644 index 000000000..87cd75419 --- /dev/null +++ b/libs/portal/stalker/feature/src/lib/stalker-vod-playback-controller.ts @@ -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; + selectedVodPosition: WritableSignal; + 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 + ): Promise { + 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 { + 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 + ); + } +} diff --git a/libs/portal/stalker/feature/src/lib/styles/detail-view.scss b/libs/portal/stalker/feature/src/lib/styles/detail-view.scss index 23d60ef58..0345bd764 100644 --- a/libs/portal/stalker/feature/src/lib/styles/detail-view.scss +++ b/libs/portal/stalker/feature/src/lib/styles/detail-view.scss @@ -129,11 +129,11 @@ display: inline-flex; align-items: center; gap: 12px; - padding: 12px 26px; - min-height: 54px; + padding: 10px 24px 10px 18px; + min-height: 64px; font-size: 1rem; font-weight: 600; - letter-spacing: 0.01em; + letter-spacing: 0; background: var(--play-btn-bg); background-size: 140% 140%; background-position: 0% 50%; @@ -221,10 +221,33 @@ box-shadow 220ms ease; } - &__label { + &__copy { display: inline-flex; - align-items: center; + flex-direction: column; + align-items: flex-start; + gap: 2px; min-width: 0; + line-height: 1.1; + text-align: left; + } + + &__label { + display: block; + min-width: 0; + line-height: 1.15; + } + + &__meta { + display: block; + max-width: min(42vw, 360px); + overflow: hidden; + color: var(--play-btn-fg); + font-size: 0.78rem; + font-weight: 500; + line-height: 1.2; + opacity: 0.72; + text-overflow: ellipsis; + white-space: nowrap; } &--state-launching, @@ -339,6 +362,21 @@ } } +.favorite-btn, +.download-btn { + min-height: 64px; + padding: 0 24px; + border-radius: 16px; + font-size: 1rem; + font-weight: 600; + + mat-icon { + font-size: 24px; + width: 24px; + height: 24px; + } +} + .favorite-btn { &--active { background: rgba(229, 9, 20, 0.8); @@ -540,9 +578,14 @@ .download-btn { flex: 1; justify-content: center; + min-height: 58px; padding: 10px 16px; } + .play-btn__meta { + max-width: 100%; + } + .youtube-trailer { margin-top: 16px; diff --git a/libs/portal/xtream/feature/src/index.ts b/libs/portal/xtream/feature/src/index.ts index 27233563f..7de311c3d 100644 --- a/libs/portal/xtream/feature/src/index.ts +++ b/libs/portal/xtream/feature/src/index.ts @@ -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'; diff --git a/libs/portal/xtream/feature/src/lib/player-dialog/player-dialog.component.html b/libs/portal/xtream/feature/src/lib/player-dialog/player-dialog.component.html deleted file mode 100644 index 92910b7b6..000000000 --- a/libs/portal/xtream/feature/src/lib/player-dialog/player-dialog.component.html +++ /dev/null @@ -1,25 +0,0 @@ -

{{ title }}

- - - - - - - diff --git a/libs/portal/xtream/feature/src/lib/player-dialog/player-dialog.component.scss b/libs/portal/xtream/feature/src/lib/player-dialog/player-dialog.component.scss deleted file mode 100644 index a77331c8c..000000000 --- a/libs/portal/xtream/feature/src/lib/player-dialog/player-dialog.component.scss +++ /dev/null @@ -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; - } -} diff --git a/libs/portal/xtream/feature/src/lib/player-dialog/player-dialog.component.ts b/libs/portal/xtream/feature/src/lib/player-dialog/player-dialog.component.ts deleted file mode 100644 index 2bb3eb771..000000000 --- a/libs/portal/xtream/feature/src/lib/player-dialog/player-dialog.component.ts +++ /dev/null @@ -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(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 - ); - } -} 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 0e64b6ab6..cd8558b1f 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 @@ -46,6 +46,28 @@
+ @if (quickStartAction(); as action) { + + } @if (!isFavorite()) {