From 16fede118042c132d0a47d6d1695cdfd2d765502 Mon Sep 17 00:00:00 2001 From: 4gray Date: Mon, 27 Apr 2026 00:24:23 +0200 Subject: [PATCH 001/116] docs(blog): add favicon Entire-Checkpoint: c6e522b4276c --- .../website/public/android-chrome-192x192.png | Bin 0 -> 18543 bytes .../website/public/android-chrome-512x512.png | Bin 0 -> 92875 bytes apps/website/public/apple-touch-icon.png | Bin 0 -> 16393 bytes apps/website/public/favicon-16x16.png | Bin 0 -> 605 bytes apps/website/public/favicon-256x256.png | Bin 0 -> 53405 bytes apps/website/public/favicon-32x32.png | Bin 0 -> 1565 bytes apps/website/public/site.webmanifest | 18 ++++++++++++++++++ apps/website/src/components/BaseHead.astro | 6 +++++- .../src/components/FeaturedBlogPost.astro | 2 +- apps/website/src/components/Footer.astro | 2 +- apps/website/src/components/Header.astro | 2 +- apps/website/src/layouts/BlogPost.astro | 2 +- 12 files changed, 27 insertions(+), 5 deletions(-) create mode 100644 apps/website/public/android-chrome-192x192.png create mode 100644 apps/website/public/android-chrome-512x512.png create mode 100644 apps/website/public/apple-touch-icon.png create mode 100644 apps/website/public/favicon-16x16.png create mode 100644 apps/website/public/favicon-256x256.png create mode 100644 apps/website/public/favicon-32x32.png create mode 100644 apps/website/public/site.webmanifest diff --git a/apps/website/public/android-chrome-192x192.png b/apps/website/public/android-chrome-192x192.png new file mode 100644 index 0000000000000000000000000000000000000000..d821e856671e6f7128808488b5978c95f02fda33 GIT binary patch literal 18543 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 VK_gI3ZXW;u002ovPDHLkV1k+%0DJ%d literal 0 HcmV?d00001 diff --git a/apps/website/public/android-chrome-512x512.png b/apps/website/public/android-chrome-512x512.png new file mode 100644 index 0000000000000000000000000000000000000000..d2e4ef332025d8efa1fa9a909cd4659dac75aca8 GIT binary patch literal 92875 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�a{UN*uu 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 literal 0 HcmV?d00001 diff --git a/apps/website/public/favicon-256x256.png b/apps/website/public/favicon-256x256.png new file mode 100644 index 0000000000000000000000000000000000000000..d3283a33a94ba6bd382f8577c162da35b59e443f GIT binary patch literal 53405 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

RAd908N^~ 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 P00000NkvXXu0mjf7>nO> literal 0 HcmV?d00001 diff --git a/apps/website/public/site.webmanifest b/apps/website/public/site.webmanifest new file mode 100644 index 000000000..2f386cb1d --- /dev/null +++ b/apps/website/public/site.webmanifest @@ -0,0 +1,18 @@ +{ + "name": "IPTVnator", + "short_name": "IPTVnator", + "icons": [ + { + "src": "/iptvnator/android-chrome-192x192.png", + "sizes": "192x192", + "type": "image/png" + }, + { + "src": "/iptvnator/android-chrome-512x512.png", + "sizes": "512x512", + "type": "image/png" + } + ], + "theme_color": "#0a0a08", + "background_color": "#0a0a08" +} diff --git a/apps/website/src/components/BaseHead.astro b/apps/website/src/components/BaseHead.astro index 2e4f4b967..cfda85563 100644 --- a/apps/website/src/components/BaseHead.astro +++ b/apps/website/src/components/BaseHead.astro @@ -35,7 +35,11 @@ const schemaPayload = jsonLd - + + + + + diff --git a/apps/website/src/components/FeaturedBlogPost.astro b/apps/website/src/components/FeaturedBlogPost.astro index 1487a4204..56f5b1046 100644 --- a/apps/website/src/components/FeaturedBlogPost.astro +++ b/apps/website/src/components/FeaturedBlogPost.astro @@ -34,7 +34,7 @@ const formattedDate = pubDate.toLocaleDateString('en-US', { ) : (

diff --git a/apps/website/src/components/Footer.astro b/apps/website/src/components/Footer.astro index 27dfc8d6b..19a240698 100644 --- a/apps/website/src/components/Footer.astro +++ b/apps/website/src/components/Footer.astro @@ -45,7 +45,7 @@ const socialLinks = [
IPTVnator logo diff --git a/apps/website/src/components/Header.astro b/apps/website/src/components/Header.astro index ee1391036..6e6de962d 100644 --- a/apps/website/src/components/Header.astro +++ b/apps/website/src/components/Header.astro @@ -17,7 +17,7 @@ const navLinks = [ IPTVnator logo diff --git a/apps/website/src/layouts/BlogPost.astro b/apps/website/src/layouts/BlogPost.astro index 19e428705..27475b8dc 100644 --- a/apps/website/src/layouts/BlogPost.astro +++ b/apps/website/src/layouts/BlogPost.astro @@ -45,7 +45,7 @@ const blogPostSchema = { url: 'https://4gray.github.io/iptvnator/', logo: { '@type': 'ImageObject', - url: 'https://4gray.github.io/iptvnator/favicon.ico', + url: 'https://4gray.github.io/iptvnator/favicon-256x256.png', }, }, mainEntityOfPage: canonicalURL, From 10139955e7e741a6ae3c7dacf16ea595d6ed0939 Mon Sep 17 00:00:00 2001 From: 4gray Date: Mon, 27 Apr 2026 00:25:56 +0200 Subject: [PATCH 002/116] fix(stalker): lazy load live tv channels Entire-Checkpoint: c6e522b4276c --- .../with-stalker-content.feature.spec.ts | 73 +++++++++++++++++++ ...alker-live-stream-layout.component.spec.ts | 32 +++++++- .../stalker-live-stream-layout.component.ts | 15 ++-- 3 files changed, 111 insertions(+), 9 deletions(-) diff --git a/libs/portal/stalker/data-access/src/lib/stores/features/with-stalker-content.feature.spec.ts b/libs/portal/stalker/data-access/src/lib/stores/features/with-stalker-content.feature.spec.ts index 1525032bb..035e92b97 100644 --- a/libs/portal/stalker/data-access/src/lib/stores/features/with-stalker-content.feature.spec.ts +++ b/libs/portal/stalker/data-access/src/lib/stores/features/with-stalker-content.feature.spec.ts @@ -224,6 +224,79 @@ describe('withStalkerContent failure states', () => { ); }); + it('loads live TV channels page by page and appends later pages', async () => { + dataService.sendIpcEvent.mockImplementation( + (_event: unknown, payload: { params?: { p?: number } }) => { + const page = Number(payload.params?.p ?? 1); + + return Promise.resolve({ + js: { + data: [ + { + id: `channel-${page}`, + name: `Channel page ${page}`, + category_id: '5', + }, + ], + total_items: 2, + }, + }); + } + ); + + store.setSelectedContentType('itv'); + store.setCategories('itv', [ + { + category_id: '5', + category_name: 'News', + }, + ]); + store.setSelectedCategory('5'); + store.setCurrentPlaylist(PLAYLIST); + void store.isPaginatedContentLoading(); + + await waitForCondition(() => store.itvChannels().length === 1); + + expect(dataService.sendIpcEvent).toHaveBeenLastCalledWith( + expect.anything(), + expect.objectContaining({ + params: expect.objectContaining({ + action: StalkerPortalActions.GetOrderedList, + type: 'itv', + category: '5', + genre: '5', + p: 1, + }), + }) + ); + expect(store.itvChannels().map((channel) => channel.name)).toEqual([ + 'Channel page 1', + ]); + expect(store.hasMoreChannels()).toBe(true); + + store.setPage(1); + + await waitForCondition(() => store.itvChannels().length === 2); + + expect(dataService.sendIpcEvent).toHaveBeenLastCalledWith( + expect.anything(), + expect.objectContaining({ + params: expect.objectContaining({ + action: StalkerPortalActions.GetOrderedList, + type: 'itv', + category: '5', + genre: '5', + p: 2, + }), + }) + ); + expect(store.itvChannels().map((channel) => channel.name)).toEqual([ + 'Channel page 1', + 'Channel page 2', + ]); + expect(store.hasMoreChannels()).toBe(false); + }); + it('ignores stale content responses after the selected page changes', async () => { const pendingByPage = new Map< number, diff --git a/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-stream-layout.component.spec.ts b/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-stream-layout.component.spec.ts index 5c112bfd8..643411369 100644 --- a/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-stream-layout.component.spec.ts +++ b/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-stream-layout.component.spec.ts @@ -116,12 +116,14 @@ describe('StalkerLiveStreamLayoutComponent', () => { const bulkItvEpgPlaylistId = signal(null); const bulkItvEpgPeriodHours = signal(null); const isLoadingBulkItvEpg = signal(false); + const hasMoreChannels = signal(false); + const page = signal(0); const stalkerStore = { getSelectedCategoryName: signal('News'), itvChannels, searchPhrase, - hasMoreChannels: signal(false), + hasMoreChannels, selectedItvId, currentPlaylist: playlist, selectedItvEpgPrograms, @@ -133,7 +135,7 @@ describe('StalkerLiveStreamLayoutComponent', () => { selectedCategoryId, selectedItem, selectedContentType: signal<'itv' | 'vod' | 'series'>('itv'), - page: signal(0), + page, setItvChannels: jest.fn(), setPage: jest.fn(), setSelectedItem: jest.fn((item) => { @@ -180,6 +182,8 @@ describe('StalkerLiveStreamLayoutComponent', () => { bulkItvEpgPlaylistId.set(null); bulkItvEpgPeriodHours.set(null); isLoadingBulkItvEpg.set(false); + hasMoreChannels.set(false); + page.set(0); resolveItvPlayback.mockReset(); resolveItvPlayback.mockResolvedValue({ @@ -202,7 +206,14 @@ describe('StalkerLiveStreamLayoutComponent', () => { ); }); stalkerStore.setItvChannels.mockClear(); - stalkerStore.setPage.mockClear(); + stalkerStore.setPage.mockReset(); + stalkerStore.setPage.mockImplementation((nextPage: number) => { + if (page() === nextPage) { + return; + } + + page.set(nextPage); + }); stalkerStore.setSelectedItem.mockClear(); stalkerStore.clearBulkItvEpgCache.mockClear(); @@ -272,6 +283,21 @@ describe('StalkerLiveStreamLayoutComponent', () => { ).toBeNull(); }); + it('does not reset live channels when loading the next lazy page', async () => { + hasMoreChannels.set(true); + fixture.detectChanges(); + await fixture.whenStable(); + stalkerStore.setItvChannels.mockClear(); + stalkerStore.setPage.mockClear(); + + component.loadMore(); + await fixture.whenStable(); + + expect(page()).toBe(1); + expect(stalkerStore.setPage).toHaveBeenCalledWith(1); + expect(stalkerStore.setItvChannels).not.toHaveBeenCalled(); + }); + it('keeps row previews empty before bulk epg is loaded', async () => { fixture.detectChanges(); await fixture.whenStable(); diff --git a/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-stream-layout.component.ts b/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-stream-layout.component.ts index 832fe349a..715122ed3 100644 --- a/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-stream-layout.component.ts +++ b/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-stream-layout.component.ts @@ -8,6 +8,7 @@ import { inject, OnDestroy, signal, + untracked, viewChild, } from '@angular/core'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; @@ -183,12 +184,14 @@ export class StalkerLiveStreamLayoutComponent implements OnDestroy { // Reset channels/page on category change effect(() => { this.stalkerStore.selectedCategoryId(); - this.stalkerStore.setItvChannels([]); - this.stalkerStore.setPage(0); - this.clearEpgPreviewMaps(); - this.epgLoadRequestId += 1; - this.fallbackEpgPrograms.set([]); - this.isLoadingFallbackEpg.set(false); + untracked(() => { + this.stalkerStore.setItvChannels([]); + this.stalkerStore.setPage(0); + this.clearEpgPreviewMaps(); + this.epgLoadRequestId += 1; + this.fallbackEpgPrograms.set([]); + this.isLoadingFallbackEpg.set(false); + }); }); // Reset loading state when channels load and keep preview data in sync with bulk EPG. From 9f873e6bedb9da062b864ceaa26c95b4ade05ec2 Mon Sep 17 00:00:00 2001 From: 4gray Date: Mon, 27 Apr 2026 00:32:14 +0200 Subject: [PATCH 003/116] feat(player): add embedded-mpv player for macOS as experimental feature - Introduced tooling for building and staging the macOS `libmpv` runtime for IPTVnator's embedded MPV player. - Added `build-macos-runtime.mjs` for building an LGPL-compatible runtime from source. - Created `stage-macos-runtime.mjs` for staging the built runtime artifacts. - Implemented validation for the packaged embedded MPV runtime in `electron-after-pack.cjs` and `embedded-mpv-macos.cjs`. - Updated packaging scripts to ensure the embedded MPV runtime is correctly integrated and validated during the build process. - Added README files to document the expected layout and usage for the embedded MPV runtime artifacts. Entire-Checkpoint: c6e522b4276c --- .github/workflows/build-and-make.yaml | 22 +- .gitignore | 16 + apps/electron-backend/build-embedded-mpv.js | 228 ++ apps/electron-backend/native/binding.gyp | 37 + .../native/src/embedded_mpv.mm | 1911 +++++++++++++++++ apps/electron-backend/project.json | 40 +- .../src/app/api/main.preload.ts | 64 + .../src/app/events/embedded-mpv.events.ts | 85 + .../services/embedded-mpv-native.service.ts | 533 +++++ apps/electron-backend/src/main.ts | 5 + .../src/app/services/player.service.spec.ts | 15 +- apps/web/src/app/services/player.service.ts | 11 +- .../app/settings/settings.component.spec.ts | 28 +- .../src/app/settings/settings.component.ts | 21 +- apps/web/src/typings.d.ts | 41 + docs/architecture/embedded-inline-playback.md | 38 + docs/architecture/embedded-mpv-native.md | 192 ++ electron-builder.json | 6 +- global.d.ts | 41 + .../video-player/video-player.component.html | 7 + .../video-player.component.spec.ts | 17 + .../video-player/video-player.component.ts | 35 + .../unified-live-tab.component.html | 7 + .../unified-live-tab.component.ts | 3 +- .../stalker-live-stream-layout.component.html | 6 +- ...alker-live-stream-layout.component.spec.ts | 2 + .../live-stream-layout.component.html | 6 +- .../live-stream-layout.component.spec.ts | 2 + .../live-stream-layout.component.ts | 23 +- .../player-dialog.component.html | 2 + .../player-dialog/player-dialog.component.ts | 7 +- .../src/lib/settings-store.service.ts | 88 +- libs/shared/interfaces/src/index.ts | 1 + .../src/lib/embedded-mpv-session.interface.ts | 44 + .../shared/interfaces/src/lib/ipc-commands.ts | 11 + .../interfaces/src/lib/settings.interface.ts | 1 + libs/ui/playback/src/index.ts | 1 + .../embedded-mpv-player.component.html | 232 ++ .../embedded-mpv-player.component.scss | 292 +++ .../embedded-mpv-player.component.ts | 686 ++++++ .../portal-inline-player.component.html | 2 + .../web-player-view.component.html | 5 + .../web-player-view.component.scss | 1 + .../web-player-view.component.ts | 35 +- tools/embedded-mpv/README.md | 75 + tools/embedded-mpv/build-macos-runtime.mjs | 475 ++++ tools/embedded-mpv/stage-macos-runtime.mjs | 170 ++ tools/packaging/electron-after-pack.cjs | 65 + tools/packaging/embedded-mpv-macos.cjs | 465 ++++ .../verify-electron-package-layout.mjs | 20 + vendor/embedded-mpv/README.md | 10 + vendor/embedded-mpv/darwin-arm64/README.md | 9 + vendor/embedded-mpv/darwin-x64/README.md | 9 + 53 files changed, 6099 insertions(+), 49 deletions(-) create mode 100644 apps/electron-backend/build-embedded-mpv.js create mode 100644 apps/electron-backend/native/binding.gyp create mode 100644 apps/electron-backend/native/src/embedded_mpv.mm create mode 100644 apps/electron-backend/src/app/events/embedded-mpv.events.ts create mode 100644 apps/electron-backend/src/app/services/embedded-mpv-native.service.ts create mode 100644 docs/architecture/embedded-mpv-native.md create mode 100644 libs/shared/interfaces/src/lib/embedded-mpv-session.interface.ts create mode 100644 libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-player.component.html create mode 100644 libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-player.component.scss create mode 100644 libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-player.component.ts create mode 100644 tools/embedded-mpv/README.md create mode 100644 tools/embedded-mpv/build-macos-runtime.mjs create mode 100644 tools/embedded-mpv/stage-macos-runtime.mjs create mode 100644 tools/packaging/electron-after-pack.cjs create mode 100644 tools/packaging/embedded-mpv-macos.cjs create mode 100644 vendor/embedded-mpv/README.md create mode 100644 vendor/embedded-mpv/darwin-arm64/README.md create mode 100644 vendor/embedded-mpv/darwin-x64/README.md diff --git a/.github/workflows/build-and-make.yaml b/.github/workflows/build-and-make.yaml index 866637e94..f9f3d70d6 100644 --- a/.github/workflows/build-and-make.yaml +++ b/.github/workflows/build-and-make.yaml @@ -15,7 +15,7 @@ jobs: build: name: Build on ${{ matrix.os }} ${{ matrix.arch }} runs-on: ${{ matrix.runner }} - timeout-minutes: 60 + timeout-minutes: 120 strategy: matrix: include: @@ -88,7 +88,21 @@ jobs: - name: Build frontend run: pnpm nx build web --skip-nx-cache + - name: Build embedded MPV runtime (macOS release) + if: matrix.os == 'macos' && startsWith(github.ref, 'refs/tags/v') + shell: bash + run: | + set -euo pipefail + + brew install meson ninja pkg-config nasm autoconf automake libtool xz + + RUNTIME_PREFIX="${RUNNER_TEMP}/embedded-mpv-runtime/${{ matrix.arch }}/prefix" + pnpm embedded-mpv:build-runtime -- "${{ matrix.arch }}" "${RUNTIME_PREFIX}" + pnpm embedded-mpv:stage-runtime -- "${{ matrix.arch }}" "${RUNTIME_PREFIX}" + - name: Build backend + env: + IPTVNATOR_EMBEDDED_MPV_ARCH: ${{ matrix.arch || '' }} run: pnpm run build:backend - name: Validate AppStream metadata @@ -210,6 +224,8 @@ jobs: CSC_LINK: ${{ secrets.CSC_LINK }} CSC_KEY_PASSWORD: ${{ secrets.CSC_KEY_PASSWORD }} DEBUG: electron-builder,electron-notarize* + IPTVNATOR_EMBEDDED_MPV_ARCH: ${{ matrix.arch }} + IPTVNATOR_REQUIRE_EMBEDDED_MPV: ${{ startsWith(github.ref, 'refs/tags/v') && '1' || '0' }} run: pnpm run make:app - name: Verify signed macOS app @@ -263,6 +279,9 @@ jobs: - name: Make Electron app if: matrix.os != 'macos' || github.event_name == 'pull_request' + env: + IPTVNATOR_EMBEDDED_MPV_ARCH: ${{ matrix.arch || '' }} + IPTVNATOR_REQUIRE_EMBEDDED_MPV: '0' run: pnpm run make:app - name: Verify packaged worker layout @@ -270,6 +289,7 @@ jobs: env: PACKAGE_OS: ${{ matrix.os }} PACKAGE_ARCH: ${{ matrix.arch || '' }} + IPTVNATOR_REQUIRE_EMBEDDED_MPV: ${{ matrix.os == 'macos' && startsWith(github.ref, 'refs/tags/v') && '1' || '0' }} run: pnpm run verify:package-layout -- "$PACKAGE_OS" "$PACKAGE_ARCH" - name: Smoke test packaged Flatpak launcher diff --git a/.gitignore b/.gitignore index f0fa8c5e3..845bb4888 100644 --- a/.gitignore +++ b/.gitignore @@ -23,6 +23,9 @@ shared/*.js playwright-report .nx server.js +.github/skills +.github/prompts +.agents/skills # dependencies node_modules @@ -65,3 +68,16 @@ testem.log Thumbs.db .angular .astro + +.gemini +.cursor +.agent +.claude +.codex +.plans + +# Native Electron addons +apps/electron-backend/native/build/ +vendor/embedded-mpv/darwin-*/include/ +vendor/embedded-mpv/darwin-*/lib/ +vendor/embedded-mpv/darwin-*/runtime-manifest.json diff --git a/apps/electron-backend/build-embedded-mpv.js b/apps/electron-backend/build-embedded-mpv.js new file mode 100644 index 000000000..6358522a2 --- /dev/null +++ b/apps/electron-backend/build-embedded-mpv.js @@ -0,0 +1,228 @@ +const fs = require('fs'); +const path = require('path'); +const { spawnSync } = require('child_process'); +const { + copyRuntimeToNativeBuild, + findLibMpv, + patchAddonForBundledRuntime, + validateNoForbiddenRuntimeLinks, +} = require('../../tools/packaging/embedded-mpv-macos.cjs'); + +const workspaceRoot = process.cwd(); +const addonRoot = path.join(workspaceRoot, 'apps', 'electron-backend', 'native'); +const outputDir = path.join(addonRoot, 'build', 'Release'); +const outputFile = path.join(outputDir, 'embedded_mpv.node'); +const outputLibDir = path.join(outputDir, 'lib'); +const unavailableMarkerFile = path.join(outputDir, 'embedded-mpv-unavailable.txt'); +const homebrewIncludeDir = '/opt/homebrew/include'; +const homebrewLibDir = '/opt/homebrew/lib'; +const targetArch = + process.env.IPTVNATOR_EMBEDDED_MPV_ARCH || + process.env.npm_config_arch || + process.arch; +const vendoredRuntimeRoot = path.join( + workspaceRoot, + 'vendor', + 'embedded-mpv', + `darwin-${targetArch}` +); +const vendoredIncludeDir = path.join(vendoredRuntimeRoot, 'include'); +const vendoredLibDir = path.join(vendoredRuntimeRoot, 'lib'); +const homebrewFallbackEnabled = + process.env.IPTVNATOR_EMBEDDED_MPV_ALLOW_HOMEBREW === '1'; + +function log(message) { + process.stdout.write(`[embedded-mpv] ${message}\n`); +} + +function cleanOutput() { + fs.rmSync(outputFile, { force: true }); + fs.rmSync(outputLibDir, { recursive: true, force: true }); + fs.rmSync(path.join(outputDir, 'embedded-mpv-runtime.json'), { + force: true, + }); + fs.writeFileSync( + unavailableMarkerFile, + 'Embedded MPV native runtime is not available for this build.\n' + ); +} + +function readRuntimeManifest(runtimeRoot) { + const manifestPath = path.join(runtimeRoot, 'runtime-manifest.json'); + if (!fs.existsSync(manifestPath)) { + return {}; + } + + return JSON.parse(fs.readFileSync(manifestPath, 'utf8')); +} + +function resolveRuntime() { + const vendoredLibMpv = findLibMpv(vendoredLibDir); + const vendoredHeader = path.join(vendoredIncludeDir, 'mpv', 'client.h'); + + if (vendoredLibMpv && fs.existsSync(vendoredHeader)) { + return { + origin: 'vendored-lgpl', + includeDir: vendoredIncludeDir, + libDir: vendoredLibDir, + manifest: readRuntimeManifest(vendoredRuntimeRoot), + }; + } + + if (homebrewFallbackEnabled) { + const homebrewLibMpv = findLibMpv(homebrewLibDir); + const homebrewHeader = path.join(homebrewIncludeDir, 'mpv', 'client.h'); + if (homebrewLibMpv && fs.existsSync(homebrewHeader)) { + log( + 'Using Homebrew libmpv as a development-only fallback. Release packaging will reject this runtime.' + ); + return { + origin: 'homebrew-dev', + includeDir: homebrewIncludeDir, + libDir: homebrewLibDir, + manifest: { + warning: + 'Development-only runtime. Do not ship this in release artifacts.', + }, + }; + } + } + + return null; +} + +function resolveElectronNodeGypBin() { + const pnpmRoot = path.join(workspaceRoot, 'node_modules', '.pnpm'); + + if (!fs.existsSync(pnpmRoot)) { + throw new Error('Unable to find node_modules/.pnpm.'); + } + + const packageDirs = fs + .readdirSync(pnpmRoot, { withFileTypes: true }) + .filter( + (entry) => + entry.isDirectory() && + entry.name.startsWith('@electron+node-gyp@') + ) + .map((entry) => entry.name) + .sort(); + + for (const packageDir of packageDirs) { + const candidate = path.join( + pnpmRoot, + packageDir, + 'node_modules', + '@electron', + 'node-gyp', + 'bin', + 'node-gyp.js' + ); + + if (fs.existsSync(candidate)) { + return candidate; + } + } + + throw new Error('Unable to resolve @electron/node-gyp.'); +} + +function runNodeGyp(command, env) { + const nodeGypBin = resolveElectronNodeGypBin(); + const result = spawnSync( + process.execPath, + [nodeGypBin, command, '--directory', addonRoot], + { + cwd: workspaceRoot, + env, + stdio: 'inherit', + } + ); + + if (result.status !== 0) { + throw new Error(`node-gyp ${command} failed with status ${result.status ?? 1}.`); + } +} + +function main() { + fs.mkdirSync(outputDir, { recursive: true }); + + if (process.platform !== 'darwin') { + cleanOutput(); + log('Skipping build on non-macOS host.'); + return; + } + + const runtime = resolveRuntime(); + if (!runtime) { + cleanOutput(); + log( + [ + `Skipping build because no embedded MPV runtime was found for darwin-${targetArch}.`, + `Expected vendored runtime at ${vendoredRuntimeRoot}.`, + 'For local development only, set IPTVNATOR_EMBEDDED_MPV_ALLOW_HOMEBREW=1 to use Homebrew libmpv.', + ].join('\n') + ); + return; + } + + const runtimeManifest = copyRuntimeToNativeBuild({ + runtimeLibDir: runtime.libDir, + outputLibDir, + runtimeOrigin: runtime.origin, + runtimeManifest: { + targetArch, + ...runtime.manifest, + }, + }); + fs.rmSync(unavailableMarkerFile, { force: true }); + + const electronPackageJson = require(path.join( + workspaceRoot, + 'node_modules', + 'electron', + 'package.json' + )); + const electronVersion = electronPackageJson.version; + const env = { + ...process.env, + npm_config_runtime: 'electron', + npm_config_target: electronVersion, + npm_config_arch: targetArch, + npm_config_disturl: 'https://electronjs.org/headers', + npm_config_build_from_source: 'true', + npm_config_update_binary: 'false', + LIBMPV_INCLUDE_DIR: runtime.includeDir, + LIBMPV_LIBRARY_DIR: outputLibDir, + }; + + log( + `Building native addon against Electron ${electronVersion} using ${runtime.origin} runtime for darwin-${targetArch}...` + ); + runNodeGyp('configure', env); + runNodeGyp('build', env); + + if (!fs.existsSync(outputFile)) { + throw new Error(`Build finished without producing ${outputFile}.`); + } + + patchAddonForBundledRuntime(outputFile, outputLibDir); + const forbiddenLinkErrors = validateNoForbiddenRuntimeLinks([ + outputFile, + ...runtimeManifest.dylibs.map((dylib) => path.join(outputLibDir, dylib)), + ]); + if (runtime.origin === 'vendored-lgpl' && forbiddenLinkErrors.length > 0) { + throw new Error(forbiddenLinkErrors.join('\n')); + } + + log(`Built ${path.relative(workspaceRoot, outputFile)}.`); +} + +try { + main(); +} catch (error) { + process.stderr.write( + `[embedded-mpv] ${error instanceof Error ? error.message : String(error)}\n` + ); + process.exit(1); +} diff --git a/apps/electron-backend/native/binding.gyp b/apps/electron-backend/native/binding.gyp new file mode 100644 index 000000000..a829bc56a --- /dev/null +++ b/apps/electron-backend/native/binding.gyp @@ -0,0 +1,37 @@ +{ + "targets": [ + { + "target_name": "embedded_mpv", + "sources": [ + "src/embedded_mpv.mm" + ], + "include_dirs": [ + "<(module_root_dir)/../../../node_modules/.pnpm/node_modules/node-addon-api", + " + +#define GL_SILENCE_DEPRECATION + +#import +#import +#import +#import +#import +#import + +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +@interface EmbeddedMpvContainerView : NSView +@end + +@implementation EmbeddedMpvContainerView +- (BOOL)isFlipped +{ + return YES; +} + +- (NSView*)hitTest:(NSPoint)point +{ + return nil; +} +@end + +@interface EmbeddedMpvOpenGLView : NSOpenGLView +@end + +@implementation EmbeddedMpvOpenGLView +- (BOOL)isFlipped +{ + return YES; +} + +- (NSView*)hitTest:(NSPoint)point +{ + return nil; +} +@end + +namespace { + +enum class SessionStatus { + Idle, + Loading, + Playing, + Paused, + Error, + Closed, +}; + +enum class RenderBackend { + OpenGL, + Software, +}; + +struct AudioTrack { + int64_t id = 0; + std::string title; + std::string language; + bool selected = false; + bool defaultTrack = false; + bool forced = false; +}; + +struct SessionSnapshot { + SessionStatus status = SessionStatus::Idle; + double positionSeconds = 0.0; + double durationSeconds = -1.0; + double volumePercent = 100.0; + std::string streamUrl; + std::string error; + std::vector audioTracks; + int64_t selectedAudioTrackId = -1; +}; + +struct Session { + std::string id; + mpv_handle* handle = nullptr; + mpv_render_context* renderContext = nullptr; + NSView* __strong parentView = nil; + NSView* __strong hostView = nil; + NSView* __strong containerView = nil; + NSOpenGLPixelFormat* __strong openGLPixelFormat = nil; + NSOpenGLContext* __strong openGLContext = nil; + CVDisplayLinkRef displayLink = nullptr; + dispatch_queue_t renderQueue = nullptr; + std::thread eventThread; + std::atomic running{false}; + std::atomic renderScheduled{false}; + std::atomic renderNeeded{false}; + std::atomic renderedFrameCount{0}; + std::atomic skippedFrameCount{0}; + std::atomic totalRenderNanoseconds{0}; + std::mutex mutex; + SessionSnapshot snapshot; + std::weak_ptr* renderCallbackContext = nullptr; + RenderBackend renderBackend = RenderBackend::OpenGL; + int renderWidthPixels = 0; + int renderHeightPixels = 0; + double renderContentsScale = 1.0; + bool paused = false; + bool loadedPath = false; +}; + +std::atomic gNextSessionId{1}; +std::atomic gNextAsyncRequestId{1}; +std::mutex gSessionsMutex; +std::unordered_map> gSessions; + +std::string toStatusString(SessionStatus status) +{ + switch (status) { + case SessionStatus::Idle: + return "idle"; + case SessionStatus::Loading: + return "loading"; + case SessionStatus::Playing: + return "playing"; + case SessionStatus::Paused: + return "paused"; + case SessionStatus::Error: + return "error"; + case SessionStatus::Closed: + return "closed"; + } + + return "idle"; +} + +void runOnMainSync(dispatch_block_t block) +{ + if ([NSThread isMainThread]) { + block(); + return; + } + + dispatch_sync(dispatch_get_main_queue(), block); +} + +std::shared_ptr getSessionOrThrow(const std::string& sessionId) +{ + std::lock_guard sessionsLock(gSessionsMutex); + const auto iterator = gSessions.find(sessionId); + + if (iterator == gSessions.end()) { + throw std::runtime_error("Embedded MPV session not found."); + } + + return iterator->second; +} + +void scheduleRender(const std::shared_ptr& session); +void requestRender(const std::shared_ptr& session); +void updateSessionError(const std::shared_ptr& session, const std::string& error); + +bool isEmbeddedMpvTraceEnabled() +{ + const char* value = std::getenv("IPTVNATOR_TRACE_EMBEDDED_MPV"); + return value && value[0] != '\0' && std::string(value) != "0"; +} + +std::string normalizeEnvValue(const char* value) +{ + if (!value) { + return ""; + } + + std::string result(value); + std::transform( + result.begin(), + result.end(), + result.begin(), + [](unsigned char character) { + return static_cast(std::tolower(character)); + } + ); + return result; +} + +RenderBackend requestedRenderBackend() +{ + const std::string renderer = + normalizeEnvValue(std::getenv("IPTVNATOR_EMBEDDED_MPV_RENDERER")); + return renderer == "sw" + ? RenderBackend::Software + : RenderBackend::OpenGL; +} + +const char* renderBackendName(RenderBackend backend) +{ + return backend == RenderBackend::OpenGL ? "gl" : "sw"; +} + +void traceEmbeddedMpv(const std::shared_ptr& session, const std::string& message) +{ + if (!isEmbeddedMpvTraceEnabled()) { + return; + } + + fprintf( + stderr, + "[embedded-mpv][%s][trace] %s\n", + session ? session->id.c_str() : "unknown", + message.c_str() + ); +} + +void recordRenderedFrame( + const std::shared_ptr& session, + std::chrono::steady_clock::duration renderDuration +) +{ + if (!session) { + return; + } + + const uint64_t durationNanoseconds = + static_cast( + std::chrono::duration_cast( + renderDuration + ).count() + ); + const uint64_t frameCount = + session->renderedFrameCount.fetch_add(1) + 1; + const uint64_t totalNanoseconds = + session->totalRenderNanoseconds.fetch_add(durationNanoseconds) + + durationNanoseconds; + + if (!isEmbeddedMpvTraceEnabled() || frameCount % 120 != 0) { + return; + } + + const double averageMilliseconds = + static_cast(totalNanoseconds) / + static_cast(frameCount) / + 1000000.0; + fprintf( + stderr, + "[embedded-mpv][%s][trace] renderer=%s frames=%llu avgRenderMs=%.2f skipped=%llu\n", + session->id.c_str(), + renderBackendName(session->renderBackend), + static_cast(frameCount), + averageMilliseconds, + static_cast(session->skippedFrameCount.load()) + ); +} + +void* getOpenGLProcAddress(void*, const char* name) +{ + if (!name) { + return nullptr; + } + + void* symbol = dlsym(RTLD_DEFAULT, name); + if (symbol) { + return symbol; + } + + CFBundleRef bundle = + CFBundleGetBundleWithIdentifier(CFSTR("com.apple.opengl")); + if (!bundle) { + return nullptr; + } + + CFStringRef symbolName = CFStringCreateWithCString( + kCFAllocatorDefault, + name, + kCFStringEncodingASCII + ); + if (!symbolName) { + return nullptr; + } + + symbol = CFBundleGetFunctionPointerForName(bundle, symbolName); + CFRelease(symbolName); + return symbol; +} + +void updateRenderSurfaceMetrics(const std::shared_ptr& session) +{ + runOnMainSync(^{ + if (!session->containerView) { + return; + } + + const NSSize backingSize = + [session->containerView convertSizeToBacking:session->containerView.bounds.size]; + const NSInteger widthPixels = std::max( + 0, + static_cast(std::lround(backingSize.width)) + ); + const NSInteger heightPixels = std::max( + 0, + static_cast(std::lround(backingSize.height)) + ); + const double contentsScale = + session->containerView.window + ? session->containerView.window.backingScaleFactor + : NSScreen.mainScreen.backingScaleFactor; + + { + std::lock_guard lock(session->mutex); + session->renderWidthPixels = static_cast(widthPixels); + session->renderHeightPixels = static_cast(heightPixels); + session->renderContentsScale = contentsScale > 0 + ? contentsScale + : 1.0; + } + + if (session->containerView.layer) { + session->containerView.layer.contentsScale = contentsScale; + } + }); +} + +void setSessionFrame(const std::shared_ptr& session, double x, double y, double width, double height) +{ + runOnMainSync(^{ + if (!session->parentView || !session->hostView || !session->containerView) { + return; + } + + NSRect parentBounds = [session->parentView bounds]; + const CGFloat frameWidth = static_cast(std::max(width, 0.0)); + const CGFloat frameHeight = static_cast(std::max(height, 0.0)); + const CGFloat originX = static_cast(x); + const CGFloat originY = [session->parentView isFlipped] + ? static_cast(y) + : NSMaxY(parentBounds) - static_cast(y) - frameHeight; + NSRect frameRect = NSMakeRect(originX, originY, frameWidth, frameHeight); + + if (session->hostView != session->parentView) { + frameRect = [session->hostView convertRect:frameRect + fromView:session->parentView]; + } + + [session->containerView setFrame:frameRect]; + if (session->openGLContext) { + [session->openGLContext update]; + } else { + [session->containerView setNeedsDisplay:YES]; + } + }); + + updateRenderSurfaceMetrics(session); + requestRender(session); +} + +void destroySession(const std::shared_ptr& session) +{ + if (!session) { + return; + } + + session->running.store(false); + session->renderNeeded.store(false); + + if (session->displayLink) { + CVDisplayLinkStop(session->displayLink); + } + + if (session->handle) { + mpv_wakeup(session->handle); + } + + if (session->eventThread.joinable()) { + session->eventThread.join(); + } + + if (session->renderContext) { + auto freeRenderContext = ^{ + if (!session->renderContext) { + return; + } + + if (session->renderBackend == RenderBackend::OpenGL && + session->openGLContext) { + CGLContextObj cglContext = + [session->openGLContext CGLContextObj]; + if (cglContext) { + CGLLockContext(cglContext); + [session->openGLContext makeCurrentContext]; + } + mpv_render_context_set_update_callback( + session->renderContext, + nullptr, + nullptr + ); + mpv_render_context_free(session->renderContext); + session->renderContext = nullptr; + [NSOpenGLContext clearCurrentContext]; + if (cglContext) { + CGLUnlockContext(cglContext); + } + return; + } + + mpv_render_context_set_update_callback( + session->renderContext, + nullptr, + nullptr + ); + mpv_render_context_free(session->renderContext); + session->renderContext = nullptr; + }; + + if (session->renderQueue) { + dispatch_sync(session->renderQueue, freeRenderContext); + } else { + freeRenderContext(); + } + } + + if (session->renderCallbackContext) { + delete session->renderCallbackContext; + session->renderCallbackContext = nullptr; + } + + if (session->displayLink) { + CVDisplayLinkRelease(session->displayLink); + session->displayLink = nullptr; + } + + if (session->handle) { + mpv_terminate_destroy(session->handle); + session->handle = nullptr; + } + + { + std::lock_guard lock(session->mutex); + session->snapshot.status = SessionStatus::Closed; + } + + runOnMainSync(^{ + if (session->containerView) { + [session->containerView removeFromSuperview]; + session->containerView = nil; + } + session->openGLContext = nil; + session->openGLPixelFormat = nil; + session->parentView = nil; + session->hostView = nil; + }); +} + +void renderSoftwareFrame(const std::shared_ptr& session) +{ + if (!session || !session->renderContext) { + return; + } + + const auto startedAt = std::chrono::steady_clock::now(); + int widthPixels = 0; + int heightPixels = 0; + double contentsScale = 1.0; + { + std::lock_guard lock(session->mutex); + widthPixels = session->renderWidthPixels; + heightPixels = session->renderHeightPixels; + contentsScale = session->renderContentsScale; + } + + if (widthPixels <= 0 || heightPixels <= 0) { + return; + } + + const size_t stride = ((static_cast(widthPixels) * 4) + 63) & ~static_cast(63); + std::vector frameBytes(stride * static_cast(heightPixels)); + int size[] = { widthPixels, heightPixels }; + size_t strideValue = stride; + char format[] = "bgr0"; + mpv_render_param params[] = { + { MPV_RENDER_PARAM_SW_SIZE, size }, + { MPV_RENDER_PARAM_SW_FORMAT, format }, + { MPV_RENDER_PARAM_SW_STRIDE, &strideValue }, + { MPV_RENDER_PARAM_SW_POINTER, frameBytes.data() }, + { MPV_RENDER_PARAM_INVALID, nullptr }, + }; + + const int result = mpv_render_context_render(session->renderContext, params); + if (result < 0) { + updateSessionError( + session, + std::string("Failed to render frame: ") + mpv_error_string(result) + ); + return; + } + + CFDataRef frameData = CFDataCreate( + kCFAllocatorDefault, + frameBytes.data(), + static_cast(frameBytes.size()) + ); + if (!frameData) { + return; + } + + CGDataProviderRef provider = CGDataProviderCreateWithCFData(frameData); + CGColorSpaceRef colorSpace = CGColorSpaceCreateDeviceRGB(); + CGBitmapInfo bitmapInfo = + static_cast(kCGBitmapByteOrder32Little | + kCGImageAlphaNoneSkipFirst); + CGImageRef image = CGImageCreate( + static_cast(widthPixels), + static_cast(heightPixels), + 8, + 32, + strideValue, + colorSpace, + bitmapInfo, + provider, + nullptr, + false, + kCGRenderingIntentDefault + ); + + CGColorSpaceRelease(colorSpace); + CGDataProviderRelease(provider); + CFRelease(frameData); + + if (!image) { + return; + } + + id frameImage = CFBridgingRelease(image); + dispatch_async(dispatch_get_main_queue(), ^{ + if (!session->containerView || !session->containerView.layer) { + return; + } + + session->containerView.layer.contents = frameImage; + session->containerView.layer.contentsScale = contentsScale; + session->containerView.layer.contentsGravity = kCAGravityResize; + }); + + recordRenderedFrame(session, std::chrono::steady_clock::now() - startedAt); +} + +void renderOpenGLFrame(const std::shared_ptr& session) +{ + if (!session || !session->renderContext || !session->openGLContext) { + return; + } + + int widthPixels = 0; + int heightPixels = 0; + { + std::lock_guard lock(session->mutex); + widthPixels = session->renderWidthPixels; + heightPixels = session->renderHeightPixels; + } + + if (widthPixels <= 0 || heightPixels <= 0) { + session->skippedFrameCount.fetch_add(1); + return; + } + + const auto startedAt = std::chrono::steady_clock::now(); + CGLContextObj cglContext = [session->openGLContext CGLContextObj]; + if (!cglContext) { + session->skippedFrameCount.fetch_add(1); + return; + } + + CGLLockContext(cglContext); + [session->openGLContext makeCurrentContext]; + + const uint64_t updateFlags = + mpv_render_context_update(session->renderContext); + if ((updateFlags & MPV_RENDER_UPDATE_FRAME) == 0) { + CGLUnlockContext(cglContext); + session->skippedFrameCount.fetch_add(1); + return; + } + + glViewport(0, 0, widthPixels, heightPixels); + glClearColor(0.0F, 0.0F, 0.0F, 1.0F); + glClear(GL_COLOR_BUFFER_BIT); + + mpv_opengl_fbo fbo = { + 0, + widthPixels, + heightPixels, + GL_RGBA8, + }; + int flipY = 1; + int blockForTargetTime = 0; + mpv_render_param params[] = { + { MPV_RENDER_PARAM_OPENGL_FBO, &fbo }, + { MPV_RENDER_PARAM_FLIP_Y, &flipY }, + { MPV_RENDER_PARAM_BLOCK_FOR_TARGET_TIME, &blockForTargetTime }, + { MPV_RENDER_PARAM_INVALID, nullptr }, + }; + + const int result = mpv_render_context_render( + session->renderContext, + params + ); + if (result < 0) { + CGLUnlockContext(cglContext); + updateSessionError( + session, + std::string("Failed to render OpenGL frame: ") + + mpv_error_string(result) + ); + return; + } + + [session->openGLContext flushBuffer]; + mpv_render_context_report_swap(session->renderContext); + CGLUnlockContext(cglContext); + + recordRenderedFrame(session, std::chrono::steady_clock::now() - startedAt); +} + +void renderSessionFrame(const std::shared_ptr& session) +{ + if (!session) { + return; + } + + if (session->renderBackend == RenderBackend::OpenGL) { + renderOpenGLFrame(session); + return; + } + + renderSoftwareFrame(session); +} + +void scheduleRender(const std::shared_ptr& session) +{ + if (!session || !session->renderQueue || !session->renderContext) { + return; + } + + if (session->renderScheduled.exchange(true)) { + return; + } + + auto sessionCopy = session; + dispatch_async(session->renderQueue, ^{ + renderSessionFrame(sessionCopy); + sessionCopy->renderScheduled.store(false); + }); +} + +void requestRender(const std::shared_ptr& session) +{ + if (!session) { + return; + } + + if (session->renderBackend == RenderBackend::OpenGL) { + session->renderNeeded.store(true); + return; + } + + scheduleRender(session); +} + +CVReturn displayLinkCallback( + CVDisplayLinkRef, + const CVTimeStamp*, + const CVTimeStamp*, + CVOptionFlags, + CVOptionFlags*, + void* context +) +{ + const auto* weakSession = + static_cast*>(context); + if (!weakSession) { + return kCVReturnSuccess; + } + + if (const auto session = weakSession->lock()) { + if (session->running.load() && + session->renderBackend == RenderBackend::OpenGL && + session->renderNeeded.exchange(false)) { + scheduleRender(session); + } + } + + return kCVReturnSuccess; +} + +void onRenderContextUpdate(void* context) +{ + const auto* weakSession = + static_cast*>(context); + if (!weakSession) { + return; + } + + if (const auto session = weakSession->lock()) { + requestRender(session); + } +} + +std::string joinHeaderFields(const Napi::Object& headers) +{ + const Napi::Array propertyNames = headers.GetPropertyNames(); + std::vector fields; + fields.reserve(propertyNames.Length()); + + for (uint32_t index = 0; index < propertyNames.Length(); index += 1) { + const Napi::Value propertyName = propertyNames.Get(index); + if (!propertyName.IsString()) { + continue; + } + + const std::string key = propertyName.As().Utf8Value(); + const Napi::Value propertyValue = headers.Get(propertyName); + if (!propertyValue.IsString() && !propertyValue.IsNumber()) { + continue; + } + + std::string value = propertyValue.ToString().Utf8Value(); + if (key.empty() || value.empty()) { + continue; + } + + fields.push_back(key + ": " + value); + } + + std::ostringstream stream; + for (size_t index = 0; index < fields.size(); index += 1) { + if (index > 0) { + stream << ","; + } + stream << fields[index]; + } + + return stream.str(); +} + +std::string readOptionalString(const Napi::Object& object, const char* key) +{ + if (!object.Has(key)) { + return ""; + } + + const Napi::Value value = object.Get(key); + if (value.IsUndefined() || value.IsNull()) { + return ""; + } + + return value.ToString().Utf8Value(); +} + +double readOptionalNumber(const Napi::Object& object, const char* key, double fallbackValue) +{ + if (!object.Has(key)) { + return fallbackValue; + } + + const Napi::Value value = object.Get(key); + if (!value.IsNumber()) { + return fallbackValue; + } + + return value.As().DoubleValue(); +} + +bool parseIntegerString(const std::string& value, int64_t& result) +{ + if (value.empty()) { + return false; + } + + char* end = nullptr; + const long long parsed = std::strtoll(value.c_str(), &end, 10); + if (!end || *end != '\0') { + return false; + } + + result = static_cast(parsed); + return true; +} + +const mpv_node* getNodeMapValue(const mpv_node& node, const char* key) +{ + if (node.format != MPV_FORMAT_NODE_MAP || + !node.u.list || + !node.u.list->keys || + !node.u.list->values) { + return nullptr; + } + + for (int index = 0; index < node.u.list->num; index += 1) { + const char* candidateKey = node.u.list->keys[index]; + if (candidateKey && std::strcmp(candidateKey, key) == 0) { + return &node.u.list->values[index]; + } + } + + return nullptr; +} + +std::string readNodeString(const mpv_node& node) +{ + switch (node.format) { + case MPV_FORMAT_STRING: + return node.u.string ? node.u.string : ""; + case MPV_FORMAT_INT64: + return std::to_string(node.u.int64); + case MPV_FORMAT_DOUBLE: { + std::ostringstream stream; + stream << node.u.double_; + return stream.str(); + } + default: + return ""; + } +} + +bool readNodeFlag(const mpv_node& node) +{ + if (node.format == MPV_FORMAT_FLAG) { + return node.u.flag != 0; + } + + if (node.format == MPV_FORMAT_STRING && node.u.string) { + const std::string value = normalizeEnvValue(node.u.string); + return value == "yes" || value == "true" || value == "1"; + } + + return false; +} + +bool readNodeInteger(const mpv_node& node, int64_t& result) +{ + if (node.format == MPV_FORMAT_INT64) { + result = node.u.int64; + return true; + } + + if (node.format == MPV_FORMAT_DOUBLE) { + result = static_cast(node.u.double_); + return true; + } + + if (node.format == MPV_FORMAT_STRING && node.u.string) { + return parseIntegerString(node.u.string, result); + } + + return false; +} + +void updateSelectedAudioTrack(SessionSnapshot& snapshot, int64_t selectedTrackId) +{ + snapshot.selectedAudioTrackId = selectedTrackId >= 0 ? selectedTrackId : -1; + for (auto& track : snapshot.audioTracks) { + track.selected = track.id == snapshot.selectedAudioTrackId; + } +} + +void updateAudioTracksFromNode(SessionSnapshot& snapshot, const mpv_node& node) +{ + if (node.format != MPV_FORMAT_NODE_ARRAY || + !node.u.list || + !node.u.list->values) { + return; + } + + std::vector audioTracks; + int64_t selectedTrackId = -1; + + for (int index = 0; index < node.u.list->num; index += 1) { + const mpv_node& trackNode = node.u.list->values[index]; + if (trackNode.format != MPV_FORMAT_NODE_MAP) { + continue; + } + + const mpv_node* typeNode = getNodeMapValue(trackNode, "type"); + if (!typeNode || readNodeString(*typeNode) != "audio") { + continue; + } + + const mpv_node* idNode = getNodeMapValue(trackNode, "id"); + int64_t id = -1; + if (!idNode || !readNodeInteger(*idNode, id) || id < 0) { + continue; + } + + AudioTrack audioTrack; + audioTrack.id = id; + if (const mpv_node* titleNode = getNodeMapValue(trackNode, "title")) { + audioTrack.title = readNodeString(*titleNode); + } + if (const mpv_node* languageNode = getNodeMapValue(trackNode, "lang")) { + audioTrack.language = readNodeString(*languageNode); + } + if (const mpv_node* selectedNode = getNodeMapValue(trackNode, "selected")) { + audioTrack.selected = readNodeFlag(*selectedNode); + } + if (const mpv_node* defaultNode = getNodeMapValue(trackNode, "default")) { + audioTrack.defaultTrack = readNodeFlag(*defaultNode); + } + if (const mpv_node* forcedNode = getNodeMapValue(trackNode, "forced")) { + audioTrack.forced = readNodeFlag(*forcedNode); + } + + if (audioTrack.selected) { + selectedTrackId = audioTrack.id; + } + audioTracks.push_back(audioTrack); + } + + snapshot.audioTracks = std::move(audioTracks); + snapshot.selectedAudioTrackId = selectedTrackId; +} + +void updateSessionError(const std::shared_ptr& session, const std::string& error) +{ + std::lock_guard lock(session->mutex); + session->snapshot.status = SessionStatus::Error; + session->snapshot.error = error; +} + +uint64_t nextAsyncRequestId() +{ + return gNextAsyncRequestId.fetch_add(1); +} + +double clampVolumePercent(double volume) +{ + return std::clamp(volume, 0.0, 1.0) * 100.0; +} + +void runEventLoop(const std::shared_ptr& session) +{ + while (session->running.load()) { + mpv_event* event = mpv_wait_event(session->handle, 0.1); + if (!event || event->event_id == MPV_EVENT_NONE) { + continue; + } + + std::lock_guard lock(session->mutex); + + switch (event->event_id) { + case MPV_EVENT_START_FILE: + session->snapshot.status = SessionStatus::Loading; + session->snapshot.error.clear(); + session->snapshot.audioTracks.clear(); + session->snapshot.selectedAudioTrackId = -1; + session->loadedPath = false; + break; + case MPV_EVENT_FILE_LOADED: + session->loadedPath = true; + session->snapshot.status = session->paused + ? SessionStatus::Paused + : SessionStatus::Playing; + break; + case MPV_EVENT_END_FILE: { + const auto* endFile = + static_cast(event->data); + if (endFile && endFile->reason == MPV_END_FILE_REASON_ERROR) { + session->snapshot.status = SessionStatus::Error; + session->snapshot.error = + endFile->error < 0 + ? mpv_error_string(endFile->error) + : "Playback failed."; + } else if (session->running.load()) { + session->snapshot.status = SessionStatus::Idle; + } + break; + } + case MPV_EVENT_PROPERTY_CHANGE: { + const auto* property = + static_cast(event->data); + if (!property || !property->name) { + break; + } + + const std::string propertyName = property->name; + + if (propertyName == "time-pos" && + property->format == MPV_FORMAT_DOUBLE && + property->data) { + session->snapshot.positionSeconds = + *static_cast(property->data); + break; + } + + if (propertyName == "duration" && + property->format == MPV_FORMAT_DOUBLE && + property->data) { + session->snapshot.durationSeconds = + *static_cast(property->data); + break; + } + + if (propertyName == "pause" && + property->format == MPV_FORMAT_FLAG && + property->data) { + session->paused = *static_cast(property->data) != 0; + if (session->snapshot.status != SessionStatus::Loading && + session->snapshot.status != SessionStatus::Error && + session->loadedPath) { + session->snapshot.status = session->paused + ? SessionStatus::Paused + : SessionStatus::Playing; + } + break; + } + + if (propertyName == "volume" && + property->format == MPV_FORMAT_DOUBLE && + property->data) { + session->snapshot.volumePercent = + *static_cast(property->data); + break; + } + + if (propertyName == "path" && + property->format == MPV_FORMAT_STRING && + property->data) { + const auto* pathValue = + static_cast(property->data); + session->snapshot.streamUrl = pathValue ? pathValue : ""; + break; + } + + if (propertyName == "track-list" && + property->format == MPV_FORMAT_NODE && + property->data) { + updateAudioTracksFromNode( + session->snapshot, + *static_cast(property->data) + ); + break; + } + + if (propertyName == "aid" && + property->format == MPV_FORMAT_STRING && + property->data) { + const auto* aidValue = static_cast(property->data); + int64_t selectedTrackId = -1; + if (aidValue && parseIntegerString(aidValue, selectedTrackId)) { + updateSelectedAudioTrack( + session->snapshot, + selectedTrackId + ); + } + break; + } + + break; + } + case MPV_EVENT_LOG_MESSAGE: { + const auto* logMessage = + static_cast(event->data); + if (!logMessage || !logMessage->level || !logMessage->text) { + break; + } + + const std::string level = logMessage->level; + const char* prefix = + logMessage->prefix ? logMessage->prefix : "mpv"; + if (level == "warn" || level == "error" || level == "fatal") { + fprintf( + stderr, + "[embedded-mpv][%s][%s] %s: %s", + session->id.c_str(), + level.c_str(), + prefix, + logMessage->text + ); + } + if (level == "error" || level == "fatal") { + session->snapshot.error = logMessage->text; + } + if (level == "fatal") { + session->snapshot.status = SessionStatus::Error; + } + break; + } + case MPV_EVENT_COMMAND_REPLY: + case MPV_EVENT_SET_PROPERTY_REPLY: + if (event->error < 0) { + session->snapshot.status = SessionStatus::Error; + session->snapshot.error = mpv_error_string(event->error); + } + break; + case MPV_EVENT_SHUTDOWN: + session->running.store(false); + session->snapshot.status = SessionStatus::Closed; + break; + default: + break; + } + } +} + +bool createSoftwareView(const std::shared_ptr& session) +{ + __block bool created = false; + runOnMainSync(^{ + session->containerView = + [[EmbeddedMpvContainerView alloc] initWithFrame:NSMakeRect(0, 0, 0, 0)]; + session->containerView.wantsLayer = YES; + session->containerView.layer = [CALayer layer]; + session->containerView.layer.backgroundColor = + NSColor.blackColor.CGColor; + session->containerView.layer.contentsGravity = kCAGravityResize; + [session->hostView addSubview:session->containerView + positioned:NSWindowAbove + relativeTo:nil]; + created = true; + }); + return created; +} + +bool createOpenGLView(const std::shared_ptr& session) +{ + __block bool created = false; + runOnMainSync(^{ + NSOpenGLPixelFormatAttribute attributes[] = { + NSOpenGLPFAOpenGLProfile, + NSOpenGLProfileVersion3_2Core, + NSOpenGLPFAAccelerated, + NSOpenGLPFADoubleBuffer, + NSOpenGLPFAColorSize, + 24, + NSOpenGLPFAAlphaSize, + 8, + 0, + }; + + session->openGLPixelFormat = + [[NSOpenGLPixelFormat alloc] initWithAttributes:attributes]; + if (!session->openGLPixelFormat) { + return; + } + + auto* openGLView = + [[EmbeddedMpvOpenGLView alloc] initWithFrame:NSMakeRect(0, 0, 0, 0) + pixelFormat:session->openGLPixelFormat]; + if (!openGLView) { + session->openGLPixelFormat = nil; + return; + } + + [openGLView setWantsBestResolutionOpenGLSurface:YES]; + session->openGLContext = [openGLView openGLContext]; + if (!session->openGLContext) { + session->openGLPixelFormat = nil; + return; + } + + GLint swapInterval = 1; + [session->openGLContext setValues:&swapInterval + forParameter:NSOpenGLContextParameterSwapInterval]; + [session->openGLContext setView:openGLView]; + session->containerView = openGLView; + [session->hostView addSubview:session->containerView + positioned:NSWindowAbove + relativeTo:nil]; + created = true; + }); + return created; +} + +void removeRenderView(const std::shared_ptr& session) +{ + runOnMainSync(^{ + if (session->containerView) { + [session->containerView removeFromSuperview]; + session->containerView = nil; + } + session->openGLContext = nil; + session->openGLPixelFormat = nil; + }); +} + +int createSoftwareRenderContext(const std::shared_ptr& session) +{ + mpv_render_param renderParams[] = { + { MPV_RENDER_PARAM_API_TYPE, (void*)MPV_RENDER_API_TYPE_SW }, + { MPV_RENDER_PARAM_INVALID, nullptr }, + }; + return mpv_render_context_create( + &session->renderContext, + session->handle, + renderParams + ); +} + +int createOpenGLRenderContext(const std::shared_ptr& session) +{ + if (!session->openGLContext || !session->renderQueue) { + return MPV_ERROR_UNSUPPORTED; + } + + __block int renderResult = MPV_ERROR_UNSUPPORTED; + dispatch_sync(session->renderQueue, ^{ + CGLContextObj cglContext = [session->openGLContext CGLContextObj]; + if (!cglContext) { + renderResult = MPV_ERROR_UNSUPPORTED; + return; + } + + CGLLockContext(cglContext); + [session->openGLContext makeCurrentContext]; + + mpv_opengl_init_params openGLInitParams = { + getOpenGLProcAddress, + nullptr, + }; + mpv_render_param renderParams[] = { + { MPV_RENDER_PARAM_API_TYPE, (void*)MPV_RENDER_API_TYPE_OPENGL }, + { MPV_RENDER_PARAM_OPENGL_INIT_PARAMS, &openGLInitParams }, + { MPV_RENDER_PARAM_INVALID, nullptr }, + }; + renderResult = mpv_render_context_create( + &session->renderContext, + session->handle, + renderParams + ); + + [NSOpenGLContext clearCurrentContext]; + CGLUnlockContext(cglContext); + }); + return renderResult; +} + +bool startDisplayLink(const std::shared_ptr& session) +{ + if (!session->openGLContext || + !session->openGLPixelFormat || + !session->renderCallbackContext) { + return false; + } + + CVDisplayLinkRef displayLink = nullptr; + CVReturn result = CVDisplayLinkCreateWithActiveCGDisplays(&displayLink); + if (result != kCVReturnSuccess || !displayLink) { + return false; + } + + result = CVDisplayLinkSetCurrentCGDisplayFromOpenGLContext( + displayLink, + [session->openGLContext CGLContextObj], + [session->openGLPixelFormat CGLPixelFormatObj] + ); + if (result != kCVReturnSuccess) { + CVDisplayLinkRelease(displayLink); + return false; + } + + CVDisplayLinkSetOutputCallback( + displayLink, + displayLinkCallback, + session->renderCallbackContext + ); + result = CVDisplayLinkStart(displayLink); + if (result != kCVReturnSuccess) { + CVDisplayLinkRelease(displayLink); + return false; + } + + session->displayLink = displayLink; + return true; +} + +Napi::Value IsSupported(const Napi::CallbackInfo& info) +{ + return Napi::Boolean::New(info.Env(), true); +} + +Napi::Value CreateSession(const Napi::CallbackInfo& info) +{ + Napi::Env env = info.Env(); + if (info.Length() < 2 || !info[0].IsBuffer() || !info[1].IsObject()) { + throw Napi::TypeError::New( + env, + "Expected window handle buffer and bounds object." + ); + } + + const auto windowHandle = info[0].As>(); + if (windowHandle.Length() < sizeof(uintptr_t)) { + throw Napi::Error::New(env, "Native window handle buffer is too small."); + } + + uintptr_t windowHandleValue = 0; + std::memcpy( + &windowHandleValue, + windowHandle.Data(), + std::min(windowHandle.Length(), sizeof(uintptr_t)) + ); + + NSView* parentView = (__bridge NSView*)reinterpret_cast( + windowHandleValue + ); + if (!parentView) { + throw Napi::Error::New(env, "Unable to resolve Electron native window handle."); + } + + const auto bounds = info[1].As(); + const auto session = std::make_shared(); + session->id = "embedded-mpv-" + std::to_string(gNextSessionId.fetch_add(1)); + session->parentView = parentView; + session->hostView = [parentView window] + ? [[parentView window] contentView] + : parentView; + session->renderQueue = dispatch_queue_create( + "dev.iptvnator.embedded-mpv.render", + DISPATCH_QUEUE_SERIAL + ); + session->snapshot.volumePercent = + info.Length() >= 4 && info[3].IsNumber() + ? clampVolumePercent(info[3].As().DoubleValue()) + : 100.0; + + if (!session->hostView) { + throw Napi::Error::New(env, "Unable to resolve the Electron content view."); + } + + session->renderBackend = requestedRenderBackend(); + if (session->renderBackend == RenderBackend::OpenGL && + !createOpenGLView(session)) { + traceEmbeddedMpv( + session, + "OpenGL view creation failed; falling back to software renderer." + ); + session->renderBackend = RenderBackend::Software; + } + + if (session->renderBackend == RenderBackend::Software && + !createSoftwareView(session)) { + destroySession(session); + throw Napi::Error::New(env, "Failed to create embedded MPV view."); + } + + session->handle = mpv_create(); + if (!session->handle) { + destroySession(session); + throw Napi::Error::New(env, "Failed to create libmpv handle."); + } + + mpv_set_option_string(session->handle, "terminal", "no"); + mpv_set_option_string(session->handle, "config", "no"); + mpv_set_option_string(session->handle, "osc", "no"); + mpv_set_option_string(session->handle, "idle", "yes"); + mpv_set_option_string(session->handle, "keep-open", "yes"); + mpv_set_option_string(session->handle, "input-default-bindings", "no"); + mpv_set_option_string(session->handle, "input-vo-keyboard", "no"); + // Keep parity with the external MPV launch path and avoid yt-dlp probing + // IPTV-style URLs before handing them to FFmpeg directly. + mpv_set_option_string(session->handle, "ytdl", "no"); + // The macOS `wid` path stays black inside Electron. Render into our own + // surface instead of asking mpv to own a foreign Cocoa subwindow. + mpv_set_option_string(session->handle, "vo", "libmpv"); + if (session->renderBackend == RenderBackend::OpenGL) { + mpv_set_option_string(session->handle, "hwdec", "auto-safe"); + } + + const auto initialVolume = + std::to_string(session->snapshot.volumePercent); + mpv_set_option_string(session->handle, "volume", initialVolume.c_str()); + + mpv_request_log_messages(session->handle, "warn"); + + const int initializeResult = mpv_initialize(session->handle); + if (initializeResult < 0) { + destroySession(session); + throw Napi::Error::New( + env, + std::string("Failed to initialize libmpv: ") + + mpv_error_string(initializeResult) + ); + } + + int renderResult = session->renderBackend == RenderBackend::OpenGL + ? createOpenGLRenderContext(session) + : createSoftwareRenderContext(session); + + if (renderResult < 0 && + session->renderBackend == RenderBackend::OpenGL) { + traceEmbeddedMpv( + session, + std::string("OpenGL render context failed: ") + + mpv_error_string(renderResult) + + "; falling back to software renderer." + ); + removeRenderView(session); + session->renderBackend = RenderBackend::Software; + if (!createSoftwareView(session)) { + destroySession(session); + throw Napi::Error::New(env, "Failed to create embedded MPV fallback view."); + } + renderResult = createSoftwareRenderContext(session); + } + + if (renderResult < 0) { + destroySession(session); + throw Napi::Error::New( + env, + std::string("Failed to create libmpv render context: ") + + mpv_error_string(renderResult) + ); + } + + session->renderCallbackContext = + new std::weak_ptr(session); + + if (session->renderBackend == RenderBackend::OpenGL && + !startDisplayLink(session)) { + traceEmbeddedMpv( + session, + "CVDisplayLink startup failed; falling back to software renderer." + ); + dispatch_sync(session->renderQueue, ^{ + if (session->renderContext) { + CGLContextObj cglContext = [session->openGLContext CGLContextObj]; + if (cglContext) { + CGLLockContext(cglContext); + [session->openGLContext makeCurrentContext]; + } + mpv_render_context_free(session->renderContext); + session->renderContext = nullptr; + [NSOpenGLContext clearCurrentContext]; + if (cglContext) { + CGLUnlockContext(cglContext); + } + } + }); + removeRenderView(session); + session->renderBackend = RenderBackend::Software; + if (!createSoftwareView(session)) { + destroySession(session); + throw Napi::Error::New(env, "Failed to create embedded MPV fallback view."); + } + renderResult = createSoftwareRenderContext(session); + if (renderResult < 0) { + destroySession(session); + throw Napi::Error::New( + env, + std::string("Failed to create libmpv fallback render context: ") + + mpv_error_string(renderResult) + ); + } + } + + traceEmbeddedMpv( + session, + std::string("Using renderer backend: ") + + renderBackendName(session->renderBackend) + ); + + mpv_render_context_set_update_callback( + session->renderContext, + onRenderContextUpdate, + session->renderCallbackContext + ); + + mpv_observe_property(session->handle, 1, "time-pos", MPV_FORMAT_DOUBLE); + mpv_observe_property(session->handle, 2, "duration", MPV_FORMAT_DOUBLE); + mpv_observe_property(session->handle, 3, "pause", MPV_FORMAT_FLAG); + mpv_observe_property(session->handle, 4, "volume", MPV_FORMAT_DOUBLE); + mpv_observe_property(session->handle, 5, "path", MPV_FORMAT_STRING); + mpv_observe_property(session->handle, 6, "track-list", MPV_FORMAT_NODE); + mpv_observe_property(session->handle, 7, "aid", MPV_FORMAT_STRING); + + session->running.store(true); + session->eventThread = std::thread(runEventLoop, session); + + setSessionFrame( + session, + readOptionalNumber(bounds, "x", 0), + readOptionalNumber(bounds, "y", 0), + readOptionalNumber(bounds, "width", 0), + readOptionalNumber(bounds, "height", 0) + ); + + { + std::lock_guard sessionsLock(gSessionsMutex); + gSessions.emplace(session->id, session); + } + + return Napi::String::New(env, session->id); +} + +Napi::Value LoadPlayback(const Napi::CallbackInfo& info) +{ + Napi::Env env = info.Env(); + if (info.Length() < 2 || !info[0].IsString() || !info[1].IsObject()) { + throw Napi::TypeError::New(env, "Expected session id and playback object."); + } + + const std::string sessionId = info[0].As().Utf8Value(); + const auto playback = info[1].As(); + const auto session = getSessionOrThrow(sessionId); + const std::string streamUrl = readOptionalString(playback, "streamUrl"); + if (streamUrl.empty()) { + throw Napi::Error::New(env, "Embedded MPV playback requires a stream URL."); + } + const std::string title = readOptionalString(playback, "title"); + const std::string userAgent = readOptionalString(playback, "userAgent"); + const std::string referer = readOptionalString(playback, "referer"); + const double startTime = readOptionalNumber(playback, "startTime", -1); + + std::vector> options; + if (!title.empty()) { + options.emplace_back("force-media-title", title); + } + + if (!userAgent.empty()) { + options.emplace_back("user-agent", userAgent); + } + + if (!referer.empty()) { + options.emplace_back("referrer", referer); + } + + if (std::isfinite(startTime) && startTime >= 0) { + std::ostringstream startValue; + startValue << startTime; + options.emplace_back("start", startValue.str()); + } + + if (playback.Has("headers") && playback.Get("headers").IsObject()) { + const auto headerFields = + joinHeaderFields(playback.Get("headers").As()); + if (!headerFields.empty()) { + options.emplace_back("http-header-fields", headerFields); + } + } + + std::vector optionValues(options.size()); + std::vector optionKeys(options.size()); + for (size_t index = 0; index < options.size(); index += 1) { + optionKeys[index] = + const_cast(options[index].first.c_str()); + optionValues[index].format = MPV_FORMAT_STRING; + optionValues[index].u.string = + const_cast(options[index].second.c_str()); + } + + mpv_node_list optionList{}; + optionList.num = static_cast(options.size()); + optionList.values = options.empty() ? nullptr : optionValues.data(); + optionList.keys = options.empty() ? nullptr : optionKeys.data(); + + mpv_node optionMap{}; + optionMap.format = MPV_FORMAT_NODE_MAP; + optionMap.u.list = &optionList; + + mpv_node commandValues[5]{}; + commandValues[0].format = MPV_FORMAT_STRING; + commandValues[0].u.string = const_cast("loadfile"); + commandValues[1].format = MPV_FORMAT_STRING; + commandValues[1].u.string = const_cast(streamUrl.c_str()); + commandValues[2].format = MPV_FORMAT_STRING; + commandValues[2].u.string = const_cast("replace"); + commandValues[3].format = MPV_FORMAT_INT64; + commandValues[3].u.int64 = -1; + commandValues[4] = optionMap; + + mpv_node_list commandList{}; + commandList.num = 5; + commandList.values = commandValues; + commandList.keys = nullptr; + + mpv_node command{}; + command.format = MPV_FORMAT_NODE_ARRAY; + command.u.list = &commandList; + + const int commandResult = mpv_command_node_async( + session->handle, + nextAsyncRequestId(), + &command + ); + if (commandResult < 0) { + updateSessionError(session, mpv_error_string(commandResult)); + throw Napi::Error::New( + env, + std::string("Failed to load playback: ") + + mpv_error_string(commandResult) + ); + } + + { + std::lock_guard lock(session->mutex); + session->snapshot.streamUrl = streamUrl; + session->snapshot.error.clear(); + session->snapshot.status = SessionStatus::Loading; + } + + return env.Undefined(); +} + +Napi::Value SetBounds(const Napi::CallbackInfo& info) +{ + Napi::Env env = info.Env(); + if (info.Length() < 2 || !info[0].IsString() || !info[1].IsObject()) { + throw Napi::TypeError::New(env, "Expected session id and bounds object."); + } + + const std::string sessionId = info[0].As().Utf8Value(); + const auto bounds = info[1].As(); + const auto session = getSessionOrThrow(sessionId); + + setSessionFrame( + session, + readOptionalNumber(bounds, "x", 0), + readOptionalNumber(bounds, "y", 0), + readOptionalNumber(bounds, "width", 0), + readOptionalNumber(bounds, "height", 0) + ); + + return env.Undefined(); +} + +Napi::Value SetPaused(const Napi::CallbackInfo& info) +{ + Napi::Env env = info.Env(); + if (info.Length() < 2 || !info[0].IsString() || !info[1].IsBoolean()) { + throw Napi::TypeError::New(env, "Expected session id and paused flag."); + } + + const std::string sessionId = info[0].As().Utf8Value(); + const auto session = getSessionOrThrow(sessionId); + int paused = info[1].As().Value() ? 1 : 0; + const int result = mpv_set_property_async( + session->handle, + nextAsyncRequestId(), + "pause", + MPV_FORMAT_FLAG, + &paused + ); + + if (result < 0) { + throw Napi::Error::New( + env, + std::string("Failed to update playback state: ") + + mpv_error_string(result) + ); + } + + { + std::lock_guard lock(session->mutex); + session->paused = paused != 0; + if (session->loadedPath && + session->snapshot.status != SessionStatus::Error) { + session->snapshot.status = session->paused + ? SessionStatus::Paused + : SessionStatus::Playing; + } + } + + return env.Undefined(); +} + +Napi::Value Seek(const Napi::CallbackInfo& info) +{ + Napi::Env env = info.Env(); + if (info.Length() < 2 || !info[0].IsString() || !info[1].IsNumber()) { + throw Napi::TypeError::New(env, "Expected session id and seek target."); + } + + const std::string sessionId = info[0].As().Utf8Value(); + const auto session = getSessionOrThrow(sessionId); + const auto target = info[1].As().DoubleValue(); + const std::string targetValue = std::to_string(target); + const char* command[] = { + "seek", + targetValue.c_str(), + "absolute+exact", + nullptr, + }; + const int result = mpv_command_async( + session->handle, + nextAsyncRequestId(), + command + ); + + if (result < 0) { + throw Napi::Error::New( + env, + std::string("Failed to seek playback: ") + + mpv_error_string(result) + ); + } + + { + std::lock_guard lock(session->mutex); + session->snapshot.positionSeconds = std::max(0.0, target); + } + + return env.Undefined(); +} + +Napi::Value SetVolume(const Napi::CallbackInfo& info) +{ + Napi::Env env = info.Env(); + if (info.Length() < 2 || !info[0].IsString() || !info[1].IsNumber()) { + throw Napi::TypeError::New(env, "Expected session id and volume."); + } + + const std::string sessionId = info[0].As().Utf8Value(); + const auto session = getSessionOrThrow(sessionId); + double volume = clampVolumePercent( + info[1].As().DoubleValue() + ); + const int result = mpv_set_property_async( + session->handle, + nextAsyncRequestId(), + "volume", + MPV_FORMAT_DOUBLE, + &volume + ); + + if (result < 0) { + throw Napi::Error::New( + env, + std::string("Failed to update volume: ") + + mpv_error_string(result) + ); + } + + { + std::lock_guard lock(session->mutex); + session->snapshot.volumePercent = volume; + } + + return env.Undefined(); +} + +Napi::Value SetAudioTrack(const Napi::CallbackInfo& info) +{ + Napi::Env env = info.Env(); + if (info.Length() < 2 || !info[0].IsString() || !info[1].IsNumber()) { + throw Napi::TypeError::New(env, "Expected session id and audio track id."); + } + + const std::string sessionId = info[0].As().Utf8Value(); + const auto session = getSessionOrThrow(sessionId); + int64_t trackId = info[1].As().Int64Value(); + const int result = mpv_set_property_async( + session->handle, + nextAsyncRequestId(), + "aid", + MPV_FORMAT_INT64, + &trackId + ); + + if (result < 0) { + throw Napi::Error::New( + env, + std::string("Failed to update audio track: ") + + mpv_error_string(result) + ); + } + + { + std::lock_guard lock(session->mutex); + updateSelectedAudioTrack(session->snapshot, trackId); + } + + return env.Undefined(); +} + +Napi::Value GetSessionSnapshot(const Napi::CallbackInfo& info) +{ + Napi::Env env = info.Env(); + if (info.Length() < 1 || !info[0].IsString()) { + throw Napi::TypeError::New(env, "Expected session id."); + } + + const std::string sessionId = info[0].As().Utf8Value(); + std::shared_ptr session; + + { + std::lock_guard sessionsLock(gSessionsMutex); + const auto iterator = gSessions.find(sessionId); + if (iterator == gSessions.end()) { + return env.Null(); + } + session = iterator->second; + } + + SessionSnapshot snapshot; + { + std::lock_guard lock(session->mutex); + snapshot = session->snapshot; + } + + auto result = Napi::Object::New(env); + result.Set("status", toStatusString(snapshot.status)); + result.Set( + "positionSeconds", + Napi::Number::New(env, snapshot.positionSeconds) + ); + if (snapshot.durationSeconds < 0) { + result.Set("durationSeconds", env.Null()); + } else { + result.Set( + "durationSeconds", + Napi::Number::New(env, snapshot.durationSeconds) + ); + } + result.Set("volume", Napi::Number::New(env, snapshot.volumePercent / 100.0)); + result.Set("streamUrl", Napi::String::New(env, snapshot.streamUrl)); + if (snapshot.selectedAudioTrackId >= 0) { + result.Set( + "selectedAudioTrackId", + Napi::Number::New(env, snapshot.selectedAudioTrackId) + ); + } else { + result.Set("selectedAudioTrackId", env.Null()); + } + auto audioTracks = Napi::Array::New(env, snapshot.audioTracks.size()); + for (size_t index = 0; index < snapshot.audioTracks.size(); index += 1) { + const AudioTrack& track = snapshot.audioTracks[index]; + auto trackObject = Napi::Object::New(env); + trackObject.Set("id", Napi::Number::New(env, track.id)); + if (!track.title.empty()) { + trackObject.Set("title", Napi::String::New(env, track.title)); + } + if (!track.language.empty()) { + trackObject.Set("language", Napi::String::New(env, track.language)); + } + trackObject.Set("selected", Napi::Boolean::New(env, track.selected)); + trackObject.Set( + "defaultTrack", + Napi::Boolean::New(env, track.defaultTrack) + ); + trackObject.Set("forced", Napi::Boolean::New(env, track.forced)); + audioTracks.Set(index, trackObject); + } + result.Set("audioTracks", audioTracks); + if (!snapshot.error.empty()) { + result.Set("error", Napi::String::New(env, snapshot.error)); + } + + return result; +} + +Napi::Value DisposeSession(const Napi::CallbackInfo& info) +{ + Napi::Env env = info.Env(); + if (info.Length() < 1 || !info[0].IsString()) { + throw Napi::TypeError::New(env, "Expected session id."); + } + + const std::string sessionId = info[0].As().Utf8Value(); + std::shared_ptr session; + + { + std::lock_guard sessionsLock(gSessionsMutex); + const auto iterator = gSessions.find(sessionId); + if (iterator == gSessions.end()) { + return env.Undefined(); + } + session = iterator->second; + gSessions.erase(iterator); + } + + destroySession(session); + return env.Undefined(); +} + +Napi::Object Init(Napi::Env env, Napi::Object exports) +{ + exports.Set("isSupported", Napi::Function::New(env, IsSupported)); + exports.Set("createSession", Napi::Function::New(env, CreateSession)); + exports.Set("loadPlayback", Napi::Function::New(env, LoadPlayback)); + exports.Set("setBounds", Napi::Function::New(env, SetBounds)); + exports.Set("setPaused", Napi::Function::New(env, SetPaused)); + exports.Set("seek", Napi::Function::New(env, Seek)); + exports.Set("setVolume", Napi::Function::New(env, SetVolume)); + exports.Set("setAudioTrack", Napi::Function::New(env, SetAudioTrack)); + exports.Set( + "getSessionSnapshot", + Napi::Function::New(env, GetSessionSnapshot) + ); + exports.Set("disposeSession", Napi::Function::New(env, DisposeSession)); + return exports; +} + +} // namespace + +NODE_API_MODULE(embedded_mpv, Init) diff --git a/apps/electron-backend/project.json b/apps/electron-backend/project.json index a925fd5be..f0deb78af 100644 --- a/apps/electron-backend/project.json +++ b/apps/electron-backend/project.json @@ -11,9 +11,20 @@ "command": "node apps/electron-backend/build-worker.js" } }, + "build-embedded-mpv": { + "executor": "nx:run-commands", + "cache": false, + "outputs": [ + "{workspaceRoot}/apps/electron-backend/native/build/Release" + ], + "options": { + "command": "node apps/electron-backend/build-embedded-mpv.js" + } + }, "build": { "dependsOn": [ "electron-backend:build-worker", + "electron-backend:build-embedded-mpv", { "projects": ["web", "remote-control-web"], "target": "build" @@ -21,11 +32,23 @@ ], "executor": "nx-electron:build", "outputs": ["{options.outputPath}"], + "inputs": [ + "production", + "^production", + "{workspaceRoot}/apps/electron-backend/native/build/Release/**" + ], "options": { "outputPath": "dist/apps/electron-backend", "main": "apps/electron-backend/src/main.ts", "tsConfig": "apps/electron-backend/tsconfig.app.json", - "assets": ["apps/electron-backend/src/assets"] + "assets": [ + "apps/electron-backend/src/assets", + { + "glob": "{embedded_mpv.node,embedded-mpv-runtime.json,embedded-mpv-unavailable.txt,lib/*}", + "input": "apps/electron-backend/native/build/Release", + "output": "native" + } + ] }, "configurations": { "production": { @@ -44,6 +67,7 @@ "build-e2e": { "dependsOn": [ "electron-backend:build-worker", + "electron-backend:build-embedded-mpv", { "projects": ["web"], "target": "build-e2e" @@ -55,11 +79,23 @@ ], "executor": "nx-electron:build", "outputs": ["{options.outputPath}"], + "inputs": [ + "production", + "^production", + "{workspaceRoot}/apps/electron-backend/native/build/Release/**" + ], "options": { "outputPath": "dist/apps/electron-backend", "main": "apps/electron-backend/src/main.ts", "tsConfig": "apps/electron-backend/tsconfig.app.json", - "assets": ["apps/electron-backend/src/assets"] + "assets": [ + "apps/electron-backend/src/assets", + { + "glob": "{embedded_mpv.node,embedded-mpv-runtime.json,embedded-mpv-unavailable.txt,lib/*}", + "input": "apps/electron-backend/native/build/Release", + "output": "native" + } + ] } }, "serve": { diff --git a/apps/electron-backend/src/app/api/main.preload.ts b/apps/electron-backend/src/app/api/main.preload.ts index a0cbd559b..90b493b89 100644 --- a/apps/electron-backend/src/app/api/main.preload.ts +++ b/apps/electron-backend/src/app/api/main.preload.ts @@ -1,8 +1,12 @@ import { contextBridge, ipcRenderer } from 'electron'; import type { + EmbeddedMpvBounds, + EmbeddedMpvSession, + EmbeddedMpvSupport, ExternalPlayerSession, PlaylistRefreshEvent, PlaylistRefreshPayload, + ResolvedPortalPlayback, XtreamCategory, } from 'shared-interfaces'; import { @@ -14,6 +18,7 @@ import { const PORTAL_DEBUG_EVENT = 'PORTAL_DEBUG_EVENT'; const EXTERNAL_PLAYER_SESSION_UPDATE = 'EXTERNAL_PLAYER_SESSION_UPDATE'; +const EMBEDDED_MPV_SESSION_UPDATE = 'EMBEDDED_MPV_SESSION_UPDATE'; const DB_OPERATION_EVENT = 'DB_OPERATION_EVENT'; const PLAYLIST_REFRESH_EVENT = 'PLAYLIST:REFRESH_EVENT'; @@ -229,6 +234,16 @@ const electronApi = { ipcRenderer.on(EXTERNAL_PLAYER_SESSION_UPDATE, handler); return () => ipcRenderer.off(EXTERNAL_PLAYER_SESSION_UPDATE, handler); }, + onEmbeddedMpvSessionUpdate: ( + callback: (data: EmbeddedMpvSession) => void + ) => { + const handler = ( + _event: Electron.IpcRendererEvent, + data: EmbeddedMpvSession + ) => callback(data); + ipcRenderer.on(EMBEDDED_MPV_SESSION_UPDATE, handler); + return () => ipcRenderer.off(EMBEDDED_MPV_SESSION_UPDATE, handler); + }, onDbOperationEvent: (callback: (data: DbOperationEvent) => void) => { const handler = (_event: Electron.IpcRendererEvent, data: any) => callback(data as DbOperationEvent); @@ -332,6 +347,55 @@ const electronApi = { ), closeExternalPlayerSession: (sessionId: string) => ipcRenderer.invoke('CLOSE_EXTERNAL_PLAYER_SESSION', sessionId), + getEmbeddedMpvSupport: (): Promise => + ipcRenderer.invoke('EMBEDDED_MPV_SUPPORT'), + prepareEmbeddedMpv: (): Promise => + ipcRenderer.invoke('EMBEDDED_MPV_PREPARE'), + createEmbeddedMpvSession: ( + bounds: EmbeddedMpvBounds, + title?: string, + initialVolume?: number + ): Promise => + ipcRenderer.invoke( + 'EMBEDDED_MPV_CREATE_SESSION', + bounds, + title, + initialVolume + ), + loadEmbeddedMpvPlayback: ( + sessionId: string, + playback: ResolvedPortalPlayback + ): Promise => + ipcRenderer.invoke('EMBEDDED_MPV_LOAD_PLAYBACK', sessionId, playback), + setEmbeddedMpvBounds: ( + sessionId: string, + bounds: EmbeddedMpvBounds + ): Promise => + ipcRenderer.invoke('EMBEDDED_MPV_SET_BOUNDS', sessionId, bounds), + setEmbeddedMpvPaused: ( + sessionId: string, + paused: boolean + ): Promise => + ipcRenderer.invoke('EMBEDDED_MPV_SET_PAUSED', sessionId, paused), + seekEmbeddedMpv: ( + sessionId: string, + seconds: number + ): Promise => + ipcRenderer.invoke('EMBEDDED_MPV_SEEK', sessionId, seconds), + setEmbeddedMpvVolume: ( + sessionId: string, + volume: number + ): Promise => + ipcRenderer.invoke('EMBEDDED_MPV_SET_VOLUME', sessionId, volume), + setEmbeddedMpvAudioTrack: ( + sessionId: string, + trackId: number + ): Promise => + ipcRenderer.invoke('EMBEDDED_MPV_SET_AUDIO_TRACK', sessionId, trackId), + disposeEmbeddedMpvSession: ( + sessionId: string + ): Promise => + ipcRenderer.invoke('EMBEDDED_MPV_DISPOSE_SESSION', sessionId), autoUpdatePlaylists: (playlists) => ipcRenderer.invoke('AUTO_UPDATE', playlists), fetchEpg: (urls: string[]) => diff --git a/apps/electron-backend/src/app/events/embedded-mpv.events.ts b/apps/electron-backend/src/app/events/embedded-mpv.events.ts new file mode 100644 index 000000000..ff761a4fb --- /dev/null +++ b/apps/electron-backend/src/app/events/embedded-mpv.events.ts @@ -0,0 +1,85 @@ +import { ipcMain } from 'electron'; +import { + EMBEDDED_MPV_CREATE_SESSION, + EMBEDDED_MPV_DISPOSE_SESSION, + EMBEDDED_MPV_LOAD_PLAYBACK, + EMBEDDED_MPV_PREPARE, + EMBEDDED_MPV_SEEK, + EMBEDDED_MPV_SET_AUDIO_TRACK, + EMBEDDED_MPV_SET_BOUNDS, + EMBEDDED_MPV_SET_PAUSED, + EMBEDDED_MPV_SET_VOLUME, + EMBEDDED_MPV_SUPPORT, + EmbeddedMpvBounds, + ResolvedPortalPlayback, +} from 'shared-interfaces'; +import { + EmbeddedMpvNativeService, + embeddedMpvNativeService, +} from '../services/embedded-mpv-native.service'; + +export default class EmbeddedMpvEvents { + static bootstrapEmbeddedMpvEvents(): Electron.IpcMain { + return ipcMain; + } +} + +function getService(): EmbeddedMpvNativeService { + return embeddedMpvNativeService; +} + +ipcMain.handle(EMBEDDED_MPV_SUPPORT, () => getService().getSupport()); + +ipcMain.handle(EMBEDDED_MPV_PREPARE, () => getService().prepareAddon()); + +ipcMain.handle( + EMBEDDED_MPV_CREATE_SESSION, + ( + _event, + bounds: EmbeddedMpvBounds, + title?: string, + initialVolume?: number + ) => getService().createSession(bounds, title, initialVolume) +); + +ipcMain.handle( + EMBEDDED_MPV_LOAD_PLAYBACK, + (_event, sessionId: string, playback: ResolvedPortalPlayback) => + getService().loadPlayback(sessionId, playback) +); + +ipcMain.handle( + EMBEDDED_MPV_SET_BOUNDS, + (_event, sessionId: string, bounds: EmbeddedMpvBounds) => + getService().setBounds(sessionId, bounds) +); + +ipcMain.handle( + EMBEDDED_MPV_SET_PAUSED, + (_event, sessionId: string, paused: boolean) => + getService().setPaused(sessionId, paused) +); + +ipcMain.handle(EMBEDDED_MPV_SEEK, (_event, sessionId: string, seconds: number) => + getService().seek(sessionId, seconds) +); + +ipcMain.handle( + EMBEDDED_MPV_SET_VOLUME, + (_event, sessionId: string, volume: number) => + getService().setVolume(sessionId, volume) +); + +ipcMain.handle( + EMBEDDED_MPV_SET_AUDIO_TRACK, + (_event, sessionId: string, trackId: number) => + getService().setAudioTrack(sessionId, trackId) +); + +ipcMain.handle(EMBEDDED_MPV_DISPOSE_SESSION, (_event, sessionId: string) => + getService().disposeSession(sessionId) +); + +export function shutdownEmbeddedMpv(): void { + getService().shutdown(); +} diff --git a/apps/electron-backend/src/app/services/embedded-mpv-native.service.ts b/apps/electron-backend/src/app/services/embedded-mpv-native.service.ts new file mode 100644 index 000000000..f3e5ccb20 --- /dev/null +++ b/apps/electron-backend/src/app/services/embedded-mpv-native.service.ts @@ -0,0 +1,533 @@ +import { app } from 'electron'; +import { existsSync, readFileSync } from 'fs'; +import { createRequire } from 'module'; +import path from 'path'; +import App from '../app'; +import { + EmbeddedMpvAudioTrack, + EmbeddedMpvBounds, + EmbeddedMpvSession, + EmbeddedMpvSessionStatus, + EmbeddedMpvSupport, + EMBEDDED_MPV_SESSION_UPDATE, + ResolvedPortalPlayback, +} from 'shared-interfaces'; + +interface NativeEmbeddedMpvSessionSnapshot { + status: EmbeddedMpvSessionStatus; + positionSeconds: number; + durationSeconds: number | null; + volume: number; + streamUrl: string; + audioTracks?: EmbeddedMpvAudioTrack[]; + selectedAudioTrackId?: number | null; + error?: string; +} + +interface NativeEmbeddedMpvAddon { + isSupported(): boolean; + createSession( + windowHandle: Buffer, + bounds: EmbeddedMpvBounds, + title?: string, + initialVolume?: number + ): string; + loadPlayback(sessionId: string, playback: ResolvedPortalPlayback): void; + setBounds(sessionId: string, bounds: EmbeddedMpvBounds): void; + setPaused(sessionId: string, paused: boolean): void; + seek(sessionId: string, seconds: number): void; + setVolume(sessionId: string, volume: number): void; + setAudioTrack(sessionId: string, trackId: number): void; + getSessionSnapshot( + sessionId: string + ): NativeEmbeddedMpvSessionSnapshot | null; + disposeSession(sessionId: string): void; +} + +interface EmbeddedMpvRuntimeSession { + id: string; + title: string; + streamUrl: string; + startedAt: string; + updatedAt: string; + lastPayloadKey: string; +} + +const EMBEDDED_MPV_EXPERIMENT_ENV = + 'IPTVNATOR_ENABLE_EMBEDDED_MPV_EXPERIMENT'; + +function dedupePaths(paths: Array): string[] { + return [...new Set(paths.filter((value): value is string => Boolean(value)))]; +} + +export class EmbeddedMpvNativeService { + private addon: NativeEmbeddedMpvAddon | null = null; + private addonLoadError: Error | null = null; + private readonly sessions = new Map(); + private pollingTimer: NodeJS.Timeout | null = null; + private readonly loadAddonModule = createRequire(__filename); + + getSupport(): EmbeddedMpvSupport { + if (process.platform !== 'darwin') { + return { + supported: false, + platform: process.platform, + reason: 'Embedded MPV is currently available on macOS only.', + }; + } + + if (!this.isEmbeddedMpvEnabled()) { + return { + supported: false, + platform: process.platform, + reason: `Embedded MPV is a macOS-only experimental player. Set ${EMBEDDED_MPV_EXPERIMENT_ENV}=1 to enable it for local development builds.`, + }; + } + + if (this.addon) { + try { + if (!this.addon.isSupported()) { + return { + supported: false, + platform: process.platform, + reason: 'The native embedded MPV addon reported itself as unsupported.', + }; + } + + return { + supported: true, + platform: process.platform, + }; + } catch (error) { + return { + supported: false, + platform: process.platform, + reason: + error instanceof Error + ? error.message + : String(error), + }; + } + } + + if (this.addonLoadError) { + return { + supported: false, + platform: process.platform, + reason: this.addonLoadError.message, + }; + } + + const candidatePaths = this.getAddonCandidatePaths(); + const existingCandidatePath = candidatePaths.find((candidatePath) => + existsSync(candidatePath) + ); + + if (!existingCandidatePath) { + return { + supported: false, + platform: process.platform, + reason: + this.readUnavailableReason(candidatePaths) ?? + [ + 'Unable to locate the embedded MPV native addon.', + ...candidatePaths.map( + (candidatePath) => `- ${candidatePath}` + ), + ].join('\n'), + }; + } + + const missingRuntimeReason = + this.getMissingRuntimeReason(existingCandidatePath); + if (missingRuntimeReason) { + return { + supported: false, + platform: process.platform, + reason: missingRuntimeReason, + }; + } + + return { + supported: true, + platform: process.platform, + }; + } + + prepareAddon(): EmbeddedMpvSupport { + const support = this.getSupport(); + if (!support.supported) { + return support; + } + + try { + const addon = this.getAddon(); + if (!addon.isSupported()) { + return { + supported: false, + platform: process.platform, + reason: 'The native embedded MPV addon reported itself as unsupported.', + }; + } + + return { + supported: true, + platform: process.platform, + }; + } catch (error) { + return { + supported: false, + platform: process.platform, + reason: + error instanceof Error + ? error.message + : String(error), + }; + } + } + + createSession( + bounds: EmbeddedMpvBounds, + title = '', + initialVolume = 1 + ): EmbeddedMpvSession { + this.assertEmbeddedMpvEnabled(); + const addon = this.getAddon(); + const windowHandle = this.getMainWindowHandle(); + const startedAt = new Date().toISOString(); + const sessionId = addon.createSession( + windowHandle, + bounds, + title, + initialVolume + ); + + this.sessions.set(sessionId, { + id: sessionId, + title, + streamUrl: '', + startedAt, + updatedAt: startedAt, + lastPayloadKey: '', + }); + + this.ensurePolling(); + return this.refreshSession(sessionId) ?? { + id: sessionId, + title, + streamUrl: '', + status: 'idle', + positionSeconds: 0, + durationSeconds: null, + volume: 1, + audioTracks: [], + selectedAudioTrackId: null, + startedAt, + updatedAt: startedAt, + }; + } + + loadPlayback(sessionId: string, playback: ResolvedPortalPlayback): void { + this.assertEmbeddedMpvEnabled(); + const addon = this.getAddon(); + const session = this.getRuntimeSession(sessionId); + session.title = playback.title ?? session.title; + session.streamUrl = playback.streamUrl ?? session.streamUrl; + session.updatedAt = new Date().toISOString(); + addon.loadPlayback(sessionId, playback); + this.refreshSession(sessionId); + } + + setBounds(sessionId: string, bounds: EmbeddedMpvBounds): void { + this.assertEmbeddedMpvEnabled(); + this.getAddon().setBounds(sessionId, bounds); + } + + setPaused(sessionId: string, paused: boolean): EmbeddedMpvSession | null { + this.assertEmbeddedMpvEnabled(); + this.getAddon().setPaused(sessionId, paused); + return this.refreshSession(sessionId); + } + + seek(sessionId: string, seconds: number): EmbeddedMpvSession | null { + this.assertEmbeddedMpvEnabled(); + this.getAddon().seek(sessionId, seconds); + return this.refreshSession(sessionId); + } + + setVolume(sessionId: string, volume: number): EmbeddedMpvSession | null { + this.assertEmbeddedMpvEnabled(); + this.getAddon().setVolume(sessionId, volume); + return this.refreshSession(sessionId); + } + + setAudioTrack(sessionId: string, trackId: number): EmbeddedMpvSession | null { + this.assertEmbeddedMpvEnabled(); + this.getAddon().setAudioTrack(sessionId, trackId); + return this.refreshSession(sessionId); + } + + disposeSession(sessionId: string): EmbeddedMpvSession | null { + const session = this.sessions.get(sessionId); + if (!session) { + return null; + } + + try { + this.getAddon().disposeSession(sessionId); + } finally { + this.sessions.delete(sessionId); + const payload: EmbeddedMpvSession = { + id: session.id, + title: session.title, + streamUrl: session.streamUrl, + status: 'closed', + positionSeconds: 0, + durationSeconds: null, + volume: 1, + audioTracks: [], + selectedAudioTrackId: null, + startedAt: session.startedAt, + updatedAt: new Date().toISOString(), + }; + this.sendSessionUpdate(payload); + this.stopPollingIfIdle(); + return payload; + } + } + + shutdown(): void { + const sessionIds = [...this.sessions.keys()]; + sessionIds.forEach((sessionId) => this.disposeSession(sessionId)); + if (this.pollingTimer) { + clearInterval(this.pollingTimer); + this.pollingTimer = null; + } + } + + private ensurePolling(): void { + if (this.pollingTimer) { + return; + } + + this.pollingTimer = setInterval(() => { + [...this.sessions.keys()].forEach((sessionId) => { + this.refreshSession(sessionId); + }); + }, 500); + } + + private stopPollingIfIdle(): void { + if (this.sessions.size > 0 || !this.pollingTimer) { + return; + } + + clearInterval(this.pollingTimer); + this.pollingTimer = null; + } + + private refreshSession(sessionId: string): EmbeddedMpvSession | null { + const addon = this.getAddon(); + const session = this.sessions.get(sessionId); + if (!session) { + return null; + } + + const snapshot = addon.getSessionSnapshot(sessionId); + if (!snapshot) { + return null; + } + + const payload: EmbeddedMpvSession = { + id: session.id, + title: session.title, + streamUrl: snapshot.streamUrl || session.streamUrl, + status: snapshot.status, + positionSeconds: Math.max(0, Math.floor(snapshot.positionSeconds)), + durationSeconds: + typeof snapshot.durationSeconds === 'number' + ? Math.max(0, Math.floor(snapshot.durationSeconds)) + : null, + volume: typeof snapshot.volume === 'number' ? snapshot.volume : 1, + audioTracks: Array.isArray(snapshot.audioTracks) + ? snapshot.audioTracks + : [], + selectedAudioTrackId: + typeof snapshot.selectedAudioTrackId === 'number' + ? snapshot.selectedAudioTrackId + : null, + startedAt: session.startedAt, + updatedAt: new Date().toISOString(), + ...(snapshot.error ? { error: snapshot.error } : {}), + }; + + session.streamUrl = payload.streamUrl; + session.updatedAt = payload.updatedAt; + const nextPayloadKey = JSON.stringify(payload); + if (session.lastPayloadKey !== nextPayloadKey) { + session.lastPayloadKey = nextPayloadKey; + this.sendSessionUpdate(payload); + } + + return payload; + } + + private sendSessionUpdate(session: EmbeddedMpvSession): void { + if (!App.mainWindow || App.mainWindow.isDestroyed()) { + return; + } + + App.mainWindow.webContents.send(EMBEDDED_MPV_SESSION_UPDATE, session); + } + + private getRuntimeSession(sessionId: string): EmbeddedMpvRuntimeSession { + const session = this.sessions.get(sessionId); + if (!session) { + throw new Error(`Embedded MPV session "${sessionId}" was not found.`); + } + + return session; + } + + private getMainWindowHandle(): Buffer { + if (!App.mainWindow || App.mainWindow.isDestroyed()) { + throw new Error('The Electron main window is not available.'); + } + + return App.mainWindow.getNativeWindowHandle(); + } + + private isExperimentEnabled(): boolean { + return ['1', 'true', 'yes', 'on'].includes( + (process.env[EMBEDDED_MPV_EXPERIMENT_ENV] ?? '') + .trim() + .toLowerCase() + ); + } + + private isEmbeddedMpvEnabled(): boolean { + return app.isPackaged || this.isExperimentEnabled(); + } + + private assertEmbeddedMpvEnabled(): void { + if (!this.isEmbeddedMpvEnabled()) { + throw new Error( + `Embedded MPV is disabled. Set ${EMBEDDED_MPV_EXPERIMENT_ENV}=1 to enable the local macOS harness, or use a packaged macOS build with the bundled runtime.` + ); + } + } + + private getAddon(): NativeEmbeddedMpvAddon { + if (this.addon) { + return this.addon; + } + + if (this.addonLoadError) { + throw this.addonLoadError; + } + + const candidatePaths = this.getAddonCandidatePaths(); + + const existingCandidatePaths = candidatePaths.filter((candidatePath) => + existsSync(candidatePath) + ); + + if (existingCandidatePaths.length === 0) { + this.addonLoadError = new Error( + [ + 'Unable to locate the embedded MPV native addon.', + ...candidatePaths.map((candidatePath) => `- ${candidatePath}`), + ].join('\n') + ); + throw this.addonLoadError; + } + + const loadErrors: string[] = []; + for (const candidatePath of existingCandidatePaths) { + try { + this.addon = this.loadAddonModule( + candidatePath + ) as NativeEmbeddedMpvAddon; + return this.addon; + } catch (error) { + loadErrors.push( + `${candidatePath}: ${ + error instanceof Error ? error.message : String(error) + }` + ); + } + } + + this.addonLoadError = new Error( + [ + 'Unable to load the embedded MPV native addon.', + ...loadErrors.map((error) => `- ${error}`), + ].join('\n') + ); + throw this.addonLoadError; + } + + private getAddonCandidatePaths(): string[] { + const localBuildAddonPath = path.resolve( + process.cwd(), + 'apps/electron-backend/native/build/Release/embedded_mpv.node' + ); + const distAddonPaths = [ + path.resolve(__dirname, 'native/embedded_mpv.node'), + path.resolve(__dirname, '../../native/embedded_mpv.node'), + ]; + const packagedAddonPaths = [ + path.resolve( + (process as NodeJS.Process & { resourcesPath?: string }) + .resourcesPath ?? '', + 'app.asar.unpacked', + 'electron-backend', + 'native', + 'embedded_mpv.node' + ), + app.getAppPath() + ? path.join( + path.dirname(app.getAppPath()), + 'app.asar.unpacked', + 'electron-backend', + 'native', + 'embedded_mpv.node' + ) + : undefined, + ]; + + return dedupePaths( + app.isPackaged + ? [...packagedAddonPaths, ...distAddonPaths, localBuildAddonPath] + : [localBuildAddonPath, ...distAddonPaths, ...packagedAddonPaths] + ); + } + + private readUnavailableReason(candidatePaths: string[]): string | null { + for (const candidatePath of candidatePaths) { + const unavailablePath = path.join( + path.dirname(candidatePath), + 'embedded-mpv-unavailable.txt' + ); + + if (existsSync(unavailablePath)) { + return readFileSync(unavailablePath, 'utf8').trim(); + } + } + + return null; + } + + private getMissingRuntimeReason(addonPath: string): string | null { + const nativeDir = path.dirname(addonPath); + const libMpvPath = path.join(nativeDir, 'lib', 'libmpv.2.dylib'); + + if (!existsSync(libMpvPath)) { + return `Embedded MPV runtime is incomplete. Missing ${libMpvPath}.`; + } + + return null; + } +} + +export const embeddedMpvNativeService = new EmbeddedMpvNativeService(); diff --git a/apps/electron-backend/src/main.ts b/apps/electron-backend/src/main.ts index bba772f58..7a62233a5 100644 --- a/apps/electron-backend/src/main.ts +++ b/apps/electron-backend/src/main.ts @@ -9,6 +9,9 @@ import { setMainWindow as setDownloadsMainWindow, } from './app/events/database/downloads.events'; import ElectronEvents from './app/events/electron.events'; +import EmbeddedMpvEvents, { + shutdownEmbeddedMpv, +} from './app/events/embedded-mpv.events'; import EpgEvents from './app/events/epg.events'; import PlayerEvents from './app/events/player.events'; import PlaylistEvents from './app/events/playlist.events'; @@ -57,6 +60,7 @@ export default class Main { } ElectronEvents.bootstrapElectronEvents(); + EmbeddedMpvEvents.bootstrapEmbeddedMpvEvents(); PlaylistEvents.bootstrapPlaylistEvents(); SharedEvents.bootstrapSharedEvents(); PlayerEvents.bootstrapPlayerEvents(); @@ -105,5 +109,6 @@ app.whenReady().then(async () => { }); app.on('before-quit', () => { + shutdownEmbeddedMpv(); void databaseWorkerClient.shutdown(); }); diff --git a/apps/web/src/app/services/player.service.spec.ts b/apps/web/src/app/services/player.service.spec.ts index 843d648ff..aba7eaaeb 100644 --- a/apps/web/src/app/services/player.service.spec.ts +++ b/apps/web/src/app/services/player.service.spec.ts @@ -47,6 +47,7 @@ describe('PlayerService', () => { expect(service.isEmbeddedPlayer(VideoPlayer.VideoJs)).toBe(true); expect(service.isEmbeddedPlayer(VideoPlayer.Html5Player)).toBe(true); expect(service.isEmbeddedPlayer(VideoPlayer.ArtPlayer)).toBe(true); + expect(service.isEmbeddedPlayer(VideoPlayer.EmbeddedMpv)).toBe(true); expect(service.isEmbeddedPlayer(VideoPlayer.MPV)).toBe(false); expect(service.isEmbeddedPlayer(VideoPlayer.VLC)).toBe(false); }); @@ -57,7 +58,19 @@ describe('PlayerService', () => { title: 'Example Video', }); - expect(dialog.open).toHaveBeenCalled(); + 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(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 17aea2d98..6f0402e6f 100644 --- a/apps/web/src/app/services/player.service.ts +++ b/apps/web/src/app/services/player.service.ts @@ -26,7 +26,8 @@ export class PlayerService { return ( player === VideoPlayer.VideoJs || player === VideoPlayer.Html5Player || - player === VideoPlayer.ArtPlayer + player === VideoPlayer.ArtPlayer || + player === VideoPlayer.EmbeddedMpv ); } @@ -118,7 +119,13 @@ export class PlayerService { return import('@iptvnator/portal/xtream/feature').then( ({ PlayerDialogComponent }) => { this.dialog.open(PlayerDialogComponent, { - data: { streamUrl, title, contentInfo, startTime }, + data: { + streamUrl, + title, + contentInfo, + startTime, + playback, + }, width: '80%', maxWidth: '1200px', maxHeight: '90vh', diff --git a/apps/web/src/app/settings/settings.component.spec.ts b/apps/web/src/app/settings/settings.component.spec.ts index 573e8e3aa..256f90cb9 100644 --- a/apps/web/src/app/settings/settings.component.spec.ts +++ b/apps/web/src/app/settings/settings.component.spec.ts @@ -402,7 +402,7 @@ describe('SettingsComponent', () => { }); }); - /* it('hides the embedded mpv option when the desktop support probe reports unsupported', async () => { + it('hides the embedded mpv option when the desktop support probe reports unsupported', async () => { window.electron = { ...window.electron, getEmbeddedMpvSupport: jest.fn().mockResolvedValue({ @@ -416,9 +416,9 @@ describe('SettingsComponent', () => { await fixture.whenStable(); expect( - component.players().some( - (player) => player.id === VideoPlayer.EmbeddedMpv - ) + component + .players() + .some((player) => player.id === VideoPlayer.EmbeddedMpv) ).toBe(false); }); @@ -435,9 +435,9 @@ describe('SettingsComponent', () => { await fixture.whenStable(); expect( - component.players().some( - (player) => player.id === VideoPlayer.EmbeddedMpv - ) + component + .players() + .some((player) => player.id === VideoPlayer.EmbeddedMpv) ).toBe(true); }); @@ -458,9 +458,9 @@ describe('SettingsComponent', () => { expect(component.settingsForm.value).toEqual(DEFAULT_SETTINGS); expect(window.electron.getEmbeddedMpvSupport).toHaveBeenCalled(); expect( - component.players().some( - (player) => player.id === VideoPlayer.EmbeddedMpv - ) + component + .players() + .some((player) => player.id === VideoPlayer.EmbeddedMpv) ).toBe(false); resolveSupport!({ @@ -470,11 +470,11 @@ describe('SettingsComponent', () => { await fixture.whenStable(); expect( - component.players().some( - (player) => player.id === VideoPlayer.EmbeddedMpv - ) + component + .players() + .some((player) => player.id === VideoPlayer.EmbeddedMpv) ).toBe(true); - }); */ + }); }); describe('Version check', () => { diff --git a/apps/web/src/app/settings/settings.component.ts b/apps/web/src/app/settings/settings.component.ts index a37bd39f9..ef6557e21 100644 --- a/apps/web/src/app/settings/settings.component.ts +++ b/apps/web/src/app/settings/settings.component.ts @@ -56,7 +56,7 @@ import { PlaylistsService, } from 'services'; import { - /* EmbeddedMpvSupport, */ + EmbeddedMpvSupport, Language, StartupBehavior, StreamFormat, @@ -151,10 +151,10 @@ export class SettingsComponent implements OnInit, OnDestroy { /** Flag that indicates whether the app runs in electron environment */ readonly isDesktop = !!window.electron; - /* readonly embeddedMpvSupport = signal(null); + readonly embeddedMpvSupport = signal(null); readonly supportsEmbeddedMpv = computed( () => this.isDesktop && !!this.embeddedMpvSupport()?.supported - ); */ + ); isPwa = this.dataService.getAppEnvironment() === 'pwa'; @@ -162,14 +162,14 @@ export class SettingsComponent implements OnInit, OnDestroy { readonly activeSection = this.settingsCtx.activeSection; readonly osPlayers = computed(() => [ - /* ...(this.supportsEmbeddedMpv() + ...(this.supportsEmbeddedMpv() ? [ { id: VideoPlayer.EmbeddedMpv, labelKey: 'SETTINGS.PLAYER_EMBEDDED_MPV', }, ] - : []), */ + : []), { id: VideoPlayer.MPV, labelKey: 'SETTINGS.PLAYER_MPV', @@ -437,7 +437,7 @@ export class SettingsComponent implements OnInit, OnDestroy { // Wait for settings to load before setting the form await this.settingsStore.loadSettings(); this.setSettings(); - /* void this.loadEmbeddedMpvSupport(); */ + void this.loadEmbeddedMpvSupport(); this.checkAppVersion(); void this.fetchLocalIpAddresses(); @@ -448,7 +448,7 @@ export class SettingsComponent implements OnInit, OnDestroy { requestAnimationFrame(() => this.setupSectionObserver()); } - /* private async loadEmbeddedMpvSupport(): Promise { + private async loadEmbeddedMpvSupport(): Promise { if (!this.isDesktop) { this.embeddedMpvSupport.set({ supported: false, @@ -475,13 +475,10 @@ export class SettingsComponent implements OnInit, OnDestroy { this.embeddedMpvSupport.set({ supported: false, platform: window.electron.platform, - reason: - error instanceof Error - ? error.message - : String(error), + reason: error instanceof Error ? error.message : String(error), }); } - } */ + } ngOnDestroy(): void { this.cancelPendingScrollTargetClear(); diff --git a/apps/web/src/typings.d.ts b/apps/web/src/typings.d.ts index 5c84ec15e..3e7b9d484 100644 --- a/apps/web/src/typings.d.ts +++ b/apps/web/src/typings.d.ts @@ -5,6 +5,9 @@ interface NodeModule { } import { + EmbeddedMpvBounds, + EmbeddedMpvSession, + EmbeddedMpvSupport, EpgChannel, EpgChannelMetadata, EpgProgram, @@ -14,6 +17,7 @@ import { PlaylistRefreshEvent, PlaylistRefreshPayload, PortalDebugEvent, + ResolvedPortalPlayback, XtreamBackupFavoriteItem, XtreamBackupHiddenCategory, XtreamBackupRecentlyViewedItem, @@ -321,9 +325,46 @@ declare global { originalError: string; }) => void ) => void; + onEmbeddedMpvSessionUpdate?: ( + callback: (data: EmbeddedMpvSession) => void + ) => () => void; onExternalPlayerSessionUpdate?: ( callback: (data: ExternalPlayerSession) => void ) => () => void; + getEmbeddedMpvSupport: () => Promise; + prepareEmbeddedMpv?: () => Promise; + createEmbeddedMpvSession: ( + bounds: EmbeddedMpvBounds, + title?: string, + initialVolume?: number + ) => Promise; + loadEmbeddedMpvPlayback: ( + sessionId: string, + playback: ResolvedPortalPlayback + ) => Promise; + setEmbeddedMpvBounds: ( + sessionId: string, + bounds: EmbeddedMpvBounds + ) => Promise; + setEmbeddedMpvPaused: ( + sessionId: string, + paused: boolean + ) => Promise; + seekEmbeddedMpv: ( + sessionId: string, + seconds: number + ) => Promise; + setEmbeddedMpvVolume: ( + sessionId: string, + volume: number + ) => Promise; + setEmbeddedMpvAudioTrack: ( + sessionId: string, + trackId: number + ) => Promise; + disposeEmbeddedMpvSession: ( + sessionId: string + ) => Promise; getLocalIpAddresses: () => Promise; // EPG progress listener onEpgProgress?: ( diff --git a/docs/architecture/embedded-inline-playback.md b/docs/architecture/embedded-inline-playback.md index 6495a5eba..37d9af89d 100644 --- a/docs/architecture/embedded-inline-playback.md +++ b/docs/architecture/embedded-inline-playback.md @@ -5,6 +5,7 @@ This document records the current contract for embedded playback in portal detai ## Summary - Embedded web players are `videojs`, `html5`, and `artplayer`. +- `embedded-mpv` exists as a hidden macOS-only feasibility harness backed by a native `libmpv` addon. - 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. @@ -25,6 +26,43 @@ 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 +## Embedded MPV Harness + +The repository now contains a first-pass native embedded MPV harness for Electron: + +- shared setting id: `embedded-mpv` +- native addon owner: `/Users/4gray/Code/iptvnator/apps/electron-backend/src/app/services/embedded-mpv-native.service.ts` +- IPC bridge: `/Users/4gray/Code/iptvnator/apps/electron-backend/src/app/events/embedded-mpv.events.ts` +- renderer host: `/Users/4gray/Code/iptvnator/libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-player.component.ts` +- native architecture and release-readiness details: `/Users/4gray/Code/iptvnator/docs/architecture/embedded-mpv-native.md` + +Current contract: + +- macOS only +- experimental opt-in +- enabled in local development only when `IPTVNATOR_ENABLE_EMBEDDED_MPV_EXPERIMENT=1` +- enabled in packaged macOS builds only when the bundled native addon and `vendored-lgpl` libmpv runtime load successfully +- uses IPTVnator-owned controls and `ResolvedPortalPlayback` payloads +- uses the libmpv render API on macOS and renders through an IPTVnator-owned native `NSView` +- defaults to libmpv's OpenGL render backend with `hwdec=auto-safe` +- keeps the previous software renderer as a debug fallback via `IPTVNATOR_EMBEDDED_MPV_RENDERER=sw` +- emits lightweight render diagnostics when `IPTVNATOR_TRACE_EMBEDDED_MPV=1` is set +- exposes an IPTVnator-owned fullscreen button that uses the renderer fullscreen API and resyncs the native MPV view bounds after fullscreen transitions +- auto-hides IPTVnator-owned controls while playback is active and restores them on pointer/focus interaction +- exposes audio-track metadata from MPV and switches tracks through the `aid` property without reloading the stream +- passes VOD/episode resume offsets to MPV through the `loadfile` options map; live catchup URLs are treated as already-positioned streams +- applies the initial volume during session creation and uses async libmpv control calls after startup +- VLC remains external-only + +Current limitation: + +- the current feasibility harness is still experimental and macOS-specific +- the original macOS `wid` embedding path produced audio with a black video surface inside Electron, so the harness now avoids foreign-window embedding on macOS +- the OpenGL render path avoids the old per-frame `CGImage` copy path, but it still needs broader interaction, resize, and packaging coverage +- startup deadlocks seen during early macOS playback bring-up are mitigated, but the feature is still kept behind the explicit experiment flag until more interaction and packaging coverage is proven +- because of that, the setting is auto-sanitized back to the default inline player unless support detection reports that the experimental runtime is available +- this follows the rollout gate: keep the native work in-tree, but do not leave it user-facing until playback, resize, focus, and packaging are stable + ## Components Shared inline player shell: diff --git a/docs/architecture/embedded-mpv-native.md b/docs/architecture/embedded-mpv-native.md new file mode 100644 index 000000000..e87e989b0 --- /dev/null +++ b/docs/architecture/embedded-mpv-native.md @@ -0,0 +1,192 @@ +# Embedded MPV Native Integration + +This document explains how IPTVnator embeds MPV inside the Electron app, which files are source versus generated build output, and what must be true before the feature is safe to expose to users. + +## What To Commit + +Source files for the embedded MPV integration: + +- `apps/electron-backend/build-embedded-mpv.js` builds the native addon for the current Electron runtime. +- `apps/electron-backend/native/binding.gyp` defines the native addon build. +- `apps/electron-backend/native/src/embedded_mpv.mm` owns the macOS `libmpv` render integration. +- `apps/electron-backend/src/app/services/embedded-mpv-native.service.ts` owns Electron main-process session lifecycle and support detection. +- `apps/electron-backend/src/app/events/embedded-mpv.events.ts` registers the IPC contract. +- `apps/electron-backend/src/app/api/main.preload.ts` exposes the preload bridge to the renderer. +- `libs/shared/interfaces/src/lib/embedded-mpv-session.interface.ts` defines the shared session and audio-track contract. +- `libs/ui/playback/src/lib/embedded-mpv-player/` owns the Angular UI and controls. + +Generated native-addon build output: + +- `apps/electron-backend/native/build/` + +The build directory contains files such as `Makefile`, `binding.Makefile`, `config.gypi`, `embedded_mpv.target.mk`, `gyp-mac-tool`, `embedded_mpv.node`, `.o`, and `.d` files. These are generated by `node-gyp` and must not be committed. The repo `.gitignore` ignores this directory. + +## How It Is Embedded + +The embedded player does not spawn the normal `mpv` application and does not use `--wid` window reparenting. IPTVnator loads `libmpv` through a native Node addon and renders MPV frames into an app-owned native macOS view. + +The flow is: + +1. Angular receives a `ResolvedPortalPlayback` payload and renders `EmbeddedMpvPlayerComponent`. +2. The component paints a loading state before requesting native startup work. +3. If available, the preload API asks the main process to prepare the embedded MPV addon. This loads `embedded_mpv.node` and its dylibs, but does not create a native view or MPV playback session. +4. The component asks the preload API to create an embedded MPV session with the current viewport bounds and initial volume. +5. The Electron preload forwards calls through IPC to the main process. +6. `EmbeddedMpvNativeService` owns sessions, polls snapshots, and emits session updates to the renderer. +7. The native addon creates an `mpv_handle`, configures `vo=libmpv`, disables MPV's own OSC/input handling, and creates an app-owned `NSOpenGLView` inside the Electron window content view. +8. The addon creates a `mpv_render_context` and uses MPV's render API to draw frames into the OpenGL surface. +9. Resize, scroll, and fullscreen changes are measured in Angular and sent back to the addon as native bounds so the `NSView` stays aligned with the Angular layout. +10. Playback controls remain IPTVnator-owned Angular UI. MPV receives commands only through the controlled IPC surface. + +The renderer never gets direct native-module access. It can only call the preload contract: + +- prepare native addon +- create session +- load playback +- set bounds +- play/pause +- seek +- set volume +- set audio track +- dispose session +- subscribe to session updates + +Settings uses the preload support API only as a lightweight availability check. That check verifies platform, experiment gating, addon presence, and bundled `libmpv.2.dylib` presence without `require()`-loading `embedded_mpv.node`. This avoids blocking Settings navigation on macOS `dlopen` and code-signing work. + +When `embedded-mpv` is the saved player, the settings store schedules an idle `prepareEmbeddedMpv()` call. This intentionally moves the first native addon load away from the click-to-play path. It can still block the Electron main process briefly because Node native addon loading is synchronous, but doing it during idle is less visible than doing it when the user clicks a video. Actual MPV session creation still happens on playback because it needs the current Electron window handle and viewport bounds. + +The MPV video surface is a native AppKit/OpenGL view, not a normal DOM element. Do not place critical Angular overlays on top of the video viewport and expect CSS `z-index` to win. The embedded MPV controls use a compositor-safe control dock below the native viewport instead of a true overlay on top of the OpenGL surface. + +The dock has a stable reserved height while embedded controls are enabled. Controls fade in and out inside that fixed dock, so normal show/hide behavior does not resize the native MPV viewport or make the video jump. Volume and audio-track panels replace the default transport controls inside the same dock and provide a back button to return to the default controls. Popovers and menus must stay inside that dock unless the native layering strategy changes. The native MPV view deliberately ignores hit testing so mouse movement passes through to Chromium and can reveal Angular controls even when the pointer moves quickly across the video area. + +## Resume And Track Handling + +`ResolvedPortalPlayback.startTime` is treated as a media offset in seconds for VOD and episodes. The native addon passes it as the `start` option in one MPV `loadfile` options map together with title, user agent, referrer, and HTTP headers. + +Live catchup is different: the catchup URL already encodes the archive window, so live catchup playback must not pass an absolute Unix timestamp as `startTime`. + +Audio tracks are discovered from MPV's `track-list` property. The selected track is controlled through MPV's `aid` property. Switching tracks must not reload the stream. + +## Packaging State + +Current development behavior: + +- The addon build is macOS-only. +- The build script first looks for a staged runtime at `vendor/embedded-mpv/darwin-/`. +- The staged runtime must contain `include/mpv/client.h`, `lib/*.dylib`, and `runtime-manifest.json`. +- The compiled `.node` addon is copied into `dist/apps/electron-backend/native/embedded_mpv.node`. +- Bundled runtime files are copied into `dist/apps/electron-backend/native/lib/`. Most are `.dylib` files, but some Homebrew-linked runtimes expose non-`.dylib` Mach-O files such as a framework `Python` binary. +- `electron-builder` unpacks `.node` files from ASAR. +- `electron-builder` also unpacks `.dylib` files from ASAR. + +Current release caveat: + +- Release packaging requires a `vendored-lgpl` runtime manifest. +- Release packaging rejects embedded MPV binaries linked to `/opt/homebrew` or `/usr/local`. +- Local development can opt into Homebrew `libmpv` only by setting `IPTVNATOR_EMBEDDED_MPV_ALLOW_HOMEBREW=1`; packaged release validation rejects that runtime origin. + +Before public release, packaging must: + +- stage an LGPL-compatible `libmpv` and required dylibs for `darwin-arm64` and `darwin-x64` +- collect indirect dependencies expressed as absolute paths, `@loader_path`, or `@rpath` +- rewrite install names and dependency paths to app-relative paths such as `@loader_path` +- code-sign and notarize the full dependency set +- publish the corresponding FFmpeg/libmpv source and build metadata + +Users do not need the MPV GUI application for this architecture. IPTVnator bundles `libmpv` for release builds. If the bundled runtime is missing or fails to load, embedded MPV is hidden/unsupported and the existing inline/external players remain available. + +## Runtime Staging + +Runtime staging tooling lives in: + +- `/Users/4gray/Code/iptvnator/tools/embedded-mpv/` +- `/Users/4gray/Code/iptvnator/vendor/embedded-mpv/` + +Release runtime policy: + +- FFmpeg must be built without `--enable-gpl` and without `--enable-nonfree`. +- mpv must be built with `-Dlibmpv=true` and `-Dgpl=false`. +- The runtime must be dynamically linked and shipped with license/source-distribution notices. + +After building an LGPL-compatible prefix for an architecture: + +```bash +node tools/embedded-mpv/stage-macos-runtime.mjs arm64 /path/to/lgpl-prefix +node tools/embedded-mpv/stage-macos-runtime.mjs x64 /path/to/lgpl-prefix +``` + +Tagged macOS release CI builds that prefix from pinned source archives first: + +```bash +pnpm embedded-mpv:build-runtime -- arm64 /tmp/embedded-mpv-prefix +pnpm embedded-mpv:stage-runtime -- arm64 /tmp/embedded-mpv-prefix +``` + +The CI builder pins FFmpeg `8.1`, mpv `0.41.0`, libplacebo `7.360.1`, libass `0.17.3`, FreeType `2.13.3`, FriBidi `1.0.16`, and HarfBuzz `8.5.0`. FFmpeg disables autodetected external libraries so Homebrew libraries cannot silently enter the runtime. The generated manifest records source URLs, SHA-256 values, FFmpeg configure flags, and mpv Meson flags. The staging step normalizes that manifest to `origin: vendored-lgpl`, which release package validation requires. + +The Electron backend build consumes the staged runtime, copies Mach-O runtime files into the native build output, and rewrites Mach-O paths so `embedded_mpv.node` loads `@loader_path/lib/libmpv.2.dylib` instead of a machine-local Homebrew path. After `install_name_tool` rewrites any addon or runtime binary, the build re-signs that binary with an ad-hoc signature for local development. Release packaging still performs the normal app signing and notarization later. + +For local development before the vendored runtime exists, Homebrew can be used explicitly: + +```bash +IPTVNATOR_EMBEDDED_MPV_ALLOW_HOMEBREW=1 \ +IPTVNATOR_ENABLE_EMBEDDED_MPV_EXPERIMENT=1 \ +pnpm nx serve electron-backend +``` + +This path is intentionally development-only. Packaged macOS builds reject `homebrew-dev` manifests and any `/opt/homebrew` or `/usr/local` embedded MPV links. + +If the settings page does not show `Embedded MPV (Experimental, macOS)` after starting with those flags, check the native build output: + +```bash +ls apps/electron-backend/native/build/Release/embedded_mpv.node +``` + +If only `embedded-mpv-unavailable.txt` exists, the dev app started from a build where no runtime was available. Stop the Electron dev process and restart it with the flags. The native MPV build target is intentionally uncached because it depends on local runtime files and environment variables such as `IPTVNATOR_EMBEDDED_MPV_ALLOW_HOMEBREW` and `IPTVNATOR_EMBEDDED_MPV_ARCH`. + +If opening Settings hard-crashes Electron on macOS and the crash report says `Code Signature Invalid`, one of the copied runtime binaries was modified by `install_name_tool` without being re-signed. Rebuild the native target and verify the copied addon/runtime files: + +```bash +IPTVNATOR_EMBEDDED_MPV_ALLOW_HOMEBREW=1 node apps/electron-backend/build-embedded-mpv.js +codesign --verify --verbose=2 apps/electron-backend/native/build/Release/embedded_mpv.node +codesign --verify --verbose=2 apps/electron-backend/native/build/Release/lib/libmpv.2.dylib +``` + +If support detection reports a missing `@rpath/...` dependency, the dependency collector missed an indirect runtime file. The packaging helper must copy that file into `native/lib/`, rewrite the dependency to `@loader_path/`, and include non-`.dylib` Mach-O files in the asset copy glob. + +## Same-Version macOS Release Gate + +The normal release tag can produce Linux, Windows, and macOS artifacts from the same source version while only macOS carries the bundled Embedded MPV runtime. + +For tagged macOS builds, CI must: + +- build the pinned LGPL-compatible runtime for the matrix architecture +- stage it into `vendor/embedded-mpv/darwin-${arch}` before `pnpm run build:backend` +- set `IPTVNATOR_EMBEDDED_MPV_ARCH=${arch}` for backend build and packaging +- set `IPTVNATOR_REQUIRE_EMBEDDED_MPV=1` for packaging and package-layout verification + +For PR, development, Linux, and Windows packaging, `IPTVNATOR_REQUIRE_EMBEDDED_MPV` stays unset or `0`. In that mode the package validators still reject a present but invalid Embedded MPV runtime, but they do not require the addon to exist. This keeps the native feature in-tree without making non-macOS or non-release builds depend on macOS runtime artifacts. + +## Release Safety + +The feature is still experimental. The largest risks are native-process risks, not normal Angular UI risks: + +- a bad native addon or `libmpv` crash can crash the Electron main process +- packaging can fail if `libmpv` or one of its dylib dependencies is missing, unsigned, or linked to the wrong runtime path +- macOS graphics behavior can vary across Intel, Apple Silicon, external displays, fullscreen transitions, and hardware decoding paths +- Homebrew `libmpv` builds can target a newer macOS version than IPTVnator's declared deployment target + +It is reasonable to ship the code in-tree behind the current experiment flag. It is not yet safe to make it the default player. It can be exposed as macOS-only experimental if support detection is strict, the UI clearly labels it experimental, and fallback to Video.js or external MPV/VLC stays available. + +If an embedded session fails to initialize, the app should keep the user in control by preserving the normal player setting choices. If a native crash occurs, normal settings fallback cannot intercept that crash, so broader macOS smoke testing and packaged-app testing are required before broad release. + +## Suggested Release Gate + +Do not expose embedded MPV broadly until these pass on both Apple Silicon and Intel macOS: + +- packaged `.app` starts without Homebrew installed +- bundled `libmpv` and dependent dylibs pass code signing and notarization +- VOD resume starts near the saved offset +- live HLS, MPEG-TS, MP4/VOD, headers, referrer, volume, seek, fullscreen, route changes, and cleanup work +- audio-track switching works on a stream with multiple audio tracks +- fallback behavior is clear when the addon or native dependencies are unavailable diff --git a/electron-builder.json b/electron-builder.json index 4335e20d7..3cfaefd57 100644 --- a/electron-builder.json +++ b/electron-builder.json @@ -19,7 +19,9 @@ "asarUnpack": [ "**/better-sqlite3/**", "**/bindings/**", - "**/file-uri-to-path/**" + "**/file-uri-to-path/**", + "**/*.node", + "**/*.dylib" ], "extraMetadata": { "main": "electron-backend/main.js" @@ -95,7 +97,7 @@ ] ] }, - "afterPack": "./tools/packaging/linux-after-pack.cjs", + "afterPack": "./tools/packaging/electron-after-pack.cjs", "snap": { "base": "core22", "confinement": "strict", diff --git a/global.d.ts b/global.d.ts index 80b615bb3..6f812dc9c 100644 --- a/global.d.ts +++ b/global.d.ts @@ -1,4 +1,7 @@ import 'jest-extended'; +import { EmbeddedMpvBounds } from './libs/shared/interfaces/src/lib/embedded-mpv-session.interface'; +import { EmbeddedMpvSession } from './libs/shared/interfaces/src/lib/embedded-mpv-session.interface'; +import { EmbeddedMpvSupport } from './libs/shared/interfaces/src/lib/embedded-mpv-session.interface'; import { ExternalPlayerSession } from './libs/shared/interfaces/src/lib/external-player-session.interface'; import { PlaybackPositionData } from './libs/shared/interfaces/src/lib/playback-position.interface'; import { @@ -11,6 +14,7 @@ import { PlaylistRefreshPayload, } from './libs/shared/interfaces/src/lib/playlist-refresh.interface'; import { Playlist } from './libs/shared/interfaces/src/lib/playlist.interface'; +import { ResolvedPortalPlayback } from './libs/shared/interfaces/src/lib/portal-playback.interface'; declare module 'video.js' { export interface VideoJsPlayer { @@ -111,9 +115,46 @@ declare global { onExternalPlayerSessionUpdate?: ( callback: (data: ExternalPlayerSession) => void ) => () => void; + onEmbeddedMpvSessionUpdate?: ( + callback: (data: EmbeddedMpvSession) => void + ) => () => void; closeExternalPlayerSession: ( sessionId: string ) => Promise; + getEmbeddedMpvSupport: () => Promise; + prepareEmbeddedMpv?: () => Promise; + createEmbeddedMpvSession: ( + bounds: EmbeddedMpvBounds, + title?: string, + initialVolume?: number + ) => Promise; + loadEmbeddedMpvPlayback: ( + sessionId: string, + playback: ResolvedPortalPlayback + ) => Promise; + setEmbeddedMpvBounds: ( + sessionId: string, + bounds: EmbeddedMpvBounds + ) => Promise; + setEmbeddedMpvPaused: ( + sessionId: string, + paused: boolean + ) => Promise; + seekEmbeddedMpv: ( + sessionId: string, + seconds: number + ) => Promise; + setEmbeddedMpvVolume: ( + sessionId: string, + volume: number + ) => Promise; + setEmbeddedMpvAudioTrack: ( + sessionId: string, + trackId: number + ) => Promise; + disposeEmbeddedMpvSession: ( + sessionId: string + ) => Promise; stalkerRequest: (payload: { url: string; macAddress: string; diff --git a/libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.html b/libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.html index e2a5c0271..e05f91d34 100644 --- a/libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.html +++ b/libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.html @@ -68,6 +68,13 @@ [showCaptions]="playerSettings.showCaptions!" /> } + @if (playerSettings.player === 'embedded-mpv') { + + } }
} diff --git a/libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.spec.ts b/libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.spec.ts index a7c1062de..9b4cd7e3f 100644 --- a/libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.spec.ts +++ b/libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.spec.ts @@ -282,6 +282,23 @@ describe('VideoPlayerComponent', () => { ).not.toBeNull(); }); + it('renders the embedded mpv inline player with the EPG panel', () => { + syncStoreState(sampleChannel); + player.set(VideoPlayer.EmbeddedMpv); + + fixture.detectChanges(); + + expect( + fixture.nativeElement.querySelector('.video-player') + ).not.toBeNull(); + expect( + fixture.nativeElement.querySelector('app-web-player-view') + ).not.toBeNull(); + expect( + fixture.nativeElement.querySelector('app-epg-list') + ).not.toBeNull(); + }); + it('renders only the EPG panel when an external player is configured', () => { syncStoreState(sampleChannel); player.set(VideoPlayer.MPV); diff --git a/libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.ts b/libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.ts index 96501d4da..fb96848db 100644 --- a/libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.ts +++ b/libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.ts @@ -61,6 +61,7 @@ import { HtmlVideoPlayerComponent, SidebarComponent, VjsPlayerComponent, + WebPlayerViewComponent, } from '@iptvnator/ui/playback'; import { ChannelListLoadingStateComponent } from 'components'; import { DataService, PlaylistsService, SettingsStore } from 'services'; @@ -71,6 +72,7 @@ import { PLAYLIST_PARSE_BY_URL, M3uRecentlyViewedItem, PlaylistMeta, + ResolvedPortalPlayback, STORE_KEY, Settings, VideoPlayer, @@ -98,6 +100,7 @@ const M3U_SIDEBAR_DEFAULT_WIDTH = 460; SidebarComponent, TranslatePipe, VjsPlayerComponent, + WebPlayerViewComponent, ], templateUrl: './video-player.component.html', styleUrl: './video-player.component.scss', @@ -145,6 +148,38 @@ export class VideoPlayerComponent implements OnInit, OnDestroy { epgParams: '', } as Channel; }); + readonly embeddedPlayback = computed(() => { + const activeChannel = this.activeChannel(); + const playbackTarget = this.playbackChannel(); + + if (!activeChannel || !playbackTarget) { + return null; + } + + const headers: Record = {}; + if (playbackTarget.http['user-agent']) { + headers['User-Agent'] = playbackTarget.http['user-agent']; + } + if (playbackTarget.http.referrer) { + headers['Referer'] = playbackTarget.http.referrer; + } + if (playbackTarget.http.origin) { + headers['Origin'] = playbackTarget.http.origin; + } + + return { + streamUrl: `${playbackTarget.url}${playbackTarget.epgParams ?? ''}`, + title: + activeChannel.name?.trim() || + activeChannel.tvg?.name || + playbackTarget.url, + thumbnail: activeChannel.tvg?.logo ?? null, + headers: Object.keys(headers).length > 0 ? headers : undefined, + userAgent: playbackTarget.http['user-agent'] || undefined, + referer: playbackTarget.http.referrer || undefined, + origin: playbackTarget.http.origin || undefined, + }; + }); readonly sidebarStorageKey = computed(() => this.activeView() === 'groups' ? M3U_GROUPS_SIDEBAR_STORAGE_KEY diff --git a/libs/portal/shared/ui/src/lib/components/unified-collection/unified-live-tab.component.html b/libs/portal/shared/ui/src/lib/components/unified-collection/unified-live-tab.component.html index 4ea757eb9..83ce255e1 100644 --- a/libs/portal/shared/ui/src/lib/components/unified-collection/unified-live-tab.component.html +++ b/libs/portal/shared/ui/src/lib/components/unified-collection/unified-live-tab.component.html @@ -88,6 +88,13 @@ [showCaptions]="false" /> } + @if (player() === 'embedded-mpv') { + + } }
} diff --git a/libs/portal/shared/ui/src/lib/components/unified-collection/unified-live-tab.component.ts b/libs/portal/shared/ui/src/lib/components/unified-collection/unified-live-tab.component.ts index e742fa4b7..23f2e6ff3 100644 --- a/libs/portal/shared/ui/src/lib/components/unified-collection/unified-live-tab.component.ts +++ b/libs/portal/shared/ui/src/lib/components/unified-collection/unified-live-tab.component.ts @@ -38,7 +38,7 @@ import { import { ResizableDirective } from 'components'; import { SettingsStore } from 'services'; import { Channel, EpgProgram } from 'shared-interfaces'; -import { EpgViewComponent } from 'shared-portals'; +import { EpgViewComponent, WebPlayerViewComponent } from 'shared-portals'; @Component({ selector: 'app-unified-live-tab', @@ -59,6 +59,7 @@ import { EpgViewComponent } from 'shared-portals'; ResizableDirective, TranslatePipe, VjsPlayerComponent, + WebPlayerViewComponent, ], }) export class UnifiedLiveTabComponent { diff --git a/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-stream-layout.component.html b/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-stream-layout.component.html index 6073d211e..c9b2aa816 100644 --- a/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-stream-layout.component.html +++ b/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-stream-layout.component.html @@ -79,7 +79,11 @@ } @else if (stalkerStore.selectedItem()) { @if (streamUrl() && isEmbeddedPlayer) {
- +
}
diff --git a/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-stream-layout.component.spec.ts b/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-stream-layout.component.spec.ts index 643411369..4cb837a40 100644 --- a/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-stream-layout.component.spec.ts +++ b/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-stream-layout.component.spec.ts @@ -43,6 +43,8 @@ class StubChannelListItemComponent { }) class StubWebPlayerViewComponent { readonly streamUrl = input(''); + readonly title = input(''); + readonly playback = input(null); } @Component({ diff --git a/libs/portal/xtream/feature/src/lib/live-stream-layout/live-stream-layout.component.html b/libs/portal/xtream/feature/src/lib/live-stream-layout/live-stream-layout.component.html index e8bd8c2e9..2445ae6ce 100644 --- a/libs/portal/xtream/feature/src/lib/live-stream-layout/live-stream-layout.component.html +++ b/libs/portal/xtream/feature/src/lib/live-stream-layout/live-stream-layout.component.html @@ -76,7 +76,11 @@ } @else if (streamUrl) { @if (isEmbeddedPlayer) {
- +
}
diff --git a/libs/portal/xtream/feature/src/lib/live-stream-layout/live-stream-layout.component.spec.ts b/libs/portal/xtream/feature/src/lib/live-stream-layout/live-stream-layout.component.spec.ts index a0e08ea33..e1899fb09 100644 --- a/libs/portal/xtream/feature/src/lib/live-stream-layout/live-stream-layout.component.spec.ts +++ b/libs/portal/xtream/feature/src/lib/live-stream-layout/live-stream-layout.component.spec.ts @@ -40,6 +40,8 @@ class StubPortalChannelsListComponent { }) class StubWebPlayerViewComponent { readonly streamUrl = input(''); + readonly title = input(''); + readonly playback = input(null); } @Component({ diff --git a/libs/portal/xtream/feature/src/lib/live-stream-layout/live-stream-layout.component.ts b/libs/portal/xtream/feature/src/lib/live-stream-layout/live-stream-layout.component.ts index bfc6b8a08..884fd5b92 100644 --- a/libs/portal/xtream/feature/src/lib/live-stream-layout/live-stream-layout.component.ts +++ b/libs/portal/xtream/feature/src/lib/live-stream-layout/live-stream-layout.component.ts @@ -35,7 +35,11 @@ import { import { EpgListComponent, EpgProgramActivationEvent } from '@iptvnator/ui/epg'; import { MatProgressSpinnerModule } from '@angular/material/progress-spinner'; import { EpgViewComponent, WebPlayerViewComponent } from 'shared-portals'; -import { EpgItem, EpgProgram } from 'shared-interfaces'; +import { + EpgItem, + EpgProgram, + ResolvedPortalPlayback, +} from 'shared-interfaces'; import { PortalChannelsListComponent } from '../portal-channels-list/portal-channels-list.component'; import { ActivatedRoute } from '@angular/router'; @@ -168,7 +172,10 @@ export class LiveStreamLayoutComponent implements OnInit, OnDestroy { readonly usesEmbeddedPlayer = computed(() => this.portalPlayer.isEmbeddedPlayer() ); - readonly activeStreamUrl = signal(''); + readonly activePlayback = signal(null); + readonly activeStreamUrl = computed( + () => this.activePlayback()?.streamUrl ?? '' + ); favorites = new Map(); constructor() { @@ -291,7 +298,11 @@ export class LiveStreamLayoutComponent implements OnInit, OnDestroy { playLive(item: XtreamLiveChannelItem) { const streamUrl = this.xtreamStore.constructStreamUrl(item); - this.activeStreamUrl.set(streamUrl); + this.activePlayback.set({ + streamUrl, + title: item.title ?? item.name ?? '', + thumbnail: item.poster_url ?? item.stream_icon ?? null, + }); if (this.usesEmbeddedPlayer()) { return; } @@ -406,7 +417,11 @@ export class LiveStreamLayoutComponent implements OnInit, OnDestroy { stopTimestamp ); - this.activeStreamUrl.set(catchupUrl); + this.activePlayback.set({ + streamUrl: catchupUrl, + title: this.getCatchupPlaybackTitle(item, program), + thumbnail: item.poster_url ?? item.stream_icon ?? null, + }); if (this.usesEmbeddedPlayer()) { return; } 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 index 9e30ad82d..6f547a7d5 100644 --- 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 @@ -2,6 +2,8 @@ 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 index 74a27115b..7218c4908 100644 --- 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 @@ -7,7 +7,7 @@ import { MatSnackBar } from '@angular/material/snack-bar'; import { MatTooltip } from '@angular/material/tooltip'; import { TranslatePipe, TranslateService } from '@ngx-translate/core'; import { PORTAL_PLAYBACK_POSITIONS } from '@iptvnator/portal/shared/util'; -import { PlayerContentInfo } from 'shared-interfaces'; +import { PlayerContentInfo, ResolvedPortalPlayback } from 'shared-interfaces'; import { WebPlayerViewComponent } from 'shared-portals'; export interface PlayerDialogData { @@ -15,6 +15,7 @@ export interface PlayerDialogData { title: string; contentInfo?: PlayerContentInfo; startTime?: number; + playback?: ResolvedPortalPlayback; } @Component({ @@ -43,8 +44,8 @@ export class PlayerDialogComponent { private lastSaveTime = 0; constructor() { - this.streamUrl = this.data.streamUrl; - this.title = this.data.title; + this.streamUrl = this.data.playback?.streamUrl ?? this.data.streamUrl; + this.title = this.data.playback?.title ?? this.data.title; } handleTimeUpdate(event: { currentTime: number; duration: number }) { diff --git a/libs/services/src/lib/settings-store.service.ts b/libs/services/src/lib/settings-store.service.ts index cc2f9e6a9..2e32aa1f7 100644 --- a/libs/services/src/lib/settings-store.service.ts +++ b/libs/services/src/lib/settings-store.service.ts @@ -36,6 +36,45 @@ const DEFAULT_SETTINGS: Settings = { downloadFolder: '', }; +let embeddedMpvPrepareScheduled = false; + +function scheduleEmbeddedMpvPrepare(): void { + if ( + embeddedMpvPrepareScheduled || + typeof window === 'undefined' || + !window.electron?.prepareEmbeddedMpv + ) { + return; + } + + embeddedMpvPrepareScheduled = true; + const prepare = () => { + void window.electron + .prepareEmbeddedMpv?.() + .then((support) => { + if (!support?.supported) { + embeddedMpvPrepareScheduled = false; + } + }) + .catch((error) => { + embeddedMpvPrepareScheduled = false; + console.warn('Failed to prepare embedded MPV.', error); + }); + }; + const idleWindow = window as typeof window & { + requestIdleCallback?: ( + callback: IdleRequestCallback, + options?: IdleRequestOptions + ) => number; + }; + + if (idleWindow.requestIdleCallback) { + idleWindow.requestIdleCallback(prepare, { timeout: 5000 }); + } else { + window.setTimeout(prepare, 2000); + } +} + export const SettingsStore = signalStore( { providedIn: 'root' }, withState(DEFAULT_SETTINGS), @@ -50,6 +89,12 @@ export const SettingsStore = signalStore( ...DEFAULT_SETTINGS, ...(stored as Settings), }); + void this.sanitizeEmbeddedMpvSelection().catch((error) => { + console.warn( + 'Failed to verify embedded MPV support while loading settings.', + error + ); + }); } } catch (error) { console.error('Failed to load settings:', error); @@ -65,6 +110,9 @@ export const SettingsStore = signalStore( await firstValueFrom( storage.set(STORE_KEY.Settings, completeSettings) ); + if (completeSettings.player === VideoPlayer.EmbeddedMpv) { + scheduleEmbeddedMpvPrepare(); + } } catch (error) { console.error('Failed to save settings:', error); throw error; @@ -102,9 +150,47 @@ export const SettingsStore = signalStore( isEmbeddedPlayer() { return ( store.player() === VideoPlayer.VideoJs || - store.player() === VideoPlayer.Html5Player + store.player() === VideoPlayer.Html5Player || + store.player() === VideoPlayer.ArtPlayer || + store.player() === VideoPlayer.EmbeddedMpv ); }, + + async sanitizeEmbeddedMpvSelection() { + if (store.player() !== VideoPlayer.EmbeddedMpv) { + return; + } + + if ( + typeof window === 'undefined' || + !window.electron?.getEmbeddedMpvSupport + ) { + await this.updateSettings({ + player: DEFAULT_SETTINGS.player, + }); + return; + } + + try { + const support = await window.electron.getEmbeddedMpvSupport(); + if (!support.supported) { + await this.updateSettings({ + player: DEFAULT_SETTINGS.player, + }); + return; + } + + scheduleEmbeddedMpvPrepare(); + } catch (error) { + console.warn( + 'Failed to verify embedded MPV support; reverting to the default inline player.', + error + ); + await this.updateSettings({ + player: DEFAULT_SETTINGS.player, + }); + } + }, })), withHooks({ onInit(store) { diff --git a/libs/shared/interfaces/src/index.ts b/libs/shared/interfaces/src/index.ts index 3bbeab1e7..ac1cb4fbd 100644 --- a/libs/shared/interfaces/src/index.ts +++ b/libs/shared/interfaces/src/index.ts @@ -1,5 +1,6 @@ export * from './lib/channel.interface'; export * from './lib/channel.model'; +export * from './lib/embedded-mpv-session.interface'; export * from './lib/epg-channel-metadata.model'; export * from './lib/epg-channel-with-programs.interface'; export * from './lib/epg-channel.model'; diff --git a/libs/shared/interfaces/src/lib/embedded-mpv-session.interface.ts b/libs/shared/interfaces/src/lib/embedded-mpv-session.interface.ts new file mode 100644 index 000000000..94d826133 --- /dev/null +++ b/libs/shared/interfaces/src/lib/embedded-mpv-session.interface.ts @@ -0,0 +1,44 @@ +export type EmbeddedMpvSessionStatus = + | 'idle' + | 'loading' + | 'playing' + | 'paused' + | 'error' + | 'closed'; + +export interface EmbeddedMpvBounds { + x: number; + y: number; + width: number; + height: number; +} + +export interface EmbeddedMpvSupport { + supported: boolean; + platform: string; + reason?: string; +} + +export interface EmbeddedMpvAudioTrack { + id: number; + title?: string; + language?: string; + selected: boolean; + defaultTrack?: boolean; + forced?: boolean; +} + +export interface EmbeddedMpvSession { + id: string; + title: string; + streamUrl: string; + status: EmbeddedMpvSessionStatus; + positionSeconds: number; + durationSeconds: number | null; + volume: number; + audioTracks: EmbeddedMpvAudioTrack[]; + selectedAudioTrackId: number | null; + startedAt: string; + updatedAt: string; + error?: string; +} diff --git a/libs/shared/interfaces/src/lib/ipc-commands.ts b/libs/shared/interfaces/src/lib/ipc-commands.ts index c26b14708..5103feeae 100644 --- a/libs/shared/interfaces/src/lib/ipc-commands.ts +++ b/libs/shared/interfaces/src/lib/ipc-commands.ts @@ -37,6 +37,17 @@ export const SET_VLC_PLAYER_PATH = 'SET_VLC_PLAYER_PATH'; export const CLOSE_EXTERNAL_PLAYER_SESSION = 'CLOSE_EXTERNAL_PLAYER_SESSION'; export const EXTERNAL_PLAYER_SESSION_UPDATE = 'EXTERNAL_PLAYER_SESSION_UPDATE'; +export const EMBEDDED_MPV_SUPPORT = 'EMBEDDED_MPV_SUPPORT'; +export const EMBEDDED_MPV_CREATE_SESSION = 'EMBEDDED_MPV_CREATE_SESSION'; +export const EMBEDDED_MPV_LOAD_PLAYBACK = 'EMBEDDED_MPV_LOAD_PLAYBACK'; +export const EMBEDDED_MPV_SET_BOUNDS = 'EMBEDDED_MPV_SET_BOUNDS'; +export const EMBEDDED_MPV_SET_PAUSED = 'EMBEDDED_MPV_SET_PAUSED'; +export const EMBEDDED_MPV_SEEK = 'EMBEDDED_MPV_SEEK'; +export const EMBEDDED_MPV_SET_VOLUME = 'EMBEDDED_MPV_SET_VOLUME'; +export const EMBEDDED_MPV_SET_AUDIO_TRACK = 'EMBEDDED_MPV_SET_AUDIO_TRACK'; +export const EMBEDDED_MPV_DISPOSE_SESSION = 'EMBEDDED_MPV_DISPOSE_SESSION'; +export const EMBEDDED_MPV_SESSION_UPDATE = 'EMBEDDED_MPV_SESSION_UPDATE'; +export const EMBEDDED_MPV_PREPARE = 'EMBEDDED_MPV_PREPARE'; // Xtream export const XTREAM_REQUEST = 'XTREAM_REQUEST'; diff --git a/libs/shared/interfaces/src/lib/settings.interface.ts b/libs/shared/interfaces/src/lib/settings.interface.ts index 78a74fcab..80451e366 100644 --- a/libs/shared/interfaces/src/lib/settings.interface.ts +++ b/libs/shared/interfaces/src/lib/settings.interface.ts @@ -8,6 +8,7 @@ import { Theme } from './theme.enum'; export enum VideoPlayer { VideoJs = 'videojs', Html5Player = 'html5', + EmbeddedMpv = 'embedded-mpv', MPV = 'mpv', VLC = 'vlc', ArtPlayer = 'artplayer', diff --git a/libs/ui/playback/src/index.ts b/libs/ui/playback/src/index.ts index 3865bcaf6..402a88515 100644 --- a/libs/ui/playback/src/index.ts +++ b/libs/ui/playback/src/index.ts @@ -1,5 +1,6 @@ export * from './lib/art-player/art-player.component'; export * from './lib/audio-player/audio-player.component'; +export * from './lib/embedded-mpv-player/embedded-mpv-player.component'; export * from './lib/external-player-info-dialog/external-player-info-dialog.component'; export * from './lib/html-video-player/html-video-player.component'; export * from './lib/portal-inline-player/portal-inline-player.component'; diff --git a/libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-player.component.html b/libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-player.component.html new file mode 100644 index 000000000..41dd2a6bb --- /dev/null +++ b/libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-player.component.html @@ -0,0 +1,232 @@ +
+
+ @if (statusLabel()) { +
+ {{ statusLabel() }} +
+ } +
+ + @if (showControls() && isSupported()) { +
+ @if (volumePopoverOpen()) { +
+ + + +
+ } @else if (audioMenuOpen()) { +
+ + + +
+ } @else { +
+ + + + + +
+ +
+ +
+ {{ + formatTime(session()?.positionSeconds) + }} + {{ + canSeek() + ? formatTime(session()?.durationSeconds) + : 'LIVE' + }} +
+
+ +
+
+ +
+ + @if (hasAudioTracks()) { +
+ +
+ } + + +
+ } +
+ } +
diff --git a/libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-player.component.scss b/libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-player.component.scss new file mode 100644 index 000000000..54afd0639 --- /dev/null +++ b/libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-player.component.scss @@ -0,0 +1,292 @@ +:host { + display: block; + height: 100%; +} + +.embedded-mpv-player { + --embedded-mpv-accent: #7dd3fc; + --embedded-mpv-glass: rgba(6, 10, 18, 0.78); + --embedded-mpv-border: rgba(255, 255, 255, 0.12); + --embedded-mpv-controls-height: 96px; + + position: relative; + display: block; + height: 100%; + min-height: 0; + background: + linear-gradient(180deg, rgba(10, 14, 24, 0.08), rgba(10, 14, 24, 0.24)), + #02050a; + border-radius: inherit; + overflow: hidden; +} + +.embedded-mpv-player--cursor-hidden { + cursor: none; +} + +.embedded-mpv-player:fullscreen, +.embedded-mpv-player--fullscreen { + width: 100vw; + height: 100vh; + border-radius: 0; +} + +.embedded-mpv-player__viewport { + position: absolute; + inset: 0; + min-height: 0; + background: + radial-gradient(circle at top, rgba(96, 125, 139, 0.24), transparent 58%), + linear-gradient(180deg, rgba(6, 8, 13, 0.88), rgba(2, 4, 8, 0.98)); +} + +.embedded-mpv-player--controls-enabled .embedded-mpv-player__viewport { + bottom: var(--embedded-mpv-controls-height); +} + +.embedded-mpv-player__status { + position: absolute; + inset: auto 16px 16px 16px; + padding: 10px 12px; + color: rgba(245, 247, 250, 0.94); + background: rgba(7, 10, 16, 0.72); + border: 1px solid rgba(255, 255, 255, 0.08); + border-radius: 12px; + backdrop-filter: blur(14px); + font-size: 0.9rem; + line-height: 1.4; + pointer-events: none; + z-index: 1; +} + +.embedded-mpv-player__controls { + position: absolute; + right: 0; + bottom: 0; + left: 0; + z-index: 2; + display: grid; + grid-template-columns: auto minmax(0, 1fr) auto; + gap: 18px; + align-items: center; + align-content: center; + height: var(--embedded-mpv-controls-height); + padding: 12px 18px 14px; + opacity: 0; + pointer-events: none; + transform: translateY(14px); + overflow: hidden; + background: + linear-gradient(180deg, rgba(2, 5, 10, 0.28), rgba(5, 9, 15, 0.88) 30%), + linear-gradient(180deg, rgba(12, 18, 29, 0.82), rgba(5, 9, 15, 0.94)); + border-top: 1px solid rgba(255, 255, 255, 0.06); + box-shadow: 0 -28px 70px rgba(0, 0, 0, 0.46); + backdrop-filter: blur(18px); + transition: + opacity 160ms ease, + transform 180ms ease; +} + +.embedded-mpv-player__controls--visible { + opacity: 1; + pointer-events: auto; + transform: translateY(0); +} + +.embedded-mpv-player__controls--panel-open { + background: + linear-gradient(180deg, rgba(2, 5, 10, 0.42), rgba(5, 9, 15, 0.92) 28%), + linear-gradient(180deg, rgba(12, 18, 29, 0.9), rgba(5, 9, 15, 0.98)); +} + +.embedded-mpv-player__transport { + display: flex; + align-items: center; + gap: 4px; +} + +.embedded-mpv-player__timeline { + display: flex; + flex-direction: column; + gap: 8px; + min-width: 0; +} + +.embedded-mpv-player__time { + display: flex; + justify-content: space-between; + gap: 12px; + color: rgba(226, 232, 240, 0.84); + font-size: 0.78rem; +} + +.embedded-mpv-player__actions { + display: flex; + align-items: center; + gap: 6px; +} + +.embedded-mpv-player__popover-anchor { + position: relative; + display: flex; +} + +.embedded-mpv-player__control-button { + position: relative; +} + +.embedded-mpv-player__slider { + width: 100%; + accent-color: var(--embedded-mpv-accent); +} + +.embedded-mpv-player__control-panel { + min-width: 0; + color: rgba(240, 247, 255, 0.94); + background: + linear-gradient(180deg, rgba(17, 26, 39, 0.9), rgba(5, 9, 16, 0.94)); + border: 1px solid var(--embedded-mpv-border); + border-radius: 18px; + box-shadow: + 0 18px 50px rgba(0, 0, 0, 0.42), + inset 0 1px 0 rgba(255, 255, 255, 0.08); + backdrop-filter: blur(18px); +} + +.embedded-mpv-player__mode-panel { + display: grid; + grid-column: 1 / -1; + grid-template-columns: auto minmax(0, 1fr); + gap: 12px; + align-items: center; + min-width: 0; +} + +.embedded-mpv-player__back-button { + color: rgba(241, 245, 249, 0.88); + background: rgba(255, 255, 255, 0.06); +} + +.embedded-mpv-player__volume-panel { + display: grid; + grid-template-columns: auto minmax(140px, 1fr) auto; + gap: 12px; + align-items: center; + padding: 10px 12px; +} + +.embedded-mpv-player__volume-panel mat-icon { + color: var(--embedded-mpv-accent); + font-size: 20px; + width: 20px; + height: 20px; +} + +.embedded-mpv-player__volume-value { + color: rgba(226, 232, 240, 0.82); + font-size: 0.76rem; + white-space: nowrap; +} + +.embedded-mpv-player__audio-menu { + display: grid; + grid-template-columns: auto minmax(0, 1fr); + gap: 12px; + align-items: center; + padding: 8px 10px; +} + +.embedded-mpv-player__menu-title { + padding: 0 2px; + color: rgba(226, 232, 240, 0.7); + font-size: 0.72rem; + font-weight: 700; + letter-spacing: 0.08em; + text-transform: uppercase; + white-space: nowrap; +} + +.embedded-mpv-player__audio-track-list { + display: flex; + gap: 6px; + min-width: 0; + overflow-x: auto; + overflow-y: hidden; + scrollbar-width: thin; +} + +.embedded-mpv-player__audio-track { + display: flex; + flex: 0 0 auto; + min-width: 154px; + max-width: 240px; + align-items: center; + justify-content: space-between; + gap: 12px; + padding: 8px 10px; + color: rgba(241, 245, 249, 0.88); + background: transparent; + border: 0; + border-radius: 12px; + cursor: pointer; + font: inherit; + text-align: left; +} + +.embedded-mpv-player__audio-track span { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.embedded-mpv-player__audio-track:hover, +.embedded-mpv-player__audio-track--selected { + color: #f8fafc; + background: rgba(125, 211, 252, 0.16); +} + +.embedded-mpv-player__audio-track mat-icon { + color: var(--embedded-mpv-accent); + font-size: 18px; + width: 18px; + height: 18px; +} + +.embedded-mpv-player :is(button[mat-icon-button]) { + color: rgba(241, 245, 249, 0.9); +} + +.embedded-mpv-player :is(button[mat-icon-button]:hover) { + color: #ffffff; + background: rgba(125, 211, 252, 0.14); +} + +@media (max-width: 720px) { + .embedded-mpv-player { + --embedded-mpv-controls-height: 148px; + } + + .embedded-mpv-player__controls { + grid-template-columns: 1fr; + gap: 12px; + } + + .embedded-mpv-player__actions { + justify-content: flex-end; + min-width: 0; + } + + .embedded-mpv-player__volume-panel { + grid-template-columns: auto 1fr; + } + + .embedded-mpv-player__volume-value { + grid-column: 1 / -1; + } + + .embedded-mpv-player__mode-panel--audio + .embedded-mpv-player__audio-menu { + grid-template-columns: 1fr; + gap: 6px; + } +} diff --git a/libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-player.component.ts b/libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-player.component.ts new file mode 100644 index 000000000..84eca7385 --- /dev/null +++ b/libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-player.component.ts @@ -0,0 +1,686 @@ +import { + ChangeDetectionStrategy, + Component, + ElementRef, + EventEmitter, + OnDestroy, + Output, + computed, + effect, + input, + signal, + untracked, + viewChild, +} from '@angular/core'; +import { MatButtonModule } from '@angular/material/button'; +import { MatIconModule } from '@angular/material/icon'; +import { MatTooltipModule } from '@angular/material/tooltip'; +import { + EmbeddedMpvAudioTrack, + EmbeddedMpvBounds, + EmbeddedMpvSession, + EmbeddedMpvSupport, + ResolvedPortalPlayback, +} from 'shared-interfaces'; + +@Component({ + selector: 'app-embedded-mpv-player', + templateUrl: './embedded-mpv-player.component.html', + styleUrl: './embedded-mpv-player.component.scss', + imports: [MatButtonModule, MatIconModule, MatTooltipModule], + changeDetection: ChangeDetectionStrategy.OnPush, + host: { + class: 'embedded-mpv-player-host', + }, +}) +export class EmbeddedMpvPlayerComponent implements OnDestroy { + readonly playback = input.required(); + readonly showControls = input(true); + + @Output() timeUpdate = new EventEmitter<{ + currentTime: number; + duration: number; + }>(); + + readonly viewport = viewChild>('viewport'); + readonly playerRoot = viewChild>('playerRoot'); + readonly support = signal(null); + readonly session = signal(null); + readonly sessionId = signal(null); + readonly volume = signal(this.readStoredVolume()); + readonly isFullscreen = signal(false); + readonly controlsVisible = signal(true); + readonly volumePopoverOpen = signal(false); + readonly audioMenuOpen = signal(false); + + readonly isSupported = computed(() => this.support()?.supported ?? false); + readonly canFullscreen = computed( + () => + typeof document !== 'undefined' && + Boolean(this.playerRoot()?.nativeElement.requestFullscreen) && + Boolean(document.exitFullscreen) + ); + readonly isLoading = computed( + () => + !this.support() || + this.session()?.status === 'loading' || + (!this.session() && this.isSupported()) + ); + readonly isPaused = computed( + () => this.session()?.status === 'paused' || this.session()?.status === 'idle' + ); + readonly isPlaying = computed(() => this.session()?.status === 'playing'); + readonly canSeek = computed( + () => (this.session()?.durationSeconds ?? 0) > 0 + ); + readonly statusLabel = computed(() => { + const session = this.session(); + if (session?.status === 'error') { + return session.error ?? 'Embedded MPV playback failed.'; + } + + if (!this.support()) { + return 'Checking embedded MPV support...'; + } + + if (!this.isSupported()) { + return ( + this.support()?.reason ?? + 'Embedded MPV is not available in this environment.' + ); + } + + if (!session || session.status === 'loading') { + return 'Loading stream in MPV...'; + } + + return ''; + }); + readonly fullscreenLabel = computed(() => + this.isFullscreen() ? 'Exit fullscreen' : 'Enter fullscreen' + ); + readonly audioTracks = computed( + () => this.session()?.audioTracks ?? [] + ); + readonly hasAudioTracks = computed(() => this.audioTracks().length > 1); + readonly selectedAudioTrack = computed( + () => this.audioTracks().find((track) => track.selected) ?? null + ); + readonly controlsAreVisible = computed( + () => + this.showControls() && + this.isSupported() && + (this.controlsVisible() || + this.isLoading() || + this.isPaused() || + this.volumePopoverOpen() || + this.audioMenuOpen() || + Boolean(this.statusLabel())) + ); + readonly hideCursor = computed( + () => + this.isFullscreen() && + this.isPlaying() && + !this.controlsAreVisible() + ); + readonly volumeIcon = computed(() => { + const value = this.volume(); + if (value <= 0) { + return 'volume_off'; + } + + return value < 0.5 ? 'volume_down' : 'volume_up'; + }); + readonly volumeLabel = computed( + () => `Volume ${Math.round(this.volume() * 100)}%` + ); + readonly timelineValue = computed(() => + Math.max(0, this.session()?.positionSeconds ?? 0) + ); + + private readonly unsubscribeSessionUpdate = + window.electron?.onEmbeddedMpvSessionUpdate?.((session) => { + if (session.id !== this.sessionId()) { + return; + } + + this.session.set(session); + this.volume.set(session.volume); + this.timeUpdate.emit({ + currentTime: session.positionSeconds, + duration: session.durationSeconds ?? 0, + }); + this.scheduleControlsHide(); + }); + private boundsAnimationFrame: number | null = null; + private controlsHideTimer: number | null = null; + private activeBoundsSync: (() => void) | null = null; + private readonly onDocumentPointerDown = (event: PointerEvent) => { + const playerRoot = this.playerRoot()?.nativeElement; + const path = event.composedPath(); + if (!playerRoot || path.includes(playerRoot)) { + return; + } + + this.closePopovers(); + }; + private readonly onDocumentPointerMove = (event: PointerEvent) => { + const playerRoot = this.playerRoot()?.nativeElement; + if ( + playerRoot && + !event.composedPath().includes(playerRoot) && + this.isPointerInsidePlayer(event) + ) { + this.revealControls(); + } + }; + private readonly onDocumentKeydown = (event: KeyboardEvent) => { + if (event.key !== 'Escape') { + return; + } + + this.closePopovers(); + }; + private readonly onFullscreenChange = () => { + const playerRoot = this.playerRoot()?.nativeElement; + this.isFullscreen.set( + Boolean(playerRoot && document.fullscreenElement === playerRoot) + ); + this.revealControls(); + this.activeBoundsSync?.(); + }; + + constructor() { + if (typeof document !== 'undefined') { + document.addEventListener( + 'fullscreenchange', + this.onFullscreenChange + ); + document.addEventListener( + 'pointerdown', + this.onDocumentPointerDown + ); + document.addEventListener( + 'pointermove', + this.onDocumentPointerMove, + { passive: true } + ); + document.addEventListener('keydown', this.onDocumentKeydown); + } + + if (window.electron?.getEmbeddedMpvSupport) { + void this.loadSupport(); + } else { + this.support.set({ + supported: false, + platform: typeof window === 'undefined' ? 'web' : 'unknown', + reason: 'Embedded MPV requires the Electron desktop build.', + }); + } + + effect((onCleanup) => { + const viewport = this.viewport(); + const playback = this.playback(); + const support = this.support(); + + if ( + !viewport || + !playback.streamUrl || + !support?.supported || + !window.electron + ) { + return; + } + + let disposed = false; + let activeSessionId: string | null = null; + const host = viewport.nativeElement; + const initialVolume = untracked(() => this.volume()); + + const syncBounds = () => { + if (!activeSessionId) { + return; + } + + void window.electron + .setEmbeddedMpvBounds( + activeSessionId, + this.measureBounds(host) + ) + .catch(() => undefined); + }; + + const scheduleBoundsSync = () => { + if (this.boundsAnimationFrame !== null) { + cancelAnimationFrame(this.boundsAnimationFrame); + } + + this.boundsAnimationFrame = requestAnimationFrame(() => { + this.boundsAnimationFrame = null; + syncBounds(); + }); + }; + + this.activeBoundsSync = scheduleBoundsSync; + + const resizeObserver = new ResizeObserver(() => { + scheduleBoundsSync(); + }); + resizeObserver.observe(host); + + window.addEventListener('resize', scheduleBoundsSync); + window.addEventListener('scroll', scheduleBoundsSync, true); + + const createSession = async () => { + this.session.set( + this.createLoadingSession(playback, initialVolume) + ); + await this.waitForStartupPaint(); + if (disposed) { + return; + } + + const prepared = await window.electron!.prepareEmbeddedMpv?.(); + if (disposed) { + return; + } + + if (prepared && !prepared.supported) { + throw new Error( + prepared.reason ?? + 'Embedded MPV is not available in this environment.' + ); + } + + const createdSession = + await window.electron!.createEmbeddedMpvSession( + this.measureBounds(host), + playback.title, + initialVolume + ); + + if (disposed) { + await window.electron!.disposeEmbeddedMpvSession( + createdSession.id + ); + return; + } + + activeSessionId = createdSession.id; + this.sessionId.set(createdSession.id); + this.session.set(createdSession); + + await window.electron!.loadEmbeddedMpvPlayback( + createdSession.id, + playback + ); + scheduleBoundsSync(); + }; + + void createSession().catch((error) => { + const startedAt = new Date().toISOString(); + this.sessionId.set(null); + this.session.set({ + id: 'embedded-mpv-error', + title: playback.title, + streamUrl: playback.streamUrl, + status: 'error', + positionSeconds: 0, + durationSeconds: null, + volume: initialVolume, + audioTracks: [], + selectedAudioTrackId: null, + startedAt, + updatedAt: startedAt, + error: + error instanceof Error + ? error.message + : String(error), + }); + }); + + onCleanup(() => { + disposed = true; + resizeObserver.disconnect(); + window.removeEventListener('resize', scheduleBoundsSync); + window.removeEventListener('scroll', scheduleBoundsSync, true); + + if (this.activeBoundsSync === scheduleBoundsSync) { + this.activeBoundsSync = null; + } + + if (this.boundsAnimationFrame !== null) { + cancelAnimationFrame(this.boundsAnimationFrame); + this.boundsAnimationFrame = null; + } + + const sessionId = activeSessionId; + activeSessionId = null; + this.sessionId.set(null); + this.session.set(null); + + if (sessionId) { + void window.electron?.disposeEmbeddedMpvSession(sessionId); + } + }); + }); + } + + ngOnDestroy(): void { + this.unsubscribeSessionUpdate?.(); + if (typeof document !== 'undefined') { + document.removeEventListener( + 'fullscreenchange', + this.onFullscreenChange + ); + document.removeEventListener( + 'pointerdown', + this.onDocumentPointerDown + ); + document.removeEventListener( + 'pointermove', + this.onDocumentPointerMove + ); + document.removeEventListener('keydown', this.onDocumentKeydown); + } + + if (this.boundsAnimationFrame !== null) { + cancelAnimationFrame(this.boundsAnimationFrame); + this.boundsAnimationFrame = null; + } + + this.clearControlsHideTimer(); + } + + onPlayerInteraction(): void { + this.revealControls(); + } + + async togglePaused(): Promise { + this.revealControls(); + const session = this.session(); + if (!session?.id || !window.electron?.setEmbeddedMpvPaused) { + return; + } + + const updatedSession = await window.electron.setEmbeddedMpvPaused( + session.id, + session.status !== 'paused' + ); + if (updatedSession) { + this.session.set(updatedSession); + } + } + + async toggleFullscreen(): Promise { + this.revealControls(); + const playerRoot = this.playerRoot()?.nativeElement; + if (!playerRoot || !this.canFullscreen()) { + return; + } + + try { + if (document.fullscreenElement === playerRoot) { + await document.exitFullscreen(); + } else { + await playerRoot.requestFullscreen(); + } + } catch { + return; + } finally { + this.activeBoundsSync?.(); + } + } + + async seekBy(deltaSeconds: number): Promise { + this.revealControls(); + const session = this.session(); + if (!session?.id || !window.electron?.seekEmbeddedMpv) { + return; + } + + const nextPosition = Math.max(0, session.positionSeconds + deltaSeconds); + const updatedSession = await window.electron.seekEmbeddedMpv( + session.id, + nextPosition + ); + if (updatedSession) { + this.session.set(updatedSession); + } + } + + async onTimelineInput(event: Event): Promise { + this.revealControls(); + const session = this.session(); + if (!session?.id || !window.electron?.seekEmbeddedMpv) { + return; + } + + const target = Number((event.target as HTMLInputElement).value); + const updatedSession = await window.electron.seekEmbeddedMpv( + session.id, + target + ); + if (updatedSession) { + this.session.set(updatedSession); + } + } + + toggleVolumePopover(): void { + this.volumePopoverOpen.update((open) => !open); + if (this.volumePopoverOpen()) { + this.audioMenuOpen.set(false); + } + this.revealControls(); + this.activeBoundsSync?.(); + } + + toggleAudioMenu(): void { + this.audioMenuOpen.update((open) => !open); + if (this.audioMenuOpen()) { + this.volumePopoverOpen.set(false); + } + this.revealControls(); + this.activeBoundsSync?.(); + } + + showDefaultControls(): void { + this.volumePopoverOpen.set(false); + this.audioMenuOpen.set(false); + this.revealControls(); + this.activeBoundsSync?.(); + } + + onVolumeInput(event: Event): void { + const nextVolume = Number((event.target as HTMLInputElement).value); + this.volume.set(nextVolume); + localStorage.setItem('volume', String(nextVolume)); + this.revealControls(false); + + const session = this.session(); + if (!session?.id || !window.electron?.setEmbeddedMpvVolume) { + return; + } + + void window.electron + .setEmbeddedMpvVolume(session.id, nextVolume) + .then((updatedSession) => { + if (updatedSession) { + this.session.set(updatedSession); + } + }) + .catch(() => undefined); + } + + async selectAudioTrack(trackId: number): Promise { + const session = this.session(); + if (!session?.id || !window.electron?.setEmbeddedMpvAudioTrack) { + return; + } + + this.revealControls(false); + const updatedSession = await window.electron.setEmbeddedMpvAudioTrack( + session.id, + trackId + ); + if (updatedSession) { + this.session.set(updatedSession); + } + this.audioMenuOpen.set(false); + this.scheduleControlsHide(); + } + + formatTime(value: number | null | undefined): string { + const safeValue = Math.max(0, Math.floor(value ?? 0)); + const hours = Math.floor(safeValue / 3600); + const minutes = Math.floor((safeValue % 3600) / 60); + const seconds = safeValue % 60; + + if (hours > 0) { + return `${hours}:${String(minutes).padStart(2, '0')}:${String(seconds).padStart(2, '0')}`; + } + + return `${minutes}:${String(seconds).padStart(2, '0')}`; + } + + trackLabel(track: EmbeddedMpvAudioTrack, index: number): string { + const label = track.title || track.language || `Audio ${index + 1}`; + return track.defaultTrack ? `${label} · Default` : label; + } + + private closePopovers(): void { + if (!this.volumePopoverOpen() && !this.audioMenuOpen()) { + return; + } + + this.volumePopoverOpen.set(false); + this.audioMenuOpen.set(false); + this.activeBoundsSync?.(); + this.scheduleControlsHide(); + } + + private revealControls(scheduleHide = true): void { + this.controlsVisible.set(true); + if (scheduleHide) { + this.clearControlsHideTimer(); + this.scheduleControlsHide(); + } + } + + private scheduleControlsHide(): void { + if ( + !this.isPlaying() || + this.volumePopoverOpen() || + this.audioMenuOpen() || + Boolean(this.statusLabel()) + ) { + this.clearControlsHideTimer(); + return; + } + + if (!this.controlsVisible() || this.controlsHideTimer !== null) { + return; + } + + this.controlsHideTimer = window.setTimeout(() => { + if ( + this.isPlaying() && + !this.volumePopoverOpen() && + !this.audioMenuOpen() && + !this.statusLabel() + ) { + this.controlsVisible.set(false); + } + }, 2500); + } + + private clearControlsHideTimer(): void { + if (this.controlsHideTimer === null) { + return; + } + + window.clearTimeout(this.controlsHideTimer); + this.controlsHideTimer = null; + } + + private isPointerInsidePlayer(event: PointerEvent): boolean { + const playerRoot = this.playerRoot()?.nativeElement; + if (!playerRoot) { + return false; + } + + const rect = playerRoot.getBoundingClientRect(); + return ( + event.clientX >= rect.left && + event.clientX <= rect.right && + event.clientY >= rect.top && + event.clientY <= rect.bottom + ); + } + + private async loadSupport(): Promise { + try { + this.support.set(await window.electron!.getEmbeddedMpvSupport()); + } catch (error) { + this.support.set({ + supported: false, + platform: window.electron?.platform ?? 'unknown', + reason: + error instanceof Error + ? error.message + : String(error), + }); + } + } + + private createLoadingSession( + playback: ResolvedPortalPlayback, + volume: number + ): EmbeddedMpvSession { + const now = new Date().toISOString(); + return { + id: 'embedded-mpv-starting', + title: playback.title, + streamUrl: playback.streamUrl, + status: 'loading', + positionSeconds: 0, + durationSeconds: null, + volume, + audioTracks: [], + selectedAudioTrackId: null, + startedAt: now, + updatedAt: now, + }; + } + + private waitForStartupPaint(): Promise { + if (typeof requestAnimationFrame !== 'function') { + return Promise.resolve(); + } + + return new Promise((resolve) => { + requestAnimationFrame(() => { + requestAnimationFrame(() => resolve()); + }); + }); + } + + private measureBounds(host: HTMLDivElement): EmbeddedMpvBounds { + const rect = host.getBoundingClientRect(); + return { + x: Math.round(rect.left), + y: Math.round(rect.top), + width: Math.max(1, Math.round(rect.width)), + height: Math.max(1, Math.round(rect.height)), + }; + } + + private readStoredVolume(): number { + const rawValue = Number(localStorage.getItem('volume') ?? '1'); + if (Number.isNaN(rawValue)) { + return 1; + } + + return Math.max(0, Math.min(1, rawValue)); + } +} diff --git a/libs/ui/playback/src/lib/portal-inline-player/portal-inline-player.component.html b/libs/ui/playback/src/lib/portal-inline-player/portal-inline-player.component.html index 07a279c2a..55dc36e19 100644 --- a/libs/ui/playback/src/lib/portal-inline-player/portal-inline-player.component.html +++ b/libs/ui/playback/src/lib/portal-inline-player/portal-inline-player.component.html @@ -32,6 +32,8 @@
diff --git a/libs/ui/playback/src/lib/web-player-view/web-player-view.component.html b/libs/ui/playback/src/lib/web-player-view/web-player-view.component.html index 5d1e1c3f5..be3348398 100644 --- a/libs/ui/playback/src/lib/web-player-view/web-player-view.component.html +++ b/libs/ui/playback/src/lib/web-player-view/web-player-view.component.html @@ -18,6 +18,11 @@ [startTime]="startTime()" (timeUpdate)="timeUpdate.emit($event)" /> +} @else if (player === 'embedded-mpv') { + } @else { (); + title = input(''); + playback = input(null); startTime = input(0); @Output() timeUpdate = new EventEmitter<{ currentTime: number; @@ -40,13 +54,26 @@ export class WebPlayerViewComponent { channel!: { url: string }; player!: VideoPlayer; vjsOptions!: { sources: { src: string; type: string }[] }; + readonly resolvedPlayback = computed(() => { + const playback = this.playback(); + if (playback) { + return playback; + } + + return { + streamUrl: this.streamUrl(), + title: this.title() || this.streamUrl(), + startTime: this.startTime(), + }; + }); constructor() { effect(() => { this.player = this.settings()?.player ?? VideoPlayer.VideoJs; - this.setChannel(this.streamUrl()); - this.setVjsOptions(this.streamUrl()); + const playback = this.resolvedPlayback(); + this.setChannel(playback.streamUrl); + this.setVjsOptions(playback.streamUrl); }); } diff --git a/tools/embedded-mpv/README.md b/tools/embedded-mpv/README.md new file mode 100644 index 000000000..eb49e9b98 --- /dev/null +++ b/tools/embedded-mpv/README.md @@ -0,0 +1,75 @@ +# Embedded MPV macOS Runtime + +This folder contains tooling for preparing the macOS `libmpv` runtime that is bundled with IPTVnator's experimental embedded MPV player. + +## Runtime Policy + +Release builds must use an LGPL-compatible runtime: + +- FFmpeg must be built without `--enable-gpl` and without `--enable-nonfree`. +- mpv must be built with `-Dlibmpv=true` and `-Dgpl=false`. +- The runtime must be dynamically linked so users can inspect and replace LGPL libraries. +- The exact source URLs, versions, build flags, local patches, and checksums must be published with the release. + +Do not ship the Homebrew `mpv` runtime. It is acceptable only for local development when `IPTVNATOR_EMBEDDED_MPV_ALLOW_HOMEBREW=1` is set, and release packaging rejects it. + +## Expected Layout + +The native addon build consumes: + +```text +vendor/embedded-mpv/ + darwin-arm64/ + include/mpv/client.h + lib/*.dylib + runtime-manifest.json + darwin-x64/ + include/mpv/client.h + lib/*.dylib + runtime-manifest.json +``` + +The generated `lib/` and `include/` directories are release inputs, not source files. They are ignored by git by default. + +## Staging A Built Runtime + +After building an LGPL-compatible prefix for one architecture, stage it with: + +```bash +node tools/embedded-mpv/stage-macos-runtime.mjs arm64 /path/to/lgpl-prefix +node tools/embedded-mpv/stage-macos-runtime.mjs x64 /path/to/lgpl-prefix +``` + +The prefix must contain `include/mpv/client.h`, `lib/libmpv.2.dylib` or `lib/libmpv.dylib`, and all non-system dylib dependencies required by `libmpv`. + +If the prefix contains `runtime-manifest.json`, the staging script copies its build metadata into the vendored manifest. At minimum, record: + +- FFmpeg version, source URL, checksum, configure flags, and patches +- mpv version, source URL, checksum, Meson flags, and patches +- source-distribution URL for the corresponding release + +## Building The CI Runtime + +Tagged macOS release builds build the runtime from pinned source archives before `electron-backend:build`: + +```bash +pnpm embedded-mpv:build-runtime -- arm64 /tmp/embedded-mpv-prefix +pnpm embedded-mpv:stage-runtime -- arm64 /tmp/embedded-mpv-prefix +``` + +The builder currently pins: + +- FFmpeg `8.1`, configured without `--enable-gpl` or `--enable-nonfree`, and with autodetected external libraries disabled +- mpv `0.41.0`, configured with `-Dlibmpv=true -Dgpl=false` +- libplacebo `7.360.1` +- libass `0.17.3` plus FreeType, FriBidi, and HarfBuzz + +The build manifest records source URLs, downloaded source SHA-256 values, and the exact FFmpeg/mpv flags. The staged manifest is normalized to `origin: vendored-lgpl`, which is the only embedded MPV runtime origin allowed in required macOS release packaging. + +## Build Integration + +`apps/electron-backend/build-embedded-mpv.js` links the native addon against the staged runtime, copies dylibs into `apps/electron-backend/native/build/Release/lib/`, rewrites Mach-O paths to `@loader_path`, and writes `embedded-mpv-runtime.json`. + +During release packaging, `tools/packaging/electron-after-pack.cjs` verifies that the packaged app uses a `vendored-lgpl` runtime and has no `/opt/homebrew` or `/usr/local` dynamic links for embedded MPV. + +Set `IPTVNATOR_REQUIRE_EMBEDDED_MPV=1` when packaging a macOS release artifact that must include Embedded MPV. PR and non-tag development builds leave that variable unset or `0`, so the same in-tree code can package without a staged runtime while Settings keeps Embedded MPV hidden. diff --git a/tools/embedded-mpv/build-macos-runtime.mjs b/tools/embedded-mpv/build-macos-runtime.mjs new file mode 100644 index 000000000..85ab1a036 --- /dev/null +++ b/tools/embedded-mpv/build-macos-runtime.mjs @@ -0,0 +1,475 @@ +#!/usr/bin/env node +import crypto from 'crypto'; +import fs from 'fs'; +import os from 'os'; +import path from 'path'; +import { spawnSync } from 'child_process'; + +const args = process.argv.slice(2); +const [arch, rawPrefix] = args; +const validArchitectures = new Set(['arm64', 'x64']); +const macosDeploymentTarget = + process.env.MACOSX_DEPLOYMENT_TARGET ?? '11.0'; +const workspaceRoot = process.cwd(); + +const sourcePackages = [ + { + id: 'freetype', + version: '2.13.3', + url: 'https://download.savannah.gnu.org/releases/freetype/freetype-2.13.3.tar.xz', + license: 'FreeType License or GPL-2.0-or-later', + }, + { + id: 'fribidi', + version: '1.0.16', + url: 'https://github.com/fribidi/fribidi/releases/download/v1.0.16/fribidi-1.0.16.tar.xz', + license: 'LGPL-2.1-or-later', + }, + { + id: 'harfbuzz', + version: '8.5.0', + url: 'https://github.com/harfbuzz/harfbuzz/releases/download/8.5.0/harfbuzz-8.5.0.tar.xz', + license: 'MIT', + }, + { + id: 'libass', + version: '0.17.3', + url: 'https://github.com/libass/libass/releases/download/0.17.3/libass-0.17.3.tar.xz', + license: 'ISC', + }, + { + id: 'ffmpeg', + version: '8.1', + url: 'https://ffmpeg.org/releases/ffmpeg-8.1.tar.xz', + license: 'LGPL-compatible configuration', + }, + { + id: 'libplacebo', + version: '7.360.1', + url: 'https://github.com/haasn/libplacebo/archive/refs/tags/v7.360.1.tar.gz', + license: 'LGPL-2.1-or-later', + }, + { + id: 'mpv', + version: '0.41.0', + url: 'https://github.com/mpv-player/mpv/archive/refs/tags/v0.41.0.tar.gz', + license: 'LGPL-compatible configuration with -Dgpl=false', + }, +]; + +if (process.platform !== 'darwin') { + console.error('Embedded MPV runtime builds are supported on macOS only.'); + process.exit(1); +} + +if (!validArchitectures.has(arch) || !rawPrefix) { + console.error( + [ + 'Usage: node tools/embedded-mpv/build-macos-runtime.mjs ', + '', + 'Builds a pinned LGPL-compatible macOS libmpv runtime from source.', + ].join('\n') + ); + process.exit(1); +} + +const prefix = path.resolve(rawPrefix); +const buildRoot = path.resolve( + process.env.IPTVNATOR_EMBEDDED_MPV_BUILD_ROOT ?? + path.join(os.tmpdir(), 'iptvnator-embedded-mpv-runtime', arch) +); +const archiveRoot = path.join(buildRoot, 'archives'); +const sourceRoot = path.join(buildRoot, 'sources'); +const packageById = new Map(sourcePackages.map((source) => [source.id, source])); +const parallelism = + process.env.MAKEFLAGS?.match(/-j\s*(\d+)/)?.[1] ?? + String(os.cpus().length); + +const ffmpegConfigureFlags = [ + `--prefix=${prefix}`, + '--enable-shared', + '--disable-static', + '--disable-doc', + '--disable-debug', + '--disable-programs', + '--disable-autodetect', + '--disable-gpl', + '--disable-nonfree', + '--enable-pic', + '--enable-securetransport', + '--enable-audiotoolbox', + '--enable-videotoolbox', +]; + +const mpvMesonFlags = [ + '-Dgpl=false', + '-Dlibmpv=true', + '-Dcplayer=false', + '-Dbuild-date=false', + '-Dtests=false', + '-Dlua=disabled', + '-Djavascript=disabled', + '-Dcplugins=disabled', + '-Dmanpage-build=disabled', + '-Dhtml-build=disabled', + '-Dpdf-build=disabled', + '-Dlibarchive=disabled', + '-Dlibbluray=disabled', + '-Ddvdnav=disabled', + '-Dcdda=disabled', + '-Ddvbin=disabled', + '-Djpeg=disabled', + '-Dlcms2=disabled', + '-Drubberband=disabled', + '-Duchardet=disabled', + '-Dzimg=disabled', + '-Dvulkan=disabled', + '-Dshaderc=disabled', + '-Dspirv-cross=disabled', + '-Dcocoa=disabled', + '-Dgl-cocoa=disabled', + '-Dmacos-cocoa-cb=disabled', + '-Dswift-build=disabled', + '-Dplain-gl=enabled', +]; + +function log(message) { + process.stdout.write(`[embedded-mpv-runtime] ${message}\n`); +} + +function run(command, commandArgs, options = {}) { + log(`${command} ${commandArgs.join(' ')}`); + const result = spawnSync(command, commandArgs, { + cwd: options.cwd ?? workspaceRoot, + env: options.env ?? buildEnv(), + stdio: 'inherit', + ...options, + }); + + if (result.status !== 0) { + throw new Error( + `${command} ${commandArgs.join(' ')} failed with status ${ + result.status ?? 1 + }.` + ); + } +} + +function commandExists(command) { + const result = spawnSync('sh', ['-lc', `command -v ${command}`], { + stdio: 'ignore', + }); + return result.status === 0; +} + +function buildEnv() { + const pkgConfigDirs = [ + path.join(prefix, 'lib', 'pkgconfig'), + path.join(prefix, 'share', 'pkgconfig'), + ].join(path.delimiter); + + return { + ...process.env, + PATH: [path.join(prefix, 'bin'), process.env.PATH] + .filter(Boolean) + .join(path.delimiter), + PKG_CONFIG_PATH: pkgConfigDirs, + PKG_CONFIG_LIBDIR: pkgConfigDirs, + CMAKE_PREFIX_PATH: prefix, + DYLD_LIBRARY_PATH: path.join(prefix, 'lib'), + MACOSX_DEPLOYMENT_TARGET: macosDeploymentTarget, + CFLAGS: [`-I${path.join(prefix, 'include')}`, process.env.CFLAGS] + .filter(Boolean) + .join(' '), + LDFLAGS: [`-L${path.join(prefix, 'lib')}`, process.env.LDFLAGS] + .filter(Boolean) + .join(' '), + }; +} + +function ensureTools() { + const requiredCommands = [ + 'curl', + 'tar', + 'make', + 'meson', + 'ninja', + 'pkg-config', + ]; + const missing = requiredCommands.filter((command) => !commandExists(command)); + + if (missing.length > 0) { + throw new Error(`Missing required build tools: ${missing.join(', ')}`); + } +} + +function archivePathFor(sourcePackage) { + const extension = sourcePackage.url.endsWith('.tar.xz') + ? '.tar.xz' + : '.tar.gz'; + return path.join( + archiveRoot, + `${sourcePackage.id}-${sourcePackage.version}${extension}` + ); +} + +function sourcePathFor(packageId) { + return path.join(sourceRoot, packageId); +} + +function sha256File(filePath) { + const hash = crypto.createHash('sha256'); + hash.update(fs.readFileSync(filePath)); + return hash.digest('hex'); +} + +function downloadSources() { + fs.mkdirSync(archiveRoot, { recursive: true }); + fs.mkdirSync(sourceRoot, { recursive: true }); + + for (const sourcePackage of sourcePackages) { + const archivePath = archivePathFor(sourcePackage); + if (!fs.existsSync(archivePath)) { + run('curl', [ + '-fL', + '--retry', + '3', + '--retry-delay', + '5', + '-o', + archivePath, + sourcePackage.url, + ]); + } + + const packageSourcePath = sourcePathFor(sourcePackage.id); + fs.rmSync(packageSourcePath, { recursive: true, force: true }); + fs.mkdirSync(packageSourcePath, { recursive: true }); + run('tar', [ + '-xf', + archivePath, + '-C', + packageSourcePath, + '--strip-components', + '1', + ]); + sourcePackage.sha256 = sha256File(archivePath); + } +} + +function configureMakeInstall(packageId, configureArgs) { + const packageSourcePath = sourcePathFor(packageId); + run('./configure', [`--prefix=${prefix}`, ...configureArgs], { + cwd: packageSourcePath, + }); + run('make', [`-j${parallelism}`], { cwd: packageSourcePath }); + run('make', ['install'], { cwd: packageSourcePath }); +} + +function mesonInstall(packageId, mesonArgs) { + const packageSourcePath = sourcePathFor(packageId); + const buildDir = path.join(packageSourcePath, 'build-iptvnator'); + fs.rmSync(buildDir, { recursive: true, force: true }); + run( + 'meson', + [ + 'setup', + buildDir, + `--prefix=${prefix}`, + '--libdir=lib', + '--buildtype=release', + '--default-library=shared', + ...mesonArgs, + ], + { cwd: packageSourcePath } + ); + run('meson', ['compile', '-C', buildDir], { cwd: packageSourcePath }); + run('meson', ['install', '-C', buildDir], { cwd: packageSourcePath }); +} + +function buildRuntime() { + fs.rmSync(prefix, { recursive: true, force: true }); + fs.mkdirSync(prefix, { recursive: true }); + + configureMakeInstall('freetype', ['--enable-shared', '--disable-static']); + configureMakeInstall('fribidi', ['--enable-shared', '--disable-static']); + mesonInstall('harfbuzz', [ + '-Dglib=disabled', + '-Dgobject=disabled', + '-Dcairo=disabled', + '-Dchafa=disabled', + '-Dicu=disabled', + '-Dfreetype=enabled', + '-Dtests=disabled', + '-Dintrospection=disabled', + '-Ddocs=disabled', + '-Dutilities=disabled', + '-Dbenchmark=disabled', + ]); + configureMakeInstall('libass', [ + '--enable-shared', + '--disable-static', + '--disable-fontconfig', + '--enable-coretext', + '--disable-libunibreak', + ]); + + const ffmpegSourcePath = sourcePathFor('ffmpeg'); + run('./configure', ffmpegConfigureFlags, { cwd: ffmpegSourcePath }); + run('make', [`-j${parallelism}`], { cwd: ffmpegSourcePath }); + run('make', ['install'], { cwd: ffmpegSourcePath }); + + mesonInstall('libplacebo', [ + '-Dopengl=enabled', + '-Dvulkan=disabled', + '-Dvk-proc-addr=disabled', + '-Dglslang=disabled', + '-Dshaderc=disabled', + '-Dlcms=disabled', + '-Ddovi=disabled', + '-Dlibdovi=disabled', + '-Ddemos=false', + '-Dtests=false', + '-Dbench=false', + '-Dfuzz=false', + '-Dunwind=disabled', + '-Dxxhash=disabled', + ]); + mesonInstall('mpv', mpvMesonFlags); +} + +function listDylibs(directoryPath) { + if (!fs.existsSync(directoryPath)) { + return []; + } + + return fs + .readdirSync(directoryPath, { withFileTypes: true }) + .filter((entry) => entry.isFile() && entry.name.endsWith('.dylib')) + .map((entry) => path.join(directoryPath, entry.name)) + .sort(); +} + +function validateRuntimeLinks() { + if (!commandExists('otool')) { + log('Skipping otool validation because otool is unavailable.'); + return; + } + + const libDir = path.join(prefix, 'lib'); + const errors = []; + const allowedSystemPrefixes = ['/System/Library/', '/usr/lib/']; + const forbiddenPrefixes = ['/opt/homebrew/', '/usr/local/']; + + for (const dylibPath of listDylibs(libDir)) { + const result = spawnSync('otool', ['-L', dylibPath], { + encoding: 'utf8', + stdio: 'pipe', + }); + if (result.status !== 0) { + errors.push(`Unable to inspect ${dylibPath}: ${result.stderr}`); + continue; + } + + for (const dependencyPath of result.stdout + .split(/\r?\n/) + .slice(1) + .map((line) => line.trim().split(/\s+\(/)[0]) + .filter(Boolean)) { + if ( + allowedSystemPrefixes.some((prefixValue) => + dependencyPath.startsWith(prefixValue) + ) || + dependencyPath.startsWith('@loader_path/') || + dependencyPath.startsWith('@rpath/') || + dependencyPath.startsWith(prefix) + ) { + continue; + } + + if ( + forbiddenPrefixes.some((prefixValue) => + dependencyPath.startsWith(prefixValue) + ) + ) { + errors.push(`${dylibPath} links to forbidden ${dependencyPath}`); + continue; + } + + if (path.isAbsolute(dependencyPath)) { + errors.push(`${dylibPath} links to external ${dependencyPath}`); + } + } + } + + if (errors.length > 0) { + throw new Error( + ['Embedded MPV runtime link validation failed.', ...errors].join('\n') + ); + } +} + +function sourceMetadata(packageId) { + const sourcePackage = packageById.get(packageId); + return { + version: sourcePackage.version, + sourceUrl: sourcePackage.url, + sourceSha256: sourcePackage.sha256, + license: sourcePackage.license, + }; +} + +function writeManifest() { + const manifest = { + origin: 'vendored-lgpl-source-build', + arch, + generatedAt: new Date().toISOString(), + macosDeploymentTarget, + buildHost: { + platform: process.platform, + arch: process.arch, + }, + packages: Object.fromEntries( + sourcePackages.map((sourcePackage) => [ + sourcePackage.id, + { + version: sourcePackage.version, + sourceUrl: sourcePackage.url, + sourceSha256: sourcePackage.sha256, + license: sourcePackage.license, + }, + ]) + ), + ffmpeg: { + ...sourceMetadata('ffmpeg'), + licensePolicy: 'LGPL, built without --enable-gpl and --enable-nonfree', + configureFlags: ffmpegConfigureFlags, + }, + mpv: { + ...sourceMetadata('mpv'), + licensePolicy: + 'LGPL-compatible libmpv, built with -Dlibmpv=true -Dgpl=false', + mesonFlags: mpvMesonFlags, + }, + sourceDistribution: + 'Attach the downloaded source archives, this manifest, and any local patches with the macOS binary release.', + }; + + fs.writeFileSync( + path.join(prefix, 'runtime-manifest.json'), + `${JSON.stringify(manifest, null, 2)}\n` + ); +} + +try { + ensureTools(); + fs.mkdirSync(buildRoot, { recursive: true }); + downloadSources(); + buildRuntime(); + validateRuntimeLinks(); + writeManifest(); + log(`Built LGPL-compatible runtime prefix at ${prefix}`); +} catch (error) { + console.error(error instanceof Error ? error.message : String(error)); + process.exit(1); +} diff --git a/tools/embedded-mpv/stage-macos-runtime.mjs b/tools/embedded-mpv/stage-macos-runtime.mjs new file mode 100644 index 000000000..2f7588f27 --- /dev/null +++ b/tools/embedded-mpv/stage-macos-runtime.mjs @@ -0,0 +1,170 @@ +import fs from 'fs'; +import path from 'path'; + +const args = process.argv.slice(2); +const [arch, sourcePrefix] = args; +const validArchitectures = new Set(['arm64', 'x64']); +const workspaceRoot = process.cwd(); + +if (!validArchitectures.has(arch) || !sourcePrefix) { + console.error( + [ + 'Usage: node tools/embedded-mpv/stage-macos-runtime.mjs ', + '', + 'The prefix must contain:', + '- include/mpv/client.h', + '- lib/libmpv.2.dylib or lib/libmpv.dylib', + '- all non-system dylib dependencies required by libmpv', + ].join('\n') + ); + process.exit(1); +} + +const normalizedPrefix = path.resolve(sourcePrefix); +const destinationRoot = path.join( + workspaceRoot, + 'vendor', + 'embedded-mpv', + `darwin-${arch}` +); +const destinationIncludeDir = path.join(destinationRoot, 'include'); +const destinationLibDir = path.join(destinationRoot, 'lib'); +const sourceIncludeDir = path.join(normalizedPrefix, 'include'); +const sourceLibDir = path.join(normalizedPrefix, 'lib'); + +function assertExists(filePath, message) { + if (!fs.existsSync(filePath)) { + throw new Error(`${message}: ${filePath}`); + } +} + +function isFileLike(sourcePath, entry) { + if (entry.isFile()) { + return true; + } + + if (!entry.isSymbolicLink()) { + return false; + } + + try { + return fs.statSync(sourcePath).isFile(); + } catch { + return false; + } +} + +function copyDirectory(sourceDir, destinationDir, filter) { + fs.mkdirSync(destinationDir, { recursive: true }); + + for (const entry of fs.readdirSync(sourceDir, { withFileTypes: true })) { + const sourcePath = path.join(sourceDir, entry.name); + const destinationPath = path.join(destinationDir, entry.name); + + if (filter && !filter(sourcePath, entry)) { + continue; + } + + if (entry.isDirectory()) { + copyDirectory(sourcePath, destinationPath, filter); + continue; + } + + if (isFileLike(sourcePath, entry)) { + fs.copyFileSync(sourcePath, destinationPath); + fs.chmodSync(destinationPath, 0o755); + } + } +} + +function findLibMpv(libDir) { + for (const candidate of ['libmpv.2.dylib', 'libmpv.dylib']) { + const candidatePath = path.join(libDir, candidate); + if (fs.existsSync(candidatePath)) { + return candidatePath; + } + } + + return null; +} + +function listDylibs(libDir) { + return fs + .readdirSync(libDir, { withFileTypes: true }) + .filter((entry) => entry.isFile() && entry.name.endsWith('.dylib')) + .map((entry) => entry.name) + .sort(); +} + +function readJsonIfExists(filePath) { + if (!fs.existsSync(filePath)) { + return null; + } + + return JSON.parse(fs.readFileSync(filePath, 'utf8')); +} + +try { + assertExists( + path.join(sourceIncludeDir, 'mpv', 'client.h'), + 'Missing libmpv header' + ); + if (!findLibMpv(sourceLibDir)) { + throw new Error(`Missing libmpv dylib in ${sourceLibDir}`); + } + + fs.rmSync(destinationIncludeDir, { recursive: true, force: true }); + fs.rmSync(destinationLibDir, { recursive: true, force: true }); + fs.mkdirSync(destinationRoot, { recursive: true }); + + copyDirectory( + path.join(sourceIncludeDir, 'mpv'), + path.join(destinationIncludeDir, 'mpv') + ); + copyDirectory(sourceLibDir, destinationLibDir, (_sourcePath, entry) => { + return entry.isDirectory() || entry.name.endsWith('.dylib'); + }); + + const externalManifest = + readJsonIfExists(path.join(normalizedPrefix, 'runtime-manifest.json')) ?? + {}; + const manifest = { + ...externalManifest, + origin: 'vendored-lgpl', + arch, + stagedAt: new Date().toISOString(), + ffmpeg: { + licensePolicy: 'LGPL, built without --enable-gpl and --enable-nonfree', + ...externalManifest.ffmpeg, + configureFlags: + externalManifest.ffmpeg?.configureFlags ?? + 'Record the exact FFmpeg configure flags used to build this runtime.', + }, + mpv: { + licensePolicy: 'LGPL-compatible libmpv, built with -Dlibmpv=true -Dgpl=false', + ...externalManifest.mpv, + mesonFlags: + externalManifest.mpv?.mesonFlags ?? + 'Record the exact mpv Meson flags used to build this runtime.', + }, + dylibs: listDylibs(destinationLibDir), + sourceDistribution: + externalManifest.sourceDistribution ?? + 'Publish exact source archives and local patches with the macOS binary release.', + }; + + fs.writeFileSync( + path.join(destinationRoot, 'runtime-manifest.json'), + `${JSON.stringify(manifest, null, 2)}\n` + ); + + console.log( + `Staged embedded MPV runtime for darwin-${arch} at ${path.relative( + workspaceRoot, + destinationRoot + )}` + ); +} catch (error) { + console.error(error instanceof Error ? error.message : String(error)); + process.exit(1); +} diff --git a/tools/packaging/electron-after-pack.cjs b/tools/packaging/electron-after-pack.cjs new file mode 100644 index 000000000..a20bf781b --- /dev/null +++ b/tools/packaging/electron-after-pack.cjs @@ -0,0 +1,65 @@ +const linuxAfterPack = require('./linux-after-pack.cjs'); +const { + validatePackagedEmbeddedMpv, +} = require('./embedded-mpv-macos.cjs'); +const fs = require('fs'); +const path = require('path'); + +function log(message) { + console.log(` - ${message}`); +} + +function isTruthy(value) { + return ['1', 'true', 'yes', 'on'].includes( + String(value ?? '') + .trim() + .toLowerCase() + ); +} + +async function afterPackHook(params) { + await linuxAfterPack(params); + + if (params.electronPlatformName !== 'darwin') { + return; + } + + const requireEmbeddedMpv = isTruthy( + process.env.IPTVNATOR_REQUIRE_EMBEDDED_MPV + ); + log( + requireEmbeddedMpv + ? 'validating required embedded MPV macOS runtime links' + : 'validating optional embedded MPV macOS runtime links' + ); + const appPath = params.appOutDir.endsWith('.app') + ? params.appOutDir + : fs + .readdirSync(params.appOutDir) + .find((entry) => entry.endsWith('.app')); + const resourceDir = appPath + ? path.join( + params.appOutDir.endsWith('.app') + ? params.appOutDir + : path.join(params.appOutDir, appPath), + 'Contents', + 'Resources' + ) + : params.appOutDir; + const errors = validatePackagedEmbeddedMpv(resourceDir, { + required: requireEmbeddedMpv, + }); + + if (errors.length > 0) { + throw new Error( + [ + 'Embedded MPV macOS package validation failed.', + ...errors.map((error) => `- ${error}`), + ].join('\n') + ); + } + + log('embedded MPV macOS runtime links validated'); +} + +module.exports = afterPackHook; diff --git a/tools/packaging/embedded-mpv-macos.cjs b/tools/packaging/embedded-mpv-macos.cjs new file mode 100644 index 000000000..9dc0a87dd --- /dev/null +++ b/tools/packaging/embedded-mpv-macos.cjs @@ -0,0 +1,465 @@ +const fs = require('fs'); +const path = require('path'); +const { spawnSync } = require('child_process'); + +const forbiddenRuntimePathPrefixes = ['/opt/homebrew/', '/usr/local/']; +const systemRuntimePathPrefixes = ['/System/Library/', '/usr/lib/']; + +function run(command, args, options = {}) { + const result = spawnSync(command, args, { + stdio: options.stdio ?? 'pipe', + encoding: 'utf8', + ...options, + }); + + if (result.status !== 0) { + const stderr = result.stderr ? `\n${result.stderr}` : ''; + throw new Error( + `${command} ${args.join(' ')} failed with status ${result.status ?? 1}.${stderr}` + ); + } + + return result.stdout ?? ''; +} + +function commandExists(command) { + const result = spawnSync('sh', ['-lc', `command -v ${command}`], { + stdio: 'ignore', + }); + return result.status === 0; +} + +function ensureDir(directoryPath) { + fs.mkdirSync(directoryPath, { recursive: true }); +} + +function removeDir(directoryPath) { + fs.rmSync(directoryPath, { recursive: true, force: true }); +} + +function copyFile(sourcePath, destinationPath) { + ensureDir(path.dirname(destinationPath)); + fs.copyFileSync(sourcePath, destinationPath); + fs.chmodSync(destinationPath, 0o755); +} + +function listDylibs(directoryPath) { + if (!fs.existsSync(directoryPath)) { + return []; + } + + return fs + .readdirSync(directoryPath, { withFileTypes: true }) + .filter((entry) => entry.isFile() && entry.name.endsWith('.dylib')) + .map((entry) => path.join(directoryPath, entry.name)) + .sort(); +} + +function listRuntimeFiles(directoryPath) { + if (!fs.existsSync(directoryPath)) { + return []; + } + + return fs + .readdirSync(directoryPath, { withFileTypes: true }) + .filter((entry) => entry.isFile()) + .map((entry) => path.join(directoryPath, entry.name)) + .sort(); +} + +function parseOtoolDependencies(binaryPath) { + const output = run('otool', ['-L', binaryPath]); + return output + .split(/\r?\n/) + .slice(1) + .map((line) => line.trim()) + .filter(Boolean) + .map((line) => line.split(/\s+\(/)[0]) + .filter(Boolean); +} + +function parseOtoolRpaths(binaryPath) { + const output = run('otool', ['-l', binaryPath]); + const lines = output.split(/\r?\n/); + const rpaths = []; + + for (let index = 0; index < lines.length; index += 1) { + if (lines[index].trim() !== 'cmd LC_RPATH') { + continue; + } + + for (let offset = index + 1; offset < lines.length; offset += 1) { + const match = lines[offset] + .trim() + .match(/^path\s+(.+?)\s+\(offset\s+\d+\)$/); + if (match) { + rpaths.push(match[1]); + break; + } + } + } + + return rpaths; +} + +function readInstallNameId(binaryPath) { + const result = spawnSync('otool', ['-D', binaryPath], { + stdio: 'pipe', + encoding: 'utf8', + }); + + if (result.status !== 0) { + return null; + } + + return ( + result.stdout + .split(/\r?\n/) + .slice(1) + .map((line) => line.trim()) + .find(Boolean) ?? null + ); +} + +function isSystemDependency(dependencyPath) { + return systemRuntimePathPrefixes.some((prefix) => + dependencyPath.startsWith(prefix) + ); +} + +function isForbiddenDependency(dependencyPath) { + return forbiddenRuntimePathPrefixes.some((prefix) => + dependencyPath.startsWith(prefix) + ); +} + +function resolvePathToken(tokenPath, binaryPath) { + if (tokenPath.startsWith('@loader_path/')) { + return path.resolve( + path.dirname(binaryPath), + tokenPath.replace('@loader_path/', '') + ); + } + + if (tokenPath.startsWith('@executable_path/')) { + return null; + } + + if (tokenPath.startsWith('@rpath/')) { + return null; + } + + return tokenPath; +} + +function resolveRpathDependency(dependencyPath, binaryPath) { + const dependencyName = dependencyPath.replace('@rpath/', ''); + + for (const rpath of parseOtoolRpaths(binaryPath)) { + const resolvedRpath = resolvePathToken(rpath, binaryPath); + if (!resolvedRpath) { + continue; + } + + const candidatePath = path.resolve(resolvedRpath, dependencyName); + if (fs.existsSync(candidatePath)) { + return candidatePath; + } + } + + return null; +} + +function resolveDependencyPath(dependencyPath, binaryPath) { + if (dependencyPath.startsWith('@loader_path/')) { + return resolvePathToken(dependencyPath, binaryPath); + } + + if (dependencyPath.startsWith('@rpath/')) { + return resolveRpathDependency(dependencyPath, binaryPath); + } + + if (dependencyPath.startsWith('@executable_path/')) { + return null; + } + + return dependencyPath; +} + +function collectExternalDylibs(entryPaths) { + const visited = new Set(); + const queue = [...entryPaths]; + const result = new Map(); + + while (queue.length > 0) { + const currentPath = queue.shift(); + if (!currentPath || visited.has(currentPath) || !fs.existsSync(currentPath)) { + continue; + } + + visited.add(currentPath); + for (const dependencyPath of parseOtoolDependencies(currentPath)) { + if (isSystemDependency(dependencyPath)) { + continue; + } + + const resolvedPath = resolveDependencyPath(dependencyPath, currentPath); + if (!resolvedPath || !fs.existsSync(resolvedPath)) { + continue; + } + + if (!result.has(path.basename(resolvedPath))) { + result.set(path.basename(resolvedPath), resolvedPath); + queue.push(resolvedPath); + } + } + } + + return [...result.values()]; +} + +function findLibMpv(runtimeLibDir) { + const candidates = ['libmpv.2.dylib', 'libmpv.dylib']; + for (const candidate of candidates) { + const candidatePath = path.join(runtimeLibDir, candidate); + if (fs.existsSync(candidatePath)) { + return candidatePath; + } + } + + return null; +} + +function patchDylibIds(libDir) { + for (const dylibPath of listRuntimeFiles(libDir)) { + if (!readInstallNameId(dylibPath)) { + continue; + } + + run('install_name_tool', [ + '-id', + `@loader_path/${path.basename(dylibPath)}`, + dylibPath, + ]); + } +} + +function patchBinaryDependencies(binaryPath, dependencyBaseDir, replacementPrefix) { + const availableRuntimeFiles = new Set( + listRuntimeFiles(dependencyBaseDir).map((runtimePath) => + path.basename(runtimePath) + ) + ); + + for (const dependencyPath of parseOtoolDependencies(binaryPath)) { + const dependencyName = path.basename(dependencyPath); + if (!availableRuntimeFiles.has(dependencyName)) { + continue; + } + + const replacementPath = `${replacementPrefix}/${dependencyName}`; + if (dependencyPath === replacementPath) { + continue; + } + + run('install_name_tool', [ + '-change', + dependencyPath, + replacementPath, + binaryPath, + ]); + } +} + +function adHocSignBinary(binaryPath) { + if (process.platform !== 'darwin' || !commandExists('codesign')) { + return; + } + + run('codesign', ['--force', '--sign', '-', '--timestamp=none', binaryPath]); +} + +function adHocSignBinaries(binaryPaths) { + for (const binaryPath of binaryPaths) { + adHocSignBinary(binaryPath); + } +} + +function copyRuntimeToNativeBuild({ + runtimeLibDir, + outputLibDir, + runtimeOrigin, + runtimeManifest, +}) { + const libMpvPath = findLibMpv(runtimeLibDir); + if (!libMpvPath) { + throw new Error(`Unable to find libmpv in ${runtimeLibDir}.`); + } + + removeDir(outputLibDir); + ensureDir(outputLibDir); + + const runtimeFilesByName = new Map([[path.basename(libMpvPath), libMpvPath]]); + for (const dylibPath of collectExternalDylibs([libMpvPath])) { + runtimeFilesByName.set(path.basename(dylibPath), dylibPath); + } + if (runtimeOrigin === 'vendored-lgpl') { + for (const runtimePath of listRuntimeFiles(runtimeLibDir)) { + runtimeFilesByName.set(path.basename(runtimePath), runtimePath); + } + } + + const dylibs = [...runtimeFilesByName.values()]; + for (const dylibPath of dylibs) { + copyFile(dylibPath, path.join(outputLibDir, path.basename(dylibPath))); + } + + if (!fs.existsSync(path.join(outputLibDir, 'libmpv.2.dylib'))) { + const copiedLibMpvPath = path.join(outputLibDir, path.basename(libMpvPath)); + if (path.basename(copiedLibMpvPath) !== 'libmpv.2.dylib') { + copyFile(copiedLibMpvPath, path.join(outputLibDir, 'libmpv.2.dylib')); + } + } + + patchDylibIds(outputLibDir); + for (const runtimePath of listRuntimeFiles(outputLibDir)) { + patchBinaryDependencies(runtimePath, outputLibDir, '@loader_path'); + } + adHocSignBinaries(listRuntimeFiles(outputLibDir)); + + const manifest = { + origin: runtimeOrigin, + generatedAt: new Date().toISOString(), + libDir: 'lib', + runtimeFiles: listRuntimeFiles(outputLibDir).map((runtimePath) => + path.basename(runtimePath) + ), + dylibs: listDylibs(outputLibDir).map((dylibPath) => + path.basename(dylibPath) + ), + ...runtimeManifest, + }; + + fs.writeFileSync( + path.join(path.dirname(outputLibDir), 'embedded-mpv-runtime.json'), + `${JSON.stringify(manifest, null, 2)}\n` + ); + + return manifest; +} + +function patchAddonForBundledRuntime(addonPath, outputLibDir) { + if (!fs.existsSync(addonPath)) { + throw new Error(`Missing embedded MPV native addon: ${addonPath}`); + } + + patchBinaryDependencies(addonPath, outputLibDir, '@loader_path/lib'); + adHocSignBinary(addonPath); +} + +function validateNoForbiddenRuntimeLinks(binaryPaths) { + const errors = []; + + for (const binaryPath of binaryPaths) { + if (!fs.existsSync(binaryPath)) { + errors.push(`Missing binary: ${binaryPath}`); + continue; + } + + for (const dependencyPath of parseOtoolDependencies(binaryPath)) { + if (isForbiddenDependency(dependencyPath)) { + errors.push( + `${binaryPath} links to forbidden runtime path: ${dependencyPath}` + ); + } + + if (dependencyPath.startsWith('@loader_path/')) { + const resolvedPath = path.resolve( + path.dirname(binaryPath), + dependencyPath.replace('@loader_path/', '') + ); + if (!fs.existsSync(resolvedPath)) { + errors.push( + `${binaryPath} links to missing bundled dependency: ${dependencyPath}` + ); + } + } + + if ( + !isSystemDependency(dependencyPath) && + !dependencyPath.startsWith('@loader_path/') + ) { + errors.push( + `${binaryPath} links to non-bundled runtime path: ${dependencyPath}` + ); + } + } + } + + return errors; +} + +function validatePackagedEmbeddedMpv(resourceDir, options = {}) { + if (process.platform !== 'darwin') { + return []; + } + + if (!commandExists('otool')) { + return ['otool is required to validate embedded MPV macOS packaging.']; + } + + const unpackedNativeDir = path.join( + resourceDir, + 'app.asar.unpacked', + 'electron-backend', + 'native' + ); + const addonPath = path.join(unpackedNativeDir, 'embedded_mpv.node'); + const libDir = path.join(unpackedNativeDir, 'lib'); + const manifestPath = path.join( + unpackedNativeDir, + 'embedded-mpv-runtime.json' + ); + const errors = []; + + if (!fs.existsSync(addonPath)) { + if (options.required) { + errors.push(`Missing embedded MPV native addon: ${addonPath}`); + } + return errors; + } + + if (!fs.existsSync(manifestPath)) { + errors.push(`Missing embedded MPV runtime manifest: ${manifestPath}`); + } else { + const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8')); + if (manifest.origin !== 'vendored-lgpl') { + errors.push( + `Embedded MPV packaged runtime must be vendored-lgpl, received: ${manifest.origin}` + ); + } + } + + if (!fs.existsSync(path.join(libDir, 'libmpv.2.dylib'))) { + errors.push(`Missing bundled libmpv.2.dylib in ${libDir}`); + } + + const binaries = [addonPath, ...listRuntimeFiles(libDir)]; + errors.push(...validateNoForbiddenRuntimeLinks(binaries)); + + return errors; +} + +module.exports = { + collectExternalDylibs, + commandExists, + copyRuntimeToNativeBuild, + findLibMpv, + listRuntimeFiles, + listDylibs, + parseOtoolDependencies, + parseOtoolRpaths, + patchAddonForBundledRuntime, + validateNoForbiddenRuntimeLinks, + validatePackagedEmbeddedMpv, +}; diff --git a/tools/packaging/verify-electron-package-layout.mjs b/tools/packaging/verify-electron-package-layout.mjs index 69d024220..18dd64777 100644 --- a/tools/packaging/verify-electron-package-layout.mjs +++ b/tools/packaging/verify-electron-package-layout.mjs @@ -1,6 +1,9 @@ import fs from 'fs'; +import { createRequire } from 'module'; import path from 'path'; +const require = createRequire(import.meta.url); +const { validatePackagedEmbeddedMpv } = require('./embedded-mpv-macos.cjs'); const args = process.argv.slice(2); const normalizedArgs = args[0] === '--' ? args.slice(1) : args; const [platform, arch = ''] = normalizedArgs; @@ -36,6 +39,7 @@ const electronBuilderConfig = JSON.parse( ); const flatpakFinishArgs = electronBuilderConfig.flatpak?.finishArgs ?? []; const snapConfigInspection = loadSnapConfigInspection(); +const embeddedMpvRequired = isTruthy(process.env.IPTVNATOR_REQUIRE_EMBEDDED_MPV); const workerRelativeDir = path.join( 'dist', 'apps', @@ -66,6 +70,14 @@ function fileExists(filePath) { return fs.existsSync(filePath) && fs.statSync(filePath).isFile(); } +function isTruthy(value) { + return ['1', 'true', 'yes', 'on'].includes( + String(value ?? '') + .trim() + .toLowerCase() + ); +} + function getMacResourceDirs() { const candidates = [ { @@ -477,6 +489,14 @@ function verifyResourceDir(resourceDir) { verifyLinuxLauncher(resourceDir, errors); } + if (platform === 'macos') { + errors.push( + ...validatePackagedEmbeddedMpv(resourceDir, { + required: embeddedMpvRequired, + }) + ); + } + return { resourceDir, errors, diff --git a/vendor/embedded-mpv/README.md b/vendor/embedded-mpv/README.md new file mode 100644 index 000000000..df0e4de0f --- /dev/null +++ b/vendor/embedded-mpv/README.md @@ -0,0 +1,10 @@ +# Embedded MPV Runtime Artifacts + +This directory is the staging location for macOS embedded MPV runtime artifacts. + +Generated architecture folders are expected at: + +- `vendor/embedded-mpv/darwin-arm64/` +- `vendor/embedded-mpv/darwin-x64/` + +Each generated folder must contain `include/mpv/client.h`, `lib/*.dylib`, and `runtime-manifest.json`. The binary runtime directories are ignored by git by default; generate or restore them in release packaging jobs before building the Electron backend. diff --git a/vendor/embedded-mpv/darwin-arm64/README.md b/vendor/embedded-mpv/darwin-arm64/README.md new file mode 100644 index 000000000..182b0599a --- /dev/null +++ b/vendor/embedded-mpv/darwin-arm64/README.md @@ -0,0 +1,9 @@ +# darwin-arm64 Embedded MPV Runtime + +Generate this folder with: + +```bash +node tools/embedded-mpv/stage-macos-runtime.mjs arm64 /path/to/lgpl-prefix +``` + +The generated `include/`, `lib/`, and `runtime-manifest.json` files are release inputs and are ignored by git by default. diff --git a/vendor/embedded-mpv/darwin-x64/README.md b/vendor/embedded-mpv/darwin-x64/README.md new file mode 100644 index 000000000..4bbc8173b --- /dev/null +++ b/vendor/embedded-mpv/darwin-x64/README.md @@ -0,0 +1,9 @@ +# darwin-x64 Embedded MPV Runtime + +Generate this folder with: + +```bash +node tools/embedded-mpv/stage-macos-runtime.mjs x64 /path/to/lgpl-prefix +``` + +The generated `include/`, `lib/`, and `runtime-manifest.json` files are release inputs and are ignored by git by default. From 0b5089853f82a0d50dc8c6fdeee1e8532b3b08f4 Mon Sep 17 00:00:00 2001 From: 4gray Date: Mon, 27 Apr 2026 00:46:54 +0200 Subject: [PATCH 004/116] ci(build): adjust conditions for macOS Embedded MPV artifact in CI workflow Entire-Checkpoint: c6e522b4276c --- .github/workflows/build-and-make.yaml | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/.github/workflows/build-and-make.yaml b/.github/workflows/build-and-make.yaml index f9f3d70d6..7864279fb 100644 --- a/.github/workflows/build-and-make.yaml +++ b/.github/workflows/build-and-make.yaml @@ -89,7 +89,9 @@ jobs: run: pnpm nx build web --skip-nx-cache - name: Build embedded MPV runtime (macOS release) - if: matrix.os == 'macos' && startsWith(github.ref, 'refs/tags/v') + # TEMPORARY PR TEST: remove `|| github.event_name == 'pull_request'` + # after the macOS Embedded MPV artifact is built and manually tested. + if: matrix.os == 'macos' && (startsWith(github.ref, 'refs/tags/v') || github.event_name == 'pull_request') shell: bash run: | set -euo pipefail @@ -281,7 +283,9 @@ jobs: if: matrix.os != 'macos' || github.event_name == 'pull_request' env: IPTVNATOR_EMBEDDED_MPV_ARCH: ${{ matrix.arch || '' }} - IPTVNATOR_REQUIRE_EMBEDDED_MPV: '0' + # TEMPORARY PR TEST: change this back to '0' after manually + # testing the macOS PR artifact with Embedded MPV included. + IPTVNATOR_REQUIRE_EMBEDDED_MPV: ${{ matrix.os == 'macos' && github.event_name == 'pull_request' && '1' || '0' }} run: pnpm run make:app - name: Verify packaged worker layout @@ -289,7 +293,9 @@ jobs: env: PACKAGE_OS: ${{ matrix.os }} PACKAGE_ARCH: ${{ matrix.arch || '' }} - IPTVNATOR_REQUIRE_EMBEDDED_MPV: ${{ matrix.os == 'macos' && startsWith(github.ref, 'refs/tags/v') && '1' || '0' }} + # TEMPORARY PR TEST: remove the PR branch after manually + # testing the macOS artifact; tagged releases should stay required. + IPTVNATOR_REQUIRE_EMBEDDED_MPV: ${{ matrix.os == 'macos' && (startsWith(github.ref, 'refs/tags/v') || github.event_name == 'pull_request') && '1' || '0' }} run: pnpm run verify:package-layout -- "$PACKAGE_OS" "$PACKAGE_ARCH" - name: Smoke test packaged Flatpak launcher From 09e65f88473e20f8e894c13a61d4764eec822ea0 Mon Sep 17 00:00:00 2001 From: 4gray Date: Mon, 27 Apr 2026 00:59:33 +0200 Subject: [PATCH 005/116] refactor(args): rename variable for clarity in macOS runtime scripts Entire-Checkpoint: c6e522b4276c --- tools/embedded-mpv/build-macos-runtime.mjs | 3 ++- tools/embedded-mpv/stage-macos-runtime.mjs | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/tools/embedded-mpv/build-macos-runtime.mjs b/tools/embedded-mpv/build-macos-runtime.mjs index 85ab1a036..853ba1e7f 100644 --- a/tools/embedded-mpv/build-macos-runtime.mjs +++ b/tools/embedded-mpv/build-macos-runtime.mjs @@ -5,7 +5,8 @@ import os from 'os'; import path from 'path'; import { spawnSync } from 'child_process'; -const args = process.argv.slice(2); +const rawArgs = process.argv.slice(2); +const args = rawArgs[0] === '--' ? rawArgs.slice(1) : rawArgs; const [arch, rawPrefix] = args; const validArchitectures = new Set(['arm64', 'x64']); const macosDeploymentTarget = diff --git a/tools/embedded-mpv/stage-macos-runtime.mjs b/tools/embedded-mpv/stage-macos-runtime.mjs index 2f7588f27..583669e61 100644 --- a/tools/embedded-mpv/stage-macos-runtime.mjs +++ b/tools/embedded-mpv/stage-macos-runtime.mjs @@ -1,7 +1,8 @@ import fs from 'fs'; import path from 'path'; -const args = process.argv.slice(2); +const rawArgs = process.argv.slice(2); +const args = rawArgs[0] === '--' ? rawArgs.slice(1) : rawArgs; const [arch, sourcePrefix] = args; const validArchitectures = new Set(['arm64', 'x64']); const workspaceRoot = process.cwd(); From 03b5270bc2f601aeada53d2dbca66f7954dff4b1 Mon Sep 17 00:00:00 2001 From: 4gray Date: Mon, 27 Apr 2026 01:27:11 +0200 Subject: [PATCH 006/116] ci(build): enhance macOS runtime build process with git source management for libplacebo Entire-Checkpoint: c6e522b4276c --- docs/architecture/embedded-mpv-native.md | 2 +- tools/embedded-mpv/README.md | 4 +- tools/embedded-mpv/build-macos-runtime.mjs | 109 +++++++++++++++++++-- 3 files changed, 106 insertions(+), 9 deletions(-) diff --git a/docs/architecture/embedded-mpv-native.md b/docs/architecture/embedded-mpv-native.md index e87e989b0..86c93c44d 100644 --- a/docs/architecture/embedded-mpv-native.md +++ b/docs/architecture/embedded-mpv-native.md @@ -122,7 +122,7 @@ pnpm embedded-mpv:build-runtime -- arm64 /tmp/embedded-mpv-prefix pnpm embedded-mpv:stage-runtime -- arm64 /tmp/embedded-mpv-prefix ``` -The CI builder pins FFmpeg `8.1`, mpv `0.41.0`, libplacebo `7.360.1`, libass `0.17.3`, FreeType `2.13.3`, FriBidi `1.0.16`, and HarfBuzz `8.5.0`. FFmpeg disables autodetected external libraries so Homebrew libraries cannot silently enter the runtime. The generated manifest records source URLs, SHA-256 values, FFmpeg configure flags, and mpv Meson flags. The staging step normalizes that manifest to `origin: vendored-lgpl`, which release package validation requires. +The CI builder pins FFmpeg `8.1`, mpv `0.41.0`, libplacebo `7.360.1`, libass `0.17.3`, FreeType `2.13.3`, FriBidi `1.0.16`, and HarfBuzz `8.5.0`. FFmpeg disables autodetected external libraries so Homebrew libraries cannot silently enter the runtime. Libplacebo is checked out from git with the OpenGL-related submodules required by its Meson build because the generated GitHub archive does not include submodule contents. The generated manifest records source URLs, archive SHA-256 values where applicable, libplacebo git commit/submodule metadata, FFmpeg configure flags, and mpv Meson flags. The staging step normalizes that manifest to `origin: vendored-lgpl`, which release package validation requires. The Electron backend build consumes the staged runtime, copies Mach-O runtime files into the native build output, and rewrites Mach-O paths so `embedded_mpv.node` loads `@loader_path/lib/libmpv.2.dylib` instead of a machine-local Homebrew path. After `install_name_tool` rewrites any addon or runtime binary, the build re-signs that binary with an ad-hoc signature for local development. Release packaging still performs the normal app signing and notarization later. diff --git a/tools/embedded-mpv/README.md b/tools/embedded-mpv/README.md index eb49e9b98..1e711bec3 100644 --- a/tools/embedded-mpv/README.md +++ b/tools/embedded-mpv/README.md @@ -61,10 +61,10 @@ The builder currently pins: - FFmpeg `8.1`, configured without `--enable-gpl` or `--enable-nonfree`, and with autodetected external libraries disabled - mpv `0.41.0`, configured with `-Dlibmpv=true -Dgpl=false` -- libplacebo `7.360.1` +- libplacebo `7.360.1`, checked out from git with the OpenGL-related submodules required by its Meson build - libass `0.17.3` plus FreeType, FriBidi, and HarfBuzz -The build manifest records source URLs, downloaded source SHA-256 values, and the exact FFmpeg/mpv flags. The staged manifest is normalized to `origin: vendored-lgpl`, which is the only embedded MPV runtime origin allowed in required macOS release packaging. +The build manifest records source URLs, downloaded archive SHA-256 values where applicable, libplacebo git commit/submodule metadata, and the exact FFmpeg/mpv flags. The staged manifest is normalized to `origin: vendored-lgpl`, which is the only embedded MPV runtime origin allowed in required macOS release packaging. ## Build Integration diff --git a/tools/embedded-mpv/build-macos-runtime.mjs b/tools/embedded-mpv/build-macos-runtime.mjs index 853ba1e7f..5520bf505 100644 --- a/tools/embedded-mpv/build-macos-runtime.mjs +++ b/tools/embedded-mpv/build-macos-runtime.mjs @@ -47,7 +47,8 @@ const sourcePackages = [ { id: 'libplacebo', version: '7.360.1', - url: 'https://github.com/haasn/libplacebo/archive/refs/tags/v7.360.1.tar.gz', + tag: 'v7.360.1', + gitUrl: 'https://github.com/haasn/libplacebo.git', license: 'LGPL-2.1-or-later', }, { @@ -196,6 +197,7 @@ function ensureTools() { 'meson', 'ninja', 'pkg-config', + 'git', ]; const missing = requiredCommands.filter((command) => !commandExists(command)); @@ -224,11 +226,86 @@ function sha256File(filePath) { return hash.digest('hex'); } +function runCapture(command, commandArgs, options = {}) { + const result = spawnSync(command, commandArgs, { + cwd: options.cwd ?? workspaceRoot, + env: options.env ?? buildEnv(), + encoding: 'utf8', + stdio: 'pipe', + ...options, + }); + + if (result.status !== 0) { + const stderr = result.stderr ? `\n${result.stderr}` : ''; + throw new Error( + `${command} ${commandArgs.join(' ')} failed with status ${ + result.status ?? 1 + }.${stderr}` + ); + } + + return result.stdout.trim(); +} + +function cloneGitSource(sourcePackage) { + const packageSourcePath = sourcePathFor(sourcePackage.id); + fs.rmSync(packageSourcePath, { recursive: true, force: true }); + + run('git', [ + 'clone', + '--depth', + '1', + '--branch', + sourcePackage.tag, + sourcePackage.gitUrl, + packageSourcePath, + ]); + run( + 'git', + [ + 'submodule', + 'update', + '--init', + '--depth', + '1', + '3rdparty/glad', + '3rdparty/jinja', + '3rdparty/markupsafe', + '3rdparty/fast_float', + ], + { cwd: packageSourcePath } + ); + + sourcePackage.gitCommit = runCapture('git', ['rev-parse', 'HEAD'], { + cwd: packageSourcePath, + }); + sourcePackage.submodules = runCapture( + 'git', + [ + 'submodule', + 'status', + '3rdparty/glad', + '3rdparty/jinja', + '3rdparty/markupsafe', + '3rdparty/fast_float', + ], + { cwd: packageSourcePath } + ) + .split(/\r?\n/) + .map((line) => line.trim()) + .filter(Boolean); +} + function downloadSources() { fs.mkdirSync(archiveRoot, { recursive: true }); fs.mkdirSync(sourceRoot, { recursive: true }); for (const sourcePackage of sourcePackages) { + if (sourcePackage.gitUrl) { + cloneGitSource(sourcePackage); + continue; + } + const archivePath = archivePathFor(sourcePackage); if (!fs.existsSync(archivePath)) { run('curl', [ @@ -414,8 +491,17 @@ function sourceMetadata(packageId) { const sourcePackage = packageById.get(packageId); return { version: sourcePackage.version, - sourceUrl: sourcePackage.url, - sourceSha256: sourcePackage.sha256, + sourceUrl: sourcePackage.url ?? sourcePackage.gitUrl, + ...(sourcePackage.tag ? { sourceTag: sourcePackage.tag } : {}), + ...(sourcePackage.sha256 + ? { sourceSha256: sourcePackage.sha256 } + : {}), + ...(sourcePackage.gitCommit + ? { sourceGitCommit: sourcePackage.gitCommit } + : {}), + ...(sourcePackage.submodules + ? { sourceSubmodules: sourcePackage.submodules } + : {}), license: sourcePackage.license, }; } @@ -435,8 +521,19 @@ function writeManifest() { sourcePackage.id, { version: sourcePackage.version, - sourceUrl: sourcePackage.url, - sourceSha256: sourcePackage.sha256, + sourceUrl: sourcePackage.url ?? sourcePackage.gitUrl, + ...(sourcePackage.tag + ? { sourceTag: sourcePackage.tag } + : {}), + ...(sourcePackage.sha256 + ? { sourceSha256: sourcePackage.sha256 } + : {}), + ...(sourcePackage.gitCommit + ? { sourceGitCommit: sourcePackage.gitCommit } + : {}), + ...(sourcePackage.submodules + ? { sourceSubmodules: sourcePackage.submodules } + : {}), license: sourcePackage.license, }, ]) @@ -453,7 +550,7 @@ function writeManifest() { mesonFlags: mpvMesonFlags, }, sourceDistribution: - 'Attach the downloaded source archives, this manifest, and any local patches with the macOS binary release.', + 'Attach the downloaded source archives, the libplacebo git checkout metadata, this manifest, and any local patches with the macOS binary release.', }; fs.writeFileSync( From 37fb7e41ee2968bf29b60c2da5f005957ea25fc3 Mon Sep 17 00:00:00 2001 From: 4gray Date: Mon, 27 Apr 2026 09:15:24 +0200 Subject: [PATCH 007/116] ci(build): include Vulkan-Headers submodule in macOS runtime build process Entire-Checkpoint: c6e522b4276c --- docs/architecture/embedded-mpv-native.md | 2 +- tools/embedded-mpv/README.md | 2 +- tools/embedded-mpv/build-macos-runtime.mjs | 2 ++ 3 files changed, 4 insertions(+), 2 deletions(-) diff --git a/docs/architecture/embedded-mpv-native.md b/docs/architecture/embedded-mpv-native.md index 86c93c44d..b990796a3 100644 --- a/docs/architecture/embedded-mpv-native.md +++ b/docs/architecture/embedded-mpv-native.md @@ -122,7 +122,7 @@ pnpm embedded-mpv:build-runtime -- arm64 /tmp/embedded-mpv-prefix pnpm embedded-mpv:stage-runtime -- arm64 /tmp/embedded-mpv-prefix ``` -The CI builder pins FFmpeg `8.1`, mpv `0.41.0`, libplacebo `7.360.1`, libass `0.17.3`, FreeType `2.13.3`, FriBidi `1.0.16`, and HarfBuzz `8.5.0`. FFmpeg disables autodetected external libraries so Homebrew libraries cannot silently enter the runtime. Libplacebo is checked out from git with the OpenGL-related submodules required by its Meson build because the generated GitHub archive does not include submodule contents. The generated manifest records source URLs, archive SHA-256 values where applicable, libplacebo git commit/submodule metadata, FFmpeg configure flags, and mpv Meson flags. The staging step normalizes that manifest to `origin: vendored-lgpl`, which release package validation requires. +The CI builder pins FFmpeg `8.1`, mpv `0.41.0`, libplacebo `7.360.1`, libass `0.17.3`, FreeType `2.13.3`, FriBidi `1.0.16`, and HarfBuzz `8.5.0`. FFmpeg disables autodetected external libraries so Homebrew libraries cannot silently enter the runtime. Libplacebo is checked out from git with the submodules required by its Meson build because the generated GitHub archive does not include submodule contents. Even with Vulkan disabled, libplacebo still compiles Vulkan stubs and needs `3rdparty/Vulkan-Headers`. The generated manifest records source URLs, archive SHA-256 values where applicable, libplacebo git commit/submodule metadata, FFmpeg configure flags, and mpv Meson flags. The staging step normalizes that manifest to `origin: vendored-lgpl`, which release package validation requires. The Electron backend build consumes the staged runtime, copies Mach-O runtime files into the native build output, and rewrites Mach-O paths so `embedded_mpv.node` loads `@loader_path/lib/libmpv.2.dylib` instead of a machine-local Homebrew path. After `install_name_tool` rewrites any addon or runtime binary, the build re-signs that binary with an ad-hoc signature for local development. Release packaging still performs the normal app signing and notarization later. diff --git a/tools/embedded-mpv/README.md b/tools/embedded-mpv/README.md index 1e711bec3..cd29b59d6 100644 --- a/tools/embedded-mpv/README.md +++ b/tools/embedded-mpv/README.md @@ -61,7 +61,7 @@ The builder currently pins: - FFmpeg `8.1`, configured without `--enable-gpl` or `--enable-nonfree`, and with autodetected external libraries disabled - mpv `0.41.0`, configured with `-Dlibmpv=true -Dgpl=false` -- libplacebo `7.360.1`, checked out from git with the OpenGL-related submodules required by its Meson build +- libplacebo `7.360.1`, checked out from git with the `glad`, Python template, `fast_float`, and `Vulkan-Headers` submodules required by its Meson build - libass `0.17.3` plus FreeType, FriBidi, and HarfBuzz The build manifest records source URLs, downloaded archive SHA-256 values where applicable, libplacebo git commit/submodule metadata, and the exact FFmpeg/mpv flags. The staged manifest is normalized to `origin: vendored-lgpl`, which is the only embedded MPV runtime origin allowed in required macOS release packaging. diff --git a/tools/embedded-mpv/build-macos-runtime.mjs b/tools/embedded-mpv/build-macos-runtime.mjs index 5520bf505..f15e2777b 100644 --- a/tools/embedded-mpv/build-macos-runtime.mjs +++ b/tools/embedded-mpv/build-macos-runtime.mjs @@ -272,6 +272,7 @@ function cloneGitSource(sourcePackage) { '3rdparty/jinja', '3rdparty/markupsafe', '3rdparty/fast_float', + '3rdparty/Vulkan-Headers', ], { cwd: packageSourcePath } ); @@ -288,6 +289,7 @@ function cloneGitSource(sourcePackage) { '3rdparty/jinja', '3rdparty/markupsafe', '3rdparty/fast_float', + '3rdparty/Vulkan-Headers', ], { cwd: packageSourcePath } ) From 6aa4f2521b83e7f8f5a6040d62c2d6e3a275f009 Mon Sep 17 00:00:00 2001 From: 4gray Date: Mon, 27 Apr 2026 11:06:12 +0200 Subject: [PATCH 008/116] build(embedded-mpv): enhance macOS build process and validation for embedded MPV integration Entire-Checkpoint: c6e522b4276c --- .github/workflows/build-and-make.yaml | 22 +++++++++ apps/electron-backend/build-embedded-mpv.js | 40 ++++++++++++--- apps/electron-backend/project.json | 54 +++++++++++++++++++-- docs/architecture/embedded-mpv-native.md | 7 ++- electron-builder.json | 7 +++ tools/embedded-mpv/README.md | 6 ++- 6 files changed, 118 insertions(+), 18 deletions(-) diff --git a/.github/workflows/build-and-make.yaml b/.github/workflows/build-and-make.yaml index 7864279fb..b1dab17e1 100644 --- a/.github/workflows/build-and-make.yaml +++ b/.github/workflows/build-and-make.yaml @@ -105,8 +105,30 @@ jobs: - name: Build backend env: IPTVNATOR_EMBEDDED_MPV_ARCH: ${{ matrix.arch || '' }} + IPTVNATOR_REQUIRE_EMBEDDED_MPV: ${{ matrix.os == 'macos' && (startsWith(github.ref, 'refs/tags/v') || github.event_name == 'pull_request') && '1' || '0' }} run: pnpm run build:backend + - name: Verify embedded MPV build output + # TEMPORARY PR TEST: remove `|| github.event_name == 'pull_request'` + # after the macOS Embedded MPV artifact is built and manually tested. + if: matrix.os == 'macos' && (startsWith(github.ref, 'refs/tags/v') || github.event_name == 'pull_request') + shell: bash + run: | + set -euo pipefail + + echo "::group::Native build output" + find apps/electron-backend/native/build/Release -maxdepth 3 -type f | sort + echo "::endgroup::" + + echo "::group::Dist native output" + find dist/apps/electron-backend/native -maxdepth 3 -type f | sort + echo "::endgroup::" + + test -f apps/electron-backend/native/build/Release/embedded_mpv.node + test -f dist/apps/electron-backend/native/embedded_mpv.node + test -f dist/apps/electron-backend/native/embedded-mpv-runtime.json + test -f dist/apps/electron-backend/native/lib/libmpv.2.dylib + - name: Validate AppStream metadata if: matrix.os == 'linux' run: appstreamcli validate --pedantic --no-net apps/electron-backend/linux/com.fourgray.iptvnator.metainfo.xml diff --git a/apps/electron-backend/build-embedded-mpv.js b/apps/electron-backend/build-embedded-mpv.js index 6358522a2..73ed27634 100644 --- a/apps/electron-backend/build-embedded-mpv.js +++ b/apps/electron-backend/build-embedded-mpv.js @@ -13,6 +13,13 @@ const addonRoot = path.join(workspaceRoot, 'apps', 'electron-backend', 'native') const outputDir = path.join(addonRoot, 'build', 'Release'); const outputFile = path.join(outputDir, 'embedded_mpv.node'); const outputLibDir = path.join(outputDir, 'lib'); +const distNativeDir = path.join( + workspaceRoot, + 'dist', + 'apps', + 'electron-backend', + 'native' +); const unavailableMarkerFile = path.join(outputDir, 'embedded-mpv-unavailable.txt'); const homebrewIncludeDir = '/opt/homebrew/include'; const homebrewLibDir = '/opt/homebrew/lib'; @@ -30,6 +37,9 @@ const vendoredIncludeDir = path.join(vendoredRuntimeRoot, 'include'); const vendoredLibDir = path.join(vendoredRuntimeRoot, 'lib'); const homebrewFallbackEnabled = process.env.IPTVNATOR_EMBEDDED_MPV_ALLOW_HOMEBREW === '1'; +const embeddedMpvRequired = ['1', 'true', 'yes', 'on'].includes( + (process.env.IPTVNATOR_REQUIRE_EMBEDDED_MPV ?? '').trim().toLowerCase() +); function log(message) { process.stdout.write(`[embedded-mpv] ${message}\n`); @@ -38,6 +48,11 @@ function log(message) { function cleanOutput() { fs.rmSync(outputFile, { force: true }); fs.rmSync(outputLibDir, { recursive: true, force: true }); + fs.rmSync(path.join(outputDir, '.deps'), { recursive: true, force: true }); + fs.rmSync(path.join(outputDir, 'obj.target'), { + recursive: true, + force: true, + }); fs.rmSync(path.join(outputDir, 'embedded-mpv-runtime.json'), { force: true, }); @@ -47,6 +62,10 @@ function cleanOutput() { ); } +function cleanDistNativeOutput() { + fs.rmSync(distNativeDir, { recursive: true, force: true }); +} + function readRuntimeManifest(runtimeRoot) { const manifestPath = path.join(runtimeRoot, 'runtime-manifest.json'); if (!fs.existsSync(manifestPath)) { @@ -146,9 +165,13 @@ function runNodeGyp(command, env) { function main() { fs.mkdirSync(outputDir, { recursive: true }); + cleanDistNativeOutput(); if (process.platform !== 'darwin') { cleanOutput(); + if (embeddedMpvRequired) { + throw new Error('Embedded MPV is required but this host is not macOS.'); + } log('Skipping build on non-macOS host.'); return; } @@ -156,13 +179,16 @@ function main() { const runtime = resolveRuntime(); if (!runtime) { cleanOutput(); - log( - [ - `Skipping build because no embedded MPV runtime was found for darwin-${targetArch}.`, - `Expected vendored runtime at ${vendoredRuntimeRoot}.`, - 'For local development only, set IPTVNATOR_EMBEDDED_MPV_ALLOW_HOMEBREW=1 to use Homebrew libmpv.', - ].join('\n') - ); + const message = [ + `Skipping build because no embedded MPV runtime was found for darwin-${targetArch}.`, + `Expected vendored runtime at ${vendoredRuntimeRoot}.`, + 'For local development only, set IPTVNATOR_EMBEDDED_MPV_ALLOW_HOMEBREW=1 to use Homebrew libmpv.', + ].join('\n'); + if (embeddedMpvRequired) { + throw new Error(message); + } + + log(message); return; } diff --git a/apps/electron-backend/project.json b/apps/electron-backend/project.json index f0deb78af..ab1a2486b 100644 --- a/apps/electron-backend/project.json +++ b/apps/electron-backend/project.json @@ -44,9 +44,15 @@ "assets": [ "apps/electron-backend/src/assets", { - "glob": "{embedded_mpv.node,embedded-mpv-runtime.json,embedded-mpv-unavailable.txt,lib/*}", + "glob": "**/*", "input": "apps/electron-backend/native/build/Release", - "output": "native" + "output": "native", + "ignore": [ + ".deps/**", + "obj.target/**", + "**/.deps/**", + "**/obj.target/**" + ] } ] }, @@ -91,9 +97,15 @@ "assets": [ "apps/electron-backend/src/assets", { - "glob": "{embedded_mpv.node,embedded-mpv-runtime.json,embedded-mpv-unavailable.txt,lib/*}", + "glob": "**/*", "input": "apps/electron-backend/native/build/Release", - "output": "native" + "output": "native", + "ignore": [ + ".deps/**", + "obj.target/**", + "**/.deps/**", + "**/obj.target/**" + ] } ] } @@ -120,6 +132,22 @@ "frontendProject": "web", "sourcePath": "dist/apps", "outputPath": "dist/packages", + "files": [ + { + "from": "electron-backend/native", + "to": "electron-backend/native", + "filter": ["**/*"] + } + ], + "asarUnpack": [ + "**/better-sqlite3/**", + "**/bindings/**", + "**/file-uri-to-path/**", + "electron-backend/native/**", + "**/electron-backend/native/**", + "**/*.node", + "**/*.dylib" + ], "prepackageOnly": true } }, @@ -130,7 +158,23 @@ "name": "electron-backend", "frontendProject": "web", "sourcePath": "dist/apps", - "outputPath": "dist/executables" + "outputPath": "dist/executables", + "files": [ + { + "from": "electron-backend/native", + "to": "electron-backend/native", + "filter": ["**/*"] + } + ], + "asarUnpack": [ + "**/better-sqlite3/**", + "**/bindings/**", + "**/file-uri-to-path/**", + "electron-backend/native/**", + "**/electron-backend/native/**", + "**/*.node", + "**/*.dylib" + ] } }, "lint": { diff --git a/docs/architecture/embedded-mpv-native.md b/docs/architecture/embedded-mpv-native.md index b990796a3..9566f2398 100644 --- a/docs/architecture/embedded-mpv-native.md +++ b/docs/architecture/embedded-mpv-native.md @@ -76,8 +76,7 @@ Current development behavior: - The staged runtime must contain `include/mpv/client.h`, `lib/*.dylib`, and `runtime-manifest.json`. - The compiled `.node` addon is copied into `dist/apps/electron-backend/native/embedded_mpv.node`. - Bundled runtime files are copied into `dist/apps/electron-backend/native/lib/`. Most are `.dylib` files, but some Homebrew-linked runtimes expose non-`.dylib` Mach-O files such as a framework `Python` binary. -- `electron-builder` unpacks `.node` files from ASAR. -- `electron-builder` also unpacks `.dylib` files from ASAR. +- `electron-builder` includes `dist/apps/electron-backend/native/` in the app package and unpacks `electron-backend/native/**` from ASAR so the addon, manifest, dylibs, and non-`.dylib` Mach-O runtime files are filesystem-addressable. Current release caveat: @@ -115,7 +114,7 @@ node tools/embedded-mpv/stage-macos-runtime.mjs arm64 /path/to/lgpl-prefix node tools/embedded-mpv/stage-macos-runtime.mjs x64 /path/to/lgpl-prefix ``` -Tagged macOS release CI builds that prefix from pinned source archives first: +Tagged macOS release CI builds that prefix from pinned source archives first. The workflow can temporarily run the same path for macOS PR artifacts while the bundled runtime is being tested: ```bash pnpm embedded-mpv:build-runtime -- arm64 /tmp/embedded-mpv-prefix @@ -165,7 +164,7 @@ For tagged macOS builds, CI must: - set `IPTVNATOR_EMBEDDED_MPV_ARCH=${arch}` for backend build and packaging - set `IPTVNATOR_REQUIRE_EMBEDDED_MPV=1` for packaging and package-layout verification -For PR, development, Linux, and Windows packaging, `IPTVNATOR_REQUIRE_EMBEDDED_MPV` stays unset or `0`. In that mode the package validators still reject a present but invalid Embedded MPV runtime, but they do not require the addon to exist. This keeps the native feature in-tree without making non-macOS or non-release builds depend on macOS runtime artifacts. +During the temporary macOS PR artifact test, CI also sets `IPTVNATOR_REQUIRE_EMBEDDED_MPV=1` for macOS PR backend build, packaging, and package-layout verification. After the artifact is manually validated, remove the workflow's `pull_request` condition so PR, development, Linux, and Windows packaging leave `IPTVNATOR_REQUIRE_EMBEDDED_MPV` unset or `0`. In that normal mode the package validators still reject a present but invalid Embedded MPV runtime, but they do not require the addon to exist. This keeps the native feature in-tree without making non-macOS or non-release builds depend on macOS runtime artifacts. ## Release Safety diff --git a/electron-builder.json b/electron-builder.json index 3cfaefd57..fd7fbd8b5 100644 --- a/electron-builder.json +++ b/electron-builder.json @@ -12,6 +12,11 @@ "to": "remote-control-web", "filter": ["**/*"] }, + { + "from": "dist/apps/electron-backend/native", + "to": "electron-backend/native", + "filter": ["**/*"] + }, "electron-backend/**/*", "web/**/*", "!**/*.map" @@ -20,6 +25,8 @@ "**/better-sqlite3/**", "**/bindings/**", "**/file-uri-to-path/**", + "electron-backend/native/**", + "**/electron-backend/native/**", "**/*.node", "**/*.dylib" ], diff --git a/tools/embedded-mpv/README.md b/tools/embedded-mpv/README.md index cd29b59d6..da4296dc7 100644 --- a/tools/embedded-mpv/README.md +++ b/tools/embedded-mpv/README.md @@ -50,7 +50,7 @@ If the prefix contains `runtime-manifest.json`, the staging script copies its bu ## Building The CI Runtime -Tagged macOS release builds build the runtime from pinned source archives before `electron-backend:build`: +Tagged macOS release builds build the runtime from pinned source archives before `electron-backend:build`. The workflow can also enable this path temporarily for macOS PR artifact testing: ```bash pnpm embedded-mpv:build-runtime -- arm64 /tmp/embedded-mpv-prefix @@ -70,6 +70,8 @@ The build manifest records source URLs, downloaded archive SHA-256 values where `apps/electron-backend/build-embedded-mpv.js` links the native addon against the staged runtime, copies dylibs into `apps/electron-backend/native/build/Release/lib/`, rewrites Mach-O paths to `@loader_path`, and writes `embedded-mpv-runtime.json`. +The Electron builder and Nx package/make targets package `dist/apps/electron-backend/native/` into `electron-backend/native/` and unpack that whole native directory from ASAR so the addon, runtime manifest, dylibs, and non-`.dylib` Mach-O runtime files are available as real files. + During release packaging, `tools/packaging/electron-after-pack.cjs` verifies that the packaged app uses a `vendored-lgpl` runtime and has no `/opt/homebrew` or `/usr/local` dynamic links for embedded MPV. -Set `IPTVNATOR_REQUIRE_EMBEDDED_MPV=1` when packaging a macOS release artifact that must include Embedded MPV. PR and non-tag development builds leave that variable unset or `0`, so the same in-tree code can package without a staged runtime while Settings keeps Embedded MPV hidden. +Set `IPTVNATOR_REQUIRE_EMBEDDED_MPV=1` when packaging a macOS release artifact that must include Embedded MPV. The same variable is temporarily enabled for macOS PR artifacts while the bundled runtime is being tested. After manual artifact validation, remove the workflow's PR condition so PR and non-tag development builds leave the variable unset or `0` and can package without a staged runtime while Settings keeps Embedded MPV hidden. From b25fc040b58d3a4dede0c837a17a12d0b0efd0ba Mon Sep 17 00:00:00 2001 From: 4gray Date: Mon, 27 Apr 2026 11:33:57 +0200 Subject: [PATCH 009/116] build(embedded-mpv): streamline macOS native output handling and packaging process Entire-Checkpoint: c6e522b4276c --- apps/electron-backend/project.json | 34 +----------------------- docs/architecture/embedded-mpv-native.md | 3 ++- electron-builder.json | 7 ----- tools/embedded-mpv/README.md | 2 +- tools/packaging/electron-after-pack.cjs | 30 +++++++++++++++++++++ 5 files changed, 34 insertions(+), 42 deletions(-) diff --git a/apps/electron-backend/project.json b/apps/electron-backend/project.json index ab1a2486b..d96fb3678 100644 --- a/apps/electron-backend/project.json +++ b/apps/electron-backend/project.json @@ -132,22 +132,6 @@ "frontendProject": "web", "sourcePath": "dist/apps", "outputPath": "dist/packages", - "files": [ - { - "from": "electron-backend/native", - "to": "electron-backend/native", - "filter": ["**/*"] - } - ], - "asarUnpack": [ - "**/better-sqlite3/**", - "**/bindings/**", - "**/file-uri-to-path/**", - "electron-backend/native/**", - "**/electron-backend/native/**", - "**/*.node", - "**/*.dylib" - ], "prepackageOnly": true } }, @@ -158,23 +142,7 @@ "name": "electron-backend", "frontendProject": "web", "sourcePath": "dist/apps", - "outputPath": "dist/executables", - "files": [ - { - "from": "electron-backend/native", - "to": "electron-backend/native", - "filter": ["**/*"] - } - ], - "asarUnpack": [ - "**/better-sqlite3/**", - "**/bindings/**", - "**/file-uri-to-path/**", - "electron-backend/native/**", - "**/electron-backend/native/**", - "**/*.node", - "**/*.dylib" - ] + "outputPath": "dist/executables" } }, "lint": { diff --git a/docs/architecture/embedded-mpv-native.md b/docs/architecture/embedded-mpv-native.md index 9566f2398..aa60b5d52 100644 --- a/docs/architecture/embedded-mpv-native.md +++ b/docs/architecture/embedded-mpv-native.md @@ -76,7 +76,8 @@ Current development behavior: - The staged runtime must contain `include/mpv/client.h`, `lib/*.dylib`, and `runtime-manifest.json`. - The compiled `.node` addon is copied into `dist/apps/electron-backend/native/embedded_mpv.node`. - Bundled runtime files are copied into `dist/apps/electron-backend/native/lib/`. Most are `.dylib` files, but some Homebrew-linked runtimes expose non-`.dylib` Mach-O files such as a framework `Python` binary. -- `electron-builder` includes `dist/apps/electron-backend/native/` in the app package and unpacks `electron-backend/native/**` from ASAR so the addon, manifest, dylibs, and non-`.dylib` Mach-O runtime files are filesystem-addressable. +- macOS `afterPack` copies `dist/apps/electron-backend/native/` into `app.asar.unpacked/electron-backend/native/` so the addon, manifest, dylibs, and non-`.dylib` Mach-O runtime files are filesystem-addressable. +- Linux and Windows packaging do not include the Embedded MPV native directory. Current release caveat: diff --git a/electron-builder.json b/electron-builder.json index fd7fbd8b5..3cfaefd57 100644 --- a/electron-builder.json +++ b/electron-builder.json @@ -12,11 +12,6 @@ "to": "remote-control-web", "filter": ["**/*"] }, - { - "from": "dist/apps/electron-backend/native", - "to": "electron-backend/native", - "filter": ["**/*"] - }, "electron-backend/**/*", "web/**/*", "!**/*.map" @@ -25,8 +20,6 @@ "**/better-sqlite3/**", "**/bindings/**", "**/file-uri-to-path/**", - "electron-backend/native/**", - "**/electron-backend/native/**", "**/*.node", "**/*.dylib" ], diff --git a/tools/embedded-mpv/README.md b/tools/embedded-mpv/README.md index da4296dc7..bad05eb1c 100644 --- a/tools/embedded-mpv/README.md +++ b/tools/embedded-mpv/README.md @@ -70,7 +70,7 @@ The build manifest records source URLs, downloaded archive SHA-256 values where `apps/electron-backend/build-embedded-mpv.js` links the native addon against the staged runtime, copies dylibs into `apps/electron-backend/native/build/Release/lib/`, rewrites Mach-O paths to `@loader_path`, and writes `embedded-mpv-runtime.json`. -The Electron builder and Nx package/make targets package `dist/apps/electron-backend/native/` into `electron-backend/native/` and unpack that whole native directory from ASAR so the addon, runtime manifest, dylibs, and non-`.dylib` Mach-O runtime files are available as real files. +The macOS `afterPack` hook copies `dist/apps/electron-backend/native/` into `app.asar.unpacked/electron-backend/native/` so the addon, runtime manifest, dylibs, and non-`.dylib` Mach-O runtime files are available as real files. Linux and Windows artifacts do not include that native directory. During release packaging, `tools/packaging/electron-after-pack.cjs` verifies that the packaged app uses a `vendored-lgpl` runtime and has no `/opt/homebrew` or `/usr/local` dynamic links for embedded MPV. diff --git a/tools/packaging/electron-after-pack.cjs b/tools/packaging/electron-after-pack.cjs index a20bf781b..496f1291e 100644 --- a/tools/packaging/electron-after-pack.cjs +++ b/tools/packaging/electron-after-pack.cjs @@ -17,6 +17,30 @@ function isTruthy(value) { ); } +function copyEmbeddedMpvNativeOutput(resourceDir, projectDir) { + const sourceDir = path.join( + projectDir, + 'dist', + 'apps', + 'electron-backend', + 'native' + ); + + if (!fs.existsSync(sourceDir)) { + return; + } + + const destinationDir = path.join( + resourceDir, + 'app.asar.unpacked', + 'electron-backend', + 'native' + ); + + fs.rmSync(destinationDir, { recursive: true, force: true }); + fs.cpSync(sourceDir, destinationDir, { recursive: true }); +} + async function afterPackHook(params) { await linuxAfterPack(params); @@ -46,6 +70,12 @@ async function afterPackHook(params) { 'Resources' ) : params.appOutDir; + + copyEmbeddedMpvNativeOutput( + resourceDir, + params.packager.projectDir ?? process.cwd() + ); + const errors = validatePackagedEmbeddedMpv(resourceDir, { required: requireEmbeddedMpv, }); From 51347fc2266a5c6a9b73cbe264e3f70d3ae66541 Mon Sep 17 00:00:00 2001 From: 4gray Date: Tue, 28 Apr 2026 19:56:41 +0200 Subject: [PATCH 010/116] feat: add collapsible live epg panel Entire-Checkpoint: 0518c49b948d --- .plans/2026-04-28-live-epg-panel-collapse.md | 60 ++++++++ apps/web/src/assets/i18n/ar.json | 5 +- apps/web/src/assets/i18n/ary.json | 5 +- apps/web/src/assets/i18n/by.json | 5 +- apps/web/src/assets/i18n/de.json | 5 +- apps/web/src/assets/i18n/el.json | 5 +- apps/web/src/assets/i18n/en.json | 5 +- apps/web/src/assets/i18n/es.json | 5 +- apps/web/src/assets/i18n/fr.json | 5 +- apps/web/src/assets/i18n/it.json | 5 +- apps/web/src/assets/i18n/ja.json | 5 +- apps/web/src/assets/i18n/ko.json | 5 +- apps/web/src/assets/i18n/nl.json | 5 +- apps/web/src/assets/i18n/pl.json | 5 +- apps/web/src/assets/i18n/pt.json | 5 +- apps/web/src/assets/i18n/ru.json | 5 +- apps/web/src/assets/i18n/tr.json | 5 +- apps/web/src/assets/i18n/zh.json | 5 +- apps/web/src/assets/i18n/zhtw.json | 5 +- docs/architecture/iptvnator-ui-guidelines.md | 13 ++ docs/architecture/m3u-playlist-module.md | 123 ++++++++++------- docs/architecture/stalker-epg.md | 100 +++++++------- .../video-player/video-player.component.html | 51 +++++-- .../video-player.component.spec.ts | 116 ++++++++++++++-- .../video-player/video-player.component.ts | 43 +++++- libs/portal/shared/util/src/index.ts | 1 + .../util/src/lib/live-epg-panel-state.spec.ts | 50 +++++++ .../util/src/lib/live-epg-panel-state.ts | 25 ++++ .../stalker-live-stream-layout.component.html | 49 +++++-- ...alker-live-stream-layout.component.spec.ts | 98 ++++++++++++- .../stalker-live-stream-layout.component.ts | 81 +++++++---- .../live-stream-layout.component.html | 100 ++++++++------ .../live-stream-layout.component.spec.ts | 95 ++++++++++++- .../live-stream-layout.component.ts | 42 +++++- .../src/lib/settings-store.service.ts | 3 +- libs/ui/shared-portals/src/index.ts | 5 +- .../live-epg-panel.component.html | 76 ++++++++++ .../live-epg-panel.component.scss | 130 ++++++++++++++++++ .../live-epg-panel.component.spec.ts | 110 +++++++++++++++ .../live-epg-panel.component.ts | 102 ++++++++++++++ .../live-stream-layout.component.html | 33 ++++- .../live-stream-layout.component.scss | 21 ++- .../live-stream-layout.component.ts | 78 +++++++++-- libs/ui/shared-portals/tsconfig.spec.json | 35 ++--- libs/ui/styles/_portal-layout.scss | 18 ++- 45 files changed, 1465 insertions(+), 283 deletions(-) create mode 100644 .plans/2026-04-28-live-epg-panel-collapse.md create mode 100644 libs/portal/shared/util/src/lib/live-epg-panel-state.spec.ts create mode 100644 libs/portal/shared/util/src/lib/live-epg-panel-state.ts create mode 100644 libs/ui/shared-portals/src/lib/live-epg-panel/live-epg-panel.component.html create mode 100644 libs/ui/shared-portals/src/lib/live-epg-panel/live-epg-panel.component.scss create mode 100644 libs/ui/shared-portals/src/lib/live-epg-panel/live-epg-panel.component.spec.ts create mode 100644 libs/ui/shared-portals/src/lib/live-epg-panel/live-epg-panel.component.ts diff --git a/.plans/2026-04-28-live-epg-panel-collapse.md b/.plans/2026-04-28-live-epg-panel-collapse.md new file mode 100644 index 000000000..2333eb471 --- /dev/null +++ b/.plans/2026-04-28-live-epg-panel-collapse.md @@ -0,0 +1,60 @@ +# Live EPG Panel Collapse + +## Summary + +Add a shared, persisted live-TV EPG collapse state across M3U, Xtream, and Stalker. Default remains expanded. When an internal player is active, the EPG can collapse to a slim bottom line showing the current program and an expand button; the player grows to use the freed space. External MPV/VLC playback keeps the existing full EPG-only layout. + +## Key Changes + +- Add shared state helpers in `@iptvnator/portal/shared/util`: + - storage key: `live-epg-panel-state` + - states: `expanded | collapsed` + - restore invalid/missing values to `expanded` +- Add a shared `LiveEpgPanelComponent` in `shared-portals`: + - inputs: `collapsed`, `summary`, `loading` + - output: `collapsedChange` + - keeps projected EPG content mounted while collapsed, but visually hides and inerts it so existing EPG effects still update current-program state. +- Update the shared live layout SCSS mixin so `.epg.epg-collapsed` has a fixed slim height and `.video-player` fills the remaining space. +- Wire the wrapper into: + - M3U `VideoPlayerComponent` + - Xtream `LiveStreamLayoutComponent` + - Stalker `StalkerLiveStreamLayoutComponent` + - legacy shared `LiveStreamLayoutComponent`, including ArtPlayer as an internal player. +- Add i18n keys for collapse/expand/current-program labels to all locale JSON files, using English fallback where no translation is available. + +## Behavior Rules + +- Collapse is available only when an internal player is shown: Video.js, HTML5, or ArtPlayer. +- Radio playback still hides EPG entirely. +- External MPV/VLC playback does not render the collapse strip and keeps the full EPG panel. +- The slim line shows current program title, start/end time, and progress when available; otherwise it shows the existing no-program/EPG-unavailable message. +- The shared persisted state applies across all three live-TV modules. + +## Tests + +- Add unit coverage for restore/persist state validation. +- Add shared `LiveEpgPanelComponent` tests for summary rendering, toggle output, hidden-but-mounted body, and fallback text. +- Extend M3U, Xtream, and Stalker component specs for: + - collapsed state from `localStorage` + - toggle persistence + - collapse only for internal players + - external player keeps full EPG + - current-program summary renders from each module's data source. +- Run: + - `pnpm nx test shared-portals` + - `pnpm nx test ui-epg` + - `pnpm nx test portal-xtream-feature` + - `pnpm nx test portal-stalker-feature` + - `pnpm nx lint playlist-m3u-feature-player` + - `pnpm nx build web` +- Verify in the running Electron app through CDP/agent-browser and Computer Use: + - M3U, Xtream, Stalker live pages + - Video.js, HTML5, ArtPlayer sizing + - collapsed state survives reload/navigation + - light and dark themes do not overlap or clip text. + +## Docs And Plan Artifact + +- Update `docs/architecture/iptvnator-ui-guidelines.md` with the collapsible live EPG pattern. +- Update module docs where they describe active live EPG behavior, especially M3U and Stalker EPG docs. +- If `IPTVNATOR_WIKI_VAULT` is configured after docs change, run `pnpm wiki:export --mode changed`. diff --git a/apps/web/src/assets/i18n/ar.json b/apps/web/src/assets/i18n/ar.json index 6f5feb8ac..d789c192b 100644 --- a/apps/web/src/assets/i18n/ar.json +++ b/apps/web/src/assets/i18n/ar.json @@ -269,7 +269,10 @@ "LANGUAGE": "اللغة", "SHOW_PROGRAM_DETAILS": "عرض تفاصيل البرنامج" }, - "FETCH_SUCCESS": "تم جلب الدليل بنجاح." + "FETCH_SUCCESS": "تم جلب الدليل بنجاح.", + "CURRENT_PROGRAM": "Current program", + "COLLAPSE_PANEL": "Collapse EPG panel", + "EXPAND_PANEL": "Expand EPG panel" }, "LANGUAGES": { "ARABIC": "العربية", diff --git a/apps/web/src/assets/i18n/ary.json b/apps/web/src/assets/i18n/ary.json index e81849c85..fac318e97 100644 --- a/apps/web/src/assets/i18n/ary.json +++ b/apps/web/src/assets/i18n/ary.json @@ -261,7 +261,10 @@ "LANGUAGE": "اللغة", "SHOW_PROGRAM_DETAILS": "وري تفاصيل حول هاد البرنامج" }, - "FETCH_SUCCESS": "EPG تجلب بنجاح" + "FETCH_SUCCESS": "EPG تجلب بنجاح", + "CURRENT_PROGRAM": "Current program", + "COLLAPSE_PANEL": "Collapse EPG panel", + "EXPAND_PANEL": "Expand EPG panel" }, "LANGUAGES": { "ARABIC": "العربية", diff --git a/apps/web/src/assets/i18n/by.json b/apps/web/src/assets/i18n/by.json index 36d6fcf1e..99cbdc583 100644 --- a/apps/web/src/assets/i18n/by.json +++ b/apps/web/src/assets/i18n/by.json @@ -257,7 +257,10 @@ "DESCRIPTION": "Апісанне", "LANGUAGE": "Мова", "SHOW_PROGRAM_DETAILS": "Паказаць падрабязнасці" - } + }, + "CURRENT_PROGRAM": "Current program", + "COLLAPSE_PANEL": "Collapse EPG panel", + "EXPAND_PANEL": "Expand EPG panel" }, "NO": "Не", "YES": "Так", diff --git a/apps/web/src/assets/i18n/de.json b/apps/web/src/assets/i18n/de.json index 8c070385e..257ca3c48 100644 --- a/apps/web/src/assets/i18n/de.json +++ b/apps/web/src/assets/i18n/de.json @@ -54,7 +54,10 @@ "DESCRIPTION": "Beschreibung", "LANGUAGE": "Sprache", "SHOW_PROGRAM_DETAILS": "Details anzeigen" - } + }, + "CURRENT_PROGRAM": "Current program", + "COLLAPSE_PANEL": "Collapse EPG panel", + "EXPAND_PANEL": "Expand EPG panel" }, "HOME": { "SUBTITLE": "Wählen Sie eine Playlist aus oder laden Sie eine neue Playlist hoch", diff --git a/apps/web/src/assets/i18n/el.json b/apps/web/src/assets/i18n/el.json index 67baeb50e..4265223c1 100644 --- a/apps/web/src/assets/i18n/el.json +++ b/apps/web/src/assets/i18n/el.json @@ -327,7 +327,10 @@ "TOTAL_CHANNELS": "Σύνολο καναλιών", "TOTAL_PROGRAMS": "Σύνολο προγραμμάτων", "UP_TO_DATE": "Το EPG είναι ενημερωμένο, δεν απαιτείται συγχρονισμός", - "RETRY": "Επανάληψη" + "RETRY": "Επανάληψη", + "CURRENT_PROGRAM": "Current program", + "COLLAPSE_PANEL": "Collapse EPG panel", + "EXPAND_PANEL": "Expand EPG panel" }, "LANGUAGES": { "ARABIC": "العربية", diff --git a/apps/web/src/assets/i18n/en.json b/apps/web/src/assets/i18n/en.json index cd0bb0b05..ac4ec6fd9 100644 --- a/apps/web/src/assets/i18n/en.json +++ b/apps/web/src/assets/i18n/en.json @@ -459,7 +459,10 @@ "TOTAL_CHANNELS": "Total channels", "TOTAL_PROGRAMS": "Total programs", "UP_TO_DATE": "EPG is up-to-date, no sync needed", - "RETRY": "Retry" + "RETRY": "Retry", + "CURRENT_PROGRAM": "Current program", + "COLLAPSE_PANEL": "Collapse EPG panel", + "EXPAND_PANEL": "Expand EPG panel" }, "LANGUAGES": { "ARABIC": "العربية", diff --git a/apps/web/src/assets/i18n/es.json b/apps/web/src/assets/i18n/es.json index 5e9b3d21b..e3ab248c7 100644 --- a/apps/web/src/assets/i18n/es.json +++ b/apps/web/src/assets/i18n/es.json @@ -285,7 +285,10 @@ "DESCRIPTION": "Descripción", "LANGUAGE": "Idioma", "SHOW_PROGRAM_DETAILS": "Mostrar detalles del programa" - } + }, + "CURRENT_PROGRAM": "Current program", + "COLLAPSE_PANEL": "Collapse EPG panel", + "EXPAND_PANEL": "Expand EPG panel" }, "LANGUAGES": { "ARABIC": "العربية", diff --git a/apps/web/src/assets/i18n/fr.json b/apps/web/src/assets/i18n/fr.json index d5197301d..0e397629c 100644 --- a/apps/web/src/assets/i18n/fr.json +++ b/apps/web/src/assets/i18n/fr.json @@ -283,7 +283,10 @@ "LANGUAGE": "Langue", "SHOW_PROGRAM_DETAILS": "Afficher les détails de ce programme" }, - "FETCH_SUCCESS": "L'EPG a été récupéré avec succès" + "FETCH_SUCCESS": "L'EPG a été récupéré avec succès", + "CURRENT_PROGRAM": "Current program", + "COLLAPSE_PANEL": "Collapse EPG panel", + "EXPAND_PANEL": "Expand EPG panel" }, "LANGUAGES": { "ARABIC": "العربية", diff --git a/apps/web/src/assets/i18n/it.json b/apps/web/src/assets/i18n/it.json index 51e9e9bfb..86d6aa8ec 100644 --- a/apps/web/src/assets/i18n/it.json +++ b/apps/web/src/assets/i18n/it.json @@ -303,7 +303,10 @@ "LANGUAGE": "Lingua", "SHOW_PROGRAM_DETAILS": "Visualizza dettagli su questo programma" }, - "FETCH_SUCCESS": "EPG scaricato correttamente" + "FETCH_SUCCESS": "EPG scaricato correttamente", + "CURRENT_PROGRAM": "Current program", + "COLLAPSE_PANEL": "Collapse EPG panel", + "EXPAND_PANEL": "Expand EPG panel" }, "LANGUAGES": { "ARABIC": "العربية", diff --git a/apps/web/src/assets/i18n/ja.json b/apps/web/src/assets/i18n/ja.json index f27658448..ba1ad5d47 100644 --- a/apps/web/src/assets/i18n/ja.json +++ b/apps/web/src/assets/i18n/ja.json @@ -249,7 +249,10 @@ "LANGUAGE": "言語", "SHOW_PROGRAM_DETAILS": "この番組の詳細を表示" }, - "FETCH_SUCCESS": "EPGの取得に成功しました" + "FETCH_SUCCESS": "EPGの取得に成功しました", + "CURRENT_PROGRAM": "Current program", + "COLLAPSE_PANEL": "Collapse EPG panel", + "EXPAND_PANEL": "Expand EPG panel" }, "LANGUAGES": { "ARABIC": "العربية", diff --git a/apps/web/src/assets/i18n/ko.json b/apps/web/src/assets/i18n/ko.json index 12905d9ca..aa93ee9a5 100644 --- a/apps/web/src/assets/i18n/ko.json +++ b/apps/web/src/assets/i18n/ko.json @@ -214,7 +214,10 @@ "DESCRIPTION": "설명", "LANGUAGE": "언어", "SHOW_PROGRAM_DETAILS": "이 프로그램에 대한 세부 정보 표시" - } + }, + "CURRENT_PROGRAM": "Current program", + "COLLAPSE_PANEL": "Collapse EPG panel", + "EXPAND_PANEL": "Expand EPG panel" }, "LANGUAGES": { "ARABIC": "العربية", diff --git a/apps/web/src/assets/i18n/nl.json b/apps/web/src/assets/i18n/nl.json index c76caf139..e905bca05 100644 --- a/apps/web/src/assets/i18n/nl.json +++ b/apps/web/src/assets/i18n/nl.json @@ -261,7 +261,10 @@ "LANGUAGE": "Taal", "SHOW_PROGRAM_DETAILS": "Toon details over dit programma" }, - "FETCH_SUCCESS": "TV-Gids bijgewerkt" + "FETCH_SUCCESS": "TV-Gids bijgewerkt", + "CURRENT_PROGRAM": "Current program", + "COLLAPSE_PANEL": "Collapse EPG panel", + "EXPAND_PANEL": "Expand EPG panel" }, "LANGUAGES": { "ARABIC": "العربية", diff --git a/apps/web/src/assets/i18n/pl.json b/apps/web/src/assets/i18n/pl.json index d82010bf6..aa7e89a6a 100644 --- a/apps/web/src/assets/i18n/pl.json +++ b/apps/web/src/assets/i18n/pl.json @@ -261,7 +261,10 @@ "LANGUAGE": "Język", "SHOW_PROGRAM_DETAILS": "Pokaż szczegóły programu" }, - "FETCH_SUCCESS": "EPG zostało pomyślnie pobrane" + "FETCH_SUCCESS": "EPG zostało pomyślnie pobrane", + "CURRENT_PROGRAM": "Current program", + "COLLAPSE_PANEL": "Collapse EPG panel", + "EXPAND_PANEL": "Expand EPG panel" }, "LANGUAGES": { "ARABIC": "العربية", diff --git a/apps/web/src/assets/i18n/pt.json b/apps/web/src/assets/i18n/pt.json index 1849d94cb..6c43c5654 100644 --- a/apps/web/src/assets/i18n/pt.json +++ b/apps/web/src/assets/i18n/pt.json @@ -280,7 +280,10 @@ "LANGUAGE": "Idioma", "SHOW_PROGRAM_DETAILS": "Mostrar detalhes sobre este programa" }, - "FETCH_SUCCESS": "EPG foi buscado com sucesso" + "FETCH_SUCCESS": "EPG foi buscado com sucesso", + "CURRENT_PROGRAM": "Current program", + "COLLAPSE_PANEL": "Collapse EPG panel", + "EXPAND_PANEL": "Expand EPG panel" }, "LANGUAGES": { "ARABIC": "العربية", diff --git a/apps/web/src/assets/i18n/ru.json b/apps/web/src/assets/i18n/ru.json index 34b3cd82d..57de5dc1d 100644 --- a/apps/web/src/assets/i18n/ru.json +++ b/apps/web/src/assets/i18n/ru.json @@ -360,7 +360,10 @@ "DESCRIPTION": "Описание", "LANGUAGE": "Язык", "SHOW_PROGRAM_DETAILS": "Показать подробности" - } + }, + "CURRENT_PROGRAM": "Current program", + "COLLAPSE_PANEL": "Collapse EPG panel", + "EXPAND_PANEL": "Expand EPG panel" }, "LANGUAGES": { "ARABIC": "العربية", diff --git a/apps/web/src/assets/i18n/tr.json b/apps/web/src/assets/i18n/tr.json index 7226bcebd..243946df7 100644 --- a/apps/web/src/assets/i18n/tr.json +++ b/apps/web/src/assets/i18n/tr.json @@ -335,7 +335,10 @@ "TOTAL_CHANNELS": "Toplam kanal", "TOTAL_PROGRAMS": "Toplam program", "UP_TO_DATE": "EPG güncel, senkronizasyona gerek yok", - "RETRY": "Tekrar Dene" + "RETRY": "Tekrar Dene", + "CURRENT_PROGRAM": "Current program", + "COLLAPSE_PANEL": "Collapse EPG panel", + "EXPAND_PANEL": "Expand EPG panel" }, "LANGUAGES": { "ARABIC": "العربية", diff --git a/apps/web/src/assets/i18n/zh.json b/apps/web/src/assets/i18n/zh.json index 553a6cbe7..4029926cf 100644 --- a/apps/web/src/assets/i18n/zh.json +++ b/apps/web/src/assets/i18n/zh.json @@ -214,7 +214,10 @@ "DESCRIPTION": "描述", "LANGUAGE": "语言", "SHOW_PROGRAM_DETAILS": "显示有关此程序的详细信息" - } + }, + "CURRENT_PROGRAM": "Current program", + "COLLAPSE_PANEL": "Collapse EPG panel", + "EXPAND_PANEL": "Expand EPG panel" }, "LANGUAGES": { "ARABIC": "العربية", diff --git a/apps/web/src/assets/i18n/zhtw.json b/apps/web/src/assets/i18n/zhtw.json index 3fa68346a..0153f36cd 100644 --- a/apps/web/src/assets/i18n/zhtw.json +++ b/apps/web/src/assets/i18n/zhtw.json @@ -260,7 +260,10 @@ "DESCRIPTION": "說明", "LANGUAGE": "語言", "SHOW_PROGRAM_DETAILS": "顯示關於該節目的詳細資訊" - } + }, + "CURRENT_PROGRAM": "Current program", + "COLLAPSE_PANEL": "Collapse EPG panel", + "EXPAND_PANEL": "Expand EPG panel" }, "LANGUAGES": { "ARABIC": "العربية", diff --git a/docs/architecture/iptvnator-ui-guidelines.md b/docs/architecture/iptvnator-ui-guidelines.md index 1ead04c24..1bedd4071 100644 --- a/docs/architecture/iptvnator-ui-guidelines.md +++ b/docs/architecture/iptvnator-ui-guidelines.md @@ -126,6 +126,19 @@ The shared row should be reused instead of rebuilding channel markup per view. - Add bottom padding so the last program is not clipped - Current program card uses the same selection treatment as selected channels +### Collapsible Live EPG + +- Live TV layouts with an internal player use `app-live-epg-panel` around the + EPG content. +- Collapsed state is shared across M3U, Xtream, and Stalker with + `live-epg-panel-state`; missing or invalid values restore to expanded. +- The collapsed panel is a slim current-program strip with a trailing progress + line and an expand button. +- Do not render the collapsed strip for external MPV/VLC playback; those + layouts keep the full EPG-only panel. +- Keep the EPG content mounted while collapsed so current-program state can + continue updating. + ### EPG Card - Radius: diff --git a/docs/architecture/m3u-playlist-module.md b/docs/architecture/m3u-playlist-module.md index aa85a41df..6c66de2aa 100644 --- a/docs/architecture/m3u-playlist-module.md +++ b/docs/architecture/m3u-playlist-module.md @@ -5,6 +5,7 @@ This document describes the M3U playlist module architecture, which handles trad ## Overview The M3U playlist module provides: + - Channel list display with virtual scrolling (90,000+ channels support) - EPG (Electronic Program Guide) integration - Favorites management with drag-and-drop reordering @@ -80,32 +81,32 @@ used by the groups view to remember which group titles the user has hidden. ### Actions -| Action Group | Actions | Purpose | -|--------------|---------|---------| -| **PlaylistActions** | `loadPlaylists`, `addPlaylist`, `removePlaylist`, `parsePlaylist`, `setActivePlaylist` | Playlist CRUD | -| **ChannelActions** | `setChannels`, `setActiveChannel`, `setAdjacentChannelAsActive` | Channel selection & navigation | -| **EpgActions** | `setActiveEpgProgram`, `setCurrentEpgProgram`, `setEpgAvailableFlag` | EPG state | -| **FavoritesActions** | `updateFavorites`, `setFavorites` | Favorites management | -| **FilterActions** | `setSelectedFilters` | Playlist type filtering | +| Action Group | Actions | Purpose | +| -------------------- | -------------------------------------------------------------------------------------- | ------------------------------ | +| **PlaylistActions** | `loadPlaylists`, `addPlaylist`, `removePlaylist`, `parsePlaylist`, `setActivePlaylist` | Playlist CRUD | +| **ChannelActions** | `setChannels`, `setActiveChannel`, `setAdjacentChannelAsActive` | Channel selection & navigation | +| **EpgActions** | `setActiveEpgProgram`, `setCurrentEpgProgram`, `setEpgAvailableFlag` | EPG state | +| **FavoritesActions** | `updateFavorites`, `setFavorites` | Favorites management | +| **FilterActions** | `setSelectedFilters` | Playlist type filtering | ### Key Selectors ```typescript // Channel selectors -selectActive // Current playing channel -selectChannelsLoading // Channel list loading flag -selectChannels // All channels array -selectFavorites // Favorite channel URLs +selectActive; // Current playing channel +selectChannelsLoading; // Channel list loading flag +selectChannels; // All channels array +selectFavorites; // Favorite channel URLs // Playlist selectors -selectAllPlaylistsMeta // All playlists -selectActivePlaylistId // Selected playlist ID -selectCurrentPlaylist // Active playlist object -selectPlaylistTitle // Title with "Global favorites" fallback +selectAllPlaylistsMeta; // All playlists +selectActivePlaylistId; // Selected playlist ID +selectCurrentPlaylist; // Active playlist object +selectPlaylistTitle; // Title with "Global favorites" fallback // EPG selectors -selectIsEpgAvailable // EPG data available flag -selectCurrentEpgProgram // Current playing program +selectIsEpgAvailable; // EPG data available flag +selectCurrentEpgProgram; // Current playing program ``` ## Channel List Container @@ -182,9 +183,9 @@ channel-list-container/ - `ChannelListContainerComponent` no longer clears `channels` on destroy; route/session code is the single owner of shared list lifecycle during navigation. - The dedicated `/workspace/playlists/:id/favorites` and `/workspace/playlists/:id/recent` collection routes do not drive the shared sidebar channel list; they default to the `playlist` scope so rail links always open the current playlist view, not the last persisted global scope. - Empty playlists and empty search results are no longer conflated: - - loading: skeletons - - empty source: no channels in the playlist after loading completes - - empty search: no matches within an already loaded playlist + - loading: skeletons + - empty source: no channels in the playlist after loading completes + - empty search: no matches within an already loaded playlist ### Group Visibility Management @@ -213,8 +214,8 @@ For performance optimization, channels are pre-enriched with EPG data: ```typescript interface EnrichedChannel extends Channel { epgProgram: EpgProgram | null | undefined; - logo: string; // Playlist tvg-logo first, XMLTV icon fallback second - progressPercentage: number; // Pre-computed by parent + logo: string; // Playlist tvg-logo first, XMLTV icon fallback second + progressPercentage: number; // Pre-computed by parent } ``` @@ -237,29 +238,32 @@ EPG lookup keys use the same precedence in both program and icon paths: ### Performance Optimizations -| Optimization | Implementation | -|--------------|----------------| -| **Virtual Scroll** | CDK virtual scroll for 90,000+ channels | -| **Computed Signals** | `enrichedChannels` computed signal replaces template pipe | -| **Debounced Search** | 300ms debounce on search input | -| **Global Progress Tick** | Single 30s interval instead of per-item intervals | -| **OnPush Change Detection** | All components use OnPush | -| **Infinite Scroll in Groups** | IntersectionObserver loads 50 channels at a time | -| **Memoized Group Enrichment** | `enrichedGroupChannelsMap` computed signal | +| Optimization | Implementation | +| ----------------------------- | --------------------------------------------------------- | +| **Virtual Scroll** | CDK virtual scroll for 90,000+ channels | +| **Computed Signals** | `enrichedChannels` computed signal replaces template pipe | +| **Debounced Search** | 300ms debounce on search input | +| **Global Progress Tick** | Single 30s interval instead of per-item intervals | +| **OnPush Change Detection** | All components use OnPush | +| **Infinite Scroll in Groups** | IntersectionObserver loads 50 channels at a time | +| **Memoized Group Enrichment** | `enrichedGroupChannelsMap` computed signal | ### Tab Components #### AllChannelsTabComponent + - **Inputs**: `channels`, `channelEpgMap`, `channelIconMap`, `progressTick`, `shouldShowEpg`, `itemSize`, `activeChannelUrl`, `favoriteIds` - **Outputs**: `channelSelected`, `favoriteToggled` - **Features**: Search with 300ms debounce, virtual scrolling, no-results placeholder #### GroupsTabComponent + - **Inputs**: Same as AllChannelsTab + `groupedChannels` - **Outputs**: `channelSelected`, `favoriteToggled` - **Features**: Expansion panels, infinite scroll with IntersectionObserver, lazy loading #### FavoritesTabComponent + - **Inputs**: `favorites`, `channelEpgMap`, `channelIconMap`, `progressTick`, `shouldShowEpg`, `activeChannelUrl` - **Outputs**: `channelSelected`, `favoriteToggled`, `favoritesReordered` - **Features**: Drag-and-drop reordering with CDK DragDrop @@ -277,10 +281,14 @@ class EpgService { getChannelPrograms(channelId: string): void; // Batch fetch current programs - getCurrentProgramsForChannels(channelIds: string[]): Observable>; + getCurrentProgramsForChannels( + channelIds: string[] + ): Observable>; // Batch fetch XMLTV channel metadata for logo fallback - getChannelMetadataForChannels(channelIds: string[]): Observable>; + getChannelMetadataForChannels( + channelIds: string[] + ): Observable>; // Observables epgAvailable$: Observable; @@ -290,27 +298,31 @@ class EpgService { ### EPG Components -| Component | Purpose | -|-----------|---------| -| `EpgListComponent` | Timeline view for single channel | -| `EpgListItemComponent` | Individual program in timeline | -| `EpgItemDescriptionComponent` | Program details dialog | -| `MultiEpgContainerComponent` | Grid view of all channels' schedules | +| Component | Purpose | +| ----------------------------- | ------------------------------------ | +| `EpgListComponent` | Timeline view for single channel | +| `EpgListItemComponent` | Individual program in timeline | +| `EpgItemDescriptionComponent` | Program details dialog | +| `MultiEpgContainerComponent` | Grid view of all channels' schedules | ## Video Player **Location**: `libs/playlist/m3u/feature-player/src/lib/video-player/` ### Supported Players + - **ArtPlayer** (default) - Modern player with plugins - **Video.js** - Fallback with HLS support - **HTML5** - Basic video element - **Audio** - For radio streams ### Player Features + - Channel navigation (prev/next) - Favorites toggle - EPG sidebar +- Collapsible inline EPG panel for internal players, persisted through the + shared `live-epg-panel-state` preference - Multi-EPG modal view - Channel info overlay - External player support (MPV, VLC) in Electron @@ -324,16 +336,16 @@ class EpgService { - M3U catch-up support is resolved in `m3u-utils` from channel metadata and the archived program start time. - Supported replay precedence: - 1. `catchup.source` if it is an HTTP(S) URL. IPTVNator rewrites or appends - standard `utc` and `lutc` query params on that URL. - 2. Legacy same-stream shift playback when `catchup.type === 'shift'`. In - that case IPTVNator rewrites or appends `utc` and `lutc` on `channel.url`. - 3. Legacy same-stream shift fallback when no explicit catch-up mode is - declared, archive-day metadata exists (`tvg.rec`, `timeshift`, or - `catchup.days`), and `channel.url` itself is an HTTP(S) stream URL. This - covers providers that only advertise archive retention such as - `tvg-rec="7"` but still expect standard `utc` and `lutc` query params on - the live URL. + 1. `catchup.source` if it is an HTTP(S) URL. IPTVNator rewrites or appends + standard `utc` and `lutc` query params on that URL. + 2. Legacy same-stream shift playback when `catchup.type === 'shift'`. In + that case IPTVNator rewrites or appends `utc` and `lutc` on `channel.url`. + 3. Legacy same-stream shift fallback when no explicit catch-up mode is + declared, archive-day metadata exists (`tvg.rec`, `timeshift`, or + `catchup.days`), and `channel.url` itself is an HTTP(S) stream URL. This + covers providers that only advertise archive retention such as + `tvg-rec="7"` but still expect standard `utc` and `lutc` query params on + the live URL. - `tvg.rec`, `timeshift`, and `catchup.days` still define the archive window shown in the EPG, but replay remains unavailable when the provider declares a different explicit catch-up scheme that IPTVNator does not understand or when @@ -346,6 +358,7 @@ class EpgService { ## Interfaces ### Channel Interface + ```typescript interface Channel { id: string; @@ -353,7 +366,7 @@ interface Channel { name: string; group: { title: string }; tvg: { - id: string; // For EPG matching + id: string; // For EPG matching name: string; url: string; logo: string; @@ -384,11 +397,12 @@ interface PlaylistState { ``` ### EpgProgram Interface + ```typescript interface EpgProgram { - start: string; // ISO string - stop: string; // ISO string - channel: string; // TVG ID + start: string; // ISO string + stop: string; // ISO string + channel: string; // TVG ID title: string; desc: string | null; category: string | null; @@ -408,17 +422,20 @@ interface EpgProgram { ## Adding New Features ### To add a new tab to channel list: + 1. Create component in `channel-list-container/new-tab/` 2. Accept inputs: `channels`, `channelEpgMap`, `progressTick`, `shouldShowEpg`, `activeChannelUrl` 3. Emit `channelSelected` output 4. Add to parent template and imports ### To add EPG-related features: + 1. Use `EpgService` for data fetching 2. Subscribe to `channelEpgMap` signal for current programs 3. Dispatch `EpgActions` for state updates ### To modify favorites behavior: + 1. Dispatch `FavoritesActions.updateFavorites` for toggle 2. Dispatch `FavoritesActions.setFavorites` for reordering 3. Effects automatically persist to database diff --git a/docs/architecture/stalker-epg.md b/docs/architecture/stalker-epg.md index 26a6ab775..a1b7e8914 100644 --- a/docs/architecture/stalker-epg.md +++ b/docs/architecture/stalker-epg.md @@ -78,21 +78,21 @@ GET load.php?type=itv&action=get_short_epg&ch_id={channel_id}&size={n}&JsHttpReq ```json { - "js": { - "data": [ - { - "id": "123", - "ch_id": "45", - "name": "Program Title", - "descr": "Program description", - "time": "2025-01-15 14:00:00", - "time_to": "2025-01-15 14:30:00", - "duration": "1800", - "start_timestamp": "1736949600", - "stop_timestamp": "1736951400" - } - ] - } + "js": { + "data": [ + { + "id": "123", + "ch_id": "45", + "name": "Program Title", + "descr": "Program description", + "time": "2025-01-15 14:00:00", + "time_to": "2025-01-15 14:30:00", + "duration": "1800", + "start_timestamp": "1736949600", + "stop_timestamp": "1736951400" + } + ] + } } ``` @@ -120,21 +120,21 @@ GET load.php?type=itv&action=get_epg_info&period={hours}&JsHttpRequest=1-xml ```json { - "js": { - "data": { - "45": [ - { - "id": "1", - "name": "Program Title", - "descr": "Program description", - "time": "2025-01-15 14:00:00", - "time_to": "2025-01-15 16:00:00", - "start_timestamp": "1736949600", - "stop_timestamp": "1736956800" + "js": { + "data": { + "45": [ + { + "id": "1", + "name": "Program Title", + "descr": "Program description", + "time": "2025-01-15 14:00:00", + "time_to": "2025-01-15 16:00:00", + "start_timestamp": "1736949600", + "stop_timestamp": "1736956800" + } + ] } - ] } - } } ``` @@ -153,16 +153,16 @@ The short EPG path now exists only for the active-panel fallback flow. Key mapped fields: -| Stalker field | `EpgItem` field | -| --- | --- | -| `id` | `id` | -| `ch_id` | `channel_id` | -| `name` | `title` | -| `descr` | `description` | -| `time` | `start` | -| `time_to` | `end`, `stop` | +| Stalker field | `EpgItem` field | +| ----------------- | ----------------- | +| `id` | `id` | +| `ch_id` | `channel_id` | +| `name` | `title` | +| `descr` | `description` | +| `time` | `start` | +| `time_to` | `end`, `stop` | | `start_timestamp` | `start_timestamp` | -| `stop_timestamp` | `stop_timestamp` | +| `stop_timestamp` | `stop_timestamp` | ### Active panel data (`get_epg_info` / fallback) → `EpgProgram` @@ -180,13 +180,13 @@ Normalization rules: ### Key files -| File | Purpose | -| --- | --- | -| `libs/portal/stalker/data-access/src/lib/stores/features/with-stalker-epg.feature.ts` | bulk cache and fallback handling | -| `libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-stream-layout.component.ts` | active-channel EPG loading and controlled `app-epg-list` wiring | -| `libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-stream-layout.component.html` | active panel template | -| `libs/portal/stalker/feature/src/lib/stalker-collection-channels-list/stalker-collection-channels-list.component.ts` | row preview loading | -| `libs/ui/shared-portals/src/lib/epg-list/epg-list.component.ts` | shared controlled EPG list with date navigator | +| File | Purpose | +| -------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------- | +| `libs/portal/stalker/data-access/src/lib/stores/features/with-stalker-epg.feature.ts` | bulk cache and fallback handling | +| `libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-stream-layout.component.ts` | active-channel EPG loading and controlled `app-epg-list` wiring | +| `libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-stream-layout.component.html` | active panel template | +| `libs/portal/stalker/feature/src/lib/stalker-collection-channels-list/stalker-collection-channels-list.component.ts` | row preview loading | +| `libs/ui/shared-portals/src/lib/epg-list/epg-list.component.ts` | shared controlled EPG list with date navigator | ### Store API @@ -215,6 +215,10 @@ playlists. `get_short_epg` The active panel no longer uses local EPG pagination or a "Load more" button. +When Stalker live TV is playing through an internal player, the active panel is +wrapped in the shared collapsible live EPG panel. The collapsed/expanded state +uses the shared `live-epg-panel-state` preference and is only applied after a +stream URL has been resolved; external playback keeps the full EPG-only panel. ### Channel row preview flow @@ -238,10 +242,10 @@ derived locally from `bulkItvEpgByChannel`: EPG requests follow the standard Stalker request path: -| Portal type | Auth path | -| --- | --- | -| Full Stalker portal | `StalkerSessionService.makeAuthenticatedRequest()` | -| Simple Stalker portal | generic IPC request path via Electron | +| Portal type | Auth path | +| --------------------- | -------------------------------------------------- | +| Full Stalker portal | `StalkerSessionService.makeAuthenticatedRequest()` | +| Simple Stalker portal | generic IPC request path via Electron | No EPG-specific backend transport was needed; the Electron Stalker request handler forwards portal params directly. diff --git a/libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.html b/libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.html index e2a5c0271..e330aa962 100644 --- a/libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.html +++ b/libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.html @@ -17,7 +17,9 @@ [activeView]="activeView()" [sidebarWidth]="sidebarWidth()" (sidebarWidthRequested)="onGroupedSidebarWidthRequested($event)" - (sidebarWidthRequestEnded)="onGroupedSidebarWidthRequestEnded($event)" + (sidebarWidthRequestEnded)=" + onGroupedSidebarWidthRequestEnded($event) + " /> } @loading { @@ -29,13 +31,18 @@
@if (activeChannel(); as activeChannel) { @let playbackTarget = playbackChannel(); - @if (activeChannel.radio === 'true' || shouldShowInlinePlayer(activeChannel)) { + @if ( + activeChannel.radio === 'true' || + shouldShowInlinePlayer(activeChannel) + ) {
@if (activeChannel.radio === 'true') { } @else { @if (playerSettings.player === 'videojs') { @@ -73,12 +80,38 @@ } @if (activeChannel.radio !== 'true') { -
-
- -
+ @let canCollapseEpgPanel = shouldShowInlinePlayer(activeChannel); +
+ @if (canCollapseEpgPanel) { + +
+ +
+
+ } @else { +
+ +
+ }
} diff --git a/libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.spec.ts b/libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.spec.ts index a7c1062de..6787ce449 100644 --- a/libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.spec.ts +++ b/libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.spec.ts @@ -1,5 +1,11 @@ import { AsyncPipe } from '@angular/common'; -import { NO_ERRORS_SCHEMA, signal } from '@angular/core'; +import { + Component, + NO_ERRORS_SCHEMA, + input, + output, + signal, +} from '@angular/core'; import { ComponentFixture, TestBed } from '@angular/core/testing'; import { ActivatedRoute, Router } from '@angular/router'; import { Store } from '@ngrx/store'; @@ -18,17 +24,30 @@ import { import { PlaylistContextFacade } from '@iptvnator/playlist/shared/util'; import { PORTAL_EXTERNAL_PLAYBACK, + LIVE_EPG_PANEL_STATE_STORAGE_KEY, WorkspaceHeaderContextService, } from '@iptvnator/portal/shared/util'; import { DataService, PlaylistsService, SettingsStore } from 'services'; -import { - Channel, - Settings, - VideoPlayer, -} from 'shared-interfaces'; +import { Channel, EpgProgram, Settings, VideoPlayer } from 'shared-interfaces'; +import { LiveEpgPanelSummary } from 'shared-portals'; import { Overlay } from '@angular/cdk/overlay'; import { VideoPlayerComponent } from './video-player.component'; +@Component({ + selector: 'app-live-epg-panel', + standalone: true, + template: ` +
{{ summary()?.title }}
+ + `, +}) +class StubLiveEpgPanelComponent { + readonly collapsed = input(false); + readonly summary = input(null); + readonly loading = input(false); + readonly collapsedChange = output(); +} + describe('VideoPlayerComponent', () => { let fixture: ComponentFixture; let component: VideoPlayerComponent; @@ -39,11 +58,11 @@ describe('VideoPlayerComponent', () => { const activePlaybackUrl = signal(null); const channels = signal([]); const channelsLoading = signal(false); - const currentEpgProgram = signal(null); + const currentEpgProgram = signal(null); const channels$ = new BehaviorSubject([]); const activeChannel$ = new BehaviorSubject(null); - const currentEpgProgram$ = new BehaviorSubject(null); + const currentEpgProgram$ = new BehaviorSubject(null); const player = signal(VideoPlayer.VideoJs); const showCaptions = signal(false); @@ -149,6 +168,7 @@ describe('VideoPlayerComponent', () => { beforeEach(async () => { syncStoreState(null); localStorage.removeItem('m3u-sidebar-width'); + localStorage.removeItem(LIVE_EPG_PANEL_STATE_STORAGE_KEY); player.set(VideoPlayer.VideoJs); showCaptions.set(false); activePlaybackUrl.set(null); @@ -233,6 +253,7 @@ describe('VideoPlayerComponent', () => { set: { imports: [ AsyncPipe, + StubLiveEpgPanelComponent, MockPipe( TranslatePipe, (value: string | null | undefined) => value ?? '' @@ -249,6 +270,7 @@ describe('VideoPlayerComponent', () => { afterEach(() => { fixture.destroy(); + localStorage.removeItem(LIVE_EPG_PANEL_STATE_STORAGE_KEY); }); it('registers and clears the workspace multi-EPG header shortcut', () => { @@ -288,20 +310,73 @@ describe('VideoPlayerComponent', () => { fixture.detectChanges(); - expect( - fixture.nativeElement.querySelector('.video-player') - ).toBeNull(); + expect(fixture.nativeElement.querySelector('.video-player')).toBeNull(); expect( fixture.nativeElement.querySelector('app-epg-list') ).not.toBeNull(); + expect( + fixture.nativeElement.querySelector('app-live-epg-panel') + ).toBeNull(); + expect( + fixture.nativeElement + .querySelector('.epg') + ?.classList.contains('epg-collapsed') + ).toBe(false); + }); + + it('restores the collapsed live EPG panel state for inline playback', () => { + fixture.destroy(); + localStorage.setItem(LIVE_EPG_PANEL_STATE_STORAGE_KEY, 'collapsed'); + + fixture = TestBed.createComponent(VideoPlayerComponent); + component = fixture.componentInstance; + headerContext = TestBed.inject(WorkspaceHeaderContextService); + syncStoreState(sampleChannel); + player.set(VideoPlayer.VideoJs); + + fixture.detectChanges(); + + expect(component.isLiveEpgPanelCollapsed()).toBe(true); + expect( + fixture.nativeElement + .querySelector('.epg') + .classList.contains('epg-collapsed') + ).toBe(true); + }); + + it('persists live EPG panel toggle changes', () => { + component.onLiveEpgPanelCollapsedChange(true); + + expect(component.isLiveEpgPanelCollapsed()).toBe(true); + expect(localStorage.getItem(LIVE_EPG_PANEL_STATE_STORAGE_KEY)).toBe( + 'collapsed' + ); + + component.onLiveEpgPanelCollapsedChange(false); + + expect(localStorage.getItem(LIVE_EPG_PANEL_STATE_STORAGE_KEY)).toBe( + 'expanded' + ); + }); + + it('renders the current EPG program summary for the inline panel', () => { + syncStoreState(sampleChannel); + player.set(VideoPlayer.VideoJs); + currentEpgProgram.set(buildProgram('Current Show')); + currentEpgProgram$.next(buildProgram('Current Show')); + + fixture.detectChanges(); + + expect( + fixture.nativeElement.querySelector('.live-epg-panel-summary') + .textContent + ).toContain('Current Show'); }); it('uses the active playback override url when archive playback is active', () => { syncStoreState(sampleChannel); player.set(VideoPlayer.VideoJs); - activePlaybackUrl.set( - 'http://localhost/archive.m3u8?utc=123&lutc=456' - ); + activePlaybackUrl.set('http://localhost/archive.m3u8?utc=123&lutc=456'); fixture.detectChanges(); @@ -411,3 +486,16 @@ describe('VideoPlayerComponent', () => { ); }); }); + +function buildProgram(title: string): EpgProgram { + return { + start: '2026-04-05T11:30:00.000Z', + stop: '2026-04-05T12:30:00.000Z', + channel: 'sample-tvg-id', + title, + desc: null, + category: null, + startTimestamp: 1775388600, + stopTimestamp: 1775392200, + }; +} diff --git a/libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.ts b/libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.ts index 96501d4da..0b154baf0 100644 --- a/libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.ts +++ b/libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.ts @@ -39,19 +39,19 @@ import { Observable, Subscription, combineLatest, - combineLatestWith, - distinctUntilChanged, filter, map, startWith, - switchMap, take, } from 'rxjs'; import { getAdjacentChannelItem, getChannelItemByNumber, isWorkspaceLayoutRoute, + persistLiveEpgPanelState, PORTAL_EXTERNAL_PLAYBACK, + restoreLiveEpgPanelState, + LiveEpgPanelState, WorkspaceHeaderContextService, } from '@iptvnator/portal/shared/util'; import { PortalEmptyStateComponent } from '@iptvnator/portal/shared/ui'; @@ -62,6 +62,7 @@ import { SidebarComponent, VjsPlayerComponent, } from '@iptvnator/ui/playback'; +import { LiveEpgPanelComponent, LiveEpgPanelSummary } from 'shared-portals'; import { ChannelListLoadingStateComponent } from 'components'; import { DataService, PlaylistsService, SettingsStore } from 'services'; import { @@ -93,6 +94,7 @@ const M3U_SIDEBAR_DEFAULT_WIDTH = 460; CommonModule, EpgListComponent, HtmlVideoPlayerComponent, + LiveEpgPanelComponent, PortalEmptyStateComponent, ResizableDirective, SidebarComponent, @@ -153,6 +155,12 @@ export class VideoPlayerComponent implements OnInit, OnDestroy { readonly sidebarWidth = signal(M3U_SIDEBAR_DEFAULT_WIDTH); readonly sidebarMinWidth = M3U_SIDEBAR_MIN_WIDTH; readonly sidebarMaxWidth = M3U_SIDEBAR_MAX_WIDTH; + readonly liveEpgPanelState = signal( + restoreLiveEpgPanelState() + ); + readonly isLiveEpgPanelCollapsed = computed( + () => this.liveEpgPanelState() === 'collapsed' + ); /** Channels list */ readonly channels$: Observable = this.store.select( @@ -161,6 +169,9 @@ export class VideoPlayerComponent implements OnInit, OnDestroy { /** Current epg program */ readonly epgProgram = this.store.selectSignal(selectCurrentEpgProgram); + readonly liveEpgPanelSummary = computed(() => + this.toLiveEpgPanelSummary(this.epgProgram()) + ); /** Active M3U view (all, groups, favorites, recent) */ readonly activeView = toSignal( @@ -427,6 +438,12 @@ export class VideoPlayerComponent implements OnInit, OnDestroy { this.persistSidebarWidth(this.sidebarStorageKey(), width); } + onLiveEpgPanelCollapsedChange(collapsed: boolean): void { + const state: LiveEpgPanelState = collapsed ? 'collapsed' : 'expanded'; + this.liveEpgPanelState.set(state); + persistLiveEpgPanelState(state); + } + /** * Opens a playlist provided as a url param * e.g. iptvnat.or?url=http://... @@ -472,9 +489,7 @@ export class VideoPlayerComponent implements OnInit, OnDestroy { ); return this.clampSidebarWidth( - Number.isNaN(storedWidth) - ? M3U_SIDEBAR_DEFAULT_WIDTH - : storedWidth + Number.isNaN(storedWidth) ? M3U_SIDEBAR_DEFAULT_WIDTH : storedWidth ); } @@ -522,7 +537,7 @@ export class VideoPlayerComponent implements OnInit, OnDestroy { _id: playlistId, recentlyViewed: updatedPlaylist?.recentlyViewed ?? [], } as PlaylistMeta, - }) as any + }) ); } @@ -726,6 +741,20 @@ export class VideoPlayerComponent implements OnInit, OnDestroy { return !this.isExternalPlayer(this.playerSettings.player); } + private toLiveEpgPanelSummary( + program: EpgProgram | null | undefined + ): LiveEpgPanelSummary | null { + if (!program) { + return null; + } + + return { + title: program.title, + start: program.start, + stop: program.stop, + }; + } + private getExternalSessionStateKey( session: ExternalPlayerSession | null | undefined ): string | null { diff --git a/libs/portal/shared/util/src/index.ts b/libs/portal/shared/util/src/index.ts index 00a61df5a..2ada08585 100644 --- a/libs/portal/shared/util/src/index.ts +++ b/libs/portal/shared/util/src/index.ts @@ -10,6 +10,7 @@ export * from './lib/portal-navigation-actions'; export * from './lib/portal-collection-context'; export * from './lib/portal-collection-context.service'; export * from './lib/portal-collection-items'; +export * from './lib/live-epg-panel-state'; export * from './lib/remote-channel-navigation'; export * from './lib/workspace-header-context.service'; export * from './lib/workspace-view-command.types'; diff --git a/libs/portal/shared/util/src/lib/live-epg-panel-state.spec.ts b/libs/portal/shared/util/src/lib/live-epg-panel-state.spec.ts new file mode 100644 index 000000000..590b5d41b --- /dev/null +++ b/libs/portal/shared/util/src/lib/live-epg-panel-state.spec.ts @@ -0,0 +1,50 @@ +import { + DEFAULT_LIVE_EPG_PANEL_STATE, + LIVE_EPG_PANEL_STATE_STORAGE_KEY, + isLiveEpgPanelState, + persistLiveEpgPanelState, + restoreLiveEpgPanelState, +} from './live-epg-panel-state'; + +describe('live EPG panel state', () => { + afterEach(() => { + localStorage.removeItem(LIVE_EPG_PANEL_STATE_STORAGE_KEY); + localStorage.removeItem('custom-live-epg-panel-state'); + }); + + it('accepts only known panel states', () => { + expect(isLiveEpgPanelState('expanded')).toBe(true); + expect(isLiveEpgPanelState('collapsed')).toBe(true); + expect(isLiveEpgPanelState('hidden')).toBe(false); + expect(isLiveEpgPanelState(null)).toBe(false); + }); + + it('restores expanded as the default for missing or invalid storage', () => { + expect(restoreLiveEpgPanelState()).toBe(DEFAULT_LIVE_EPG_PANEL_STATE); + + localStorage.setItem(LIVE_EPG_PANEL_STATE_STORAGE_KEY, 'hidden'); + + expect(restoreLiveEpgPanelState()).toBe(DEFAULT_LIVE_EPG_PANEL_STATE); + }); + + it('restores and persists collapsed state', () => { + persistLiveEpgPanelState('collapsed'); + + expect(localStorage.getItem(LIVE_EPG_PANEL_STATE_STORAGE_KEY)).toBe( + 'collapsed' + ); + expect(restoreLiveEpgPanelState()).toBe('collapsed'); + }); + + it('supports custom storage keys and fallback values', () => { + expect( + restoreLiveEpgPanelState('custom-live-epg-panel-state', 'collapsed') + ).toBe('collapsed'); + + persistLiveEpgPanelState('expanded', 'custom-live-epg-panel-state'); + + expect(restoreLiveEpgPanelState('custom-live-epg-panel-state')).toBe( + 'expanded' + ); + }); +}); diff --git a/libs/portal/shared/util/src/lib/live-epg-panel-state.ts b/libs/portal/shared/util/src/lib/live-epg-panel-state.ts new file mode 100644 index 000000000..325f91544 --- /dev/null +++ b/libs/portal/shared/util/src/lib/live-epg-panel-state.ts @@ -0,0 +1,25 @@ +export type LiveEpgPanelState = 'expanded' | 'collapsed'; + +export const LIVE_EPG_PANEL_STATE_STORAGE_KEY = 'live-epg-panel-state'; +export const DEFAULT_LIVE_EPG_PANEL_STATE: LiveEpgPanelState = 'expanded'; + +export function isLiveEpgPanelState( + value: unknown +): value is LiveEpgPanelState { + return value === 'expanded' || value === 'collapsed'; +} + +export function restoreLiveEpgPanelState( + storageKey: string = LIVE_EPG_PANEL_STATE_STORAGE_KEY, + fallback: LiveEpgPanelState = DEFAULT_LIVE_EPG_PANEL_STATE +): LiveEpgPanelState { + const storedValue = localStorage.getItem(storageKey); + return isLiveEpgPanelState(storedValue) ? storedValue : fallback; +} + +export function persistLiveEpgPanelState( + state: LiveEpgPanelState, + storageKey: string = LIVE_EPG_PANEL_STATE_STORAGE_KEY +): void { + localStorage.setItem(storageKey, state); +} diff --git a/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-stream-layout.component.html b/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-stream-layout.component.html index 6073d211e..a2cb22c2e 100644 --- a/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-stream-layout.component.html +++ b/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-stream-layout.component.html @@ -82,20 +82,25 @@
} -
-
- @if (isLoadingEpg()) { -
- -
- } @else { - - } -
+ @let canCollapseEpgPanel = !!streamUrl() && isEmbeddedPlayer; +
+ @if (canCollapseEpgPanel) { + + + + } @else { + + }
} @else { }
+ + +
+ @if (isLoadingEpg()) { +
+ +
+ } @else { + + } +
+
diff --git a/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-stream-layout.component.spec.ts b/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-stream-layout.component.spec.ts index 643411369..e2aa8bbe7 100644 --- a/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-stream-layout.component.spec.ts +++ b/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-stream-layout.component.spec.ts @@ -4,6 +4,7 @@ import { MatSnackBar } from '@angular/material/snack-bar'; import { NoopAnimationsModule } from '@angular/platform-browser/animations'; import { PortalEmptyStateComponent } from '@iptvnator/portal/shared/ui'; import { + LIVE_EPG_PANEL_STATE_STORAGE_KEY, PORTAL_PLAYER, ResizableDirective, } from '@iptvnator/portal/shared/util'; @@ -15,7 +16,11 @@ import { MockPipe } from 'ng-mocks'; import { of } from 'rxjs'; import { PlaylistsService } from 'services'; import { EpgProgram } from 'shared-interfaces'; -import { WebPlayerViewComponent } from 'shared-portals'; +import { + LiveEpgPanelComponent, + LiveEpgPanelSummary, + WebPlayerViewComponent, +} from 'shared-portals'; import { StalkerLiveStreamLayoutComponent } from './stalker-live-stream-layout.component'; @Component({ @@ -57,6 +62,21 @@ class StubEpgListComponent { readonly archivePlaybackAvailable = input(null); } +@Component({ + selector: 'app-live-epg-panel', + standalone: true, + template: ` +
{{ summary()?.title }}
+ + `, +}) +class StubLiveEpgPanelComponent { + readonly collapsed = input(false); + readonly summary = input(null); + readonly loading = input(false); + readonly collapsedChange = output(); +} + @Component({ selector: 'app-portal-empty-state', standalone: true, @@ -184,11 +204,15 @@ describe('StalkerLiveStreamLayoutComponent', () => { isLoadingBulkItvEpg.set(false); hasMoreChannels.set(false); page.set(0); + localStorage.removeItem(LIVE_EPG_PANEL_STATE_STORAGE_KEY); resolveItvPlayback.mockReset(); resolveItvPlayback.mockResolvedValue({ streamUrl: 'https://example.com/alpha.m3u8', }); + portalPlayer.isEmbeddedPlayer.mockReset(); + portalPlayer.isEmbeddedPlayer.mockReturnValue(true); + portalPlayer.openResolvedPlayback.mockClear(); fetchChannelEpg.mockReset(); fetchChannelEpg.mockResolvedValue([]); ensureBulkItvEpg.mockReset(); @@ -242,6 +266,7 @@ describe('StalkerLiveStreamLayoutComponent', () => { imports: [ ChannelListItemComponent, EpgListComponent, + LiveEpgPanelComponent, PortalEmptyStateComponent, ResizableDirective, TranslatePipe, @@ -252,6 +277,7 @@ describe('StalkerLiveStreamLayoutComponent', () => { imports: [ StubChannelListItemComponent, StubEpgListComponent, + StubLiveEpgPanelComponent, StubPortalEmptyStateComponent, StubResizableDirective, MockPipe( @@ -270,6 +296,7 @@ describe('StalkerLiveStreamLayoutComponent', () => { afterEach(() => { fixture?.destroy(); + localStorage.removeItem(LIVE_EPG_PANEL_STATE_STORAGE_KEY); }); it('renders the controlled epg list and removes the load-more button', () => { @@ -283,10 +310,75 @@ describe('StalkerLiveStreamLayoutComponent', () => { ).toBeNull(); }); - it('does not reset live channels when loading the next lazy page', async () => { - hasMoreChannels.set(true); + it('restores the collapsed live EPG panel state after embedded playback starts', async () => { + fixture.destroy(); + localStorage.setItem(LIVE_EPG_PANEL_STATE_STORAGE_KEY, 'collapsed'); + + fixture = TestBed.createComponent(StalkerLiveStreamLayoutComponent); + component = fixture.componentInstance; + await component.playChannel(itvChannels()[0]); + await fixture.whenStable(); + fixture.detectChanges(); + + expect(component.isLiveEpgPanelCollapsed()).toBe(true); + expect( + fixture.nativeElement + .querySelector('.epg') + .classList.contains('epg-collapsed') + ).toBe(true); + }); + + it('persists live EPG panel toggle changes', () => { + component.onLiveEpgPanelCollapsedChange(true); + + expect(component.isLiveEpgPanelCollapsed()).toBe(true); + expect(localStorage.getItem(LIVE_EPG_PANEL_STATE_STORAGE_KEY)).toBe( + 'collapsed' + ); + + component.onLiveEpgPanelCollapsedChange(false); + + expect(localStorage.getItem(LIVE_EPG_PANEL_STATE_STORAGE_KEY)).toBe( + 'expanded' + ); + }); + + it('does not render the collapsible panel for external playback', async () => { + portalPlayer.isEmbeddedPlayer.mockReturnValue(false); + + await component.playChannel(itvChannels()[0]); + await fixture.whenStable(); + fixture.detectChanges(); + + expect( + fixture.nativeElement.querySelector('app-live-epg-panel') + ).toBeNull(); + expect( + fixture.nativeElement + .querySelector('.epg') + .classList.contains('epg-collapsed') + ).toBe(false); + }); + + it('renders the current EPG program in the collapsible panel summary', async () => { fixture.detectChanges(); await fixture.whenStable(); + + await component.playChannel(itvChannels()[0]); + await fixture.whenStable(); + fixture.detectChanges(); + + expect( + fixture.nativeElement.querySelector('.live-epg-panel-summary') + .textContent + ).toContain('Current Show'); + }); + + it('does not reset live channels when loading the next lazy page', async () => { + fixture.detectChanges(); + await fixture.whenStable(); + page.set(0); + hasMoreChannels.set(true); stalkerStore.setItvChannels.mockClear(); stalkerStore.setPage.mockClear(); diff --git a/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-stream-layout.component.ts b/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-stream-layout.component.ts index 715122ed3..6e509bb7c 100644 --- a/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-stream-layout.component.ts +++ b/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-stream-layout.component.ts @@ -1,3 +1,4 @@ +import { NgTemplateOutlet } from '@angular/common'; import { ChangeDetectionStrategy, ChangeDetectorRef, @@ -15,10 +16,7 @@ import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { MatProgressSpinnerModule } from '@angular/material/progress-spinner'; import { MatSnackBar } from '@angular/material/snack-bar'; import { TranslatePipe, TranslateService } from '@ngx-translate/core'; -import { - ChannelListItemComponent, - ResizableDirective, -} from 'components'; +import { ChannelListItemComponent, ResizableDirective } from 'components'; import { PlaylistsService } from 'services'; import { Channel, @@ -27,12 +25,19 @@ import { ResolvedPortalPlayback, } from 'shared-interfaces'; import { EpgListComponent } from '@iptvnator/ui/epg'; -import { WebPlayerViewComponent } from 'shared-portals'; +import { + LiveEpgPanelComponent, + LiveEpgPanelSummary, + WebPlayerViewComponent, +} from 'shared-portals'; import { PORTAL_PLAYER, createLogger, getAdjacentChannelItem, getChannelItemByNumber, + LiveEpgPanelState, + persistLiveEpgPanelState, + restoreLiveEpgPanelState, } from '@iptvnator/portal/shared/util'; import { PortalEmptyStateComponent } from '@iptvnator/portal/shared/ui'; import { @@ -50,6 +55,8 @@ import { ChannelListItemComponent, EpgListComponent, MatProgressSpinnerModule, + LiveEpgPanelComponent, + NgTemplateOutlet, PortalEmptyStateComponent, ResizableDirective, TranslatePipe, @@ -96,9 +103,7 @@ export class StalkerLiveStreamLayoutComponent implements OnDestroy { this.portalPlayer.isEmbeddedPlayer() ); readonly activePlayback = signal(null); - readonly streamUrl = computed( - () => this.activePlayback()?.streamUrl ?? '' - ); + readonly streamUrl = computed(() => this.activePlayback()?.streamUrl ?? ''); /** EPG */ readonly fallbackEpgPrograms = signal([]); @@ -112,6 +117,15 @@ export class StalkerLiveStreamLayoutComponent implements OnDestroy { readonly currentProgram = computed(() => this.findCurrentProgram(this.activeEpgPrograms()) ); + readonly liveEpgPanelState = signal( + restoreLiveEpgPanelState() + ); + readonly isLiveEpgPanelCollapsed = computed( + () => this.liveEpgPanelState() === 'collapsed' + ); + readonly liveEpgPanelSummary = computed(() => + this.toLiveEpgPanelSummary(this.currentProgram()) + ); readonly controlledChannel = computed(() => { const selectedType = this.stalkerStore.selectedContentType(); const selectedItem = this.stalkerStore.selectedItem(); @@ -343,6 +357,12 @@ export class StalkerLiveStreamLayoutComponent implements OnDestroy { this.stalkerStore.setPage(nextPage); } + onLiveEpgPanelCollapsedChange(collapsed: boolean): void { + const state: LiveEpgPanelState = collapsed ? 'collapsed' : 'expanded'; + this.liveEpgPanelState.set(state); + persistLiveEpgPanelState(state); + } + private async loadEpgForChannel(item: StalkerItvChannel) { const requestId = ++this.epgLoadRequestId; const normalizedChannelId = normalizeStalkerEntityId(item.id); @@ -358,12 +378,7 @@ export class StalkerLiveStreamLayoutComponent implements OnDestroy { try { if (shouldEnsureBulk) { await this.stalkerStore.ensureBulkItvEpg(168); - if ( - !this.isCurrentEpgRequest( - requestId, - normalizedChannelId - ) - ) { + if (!this.isCurrentEpgRequest(requestId, normalizedChannelId)) { return; } } @@ -376,9 +391,7 @@ export class StalkerLiveStreamLayoutComponent implements OnDestroy { const fallbackItems = await this.stalkerStore.fetchChannelEpg( item.id ); - if ( - !this.isCurrentEpgRequest(requestId, normalizedChannelId) - ) { + if (!this.isCurrentEpgRequest(requestId, normalizedChannelId)) { return; } @@ -389,15 +402,11 @@ export class StalkerLiveStreamLayoutComponent implements OnDestroy { ); } catch (error) { this.logger.warn('Failed to load Stalker live EPG', error); - if ( - this.isCurrentEpgRequest(requestId, normalizedChannelId) - ) { + if (this.isCurrentEpgRequest(requestId, normalizedChannelId)) { this.fallbackEpgPrograms.set([]); } } finally { - if ( - this.isCurrentEpgRequest(requestId, normalizedChannelId) - ) { + if (this.isCurrentEpgRequest(requestId, normalizedChannelId)) { this.isLoadingFallbackEpg.set(false); } } @@ -508,10 +517,7 @@ export class StalkerLiveStreamLayoutComponent implements OnDestroy { } } - private toProgram( - item: EpgItem, - channelId: string | number - ): EpgProgram { + private toProgram(item: EpgItem, channelId: string | number): EpgProgram { return { start: item.start, stop: item.stop || item.end, @@ -527,6 +533,20 @@ export class StalkerLiveStreamLayoutComponent implements OnDestroy { }; } + private toLiveEpgPanelSummary( + program: EpgProgram | null | undefined + ): LiveEpgPanelSummary | null { + if (!program) { + return null; + } + + return { + title: program.title, + start: program.start, + stop: program.stop, + }; + } + private toTimestamp( rawTimestamp: string | number | null | undefined, rawDate: string @@ -554,7 +574,12 @@ export class StalkerLiveStreamLayoutComponent implements OnDestroy { program.stop, program.stopTimestamp ); - return start !== null && stop !== null && now >= start && now < stop; + return ( + start !== null && + stop !== null && + now >= start && + now < stop + ); }) ?? null ); } diff --git a/libs/portal/xtream/feature/src/lib/live-stream-layout/live-stream-layout.component.html b/libs/portal/xtream/feature/src/lib/live-stream-layout/live-stream-layout.component.html index e8bd8c2e9..b4db74d23 100644 --- a/libs/portal/xtream/feature/src/lib/live-stream-layout/live-stream-layout.component.html +++ b/libs/portal/xtream/feature/src/lib/live-stream-layout/live-stream-layout.component.html @@ -1,6 +1,5 @@ @let isEmbeddedPlayer = usesEmbeddedPlayer(); @let streamUrl = activeStreamUrl(); -@let epgPrograms = controlledEpgPrograms(); @if (selectedCategoryId()) {
} -
-
- @if (isLoadingEpg()) { -
- -
- } @else if (isElectron && epgPrograms.length > 0) { - @if (showArchiveUnavailableNotice()) { -
-
- {{ - 'EPG.ARCHIVE_PLAYBACK_UNAVAILABLE_TITLE' - | translate - }} -
-
- {{ - 'EPG.ARCHIVE_PLAYBACK_UNAVAILABLE_DESCRIPTION' - | translate - }} -
-
- } - - } @else if (!isElectron && epgItems().length > 0) { - - } @else { - - } -
+
+ @if (isEmbeddedPlayer) { + + + + } @else { + + }
} @else { }
+ + +
+ @if (isLoadingEpg()) { +
+ +
+ } @else if (isElectron && controlledEpgPrograms().length > 0) { + @if (showArchiveUnavailableNotice()) { +
+
+ {{ + 'EPG.ARCHIVE_PLAYBACK_UNAVAILABLE_TITLE' | translate + }} +
+
+ {{ + 'EPG.ARCHIVE_PLAYBACK_UNAVAILABLE_DESCRIPTION' + | translate + }} +
+
+ } + + } @else if (!isElectron && epgItems().length > 0) { + + } @else { + + } +
+
diff --git a/libs/portal/xtream/feature/src/lib/live-stream-layout/live-stream-layout.component.spec.ts b/libs/portal/xtream/feature/src/lib/live-stream-layout/live-stream-layout.component.spec.ts index a0e08ea33..b1922d72c 100644 --- a/libs/portal/xtream/feature/src/lib/live-stream-layout/live-stream-layout.component.spec.ts +++ b/libs/portal/xtream/feature/src/lib/live-stream-layout/live-stream-layout.component.spec.ts @@ -6,6 +6,7 @@ import { MockPipe } from 'ng-mocks'; import { TranslatePipe } from '@ngx-translate/core'; import { of } from 'rxjs'; import { + LIVE_EPG_PANEL_STATE_STORAGE_KEY, PORTAL_PLAYER, ResizableDirective, } from '@iptvnator/portal/shared/util'; @@ -15,7 +16,12 @@ import { XtreamUrlService, } from '@iptvnator/portal/xtream/data-access'; import { EpgListComponent, EpgProgramActivationEvent } from '@iptvnator/ui/epg'; -import { EpgViewComponent, WebPlayerViewComponent } from 'shared-portals'; +import { + EpgViewComponent, + LiveEpgPanelComponent, + LiveEpgPanelSummary, + WebPlayerViewComponent, +} from 'shared-portals'; import { EpgItem, EpgProgram } from 'shared-interfaces'; import { PortalChannelsListComponent } from '../portal-channels-list/portal-channels-list.component'; import { LiveStreamLayoutComponent } from './live-stream-layout.component'; @@ -63,6 +69,21 @@ class StubEpgListComponent { readonly programActivated = output(); } +@Component({ + selector: 'app-live-epg-panel', + standalone: true, + template: ` +
{{ summary()?.title }}
+ + `, +}) +class StubLiveEpgPanelComponent { + readonly collapsed = input(false); + readonly summary = input(null); + readonly loading = input(false); + readonly collapsedChange = output(); +} + @Directive({ selector: '[appResizable]', standalone: true, @@ -130,6 +151,7 @@ describe('LiveStreamLayoutComponent', () => { jest.useFakeTimers(); jest.setSystemTime(fixedNow); localStorage.removeItem(LIVE_CHANNEL_SORT_STORAGE_KEY); + localStorage.removeItem(LIVE_EPG_PANEL_STATE_STORAGE_KEY); window.electron = { updateRemoteControlStatus: jest.fn(), @@ -184,6 +206,7 @@ describe('LiveStreamLayoutComponent', () => { imports: [ EpgListComponent, EpgViewComponent, + LiveEpgPanelComponent, PortalChannelsListComponent, ResizableDirective, TranslatePipe, @@ -194,6 +217,7 @@ describe('LiveStreamLayoutComponent', () => { imports: [ StubEpgListComponent, StubEpgViewComponent, + StubLiveEpgPanelComponent, StubPortalChannelsListComponent, StubResizableDirective, MockPipe( @@ -214,6 +238,7 @@ describe('LiveStreamLayoutComponent', () => { fixture.destroy(); jest.useRealTimers(); localStorage.removeItem(LIVE_CHANNEL_SORT_STORAGE_KEY); + localStorage.removeItem(LIVE_EPG_PANEL_STATE_STORAGE_KEY); window.electron = originalElectron; }); @@ -236,6 +261,74 @@ describe('LiveStreamLayoutComponent', () => { expect(fixture.nativeElement.querySelector('app-epg-view')).toBeNull(); }); + it('restores the collapsed live EPG panel state for embedded playback', () => { + fixture.destroy(); + localStorage.setItem(LIVE_EPG_PANEL_STATE_STORAGE_KEY, 'collapsed'); + + fixture = TestBed.createComponent(LiveStreamLayoutComponent); + component = fixture.componentInstance; + component.playLive(sampleChannel); + fixture.detectChanges(); + + expect(component.isLiveEpgPanelCollapsed()).toBe(true); + expect( + fixture.nativeElement + .querySelector('.epg') + .classList.contains('epg-collapsed') + ).toBe(true); + }); + + it('persists live EPG panel toggle changes', () => { + component.onLiveEpgPanelCollapsedChange(true); + + expect(component.isLiveEpgPanelCollapsed()).toBe(true); + expect(localStorage.getItem(LIVE_EPG_PANEL_STATE_STORAGE_KEY)).toBe( + 'collapsed' + ); + + component.onLiveEpgPanelCollapsedChange(false); + + expect(localStorage.getItem(LIVE_EPG_PANEL_STATE_STORAGE_KEY)).toBe( + 'expanded' + ); + }); + + it('does not render the collapsible panel for external playback', () => { + portalPlayer.isEmbeddedPlayer.mockReturnValue(false); + + component.playLive(sampleChannel); + fixture.detectChanges(); + + expect( + fixture.nativeElement.querySelector('app-live-epg-panel') + ).toBeNull(); + expect( + fixture.nativeElement + .querySelector('.epg') + .classList.contains('epg-collapsed') + ).toBe(false); + }); + + it('renders the current EPG program in the collapsible panel summary', () => { + epgItems.set([ + buildEpgItem( + 'current', + 'Current Show', + '2026-04-05T11:30:00.000Z', + '2026-04-05T12:30:00.000Z' + ), + ]); + currentEpgItem.set(epgItems()[0]); + + component.playLive(sampleChannel); + fixture.detectChanges(); + + expect( + fixture.nativeElement.querySelector('.live-epg-panel-summary') + .textContent + ).toContain('Current Show'); + }); + it('uses currentEpgItem instead of assuming the first schedule item is current', () => { epgItems.set([ buildEpgItem( diff --git a/libs/portal/xtream/feature/src/lib/live-stream-layout/live-stream-layout.component.ts b/libs/portal/xtream/feature/src/lib/live-stream-layout/live-stream-layout.component.ts index bfc6b8a08..ffce599f3 100644 --- a/libs/portal/xtream/feature/src/lib/live-stream-layout/live-stream-layout.component.ts +++ b/libs/portal/xtream/feature/src/lib/live-stream-layout/live-stream-layout.component.ts @@ -1,3 +1,4 @@ +import { NgTemplateOutlet } from '@angular/common'; import { ChangeDetectionStrategy, Component, @@ -22,8 +23,11 @@ import { getAdjacentChannelItem, getChannelItemByNumber, isWorkspaceLayoutRoute, + LiveEpgPanelState, + persistLiveEpgPanelState, persistPortalChannelSortMode, queryParamSignal, + restoreLiveEpgPanelState, restorePortalChannelSortMode, } from '@iptvnator/portal/shared/util'; import { @@ -34,7 +38,12 @@ import { } from '@iptvnator/portal/xtream/data-access'; import { EpgListComponent, EpgProgramActivationEvent } from '@iptvnator/ui/epg'; import { MatProgressSpinnerModule } from '@angular/material/progress-spinner'; -import { EpgViewComponent, WebPlayerViewComponent } from 'shared-portals'; +import { + EpgViewComponent, + LiveEpgPanelComponent, + LiveEpgPanelSummary, + WebPlayerViewComponent, +} from 'shared-portals'; import { EpgItem, EpgProgram } from 'shared-interfaces'; import { PortalChannelsListComponent } from '../portal-channels-list/portal-channels-list.component'; import { ActivatedRoute } from '@angular/router'; @@ -58,11 +67,13 @@ interface XtreamLiveChannelItem { imports: [ EpgListComponent, EpgViewComponent, + LiveEpgPanelComponent, MatIcon, MatIconButton, MatMenuModule, MatProgressSpinnerModule, MatTooltipModule, + NgTemplateOutlet, PortalChannelsListComponent, PortalEmptyStateComponent, ResizableDirective, @@ -142,6 +153,15 @@ export class LiveStreamLayoutComponent implements OnInit, OnDestroy { this.hasPastPrograms() && !this.archivePlaybackAvailable() ); + readonly liveEpgPanelState = signal( + restoreLiveEpgPanelState() + ); + readonly isLiveEpgPanelCollapsed = computed( + () => this.liveEpgPanelState() === 'collapsed' + ); + readonly liveEpgPanelSummary = computed(() => + this.toLiveEpgPanelSummary(this.currentEpgItem()) + ); readonly liveChannelSortLabel = computed(() => getPortalChannelSortModeLabel(this.liveChannelSortMode()) ); @@ -321,6 +341,12 @@ export class LiveStreamLayoutComponent implements OnInit, OnDestroy { persistPortalChannelSortMode(LIVE_CHANNEL_SORT_STORAGE_KEY, mode); } + onLiveEpgPanelCollapsedChange(collapsed: boolean): void { + const state: LiveEpgPanelState = collapsed ? 'collapsed' : 'expanded'; + this.liveEpgPanelState.set(state); + persistLiveEpgPanelState(state); + } + ngOnDestroy(): void { this.unsubscribeRemoteChannelChange?.(); this.unsubscribeRemoteCommand?.(); @@ -437,6 +463,20 @@ export class LiveStreamLayoutComponent implements OnInit, OnDestroy { }; } + private toLiveEpgPanelSummary( + program: EpgItem | null | undefined + ): LiveEpgPanelSummary | null { + if (!program) { + return null; + } + + return { + title: program.title, + start: program.start, + stop: program.stop ?? program.end, + }; + } + private getProgramTimestampSeconds( dateValue: string, unixTimestampValue?: number | string | null diff --git a/libs/services/src/lib/settings-store.service.ts b/libs/services/src/lib/settings-store.service.ts index cc2f9e6a9..44b655113 100644 --- a/libs/services/src/lib/settings-store.service.ts +++ b/libs/services/src/lib/settings-store.service.ts @@ -102,7 +102,8 @@ export const SettingsStore = signalStore( isEmbeddedPlayer() { return ( store.player() === VideoPlayer.VideoJs || - store.player() === VideoPlayer.Html5Player + store.player() === VideoPlayer.Html5Player || + store.player() === VideoPlayer.ArtPlayer ); }, })), diff --git a/libs/ui/shared-portals/src/index.ts b/libs/ui/shared-portals/src/index.ts index f578cba7d..361627ae0 100644 --- a/libs/ui/shared-portals/src/index.ts +++ b/libs/ui/shared-portals/src/index.ts @@ -1,3 +1,4 @@ -export * from './lib/epg-view/epg-view.component'; -export * from './lib/live-stream-layout/live-stream-layout.component'; +export * from './lib/epg-view/epg-view.component'; +export * from './lib/live-epg-panel/live-epg-panel.component'; +export * from './lib/live-stream-layout/live-stream-layout.component'; export { WebPlayerViewComponent } from '@iptvnator/ui/playback'; diff --git a/libs/ui/shared-portals/src/lib/live-epg-panel/live-epg-panel.component.html b/libs/ui/shared-portals/src/lib/live-epg-panel/live-epg-panel.component.html new file mode 100644 index 000000000..d2dfec81d --- /dev/null +++ b/libs/ui/shared-portals/src/lib/live-epg-panel/live-epg-panel.component.html @@ -0,0 +1,76 @@ +
+
+ + +
+ + {{ 'EPG.CURRENT_PROGRAM' | translate }} + + + @if (loading()) { + + {{ 'EPG.LOADING' | translate }} + + } @else if (hasSummary()) { + + {{ summary()?.title }} + + + @if (hasTimeRange()) { + + @if (summary()?.start) { + {{ summary()?.start | date: 'HH:mm' }} + } + @if (summary()?.start && summary()?.stop) { + - + } + @if (summary()?.stop) { + {{ summary()?.stop | date: 'HH:mm' }} + } + + } + } @else { + + {{ 'EPG.NO_PROGRAM_INFO' | translate }} + + } +
+ + @if (progress() !== null) { + + } +
+ +
+ +
+
diff --git a/libs/ui/shared-portals/src/lib/live-epg-panel/live-epg-panel.component.scss b/libs/ui/shared-portals/src/lib/live-epg-panel/live-epg-panel.component.scss new file mode 100644 index 000000000..45849c282 --- /dev/null +++ b/libs/ui/shared-portals/src/lib/live-epg-panel/live-epg-panel.component.scss @@ -0,0 +1,130 @@ +:host { + display: block; + height: 100%; + min-height: 0; +} + +.live-epg-panel { + display: flex; + flex-direction: column; + height: 100%; + min-height: 0; + overflow: hidden; + background: var(--mat-sys-surface); +} + +.live-epg-panel__summary-bar { + position: relative; + display: grid; + grid-template-columns: auto minmax(0, 1fr); + align-items: center; + column-gap: 8px; + min-height: 56px; + padding: 7px 12px 9px 8px; + flex-shrink: 0; + border-bottom: 1px solid var(--app-separator); + background: var( + --app-widget-header-bg, + var(--mat-sys-surface-container-high) + ); + box-sizing: border-box; +} + +.live-epg-panel__toggle { + width: 40px; + height: 40px; + border-radius: 10px; + color: var(--mat-sys-on-surface-variant); + app-region: no-drag; + + &:hover { + color: var(--mat-sys-on-surface); + background: color-mix(in srgb, currentColor 10%, transparent); + } +} + +.live-epg-panel__summary { + min-width: 0; + display: flex; + align-items: baseline; + gap: 8px; +} + +.live-epg-panel__label { + flex-shrink: 0; + font-size: 0.7rem; + line-height: 1; + font-weight: 700; + letter-spacing: 0.08em; + text-transform: uppercase; + color: var(--app-selection-color); +} + +.live-epg-panel__title { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-size: 0.9rem; + line-height: 1.25; + font-weight: 600; + color: var(--mat-sys-on-surface); +} + +.live-epg-panel__title--muted { + font-weight: 500; + color: var(--mat-sys-on-surface-variant); +} + +.live-epg-panel__time { + flex-shrink: 0; + font-size: 0.78rem; + line-height: 1; + font-weight: 600; + font-variant-numeric: tabular-nums; + color: var(--mat-sys-on-surface-variant); +} + +.live-epg-panel__progress { + position: absolute; + inset-inline: 8px 12px; + bottom: 5px; + height: 4px; + overflow: hidden; + border-radius: 999px; + background: color-mix(in srgb, var(--mat-sys-on-surface) 14%, transparent); +} + +.live-epg-panel__progress-fill { + height: 100%; + border-radius: inherit; + background: var(--app-selection-color); + box-shadow: 0 0 8px -2px var(--app-selection-glow); + transition: width 0.35s ease; +} + +.live-epg-panel__body { + flex: 1 1 auto; + min-height: 0; + display: flex; + flex-direction: column; + overflow: hidden; + transition: + opacity 0.16s ease, + block-size 0.16s ease; +} + +.live-epg-panel--collapsed { + .live-epg-panel__summary-bar { + border-bottom-color: transparent; + } + + .live-epg-panel__body { + flex-basis: 0; + block-size: 0; + min-block-size: 0; + opacity: 0; + visibility: hidden; + pointer-events: none; + } +} diff --git a/libs/ui/shared-portals/src/lib/live-epg-panel/live-epg-panel.component.spec.ts b/libs/ui/shared-portals/src/lib/live-epg-panel/live-epg-panel.component.spec.ts new file mode 100644 index 000000000..d13af57c1 --- /dev/null +++ b/libs/ui/shared-portals/src/lib/live-epg-panel/live-epg-panel.component.spec.ts @@ -0,0 +1,110 @@ +import { Component } from '@angular/core'; +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { NoopAnimationsModule } from '@angular/platform-browser/animations'; +import { TranslateModule } from '@ngx-translate/core'; +import { + LiveEpgPanelComponent, + LiveEpgPanelSummary, +} from './live-epg-panel.component'; + +@Component({ + standalone: true, + imports: [LiveEpgPanelComponent], + template: ` + +
Projected EPG
+
+ `, +}) +class HostComponent { + collapsed = false; + loading = false; + summary: LiveEpgPanelSummary | null = { + title: 'Current Show', + start: '2026-04-05T11:30:00.000Z', + stop: '2026-04-05T12:30:00.000Z', + }; +} + +describe('LiveEpgPanelComponent', () => { + let fixture: ComponentFixture; + + beforeEach(async () => { + jest.useFakeTimers(); + jest.setSystemTime(new Date('2026-04-05T12:00:00.000Z')); + + await TestBed.configureTestingModule({ + imports: [ + HostComponent, + NoopAnimationsModule, + TranslateModule.forRoot(), + ], + }).compileComponents(); + + fixture = TestBed.createComponent(HostComponent); + }); + + afterEach(() => { + fixture.destroy(); + jest.useRealTimers(); + }); + + it('renders the current program summary and progress', () => { + fixture.detectChanges(); + + expect( + fixture.nativeElement.querySelector('.live-epg-panel__title') + .textContent + ).toContain('Current Show'); + expect( + fixture.nativeElement.querySelector( + '.live-epg-panel__progress-fill' + ).style.width + ).toBe('50%'); + }); + + it('emits and applies collapsed state from the toggle button', () => { + fixture.detectChanges(); + + fixture.nativeElement.querySelector('button').click(); + fixture.detectChanges(); + + expect(fixture.componentInstance.collapsed).toBe(true); + expect( + fixture.nativeElement + .querySelector('.live-epg-panel') + .classList.contains('live-epg-panel--collapsed') + ).toBe(true); + }); + + it('keeps projected EPG content mounted while collapsed and makes it inert', () => { + fixture.componentInstance.collapsed = true; + fixture.detectChanges(); + + const body = fixture.nativeElement.querySelector( + '.live-epg-panel__body' + ); + + expect( + fixture.nativeElement.querySelector('.projected-content') + .textContent + ).toContain('Projected EPG'); + expect(body.getAttribute('aria-hidden')).toBe('true'); + expect(body.hasAttribute('inert')).toBe(true); + }); + + it('renders the fallback text when there is no current program', () => { + fixture.componentInstance.summary = null; + fixture.detectChanges(); + + expect( + fixture.nativeElement.querySelector('.live-epg-panel__title') + .textContent + ).toContain('EPG.NO_PROGRAM_INFO'); + }); +}); diff --git a/libs/ui/shared-portals/src/lib/live-epg-panel/live-epg-panel.component.ts b/libs/ui/shared-portals/src/lib/live-epg-panel/live-epg-panel.component.ts new file mode 100644 index 000000000..870717612 --- /dev/null +++ b/libs/ui/shared-portals/src/lib/live-epg-panel/live-epg-panel.component.ts @@ -0,0 +1,102 @@ +import { DatePipe } from '@angular/common'; +import { + ChangeDetectionStrategy, + Component, + computed, + effect, + input, + output, + signal, +} from '@angular/core'; +import { MatIconButton } from '@angular/material/button'; +import { MatIcon } from '@angular/material/icon'; +import { MatTooltip } from '@angular/material/tooltip'; +import { TranslatePipe } from '@ngx-translate/core'; + +export interface LiveEpgPanelSummary { + readonly title?: string | null; + readonly start?: string | number | Date | null; + readonly stop?: string | number | Date | null; + readonly progress?: number | null; +} + +@Component({ + selector: 'app-live-epg-panel', + imports: [DatePipe, MatIcon, MatIconButton, MatTooltip, TranslatePipe], + templateUrl: './live-epg-panel.component.html', + styleUrl: './live-epg-panel.component.scss', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class LiveEpgPanelComponent { + readonly collapsed = input(false); + readonly summary = input(null); + readonly loading = input(false); + readonly collapsedChange = output(); + + private readonly currentTimeMs = signal(Date.now()); + + readonly hasSummary = computed(() => { + const title = this.summary()?.title; + return typeof title === 'string' && title.trim().length > 0; + }); + + readonly hasTimeRange = computed(() => { + const summary = this.summary(); + return !!summary?.start || !!summary?.stop; + }); + + readonly progress = computed(() => { + const summary = this.summary(); + if (!summary) { + return null; + } + + const explicitProgress = Number(summary.progress); + if (Number.isFinite(explicitProgress)) { + return clampProgress(explicitProgress); + } + + const startMs = toTimeMs(summary.start); + const stopMs = toTimeMs(summary.stop); + if (startMs === null || stopMs === null || stopMs <= startMs) { + return null; + } + + const elapsed = this.currentTimeMs() - startMs; + return clampProgress((elapsed / (stopMs - startMs)) * 100); + }); + + constructor() { + effect((onCleanup) => { + const intervalId = window.setInterval(() => { + this.currentTimeMs.set(Date.now()); + }, 30_000); + + onCleanup(() => clearInterval(intervalId)); + }); + } + + toggleCollapsed(): void { + this.collapsedChange.emit(!this.collapsed()); + } +} + +function toTimeMs( + value: string | number | Date | null | undefined +): number | null { + if (value === null || value === undefined || value === '') { + return null; + } + + const parsed = + value instanceof Date + ? value.getTime() + : typeof value === 'number' + ? value + : Date.parse(value); + return Number.isFinite(parsed) ? parsed : null; +} + +function clampProgress(value: number): number { + return Math.min(100, Math.max(0, value)); +} diff --git a/libs/ui/shared-portals/src/lib/live-stream-layout/live-stream-layout.component.html b/libs/ui/shared-portals/src/lib/live-stream-layout/live-stream-layout.component.html index 71982145d..7408ff411 100644 --- a/libs/ui/shared-portals/src/lib/live-stream-layout/live-stream-layout.component.html +++ b/libs/ui/shared-portals/src/lib/live-stream-layout/live-stream-layout.component.html @@ -1,7 +1,9 @@
@@ -58,7 +60,7 @@
- @if (player === 'videojs' || player === 'html5') { + @if (usesInternalPlayer()) { @if (streamUrl) {
@@ -66,10 +68,27 @@ } } @if (streamUrl) { -
-
- -
+
+ @if (usesInternalPlayer()) { + +
+ +
+
+ } @else { +
+ +
+ }
} @else {
diff --git a/libs/ui/shared-portals/src/lib/live-stream-layout/live-stream-layout.component.scss b/libs/ui/shared-portals/src/lib/live-stream-layout/live-stream-layout.component.scss index 4a9e1a447..519e8e7d5 100644 --- a/libs/ui/shared-portals/src/lib/live-stream-layout/live-stream-layout.component.scss +++ b/libs/ui/shared-portals/src/lib/live-stream-layout/live-stream-layout.component.scss @@ -20,6 +20,10 @@ height: 100%; } +.itv-container--epg-collapsed { + grid-template-rows: minmax(0, 1fr) 56px; +} + .itv-container-epg { display: grid; // Use auto for sidebar to respect the directive's width @@ -63,6 +67,12 @@ grid-area: epg; min-height: 0; overflow: hidden; + display: flex; + flex-direction: column; + + &.epg-collapsed { + min-height: 56px; + } } .search-panel { @@ -91,6 +101,11 @@ min-height: 0; } +.epg > app-live-epg-panel { + flex: 1; + min-height: 0; +} + .sidebar-content { height: 100%; overflow-y: auto; @@ -149,7 +164,11 @@ border-radius: 4px; object-fit: cover; flex-shrink: 0; - background: color-mix(in srgb, var(--mat-sys-on-surface) 5%, transparent); + background: color-mix( + in srgb, + var(--mat-sys-on-surface) 5%, + transparent + ); } span, diff --git a/libs/ui/shared-portals/src/lib/live-stream-layout/live-stream-layout.component.ts b/libs/ui/shared-portals/src/lib/live-stream-layout/live-stream-layout.component.ts index f2fcc0c79..a861e3ecc 100644 --- a/libs/ui/shared-portals/src/lib/live-stream-layout/live-stream-layout.component.ts +++ b/libs/ui/shared-portals/src/lib/live-stream-layout/live-stream-layout.component.ts @@ -3,6 +3,7 @@ import { ScrollingModule } from '@angular/cdk/scrolling'; import { ChangeDetectionStrategy, Component, + computed, EventEmitter, Input, Output, @@ -15,28 +16,38 @@ import { MatInputModule } from '@angular/material/input'; import { MatListModule } from '@angular/material/list'; import { FilterPipe } from '@iptvnator/pipes'; import { TranslateModule } from '@ngx-translate/core'; +import { + LiveEpgPanelState, + persistLiveEpgPanelState, + restoreLiveEpgPanelState, +} from '@iptvnator/portal/shared/util'; import { WebPlayerViewComponent } from '@iptvnator/ui/playback'; import { ResizableDirective } from 'components'; import { EpgItem, VideoPlayer, XtreamItem } from 'shared-interfaces'; import { EpgViewComponent } from '../epg-view/epg-view.component'; +import { + LiveEpgPanelComponent, + LiveEpgPanelSummary, +} from '../live-epg-panel/live-epg-panel.component'; @Component({ selector: 'app-live-stream-layout', templateUrl: './live-stream-layout.component.html', styleUrls: ['./live-stream-layout.component.scss'], imports: [ - EpgViewComponent, - FilterPipe, - FormsModule, - MatListModule, - MatIconModule, - MatInputModule, - MatFormFieldModule, - ResizableDirective, - ScrollingModule, - WebPlayerViewComponent, - TranslateModule -], + EpgViewComponent, + FilterPipe, + FormsModule, + LiveEpgPanelComponent, + MatListModule, + MatIconModule, + MatInputModule, + MatFormFieldModule, + ResizableDirective, + ScrollingModule, + WebPlayerViewComponent, + TranslateModule, + ], changeDetection: ChangeDetectionStrategy.OnPush, }) export class LiveStreamLayoutComponent { @@ -49,8 +60,51 @@ export class LiveStreamLayoutComponent { @Output() itemClicked = new EventEmitter(); searchString = signal(''); + readonly liveEpgPanelState = signal( + restoreLiveEpgPanelState() + ); + readonly isLiveEpgPanelCollapsed = computed( + () => this.liveEpgPanelState() === 'collapsed' + ); trackBy(_index: number, item: XtreamItem) { return item.stream_id; } + + usesInternalPlayer(): boolean { + return ( + this.player === VideoPlayer.VideoJs || + this.player === VideoPlayer.Html5Player || + this.player === VideoPlayer.ArtPlayer + ); + } + + getLiveEpgPanelSummary(): LiveEpgPanelSummary | null { + const currentProgram = + this.epgItems?.find((item) => this.isCurrentProgram(item)) ?? null; + + if (!currentProgram) { + return null; + } + + return { + title: currentProgram.title, + start: currentProgram.start, + stop: currentProgram.stop ?? currentProgram.end, + }; + } + + onLiveEpgPanelCollapsedChange(collapsed: boolean): void { + const state: LiveEpgPanelState = collapsed ? 'collapsed' : 'expanded'; + this.liveEpgPanelState.set(state); + persistLiveEpgPanelState(state); + } + + private isCurrentProgram(item: EpgItem): boolean { + const end = item.stop ?? item.end; + const now = Date.now(); + const start = new Date(item.start).getTime(); + const stop = new Date(end).getTime(); + return now >= start && now <= stop; + } } diff --git a/libs/ui/shared-portals/tsconfig.spec.json b/libs/ui/shared-portals/tsconfig.spec.json index 553f8af7c..65a0eb8ce 100644 --- a/libs/ui/shared-portals/tsconfig.spec.json +++ b/libs/ui/shared-portals/tsconfig.spec.json @@ -1,22 +1,17 @@ { - "extends": "./tsconfig.json", - "compilerOptions": { - "outDir": "../../../dist/out-tsc", - "module": "commonjs", - "target": "es2016", - "types": [ - "jest", - "node" - ], - "moduleResolution": "node10" - }, - "files": [ - "src/test-setup.ts" - ], - "include": [ - "jest.config.ts", - "src/**/*.test.ts", - "src/**/*.spec.ts", - "src/**/*.d.ts" - ] + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "../../../dist/out-tsc", + "module": "ESNext", + "target": "ESNext", + "types": ["jest", "node"], + "moduleResolution": "bundler" + }, + "files": ["src/test-setup.ts"], + "include": [ + "jest.config.ts", + "src/**/*.test.ts", + "src/**/*.spec.ts", + "src/**/*.d.ts" + ] } diff --git a/libs/ui/styles/_portal-layout.scss b/libs/ui/styles/_portal-layout.scss index b79d904bf..051a34713 100644 --- a/libs/ui/styles/_portal-layout.scss +++ b/libs/ui/styles/_portal-layout.scss @@ -65,6 +65,21 @@ flex: 1 1 0; min-height: 0; overflow: hidden; + display: flex; + flex-direction: column; + transition: + flex-basis 0.18s ease, + min-height 0.18s ease; + + &.epg-collapsed { + flex: 0 0 56px; + min-height: 56px; + } + + > app-live-epg-panel { + flex: 1; + min-height: 0; + } } .epg-content { @@ -74,7 +89,8 @@ display: flex; flex-direction: column; - > app-epg-view { + > app-epg-view, + > app-live-epg-panel { flex: 1; min-height: 0; } From d163a1fbf23cd50fa67a6e01434e45717a97fdd2 Mon Sep 17 00:00:00 2001 From: 4gray Date: Tue, 28 Apr 2026 20:14:04 +0200 Subject: [PATCH 011/116] feat(dashboard): contained logo + blurred backdrop for live channels Render live TV channel logos with object-fit: contain on a soft blurred wash sampled from the logo itself, instead of stretching small (100-256px) logos to fill 2:3 portrait cards with object-fit: cover. Mirrors the treatment used by Apple Podcasts and Plex Live TV. VOD/series posters still get the full-bleed cover treatment. Co-Authored-By: Claude Opus 4.7 (1M context) Entire-Checkpoint: 6bb6f5796090 --- .../lib/rails/dashboard-rail.component.html | 12 ++++++++ .../lib/rails/dashboard-rail.component.scss | 28 +++++++++++++++++++ .../src/lib/rails/dashboard-rail.component.ts | 1 + .../workspace-dashboard-rails.component.html | 1 + .../workspace-dashboard-rails.component.scss | 21 ++++++++++++++ .../workspace-dashboard-rails.component.ts | 5 ++++ 6 files changed, 68 insertions(+) diff --git a/libs/workspace/dashboard/feature/src/lib/rails/dashboard-rail.component.html b/libs/workspace/dashboard/feature/src/lib/rails/dashboard-rail.component.html index 60dd77c2f..07878677b 100644 --- a/libs/workspace/dashboard/feature/src/lib/rails/dashboard-rail.component.html +++ b/libs/workspace/dashboard/feature/src/lib/rails/dashboard-rail.component.html @@ -49,9 +49,21 @@ >
@if (card.imageUrl && !failedImages()[card.id]) { + @if (card.contentType === 'live') { + + } ; actions?: DashboardRailAction[]; diff --git a/libs/workspace/dashboard/feature/src/lib/rails/workspace-dashboard-rails.component.html b/libs/workspace/dashboard/feature/src/lib/rails/workspace-dashboard-rails.component.html index 054be0621..abc1c3a7a 100644 --- a/libs/workspace/dashboard/feature/src/lib/rails/workspace-dashboard-rails.component.html +++ b/libs/workspace/dashboard/feature/src/lib/rails/workspace-dashboard-rails.component.html @@ -54,6 +54,7 @@ [class.hero--has-default-backdrop]=" item.backdropSource === 'fallback' " + [class.hero--live]="item.contentType === 'live'" [routerLink]="item.link" [state]="item.state ?? {}" > diff --git a/libs/workspace/dashboard/feature/src/lib/rails/workspace-dashboard-rails.component.scss b/libs/workspace/dashboard/feature/src/lib/rails/workspace-dashboard-rails.component.scss index 0e25da815..a7f1f8645 100644 --- a/libs/workspace/dashboard/feature/src/lib/rails/workspace-dashboard-rails.component.scss +++ b/libs/workspace/dashboard/feature/src/lib/rails/workspace-dashboard-rails.component.scss @@ -223,6 +223,27 @@ } } +// Live channel logos (small square images) get destroyed by the default +// `object-fit: cover` poster treatment. The `.hero--live` modifier centers +// the logo with breathing room over a tinted backdrop. The blurred +// `.hero__backdrop` already provides the ambient color wash for the wider hero. +.hero--live .hero__poster { + background: + radial-gradient( + circle at 50% 35%, + var(--app-selection-surface, rgba(120, 173, 255, 0.18)) 0%, + transparent 65% + ), + var(--app-widget-bg, #1e2330); + + img { + box-sizing: border-box; + object-fit: contain; + padding: 22% 18%; + filter: drop-shadow(0 4px 12px rgba(0, 0, 0, 0.45)); + } +} + .hero__body { position: relative; z-index: 2; diff --git a/libs/workspace/dashboard/feature/src/lib/rails/workspace-dashboard-rails.component.ts b/libs/workspace/dashboard/feature/src/lib/rails/workspace-dashboard-rails.component.ts index abb12ce69..84e7163a9 100644 --- a/libs/workspace/dashboard/feature/src/lib/rails/workspace-dashboard-rails.component.ts +++ b/libs/workspace/dashboard/feature/src/lib/rails/workspace-dashboard-rails.component.ts @@ -52,6 +52,7 @@ const SKELETON_RAILS = [1, 2, 3] as const; interface DashboardHeroModel { readonly backdropUrl?: string; readonly backdropSource: DashboardHeroBackdropSource; + readonly contentType?: 'live' | 'movie' | 'series'; readonly fallbackBackdropBackground: string; readonly fallbackPosterBackground: string; readonly hasBackdrop: boolean; @@ -260,6 +261,7 @@ export class WorkspaceDashboardRailsComponent { return { ...artwork, + contentType: item.type, icon: this.typeIcon(item.type), link: this.data.getRecentItemLink(item), state: this.data.getRecentItemNavigationState(item), @@ -379,6 +381,7 @@ export class WorkspaceDashboardRailsComponent { subtitle: `${this.data.getRecentItemProviderLabel(item)} · ${this.data.getRecentItemTypeLabel(item)}`, imageUrl: item.poster_url, icon: this.typeIcon(item.type), + contentType: item.type, link: this.data.getRecentItemLink(item), state: this.data.getRecentItemNavigationState(item), }; @@ -391,6 +394,7 @@ export class WorkspaceDashboardRailsComponent { subtitle: `${this.data.getFavoriteItemProviderLabel(item)} · ${this.data.getFavoriteItemTypeLabel(item)}`, imageUrl: item.poster_url, icon: this.typeIcon(item.type), + contentType: item.type, link: this.data.getGlobalFavoriteLink(item), state: this.data.getGlobalFavoriteNavigationState(item), }; @@ -409,6 +413,7 @@ export class WorkspaceDashboardRailsComponent { subtitle: subtitleParts.join(' · '), imageUrl: item.poster_url, icon: this.typeIcon(item.type), + contentType: item.type, link: this.data.getRecentlyAddedLink(item), state: this.data.getRecentlyAddedNavigationState(item), }; From 31258624b572faa00a7ef1d29cedbd25b5c9c2a2 Mon Sep 17 00:00:00 2001 From: 4gray Date: Wed, 29 Apr 2026 00:20:34 +0200 Subject: [PATCH 012/116] feat: unify live epg toolbar Entire-Checkpoint: 978eaff2e478 --- .plans/2026-04-28-live-epg-unified-toolbar.md | 49 +++++++ .../src/xtream-epg.e2e.ts | 67 +++++---- apps/web-e2e/src/stalker.e2e.ts | 8 +- docs/architecture/iptvnator-ui-guidelines.md | 5 +- .../video-player/video-player.component.html | 8 ++ .../video-player.component.spec.ts | 3 + .../video-player/video-player.component.ts | 14 ++ .../stalker-live-stream-layout.component.html | 26 +++- ...alker-live-stream-layout.component.spec.ts | 6 + .../stalker-live-stream-layout.component.ts | 18 ++- .../live-stream-layout.component.html | 26 +++- .../live-stream-layout.component.spec.ts | 6 + .../live-stream-layout.component.ts | 19 ++- libs/ui/epg/src/index.ts | 1 + libs/ui/epg/src/lib/epg-date.ts | 39 ++++++ .../src/lib/epg-list/epg-list.component.html | 54 ++++---- .../lib/epg-list/epg-list.component.spec.ts | 34 ++++- .../src/lib/epg-list/epg-list.component.ts | 61 +++++---- .../live-epg-panel.component.html | 129 ++++++++++++++---- .../live-epg-panel.component.scss | 121 +++++++++++++++- .../live-epg-panel.component.spec.ts | 32 +++++ .../live-epg-panel.component.ts | 44 +++++- tsconfig.base.json | 1 + 23 files changed, 648 insertions(+), 123 deletions(-) create mode 100644 .plans/2026-04-28-live-epg-unified-toolbar.md create mode 100644 libs/ui/epg/src/lib/epg-date.ts diff --git a/.plans/2026-04-28-live-epg-unified-toolbar.md b/.plans/2026-04-28-live-epg-unified-toolbar.md new file mode 100644 index 000000000..c0b45efff --- /dev/null +++ b/.plans/2026-04-28-live-epg-unified-toolbar.md @@ -0,0 +1,49 @@ +# Unified Live EPG Toolbar + +## Summary + +Merge the current-program strip and date navigator into one shared `app-live-epg-panel` toolbar for M3U, Xtream, and Stalker live playback. The EPG list starts directly below the unified toolbar when the panel is expanded. In collapsed mode, keep the slim current-program strip and show a compact date chip. + +## Key Changes + +- Update `EpgListComponent` to support controlled date state: + - Add `[selectedDate]`, `(selectedDateChange)`, and `[showDateNavigator]`. + - Rename its internal date signal to avoid API collision. + - Keep existing standalone behavior by default, so non-live or external-player layouts still show the internal date row. +- Add small shared EPG date helpers in `@iptvnator/ui/epg`: + - Today date key. + - Parse date key. + - Shift date key by previous/next day. +- Extend `LiveEpgPanelComponent` with optional date navigation: + - Inputs: `[showDateNavigator]`, `[selectedDate]`. + - Output: `(dateNavigation)` emitting `prev | next`. + - Expanded toolbar: collapse button, current-program label/title/time, previous-day button, date label, next-day button, progress line. + - Collapsed toolbar: current-program summary plus compact date chip; the chip opens a small menu for previous/next day on narrow or collapsed states. +- Wire the unified toolbar into M3U, Xtream, and Stalker live layouts. +- Update focused unit specs and e2e selectors that currently assume `.selected-date` / `.next-day` live inside `app-epg-list`. +- Update `docs/architecture/iptvnator-ui-guidelines.md` to document that live inline-player layouts use the unified current-program/date toolbar. + +## Test Plan + +- Run unit tests: + - `pnpm nx test ui-epg` + - `pnpm nx test shared-portals` + - `pnpm nx test portal-xtream-feature` + - `pnpm nx test portal-stalker-feature` + - `pnpm nx test web` +- Run build/type validation through Nx: + - `pnpm nx build web --configuration=development` +- Use the running Electron app via CDP `127.0.0.1:9222` with `agent-browser`: + - Verify the IPTVnator page target is selected. + - Confirm the old `app-epg-list #channel-header` date row is absent when wrapped by `app-live-epg-panel`. + - Confirm the program list starts immediately below the unified toolbar. + - Click previous/next day in the unified toolbar and verify visible EPG programs change. + - Collapse the panel and verify the slim strip remains, body is inert, and the date chip stays visible. + - Capture a screenshot for visual confirmation. +- If docs changed and `IPTVNATOR_WIKI_VAULT` is configured, run `pnpm wiki:export --mode changed`. + +## Assumptions + +- Keep external MPV/VLC and non-inline layouts on the existing full EPG list behavior. +- Use existing translation keys for current program, loading, no program info, previous day, and next day. +- Leave unrelated dirty worktree changes untouched. diff --git a/apps/electron-backend-e2e/src/xtream-epg.e2e.ts b/apps/electron-backend-e2e/src/xtream-epg.e2e.ts index 59876cc51..25b6f139d 100644 --- a/apps/electron-backend-e2e/src/xtream-epg.e2e.ts +++ b/apps/electron-backend-e2e/src/xtream-epg.e2e.ts @@ -31,7 +31,9 @@ for (const timeZone of ['UTC', 'Europe/Berlin'] as const) { const fixture = await fetchXtreamEpgFixture(request, epgCredentials); const currentProgram = fixture.shortEpg[0]; if (!currentProgram) { - throw new Error('Expected the Xtream EPG fixture to include a current program.'); + throw new Error( + 'Expected the Xtream EPG fixture to include a current program.' + ); } const app = await launchElectronApp(dataDir, { env: { TZ: timeZone }, @@ -45,7 +47,10 @@ for (const timeZone of ['UTC', 'Europe/Berlin'] as const) { }); await waitForXtreamWorkspaceReady(app.mainWindow); await openWorkspaceSection(app.mainWindow, 'Live TV'); - await clickCategoryByNameExact(app.mainWindow, fixture.categoryName); + await clickCategoryByNameExact( + app.mainWindow, + fixture.categoryName + ); const channelRow = channelItemByTitle( app.mainWindow, @@ -55,14 +60,18 @@ for (const timeZone of ['UTC', 'Europe/Berlin'] as const) { await expect .poll(async () => - ((await channelRow.locator('.epg-title').textContent()) ?? '').trim() + ( + (await channelRow + .locator('.epg-title') + .textContent()) ?? '' + ).trim() ) .toBe(currentProgram.title); await expect .poll(async () => { - return (await channelRow.locator('.epg-time').allInnerTexts()).map( - (value) => value.trim() - ); + return ( + await channelRow.locator('.epg-time').allInnerTexts() + ).map((value) => value.trim()); }) .toEqual([ formatTimeInZone(currentProgram.startTimestamp, timeZone), @@ -84,17 +93,17 @@ for (const timeZone of ['UTC', 'Europe/Berlin'] as const) { timeZone ); - await expect.poll(() => visibleProgramTitles(app.mainWindow)).toEqual( - currentDayTitles - ); + await expect + .poll(() => visibleProgramTitles(app.mainWindow)) + .toEqual(currentDayTitles); const currentProgramRow = app.mainWindow .locator('app-epg-list .program-item.current-program') .first(); await expect(currentProgramRow).toBeVisible(); - await expect(currentProgramRow.locator('.program-title')).toHaveText( - currentProgram.title - ); + await expect( + currentProgramRow.locator('.program-title') + ).toHaveText(currentProgram.title); await expect(currentProgramRow.locator('.time')).toHaveText( `${formatTimeInZone( currentProgram.startTimestamp, @@ -114,25 +123,29 @@ for (const timeZone of ['UTC', 'Europe/Berlin'] as const) { firstFutureDateKey!, timeZone ); - const nextDayClicks = dayDifference(currentDateKey, firstFutureDateKey!); + const nextDayClicks = dayDifference( + currentDateKey, + firstFutureDateKey! + ); for (let index = 0; index < nextDayClicks; index += 1) { - await app.mainWindow.locator('app-epg-list .next-day').click(); + await app.mainWindow + .locator( + 'app-live-epg-panel .next-day, app-epg-list .next-day' + ) + .click(); } - await expect.poll(() => visibleProgramTitles(app.mainWindow)).toEqual( - futureDayTitles - ); + await expect + .poll(() => visibleProgramTitles(app.mainWindow)) + .toEqual(futureDayTitles); } finally { await closeElectronApp(app); } }); } -function formatTimeInZone( - timestampSeconds: number, - timeZone: string -): string { +function formatTimeInZone(timestampSeconds: number, timeZone: string): string { return new Intl.DateTimeFormat('en-US', { timeZone, hour: '2-digit', @@ -157,7 +170,9 @@ function formatDateKeyInZone( const day = parts.find((part) => part.type === 'day')?.value; if (!year || !month || !day) { - throw new Error(`Failed to format a date key for timezone ${timeZone}.`); + throw new Error( + `Failed to format a date key for timezone ${timeZone}.` + ); } return `${year}-${month}-${day}`; @@ -215,14 +230,18 @@ function dayDifference(fromDateKey: string, toDateKey: string): number { return Math.round((to - from) / (24 * 60 * 60 * 1000)); } -async function visibleProgramTitles(page: Parameters[0]) { +async function visibleProgramTitles( + page: Parameters[0] +) { return page .locator('app-epg-list .program-title') .allInnerTexts() .then((titles) => titles.map((title) => title.trim()).filter(Boolean)); } -async function getProgressWidthPercent(row: ReturnType) { +async function getProgressWidthPercent( + row: ReturnType +) { const style = await row.locator('.epg-progress-fill').getAttribute('style'); const width = style?.match(/width:\s*([\d.]+)%/)?.[1] ?? '0'; return Number.parseFloat(width); diff --git a/apps/web-e2e/src/stalker.e2e.ts b/apps/web-e2e/src/stalker.e2e.ts index 57e6e543f..7747bb1b7 100644 --- a/apps/web-e2e/src/stalker.e2e.ts +++ b/apps/web-e2e/src/stalker.e2e.ts @@ -201,9 +201,11 @@ test('@stalker EPG data loads for ITV channel', async ({ page }) => { await expect(page.locator('app-epg-list')).toBeVisible({ timeout: 20_000, }); - await expect(page.locator('app-epg-list .selected-date')).toBeVisible({ - timeout: 20_000, - }); + await expect( + page.locator( + 'app-live-epg-panel .selected-date, app-epg-list .selected-date' + ) + ).toBeVisible({ timeout: 20_000 }); }); test('@stalker bulk EPG is fetched once and reused across channel switches', async ({ diff --git a/docs/architecture/iptvnator-ui-guidelines.md b/docs/architecture/iptvnator-ui-guidelines.md index 1bedd4071..da6637338 100644 --- a/docs/architecture/iptvnator-ui-guidelines.md +++ b/docs/architecture/iptvnator-ui-guidelines.md @@ -130,10 +130,13 @@ The shared row should be reused instead of rebuilding channel markup per view. - Live TV layouts with an internal player use `app-live-epg-panel` around the EPG content. +- The live panel toolbar owns the current-program summary and live date + navigation together, so `app-epg-list` hides its internal date navigator when + projected inside that panel. - Collapsed state is shared across M3U, Xtream, and Stalker with `live-epg-panel-state`; missing or invalid values restore to expanded. - The collapsed panel is a slim current-program strip with a trailing progress - line and an expand button. + line, an expand button, and a compact date chip. - Do not render the collapsed strip for external MPV/VLC playback; those layouts keep the full EPG-only panel. - Keep the EPG content mounted while collapsed so current-program state can diff --git a/libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.html b/libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.html index e330aa962..3dfbf95f7 100644 --- a/libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.html +++ b/libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.html @@ -91,15 +91,23 @@
diff --git a/libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.spec.ts b/libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.spec.ts index 6787ce449..8c5b897e2 100644 --- a/libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.spec.ts +++ b/libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.spec.ts @@ -45,7 +45,10 @@ class StubLiveEpgPanelComponent { readonly collapsed = input(false); readonly summary = input(null); readonly loading = input(false); + readonly showDateNavigator = input(false); + readonly selectedDate = input(null); readonly collapsedChange = output(); + readonly dateNavigation = output<'next' | 'prev'>(); } describe('VideoPlayerComponent', () => { diff --git a/libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.ts b/libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.ts index 0b154baf0..fb3b3c035 100644 --- a/libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.ts +++ b/libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.ts @@ -22,8 +22,11 @@ import { isM3uCatchupPlaybackSupported } from 'm3u-utils'; import { PlaylistContextFacade } from '@iptvnator/playlist/shared/util'; import { COMPONENT_OVERLAY_REF, + EpgDateNavigationDirection, EpgListComponent, + getTodayEpgDateKey, MultiEpgContainerComponent, + shiftEpgDateKey, } from '@iptvnator/ui/epg'; import { ChannelActions, @@ -158,6 +161,7 @@ export class VideoPlayerComponent implements OnInit, OnDestroy { readonly liveEpgPanelState = signal( restoreLiveEpgPanelState() ); + readonly selectedLiveEpgDate = signal(getTodayEpgDateKey()); readonly isLiveEpgPanelCollapsed = computed( () => this.liveEpgPanelState() === 'collapsed' ); @@ -444,6 +448,16 @@ export class VideoPlayerComponent implements OnInit, OnDestroy { persistLiveEpgPanelState(state); } + onLiveEpgDateNavigation(direction: EpgDateNavigationDirection): void { + this.selectedLiveEpgDate.set( + shiftEpgDateKey(this.selectedLiveEpgDate(), direction) + ); + } + + onLiveEpgSelectedDateChange(selectedDate: string): void { + this.selectedLiveEpgDate.set(selectedDate); + } + /** * Opens a playlist provided as a url param * e.g. iptvnat.or?url=http://... diff --git a/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-stream-layout.component.html b/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-stream-layout.component.html index a2cb22c2e..e1732252a 100644 --- a/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-stream-layout.component.html +++ b/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-stream-layout.component.html @@ -94,12 +94,25 @@ [collapsed]="isLiveEpgPanelCollapsed()" [summary]="liveEpgPanelSummary()" [loading]="isLoadingEpg()" + [showDateNavigator]="true" + [selectedDate]="selectedLiveEpgDate()" (collapsedChange)="onLiveEpgPanelCollapsedChange($event)" + (dateNavigation)="onLiveEpgDateNavigation($event)" > - + } @else { - + }
} @else { @@ -110,7 +123,7 @@ }
- +
@if (isLoadingEpg()) {
@@ -121,6 +134,13 @@ [controlledChannel]="controlledChannel()" [controlledPrograms]="activeEpgPrograms()" [controlledArchiveDays]="0" + [selectedDate]=" + unifiedDateNavigator ? selectedLiveEpgDate() : null + " + [showDateNavigator]="!unifiedDateNavigator" + (selectedDateChange)=" + unifiedDateNavigator && onLiveEpgSelectedDateChange($event) + " /> }
diff --git a/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-stream-layout.component.spec.ts b/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-stream-layout.component.spec.ts index e2aa8bbe7..4f29d769d 100644 --- a/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-stream-layout.component.spec.ts +++ b/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-stream-layout.component.spec.ts @@ -60,6 +60,9 @@ class StubEpgListComponent { readonly controlledPrograms = input(null); readonly controlledArchiveDays = input(null); readonly archivePlaybackAvailable = input(null); + readonly selectedDate = input(null); + readonly showDateNavigator = input(true); + readonly selectedDateChange = output(); } @Component({ @@ -74,7 +77,10 @@ class StubLiveEpgPanelComponent { readonly collapsed = input(false); readonly summary = input(null); readonly loading = input(false); + readonly showDateNavigator = input(false); + readonly selectedDate = input(null); readonly collapsedChange = output(); + readonly dateNavigation = output<'next' | 'prev'>(); } @Component({ diff --git a/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-stream-layout.component.ts b/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-stream-layout.component.ts index 6e509bb7c..4fc195897 100644 --- a/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-stream-layout.component.ts +++ b/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-stream-layout.component.ts @@ -24,7 +24,12 @@ import { EpgProgram, ResolvedPortalPlayback, } from 'shared-interfaces'; -import { EpgListComponent } from '@iptvnator/ui/epg'; +import { + EpgDateNavigationDirection, + EpgListComponent, + getTodayEpgDateKey, + shiftEpgDateKey, +} from '@iptvnator/ui/epg'; import { LiveEpgPanelComponent, LiveEpgPanelSummary, @@ -120,6 +125,7 @@ export class StalkerLiveStreamLayoutComponent implements OnDestroy { readonly liveEpgPanelState = signal( restoreLiveEpgPanelState() ); + readonly selectedLiveEpgDate = signal(getTodayEpgDateKey()); readonly isLiveEpgPanelCollapsed = computed( () => this.liveEpgPanelState() === 'collapsed' ); @@ -363,6 +369,16 @@ export class StalkerLiveStreamLayoutComponent implements OnDestroy { persistLiveEpgPanelState(state); } + onLiveEpgDateNavigation(direction: EpgDateNavigationDirection): void { + this.selectedLiveEpgDate.set( + shiftEpgDateKey(this.selectedLiveEpgDate(), direction) + ); + } + + onLiveEpgSelectedDateChange(selectedDate: string): void { + this.selectedLiveEpgDate.set(selectedDate); + } + private async loadEpgForChannel(item: StalkerItvChannel) { const requestId = ++this.epgLoadRequestId; const normalizedChannelId = normalizeStalkerEntityId(item.id); diff --git a/libs/portal/xtream/feature/src/lib/live-stream-layout/live-stream-layout.component.html b/libs/portal/xtream/feature/src/lib/live-stream-layout/live-stream-layout.component.html index b4db74d23..3497dfc3b 100644 --- a/libs/portal/xtream/feature/src/lib/live-stream-layout/live-stream-layout.component.html +++ b/libs/portal/xtream/feature/src/lib/live-stream-layout/live-stream-layout.component.html @@ -89,12 +89,25 @@ [collapsed]="isLiveEpgPanelCollapsed()" [summary]="liveEpgPanelSummary()" [loading]="isLoadingEpg()" + [showDateNavigator]="true" + [selectedDate]="selectedLiveEpgDate()" (collapsedChange)="onLiveEpgPanelCollapsedChange($event)" + (dateNavigation)="onLiveEpgDateNavigation($event)" > - + } @else { - + }
} @else { @@ -105,7 +118,7 @@ }
- +
@if (isLoadingEpg()) {
@@ -131,7 +144,14 @@ [controlledPrograms]="controlledEpgPrograms()" [controlledArchiveDays]="controlledArchiveDays()" [archivePlaybackAvailable]="archivePlaybackAvailable()" + [selectedDate]=" + unifiedDateNavigator ? selectedLiveEpgDate() : null + " + [showDateNavigator]="!unifiedDateNavigator" (programActivated)="onProgramActivated($event)" + (selectedDateChange)=" + unifiedDateNavigator && onLiveEpgSelectedDateChange($event) + " /> } @else if (!isElectron && epgItems().length > 0) { diff --git a/libs/portal/xtream/feature/src/lib/live-stream-layout/live-stream-layout.component.spec.ts b/libs/portal/xtream/feature/src/lib/live-stream-layout/live-stream-layout.component.spec.ts index b1922d72c..4112ab276 100644 --- a/libs/portal/xtream/feature/src/lib/live-stream-layout/live-stream-layout.component.spec.ts +++ b/libs/portal/xtream/feature/src/lib/live-stream-layout/live-stream-layout.component.spec.ts @@ -66,7 +66,10 @@ class StubEpgListComponent { readonly controlledPrograms = input(null); readonly controlledArchiveDays = input(null); readonly archivePlaybackAvailable = input(null); + readonly selectedDate = input(null); + readonly showDateNavigator = input(true); readonly programActivated = output(); + readonly selectedDateChange = output(); } @Component({ @@ -81,7 +84,10 @@ class StubLiveEpgPanelComponent { readonly collapsed = input(false); readonly summary = input(null); readonly loading = input(false); + readonly showDateNavigator = input(false); + readonly selectedDate = input(null); readonly collapsedChange = output(); + readonly dateNavigation = output<'next' | 'prev'>(); } @Directive({ diff --git a/libs/portal/xtream/feature/src/lib/live-stream-layout/live-stream-layout.component.ts b/libs/portal/xtream/feature/src/lib/live-stream-layout/live-stream-layout.component.ts index ffce599f3..dee7d6815 100644 --- a/libs/portal/xtream/feature/src/lib/live-stream-layout/live-stream-layout.component.ts +++ b/libs/portal/xtream/feature/src/lib/live-stream-layout/live-stream-layout.component.ts @@ -36,7 +36,13 @@ import { XtreamUrlService, XtreamStore, } from '@iptvnator/portal/xtream/data-access'; -import { EpgListComponent, EpgProgramActivationEvent } from '@iptvnator/ui/epg'; +import { + EpgDateNavigationDirection, + EpgListComponent, + EpgProgramActivationEvent, + getTodayEpgDateKey, + shiftEpgDateKey, +} from '@iptvnator/ui/epg'; import { MatProgressSpinnerModule } from '@angular/material/progress-spinner'; import { EpgViewComponent, @@ -156,6 +162,7 @@ export class LiveStreamLayoutComponent implements OnInit, OnDestroy { readonly liveEpgPanelState = signal( restoreLiveEpgPanelState() ); + readonly selectedLiveEpgDate = signal(getTodayEpgDateKey()); readonly isLiveEpgPanelCollapsed = computed( () => this.liveEpgPanelState() === 'collapsed' ); @@ -347,6 +354,16 @@ export class LiveStreamLayoutComponent implements OnInit, OnDestroy { persistLiveEpgPanelState(state); } + onLiveEpgDateNavigation(direction: EpgDateNavigationDirection): void { + this.selectedLiveEpgDate.set( + shiftEpgDateKey(this.selectedLiveEpgDate(), direction) + ); + } + + onLiveEpgSelectedDateChange(selectedDate: string): void { + this.selectedLiveEpgDate.set(selectedDate); + } + ngOnDestroy(): void { this.unsubscribeRemoteChannelChange?.(); this.unsubscribeRemoteCommand?.(); diff --git a/libs/ui/epg/src/index.ts b/libs/ui/epg/src/index.ts index 1020292cc..aa9f3a7b7 100644 --- a/libs/ui/epg/src/index.ts +++ b/libs/ui/epg/src/index.ts @@ -1,5 +1,6 @@ export * from './lib/epg-list/epg-list.component'; export * from './lib/epg-list/epg-item-description/epg-item-description.component'; +export * from './lib/epg-date'; export * from './lib/epg-progress-panel/epg-progress-panel.component'; export * from './lib/epg-source-status/epg-source-status.component'; export * from './lib/multi-epg/multi-epg-container.component'; diff --git a/libs/ui/epg/src/lib/epg-date.ts b/libs/ui/epg/src/lib/epg-date.ts new file mode 100644 index 000000000..c984f94be --- /dev/null +++ b/libs/ui/epg/src/lib/epg-date.ts @@ -0,0 +1,39 @@ +import { addDays, format, subDays } from 'date-fns'; + +export const EPG_DATE_KEY_FORMAT = 'yyyy-MM-dd'; + +export type EpgDateNavigationDirection = 'next' | 'prev'; + +export function getTodayEpgDateKey(): string { + return format(new Date(), EPG_DATE_KEY_FORMAT); +} + +export function parseEpgDateKey( + dateKey: string | null | undefined, + fallbackDateKey = getTodayEpgDateKey() +): Date { + const normalizedDateKey = + typeof dateKey === 'string' && dateKey.trim().length > 0 + ? dateKey + : fallbackDateKey; + const parsedDate = new Date(`${normalizedDateKey}T00:00:00`); + + if (Number.isFinite(parsedDate.getTime())) { + return parsedDate; + } + + return new Date(`${fallbackDateKey}T00:00:00`); +} + +export function shiftEpgDateKey( + dateKey: string | null | undefined, + direction: EpgDateNavigationDirection +): string { + const selectedDate = parseEpgDateKey(dateKey); + return format( + direction === 'next' + ? addDays(selectedDate, 1) + : subDays(selectedDate, 1), + EPG_DATE_KEY_FORMAT + ); +} diff --git a/libs/ui/epg/src/lib/epg-list/epg-list.component.html b/libs/ui/epg/src/lib/epg-list/epg-list.component.html index 1b27b847a..00d6cee08 100644 --- a/libs/ui/epg/src/lib/epg-list/epg-list.component.html +++ b/libs/ui/epg/src/lib/epg-list/epg-list.component.html @@ -1,33 +1,35 @@ @let items = filteredItems(); @let timeshiftLimit = timeshiftUntil(); - -
-
- - - {{ - selectedDate() - | date: 'MMMM d, EEEE' : undefined : currentLocale() - }} - +@if (showDateNavigator()) { + +
+
+ + + {{ + selectedDateKey() + | date: 'MMMM d, EEEE' : undefined : currentLocale() + }} + +
-
+} @if (timeshiftLimit) { diff --git a/libs/ui/epg/src/lib/epg-list/epg-list.component.spec.ts b/libs/ui/epg/src/lib/epg-list/epg-list.component.spec.ts index 801d758b7..5c2c1d71c 100644 --- a/libs/ui/epg/src/lib/epg-list/epg-list.component.spec.ts +++ b/libs/ui/epg/src/lib/epg-list/epg-list.component.spec.ts @@ -243,6 +243,34 @@ describe('EpgListComponent', () => { .textContent.trim() ).toContain('Sonntag'); }); + + it('can hide its internal date navigator for a parent-owned toolbar', () => { + fixture.componentRef.setInput('controlledPrograms', buildPrograms()); + fixture.componentRef.setInput('showDateNavigator', false); + fixture.detectChanges(); + + expect( + fixture.nativeElement.querySelector('#channel-header') + ).toBeNull(); + expect( + fixture.nativeElement.querySelector('#program-list') + ).not.toBeNull(); + }); + + it('emits date changes when the selected date is controlled', () => { + const emittedDates: string[] = []; + component.selectedDateChange.subscribe((date) => + emittedDates.push(date) + ); + fixture.componentRef.setInput('selectedDate', '2026-04-04'); + fixture.componentRef.setInput('controlledPrograms', buildPrograms()); + fixture.detectChanges(); + + component.changeDate('next'); + + expect(emittedDates).toEqual(['2026-04-05']); + expect(component.selectedDateKey()).toBe('2026-04-04'); + }); }); function buildPrograms(overrides?: { @@ -253,11 +281,9 @@ function buildPrograms(overrides?: { }): EpgProgram[] { const pastStart = '2026-04-05T09:00:00.000Z'; const pastStop = '2026-04-05T10:00:00.000Z'; - const currentStart = - overrides?.currentStart ?? '2026-04-05T11:30:00.000Z'; + const currentStart = overrides?.currentStart ?? '2026-04-05T11:30:00.000Z'; const currentStop = overrides?.currentStop ?? '2026-04-05T12:30:00.000Z'; - const futureStart = - overrides?.futureStart ?? '2026-04-05T12:30:00.000Z'; + const futureStart = overrides?.futureStart ?? '2026-04-05T12:30:00.000Z'; const futureStop = overrides?.futureStop ?? '2026-04-05T13:30:00.000Z'; return [ diff --git a/libs/ui/epg/src/lib/epg-list/epg-list.component.ts b/libs/ui/epg/src/lib/epg-list/epg-list.component.ts index 0979fb2a4..b92072fbf 100644 --- a/libs/ui/epg/src/lib/epg-list/epg-list.component.ts +++ b/libs/ui/epg/src/lib/epg-list/epg-list.component.ts @@ -20,13 +20,17 @@ import { normalizeDateLocale } from '@iptvnator/pipes'; import { Store } from '@ngrx/store'; import { TranslatePipe, TranslateService } from '@ngx-translate/core'; import { EpgActions, selectActive } from 'm3u-state'; -import { addDays, format, subDays } from 'date-fns'; +import { format, subDays } from 'date-fns'; import { startWith } from 'rxjs'; import { Channel, EpgChannel, EpgProgram } from 'shared-interfaces'; +import { + EPG_DATE_KEY_FORMAT, + EpgDateNavigationDirection, + getTodayEpgDateKey, + shiftEpgDateKey, +} from '../epg-date'; import { EpgListItemComponent } from './epg-list-item/epg-list-item.component'; -const DATE_FORMAT = 'yyyy-MM-dd'; - export interface EpgProgramActivationEvent { program: EpgProgram; type: 'live' | 'timeshift'; @@ -51,7 +55,12 @@ export class EpgListComponent { readonly controlledPrograms = input(null); readonly controlledArchiveDays = input(null); readonly archivePlaybackAvailable = input(null); + readonly selectedDateInput = input(null, { + alias: 'selectedDate', + }); + readonly showDateNavigator = input(true); readonly programActivated = output(); + readonly selectedDateChange = output(); private readonly store = inject(Store); private readonly epgService = inject(EpgService); @@ -70,7 +79,10 @@ export class EpgListComponent { ); readonly programList = viewChild>('programList'); - readonly selectedDate = signal(format(new Date(), DATE_FORMAT)); + readonly internalSelectedDate = signal(getTodayEpgDateKey()); + readonly selectedDateKey = computed( + () => this.selectedDateInput() ?? this.internalSelectedDate() + ); readonly timeNow = signal(new Date().toISOString()); readonly currentTimeMs = computed(() => Date.parse(this.timeNow())); readonly currentLocale = computed(() => { @@ -85,8 +97,8 @@ export class EpgListComponent { this.controlledChannel() !== null || this.controlledPrograms() !== null ); - readonly displayChannel = computed( - () => this.controlledChannel() ?? this.activeChannel() + readonly displayChannel = computed( + () => this.controlledChannel() ?? this.activeChannel() ?? null ); readonly channel = computed(() => { const channel = this.displayChannel(); @@ -96,7 +108,9 @@ export class EpgListComponent { return { id: channel.tvg?.id || '', - displayName: channel.name ? [{ lang: '', value: channel.name }] : [], + displayName: channel.name + ? [{ lang: '', value: channel.name }] + : [], url: channel.url ? [channel.url] : [], icon: channel.tvg?.logo ? [{ src: channel.tvg.logo }] : [], }; @@ -119,14 +133,14 @@ export class EpgListComponent { subDays(new Date(), this.archiveDays()).toISOString() ); readonly archivePlaybackEnabled = computed( - () => this.archivePlaybackAvailable() ?? (this.archiveDays() > 0) + () => this.archivePlaybackAvailable() ?? this.archiveDays() > 0 ); readonly filteredItems = computed(() => [...this.items()] .filter( (item) => getProgramDateKey(item.start, item.startTimestamp) === - this.selectedDate() + this.selectedDateKey() ) .sort( (left, right) => @@ -185,16 +199,14 @@ export class EpgListComponent { }); } - changeDate(direction: 'next' | 'prev'): void { - const selectedDate = new Date(`${this.selectedDate()}T00:00:00`); - this.selectedDate.set( - format( - direction === 'next' - ? addDays(selectedDate, 1) - : subDays(selectedDate, 1), - DATE_FORMAT - ) - ); + changeDate(direction: EpgDateNavigationDirection): void { + const nextDate = shiftEpgDateKey(this.selectedDateKey(), direction); + + if (this.selectedDateInput() == null) { + this.internalSelectedDate.set(nextDate); + } + + this.selectedDateChange.emit(nextDate); } activateProgram(program: EpgProgram): void { @@ -225,7 +237,8 @@ export class EpgListComponent { canActivateProgram(program: EpgProgram): boolean { return ( - this.isProgramPlaying(program) || this.canPlayArchivedProgram(program) + this.isProgramPlaying(program) || + this.canPlayArchivedProgram(program) ); } @@ -240,7 +253,9 @@ export class EpgListComponent { const total = stop - start; const elapsed = now - start; - return total > 0 ? Math.min(100, Math.max(0, (elapsed / total) * 100)) : 0; + return total > 0 + ? Math.min(100, Math.max(0, (elapsed / total) * 100)) + : 0; } isProgramPlaying(program: EpgProgram): boolean { @@ -289,7 +304,7 @@ export class EpgListComponent { } private scheduleScrollToCurrentProgram(): void { - if (this.selectedDate() !== format(new Date(), DATE_FORMAT)) { + if (this.selectedDateKey() !== getTodayEpgDateKey()) { return; } @@ -385,5 +400,5 @@ function getProgramDateKey( return ''; } - return format(new Date(programTimeMs), DATE_FORMAT); + return format(new Date(programTimeMs), EPG_DATE_KEY_FORMAT); } diff --git a/libs/ui/shared-portals/src/lib/live-epg-panel/live-epg-panel.component.html b/libs/ui/shared-portals/src/lib/live-epg-panel/live-epg-panel.component.html index d2dfec81d..98bf21751 100644 --- a/libs/ui/shared-portals/src/lib/live-epg-panel/live-epg-panel.component.html +++ b/libs/ui/shared-portals/src/lib/live-epg-panel/live-epg-panel.component.html @@ -25,37 +25,114 @@ {{ 'EPG.CURRENT_PROGRAM' | translate }} - @if (loading()) { - - {{ 'EPG.LOADING' | translate }} - - } @else if (hasSummary()) { - - {{ summary()?.title }} - +
+ @if (loading()) { + + {{ 'EPG.LOADING' | translate }} + + } @else if (hasSummary()) { + + {{ summary()?.title }} + - @if (hasTimeRange()) { - - @if (summary()?.start) { - {{ summary()?.start | date: 'HH:mm' }} - } - @if (summary()?.start && summary()?.stop) { - - - } - @if (summary()?.stop) { - {{ summary()?.stop | date: 'HH:mm' }} - } + @if (hasTimeRange()) { + + @if (summary()?.start) { + {{ summary()?.start | date: 'HH:mm' }} + } + @if (summary()?.start && summary()?.stop) { + - + } + @if (summary()?.stop) { + {{ summary()?.stop | date: 'HH:mm' }} + } + + } + } @else { + + {{ 'EPG.NO_PROGRAM_INFO' | translate }} } - } @else { - - {{ 'EPG.NO_PROGRAM_INFO' | translate }} - - } +
+ @if (showDateNavigator()) { + @if (!collapsed()) { +
+ + + {{ + selectedDateValue() + | date + : 'MMMM d, EEEE' + : undefined + : currentLocale() + }} + + +
+ + + + + + + + } + } + @if (progress() !== null) {