diff --git a/.claude/settings.json b/.claude/settings.json index 37401095c..0967ef424 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -1,84 +1 @@ -{ - "hooks": { - "SessionStart": [ - { - "matcher": "", - "hooks": [ - { - "type": "command", - "command": "entire hooks claude-code session-start" - } - ] - } - ], - "SessionEnd": [ - { - "matcher": "", - "hooks": [ - { - "type": "command", - "command": "entire hooks claude-code session-end" - } - ] - } - ], - "UserPromptSubmit": [ - { - "matcher": "", - "hooks": [ - { - "type": "command", - "command": "entire hooks claude-code user-prompt-submit" - } - ] - } - ], - "Stop": [ - { - "matcher": "", - "hooks": [ - { - "type": "command", - "command": "entire hooks claude-code stop" - } - ] - } - ], - "PreToolUse": [ - { - "matcher": "Task", - "hooks": [ - { - "type": "command", - "command": "entire hooks claude-code pre-task" - } - ] - } - ], - "PostToolUse": [ - { - "matcher": "Task", - "hooks": [ - { - "type": "command", - "command": "entire hooks claude-code post-task" - } - ] - }, - { - "matcher": "TodoWrite", - "hooks": [ - { - "type": "command", - "command": "entire hooks claude-code post-todo" - } - ] - } - ] - }, - "permissions": { - "deny": [ - "Read(./.entire/metadata/**)" - ] - } -} +{} diff --git a/.github/workflows/build-and-make.yaml b/.github/workflows/build-and-make.yaml index 866637e94..0e2d636d4 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,9 +88,47 @@ jobs: - name: Build frontend run: pnpm nx build web --skip-nx-cache + - name: Build embedded MPV runtime (macOS release) + # TEMPORARY ARTIFACT TEST: remove `|| github.event_name == 'pull_request' || github.ref == 'refs/heads/master'` + # after the macOS Embedded MPV artifacts are built and manually tested. + if: matrix.os == 'macos' && (startsWith(github.ref, 'refs/tags/v') || github.event_name == 'pull_request' || github.ref == 'refs/heads/master') + 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 || '' }} + IPTVNATOR_REQUIRE_EMBEDDED_MPV: ${{ matrix.os == 'macos' && (startsWith(github.ref, 'refs/tags/v') || github.event_name == 'pull_request' || github.ref == 'refs/heads/master') && '1' || '0' }} run: pnpm run build:backend + - name: Verify embedded MPV build output + # TEMPORARY ARTIFACT TEST: remove `|| github.event_name == 'pull_request' || github.ref == 'refs/heads/master'` + # after the macOS Embedded MPV artifacts are built and manually tested. + if: matrix.os == 'macos' && (startsWith(github.ref, 'refs/tags/v') || github.event_name == 'pull_request' || github.ref == 'refs/heads/master') + 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 @@ -210,6 +248,10 @@ jobs: CSC_LINK: ${{ secrets.CSC_LINK }} CSC_KEY_PASSWORD: ${{ secrets.CSC_KEY_PASSWORD }} DEBUG: electron-builder,electron-notarize* + IPTVNATOR_EMBEDDED_MPV_ARCH: ${{ matrix.arch }} + # TEMPORARY MASTER ARTIFACT TEST: remove `|| github.ref == 'refs/heads/master'` + # after the macOS Embedded MPV artifacts are built and manually tested. + IPTVNATOR_REQUIRE_EMBEDDED_MPV: ${{ (startsWith(github.ref, 'refs/tags/v') || github.ref == 'refs/heads/master') && '1' || '0' }} run: pnpm run make:app - name: Verify signed macOS app @@ -263,6 +305,11 @@ jobs: - name: Make Electron app if: matrix.os != 'macos' || github.event_name == 'pull_request' + env: + IPTVNATOR_EMBEDDED_MPV_ARCH: ${{ matrix.arch || '' }} + # 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 @@ -270,6 +317,9 @@ jobs: env: PACKAGE_OS: ${{ matrix.os }} PACKAGE_ARCH: ${{ matrix.arch || '' }} + # TEMPORARY ARTIFACT TEST: remove `|| github.event_name == 'pull_request' || github.ref == 'refs/heads/master'` + # after the macOS Embedded MPV artifacts are built and manually tested. + IPTVNATOR_REQUIRE_EMBEDDED_MPV: ${{ matrix.os == 'macos' && (startsWith(github.ref, 'refs/tags/v') || github.event_name == 'pull_request' || github.ref == 'refs/heads/master') && '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 cf16152ae..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,4 +68,16 @@ testem.log Thumbs.db .angular .astro -.claude/settings.local.json + +.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/.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/.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/.plans/2026-05-01-xtream-type-search.md b/.plans/2026-05-01-xtream-type-search.md new file mode 100644 index 000000000..9a2c7181a --- /dev/null +++ b/.plans/2026-05-01-xtream-type-search.md @@ -0,0 +1,35 @@ +# Fix Xtream Type-Level VOD/Series Search + +## Summary + +- Root cause: `withSelection()` receives the `q` query through the Xtream catalog facade, but `filteredAndSortedContent` returns `sortedContent()` immediately when `selectedCategoryId` is null, so `categorySearchTerm` is ignored on all-items VOD/series routes. +- Fix the store selector so VOD and series search terms filter the current type even when no category is selected. +- No public API, route, schema, or Electron IPC contract changes are needed. + +## Key Changes + +- Update `libs/portal/xtream/data-access/src/lib/stores/features/with-selection.feature.ts` so: + - `vod` and `series` apply `filterBySearchTerm` whether `selectedCategoryId` is null or set. + - Existing selected-category behavior, sorting, pagination reset, and `live` behavior stay unchanged. + - Empty search terms still return the full sorted current type. +- Update `libs/portal/xtream/data-access/src/lib/stores/features/with-selection.feature.spec.ts` with regression coverage for: + - VOD all-items search with `selectedCategoryId === null`. + - Series all-items search with `selectedCategoryId === null`, ideally across multiple categories. + - Existing category-scoped search remains covered and unchanged. + +## Test Plan + +- Run `pnpm nx test portal-xtream-data-access`. +- Run `pnpm nx test portal-xtream-feature` to confirm the shared catalog route/facade integration still passes. +- Run `pnpm nx build web` as the Angular/Nx build validation. +- Manual Electron check: + - Start `pnpm run serve:backend`. + - Load agent-browser guidance with `agent-browser skills get core` and `agent-browser skills get electron`. + - Attach using `agent-browser --cdp 9222 snapshot -i`. + - Verify an Xtream playlist can search from `/vod?q=...` and `/series?q=...` with no category selected, and still search inside a selected category. + +## Assumptions + +- Scope is limited to Xtream VOD/series catalog search, not the separate global search page. +- Documentation updates are not expected because this is a regression fix for existing behavior; reassess after implementation. +- Finalized plan file target: `.plans/2026-05-01-xtream-type-search.md`. diff --git a/.plans/2026-05-02-embedded-mpv-merge-conflicts.md b/.plans/2026-05-02-embedded-mpv-merge-conflicts.md new file mode 100644 index 000000000..bd6881e48 --- /dev/null +++ b/.plans/2026-05-02-embedded-mpv-merge-conflicts.md @@ -0,0 +1,54 @@ +# Resolve Embedded MPV PR Merge Conflicts + +## Summary + +- Update `feat/embedded-mpv-mac-os-experimental` by merging `origin/master` into the branch, not rebasing, so no force-push is needed. +- Resolve the four simulated conflicts while preserving both sides: + - master’s settings refactor, cover-size setting, live EPG panel work, favorite/recent UX changes + - branch’s macOS Embedded MPV support, player option gating, IPC typings, and package/runtime behavior +- Leave untracked `tools/wiki/` untouched. + +## Key Changes + +- Settings: keep master’s refactored section components/options and reapply Embedded MPV support in that shape: `EmbeddedMpvSupport` signal, async support probe in `ngOnInit`, and conditional `VideoPlayer.EmbeddedMpv` option before external MPV/VLC. +- Settings store/interfaces: combine `CoverSize`/`coverSize: 'medium'` with `VideoPlayer.EmbeddedMpv`, embedded MPV prepare/sanitize logic, and `isEmbeddedPlayer()` including Video.js, HTML5, ArtPlayer, and Embedded MPV. +- Live playback conflicts: keep master’s `LiveEpgPanel`, date navigation, loading state, and favorite/recent outputs; add Embedded MPV rendering through `WebPlayerViewComponent` with full `ResolvedPortalPlayback` metadata where needed. +- Electron typings/preload: keep master’s new EPG/current-program and recent-batch APIs while preserving all Embedded MPV IPC APIs and session update hooks. + +## Public Interfaces + +- Preserve existing branch additions: + - `VideoPlayer.EmbeddedMpv = 'embedded-mpv'` + - `EmbeddedMpvSupport`, `EmbeddedMpvSession`, `EmbeddedMpvBounds` + - `window.electron` Embedded MPV support/session methods +- Preserve master additions: + - `CoverSize = 'small' | 'medium' | 'large'` + - `Settings.coverSize` + - `getCurrentProgramsBatch` + - `dbRemoveRecentItemsBatch` + +## Test Plan + +- Static checks: + - `rg '<<<<<<<|=======|>>>>>>>'` + - `git diff --check` + - `pnpm run typecheck:ci` +- Unit tests: + - `pnpm run test:unit:all` +- E2E tests: + - `pnpm nx run web-e2e:e2e-ci` + - `pnpm nx run electron-backend-e2e:e2e-ci` +- Build/package confidence: + - `pnpm run build:frontend` + - `pnpm run build:backend` + - run local package-layout validation if a package artifact is produced; otherwise rely on the pushed GitHub Actions macOS/Linux/Windows package jobs. +- Docs/wiki: + - Verify `docs/architecture/embedded-mpv-native.md` still matches the merged packaging/runtime behavior. + - If repo docs changed and `IPTVNATOR_WIKI_VAULT` is set, run `pnpm wiki:export --mode changed`; otherwise report it as skipped. + +## Commit And Push + +- Stage only tracked merge-resolution files plus the saved `.plans/...` file; do not stage untracked `tools/wiki/`. +- Commit as `chore: resolve master conflicts for embedded mpv branch`. +- Push normally with `git push origin feat/embedded-mpv-mac-os-experimental`. +- Recheck PR #877 merge state and checks with `gh pr view --json mergeStateStatus,statusCheckRollup`; note that Vercel was already an external failure unrelated to these conflicts. diff --git a/AGENTS.md b/AGENTS.md index d261cde34..eb95e0745 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -24,6 +24,19 @@ This file provides guidance to coding agents working in this repository. - The wiki exporter only owns `_repo-context/` in the external vault. It must never overwrite repo docs or maintained wiki pages outside that folder. - Final task summaries should state whether docs were updated, which doc changed, and whether wiki export ran, was skipped, or failed. +## Regression Prevention And Test Updates + +- Before the final summary for any feature, behavior change, bug fix, data-flow change, Electron IPC/database change, or user-visible UI workflow change, complete a test impact pass. Identify the affected projects and decide whether unit, integration, E2E, build, lint, or manual/CDP verification is required. +- Bug fixes must normally include regression coverage that fails on the old behavior and passes with the fix. If automated coverage is not practical, document why in the final summary and include the strongest manual validation performed. +- Feature work and behavior changes must update existing tests when assertions, fixtures, mocks, routes, or E2E flows are now stale, incomplete, or missing. Prefer extending the closest existing spec or E2E file before adding a new suite. +- Default validation ladder: + 1. Run targeted unit tests for directly affected projects with `pnpm nx test ` or existing scripts such as `pnpm run test:frontend`, `pnpm run test:backend`, or `pnpm run test:unit:ci` when the scope is broader. + 2. Run affected E2E coverage when changing user-visible workflows, routing, persistence, playback, portals, settings, import flows, or Electron-only behavior. + 3. Use `pnpm nx show projects --withTarget test` and `pnpm nx show projects --withTarget e2e` when project ownership or available validation targets are unclear. + 4. Prefer specific atomized E2E targets before broad suites when they cover the changed behavior, for example `pnpm nx run web-e2e:e2e-ci--src/xtream.e2e.ts` or `pnpm nx run electron-backend-e2e:e2e-ci--src/search.e2e.ts`. +- Electron-specific changes affecting IPC, SQLite, packaged runtime, external players, native file access, or Electron-only routes require Electron E2E coverage where available, or CDP/manual verification with `agent-browser` and the tracing flags documented below. +- Final task summaries must list tests added or updated, validation commands run with results, and any skipped validation with the reason. For docs-only changes, state that unit/E2E validation was not required and verify the changed Markdown instead. + ## Electron Debugging (CDP) - Start the Electron development app with: `nx serve electron-backend` diff --git a/CLAUDE.md b/CLAUDE.md index e8f75d755..02daca064 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -24,6 +24,19 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co - The wiki exporter only owns `_repo-context/` in the external vault. It must never overwrite repo docs or maintained wiki pages outside that folder. - Final task summaries should state whether docs were updated, which doc changed, and whether wiki export ran, was skipped, or failed. +## Regression Prevention And Test Updates + +- Before the final summary for any feature, behavior change, bug fix, data-flow change, Electron IPC/database change, or user-visible UI workflow change, Claude Code must complete a test impact pass. Identify the affected projects and decide whether unit, integration, E2E, build, lint, or manual/CDP verification is required. +- Bug fixes must normally include regression coverage that fails on the old behavior and passes with the fix. If automated coverage is not practical, document why in the final summary and include the strongest manual validation performed. +- Feature work and behavior changes must update existing tests when assertions, fixtures, mocks, routes, or E2E flows are now stale, incomplete, or missing. Prefer extending the closest existing spec or E2E file before adding a new suite. +- Default validation ladder: + 1. Run targeted unit tests for directly affected projects with `pnpm nx test ` or existing scripts such as `pnpm run test:frontend`, `pnpm run test:backend`, or `pnpm run test:unit:ci` when the scope is broader. + 2. Run affected E2E coverage when changing user-visible workflows, routing, persistence, playback, portals, settings, import flows, or Electron-only behavior. + 3. Use `pnpm nx show projects --withTarget test` and `pnpm nx show projects --withTarget e2e` when project ownership or available validation targets are unclear. + 4. Prefer specific atomized E2E targets before broad suites when they cover the changed behavior, for example `pnpm nx run web-e2e:e2e-ci--src/xtream.e2e.ts` or `pnpm nx run electron-backend-e2e:e2e-ci--src/search.e2e.ts`. +- Electron-specific changes affecting IPC, SQLite, packaged runtime, external players, native file access, or Electron-only routes require Electron E2E coverage where available, or CDP/manual verification with `agent-browser` and the tracing flags documented below. +- Final task summaries must list tests added or updated, validation commands run with results, and any skipped validation with the reason. For docs-only changes, state that unit/E2E validation was not required and verify the changed Markdown instead. + ## Project Overview IPTVnator is a cross-platform IPTV player application built with Angular and Electron, supporting M3U/M3U8 playlists, Xtream Codes API, and Stalker portals. @@ -139,20 +152,27 @@ npx --yes agent-browser --cdp 9222 tab list # Run frontend tests pnpm run test:frontend # or -nx test web +pnpm nx test web # Run backend tests pnpm run test:backend # or -nx test electron-backend +pnpm nx test electron-backend -# Run e2e tests (Playwright) -nx e2e web-e2e +# Run targeted E2E tests (Playwright) +pnpm nx run web-e2e:e2e-ci--src/xtream.e2e.ts +pnpm nx run electron-backend-e2e:e2e-ci--src/search.e2e.ts -# Run tests with coverage -nx test web --configuration=ci +# Run broad E2E suites only when the impact justifies it +pnpm nx e2e web-e2e +pnpm nx e2e electron-backend-e2e + +# Run tests with coverage when needed +pnpm nx test web --configuration=ci ``` +Before finishing behavior changes or bug fixes, follow `Regression Prevention And Test Updates` above and report the test impact decision in the final summary. + ### Linting ```bash @@ -534,6 +554,7 @@ This project uses modern Angular signal-based APIs and patterns. **ALWAYS** use - Built-in HTML5 player with HLS.js or Video.js - External players: MPV, VLC (via IPC to Electron backend) +- Embedded MPV (experimental, macOS only): renders libmpv into a custom Cocoa view inside the Electron window. Because the standard `--wid` path is bypassed, mpv's own screensaver inhibition does not apply, so `EmbeddedMpvNativeService` holds an Electron `powerSaveBlocker` (`prevent-display-sleep`) whenever any session's status is `playing`, and releases it on pause, dispose, or shutdown. Service: `apps/electron-backend/src/app/services/embedded-mpv-native.service.ts`. **Radio Player**: @@ -608,23 +629,25 @@ The factory pattern ensures a single codebase works in both environments without ### Testing Strategy - **Unit tests**: Jest with `jest-preset-angular` and `ng-mocks` -- **E2E tests**: Playwright testing the web app +- **E2E tests**: Playwright testing the web app and Electron app - Backend tests use standard Jest +- Bug fixes should add focused regression coverage unless there is a documented reason not to. +- Use the impact-based validation policy in `Regression Prevention And Test Updates` to choose targeted unit tests, atomized E2E targets, broad suites, or CDP/manual verification. ### Nx Commands Use `nx` CLI for better performance: ```bash -nx run : -# Example: nx run web:build -# Example: nx run electron-backend:serve +pnpm nx run : +# Example: pnpm nx run web:build +# Example: pnpm nx run electron-backend:serve ``` To run multiple projects: ```bash -nx run-many --target=test --all +pnpm nx run-many --target=test --all ``` ### Electron Build Process diff --git a/README.md b/README.md index 46165f01b..db5f61c92 100644 --- a/README.md +++ b/README.md @@ -31,9 +31,10 @@ The application is a cross-platform, open-source project built with Electron and - EPG support (TV Guide) with detailed information - TV archive/catchup/timeshift functionality - Group-based channel list -- Read-only M3U channel details from the channel context menu +- Read-only M3U channel details from channel context menus, including favorites and recently viewed - Favorite channels management - Global favorites aggregated from all playlists +- Recently viewed live channel removal from row actions and context menus - HTML video player with HLS.js support or Video.js-based player - Internationalization with support for 16 languages: - Arabic @@ -276,6 +277,10 @@ $ pnpm run serve:frontend **IPTVnator doesn't provide any playlists or other digital content.** +## Trademark + +The name **"IPTVnator"** and the IPTVnator logo are unregistered trademarks of the project owner. The MIT license covers the source code only — it does **not** grant rights to the name or logo. Forks and redistributions (including app-store submissions) must use a different name and their own icon. See [TRADEMARK.md](./TRADEMARK.md) for details. + [![All Contributors](https://img.shields.io/badge/all_contributors-13-orange.svg?style=flat-square)](#contributors) diff --git a/TRADEMARK.md b/TRADEMARK.md new file mode 100644 index 000000000..0c326c012 --- /dev/null +++ b/TRADEMARK.md @@ -0,0 +1,42 @@ +# Trademark Notice + +**IPTVnator** (the name) and the IPTVnator logo / icon artwork are trademarks of the IPTVnator project owner ([4gray](https://github.com/4gray)). They are used as unregistered (common-law) trademarks to identify this project and its official builds. + +## What the MIT License Covers + +The [MIT License](./LICENSE.md) in this repository grants you broad rights to **use, copy, modify, and distribute the source code** of IPTVnator, including for commercial purposes. + +## What the MIT License Does **Not** Cover + +The MIT License grants rights to the **software**. It does **not** grant any rights to the project's branding. In particular, it does **not** grant you the right to: + +- Use the name **"IPTVnator"** (or any confusingly similar variant such as "IPTV-Nator", "IPTVNator", "IPTV Nator", etc.) as the name of your fork, redistribution, app-store listing, paid product, or service. +- Use the IPTVnator **logo, icon, or artwork** to identify your fork or any other product. +- Imply, suggest, or otherwise lead users to believe that your fork or product is the official IPTVnator, is endorsed by the IPTVnator project, or is affiliated with it in any way. + +This is consistent with how MIT-licensed projects such as Rust, Node.js, Mozilla, and Kubernetes treat their names and logos: the **code** is open, the **brand** is not. + +## What You **Can** Do With a Fork + +You are very welcome to fork and modify IPTVnator. When you redistribute it publicly (GitHub releases, app stores, paid SaaS, bundled hardware, etc.), please: + +1. **Choose your own name** for the fork. It must not be "IPTVnator" or a confusingly similar variant. +2. **Use your own logo / icon.** Do not reuse the IPTVnator logo, icon, or other artwork files from this repository as the identity of your fork. +3. **Keep the MIT copyright notice** as the license requires. +4. You may, factually, state that your project is "based on IPTVnator" or "a fork of IPTVnator" — this kind of nominative reference is fine and expected. + +## Reporting Misuse + +If you see a project, app store listing, or service that uses the IPTVnator name or logo in a way that violates this notice — especially on app stores where it can mislead users — please: + +- Open an issue on this repository, **or** +- Contact the project owner directly via the contact details on the [GitHub profile](https://github.com/4gray). + +Apple App Store and Google Play both provide first-party channels for trademark and copycat-app disputes: + +- Apple — [App Store Content Dispute](https://www.apple.com/legal/internet-services/itunes/appstorenotices/) +- Google — [Removing Content From Google (Trademark)](https://support.google.com/legal/answer/3110420) + +## Questions + +If you are unsure whether a planned use of the IPTVnator name or logo is acceptable, please **ask before shipping**. Opening an issue is the easiest way to get a fast answer. diff --git a/apps/electron-backend-e2e/src/catalog-sorting.e2e.ts b/apps/electron-backend-e2e/src/catalog-sorting.e2e.ts index 3c6b256b1..be2db1331 100644 --- a/apps/electron-backend-e2e/src/catalog-sorting.e2e.ts +++ b/apps/electron-backend-e2e/src/catalog-sorting.e2e.ts @@ -8,12 +8,14 @@ import { defaultXtreamPassword, defaultXtreamUsername, expect, + fillWorkspaceSearch, expectPathname, launchElectronApp, openWorkspaceSection, resetMockServers, test, waitForStalkerCatalog, + waitForPortalDebugEvent, waitForXtreamWorkspaceReady, } from './electron-test-fixtures'; import { @@ -236,6 +238,13 @@ test.describe('Electron Catalog Sorting', () => { vodFixture.categoryName ); await expectCatalogGridReady(app.mainWindow); + const vodSearchTitle = await firstVisibleGridTitle(app.mainWindow); + await expectCatalogScrollResetAfterNextPage(app.mainWindow); + await expectCatalogSearchResetsToFirstPage( + app.mainWindow, + vodSearchTitle + ); + await clearCatalogSearch(app.mainWindow); await expectCatalogScrollResetAfterNextPage(app.mainWindow); await clickFirstGridListCard(app.mainWindow); await expectPathname( @@ -252,6 +261,13 @@ test.describe('Electron Catalog Sorting', () => { seriesFixture.categoryName ); await expectCatalogGridReady(app.mainWindow); + const seriesSearchTitle = await firstVisibleGridTitle(app.mainWindow); + await expectCatalogScrollResetAfterNextPage(app.mainWindow); + await expectCatalogSearchResetsToFirstPage( + app.mainWindow, + seriesSearchTitle + ); + await clearCatalogSearch(app.mainWindow); await expectCatalogScrollResetAfterNextPage(app.mainWindow); await clickFirstGridListCard(app.mainWindow); await expectPathname( @@ -288,6 +304,16 @@ test.describe('Electron Catalog Sorting', () => { vodFixture.categoryName ); await expectCatalogGridReady(app.mainWindow); + const vodSearchTitle = await firstVisibleGridTitle(app.mainWindow); + await expectCatalogScrollResetAfterNextPage(app.mainWindow, { + expectContentChange: false, + }); + await expectStalkerCatalogSearchResetsToFirstPage(app.mainWindow, { + categoryId: vodFixture.categoryId, + title: vodSearchTitle, + type: 'vod', + }); + await clearCatalogSearch(app.mainWindow); await expectCatalogScrollResetAfterNextPage(app.mainWindow, { expectContentChange: false, }); @@ -299,6 +325,16 @@ test.describe('Electron Catalog Sorting', () => { seriesFixture.categoryName ); await expectCatalogGridReady(app.mainWindow); + const seriesSearchTitle = await firstVisibleGridTitle(app.mainWindow); + await expectCatalogScrollResetAfterNextPage(app.mainWindow, { + expectContentChange: false, + }); + await expectStalkerCatalogSearchResetsToFirstPage(app.mainWindow, { + categoryId: seriesFixture.categoryId, + title: seriesSearchTitle, + type: 'series', + }); + await clearCatalogSearch(app.mainWindow); await expectCatalogScrollResetAfterNextPage(app.mainWindow, { expectContentChange: false, }); @@ -401,6 +437,48 @@ async function expectCatalogScrollResetAfterNextPage( return visibleGridTitles(page); } +async function expectCatalogSearchResetsToFirstPage( + page: Page, + title: string +): Promise { + await fillWorkspaceSearch(page, title); + await expectCatalogSearchQuery(page, title); + await expectCatalogPageQuery(page, null); + await expect(catalogGridCardByTitle(page, title).first()).toBeVisible({ + timeout: 20000, + }); +} + +async function expectStalkerCatalogSearchResetsToFirstPage( + page: Page, + options: { categoryId: string; title: string; type: 'series' | 'vod' } +): Promise { + await expectCatalogSearchResetsToFirstPage(page, options.title); + await waitForPortalDebugEvent(page, { + provider: 'stalker', + operation: 'get_ordered_list', + predicate: (event) => { + const requestPayload = event.request as { + params?: Record; + }; + + return ( + requestPayload.params?.['type'] === options.type && + String(requestPayload.params?.['category']) === + options.categoryId && + requestPayload.params?.['search'] === options.title && + String(requestPayload.params?.['p']) === '1' + ); + }, + }); +} + +async function clearCatalogSearch(page: Page): Promise { + await fillWorkspaceSearch(page, ''); + await expectCatalogSearchQuery(page, null); + await expectCatalogPageQuery(page, null); +} + async function expectStalkerDetailBackPreservesCatalogPage( page: Page ): Promise { @@ -416,13 +494,33 @@ async function expectStalkerDetailBackPreservesCatalogPage( async function expectCatalogPageQuery( page: Page, - expectedPage: string + expectedPage: string | null ): Promise { await expect .poll(() => new URL(page.url()).searchParams.get('page')) .toBe(expectedPage); } +async function expectCatalogSearchQuery( + page: Page, + expectedSearch: string | null +): Promise { + await expect + .poll(() => new URL(page.url()).searchParams.get('q')) + .toBe(expectedSearch); +} + +async function firstVisibleGridTitle(page: Page): Promise { + const titles = await visibleGridTitles(page); + const title = titles[0]; + + if (!title) { + throw new Error('Expected at least one visible catalog grid title.'); + } + + return title; +} + async function goBackFromDetail(page: Page): Promise { const backButton = page .locator('app-content-hero .hero__back-button') @@ -460,6 +558,14 @@ function catalogGrid(page: Page) { return page.locator('app-category-content-view app-grid-list').first(); } +function catalogGridCardByTitle(page: Page, title: string) { + return page.locator('.category-content-layout mat-card').filter({ + has: page.locator('.title', { + hasText: new RegExp(`^\\s*${escapeRegex(title)}\\s*$`), + }), + }); +} + async function ensureCatalogCanGoNext(page: Page): Promise { const header = page.locator('.category-content-header'); const nextButton = header.getByRole('button', { name: 'Next page' }); diff --git a/apps/electron-backend-e2e/src/category-management.e2e.ts b/apps/electron-backend-e2e/src/category-management.e2e.ts index c4b9c8a50..a9de2a5e4 100644 --- a/apps/electron-backend-e2e/src/category-management.e2e.ts +++ b/apps/electron-backend-e2e/src/category-management.e2e.ts @@ -231,6 +231,20 @@ async function refreshFromWorkspaceHeader(page: Page): Promise { const dialog = page.locator('mat-dialog-container'); await expect(dialog).toBeVisible(); await dialog.getByRole('button', { name: 'Yes', exact: true }).click(); + + const refreshOverlay = page.locator('app-workspace-shell-import-overlay'); + await expect(refreshOverlay).toBeVisible({ timeout: 5000 }); + await expect( + refreshOverlay.getByRole('heading', { + name: 'Refreshing playlist', + exact: true, + }) + ).toBeVisible(); + await expect(refreshOverlay).toContainText(/Local library/); + await expect(refreshOverlay).toContainText( + /Preserving your library data|Removing cached streams|Removing cached categories/ + ); + await page.waitForSelector('mat-dialog-container', { state: 'detached' }); } @@ -240,66 +254,60 @@ function sidebarCategoryById(page: Page, categoryId: string): Locator { ); } -async function expectVisibleSidebarCategoryIds( - page: Page, - expectedIds: string[] -): Promise { - const categories = page.locator( - 'app-workspace-context-panel .category-item:visible' - ); - const actualIds: string[] = []; - const count = await categories.count(); - - for (let index = 0; index < count; index += 1) { - const categoryId = await categories - .nth(index) - .getAttribute('data-category-id'); - - if (categoryId) { - actualIds.push(categoryId.trim()); - } - } - - expect(actualIds).toEqual(expectedIds); -} - async function expectVisibleSidebarCategoryNames( page: Page, expectedNames: string[] ): Promise { - await expect - .poll(async () => { - const categories = page.locator( - 'app-workspace-context-panel .category-item:visible' - ); - const actualNames: string[] = []; - const count = await categories.count(); + try { + await expect + .poll(() => readVisibleSidebarCategoryNames(page), { + timeout: 30000, + }) + .toEqual(expectedNames); + } catch (error) { + const actualNames = await readVisibleSidebarCategoryNames(page); + throw new Error( + `Expected visible sidebar categories ${JSON.stringify( + expectedNames + )}, received ${JSON.stringify(actualNames)}`, + { cause: error } + ); + } +} - for (let index = 0; index < count; index += 1) { - const categoryName = - ( - await categories - .nth(index) - .locator('.nav-item-label') - .textContent() - )?.trim() ?? ''; +async function readVisibleSidebarCategoryNames(page: Page): Promise { + const categories = page.locator( + 'app-workspace-context-panel .category-item' + ); + const actualNames: string[] = []; + const count = await categories.count(); - if (categoryName) { - actualNames.push(categoryName); - } - } + for (let index = 0; index < count; index += 1) { + const category = categories.nth(index); + if (!(await category.isVisible())) { + continue; + } - return actualNames; - }) - .toEqual(expectedNames); + const categoryName = + (await category.locator('.nav-item-label').textContent())?.trim() ?? + ''; + + if (categoryName) { + actualNames.push(categoryName); + } + } + + return actualNames; } async function pickSidebarCategory( page: Page ): Promise<{ id: string; itemCount: number; name: string }> { - let preferredCandidate: - | { id: string; itemCount: number; name: string } - | null = null; + let preferredCandidate: { + id: string; + itemCount: number; + name: string; + } | null = null; await expect .poll(async () => { diff --git a/apps/electron-backend-e2e/src/dashboard-activation.e2e.ts b/apps/electron-backend-e2e/src/dashboard-activation.e2e.ts index 007a473f7..f1d23c113 100644 --- a/apps/electron-backend-e2e/src/dashboard-activation.e2e.ts +++ b/apps/electron-backend-e2e/src/dashboard-activation.e2e.ts @@ -1,6 +1,7 @@ import { Page } from '@playwright/test'; import { addXtreamPortal, + channelItemByTitle, clickCategoryByNameExact, clickFirstGridListCard, closeElectronApp, @@ -94,8 +95,15 @@ test.describe('Dashboard Activation', () => { /\/workspace\/xtreams\/[^/]+\/favorites$/ ); await expect( - app.mainWindow.locator('.player-toolbar .close-btn').first() + app.mainWindow + .locator( + 'app-unified-live-tab .content-container .video-player' + ) + .first() ).toBeVisible({ timeout: 20000 }); + await expect( + channelItemByTitle(app.mainWindow, liveTitle).first() + ).toHaveClass(/(^|\s)active(\s|$)/); await app.mainWindow.goBack(); await app.mainWindow.waitForURL(/\/workspace\/dashboard$/); diff --git a/apps/electron-backend-e2e/src/electron-test-fixtures.ts b/apps/electron-backend-e2e/src/electron-test-fixtures.ts index a566dced7..d37a9354b 100644 --- a/apps/electron-backend-e2e/src/electron-test-fixtures.ts +++ b/apps/electron-backend-e2e/src/electron-test-fixtures.ts @@ -289,6 +289,60 @@ export async function importM3uPlaylistFromNativeDialog( await dialog.waitFor({ state: 'detached' }); } +export async function dropM3uPlaylistOntoWorkspace( + page: Page, + filePath: string +): Promise { + await expect(page.locator('.workspace-shell')).toBeVisible({ + timeout: 20000, + }); + + const inputHandle = await page.evaluateHandle(() => { + const input = document.createElement('input'); + input.type = 'file'; + input.style.position = 'fixed'; + input.style.left = '-9999px'; + document.body.append(input); + return input; + }); + const inputElement = inputHandle.asElement(); + if (!inputElement) { + await inputHandle.dispose(); + throw new Error('Could not create temporary file input.'); + } + + await inputElement.setInputFiles(filePath); + + await page.evaluate((element) => { + const input = element as HTMLInputElement; + const file = input.files?.[0]; + if (!file) { + throw new Error('Temporary file input did not receive a file.'); + } + + const target = document.querySelector('.workspace-shell'); + if (!target) { + throw new Error('Workspace drop target was not found.'); + } + + const dataTransfer = new DataTransfer(); + dataTransfer.items.add(file); + + for (const type of ['dragenter', 'dragover', 'drop']) { + target.dispatchEvent( + new DragEvent(type, { + bubbles: true, + cancelable: true, + dataTransfer, + }) + ); + } + + input.remove(); + }, inputElement); + await inputHandle.dispose(); +} + export async function addXtreamPortal( page: Page, options: { diff --git a/apps/electron-backend-e2e/src/providers.e2e.ts b/apps/electron-backend-e2e/src/providers.e2e.ts index 236c935fc..c9ebd4eb7 100644 --- a/apps/electron-backend-e2e/src/providers.e2e.ts +++ b/apps/electron-backend-e2e/src/providers.e2e.ts @@ -13,6 +13,7 @@ import { resetMockServers, stalkerMockServer, test, + waitForXtreamImportToFinish, waitForStalkerCatalog, waitForXtreamCatalog, } from './electron-test-fixtures'; @@ -65,23 +66,93 @@ test.describe('Electron Provider Smoke Tests', () => { await closeElectronApp(app); } }); + + test('shows refresh overlay immediately from the dashboard Xtream source menu', async ({ + dataDir, + request, + }) => { + await resetMockServers(request, ['xtream']); + + const app = await launchElectronApp(dataDir); + + try { + await addXtreamPortal(app.mainWindow); + await waitForXtreamCatalog(app.mainWindow); + await expectPortalDebugSuccess(app.mainWindow, 'xtream'); + + await goToDashboard(app.mainWindow); + await refreshRecentXtreamSourceFromDashboard( + app.mainWindow, + defaultXtreamPortalName + ); + await waitForXtreamImportToFinish(app.mainWindow); + } finally { + await closeElectronApp(app); + } + }); }); async function expectRecentSourceCard( page: Page, title: string ): Promise { - await expect( - page.getByTestId('dashboard-recent-sources-rail') - ).toBeVisible({ - timeout: 20000, - }); + await expect(page.getByTestId('dashboard-recent-sources-rail')).toBeVisible( + { + timeout: 20000, + } + ); await expect( - page.getByTestId('dashboard-recent-sources-rail-card').filter({ - hasText: title, - }).first() + page + .getByTestId('dashboard-recent-sources-rail-card') + .filter({ + hasText: title, + }) + .first() ).toBeVisible({ timeout: 20000, }); } + +async function refreshRecentXtreamSourceFromDashboard( + page: Page, + title: string +): Promise { + const sourceCard = page + .getByTestId('dashboard-recent-sources-rail-card') + .filter({ + hasText: title, + }) + .first(); + + await expect(sourceCard).toBeVisible({ timeout: 20000 }); + await sourceCard.hover(); + await sourceCard + .getByTestId('dashboard-recent-sources-rail-card-actions') + .click(); + await page + .getByRole('menuitem', { + name: 'Refresh Xtream playlist from remote', + exact: true, + }) + .click(); + + const dialog = page.locator('mat-dialog-container'); + await expect(dialog).toBeVisible(); + await dialog.getByRole('button', { name: 'Yes', exact: true }).click(); + + const refreshOverlay = page.locator('app-workspace-shell-import-overlay'); + await expect(refreshOverlay).toBeVisible({ timeout: 5000 }); + await expect( + refreshOverlay.getByRole('heading', { + name: 'Refreshing playlist', + exact: true, + }) + ).toBeVisible(); + await expect(refreshOverlay).toContainText(/Local library/); + await expect(refreshOverlay).toContainText( + /Preserving your library data|Removing cached streams|Removing cached categories/ + ); + + await page.waitForSelector('mat-dialog-container', { state: 'detached' }); +} diff --git a/apps/electron-backend-e2e/src/recent.e2e.ts b/apps/electron-backend-e2e/src/recent.e2e.ts index b277325c8..c58ca5894 100644 --- a/apps/electron-backend-e2e/src/recent.e2e.ts +++ b/apps/electron-backend-e2e/src/recent.e2e.ts @@ -73,21 +73,23 @@ test.describe('Electron Recently Viewed', () => { app.mainWindow, 'Stable Recent Channel' ).first(); - const closeButton = app.mainWindow - .locator('.player-toolbar .close-btn') + const livePlayer = app.mainWindow + .locator('app-unified-live-tab .content-container .video-player') .first(); await item.click(); - await expect(closeButton).toBeVisible({ timeout: 20000 }); + await expect(livePlayer).toBeVisible({ timeout: 20000 }); + await expect(item).toHaveClass(/(^|\s)active(\s|$)/); await item.click(); - await expect(closeButton).toBeVisible({ timeout: 20000 }); + await expect(livePlayer).toBeVisible({ timeout: 20000 }); + await expect(item).toHaveClass(/(^|\s)active(\s|$)/); } finally { await closeElectronApp(app); } }); - test('tracks M3U recent channels in newest-first order, supports all-playlists scope, and persists removals after restart', async ({ + test('tracks M3U recent channels in newest-first order, supports all-playlists scope, and persists favorites after restart', async ({ dataDir, }) => { const playlistTitle = 'm3u-recent-source.m3u'; @@ -127,10 +129,15 @@ test.describe('Electron Recently Viewed', () => { .poll(() => visibleLiveTitles(app.mainWindow)) .toEqual(['Recent Channel Two', 'Recent Channel One']); - await removeLiveRecentItem(app.mainWindow, 'Recent Channel Two'); + await toggleFavoriteForChannel(app.mainWindow, 'Recent Channel Two'); await expect .poll(() => visibleLiveTitles(app.mainWindow)) - .toEqual(['Recent Channel One']); + .toEqual(['Recent Channel Two', 'Recent Channel One']); + + await openPlaylistFavorites(app.mainWindow); + await expect( + channelItemByTitle(app.mainWindow, 'Recent Channel Two').first() + ).toBeVisible({ timeout: 20000 }); const restarted = await restartElectronApp(app, dataDir); app.electronApp = restarted.electronApp; @@ -145,7 +152,68 @@ test.describe('Electron Recently Viewed', () => { await switchUnifiedCollectionScope(app.mainWindow, 'All playlists'); await expect .poll(() => visibleLiveTitles(app.mainWindow)) - .toEqual(['Recent Channel One']); + .toEqual(['Recent Channel Two', 'Recent Channel One']); + + await openPlaylistFavorites(app.mainWindow); + await expect( + channelItemByTitle(app.mainWindow, 'Recent Channel Two').first() + ).toBeVisible({ timeout: 20000 }); + } finally { + await closeElectronApp(app); + } + }); + + test('removes one M3U recent live channel from the all-playlists context menu', async ({ + dataDir, + }) => { + const filePath = writeTemporaryM3uFile( + dataDir, + 'm3u-recent-context-menu-source.m3u', + [ + { + groupTitle: 'Live', + name: 'Context Recent One', + url: 'https://streams.example.test/context-recent-one.m3u8', + }, + { + groupTitle: 'Live', + name: 'Context Recent Two', + url: 'https://streams.example.test/context-recent-two.m3u8', + }, + ] + ); + const app = await launchElectronApp(dataDir); + + try { + await importM3uPlaylistFromNativeDialog(app, filePath); + await waitForM3uCatalog(app.mainWindow); + + await channelItemByTitle(app.mainWindow, 'Context Recent One') + .first() + .click(); + await channelItemByTitle(app.mainWindow, 'Context Recent Two') + .first() + .click(); + + await openPlaylistRecent(app.mainWindow); + await switchUnifiedCollectionScope(app.mainWindow, 'All playlists'); + await expect + .poll(() => visibleLiveTitles(app.mainWindow)) + .toEqual(['Context Recent Two', 'Context Recent One']); + + await channelItemByTitle(app.mainWindow, 'Context Recent One') + .first() + .click({ button: 'right' }); + await app.mainWindow + .getByRole('menuitem', { name: 'Remove from history' }) + .click(); + + await expect + .poll(() => visibleLiveTitles(app.mainWindow)) + .toEqual(['Context Recent Two']); + await expect( + channelItemByTitle(app.mainWindow, 'Context Recent One') + ).toHaveCount(0); } finally { await closeElectronApp(app); } @@ -189,7 +257,7 @@ test.describe('Electron Recently Viewed', () => { await toggleFavoriteForChannel(app.mainWindow, liveTitle); await openPlaylistFavorites(app.mainWindow); await channelItemByTitle(app.mainWindow, liveTitle).first().click(); - await closeUnifiedLiveDetail(app.mainWindow); + await expectUnifiedLiveDetailOpen(app.mainWindow, liveTitle); await app.mainWindow .getByRole('link', { name: 'Movies', exact: true }) @@ -396,7 +464,7 @@ test.describe('Electron Recently Viewed', () => { await toggleFavoriteForChannel(app.mainWindow, liveTitle); await openPlaylistFavorites(app.mainWindow); await channelItemByTitle(app.mainWindow, liveTitle).first().click(); - await closeUnifiedLiveDetail(app.mainWindow); + await expectUnifiedLiveDetailOpen(app.mainWindow, liveTitle); await app.mainWindow .getByRole('link', { name: 'Movies', exact: true }) @@ -561,11 +629,18 @@ async function clearRecentItems(page: Page, typeLabel: string): Promise { await page.getByRole('button', { name: 'Yes' }).click(); } -async function closeUnifiedLiveDetail(page: Page): Promise { - const closeButton = page.locator('.player-toolbar .close-btn').first(); - - await expect(closeButton).toBeVisible({ timeout: 20000 }); - await closeButton.click(); +async function expectUnifiedLiveDetailOpen( + page: Page, + title: string +): Promise { + await expect( + page + .locator('app-unified-live-tab .content-container .video-player') + .first() + ).toBeVisible({ timeout: 20000 }); + await expect(channelItemByTitle(page, title).first()).toHaveClass( + /(^|\s)active(\s|$)/ + ); } async function goBackFromDetail(page: Page): Promise { @@ -634,14 +709,6 @@ async function playFirstSeriesEpisode(page: Page): Promise { await episodeCard.click(); } -async function removeLiveRecentItem(page: Page, title: string): Promise { - const item = channelItemByTitle(page, title).first(); - - await expect(item).toBeVisible({ timeout: 20000 }); - await item.hover(); - await item.locator('.favorite-button').first().click(); -} - async function toggleFavoriteForChannel( page: Page, title: string @@ -651,6 +718,9 @@ async function toggleFavoriteForChannel( await expect(item).toBeVisible({ timeout: 20000 }); await item.hover(); await item.locator('.favorite-button').first().click(); + await expect(item.locator('.favorite-button mat-icon').first()).toHaveText( + 'star' + ); } async function visibleLiveTitles(page: Page): Promise { diff --git a/apps/electron-backend-e2e/src/search.e2e.ts b/apps/electron-backend-e2e/src/search.e2e.ts index 291dd7175..653acae96 100644 --- a/apps/electron-backend-e2e/src/search.e2e.ts +++ b/apps/electron-backend-e2e/src/search.e2e.ts @@ -206,7 +206,10 @@ test.describe('Electron Workspace Search', () => { await fillWorkspaceSearch(app.mainWindow, sample.groupTitle); - await expectPathname(app.mainWindow, /\/workspace\/global-favorites$/); + await expectPathname( + app.mainWindow, + /\/workspace\/global-favorites$/ + ); await expectQueryParam(app.mainWindow, 'q', sample.groupTitle); await expectWorkspaceSearchScope( app.mainWindow, @@ -411,6 +414,134 @@ test.describe('Electron Workspace Search', () => { } }); + test('@search @xtream filters VOD and series root views across categories', async ({ + dataDir, + request, + }) => { + await resetMockServers(request, ['xtream']); + const vodSample = await fetchXtreamVodFixture(request); + const seriesSample = await fetchXtreamSeriesFixture(request); + + const app = await launchElectronApp(dataDir); + + try { + await addXtreamPortal(app.mainWindow, { + password: xtreamSearchPassword, + username: xtreamSearchUsername, + }); + await waitForXtreamWorkspaceReady(app.mainWindow); + + await expectXtreamRootCatalogSearch(app.mainWindow, { + pathPattern: /\/workspace\/xtreams\/[^/]+\/vod$/, + sample: vodSample, + sectionLabel: 'Movies', + }); + await expect( + app.mainWindow.locator(headerSearchSelector) + ).toHaveValue(vodSample.targetTitle); + + await fillWorkspaceSearch(app.mainWindow, ''); + await openWorkspaceSection(app.mainWindow, 'Series'); + + await expectXtreamRootCatalogSearch(app.mainWindow, { + pathPattern: /\/workspace\/xtreams\/[^/]+\/series$/, + sample: seriesSample, + sectionLabel: 'Series', + }); + await expect( + app.mainWindow.locator(headerSearchSelector) + ).toHaveValue(seriesSample.targetTitle); + } finally { + await closeElectronApp(app); + } + }); + + test('@search @xtream filters live root and category scopes while preserving playback sidebar', async ({ + dataDir, + request, + }) => { + await resetMockServers(request, ['xtream']); + const sample = await fetchXtreamLiveFixture(request); + + const app = await launchElectronApp(dataDir); + + try { + await addXtreamPortal(app.mainWindow, { + password: xtreamSearchPassword, + username: xtreamSearchUsername, + }); + await waitForXtreamWorkspaceReady(app.mainWindow); + await openWorkspaceSection(app.mainWindow, 'Live TV'); + await expectPathname( + app.mainWindow, + /\/workspace\/xtreams\/[^/]+\/live$/ + ); + + await fillWorkspaceSearch(app.mainWindow, sample.targetTitle); + + await expectPathname( + app.mainWindow, + /\/workspace\/xtreams\/[^/]+\/live$/ + ); + await expectQueryParam(app.mainWindow, 'q', sample.targetTitle); + await expectWorkspaceSearchScope( + app.mainWindow, + 'Live TV / All Items' + ); + await expect( + liveChannelSidebar(app.mainWindow) + .locator('[data-test-id="channel-item"]') + .first() + ).toBeVisible({ timeout: 20000 }); + await expect( + channelItemByTitle(app.mainWindow, sample.targetTitle).first() + ).toBeVisible({ timeout: 20000 }); + await expect( + channelItemByTitle(app.mainWindow, sample.controlTitle) + ).toHaveCount(0); + + await channelItemByTitle(app.mainWindow, sample.targetTitle) + .first() + .click(); + + await expect( + app.mainWindow.locator( + 'app-live-stream-layout .content-container .video-player' + ) + ).toBeVisible({ timeout: 20000 }); + await expect( + liveChannelSidebar(app.mainWindow) + .locator('[data-test-id="channel-item"]') + .first() + ).toBeVisible(); + await expect( + channelItemByTitle(app.mainWindow, sample.targetTitle).first() + ).toHaveClass(/(^|\s)active(\s|$)/); + + await fillWorkspaceSearch(app.mainWindow, ''); + await clickCategoryByNameExact(app.mainWindow, sample.categoryName); + await fillWorkspaceSearch(app.mainWindow, sample.targetTitle); + + await expectPathname( + app.mainWindow, + /\/workspace\/xtreams\/[^/]+\/live(?:\/[^/]+)?$/ + ); + await expectQueryParam(app.mainWindow, 'q', sample.targetTitle); + await expectWorkspaceSearchScope( + app.mainWindow, + `Live TV / ${sample.categoryName}` + ); + await expect( + channelItemByTitle(app.mainWindow, sample.targetTitle).first() + ).toBeVisible({ timeout: 20000 }); + await expect( + channelItemByTitle(app.mainWindow, sample.controlTitle) + ).toHaveCount(0); + } finally { + await closeElectronApp(app); + } + }); + test('@search @xtream promotes dashboard header search into advanced playlist search', async ({ dataDir, request, @@ -561,7 +692,10 @@ test.describe('Electron Workspace Search', () => { ); await openWorkspaceSection(app.mainWindow, 'Favorites'); - await expectPathname(app.mainWindow, /\/workspace\/global-favorites$/); + await expectPathname( + app.mainWindow, + /\/workspace\/global-favorites$/ + ); await expect( contentCardByTitle( app.mainWindow, @@ -801,7 +935,10 @@ test.describe('Electron Workspace Search', () => { await toggleFavoriteForChannel(app.mainWindow, sample.controlTitle); await openWorkspaceSection(app.mainWindow, 'Favorites'); - await expectPathname(app.mainWindow, /\/workspace\/global-favorites$/); + await expectPathname( + app.mainWindow, + /\/workspace\/global-favorites$/ + ); await expect( channelItemByTitle(app.mainWindow, sample.targetTitle).first() ).toBeVisible({ timeout: 20000 }); @@ -911,6 +1048,15 @@ async function fetchXtreamVodFixture( }); } +async function fetchXtreamLiveFixture( + request: APIRequestContext +): Promise { + return fetchXtreamFixture(request, { + categoriesAction: 'get_live_categories', + itemsAction: 'get_live_streams', + }); +} + async function fetchXtreamSeriesFixture( request: APIRequestContext ): Promise { @@ -1262,6 +1408,10 @@ function xtreamSearchResultCards(page: Page) { return page.locator('app-search-results .results-grid .content-card'); } +function liveChannelSidebar(page: Page) { + return page.locator('app-live-stream-layout .sidebar'); +} + function gridListCardByTitle(page: Page, title: string) { return page.locator('.category-content-layout mat-card').filter({ has: page.locator('.title', { @@ -1329,6 +1479,34 @@ async function resolveVisibleXtreamTitles( }; } +async function expectXtreamRootCatalogSearch( + page: Page, + options: { + pathPattern: RegExp; + sample: XtreamVodFixture; + sectionLabel: string; + } +): Promise { + await openWorkspaceSection(page, options.sectionLabel); + await expectPathname(page, options.pathPattern); + await fillWorkspaceSearch(page, options.sample.targetTitle); + + await expectPathname(page, options.pathPattern); + await expectQueryParam(page, 'q', options.sample.targetTitle); + await expectWorkspaceSearchScope( + page, + `${options.sectionLabel} / All Items` + ); + const contentLayout = page.locator('.category-content-layout'); + await expect(contentLayout).toContainText( + flexibleTextPattern(options.sample.targetTitle), + { timeout: 20000 } + ); + await expect(contentLayout).not.toContainText( + flexibleTextPattern(options.sample.controlTitle) + ); +} + async function addCurrentDetailToFavorites(page: Page): Promise { const addButton = page .locator('button.favorite-btn') diff --git a/apps/electron-backend-e2e/src/settings.e2e.ts b/apps/electron-backend-e2e/src/settings.e2e.ts index 2b4fbda65..cc5e1de3e 100644 --- a/apps/electron-backend-e2e/src/settings.e2e.ts +++ b/apps/electron-backend-e2e/src/settings.e2e.ts @@ -1,13 +1,18 @@ import { Page } from '@playwright/test'; import { addXtreamPortal, + channelItemByTitle, clickCategoryByNameExact, clickFirstGridListCard, closeElectronApp, createMutableTextServer, enableRemoteControl, expect, + goToDashboard, + importM3uPlaylistFromNativeDialog, launchElectronApp, + LaunchedElectronApp, + m3uFixturePath, openGlobalRecent, openSettings, openWorkspaceSection, @@ -36,6 +41,86 @@ const epgFixtureXml = ` `; test.describe('Electron Settings', () => { + test('gates external MPV playback behind double-click when enabled', async ({ + dataDir, + }) => { + const app = await launchElectronApp(dataDir); + + try { + await installExternalPlayerLaunchCapture(app); + + await openSettings(app.mainWindow); + await expect( + app.mainWindow.getByTestId( + 'external-player-double-click-setting' + ) + ).toHaveCount(0); + + await selectSettingsOption( + app.mainWindow, + 'select-video-player', + 'mpv' + ); + + const doubleClickSetting = app.mainWindow.getByTestId( + 'external-player-double-click-setting' + ); + const doubleClickCheckbox = doubleClickSetting.locator( + 'input[type="checkbox"]' + ); + + await expect(doubleClickSetting).toBeVisible(); + await expect(doubleClickCheckbox).not.toBeChecked(); + await saveSettings(app.mainWindow); + + await goToDashboard(app.mainWindow); + await importM3uPlaylistFromNativeDialog(app, m3uFixturePath); + await app.mainWindow.waitForURL(/\/workspace\/playlists\/.+/); + + const firstChannel = channelItemByTitle( + app.mainWindow, + 'Channel 1' + ).first(); + + await expect(firstChannel).toBeVisible({ timeout: 20000 }); + await firstChannel.click(); + await expectExternalPlayerLaunchCount(app, 1); + await expectExternalPlayerLaunch(app, 0, { + player: 'mpv', + title: 'Channel 1', + url: 'https://example.channels/path-to-file/1.m3u8', + }); + + await openSettings(app.mainWindow); + await expect(doubleClickSetting).toBeVisible(); + await doubleClickCheckbox.check(); + await saveSettings(app.mainWindow); + await app.mainWindow.goBack(); + await app.mainWindow.waitForURL(/\/workspace\/playlists\/.+/); + await resetExternalPlayerLaunches(app); + + const secondChannel = channelItemByTitle( + app.mainWindow, + 'Positive News TV' + ).first(); + + await expect(secondChannel).toBeVisible({ timeout: 20000 }); + await secondChannel.click(); + await expect(secondChannel).toHaveClass(/active/); + await expectNoExternalPlayerLaunchesAfterSettled(app); + + await secondChannel.dblclick(); + await expectExternalPlayerLaunchCount(app, 1); + await expectExternalPlayerLaunch(app, 0, { + player: 'mpv', + title: 'Positive News TV', + url: 'https://example.channels/path-to-file/2.m3u8', + }); + } finally { + await closeElectronApp(app); + } + }); + test('persists changed desktop settings across app restart', async ({ dataDir, }) => { @@ -236,3 +321,112 @@ async function selectSettingsOption( await page.getByTestId(selectTestId).click(); await page.getByTestId(optionTestId).click(); } + +type CapturedExternalPlayerLaunch = { + player: 'mpv' | 'vlc'; + title: string; + url: string; +}; + +const externalPlayerLaunchCaptureKey = + '__iptvnatorE2eExternalPlayerLaunches'; + +async function installExternalPlayerLaunchCapture( + app: LaunchedElectronApp +): Promise { + await app.electronApp.evaluate(({ ipcMain }, captureKey) => { + const globalRef = globalThis as typeof globalThis & + Record; + globalRef[captureKey] = []; + + const captureLaunch = + (player: 'mpv' | 'vlc') => + async ( + _event: unknown, + url: string, + title: string, + thumbnail?: string | null + ) => { + const launches = globalRef[captureKey] ?? []; + const now = new Date().toISOString(); + + launches.push({ + player, + title, + url, + }); + globalRef[captureKey] = launches; + + return { + canClose: false, + id: `e2e-${player}-${launches.length}`, + player, + startedAt: now, + status: 'opened', + streamUrl: url, + thumbnail: thumbnail ?? null, + title, + updatedAt: now, + }; + }; + + ipcMain.removeHandler('OPEN_MPV_PLAYER'); + ipcMain.removeHandler('OPEN_VLC_PLAYER'); + ipcMain.handle('OPEN_MPV_PLAYER', captureLaunch('mpv')); + ipcMain.handle('OPEN_VLC_PLAYER', captureLaunch('vlc')); + }, externalPlayerLaunchCaptureKey); +} + +async function getExternalPlayerLaunches( + app: LaunchedElectronApp +): Promise { + return app.electronApp.evaluate((_, captureKey) => { + const globalRef = globalThis as typeof globalThis & + Record; + + return globalRef[captureKey] ?? []; + }, externalPlayerLaunchCaptureKey); +} + +async function resetExternalPlayerLaunches( + app: LaunchedElectronApp +): Promise { + await app.electronApp.evaluate((_, captureKey) => { + const globalRef = globalThis as typeof globalThis & + Record; + + globalRef[captureKey] = []; + }, externalPlayerLaunchCaptureKey); +} + +async function expectExternalPlayerLaunchCount( + app: LaunchedElectronApp, + count: number +): Promise { + await expect + .poll(async () => (await getExternalPlayerLaunches(app)).length, { + timeout: 10000, + }) + .toBe(count); +} + +async function expectExternalPlayerLaunch( + app: LaunchedElectronApp, + index: number, + expected: CapturedExternalPlayerLaunch +): Promise { + const launches = await getExternalPlayerLaunches(app); + + expect(launches[index]).toMatchObject({ + player: expected.player, + url: expected.url, + }); + expect(launches[index]?.title.trim()).toBe(expected.title); +} + +async function expectNoExternalPlayerLaunchesAfterSettled( + app: LaunchedElectronApp +): Promise { + await new Promise((resolve) => setTimeout(resolve, 750)); + expect(await getExternalPlayerLaunches(app)).toHaveLength(0); +} diff --git a/apps/electron-backend-e2e/src/sources.e2e.ts b/apps/electron-backend-e2e/src/sources.e2e.ts index 2deeb8b40..7c198e900 100644 --- a/apps/electron-backend-e2e/src/sources.e2e.ts +++ b/apps/electron-backend-e2e/src/sources.e2e.ts @@ -4,11 +4,13 @@ import { closeElectronApp, createMutableTextServer, deleteSource, + dropM3uPlaylistOntoWorkspace, dragSourceBefore, expect, expectPlaylistUpdatedToast, expectSourceDialogValues, getVisibleSourceTitles, + goToDashboard, importM3uPlaylistFromNativeDialog, importM3uPlaylistFromUrl, launchElectronApp, @@ -425,7 +427,37 @@ https://streams.example.test/original-url.m3u8 const app = await launchElectronApp(dataDir); try { - await importM3uPlaylistFromNativeDialog(app, localFilePath); + await dropM3uPlaylistOntoWorkspace( + app.mainWindow, + localFilePath + ); + await waitForM3uCatalog(app.mainWindow); + await expect( + app.mainWindow.locator( + 'app-playlist-switcher .trigger-refresh-button' + ) + ).toBeVisible(); + + await goToDashboard(app.mainWindow); + const sourceCard = app.mainWindow + .getByTestId('dashboard-recent-sources-rail-card') + .filter({ + hasText: refreshLocalSourceDisplayName, + }) + .first(); + await expect(sourceCard).toBeVisible({ timeout: 20000 }); + await sourceCard.hover(); + await sourceCard + .getByTestId('dashboard-recent-sources-rail-card-actions') + .click(); + await expect( + app.mainWindow.getByRole('menuitem', { + name: 'Refresh playlist', + exact: true, + }) + ).toBeVisible(); + await app.mainWindow.keyboard.press('Escape'); + await importM3uPlaylistFromUrl( app.mainWindow, urlServer.resourceUrl @@ -434,6 +466,14 @@ https://streams.example.test/original-url.m3u8 name: 'Refresh Xtream Source', }); await openSources(app.mainWindow); + await expect( + sourceRowByTitle( + app.mainWindow, + refreshLocalSourceDisplayName + ) + .first() + .locator('.refresh-btn') + ).toBeVisible(); writeTemporaryM3uFile(dataDir, 'refresh-local-source.m3u', [ { diff --git a/apps/electron-backend-e2e/src/xtream-epg.e2e.ts b/apps/electron-backend-e2e/src/xtream-epg.e2e.ts index 59876cc51..000d74894 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,22 +93,14 @@ 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('.time')).toHaveText( - `${formatTimeInZone( - currentProgram.startTimestamp, - timeZone - )} - ${formatTimeInZone(currentProgram.stopTimestamp, timeZone)}` + await expectCurrentProgram( + app.mainWindow, + currentProgram, + timeZone ); const firstFutureDateKey = getFirstFutureDateKey( @@ -114,25 +115,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 +162,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 +222,48 @@ 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 expectCurrentProgram( + page: Parameters[0], + currentProgram: XtreamNormalizedEpgListing, + timeZone: string +): Promise { + const startTime = formatTimeInZone(currentProgram.startTimestamp, timeZone); + const stopTime = formatTimeInZone(currentProgram.stopTimestamp, timeZone); + const currentProgramRow = page + .locator('app-epg-list .program-item.current-program') + .first(); + + if (await currentProgramRow.isVisible().catch(() => false)) { + await expect(currentProgramRow.locator('.program-title')).toHaveText( + currentProgram.title + ); + await expect(currentProgramRow.locator('.time')).toHaveText( + `${startTime}–${stopTime}` + ); + return; + } + + const summary = page.locator('app-live-epg-panel').first(); + await expect(summary.locator('.live-epg-panel__title')).toHaveText( + currentProgram.title + ); + await expect(summary.locator('.live-epg-panel__time')).toHaveText( + `${startTime} - ${stopTime}` + ); +} + +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/electron-backend/build-embedded-mpv.js b/apps/electron-backend/build-embedded-mpv.js new file mode 100644 index 000000000..73ed27634 --- /dev/null +++ b/apps/electron-backend/build-embedded-mpv.js @@ -0,0 +1,254 @@ +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 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'; +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'; +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`); +} + +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, + }); + fs.writeFileSync( + unavailableMarkerFile, + 'Embedded MPV native runtime is not available for this build.\n' + ); +} + +function cleanDistNativeOutput() { + fs.rmSync(distNativeDir, { recursive: true, force: true }); +} + +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 }); + 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; + } + + const runtime = resolveRuntime(); + if (!runtime) { + cleanOutput(); + 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; + } + + 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; + std::vector subtitleTracks; + int64_t selectedSubtitleTrackId = -1; + double playbackSpeed = 1.0; + std::string aspectOverride = "no"; +}; + +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 findSession(const std::string& sessionId) +{ + std::lock_guard sessionsLock(gSessionsMutex); + const auto iterator = gSessions.find(sessionId); + if (iterator == gSessions.end()) { + return nullptr; + } + return iterator->second; +} + +std::shared_ptr getSessionOrThrow( + Napi::Env env, + const std::string& sessionId) +{ + auto session = findSession(sessionId); + if (!session) { + throw Napi::Error::New(env, "Embedded MPV session not found."); + } + return session; +} + +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 updateSelectedSubtitleTrack(SessionSnapshot& snapshot, int64_t selectedTrackId) +{ + snapshot.selectedSubtitleTrackId = + selectedTrackId >= 0 ? selectedTrackId : -1; + for (auto& track : snapshot.subtitleTracks) { + track.selected = track.id == snapshot.selectedSubtitleTrackId; + } +} + +void updateTracksFromNode( + SessionSnapshot& snapshot, + const mpv_node& node, + const std::string& typeFilter, + std::vector& outTracks, + int64_t& outSelectedId) +{ + outTracks.clear(); + outSelectedId = -1; + + if (node.format != MPV_FORMAT_NODE_ARRAY || + !node.u.list || + !node.u.list->values) { + return; + } + + 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) != typeFilter) { + continue; + } + + const mpv_node* idNode = getNodeMapValue(trackNode, "id"); + int64_t id = -1; + if (!idNode || !readNodeInteger(*idNode, id) || id < 0) { + continue; + } + + AudioTrack track; + track.id = id; + if (const mpv_node* titleNode = getNodeMapValue(trackNode, "title")) { + track.title = readNodeString(*titleNode); + } + if (const mpv_node* languageNode = getNodeMapValue(trackNode, "lang")) { + track.language = readNodeString(*languageNode); + } + if (const mpv_node* selectedNode = getNodeMapValue(trackNode, "selected")) { + track.selected = readNodeFlag(*selectedNode); + } + if (const mpv_node* defaultNode = getNodeMapValue(trackNode, "default")) { + track.defaultTrack = readNodeFlag(*defaultNode); + } + if (const mpv_node* forcedNode = getNodeMapValue(trackNode, "forced")) { + track.forced = readNodeFlag(*forcedNode); + } + + if (track.selected) { + outSelectedId = track.id; + } + outTracks.push_back(track); + } +} + +void updateAudioTracksFromNode(SessionSnapshot& snapshot, const mpv_node& node) +{ + updateTracksFromNode( + snapshot, + node, + "audio", + snapshot.audioTracks, + snapshot.selectedAudioTrackId + ); +} + +void updateSubtitleTracksFromNode(SessionSnapshot& snapshot, const mpv_node& node) +{ + updateTracksFromNode( + snapshot, + node, + "sub", + snapshot.subtitleTracks, + snapshot.selectedSubtitleTrackId + ); +} + +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->snapshot.subtitleTracks.clear(); + session->snapshot.selectedSubtitleTrackId = -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) { + const auto& trackListNode = + *static_cast(property->data); + updateAudioTracksFromNode( + session->snapshot, + trackListNode + ); + updateSubtitleTracksFromNode( + session->snapshot, + trackListNode + ); + 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; + } + + if (propertyName == "sid" && + property->format == MPV_FORMAT_STRING && + property->data) { + const auto* sidValue = static_cast(property->data); + int64_t selectedTrackId = -1; + if (sidValue && parseIntegerString(sidValue, selectedTrackId)) { + updateSelectedSubtitleTrack( + session->snapshot, + selectedTrackId + ); + } else { + updateSelectedSubtitleTrack(session->snapshot, -1); + } + break; + } + + if (propertyName == "speed" && + property->format == MPV_FORMAT_DOUBLE && + property->data) { + session->snapshot.playbackSpeed = + *static_cast(property->data); + break; + } + + if (propertyName == "video-aspect-override" && + property->format == MPV_FORMAT_STRING && + property->data) { + const auto* aspectValue = + static_cast(property->data); + session->snapshot.aspectOverride = + aspectValue ? aspectValue : "no"; + 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); + mpv_observe_property(session->handle, 8, "sid", MPV_FORMAT_STRING); + mpv_observe_property(session->handle, 9, "speed", MPV_FORMAT_DOUBLE); + mpv_observe_property( + session->handle, + 10, + "video-aspect-override", + 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(env, 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(env, 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(env, 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(env, 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(env, 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(env, 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 SetSubtitleTrack(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 subtitle track id." + ); + } + + const std::string sessionId = info[0].As().Utf8Value(); + const auto session = getSessionOrThrow(env, sessionId); + int64_t trackId = info[1].As().Int64Value(); + + int result = -1; + if (trackId < 0) { + const char* disabledValue = "no"; + std::string disabled = disabledValue; + result = mpv_set_property_async( + session->handle, + nextAsyncRequestId(), + "sid", + MPV_FORMAT_STRING, + const_cast(&disabledValue) + ); + } else { + result = mpv_set_property_async( + session->handle, + nextAsyncRequestId(), + "sid", + MPV_FORMAT_INT64, + &trackId + ); + } + + if (result < 0) { + throw Napi::Error::New( + env, + std::string("Failed to update subtitle track: ") + + mpv_error_string(result) + ); + } + + { + std::lock_guard lock(session->mutex); + updateSelectedSubtitleTrack(session->snapshot, trackId); + } + + return env.Undefined(); +} + +Napi::Value SetSpeed(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 speed."); + } + + const std::string sessionId = info[0].As().Utf8Value(); + const auto session = getSessionOrThrow(env, sessionId); + double speed = info[1].As().DoubleValue(); + speed = std::clamp(speed, 0.25, 4.0); + + const int result = mpv_set_property_async( + session->handle, + nextAsyncRequestId(), + "speed", + MPV_FORMAT_DOUBLE, + &speed + ); + + if (result < 0) { + throw Napi::Error::New( + env, + std::string("Failed to update playback speed: ") + + mpv_error_string(result) + ); + } + + { + std::lock_guard lock(session->mutex); + session->snapshot.playbackSpeed = speed; + } + + return env.Undefined(); +} + +Napi::Value SetAspect(const Napi::CallbackInfo& info) +{ + Napi::Env env = info.Env(); + if (info.Length() < 2 || !info[0].IsString() || !info[1].IsString()) { + throw Napi::TypeError::New( + env, + "Expected session id and aspect override." + ); + } + + const std::string sessionId = info[0].As().Utf8Value(); + const auto session = getSessionOrThrow(env, sessionId); + std::string aspect = info[1].As().Utf8Value(); + if (aspect.empty()) { + aspect = "no"; + } + + const char* aspectValue = aspect.c_str(); + const int result = mpv_set_property_async( + session->handle, + nextAsyncRequestId(), + "video-aspect-override", + MPV_FORMAT_STRING, + const_cast(&aspectValue) + ); + + if (result < 0) { + throw Napi::Error::New( + env, + std::string("Failed to update aspect override: ") + + mpv_error_string(result) + ); + } + + { + std::lock_guard lock(session->mutex); + session->snapshot.aspectOverride = aspect; + } + + 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.selectedSubtitleTrackId >= 0) { + result.Set( + "selectedSubtitleTrackId", + Napi::Number::New(env, snapshot.selectedSubtitleTrackId) + ); + } else { + result.Set("selectedSubtitleTrackId", env.Null()); + } + auto subtitleTracks = + Napi::Array::New(env, snapshot.subtitleTracks.size()); + for (size_t index = 0; index < snapshot.subtitleTracks.size(); + index += 1) { + const AudioTrack& track = snapshot.subtitleTracks[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)); + subtitleTracks.Set(index, trackObject); + } + result.Set("subtitleTracks", subtitleTracks); + + result.Set( + "playbackSpeed", + Napi::Number::New(env, snapshot.playbackSpeed) + ); + result.Set( + "aspectOverride", + Napi::String::New(env, snapshot.aspectOverride) + ); + + 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( + "setSubtitleTrack", + Napi::Function::New(env, SetSubtitleTrack) + ); + exports.Set("setSpeed", Napi::Function::New(env, SetSpeed)); + exports.Set("setAspect", Napi::Function::New(env, SetAspect)); + 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..d96fb3678 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,29 @@ ], "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": "**/*", + "input": "apps/electron-backend/native/build/Release", + "output": "native", + "ignore": [ + ".deps/**", + "obj.target/**", + "**/.deps/**", + "**/obj.target/**" + ] + } + ] }, "configurations": { "production": { @@ -44,6 +73,7 @@ "build-e2e": { "dependsOn": [ "electron-backend:build-worker", + "electron-backend:build-embedded-mpv", { "projects": ["web"], "target": "build-e2e" @@ -55,11 +85,29 @@ ], "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": "**/*", + "input": "apps/electron-backend/native/build/Release", + "output": "native", + "ignore": [ + ".deps/**", + "obj.target/**", + "**/.deps/**", + "**/obj.target/**" + ] + } + ] } }, "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..80573f24f 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 { contextBridge, ipcRenderer, webUtils } 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); @@ -276,6 +291,7 @@ const electronApi = { updatePlaylistFromFilePath: (filePath: string, title: string) => ipcRenderer.invoke('update-playlist-from-file-path', filePath, title), openPlaylistFromFile: () => ipcRenderer.invoke('open-playlist-from-file'), + getPathForFile: (file: File) => webUtils.getPathForFile(file), saveFileDialog: ( defaultPath: string, filters?: { name: string; extensions: string[] }[] @@ -332,12 +348,82 @@ 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), + setEmbeddedMpvSubtitleTrack: ( + sessionId: string, + trackId: number + ): Promise => + ipcRenderer.invoke( + 'EMBEDDED_MPV_SET_SUBTITLE_TRACK', + sessionId, + trackId + ), + setEmbeddedMpvSpeed: ( + sessionId: string, + speed: number + ): Promise => + ipcRenderer.invoke('EMBEDDED_MPV_SET_SPEED', sessionId, speed), + setEmbeddedMpvAspect: ( + sessionId: string, + aspect: string + ): Promise => + ipcRenderer.invoke('EMBEDDED_MPV_SET_ASPECT', sessionId, aspect), + disposeEmbeddedMpvSession: ( + sessionId: string + ): Promise => + ipcRenderer.invoke('EMBEDDED_MPV_DISPOSE_SESSION', sessionId), autoUpdatePlaylists: (playlists) => ipcRenderer.invoke('AUTO_UPDATE', playlists), fetchEpg: (urls: string[]) => ipcRenderer.invoke('FETCH_EPG', { url: urls }), getChannelPrograms: (channelId: string) => ipcRenderer.invoke('GET_CHANNEL_PROGRAMS', { channelId }), + getCurrentProgramsBatch: (channelIds: string[]) => + ipcRenderer.invoke('GET_CURRENT_PROGRAMS_BATCH', { channelIds }), getEpgChannelMetadata: (channelIds: string[]) => ipcRenderer.invoke('EPG_GET_CHANNEL_METADATA', { channelIds }), getEpgChannels: () => ipcRenderer.invoke('EPG_GET_CHANNELS'), @@ -540,6 +626,9 @@ const electronApi = { ipcRenderer.invoke('DB_CLEAR_PLAYLIST_RECENT_ITEMS', playlistId), dbRemoveRecentItem: (contentId: number, playlistId: string) => ipcRenderer.invoke('DB_REMOVE_RECENT_ITEM', contentId, playlistId), + dbRemoveRecentItemsBatch: ( + items: { contentId: number; playlistId: string }[] + ) => ipcRenderer.invoke('DB_REMOVE_RECENT_ITEMS_BATCH', items), dbGetContentByXtreamId: ( xtreamId: number, playlistId: string, diff --git a/apps/electron-backend/src/app/database/connection-schema.spec.ts b/apps/electron-backend/src/app/database/connection-schema.spec.ts new file mode 100644 index 000000000..0535311e2 --- /dev/null +++ b/apps/electron-backend/src/app/database/connection-schema.spec.ts @@ -0,0 +1,48 @@ +import { __databaseConnectionTestHooks } from 'database'; + +function statementContaining(pattern: string): string { + const statement = + __databaseConnectionTestHooks.createTableStatements.find((entry) => + entry.includes(pattern) + ) ?? + __databaseConnectionTestHooks.indexMigrationStatements.find((entry) => + entry.includes(pattern) + ) ?? + __databaseConnectionTestHooks.columnMigrationStatements.find((entry) => + entry.includes(pattern) + ); + + if (!statement) { + throw new Error(`Expected database statement containing "${pattern}"`); + } + + return statement; +} + +describe('database connection schema', () => { + it('creates fresh Xtream cache tables with the unique targets used by IPC writes', () => { + expect(statementContaining('CREATE TABLE IF NOT EXISTS categories')).toContain( + 'UNIQUE(playlist_id, type, xtream_id)' + ); + expect(statementContaining('CREATE TABLE IF NOT EXISTS content')).toContain( + 'UNIQUE(category_id, type, xtream_id)' + ); + }); + + it('creates the favorites position index only after the position migration can run', () => { + expect(statementContaining('CREATE TABLE IF NOT EXISTS favorites')).toContain( + 'position INTEGER DEFAULT 0' + ); + expect( + __databaseConnectionTestHooks.createTableStatements.some((entry) => + entry.includes('favorites_playlist_position_idx') + ) + ).toBe(false); + expect(statementContaining('ALTER TABLE favorites ADD COLUMN position')).toBe( + 'ALTER TABLE favorites ADD COLUMN position INTEGER DEFAULT 0' + ); + expect(statementContaining('favorites_playlist_position_idx')).toContain( + 'CREATE INDEX IF NOT EXISTS favorites_playlist_position_idx' + ); + }); +}); diff --git a/apps/electron-backend/src/app/database/operations/content.operations.spec.ts b/apps/electron-backend/src/app/database/operations/content.operations.spec.ts index f09809da8..69314a23c 100644 --- a/apps/electron-backend/src/app/database/operations/content.operations.spec.ts +++ b/apps/electron-backend/src/app/database/operations/content.operations.spec.ts @@ -20,7 +20,7 @@ jest.mock('drizzle-orm', () => ({ import * as schema from 'database-schema'; import type { AppDatabase } from '../database.types'; -import { getContentByXtreamId } from './content.operations'; +import { getContentByXtreamId, saveContent } from './content.operations'; function createDbMock(result: unknown[] = []) { const limit = jest.fn().mockResolvedValue(result); @@ -91,4 +91,79 @@ describe('content.operations', () => { expect(eqMock).not.toHaveBeenCalledWith(schema.content.type, 'series'); expect(where.mock.calls[0][0].conditions).toHaveLength(2); }); + + it('uses a synchronous better-sqlite transaction callback when saving content', async () => { + const existingContentWhere = jest.fn().mockResolvedValue([{ count: 0 }]); + const existingContentInnerJoin = jest + .fn() + .mockReturnValue({ where: existingContentWhere }); + const existingContentFrom = jest + .fn() + .mockReturnValue({ innerJoin: existingContentInnerJoin }); + + const categoriesWhere = jest.fn().mockResolvedValue([ + { + id: 42, + xtreamId: 201, + }, + ]); + const categoriesFrom = jest + .fn() + .mockReturnValue({ where: categoriesWhere }); + + const select = jest + .fn() + .mockReturnValueOnce({ from: existingContentFrom }) + .mockReturnValueOnce({ from: categoriesFrom }); + + const run = jest.fn(); + const onConflictDoNothing = jest.fn().mockReturnValue({ run }); + const values = jest.fn().mockReturnValue({ onConflictDoNothing }); + const insert = jest.fn().mockReturnValue({ values }); + const transactionResults: unknown[] = []; + const transaction = jest.fn((callback: (tx: unknown) => unknown) => { + const result = callback({ insert }); + transactionResults.push(result); + + if ( + typeof result === 'object' && + result !== null && + 'then' in result + ) { + throw new Error('Transaction function cannot return a promise'); + } + + return result; + }); + const db = { + select, + transaction, + } as unknown as AppDatabase; + + await expect( + saveContent( + db, + 'playlist-1', + [ + { + category_id: '201', + name: 'News Live', + stream_id: '100', + }, + ], + 'live' + ) + ).resolves.toEqual({ success: true, count: 1 }); + + expect(transactionResults).toEqual([undefined]); + expect(values).toHaveBeenCalledWith([ + expect.objectContaining({ + categoryId: 42, + title: 'News Live', + xtreamId: 100, + type: 'live', + }), + ]); + expect(run).toHaveBeenCalled(); + }); }); diff --git a/apps/electron-backend/src/app/database/operations/content.operations.ts b/apps/electron-backend/src/app/database/operations/content.operations.ts index ed38fecf9..444d4aecc 100644 --- a/apps/electron-backend/src/app/database/operations/content.operations.ts +++ b/apps/electron-backend/src/app/database/operations/content.operations.ts @@ -124,7 +124,6 @@ export async function getGlobalRecentlyAdded( const normalizedLimit = Number.isFinite(limit) ? Math.min(Math.max(Math.trunc(limit), 1), 200) : 200; - const addedOrder = sql`CAST(${schema.content.added} AS INTEGER)`; const whereConditions = [ inArray(schema.content.type, contentTypes), @@ -136,6 +135,12 @@ export async function getGlobalRecentlyAdded( whereConditions.push(eq(schema.playlists.type, playlistType)); } + // Sort by `added` directly. Xtream stores Unix-epoch timestamps as + // 10-digit numeric strings (since 2001-09-09), so lexicographic sort + // is equivalent to numeric sort. Wrapping the column in CAST(... AS + // INTEGER) — as we used to — blocks SQLite from using + // idx_content_type_added and forces a full table scan + sort on the + // entire content table (often 100k+ rows) on every dashboard load. return db .select({ ...selectContentFields(), @@ -153,7 +158,7 @@ export async function getGlobalRecentlyAdded( eq(schema.categories.playlistId, schema.playlists.id) ) .where(and(...whereConditions)) - .orderBy(desc(addedOrder)) + .orderBy(desc(schema.content.added)) .limit(normalizedLimit); } @@ -311,16 +316,19 @@ export async function saveContent( for (let index = 0; index < values.length; index += chunkSize) { await checkpointOperation(control); const chunk = values.slice(index, index + chunkSize); - await db - .insert(schema.content) - .values(chunk) - .onConflictDoNothing({ - target: [ - schema.content.categoryId, - schema.content.type, - schema.content.xtreamId, - ], - }); + await db.transaction((tx) => { + tx + .insert(schema.content) + .values(chunk) + .onConflictDoNothing({ + target: [ + schema.content.categoryId, + schema.content.type, + schema.content.xtreamId, + ], + }) + .run(); + }); totalInserted += chunk.length; await reportOperationProgress(control, { phase: 'saving-content', @@ -365,13 +373,21 @@ export async function clearXtreamImportCache( contentRows.map((row) => row.id), 100 )) { - await db.delete(schema.content).where(inArray(schema.content.id, chunk)); + await db.transaction((tx) => { + tx + .delete(schema.content) + .where(inArray(schema.content.id, chunk)) + .run(); + }); } for (const chunk of chunkValues(categoryIds, 100)) { - await db - .delete(schema.categories) - .where(inArray(schema.categories.id, chunk)); + await db.transaction((tx) => { + tx + .delete(schema.categories) + .where(inArray(schema.categories.id, chunk)) + .run(); + }); } return { success: true }; diff --git a/apps/electron-backend/src/app/database/operations/favorites.operations.ts b/apps/electron-backend/src/app/database/operations/favorites.operations.ts index fb0ab1f90..3c6db8eeb 100644 --- a/apps/electron-backend/src/app/database/operations/favorites.operations.ts +++ b/apps/electron-backend/src/app/database/operations/favorites.operations.ts @@ -168,15 +168,25 @@ export async function reorderGlobalFavorites( let current = 0; const total = updates.length; + // Drizzle's .set() doesn't accept a bare Placeholder — wrap it in an + // sql template so the value resolves to SQL at compile time. + const updateFavoritePosition = db + .update(schema.favorites) + .set({ position: sql`${sql.placeholder('position')}` }) + .where(eq(schema.favorites.contentId, sql.placeholder('contentId'))) + .prepare(); + for (const chunk of chunkValues(updates, DEFAULT_BATCH_SIZE)) { await checkpointOperation(control); - for (const { content_id, position } of chunk) { - await db - .update(schema.favorites) - .set({ position }) - .where(eq(schema.favorites.contentId, content_id)); - } + await db.transaction(() => { + for (const { content_id, position } of chunk) { + updateFavoritePosition.execute({ + position, + contentId: content_id, + }); + } + }); current += chunk.length; await reportOperationProgress(control, { diff --git a/apps/electron-backend/src/app/database/operations/playlist.operations.ts b/apps/electron-backend/src/app/database/operations/playlist.operations.ts index bd9e4cdef..b8cc19493 100644 --- a/apps/electron-backend/src/app/database/operations/playlist.operations.ts +++ b/apps/electron-backend/src/app/database/operations/playlist.operations.ts @@ -248,26 +248,28 @@ export async function upsertAppPlaylists( return { success: true, count: 0 }; } - let count = 0; + const rows = playlists + .map((playlist) => buildPlaylistRow(playlist)) + .filter((row): row is NonNullable => row !== null); - for (const playlist of playlists) { - const row = buildPlaylistRow(playlist); - if (!row) { - continue; - } - - await db - .insert(schema.playlists) - .values(row) - .onConflictDoUpdate({ - target: schema.playlists.id, - set: row, - }); - - count += 1; + if (rows.length === 0) { + return { success: true, count: 0 }; } - return { success: true, count }; + await db.transaction((tx) => { + for (const row of rows) { + tx + .insert(schema.playlists) + .values(row) + .onConflictDoUpdate({ + target: schema.playlists.id, + set: row, + }) + .run(); + } + }); + + return { success: true, count: rows.length }; } export async function getAppPlaylists(db: AppDatabase) { @@ -395,7 +397,9 @@ export async function deletePlaylist( for (const chunk of chunkValues(ids, DEFAULT_BATCH_SIZE)) { await checkpointOperation(control); - await db.delete(table).where(inArray(column, chunk)); + await db.transaction((tx) => { + tx.delete(table).where(inArray(column, chunk)).run(); + }); current += chunk.length; await reportOperationProgress(control, { phase, diff --git a/apps/electron-backend/src/app/database/operations/recently-viewed.operations.ts b/apps/electron-backend/src/app/database/operations/recently-viewed.operations.ts index 854153ca6..04b6fea49 100644 --- a/apps/electron-backend/src/app/database/operations/recently-viewed.operations.ts +++ b/apps/electron-backend/src/app/database/operations/recently-viewed.operations.ts @@ -150,3 +150,36 @@ export async function removeRecentItem( return { success: true }; } + +export async function removeRecentItemsBatch( + db: AppDatabase, + items: { contentId: number; playlistId: string }[] +): Promise<{ success: boolean; count: number }> { + if (!Array.isArray(items) || items.length === 0) { + return { success: true, count: 0 }; + } + + const stmt = db + .delete(schema.recentlyViewed) + .where( + and( + eq( + schema.recentlyViewed.contentId, + sql.placeholder('contentId') + ), + eq( + schema.recentlyViewed.playlistId, + sql.placeholder('playlistId') + ) + ) + ) + .prepare(); + + await db.transaction(() => { + for (const { contentId, playlistId } of items) { + stmt.execute({ contentId, playlistId }); + } + }); + + return { success: true, count: items.length }; +} diff --git a/apps/electron-backend/src/app/database/operations/xtream.operations.ts b/apps/electron-backend/src/app/database/operations/xtream.operations.ts index 7b51ec8ee..cc4980945 100644 --- a/apps/electron-backend/src/app/database/operations/xtream.operations.ts +++ b/apps/electron-backend/src/app/database/operations/xtream.operations.ts @@ -123,9 +123,12 @@ export async function deleteXtreamContent( DEFAULT_BATCH_SIZE )) { await checkpointOperation(control); - await db - .delete(schema.content) - .where(inArray(schema.content.id, chunk)); + await db.transaction((tx) => { + tx + .delete(schema.content) + .where(inArray(schema.content.id, chunk)) + .run(); + }); deletedContent += chunk.length; await reportOperationProgress(control, { phase: 'deleting-content', @@ -141,9 +144,12 @@ export async function deleteXtreamContent( for (const chunk of chunkValues(categoryIds, DEFAULT_BATCH_SIZE)) { await checkpointOperation(control); - await db - .delete(schema.categories) - .where(inArray(schema.categories.id, chunk)); + await db.transaction((tx) => { + tx + .delete(schema.categories) + .where(inArray(schema.categories.id, chunk)) + .run(); + }); deletedCategories += chunk.length; await reportOperationProgress(control, { phase: 'deleting-categories', @@ -264,7 +270,9 @@ export async function restoreXtreamUserData( for (const chunk of chunkValues(favoriteValues, DEFAULT_BATCH_SIZE)) { await checkpointOperation(control); - await db.insert(schema.favorites).values(chunk); + await db.transaction((tx) => { + tx.insert(schema.favorites).values(chunk).run(); + }); restoredFavorites += chunk.length; await reportOperationProgress(control, { phase: 'restoring-favorites', @@ -305,7 +313,9 @@ export async function restoreXtreamUserData( for (const chunk of chunkValues(recentlyViewedValues, DEFAULT_BATCH_SIZE)) { await checkpointOperation(control); - await db.insert(schema.recentlyViewed).values(chunk); + await db.transaction((tx) => { + tx.insert(schema.recentlyViewed).values(chunk).run(); + }); restoredRecentlyViewed += chunk.length; await reportOperationProgress(control, { phase: 'restoring-recently-viewed', diff --git a/apps/electron-backend/src/app/events/database/recently-viewed.events.ts b/apps/electron-backend/src/app/events/database/recently-viewed.events.ts index 9a871f2f4..041989d65 100644 --- a/apps/electron-backend/src/app/events/database/recently-viewed.events.ts +++ b/apps/electron-backend/src/app/events/database/recently-viewed.events.ts @@ -35,3 +35,10 @@ handleWorkerRequest( playlistId, }) ); + +handleWorkerRequest( + 'DB_REMOVE_RECENT_ITEMS_BATCH', + (items: { contentId: number; playlistId: string }[]) => ({ + items, + }) +); 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..c55628fe4 --- /dev/null +++ b/apps/electron-backend/src/app/events/embedded-mpv.events.ts @@ -0,0 +1,106 @@ +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_ASPECT, + EMBEDDED_MPV_SET_AUDIO_TRACK, + EMBEDDED_MPV_SET_BOUNDS, + EMBEDDED_MPV_SET_PAUSED, + EMBEDDED_MPV_SET_SPEED, + EMBEDDED_MPV_SET_SUBTITLE_TRACK, + 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_SET_SUBTITLE_TRACK, + (_event, sessionId: string, trackId: number) => + getService().setSubtitleTrack(sessionId, trackId) +); + +ipcMain.handle( + EMBEDDED_MPV_SET_SPEED, + (_event, sessionId: string, speed: number) => + getService().setSpeed(sessionId, speed) +); + +ipcMain.handle( + EMBEDDED_MPV_SET_ASPECT, + (_event, sessionId: string, aspect: string) => + getService().setAspect(sessionId, aspect) +); + +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/events/epg.events.ts b/apps/electron-backend/src/app/events/epg.events.ts index ee8000e6d..4a7e31c91 100644 --- a/apps/electron-backend/src/app/events/epg.events.ts +++ b/apps/electron-backend/src/app/events/epg.events.ts @@ -1,4 +1,4 @@ -import { eq, sql } from 'drizzle-orm'; +import { and, eq, gte, inArray, lte, sql } from 'drizzle-orm'; import { app, BrowserWindow, ipcMain } from 'electron'; import * as path from 'path'; import { EpgChannelMetadata, EpgProgram } from 'shared-interfaces'; @@ -81,6 +81,14 @@ export default class EpgEvents { } ); + // Get current programs for many channels in a single batched query + ipcMain.handle( + 'GET_CURRENT_PROGRAMS_BATCH', + async (_event, args: { channelIds: string[] }) => { + return this.handleGetCurrentProgramsBatch(args.channelIds); + } + ); + // Get all channels from database ipcMain.handle('EPG_GET_CHANNELS', async () => { return this.handleGetAllChannels(); @@ -635,6 +643,115 @@ export default class EpgEvents { } } + /** + * Batch lookup of "currently playing" programs for many channels in a + * single SQL query. Replaces the N+1 pattern where the channel list + * fired one IPC + query per visible channel. + */ + private static async handleGetCurrentProgramsBatch( + channelIds: string[] + ): Promise> { + const result: Record = {}; + if (!Array.isArray(channelIds) || channelIds.length === 0) { + return result; + } + + const validIds = Array.from( + new Set( + channelIds + .map((id) => id?.trim()) + .filter((id): id is string => Boolean(id)) + ) + ); + if (validIds.length === 0) { + return result; + } + + try { + const db = await getDatabase(); + const now = new Date().toISOString(); + + const rows = await db + .select() + .from(schema.epgPrograms) + .where( + and( + inArray(schema.epgPrograms.channelId, validIds), + lte(schema.epgPrograms.start, now), + gte(schema.epgPrograms.stop, now) + ) + ); + + for (const row of rows) { + if (!result[row.channelId]) { + const program = this.transformDbRowToEpgProgram(row); + if (this.isValidEpgProgram(program)) { + result[row.channelId] = program; + } + } + } + + // Per-channel fallback for IDs that didn't match by exact channel_id. + // Mirrors handleGetChannelPrograms's NOCASE-id and display-name resolution. + const unmatchedIds = validIds.filter((id) => !(id in result)); + for (const channelId of unmatchedIds) { + result[channelId] = null; + + let channel = await db + .select() + .from(schema.epgChannels) + .where( + sql`${schema.epgChannels.id} = ${channelId} COLLATE NOCASE` + ) + .limit(1); + + if (channel.length === 0) { + channel = await db + .select() + .from(schema.epgChannels) + .where( + sql`${schema.epgChannels.displayName} = ${channelId} COLLATE NOCASE` + ) + .limit(1); + } + + if (channel.length === 0) { + continue; + } + + const programRows = await db + .select() + .from(schema.epgPrograms) + .where( + and( + eq(schema.epgPrograms.channelId, channel[0].id), + lte(schema.epgPrograms.start, now), + gte(schema.epgPrograms.stop, now) + ) + ) + .limit(1); + + if (programRows.length > 0) { + const program = this.transformDbRowToEpgProgram( + programRows[0] + ); + if (this.isValidEpgProgram(program)) { + result[channelId] = program; + } + } + } + + return result; + } catch (error) { + console.error( + this.loggerLabel, + 'Error getting batch current programs:', + error + ); + return result; + } + } + /** * Get all channels from database */ diff --git a/apps/electron-backend/src/app/events/player.events.spec.ts b/apps/electron-backend/src/app/events/player.events.spec.ts index 016b0c59b..a13cb57e1 100644 --- a/apps/electron-backend/src/app/events/player.events.spec.ts +++ b/apps/electron-backend/src/app/events/player.events.spec.ts @@ -15,6 +15,7 @@ jest.mock('../services/store.service', () => ({ MPV_PLAYER_PATH: 'MPV_PLAYER_PATH', MPV_REUSE_INSTANCE: 'MPV_REUSE_INSTANCE', VLC_PLAYER_PATH: 'VLC_PLAYER_PATH', + VLC_REUSE_INSTANCE: 'VLC_REUSE_INSTANCE', store: { get: jest.fn(), set: jest.fn(), @@ -25,13 +26,22 @@ jest.mock('../services/stalker-playback-context.service', () => ({ getStalkerPlaybackContextHeaders: jest.fn(() => undefined), })); +import { ipcMain } from 'electron'; +import { + MPV_PLAYER_PATH, + store, + VLC_PLAYER_PATH, + VLC_REUSE_INSTANCE, +} from '../services/store.service'; import { buildExternalPlayerSpawnSpec, + buildVlcEnqueueCommands, isRunningInFlatpak, parseVlcRcPlaybackState, parseVlcRcNumericResponse, resolveExternalPlayerLaunchContext, shouldReuseMpvInstance, + shouldReuseVlcInstance, shouldUseMpvSocketBridge, } from './player.events'; @@ -39,6 +49,22 @@ function createPathExists(existingPaths: string[]) { return (candidatePath: string) => existingPaths.includes(candidatePath); } +function getIpcMainHandler(channel: string): (...args: unknown[]) => unknown { + const handleMock = ipcMain.handle as unknown as jest.Mock; + const calls = handleMock.mock.calls as Array< + [string, (...args: unknown[]) => unknown] + >; + const match = calls.find( + ([registeredChannel]) => registeredChannel === channel + ); + + if (!match) { + throw new Error(`Missing ipcMain handler for ${channel}`); + } + + return match[1]; +} + describe('player.events Flatpak launch helpers', () => { it('detects Flatpak only on Linux when /.flatpak-info exists', () => { expect(isRunningInFlatpak(createPathExists(['/.flatpak-info']), 'linux')).toBe( @@ -128,6 +154,12 @@ describe('player.events Flatpak launch helpers', () => { expect(shouldUseMpvSocketBridge(false)).toBe(true); }); + it('disables VLC reuse only in Flatpak', () => { + expect(shouldReuseVlcInstance(true, true)).toBe(false); + expect(shouldReuseVlcInstance(true, false)).toBe(true); + expect(shouldReuseVlcInstance(false, false)).toBe(false); + }); + it('parses numeric VLC RC responses that include the prompt prefix', () => { expect( parseVlcRcNumericResponse(`VLC media player 3.0.20 Vetinari @@ -170,3 +202,90 @@ Command Line Interface initialized. Type \`help' for help. ).toBe('stopped'); }); }); + +describe('player.events external player path settings', () => { + beforeEach(() => { + (store.set as unknown as jest.Mock).mockClear(); + }); + + it('stores cleared VLC player paths as an empty string', () => { + getIpcMainHandler('SET_VLC_PLAYER_PATH')({}, ' '); + + expect(store.set).toHaveBeenCalledWith(VLC_PLAYER_PATH, ''); + }); + + it('trims custom MPV player paths before storing them', () => { + getIpcMainHandler('SET_MPV_PLAYER_PATH')( + {}, + ' /Applications/mpv.app/Contents/MacOS/mpv ' + ); + + expect(store.set).toHaveBeenCalledWith( + MPV_PLAYER_PATH, + '/Applications/mpv.app/Contents/MacOS/mpv' + ); + }); + + it('persists the VLC reuse-instance preference', () => { + getIpcMainHandler('SET_VLC_REUSE_INSTANCE')({}, true); + + expect(store.set).toHaveBeenCalledWith(VLC_REUSE_INSTANCE, true); + }); +}); + +describe('buildVlcEnqueueCommands', () => { + it('clears the playlist and adds the URL with no extra options', () => { + expect( + buildVlcEnqueueCommands({ url: 'http://stream.example/a.m3u8' }) + ).toEqual(['clear', 'add http://stream.example/a.m3u8']); + }); + + it('attaches per-input HTTP options inline with the add command', () => { + const commands = buildVlcEnqueueCommands({ + url: 'http://stream.example/a.m3u8', + title: 'Channel One', + userAgent: 'Custom/1.0', + referer: 'https://referer.example', + headers: { 'X-Token': 'abc' }, + }); + + expect(commands[0]).toBe('clear'); + expect(commands[1]).toBe( + 'add http://stream.example/a.m3u8 :http-user-agent=Custom/1.0 :http-referrer=https://referer.example :http-header=X-Token: abc :meta-title=Channel One' + ); + }); + + it('falls back to origin when referer is absent', () => { + const commands = buildVlcEnqueueCommands({ + url: 'http://stream.example/a.m3u8', + origin: 'https://origin.example', + }); + + expect(commands[1]).toContain( + ':http-referrer=https://origin.example' + ); + }); + + it('appends a seek command when startTime is provided', () => { + const commands = buildVlcEnqueueCommands({ + url: 'http://stream.example/a.m3u8', + startTime: 42.7, + }); + + expect(commands).toEqual([ + 'clear', + 'add http://stream.example/a.m3u8', + 'seek 42', + ]); + }); + + it('skips empty header values', () => { + const commands = buildVlcEnqueueCommands({ + url: 'http://stream.example/a.m3u8', + headers: { 'X-Empty': ' ', 'X-Real': 'value' }, + }); + + expect(commands[1]).toContain(':http-header=X-Real: value'); + expect(commands[1]).not.toContain('X-Empty'); + }); +}); diff --git a/apps/electron-backend/src/app/events/player.events.ts b/apps/electron-backend/src/app/events/player.events.ts index f8de5c802..689e8b03f 100644 --- a/apps/electron-backend/src/app/events/player.events.ts +++ b/apps/electron-backend/src/app/events/player.events.ts @@ -11,6 +11,7 @@ import { MPV_REUSE_INSTANCE, store, VLC_PLAYER_PATH, + VLC_REUSE_INSTANCE, } from '../services/store.service'; import { ChildProcess, spawn } from 'child_process'; @@ -31,6 +32,10 @@ let mpvProcess: ChildProcess | null = null; let mpvSocketPath: string | null = null; let positionPollingInterval: NodeJS.Timeout | null = null; +// Keep track of the running VLC process for reuse +let vlcProcess: ChildProcess | null = null; +let vlcRcPort: number | null = null; + interface ExternalPlaybackSnapshot { positionSeconds: number; durationSeconds: number | null; @@ -77,11 +82,17 @@ export function isRunningInFlatpak( return platform === 'linux' && pathExists('/.flatpak-info'); } -function normalizeCustomPlayerPath(value: string | undefined): string | null { +function normalizeCustomPlayerPath( + value: string | null | undefined +): string | null { const trimmedValue = value?.trim(); return trimmedValue ? trimmedValue : null; } +function normalizePlayerPathForStore(value: string | null | undefined): string { + return normalizeCustomPlayerPath(value) ?? ''; +} + function getDefaultPlayerPath( player: ExternalPlayerName, options: PlayerPathOptions = {} @@ -163,6 +174,96 @@ export function shouldUseMpvSocketBridge( return !isFlatpak; } +export function shouldReuseVlcInstance( + requestedReuseInstance: boolean, + isFlatpak: boolean = isRunningInFlatpak() +): boolean { + return !isFlatpak && requestedReuseInstance; +} + +export function buildVlcEnqueueCommands(options: { + url: string; + title?: string; + userAgent?: string; + referer?: string; + origin?: string; + headers?: Record; + startTime?: number; +}): string[] { + const inputOptions: string[] = []; + + if (options.userAgent) { + inputOptions.push(`:http-user-agent=${options.userAgent}`); + } + if (options.referer) { + inputOptions.push(`:http-referrer=${options.referer}`); + } else if (options.origin) { + inputOptions.push(`:http-referrer=${options.origin}`); + } + Object.entries(options.headers ?? {}).forEach(([name, value]) => { + if (!name || value === undefined || value === null) return; + const trimmedValue = String(value).trim(); + if (!trimmedValue) return; + inputOptions.push(`:http-header=${name}: ${trimmedValue}`); + }); + if (options.title) { + inputOptions.push(`:meta-title=${options.title}`); + } + + const inputLine = + inputOptions.length > 0 + ? `${options.url} ${inputOptions.join(' ')}` + : options.url; + + const commands = ['clear', `add ${inputLine}`]; + + if (options.startTime && Number.isFinite(options.startTime)) { + commands.push(`seek ${Math.floor(options.startTime)}`); + } + + return commands; +} + +function sendVlcRcCommand(port: number, command: string): Promise { + return new Promise((resolve, reject) => { + const client = createConnection({ port, host: '127.0.0.1' }); + let settled = false; + + const finish = (err?: Error) => { + if (settled) return; + settled = true; + clearTimeout(timeoutHandle); + if (!client.destroyed) client.destroy(); + err ? reject(err) : resolve(); + }; + + const timeoutHandle = setTimeout( + () => finish(new Error('VLC RC command timed out')), + 2000 + ); + + client.on('connect', () => { + client.write(`${command}\n`); + }); + // The VLC RC interface emits its '> ' prompt after each command finishes. + client.on('data', (chunk) => { + if (chunk.toString().includes('>')) { + finish(); + } + }); + client.on('error', (err) => finish(err)); + }); +} + +async function sendVlcRcCommands( + port: number, + commands: string[] +): Promise { + for (const command of commands) { + await sendVlcRcCommand(port, command); + } +} + function buildPlayerStartError( player: 'MPV' | 'VLC', error: Error, @@ -921,10 +1022,14 @@ ipcMain.handle( } ); -ipcMain.handle('SET_MPV_PLAYER_PATH', (_event, mpvPlayerPath) => { - console.log('... setting mpv player path', mpvPlayerPath); - store.set(MPV_PLAYER_PATH, mpvPlayerPath); -}); +ipcMain.handle( + 'SET_MPV_PLAYER_PATH', + (_event, mpvPlayerPath: string | null | undefined) => { + const normalizedPlayerPath = normalizePlayerPathForStore(mpvPlayerPath); + console.log('... setting mpv player path', normalizedPlayerPath); + store.set(MPV_PLAYER_PATH, normalizedPlayerPath); + } +); ipcMain.handle('SET_MPV_REUSE_INSTANCE', (_event, reuseInstance: boolean) => { console.log('... setting mpv reuse instance', reuseInstance); @@ -968,6 +1073,14 @@ ipcMain.handle( getVlcPath({ isFlatpak }), { isFlatpak } ); + const requestedReuseInstance = store.get( + VLC_REUSE_INSTANCE, + false + ); + const reuseInstance = shouldReuseVlcInstance( + requestedReuseInstance, + isFlatpak + ); const fallbackHeaders = getStalkerPlaybackContextHeaders(url) ?? {}; const mergedHeaders = isStalkerDirectStreamProfile(fallbackHeaders) ? fallbackHeaders @@ -990,17 +1103,118 @@ ipcMain.handle( mergedHeaders['User-Agent'] ?? mergedHeaders['user-agent'] ?? undefined; - console.log('Opening VLC player with path:', vlcLaunchContext.playerPath); - console.log('VLC launch mode:', vlcLaunchContext.mode); - console.log('URL:', url); - // console.log('User-Agent:', userAgent); - // console.log('Referer:', referer); - // console.log('Origin:', origin); - // console.log('Start Time:', startTime); + console.log('[VLC] Opening player', { + path: vlcLaunchContext.playerPath, + launchMode: vlcLaunchContext.mode, + requestedReuseInstance, + reuseInstance, + stream: maskUrlForLogs(url), + hasUserAgent: Boolean(effectiveUserAgent), + hasReferer: Boolean(effectiveReferer), + hasOrigin: Boolean(effectiveOrigin), + hasContentInfo: Boolean(contentInfo), + startTime: startTime ?? null, + }); - // Get free port for RC interface + // Try to drive an already-running VLC via its RC port instead of + // spawning a new instance. + if ( + reuseInstance && + vlcProcess && + !vlcProcess.killed && + vlcRcPort + ) { + console.log( + 'Reusing existing VLC instance on RC port', + vlcRcPort + ); + try { + const enqueueCommands = buildVlcEnqueueCommands({ + url, + title, + userAgent: effectiveUserAgent, + referer: effectiveReferer, + origin: effectiveOrigin, + headers: mergedHeaders, + startTime, + }); + await sendVlcRcCommands(vlcRcPort, enqueueCommands); + console.log( + 'Successfully loaded new URL in existing VLC instance' + ); + + const reusedRcPort = vlcRcPort; + let lastReusedSnapshot: ExternalPlaybackSnapshot | null = + null; + externalPlayerSessions.attachCloser( + session.id, + async () => { + try { + await sendVlcRcCommand(reusedRcPort, 'stop'); + } catch { + if (vlcProcess && !vlcProcess.killed) { + vlcProcess.kill(); + } + } + } + ); + + if (contentInfo) { + startVlcPositionPolling( + reusedRcPort, + contentInfo, + session.id, + (snapshot) => { + lastReusedSnapshot = snapshot; + }, + () => { + if ( + lastReusedSnapshot && + contentInfo && + externalPlayerSessions.getSession( + session.id + )?.status !== 'closed' + ) { + sendPlaybackPositionUpdate( + session.id, + contentInfo, + lastReusedSnapshot + ); + } + externalPlayerSessions.markClosed(session.id); + } + ); + } else { + stopVlcPositionPolling(); + } + + return ( + externalPlayerSessions.markOpened(session.id) ?? session + ); + } catch (err) { + console.error( + 'Failed to reuse existing VLC, spawning fresh:', + err + ); + // Tracked process is not responsive; clear state and fall + // through to spawn a brand new VLC. + if (vlcProcess && !vlcProcess.killed) { + try { + vlcProcess.kill(); + } catch { + /* ignore */ + } + } + vlcProcess = null; + vlcRcPort = null; + stopVlcPositionPolling(); + } + } + + // Get free port for RC interface (always allocate when reuse is + // enabled so future calls can drive this instance). let rcPort = 0; - if (contentInfo) { + if (contentInfo || reuseInstance) { try { rcPort = await getFreePort(); console.log('Using VLC RC port:', rcPort); @@ -1063,12 +1277,24 @@ ipcMain.handle( vlcLaunchContext, playerArgs ); + const trackProcess = reuseInstance && !isRetry; const proc = spawn(spawnSpec.command, spawnSpec.args, { shell: false, - detached: true, - stdio: 'ignore', // Use 'ignore' for detached to allow clean shutdown + detached: !trackProcess, + stdio: trackProcess + ? ['ignore', 'pipe', 'pipe'] + : 'ignore', }); + if (trackProcess && rcPort > 0) { + vlcProcess = proc; + vlcRcPort = rcPort; + console.log( + 'Tracking VLC process for reuse on RC port', + rcPort + ); + } + const markVlcSessionClosed = () => { if ( externalPlayerSessions.getSession(session.id)?.status === @@ -1152,6 +1378,10 @@ ipcMain.handle( proc.on('error', (err) => { console.error('Failed to start VLC player:', err); + if (vlcProcess === proc) { + vlcProcess = null; + vlcRcPort = null; + } if (!isRetry && rcPort > 0) { console.log('Retrying VLC without RC interface...'); // Retry without RC args @@ -1179,6 +1409,10 @@ ipcMain.handle( proc.on('exit', (code) => { console.log(`VLC exited with code ${code}`); + if (vlcProcess === proc) { + vlcProcess = null; + vlcRcPort = null; + } stopVlcPositionPolling(); if ( @@ -1228,7 +1462,9 @@ ipcMain.handle( externalPlayerSessions.markClosed(session.id); }); - proc.unref(); + if (!trackProcess) { + proc.unref(); + } }; spawnVlc(args); @@ -1247,9 +1483,26 @@ ipcMain.handle( } ); -ipcMain.handle('SET_VLC_PLAYER_PATH', (_event, vlcPlayerPath) => { - console.log('... setting vlc player path', vlcPlayerPath); - store.set(VLC_PLAYER_PATH, vlcPlayerPath); +ipcMain.handle( + 'SET_VLC_PLAYER_PATH', + (_event, vlcPlayerPath: string | null | undefined) => { + const normalizedPlayerPath = normalizePlayerPathForStore(vlcPlayerPath); + console.log('... setting vlc player path', normalizedPlayerPath); + store.set(VLC_PLAYER_PATH, normalizedPlayerPath); + } +); + +ipcMain.handle('SET_VLC_REUSE_INSTANCE', (_event, reuseInstance: boolean) => { + console.log('... setting vlc reuse instance', reuseInstance); + store.set(VLC_REUSE_INSTANCE, reuseInstance); + + // If disabling reuse, kill the existing tracked process + if (!reuseInstance && vlcProcess && !vlcProcess.killed) { + console.log('Disabling reuse, cleaning up existing VLC process'); + vlcProcess.kill(); + vlcProcess = null; + vlcRcPort = null; + } }); ipcMain.handle( diff --git a/apps/electron-backend/src/app/events/settings.events.ts b/apps/electron-backend/src/app/events/settings.events.ts index 4d50233dc..322d5720f 100644 --- a/apps/electron-backend/src/app/events/settings.events.ts +++ b/apps/electron-backend/src/app/events/settings.events.ts @@ -2,6 +2,7 @@ import { ipcMain } from 'electron'; import { MPV_REUSE_INSTANCE, store, + VLC_REUSE_INSTANCE, } from '../services/store.service'; import { httpServer } from '../server/http-server'; @@ -19,6 +20,10 @@ ipcMain.handle('SETTINGS_UPDATE', (_event, arg) => { store.set(MPV_REUSE_INSTANCE, arg.mpvReuseInstance); } + if (arg.vlcReuseInstance !== undefined) { + store.set(VLC_REUSE_INSTANCE, arg.vlcReuseInstance); + } + // Handle remote control settings if (arg.remoteControl !== undefined || arg.remoteControlPort !== undefined) { const enabled = arg.remoteControl ?? store.get('remoteControl', false); diff --git a/apps/electron-backend/src/app/services/embedded-mpv-native.service.spec.ts b/apps/electron-backend/src/app/services/embedded-mpv-native.service.spec.ts new file mode 100644 index 000000000..fa731981b --- /dev/null +++ b/apps/electron-backend/src/app/services/embedded-mpv-native.service.spec.ts @@ -0,0 +1,215 @@ +import type { + EmbeddedMpvBounds, + EmbeddedMpvSessionStatus, + ResolvedPortalPlayback, +} from 'shared-interfaces'; +import type { EmbeddedMpvNativeService as EmbeddedMpvNativeServiceType } from './embedded-mpv-native.service'; + +const powerSaveBlockerMock = { + start: jest.fn(), + stop: jest.fn(), + isStarted: jest.fn(), +}; + +jest.mock('electron', () => ({ + app: { + isPackaged: true, + getAppPath: () => '/mock/app.asar', + }, + powerSaveBlocker: powerSaveBlockerMock, +})); + +const mainWindowSendMock = jest.fn(); +const mainWindowMock = { + isDestroyed: () => false, + getNativeWindowHandle: () => Buffer.alloc(8), + webContents: { send: mainWindowSendMock }, +}; + +jest.mock('../app', () => ({ + __esModule: true, + default: { + get mainWindow() { + return mainWindowMock; + }, + }, +})); + +interface MockSnapshot { + status: EmbeddedMpvSessionStatus; + positionSeconds: number; + durationSeconds: number | null; + volume: number; + streamUrl: string; + audioTracks?: never[]; + selectedAudioTrackId?: number | null; + error?: string; +} + +interface MockAddon { + isSupported: jest.Mock; + createSession: jest.Mock< + string, + [Buffer, EmbeddedMpvBounds, string?, number?] + >; + loadPlayback: jest.Mock; + setBounds: jest.Mock; + setPaused: jest.Mock; + seek: jest.Mock; + setVolume: jest.Mock; + setAudioTrack: jest.Mock; + getSessionSnapshot: jest.Mock; + disposeSession: jest.Mock; +} + +function createMockAddon(): MockAddon { + return { + isSupported: jest.fn().mockReturnValue(true), + createSession: jest.fn(), + loadPlayback: jest.fn(), + setBounds: jest.fn(), + setPaused: jest.fn(), + seek: jest.fn(), + setVolume: jest.fn(), + setAudioTrack: jest.fn(), + getSessionSnapshot: jest.fn(), + disposeSession: jest.fn(), + }; +} + +const BOUNDS: EmbeddedMpvBounds = { x: 0, y: 0, width: 100, height: 100 }; + +describe('EmbeddedMpvNativeService power blocker', () => { + let EmbeddedMpvNativeService: typeof EmbeddedMpvNativeServiceType; + let service: EmbeddedMpvNativeServiceType; + let addon: MockAddon; + let nextBlockerId: number; + + beforeEach(async () => { + jest.resetModules(); + powerSaveBlockerMock.start.mockReset(); + powerSaveBlockerMock.stop.mockReset(); + powerSaveBlockerMock.isStarted.mockReset(); + mainWindowSendMock.mockReset(); + + nextBlockerId = 1; + powerSaveBlockerMock.start.mockImplementation(() => nextBlockerId++); + powerSaveBlockerMock.isStarted.mockReturnValue(true); + + ({ EmbeddedMpvNativeService } = await import( + './embedded-mpv-native.service' + )); + service = new EmbeddedMpvNativeService(); + addon = createMockAddon(); + // The addon is normally loaded via createRequire from a vendored .node + // file. For unit tests we inject a mock implementation directly. + (service as unknown as { addon: MockAddon }).addon = addon; + }); + + afterEach(() => { + service.shutdown(); + }); + + function startSession( + sessionId: string, + snapshot: MockSnapshot + ): void { + addon.createSession.mockReturnValueOnce(sessionId); + addon.getSessionSnapshot.mockReturnValueOnce(snapshot); + service.createSession(BOUNDS, '', 1); + } + + function snapshot( + status: EmbeddedMpvSessionStatus, + overrides: Partial = {} + ): MockSnapshot { + return { + status, + positionSeconds: 0, + durationSeconds: null, + volume: 1, + streamUrl: 'mock://stream', + ...overrides, + }; + } + + it('does not acquire a blocker for a loading session', () => { + startSession('s1', snapshot('loading')); + expect(powerSaveBlockerMock.start).not.toHaveBeenCalled(); + }); + + it('acquires a single prevent-display-sleep blocker once a session is playing', () => { + startSession('s1', snapshot('loading')); + + addon.getSessionSnapshot.mockReturnValueOnce(snapshot('playing')); + service.setPaused('s1', false); + + expect(powerSaveBlockerMock.start).toHaveBeenCalledTimes(1); + expect(powerSaveBlockerMock.start).toHaveBeenCalledWith( + 'prevent-display-sleep' + ); + + // Subsequent refreshes while still playing must not start a second blocker. + addon.getSessionSnapshot.mockReturnValueOnce(snapshot('playing')); + service.setPaused('s1', false); + expect(powerSaveBlockerMock.start).toHaveBeenCalledTimes(1); + }); + + it('releases the blocker when the session transitions to paused', () => { + startSession('s1', snapshot('playing')); + expect(powerSaveBlockerMock.start).toHaveBeenCalledTimes(1); + + addon.getSessionSnapshot.mockReturnValueOnce(snapshot('paused')); + service.setPaused('s1', true); + + expect(powerSaveBlockerMock.stop).toHaveBeenCalledTimes(1); + expect(powerSaveBlockerMock.stop).toHaveBeenCalledWith(1); + }); + + it('keeps the blocker held while any other session is still playing', () => { + startSession('s1', snapshot('playing')); + startSession('s2', snapshot('playing')); + + // Only one blocker for both sessions. + expect(powerSaveBlockerMock.start).toHaveBeenCalledTimes(1); + + // s1 pauses — s2 still playing, so do not release. + addon.getSessionSnapshot.mockReturnValueOnce(snapshot('paused')); + service.setPaused('s1', true); + expect(powerSaveBlockerMock.stop).not.toHaveBeenCalled(); + + // s2 pauses — now there is nothing playing. + addon.getSessionSnapshot.mockReturnValueOnce(snapshot('paused')); + service.setPaused('s2', true); + expect(powerSaveBlockerMock.stop).toHaveBeenCalledTimes(1); + }); + + it('releases the blocker when the playing session is disposed', () => { + startSession('s1', snapshot('playing')); + expect(powerSaveBlockerMock.start).toHaveBeenCalledTimes(1); + + service.disposeSession('s1'); + + expect(addon.disposeSession).toHaveBeenCalledWith('s1'); + expect(powerSaveBlockerMock.stop).toHaveBeenCalledTimes(1); + }); + + it('releases the blocker on shutdown', () => { + startSession('s1', snapshot('playing')); + expect(powerSaveBlockerMock.start).toHaveBeenCalledTimes(1); + + service.shutdown(); + + expect(powerSaveBlockerMock.stop).toHaveBeenCalled(); + }); + + it('skips powerSaveBlocker.stop if the assertion was already cleared externally', () => { + startSession('s1', snapshot('playing')); + powerSaveBlockerMock.isStarted.mockReturnValue(false); + + addon.getSessionSnapshot.mockReturnValueOnce(snapshot('paused')); + service.setPaused('s1', true); + + expect(powerSaveBlockerMock.stop).not.toHaveBeenCalled(); + }); +}); 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..d1f8eeaa0 --- /dev/null +++ b/apps/electron-backend/src/app/services/embedded-mpv-native.service.ts @@ -0,0 +1,660 @@ +import { app, powerSaveBlocker } from 'electron'; +import { existsSync, readFileSync } from 'fs'; +import { createRequire } from 'module'; +import path from 'path'; +import App from '../app'; +import { + EmbeddedMpvAudioTrack, + EmbeddedMpvBounds, + EmbeddedMpvCapabilities, + EmbeddedMpvSession, + EmbeddedMpvSessionStatus, + EmbeddedMpvSubtitleTrack, + 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; + subtitleTracks?: EmbeddedMpvSubtitleTrack[]; + selectedSubtitleTrackId?: number | null; + playbackSpeed?: number; + aspectOverride?: string; + 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; + setSubtitleTrack?(sessionId: string, trackId: number): void; + setSpeed?(sessionId: string, speed: number): void; + setAspect?(sessionId: string, aspect: string): void; + getSessionSnapshot( + sessionId: string + ): NativeEmbeddedMpvSessionSnapshot | null; + disposeSession(sessionId: string): void; +} + +interface EmbeddedMpvRuntimeSession { + id: string; + title: string; + streamUrl: string; + startedAt: string; + updatedAt: string; + lastPayloadKey: string; + lastStatus: EmbeddedMpvSessionStatus | null; +} + +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 powerBlockerId: number | null = null; + private readonly loadAddonModule = createRequire(__filename); + + private detectCapabilities(): EmbeddedMpvCapabilities { + const addon = this.addon; + return { + subtitles: typeof addon?.setSubtitleTrack === 'function', + playbackSpeed: typeof addon?.setSpeed === 'function', + aspectOverride: typeof addon?.setAspect === 'function', + screenshot: false, + }; + } + + 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, + capabilities: this.detectCapabilities(), + }; + } 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, + capabilities: this.detectCapabilities(), + }; + } + + 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, + capabilities: this.detectCapabilities(), + }; + } 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: '', + lastStatus: null, + }); + + this.ensurePolling(); + return this.refreshSession(sessionId) ?? { + id: sessionId, + title, + streamUrl: '', + status: 'idle', + positionSeconds: 0, + durationSeconds: null, + volume: 1, + audioTracks: [], + selectedAudioTrackId: null, + subtitleTracks: [], + selectedSubtitleTrackId: null, + playbackSpeed: 1, + aspectOverride: 'no', + 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); + } + + setSubtitleTrack( + sessionId: string, + trackId: number + ): EmbeddedMpvSession | null { + this.assertEmbeddedMpvEnabled(); + const addon = this.getAddon(); + if (typeof addon.setSubtitleTrack !== 'function') { + throw new Error( + 'Embedded MPV addon does not support subtitle tracks. Rebuild the native addon to enable this feature.' + ); + } + addon.setSubtitleTrack(sessionId, trackId); + return this.refreshSession(sessionId); + } + + setSpeed(sessionId: string, speed: number): EmbeddedMpvSession | null { + this.assertEmbeddedMpvEnabled(); + const addon = this.getAddon(); + if (typeof addon.setSpeed !== 'function') { + throw new Error( + 'Embedded MPV addon does not support playback speed. Rebuild the native addon to enable this feature.' + ); + } + addon.setSpeed(sessionId, speed); + return this.refreshSession(sessionId); + } + + setAspect(sessionId: string, aspect: string): EmbeddedMpvSession | null { + this.assertEmbeddedMpvEnabled(); + const addon = this.getAddon(); + if (typeof addon.setAspect !== 'function') { + throw new Error( + 'Embedded MPV addon does not support aspect override. Rebuild the native addon to enable this feature.' + ); + } + addon.setAspect(sessionId, aspect); + 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, + subtitleTracks: [], + selectedSubtitleTrackId: null, + playbackSpeed: 1, + aspectOverride: 'no', + startedAt: session.startedAt, + updatedAt: new Date().toISOString(), + }; + this.sendSessionUpdate(payload); + this.stopPollingIfIdle(); + this.updatePowerBlocker(); + return payload; + } + } + + shutdown(): void { + const sessionIds = [...this.sessions.keys()]; + sessionIds.forEach((sessionId) => this.disposeSession(sessionId)); + if (this.pollingTimer) { + clearInterval(this.pollingTimer); + this.pollingTimer = null; + } + this.updatePowerBlocker(); + } + + 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, + subtitleTracks: Array.isArray(snapshot.subtitleTracks) + ? snapshot.subtitleTracks + : [], + selectedSubtitleTrackId: + typeof snapshot.selectedSubtitleTrackId === 'number' + ? snapshot.selectedSubtitleTrackId + : null, + playbackSpeed: + typeof snapshot.playbackSpeed === 'number' + ? snapshot.playbackSpeed + : 1, + aspectOverride: + typeof snapshot.aspectOverride === 'string' + ? snapshot.aspectOverride + : 'no', + startedAt: session.startedAt, + updatedAt: new Date().toISOString(), + ...(snapshot.error ? { error: snapshot.error } : {}), + }; + + session.streamUrl = payload.streamUrl; + session.updatedAt = payload.updatedAt; + session.lastStatus = payload.status; + const nextPayloadKey = JSON.stringify(payload); + if (session.lastPayloadKey !== nextPayloadKey) { + session.lastPayloadKey = nextPayloadKey; + this.sendSessionUpdate(payload); + } + + this.updatePowerBlocker(); + return payload; + } + + private hasPlayingSession(): boolean { + for (const session of this.sessions.values()) { + if (session.lastStatus === 'playing') { + return true; + } + } + return false; + } + + private updatePowerBlocker(): void { + const shouldBlock = this.hasPlayingSession(); + + if (shouldBlock && this.powerBlockerId === null) { + try { + this.powerBlockerId = powerSaveBlocker.start( + 'prevent-display-sleep' + ); + } catch { + this.powerBlockerId = null; + } + return; + } + + if (!shouldBlock && this.powerBlockerId !== null) { + const blockerId = this.powerBlockerId; + this.powerBlockerId = null; + try { + if (powerSaveBlocker.isStarted(blockerId)) { + powerSaveBlocker.stop(blockerId); + } + } catch { + // ignore — assertion will be cleaned up when the process exits + } + } + } + + 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/app/services/store.service.ts b/apps/electron-backend/src/app/services/store.service.ts index 1b026af08..d83b3e607 100644 --- a/apps/electron-backend/src/app/services/store.service.ts +++ b/apps/electron-backend/src/app/services/store.service.ts @@ -5,12 +5,14 @@ export const WINDOW_BOUNDS = 'WINDOW_BOUNDS'; export const MPV_PLAYER_PATH = 'MPV_PLAYER_PATH'; export const VLC_PLAYER_PATH = 'VLC_PLAYER_PATH'; export const MPV_REUSE_INSTANCE = 'MPV_REUSE_INSTANCE'; +export const VLC_REUSE_INSTANCE = 'VLC_REUSE_INSTANCE'; export type StoreType = { [WINDOW_BOUNDS]: Electron.Rectangle; [MPV_PLAYER_PATH]: string; [VLC_PLAYER_PATH]: string; [MPV_REUSE_INSTANCE]: boolean; + [VLC_REUSE_INSTANCE]: boolean; }; // Export singleton store instance diff --git a/apps/electron-backend/src/app/workers/database-worker.types.ts b/apps/electron-backend/src/app/workers/database-worker.types.ts index f8fe3a9f3..e7a86da44 100644 --- a/apps/electron-backend/src/app/workers/database-worker.types.ts +++ b/apps/electron-backend/src/app/workers/database-worker.types.ts @@ -39,6 +39,7 @@ export const DB_WORKER_OPERATIONS = [ 'DB_ADD_RECENT_ITEM', 'DB_CLEAR_PLAYLIST_RECENT_ITEMS', 'DB_REMOVE_RECENT_ITEM', + 'DB_REMOVE_RECENT_ITEMS_BATCH', 'DB_SAVE_PLAYBACK_POSITION', 'DB_GET_PLAYBACK_POSITION', 'DB_GET_SERIES_PLAYBACK_POSITIONS', diff --git a/apps/electron-backend/src/app/workers/database.worker-connection.ts b/apps/electron-backend/src/app/workers/database.worker-connection.ts index 8e6d79d78..de81d9781 100644 --- a/apps/electron-backend/src/app/workers/database.worker-connection.ts +++ b/apps/electron-backend/src/app/workers/database.worker-connection.ts @@ -80,6 +80,10 @@ export async function getWorkerDatabase(): Promise { sqlite.pragma('foreign_keys = ON'); sqlite.pragma('journal_mode = WAL'); sqlite.pragma('busy_timeout = 5000'); + sqlite.pragma('synchronous = NORMAL'); + sqlite.pragma('cache_size = -64000'); + sqlite.pragma('temp_store = MEMORY'); + sqlite.pragma('mmap_size = 268435456'); if (isSqlTraceEnabled()) { trace('sql-worker', 'open', { @@ -96,6 +100,12 @@ export function closeWorkerDatabase(): void { return; } + try { + sqlite.pragma('optimize'); + } catch { + // Optimize is advisory; never block close on it. + } + sqlite.close(); if (isSqlTraceEnabled()) { diff --git a/apps/electron-backend/src/app/workers/database.worker.ts b/apps/electron-backend/src/app/workers/database.worker.ts index c5b53c84b..4649e5167 100644 --- a/apps/electron-backend/src/app/workers/database.worker.ts +++ b/apps/electron-backend/src/app/workers/database.worker.ts @@ -73,6 +73,7 @@ import { getRecentItems, getRecentlyViewed, removeRecentItem, + removeRecentItemsBatch, } from '../database/operations/recently-viewed.operations'; import { deleteXtreamContent, @@ -761,6 +762,13 @@ async function executeRequest(message: DbWorkerRequestMessage) { return removeRecentItem(db, payload.contentId, payload.playlistId); } + case 'DB_REMOVE_RECENT_ITEMS_BATCH': { + const payload = message.payload as { + items: { contentId: number; playlistId: string }[]; + }; + return removeRecentItemsBatch(db, payload.items); + } + case 'DB_SAVE_PLAYBACK_POSITION': { const payload = message.payload as { playlistId: string; diff --git a/apps/electron-backend/src/main.ts b/apps/electron-backend/src/main.ts index bba772f58..ad3861d1a 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'; @@ -29,6 +32,34 @@ if (electronUserDataPath) { app.setPath('userData', electronUserDataPath); } +let fixPathScheduled = false; + +/** + * Update process.env.PATH from the user's interactive login shell so that + * spawned external players (MPV/VLC) can be resolved by binary name. + * + * Runs after window creation + IPC handler registration so the 50-300 ms + * shell-spawn cost (bash/zsh -ilc env) doesn't block startup. Idempotent: + * subsequent calls are no-ops. + */ +function scheduleDeferredFixPath(): void { + if (fixPathScheduled || process.platform === 'win32') { + return; + } + + fixPathScheduled = true; + setImmediate(() => { + try { + fixPath(); + if (isStartupTraceEnabled()) { + trace('startup', 'fix-path:done'); + } + } catch (error) { + console.warn('fix-path failed:', error); + } + }); +} + export default class Main { static initialize() { if (SquirrelEvents.handleEvents()) { @@ -57,6 +88,7 @@ export default class Main { } ElectronEvents.bootstrapElectronEvents(); + EmbeddedMpvEvents.bootstrapEmbeddedMpvEvents(); PlaylistEvents.bootstrapPlaylistEvents(); SharedEvents.bootstrapSharedEvents(); PlayerEvents.bootstrapPlayerEvents(); @@ -85,11 +117,18 @@ export default class Main { if (isStartupTraceEnabled()) { trace('startup', 'bootstrap-events:done'); } + + // Hydrate process.env.PATH from the user's login shell now — after + // the window has loaded and IPC handlers are live. Fire-and-forget + // (setImmediate) so it doesn't gate any user-visible work. Worst + // case: the user clicks an external player within the ~100 ms it + // takes to complete; the spawn would still find MPV/VLC at any of + // the well-known paths checked by getDefault*Path before falling + // back to bare-name PATH lookup. + scheduleDeferredFixPath(); } } -fixPath(); - // handle setup events as quickly as possible Main.initialize(); @@ -105,5 +144,6 @@ app.whenReady().then(async () => { }); app.on('before-quit', () => { + shutdownEmbeddedMpv(); void databaseWorkerClient.shutdown(); }); diff --git a/apps/stalker-mock-server/src/app/data-generator.ts b/apps/stalker-mock-server/src/app/data-generator.ts index 321e91769..0f0cb9c68 100644 --- a/apps/stalker-mock-server/src/app/data-generator.ts +++ b/apps/stalker-mock-server/src/app/data-generator.ts @@ -22,6 +22,18 @@ export interface RawChannel { xmltv_id: string; } +export interface RawRadioStation { + id: string; + name: string; + o_name: string; + cmd: string; + logo: string; + category_id: string; + tv_genre_id: string; + number: string; + radio: true; +} + export interface RawVodItem { id: string; name: string; @@ -102,7 +114,9 @@ export interface GeneratedPortalData { itvCategories: RawCategory[]; vodCategories: RawCategory[]; seriesCategories: RawCategory[]; + radioCategories: RawCategory[]; channels: Map; // categoryId -> channels + radio: Map; // categoryId -> radio stations vod: Map; // categoryId -> items series: Map; // categoryId -> items seasons: Map; // seriesItemId -> seasons @@ -146,7 +160,9 @@ export function generatePortalData(config: ScenarioConfig): GeneratedPortalData itvCategories: [], vodCategories: [], seriesCategories: [], + radioCategories: [], channels: new Map(), + radio: new Map(), vod: new Map(), series: new Map(), seasons: new Map(), @@ -165,6 +181,22 @@ export function generatePortalData(config: ScenarioConfig): GeneratedPortalData channelIndex += config.itemsPerCategory; } + // ------ Radio categories + stations ------ + data.radioCategories = generateCategories( + 'radio', + config.categoryCount.radio + ); + let radioIndex = 0; + for (const cat of data.radioCategories) { + const stations = generateRadioStations( + cat.id, + config.itemsPerCategory, + radioIndex + ); + data.radio.set(cat.id, stations); + radioIndex += config.itemsPerCategory; + } + // ------ VOD categories + items ------ data.vodCategories = generateCategories('vod', config.categoryCount.vod); let vodIndex = 0; @@ -223,16 +255,35 @@ const SERIES_GENRE_NAMES = [ 'Thriller Series', 'Horror Series', 'Western Series', ]; -function getGenreNames(type: 'itv' | 'vod' | 'series'): string[] { +const RADIO_GENRE_NAMES = [ + 'News Radio', 'Talk Radio', 'Jazz', 'Classical', 'Rock', + 'Pop Hits', 'Electronic', 'World Music', 'Sports Radio', 'Public Radio', + 'Local Stations', 'Latin', 'Hip Hop', 'Ambient', 'Oldies', + 'Country', 'Reggae', 'Soul', 'Dance', 'Weather', +]; + +function getGenreNames(type: 'itv' | 'vod' | 'series' | 'radio'): string[] { if (type === 'itv') return ITV_GENRE_NAMES; if (type === 'vod') return VOD_GENRE_NAMES; + if (type === 'radio') return RADIO_GENRE_NAMES; return SERIES_GENRE_NAMES; } -function generateCategories(type: 'itv' | 'vod' | 'series', count: number): RawCategory[] { +function generateCategories( + type: 'itv' | 'vod' | 'series' | 'radio', + count: number +): RawCategory[] { const names = getGenreNames(type); return Array.from({ length: count }, (_, i) => { - const id = String((type === 'itv' ? 1000 : type === 'vod' ? 2000 : 3000) + i + 1); + const idBase = + type === 'itv' + ? 1000 + : type === 'vod' + ? 2000 + : type === 'series' + ? 3000 + : 4000; + const id = String(idBase + i + 1); const title = names[i % names.length]; return { id, title, alias: title.toLowerCase().replace(/\s+/g, '_') }; }); @@ -260,6 +311,29 @@ function generateChannels(categoryId: string, count: number, startIndex: number) }); } +function generateRadioStations( + categoryId: string, + count: number, + startIndex: number +): RawRadioStation[] { + return Array.from({ length: count }, (_, i) => { + const globalIndex = startIndex + i; + const id = String(40000 + globalIndex); + const name = `${faker.music.genre()} ${faker.word.noun()} Radio`; + return { + id, + name, + o_name: name, + cmd: `ffrt4://radio/${id}/index.mp3`, + logo: logoUrl(`radio-${id}`), + category_id: categoryId, + tv_genre_id: categoryId, + number: String(globalIndex + 1), + radio: true, + }; + }); +} + // --------------------------------------------------------------------------- // VOD generators // --------------------------------------------------------------------------- diff --git a/apps/stalker-mock-server/src/app/handlers/create-link.handler.ts b/apps/stalker-mock-server/src/app/handlers/create-link.handler.ts index 5d083e56b..163ecc225 100644 --- a/apps/stalker-mock-server/src/app/handlers/create-link.handler.ts +++ b/apps/stalker-mock-server/src/app/handlers/create-link.handler.ts @@ -7,7 +7,7 @@ import { extractMac } from './get-categories.handler.js'; * * Query params: * cmd: the ffrt4:// or similar command from the content item - * type: itv | vod | series + * type: itv | vod | series | radio */ export function handleCreateLink(req: Request, res: Response): void { const mac = extractMac(req); diff --git a/apps/stalker-mock-server/src/app/handlers/favorites.handler.ts b/apps/stalker-mock-server/src/app/handlers/favorites.handler.ts index 5d9c1e93d..10b58dd93 100644 --- a/apps/stalker-mock-server/src/app/handlers/favorites.handler.ts +++ b/apps/stalker-mock-server/src/app/handlers/favorites.handler.ts @@ -6,7 +6,12 @@ import { removeFavorite, } from '../data-store.js'; import { extractMac } from './get-categories.handler.js'; -import { RawChannel, RawSeriesItem, RawVodItem } from '../data-generator.js'; +import { + RawChannel, + RawRadioStation, + RawSeriesItem, + RawVodItem, +} from '../data-generator.js'; /** * Stalker favorites endpoint. @@ -15,7 +20,7 @@ import { RawChannel, RawSeriesItem, RawVodItem } from '../data-generator.js'; * action: favorites * fav_action: get | add | remove (some portals use set/unset) * item_id: the item id to add/remove - * type: itv | vod | series + * type: itv | vod | series | radio */ export function handleFavorites(req: Request, res: Response): void { const mac = extractMac(req); @@ -38,7 +43,7 @@ export function handleFavorites(req: Request, res: Response): void { const favIds = getFavorites(mac); const data = getPortalData(mac); - type AnyItem = RawChannel | RawVodItem | RawSeriesItem; + type AnyItem = RawChannel | RawRadioStation | RawVodItem | RawSeriesItem; const result: AnyItem[] = []; for (const id of favIds) { @@ -58,11 +63,15 @@ export function handleFavorites(req: Request, res: Response): void { function findItemById( data: ReturnType, id: string -): RawChannel | RawVodItem | RawSeriesItem | undefined { +): RawChannel | RawRadioStation | RawVodItem | RawSeriesItem | undefined { for (const items of data.channels.values()) { const found = items.find((i) => i.id === id); if (found) return found; } + for (const items of data.radio.values()) { + const found = items.find((i) => i.id === id); + if (found) return found; + } for (const items of data.vod.values()) { const found = items.find((i) => i.id === id); if (found) return found; diff --git a/apps/stalker-mock-server/src/app/handlers/get-categories.handler.ts b/apps/stalker-mock-server/src/app/handlers/get-categories.handler.ts index 8f57a0e49..e1b49293a 100644 --- a/apps/stalker-mock-server/src/app/handlers/get-categories.handler.ts +++ b/apps/stalker-mock-server/src/app/handlers/get-categories.handler.ts @@ -2,7 +2,7 @@ import { Request, Response } from 'express'; import { getPortalData } from '../data-store.js'; /** - * Stalker get_categories — returns category list filtered by type (itv/vod/series). + * Stalker get_categories — returns category list filtered by type. */ export function handleGetCategories(req: Request, res: Response): void { const mac = extractMac(req); @@ -12,6 +12,8 @@ export function handleGetCategories(req: Request, res: Response): void { let categories; if (type === 'itv') { categories = data.itvCategories; + } else if (type === 'radio') { + categories = data.radioCategories; } else if (type === 'series') { categories = data.seriesCategories; } else { diff --git a/apps/stalker-mock-server/src/app/handlers/get-genres.handler.ts b/apps/stalker-mock-server/src/app/handlers/get-genres.handler.ts index 1c82ad158..a31002aa0 100644 --- a/apps/stalker-mock-server/src/app/handlers/get-genres.handler.ts +++ b/apps/stalker-mock-server/src/app/handlers/get-genres.handler.ts @@ -14,6 +14,8 @@ export function handleGetGenres(req: Request, res: Response): void { let categories; if (type === 'itv') { categories = data.itvCategories; + } else if (type === 'radio') { + categories = data.radioCategories; } else if (type === 'series') { categories = data.seriesCategories; } else { diff --git a/apps/stalker-mock-server/src/app/handlers/get-ordered-list.handler.ts b/apps/stalker-mock-server/src/app/handlers/get-ordered-list.handler.ts index a7affa37f..569ead57d 100644 --- a/apps/stalker-mock-server/src/app/handlers/get-ordered-list.handler.ts +++ b/apps/stalker-mock-server/src/app/handlers/get-ordered-list.handler.ts @@ -1,17 +1,22 @@ import { Request, Response } from 'express'; import { getPortalData } from '../data-store.js'; import { extractMac } from './get-categories.handler.js'; -import { RawChannel, RawSeriesItem, RawVodItem } from '../data-generator.js'; +import { + RawChannel, + RawRadioStation, + RawSeriesItem, + RawVodItem, +} from '../data-generator.js'; const PAGE_SIZE = 14; -type AnyItem = RawChannel | RawVodItem | RawSeriesItem; +type AnyItem = RawChannel | RawRadioStation | RawVodItem | RawSeriesItem; /** * Stalker get_ordered_list — returns paginated content for a category. * * Query params: - * type: itv | vod | series + * type: itv | vod | series | radio * category: category_id (or "*" for all) * genre: same as category for itv * p: page number (1-based) @@ -35,6 +40,14 @@ export function handleGetOrderedList(req: Request, res: Response): void { } else { allItems = data.channels.get(categoryId) ?? []; } + } else if (type === 'radio') { + if (categoryId === '*') { + for (const items of data.radio.values()) { + allItems.push(...items); + } + } else { + allItems = data.radio.get(categoryId) ?? []; + } } else if (type === 'series') { if (categoryId === '*') { for (const items of data.series.values()) { diff --git a/apps/stalker-mock-server/src/app/scenarios.ts b/apps/stalker-mock-server/src/app/scenarios.ts index 3a0454bd8..6657990ae 100644 --- a/apps/stalker-mock-server/src/app/scenarios.ts +++ b/apps/stalker-mock-server/src/app/scenarios.ts @@ -4,6 +4,7 @@ export interface ScenarioConfig { seed: number; categoryCount: { itv: number; + radio: number; vod: number; series: number; }; @@ -25,7 +26,7 @@ export const SCENARIOS: Record = { name: 'default', description: 'Balanced portal — 8 categories, 40 items each', seed: 1001, - categoryCount: { itv: 8, vod: 8, series: 8 }, + categoryCount: { itv: 8, radio: 8, vod: 8, series: 8 }, itemsPerCategory: 40, seasonsPerSeries: 3, episodesPerSeason: 8, @@ -36,7 +37,7 @@ export const SCENARIOS: Record = { name: 'large', description: 'Large catalog — 20 categories, 200 items each', seed: 9999, - categoryCount: { itv: 20, vod: 20, series: 20 }, + categoryCount: { itv: 20, radio: 20, vod: 20, series: 20 }, itemsPerCategory: 200, seasonsPerSeries: 5, episodesPerSeason: 12, @@ -47,7 +48,7 @@ export const SCENARIOS: Record = { name: 'series-heavy', description: 'Series-heavy portal — many series with deep seasons', seed: 2002, - categoryCount: { itv: 3, vod: 5, series: 15 }, + categoryCount: { itv: 3, radio: 3, vod: 5, series: 15 }, itemsPerCategory: 30, seasonsPerSeries: 6, episodesPerSeason: 10, @@ -58,7 +59,7 @@ export const SCENARIOS: Record = { name: 'minimal', description: 'Minimal portal — 2 categories, 5 items (edge case testing)', seed: 3003, - categoryCount: { itv: 2, vod: 2, series: 2 }, + categoryCount: { itv: 2, radio: 2, vod: 2, series: 2 }, itemsPerCategory: 5, seasonsPerSeries: 1, episodesPerSeason: 3, @@ -69,7 +70,7 @@ export const SCENARIOS: Record = { name: 'is-series', description: 'VOD with is_series=1 flag (Ministra plugin flow testing)', seed: 4004, - categoryCount: { itv: 4, vod: 6, series: 4 }, + categoryCount: { itv: 4, radio: 4, vod: 6, series: 4 }, itemsPerCategory: 20, seasonsPerSeries: 3, episodesPerSeason: 6, @@ -80,7 +81,7 @@ export const SCENARIOS: Record = { name: 'embedded-series', description: 'VOD with embedded series[] arrays', seed: 5005, - categoryCount: { itv: 4, vod: 6, series: 4 }, + categoryCount: { itv: 4, radio: 4, vod: 6, series: 4 }, itemsPerCategory: 20, seasonsPerSeries: 2, episodesPerSeason: 5, @@ -111,7 +112,7 @@ export function getScenario(mac: string): ScenarioConfig { name: 'auto', description: `Auto-generated from MAC ${mac}`, seed: macToSeed(mac), - categoryCount: { itv: 6, vod: 6, series: 6 }, + categoryCount: { itv: 6, radio: 6, vod: 6, series: 6 }, itemsPerCategory: 30, seasonsPerSeries: 3, episodesPerSeason: 8, diff --git a/apps/web-e2e/src/stalker.e2e.ts b/apps/web-e2e/src/stalker.e2e.ts index 57e6e543f..799b4cbe8 100644 --- a/apps/web-e2e/src/stalker.e2e.ts +++ b/apps/web-e2e/src/stalker.e2e.ts @@ -1,4 +1,10 @@ -import { expect, Locator, Page, test } from '@playwright/test'; +import { + expect, + type APIRequestContext, + type Locator, + type Page, + test, +} from '@playwright/test'; /** * Stalker Portal E2E Tests @@ -8,7 +14,7 @@ import { expect, Locator, Page, test } from '@playwright/test'; * dev server when running e2e tests (see playwright.config.ts). * * Default scenario MAC (00:1A:79:00:00:01) provides: - * - 8 categories per content type (VOD / Series / ITV) + * - 8 categories per content type (VOD / Series / ITV / Radio) * - 40 items per category * - 3 seasons × 8 episodes per series item * @@ -62,6 +68,31 @@ async function setInputValue(input: Locator, value: string): Promise { await expect(input).toHaveValue(value); } +async function resetMockServer(request: APIRequestContext): Promise { + let lastError: unknown; + + for (let attempt = 0; attempt < 3; attempt += 1) { + try { + const response = await request.post(`${MOCK_SERVER}/reset`); + if (response.ok()) { + return; + } + + lastError = new Error( + `Reset failed with status ${response.status()}` + ); + } catch (error) { + lastError = error; + } + + await new Promise((resolve) => + setTimeout(resolve, 250 * (attempt + 1)) + ); + } + + throw lastError; +} + /** * Add a Stalker portal via the UI: * 1. Click the "add playlist" button to open the unified dialog @@ -97,7 +128,7 @@ async function addStalkerPortal( test.beforeEach(async ({ page, request }) => { // Reset mock server state (clears in-memory favorites and cache) - await request.post(`${MOCK_SERVER}/reset`); + await resetMockServer(request); // Playwright creates a fresh browser context per test, so extra // IndexedDB cleanup here only risks racing with app-managed DB handles. @@ -201,9 +232,63 @@ 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({ + await expect( + page.locator( + 'app-live-epg-panel .selected-date, app-epg-list .selected-date' + ) + ).toBeVisible({ timeout: 20_000 }); +}); + +test('@stalker radio — stations use the inline audio player without EPG', async ({ + page, +}) => { + await addStalkerPortal(page); + + const radioListRequests: string[] = []; + const epgRequests: string[] = []; + page.on('request', (request) => { + const url = request.url(); + if ( + url.includes('type=radio') && + url.includes('action=get_ordered_list') + ) { + radioListRequests.push(url); + } + if ( + url.includes('action=get_epg_info') || + url.includes('action=get_short_epg') + ) { + epgRequests.push(url); + } + }); + + await page.getByRole('link', { name: /radio/i }).click(); + await page.waitForURL(/stalker.*radio/); + + const categories = page.locator('.category-item'); + await expect(categories.nth(1)).toBeVisible({ timeout: 10_000 }); + await categories.nth(1).click(); + + const stations = page.locator('[data-test-id="channel-item"]'); + await expect(stations.first()).toBeVisible({ timeout: 20_000 }); + await expect.poll(() => radioListRequests.length).toBeGreaterThan(0); + + await stations.first().click(); + await expect(stations.first()).toHaveClass(/active/, { timeout: 20_000 }); + await expect(page.locator('app-audio-player')).toBeVisible({ timeout: 20_000, }); + + await page.getByRole('button', { name: 'Hide channels list' }).click(); + const restoreButton = page.getByRole('button', { + name: 'Show channels list', + }); + await expect(restoreButton).toBeVisible(); + await restoreButton.click(); + await expect(stations.first()).toBeVisible(); + + await expect(page.locator('app-epg-list')).toHaveCount(0); + expect(epgRequests).toHaveLength(0); }); test('@stalker bulk EPG is fetched once and reused across channel switches', async ({ @@ -282,6 +367,31 @@ test('@stalker create_link returns a playable stream URL', async ({ expect(streamUrl).toMatch(/\.m3u8$/); }); +test('@stalker mock server returns radio categories and stations', async ({ + request, +}) => { + const categoriesResponse = await request.get( + `${MOCK_SERVER}/stalker?action=get_categories&type=radio&macAddress=${DEFAULT_MAC}` + ); + expect(categoriesResponse.ok()).toBeTruthy(); + const categoriesBody = await categoriesResponse.json(); + expect(categoriesBody.payload.js.length).toBeGreaterThan(0); + + const firstCategory = categoriesBody.payload.js[0].id; + const stationsResponse = await request.get( + `${MOCK_SERVER}/stalker?action=get_ordered_list&type=radio&category=${firstCategory}&p=1&macAddress=${DEFAULT_MAC}&JsHttpRequest=1-xml` + ); + expect(stationsResponse.ok()).toBeTruthy(); + const stationsBody = await stationsResponse.json(); + const firstStation = stationsBody.payload.js.data[0]; + expect(firstStation).toEqual( + expect.objectContaining({ + category_id: firstCategory, + radio: true, + }) + ); +}); + test('@stalker series — seasons load for a series item', async ({ request, }) => { diff --git a/apps/web/src/_cover-size.scss b/apps/web/src/_cover-size.scss new file mode 100644 index 000000000..6a45242b0 --- /dev/null +++ b/apps/web/src/_cover-size.scss @@ -0,0 +1,32 @@ +// Cover size tokens — consumed by all cover/poster grids and rails. +// Toggled via [data-cover-size] attribute on , set from SettingsStore. +:root { + --cover-grid-min-width: 148px; + --cover-rail-width: 172px; + --cover-gap: 16px; +} + +:root[data-cover-size='small'] { + --cover-grid-min-width: 120px; + --cover-rail-width: 140px; + --cover-gap: 12px; +} + +:root[data-cover-size='medium'] { + --cover-grid-min-width: 148px; + --cover-rail-width: 172px; + --cover-gap: 16px; +} + +:root[data-cover-size='large'] { + --cover-grid-min-width: 200px; + --cover-rail-width: 220px; + --cover-gap: 20px; +} + +@media (max-width: 600px) { + :root[data-cover-size='large'] { + --cover-grid-min-width: 160px; + --cover-rail-width: 170px; + } +} diff --git a/apps/web/src/app/app.component.spec.ts b/apps/web/src/app/app.component.spec.ts index 7792752d1..81150dc9e 100644 --- a/apps/web/src/app/app.component.spec.ts +++ b/apps/web/src/app/app.component.spec.ts @@ -36,6 +36,7 @@ const DEFAULT_SETTINGS: Settings = { player: VideoPlayer.VideoJs, epgUrl: [], streamFormat: StreamFormat.M3u8StreamFormat, + openStreamOnDoubleClick: false, language: Language.ENGLISH, showCaptions: false, showDashboard: true, @@ -45,6 +46,7 @@ const DEFAULT_SETTINGS: Settings = { mpvPlayerPath: '', mpvReuseInstance: false, vlcPlayerPath: '', + vlcReuseInstance: false, remoteControl: false, remoteControlPort: 8765, downloadFolder: '', diff --git a/apps/web/src/app/app.component.ts b/apps/web/src/app/app.component.ts index 7ffaba87f..933a9aa11 100644 --- a/apps/web/src/app/app.component.ts +++ b/apps/web/src/app/app.component.ts @@ -1,4 +1,4 @@ -import { Component, HostBinding, inject, OnInit } from '@angular/core'; +import { Component, effect, HostBinding, inject, OnInit } from '@angular/core'; import { MatSnackBar } from '@angular/material/snack-bar'; import { Router, RouterOutlet } from '@angular/router'; import { Actions, ofType } from '@ngrx/effects'; @@ -9,7 +9,7 @@ import { WORKSPACE_SHELL_ACTIONS } from '@iptvnator/workspace/shell/util'; import { EpgProgressPanelComponent } from '@iptvnator/ui/epg/progress-panel'; import { PlaylistActions, selectAllPlaylistsMeta } from 'm3u-state'; import { filter, take } from 'rxjs'; -import { DataService } from 'services'; +import { DataService, SettingsStore } from 'services'; import { AUTO_UPDATE_PLAYLISTS, Language, @@ -39,6 +39,7 @@ export class AppComponent implements OnInit { private store = inject(Store); private translate = inject(TranslateService); private settingsService = inject(SettingsService); + private settingsStore = inject(SettingsStore); private readonly workspaceShellActions = inject(WORKSPACE_SHELL_ACTIONS); /** Default language as fallback */ @@ -64,6 +65,11 @@ export class AppComponent implements OnInit { }); } } + effect(() => { + const size = this.settingsStore.coverSize?.() ?? 'medium'; + document.documentElement.dataset.coverSize = size; + }); + if (window.electron) { document.addEventListener('keydown', (event) => { if (event.ctrlKey || event.metaKey) { @@ -100,7 +106,21 @@ export class AppComponent implements OnInit { // Settings are stored in IndexedDB and loaded by the settings store // Only specific Electron settings (MPV/VLC paths) are sent when changed in settings component - this.translate.use(settings.language ?? this.DEFAULT_LANG); + const resolvedLang = + settings.language ?? this.DEFAULT_LANG; + this.translate.use(resolvedLang); + // Mirror the active language to localStorage so the next + // cold start can read it synchronously in app.config.ts's + // getInitialLanguage() and avoid the English-then-localized + // flash for non-English users. + try { + localStorage.setItem( + 'iptvnator:preferred-language', + resolvedLang + ); + } catch { + // Ignore quota / privacy mode errors. + } // Fetch EPG if URLs are configured (only fetch stale data) if ( diff --git a/apps/web/src/app/app.config.ts b/apps/web/src/app/app.config.ts index 49f18665b..56a9f91df 100644 --- a/apps/web/src/app/app.config.ts +++ b/apps/web/src/app/app.config.ts @@ -47,6 +47,48 @@ export function HttpLoaderFactory(http: HttpClient): TranslateHttpLoader { return new TranslateHttpLoader(http, './assets/i18n/', '.json'); } +/** + * Synchronously read the user's preferred language from localStorage so the + * very first render uses the right locale instead of flashing English first + * and re-rendering once the IDB-backed settings finish loading. + * + * The hint is written by AppComponent.initSettings() whenever the saved + * settings resolve, so first-ever boots fall back to English (no hint + * present yet) and every subsequent boot uses the saved language. + */ +const SUPPORTED_LANGS = new Set([ + 'ar', + 'ary', + 'by', + 'de', + 'el', + 'en', + 'es', + 'fr', + 'it', + 'ja', + 'ko', + 'nl', + 'pl', + 'pt', + 'ru', + 'tr', + 'zh', + 'zhtw', +]); +export const PREFERRED_LANGUAGE_STORAGE_KEY = 'iptvnator:preferred-language'; +export function getInitialLanguage(): string { + try { + const saved = localStorage.getItem(PREFERRED_LANGUAGE_STORAGE_KEY); + if (saved && SUPPORTED_LANGS.has(saved)) { + return saved; + } + } catch { + // localStorage may throw in privacy modes; fall through to default. + } + return 'en'; +} + /** * Conditionally provides the necessary service based on the current environment */ @@ -69,24 +111,19 @@ export const appConfig: ApplicationConfig = { }), provideEffects([PlaylistEffects]), provideRouterStore(), - provideStoreDevtools({ - maxAge: 25, - logOnly: AppConfig.production, - }), + ...(AppConfig.production ? [] : [provideStoreDevtools({ maxAge: 25 })]), provideServiceWorker('ngsw-worker.js', { enabled: AppConfig.production && !!window.electron, registrationStrategy: 'registerWhenStable:30000', }), importProvidersFrom( - AppConfig.environment === 'WEB' - ? NgxIndexedDBModule.forRoot(dbConfig) - : [], NgxIndexedDBModule.forRoot(dbConfig), NgxSkeletonLoaderModule.forRoot({ animation: 'pulse', loadingText: 'This item is actually loading...', }), TranslateModule.forRoot({ + defaultLanguage: getInitialLanguage(), loader: { provide: TranslateLoader, useFactory: HttpLoaderFactory, diff --git a/apps/web/src/app/app.routes.spec.ts b/apps/web/src/app/app.routes.spec.ts index 900553481..fa4bb7078 100644 --- a/apps/web/src/app/app.routes.spec.ts +++ b/apps/web/src/app/app.routes.spec.ts @@ -2,6 +2,7 @@ describe('app routes', () => { let workspaceChildren: Array<{ canActivate?: unknown[]; data?: Record; + loadChildren?: unknown; loadComponent?: unknown; path?: string; redirectTo?: unknown; @@ -9,25 +10,61 @@ describe('app routes', () => { beforeAll(async () => { jest.resetModules(); - jest.unstable_mockModule('@iptvnator/playlist/m3u/feature-player', () => ({ - M3uCollectionRouteComponent: class {}, - VideoPlayerComponent: class {}, - provideM3uWorkspaceRouteSession: () => [], - })); + jest.unstable_mockModule( + '@iptvnator/playlist/m3u/feature-player', + () => ({ + createM3uWorkspaceRoutes: () => [ + { + path: '', + pathMatch: 'full', + redirectTo: 'all', + }, + { + path: 'favorites', + data: { + mode: 'favorites', + portalType: 'm3u', + defaultScope: 'playlist', + }, + }, + { + path: 'recent', + data: { + mode: 'recent', + portalType: 'm3u', + defaultScope: 'playlist', + }, + }, + { + path: ':view', + }, + ], + }) + ); const { routes } = await import('./app.routes'); - const workspaceRoute = routes.find((route) => route.path === 'workspace'); + const workspaceRoute = routes.find( + (route) => route.path === 'workspace' + ); workspaceChildren = workspaceRoute?.children ?? []; }); - it('routes M3U favorites and recent pages through the shared collection wrapper', async () => { - const favoritesRoute = workspaceChildren.find( - (route) => route.path === 'playlists/:id/favorites' + it('lazy-loads M3U workspace routes through the feature boundary', async () => { + const playlistRoute = workspaceChildren.find( + (route) => route.path === 'playlists/:id' ); - const recentRoute = workspaceChildren.find( - (route) => route.path === 'playlists/:id/recent' + const loadChildren = playlistRoute?.loadChildren as + | (() => Promise) + | undefined; + const m3uRoutes = (await loadChildren?.()) ?? []; + const defaultRoute = m3uRoutes.find((route) => route.path === ''); + const favoritesRoute = m3uRoutes.find( + (route) => route.path === 'favorites' ); + const recentRoute = m3uRoutes.find((route) => route.path === 'recent'); + const playerRoute = m3uRoutes.find((route) => route.path === ':view'); + expect(defaultRoute?.redirectTo).toBe('all'); expect(favoritesRoute?.data).toEqual({ mode: 'favorites', portalType: 'm3u', @@ -39,8 +76,7 @@ describe('app routes', () => { defaultScope: 'playlist', }); - expect(typeof favoritesRoute?.loadComponent).toBe('function'); - expect(typeof recentRoute?.loadComponent).toBe('function'); + expect(playerRoute).toBeDefined(); }); it('adds a shared /workspace/global-recent route', async () => { diff --git a/apps/web/src/app/app.routes.ts b/apps/web/src/app/app.routes.ts index 3f056aa4c..657b026c5 100644 --- a/apps/web/src/app/app.routes.ts +++ b/apps/web/src/app/app.routes.ts @@ -1,6 +1,5 @@ import { inject } from '@angular/core'; import { Router, Routes } from '@angular/router'; -import { provideM3uWorkspaceRouteSession } from '@iptvnator/playlist/m3u/feature-player'; import { WorkspaceStartupPreferencesService } from '@iptvnator/workspace/shell/util'; const workspaceEntryRedirect = async () => @@ -54,41 +53,9 @@ export const routes: Routes = [ }, { path: 'playlists/:id', - pathMatch: 'full', - redirectTo: 'playlists/:id/all', - }, - { - path: 'playlists/:id/favorites', - providers: provideM3uWorkspaceRouteSession(), - loadComponent: () => - import('@iptvnator/playlist/m3u/feature-player').then( - (c) => c.M3uCollectionRouteComponent - ), - data: { - mode: 'favorites', - portalType: 'm3u', - defaultScope: 'playlist', - }, - }, - { - path: 'playlists/:id/recent', - providers: provideM3uWorkspaceRouteSession(), - loadComponent: () => - import('@iptvnator/playlist/m3u/feature-player').then( - (c) => c.M3uCollectionRouteComponent - ), - data: { - mode: 'recent', - portalType: 'm3u', - defaultScope: 'playlist', - }, - }, - { - path: 'playlists/:id/:view', - providers: provideM3uWorkspaceRouteSession(), - loadComponent: () => - import('@iptvnator/playlist/m3u/feature-player').then( - (c) => c.VideoPlayerComponent + loadChildren: () => + import('@iptvnator/playlist/m3u/feature-player').then((m) => + m.createM3uWorkspaceRoutes() ), }, { diff --git a/apps/web/src/app/global-collection-route.component.ts b/apps/web/src/app/global-collection-route.component.ts index f2a7f5e31..f144e57d4 100644 --- a/apps/web/src/app/global-collection-route.component.ts +++ b/apps/web/src/app/global-collection-route.component.ts @@ -1,9 +1,16 @@ import { ChangeDetectionStrategy, Component, + EnvironmentInjector, + OnDestroy, + Type, + ViewContainerRef, + effect, computed, inject, input, + output, + untracked, } from '@angular/core'; import { Router } from '@angular/router'; import { Store } from '@ngrx/store'; @@ -14,14 +21,24 @@ import { UnifiedCollectionDetailDirective, UnifiedCollectionPageComponent, } from '@iptvnator/portal/shared/ui'; -import { CollectionScope, PortalProvider } from '@iptvnator/portal/shared/util'; -import { StalkerCollectionDetailComponent } from '@iptvnator/portal/stalker/feature'; -import { XtreamCollectionDetailComponent } from '@iptvnator/portal/xtream/feature'; +import { + CollectionScope, + PortalProvider, + UnifiedCollectionItem, +} from '@iptvnator/portal/shared/util'; import { WORKSPACE_SHELL_ACTIONS } from '@iptvnator/workspace/shell/util'; type UnifiedPortalType = 'm3u' | 'xtream' | 'stalker'; type CollectionMode = 'favorites' | 'recent'; +interface ComponentOutputRef { + subscribe(callback: () => void): { unsubscribe(): void }; +} + +interface CollectionDetailInstance { + closeRequested?: ComponentOutputRef; +} + const EMPTY_STATE_BY_MODE: Record< CollectionMode, { icon: string; titleKey: string; descriptionKey: string } @@ -44,14 +61,100 @@ function providerToPortalType(provider: PortalProvider): UnifiedPortalType { return 'stalker'; } +@Component({ + selector: 'app-global-collection-detail-host', + template: '', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class GlobalCollectionDetailHostComponent implements OnDestroy { + readonly item = input(null); + readonly closeRequested = output(); + + private readonly viewContainer = inject(ViewContainerRef); + private readonly environmentInjector = inject(EnvironmentInjector); + private closeSubscription: { unsubscribe(): void } | null = null; + private renderRequestId = 0; + + constructor() { + effect(() => { + const item = this.item(); + + untracked(() => { + void this.renderDetail(item); + }); + }); + } + + ngOnDestroy(): void { + this.renderRequestId++; + this.clearDetail(); + } + + private async renderDetail( + item: UnifiedCollectionItem | null + ): Promise { + const requestId = ++this.renderRequestId; + this.clearDetail(); + + if (!item) { + return; + } + + const componentType = await this.loadDetailComponent(item.sourceType); + if (requestId !== this.renderRequestId || !componentType) { + return; + } + + const componentRef = this.viewContainer.createComponent(componentType, { + environmentInjector: this.environmentInjector, + }); + componentRef.setInput('item', item); + this.subscribeToClose(componentRef.instance); + } + + private async loadDetailComponent( + sourceType: UnifiedCollectionItem['sourceType'] + ): Promise | null> { + if (sourceType === 'xtream') { + const component = await import('@iptvnator/portal/xtream/feature'); + return component.XtreamCollectionDetailComponent; + } + + if (sourceType === 'stalker') { + const component = await import('@iptvnator/portal/stalker/feature'); + return component.StalkerCollectionDetailComponent; + } + + return null; + } + + private subscribeToClose(instance: unknown): void { + const closeRequested = (instance as CollectionDetailInstance) + .closeRequested; + + if (!closeRequested) { + return; + } + + this.closeSubscription = closeRequested.subscribe(() => { + this.closeRequested.emit(); + }); + } + + private clearDetail(): void { + this.closeSubscription?.unsubscribe(); + this.closeSubscription = null; + this.viewContainer.clear(); + } +} + @Component({ selector: 'app-global-collection-route', imports: [ EmptyStateComponent, - StalkerCollectionDetailComponent, + GlobalCollectionDetailHostComponent, UnifiedCollectionDetailDirective, UnifiedCollectionPageComponent, - XtreamCollectionDetailComponent, ], template: ` @if (hasNoPlaylists()) { @@ -74,9 +177,9 @@ function providerToPortalType(provider: PortalProvider): UnifiedPortalType { > @if (item.sourceType === 'xtream') { - + } @else if (item.sourceType === 'stalker') { - @@ -96,7 +199,9 @@ export class GlobalCollectionRouteComponent { readonly mode = input('favorites'); readonly defaultScope = input(undefined); - private readonly playlists = this.store.selectSignal(selectAllPlaylistsMeta); + private readonly playlists = this.store.selectSignal( + selectAllPlaylistsMeta + ); readonly hasNoPlaylists = computed(() => this.playlists().length === 0); readonly emptyState = computed(() => EMPTY_STATE_BY_MODE[this.mode()]); @@ -108,8 +213,11 @@ export class GlobalCollectionRouteComponent { const provider = this.playlistContext.activeProvider(); return provider ? providerToPortalType(provider) : null; }); - readonly effectiveDefaultScope = computed(() => - this.activePlaylistId() ? 'playlist' : (this.defaultScope() ?? 'all') + readonly effectiveDefaultScope = computed( + () => + this.activePlaylistId() + ? 'playlist' + : (this.defaultScope() ?? 'all') ); addPlaylist(): void { diff --git a/apps/web/src/app/services/electron.service.ts b/apps/web/src/app/services/electron.service.ts index d2af6bea4..5052a925c 100644 --- a/apps/web/src/app/services/electron.service.ts +++ b/apps/web/src/app/services/electron.service.ts @@ -42,6 +42,7 @@ interface ErrorStatus { }) export class ElectronService extends DataService { private eventListeners: { [key: string]: () => void } = {}; + private messageListeners = new Map(); private readonly snackBar = inject(MatSnackBar); private readonly store = inject(Store); private readonly translateService = inject(TranslateService); @@ -556,27 +557,39 @@ export class ElectronService extends DataService { unsubscribe() ); this.eventListeners = {}; - } else if (this.eventListeners[type]) { + // Remove all tracked window message listeners + this.messageListeners.forEach((listener) => + window.removeEventListener('message', listener) + ); + this.messageListeners.clear(); + return; + } + + if (this.eventListeners[type]) { // Unsubscribe from a specific event this.eventListeners[type](); delete this.eventListeners[type]; } - // Also remove any window message listeners - window.removeEventListener('message', this.getListenerForCommand(type)); - } - - private getListenerForCommand(_command: string): EventListener { - void _command; - // This is a placeholder. In a real implementation, you would need to - // store the actual listener functions to be able to remove them - return () => undefined; + // Remove the window message listener registered for this command + const messageListener = this.messageListeners.get(type); + if (messageListener) { + window.removeEventListener('message', messageListener); + this.messageListeners.delete(type); + } } listenOn(command: string, callback: (...args: unknown[]) => void): void { - // For Electron, use window message events - void command; - window.addEventListener('message', callback); + // Drop any existing listener for this command so calling listenOn() + // again rebinds rather than accumulating duplicates. + const existing = this.messageListeners.get(command); + if (existing) { + window.removeEventListener('message', existing); + } + + const listener = callback as EventListener; + window.addEventListener('message', listener); + this.messageListeners.set(command, listener); } getAppEnvironment(): string { 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/services/pwa.service.ts b/apps/web/src/app/services/pwa.service.ts index 3f7a26c4a..e82872587 100644 --- a/apps/web/src/app/services/pwa.service.ts +++ b/apps/web/src/app/services/pwa.service.ts @@ -52,6 +52,7 @@ interface ErrorStatus { providedIn: 'root', }) export class PwaService extends DataService { + private messageListeners = new Map(); private readonly http = inject(HttpClient); private readonly snackBar = inject(MatSnackBar); private readonly store = inject(Store); @@ -500,12 +501,33 @@ export class PwaService extends DataService { }); } - removeAllListeners(): void { - // not implemented + removeAllListeners(type: string): void { + if (type === 'all') { + this.messageListeners.forEach((listener) => + window.removeEventListener('message', listener) + ); + this.messageListeners.clear(); + return; + } + + const messageListener = this.messageListeners.get(type); + if (messageListener) { + window.removeEventListener('message', messageListener); + this.messageListeners.delete(type); + } } - listenOn(_command: string, callback: (...args: unknown[]) => void): void { - window.addEventListener('message', callback); + listenOn(command: string, callback: (...args: unknown[]) => void): void { + // Drop any existing listener for this command so calling listenOn() + // again rebinds rather than accumulating duplicates. + const existing = this.messageListeners.get(command); + if (existing) { + window.removeEventListener('message', existing); + } + + const listener = callback as EventListener; + window.addEventListener('message', listener); + this.messageListeners.set(command, listener); } getAppEnvironment(): string { diff --git a/apps/web/src/app/services/workspace-shell-actions.service.ts b/apps/web/src/app/services/workspace-shell-actions.service.ts index 2e2395d20..bf379c6ea 100644 --- a/apps/web/src/app/services/workspace-shell-actions.service.ts +++ b/apps/web/src/app/services/workspace-shell-actions.service.ts @@ -1,6 +1,7 @@ import { inject, Injectable, Provider } from '@angular/core'; import { MatDialog } from '@angular/material/dialog'; import { Router } from '@angular/router'; +import { PORTAL_SHELL_ACTIONS } from '@iptvnator/portal/shared/util'; import { WORKSPACE_SHELL_ACTIONS, WorkspaceAccountInfoData, @@ -67,5 +68,9 @@ export function provideWorkspaceShellActions(): Provider[] { provide: WORKSPACE_SHELL_ACTIONS, useExisting: AppWorkspaceShellActionsService, }, + { + provide: PORTAL_SHELL_ACTIONS, + useExisting: AppWorkspaceShellActionsService, + }, ]; } diff --git a/apps/web/src/app/settings/settings-about-section.component.html b/apps/web/src/app/settings/settings-about-section.component.html new file mode 100644 index 000000000..b0887b15f --- /dev/null +++ b/apps/web/src/app/settings/settings-about-section.component.html @@ -0,0 +1,179 @@ +
+
+
+ info +
+
+

{{ 'SETTINGS.ABOUT' | translate }}

+

{{ 'SETTINGS.ABOUT_SUBTITLE' | translate }}

+
+
+ +
+
+

{{ 'SETTINGS.VERSION' | translate }}

+

{{ 'SETTINGS.VERSION_DESCRIPTION' | translate }}

+
+
+ {{ version() }} + {{ updateMessage() }} +
+
+ + @if (isDesktop()) { +
+ {{ + 'SETTINGS.EMBEDDED_MPV_NOTICE_TITLE' | translate + }} + {{ + 'SETTINGS.EMBEDDED_MPV_NOTICE_DESCRIPTION' | translate + }} +
+ } + + + +
+
+
+
+ favorite +
+
+

{{ 'SETTINGS.SUPPORT_TITLE' | translate }}

+

{{ 'SETTINGS.SUPPORT_DESCRIPTION' | translate }}

+
+
+ +
+
+
diff --git a/apps/web/src/app/settings/settings-about-section.component.ts b/apps/web/src/app/settings/settings-about-section.component.ts new file mode 100644 index 000000000..0c2be99a9 --- /dev/null +++ b/apps/web/src/app/settings/settings-about-section.component.ts @@ -0,0 +1,17 @@ +import { Component, input, ViewEncapsulation } from '@angular/core'; +import { MatIconModule } from '@angular/material/icon'; +import { TranslateModule } from '@ngx-translate/core'; + +@Component({ + selector: 'app-settings-about-section', + imports: [MatIconModule, TranslateModule], + templateUrl: './settings-about-section.component.html', + encapsulation: ViewEncapsulation.None, + styles: [':host { display: contents; }'], +}) +export class SettingsAboutSectionComponent { + readonly activeSection = input.required(); + readonly isDesktop = input(false); + readonly version = input(); + readonly updateMessage = input(); +} diff --git a/apps/web/src/app/settings/settings-backup-section.component.html b/apps/web/src/app/settings/settings-backup-section.component.html new file mode 100644 index 000000000..0a9f7189d --- /dev/null +++ b/apps/web/src/app/settings/settings-backup-section.component.html @@ -0,0 +1,77 @@ +
+
+
+ backup +
+
+

{{ 'SETTINGS.BACKUP' | translate }}

+

{{ 'SETTINGS.BACKUP_DESCRIPTION' | translate }}

+
+
+ +
+
+

{{ 'SETTINGS.IMPORT_EXPORT_DATA' | translate }}

+

+ {{ 'SETTINGS.IMPORT_EXPORT_DATA_DESCRIPTION' | translate }} +

+
+
+ + +
+
+ + @if (isPwa()) { +
+ {{ 'SETTINGS.EPG_NOTE' | translate }} +  {{ 'SETTINGS.EPG_NOTE_URL_TEXT' | translate }} +
+ } +
diff --git a/apps/web/src/app/settings/settings-backup-section.component.ts b/apps/web/src/app/settings/settings-backup-section.component.ts new file mode 100644 index 000000000..9cfb5af6c --- /dev/null +++ b/apps/web/src/app/settings/settings-backup-section.component.ts @@ -0,0 +1,27 @@ +import { Component, input, output, ViewEncapsulation } from '@angular/core'; +import { MatButtonModule } from '@angular/material/button'; +import { MatIconModule } from '@angular/material/icon'; +import { MatProgressSpinnerModule } from '@angular/material/progress-spinner'; +import { TranslateModule } from '@ngx-translate/core'; + +@Component({ + selector: 'app-settings-backup-section', + imports: [ + MatButtonModule, + MatIconModule, + MatProgressSpinnerModule, + TranslateModule, + ], + templateUrl: './settings-backup-section.component.html', + encapsulation: ViewEncapsulation.None, + styles: [':host { display: contents; }'], +}) +export class SettingsBackupSectionComponent { + readonly activeSection = input.required(); + readonly isPwa = input(false); + readonly isRemovingAllPlaylists = input(false); + readonly isExportingData = input(false); + + readonly importData = output(); + readonly exportData = output(); +} diff --git a/apps/web/src/app/settings/settings-epg-section.component.html b/apps/web/src/app/settings/settings-epg-section.component.html new file mode 100644 index 000000000..dec5beaa6 --- /dev/null +++ b/apps/web/src/app/settings/settings-epg-section.component.html @@ -0,0 +1,140 @@ +
+
+
+ list_alt +
+
+

{{ 'SETTINGS.EPG_SOURCES' | translate }}

+

{{ 'SETTINGS.EPG_SOURCES_DESCRIPTION' | translate }}

+
+
+ +
+
+ + @if (epgUrl().length === 0) { +
+ event_busy +

+ {{ 'SETTINGS.EPG_EMPTY_TITLE' | translate }} +

+

+ {{ 'SETTINGS.EPG_EMPTY_HINT' | translate }} +

+
+ } @else { +
+ @for (_ of epgUrl().controls; track _; let i = $index) { +
+ + {{ + 'SETTINGS.EPG_URL_PLACEHOLDER' + | translate + }} + + {{ + 'SETTINGS.EPG_URL_ERROR' | translate + }} + + @if (epgField.value) { + + } + + +
+ } +
+ } +
+
+
+ +
+
+

{{ 'SETTINGS.PREFER_UPLOADED_EPG_TITLE' | translate }}

+

{{ 'SETTINGS.PREFER_UPLOADED_EPG_HINT' | translate }}

+
+
+ +
+
+ +
+
+ + + +
+
+
diff --git a/apps/web/src/app/settings/settings-epg-section.component.ts b/apps/web/src/app/settings/settings-epg-section.component.ts new file mode 100644 index 000000000..beb0e7fa3 --- /dev/null +++ b/apps/web/src/app/settings/settings-epg-section.component.ts @@ -0,0 +1,42 @@ +import { Component, input, output, ViewEncapsulation } from '@angular/core'; +import { FormArray, FormGroup, ReactiveFormsModule } from '@angular/forms'; +import { MatButtonModule } from '@angular/material/button'; +import { MatCheckboxModule } from '@angular/material/checkbox'; +import { MatFormFieldModule } from '@angular/material/form-field'; +import { MatIconModule } from '@angular/material/icon'; +import { MatInputModule } from '@angular/material/input'; +import { MatProgressSpinnerModule } from '@angular/material/progress-spinner'; +import { MatTooltipModule } from '@angular/material/tooltip'; +import { EpgSourceStatusComponent } from '@iptvnator/ui/epg'; +import { TranslateModule } from '@ngx-translate/core'; + +@Component({ + selector: 'app-settings-epg-section', + imports: [ + EpgSourceStatusComponent, + MatButtonModule, + MatCheckboxModule, + MatFormFieldModule, + MatIconModule, + MatInputModule, + MatProgressSpinnerModule, + MatTooltipModule, + ReactiveFormsModule, + TranslateModule, + ], + templateUrl: './settings-epg-section.component.html', + encapsulation: ViewEncapsulation.None, + styles: [':host { display: contents; }'], +}) +export class SettingsEpgSectionComponent { + readonly form = input.required(); + readonly activeSection = input.required(); + readonly epgUrl = input.required(); + readonly isClearingEpgData = input(false); + + readonly refreshEpg = output(); + readonly removeEpgSource = output(); + readonly addEpgSource = output(); + readonly refreshAllEpg = output(); + readonly clearEpgData = output(); +} diff --git a/apps/web/src/app/settings/settings-form.utils.ts b/apps/web/src/app/settings/settings-form.utils.ts new file mode 100644 index 000000000..f7c91d176 --- /dev/null +++ b/apps/web/src/app/settings/settings-form.utils.ts @@ -0,0 +1,7 @@ +import { FormControl, Validators } from '@angular/forms'; + +export const EPG_URL_PATTERN = /^(http|https|file):\/\/[^ "]+$/; + +export function createEpgUrlControl(value = ''): FormControl { + return new FormControl(value, [Validators.pattern(EPG_URL_PATTERN)]); +} diff --git a/apps/web/src/app/settings/settings-general-section.component.html b/apps/web/src/app/settings/settings-general-section.component.html new file mode 100644 index 000000000..4e831237e --- /dev/null +++ b/apps/web/src/app/settings/settings-general-section.component.html @@ -0,0 +1,174 @@ +
+
+
+ settings +
+
+

{{ 'SETTINGS.GENERAL' | translate }}

+

{{ 'SETTINGS.GENERAL_SUBTITLE' | translate }}

+
+
+ +
+
+

{{ 'SETTINGS.LANGUAGE' | translate }}

+

{{ 'SETTINGS.LANGUAGE_DESCRIPTION' | translate }}

+
+
+ + {{ + 'SETTINGS.VIDEO_PLAYER_PLACEHOLDER' | translate + }} + + @for ( + language of languageEnum() | keyvalue; + track language + ) { + {{ + 'LANGUAGES.' + language.key | translate + }} + } + + +
+
+ +
+
+

{{ 'SETTINGS.THEME' | translate }}

+

{{ 'SETTINGS.THEME_DESCRIPTION' | translate }}

+
+
+
+ @for (themeOption of themeOptions(); track themeOption.value) { + + } +
+
+
+ +
+
+

{{ 'SETTINGS.COVER_SIZE' | translate }}

+

{{ 'SETTINGS.COVER_SIZE_DESCRIPTION' | translate }}

+
+
+
+ @for (option of coverSizeOptions(); track option.value) { + + } +
+
+
+ +
+
+

{{ 'SETTINGS.SHOW_DASHBOARD' | translate }}

+

+ {{ 'SETTINGS.SHOW_DASHBOARD_DESCRIPTION' | translate }} +

+
+
+ +
+
+ +
+
+

{{ 'SETTINGS.STARTUP_BEHAVIOR' | translate }}

+

+ {{ 'SETTINGS.STARTUP_BEHAVIOR_DESCRIPTION' | translate }} +

+
+
+ + {{ + 'SETTINGS.STARTUP_BEHAVIOR' | translate + }} + + @for ( + option of startupBehaviorOptions(); + track option.value + ) { + + {{ option.labelKey | translate }} + + } + + +
+
+ +
+
+

{{ 'SETTINGS.SHOW_CAPTIONS' | translate }}

+

+ {{ 'SETTINGS.SHOW_CAPTIONS_DESCRIPTION' | translate }} +

+
+
+ +
+
+
diff --git a/apps/web/src/app/settings/settings-general-section.component.ts b/apps/web/src/app/settings/settings-general-section.component.ts new file mode 100644 index 000000000..be634dc9d --- /dev/null +++ b/apps/web/src/app/settings/settings-general-section.component.ts @@ -0,0 +1,41 @@ +import { CommonModule } from '@angular/common'; +import { Component, input, output, ViewEncapsulation } from '@angular/core'; +import { FormGroup, ReactiveFormsModule } from '@angular/forms'; +import { MatCheckboxModule } from '@angular/material/checkbox'; +import { MatFormFieldModule } from '@angular/material/form-field'; +import { MatIconModule } from '@angular/material/icon'; +import { MatSelectModule } from '@angular/material/select'; +import { TranslateModule } from '@ngx-translate/core'; +import { CoverSize, Language, Theme } from 'shared-interfaces'; +import { + CoverSizeOption, + StartupBehaviorOption, + ThemeOption, +} from './settings.models'; + +@Component({ + selector: 'app-settings-general-section', + imports: [ + CommonModule, + MatCheckboxModule, + MatFormFieldModule, + MatIconModule, + MatSelectModule, + ReactiveFormsModule, + TranslateModule, + ], + templateUrl: './settings-general-section.component.html', + encapsulation: ViewEncapsulation.None, + styles: [':host { display: contents; }'], +}) +export class SettingsGeneralSectionComponent { + readonly form = input.required(); + readonly activeSection = input.required(); + readonly languageEnum = input.required(); + readonly themeOptions = input.required(); + readonly coverSizeOptions = input.required(); + readonly startupBehaviorOptions = input.required(); + + readonly selectTheme = output(); + readonly selectCoverSize = output(); +} diff --git a/apps/web/src/app/settings/settings-options.ts b/apps/web/src/app/settings/settings-options.ts new file mode 100644 index 000000000..0e05271c5 --- /dev/null +++ b/apps/web/src/app/settings/settings-options.ts @@ -0,0 +1,135 @@ +import { + CoverSize, + StartupBehavior, + Theme, + VideoPlayer, +} from 'shared-interfaces'; +import { + CoverSizeOption, + SettingsPlayerOption, + SettingsSection, + StartupBehaviorOption, + ThemeOption, +} from './settings.models'; + +export const SETTINGS_THEME_OPTIONS: ThemeOption[] = [ + { + value: Theme.LightTheme, + icon: 'light_mode', + labelKey: 'THEMES.LIGHT_THEME', + }, + { + value: Theme.DarkTheme, + icon: 'dark_mode', + labelKey: 'THEMES.DARK_THEME', + }, + { + value: Theme.SystemTheme, + icon: 'desktop_windows', + labelKey: 'THEMES.SYSTEM_THEME', + }, +]; + +export const SETTINGS_COVER_SIZE_OPTIONS: CoverSizeOption[] = [ + { + value: 'small' satisfies CoverSize, + icon: 'view_module', + labelKey: 'SETTINGS.COVER_SIZE_SMALL', + }, + { + value: 'medium' satisfies CoverSize, + icon: 'view_comfy', + labelKey: 'SETTINGS.COVER_SIZE_MEDIUM', + }, + { + value: 'large' satisfies CoverSize, + icon: 'view_quilt', + labelKey: 'SETTINGS.COVER_SIZE_LARGE', + }, +]; + +export const SETTINGS_STARTUP_BEHAVIOR_OPTIONS: StartupBehaviorOption[] = [ + { + value: StartupBehavior.FirstView, + labelKey: 'SETTINGS.STARTUP_BEHAVIOR_FIRST_VIEW', + }, + { + value: StartupBehavior.RestoreLastView, + labelKey: 'SETTINGS.STARTUP_BEHAVIOR_RESTORE_LAST_VIEW', + }, +]; + +export const SETTINGS_OS_PLAYER_OPTIONS: SettingsPlayerOption[] = [ + { + id: VideoPlayer.MPV, + labelKey: 'SETTINGS.PLAYER_MPV', + }, + { + id: VideoPlayer.VLC, + labelKey: 'SETTINGS.PLAYER_VLC', + }, +]; + +export const SETTINGS_EMBEDDED_PLAYER_OPTIONS: SettingsPlayerOption[] = [ + { + id: VideoPlayer.Html5Player, + labelKey: 'SETTINGS.PLAYER_HTML5', + }, + { + id: VideoPlayer.VideoJs, + labelKey: 'SETTINGS.PLAYER_VIDEOJS', + }, + { + id: VideoPlayer.ArtPlayer, + labelKey: 'SETTINGS.PLAYER_ARTPLAYER', + }, +]; + +export function buildSettingsSectionNavItems( + isDesktop: boolean +): SettingsSection[] { + return [ + { + id: 'general', + label: 'SETTINGS.NAV_GENERAL', + icon: 'tune', + visible: true, + }, + { + id: 'playback', + label: 'SETTINGS.NAV_PLAYBACK', + icon: 'play_circle', + visible: true, + }, + { + id: 'epg', + label: 'SETTINGS.NAV_EPG', + icon: 'calendar_month', + visible: isDesktop, + }, + { + id: 'remote-control', + label: 'SETTINGS.NAV_REMOTE', + icon: 'smartphone', + visible: isDesktop, + }, + { + id: 'backup', + label: 'SETTINGS.NAV_BACKUP', + icon: 'backup', + visible: true, + }, + { + id: 'reset', + label: 'SETTINGS.NAV_RESET', + icon: 'delete_sweep', + visible: true, + }, + { + id: 'about', + label: 'SETTINGS.NAV_ABOUT', + icon: 'info', + visible: true, + }, + ]; +} diff --git a/apps/web/src/app/settings/settings-playback-section.component.html b/apps/web/src/app/settings/settings-playback-section.component.html new file mode 100644 index 000000000..622cd79ae --- /dev/null +++ b/apps/web/src/app/settings/settings-playback-section.component.html @@ -0,0 +1,189 @@ +
+
+
+ play_circle +
+
+

{{ 'SETTINGS.PLAYBACK' | translate }}

+

{{ 'SETTINGS.VIDEO_PLAYER_DESCRIPTION' | translate }}

+
+
+ +
+
+

{{ 'SETTINGS.VIDEO_PLAYER_LABEL' | translate }}

+

{{ 'SETTINGS.VIDEO_PLAYER_DESCRIPTION' | translate }}

+
+
+ + {{ + 'SETTINGS.VIDEO_PLAYER_PLACEHOLDER' | translate + }} + + @for (player of players(); track player.id) { + {{ player.labelKey | translate }} + + } + + +
+
+ +
+
+

{{ 'SETTINGS.STREAM_FORMAT' | translate }}

+

{{ 'SETTINGS.STREAM_FORMAT_DESCRIPTION' | translate }}

+
+
+ + {{ + 'SETTINGS.VIDEO_PLAYER_PLACEHOLDER' | translate + }} + + @for ( + streamFormat of streamFormatEnum() | keyvalue; + track streamFormat + ) { + {{ streamFormat.value }} + + } + + +
+
+ + @if (isDesktop() && isExternalPlayerSelected()) { +
+
+

+ {{ + 'SETTINGS.OPEN_EXTERNAL_PLAYER_ON_DOUBLE_CLICK' + | translate + }} +

+

+ {{ + 'SETTINGS.OPEN_EXTERNAL_PLAYER_ON_DOUBLE_CLICK_DESCRIPTION' + | translate + }} +

+
+
+ +
+
+ } + + @if (isDesktop()) { +
+
+

{{ 'SETTINGS.SHOW_EXTERNAL_PLAYBACK_BAR' | translate }}

+

+ {{ + 'SETTINGS.SHOW_EXTERNAL_PLAYBACK_BAR_DESCRIPTION' + | translate + }} +

+
+
+ +
+
+ } + + @if (form().value.player === 'mpv') { +
+
+

{{ 'SETTINGS.MPV_PLAYER_PATH_LABEL' | translate }}

+

+ {{ 'SETTINGS.MPV_PLAYER_PATH_DESCRIPTION' | translate }} +

+
+
+ + {{ + 'SETTINGS.MPV_PLAYER_PATH' | translate + }} + + +
+
+ +
+
+

{{ 'SETTINGS.MPV_REUSE_INSTANCE_LABEL' | translate }}

+

+ {{ 'SETTINGS.MPV_REUSE_INSTANCE_DESCRIPTION' | translate }} +

+
+
+ +
+
+ } + + @if (form().value.player === 'vlc') { +
+
+

{{ 'SETTINGS.VLC_PLAYER_PATH_LABEL' | translate }}

+

+ {{ 'SETTINGS.VLC_PLAYER_PATH_DESCRIPTION' | translate }} +

+
+
+ + {{ + 'SETTINGS.VLC_PLAYER_PATH' | translate + }} + + +
+
+ +
+
+

{{ 'SETTINGS.VLC_REUSE_INSTANCE_LABEL' | translate }}

+

+ {{ 'SETTINGS.VLC_REUSE_INSTANCE_DESCRIPTION' | translate }} +

+
+
+ +
+
+ } +
diff --git a/apps/web/src/app/settings/settings-playback-section.component.spec.ts b/apps/web/src/app/settings/settings-playback-section.component.spec.ts new file mode 100644 index 000000000..9e59a58c0 --- /dev/null +++ b/apps/web/src/app/settings/settings-playback-section.component.spec.ts @@ -0,0 +1,115 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { FormControl, FormGroup, ReactiveFormsModule } from '@angular/forms'; +import { NoopAnimationsModule } from '@angular/platform-browser/animations'; +import { TranslateModule } from '@ngx-translate/core'; +import { StreamFormat, VideoPlayer } from 'shared-interfaces'; +import { SettingsPlaybackSectionComponent } from './settings-playback-section.component'; + +describe('SettingsPlaybackSectionComponent', () => { + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [ + SettingsPlaybackSectionComponent, + NoopAnimationsModule, + ReactiveFormsModule, + TranslateModule.forRoot(), + ], + }).compileComponents(); + + fixture = TestBed.createComponent(SettingsPlaybackSectionComponent); + fixture.componentRef.setInput('form', createForm()); + fixture.componentRef.setInput('activeSection', 'playback'); + fixture.componentRef.setInput('players', [ + { + id: VideoPlayer.VideoJs, + labelKey: 'SETTINGS.PLAYER_VIDEOJS', + }, + { + id: VideoPlayer.MPV, + labelKey: 'SETTINGS.PLAYER_MPV', + }, + ]); + fixture.componentRef.setInput('streamFormatEnum', StreamFormat); + }); + + it('hides the external-player double-click option outside desktop builds', () => { + fixture.componentRef.setInput('form', createForm(VideoPlayer.MPV)); + fixture.componentRef.setInput('isDesktop', false); + fixture.detectChanges(); + + expect( + fixture.nativeElement.querySelector( + '[data-test-id="external-player-double-click-setting"]' + ) + ).toBeNull(); + expect(fixture.nativeElement.textContent).not.toContain( + 'SETTINGS.OPEN_STREAM_ON_DOUBLE_CLICK' + ); + }); + + it('hides the external-player double-click option for embedded players', () => { + fixture.componentRef.setInput('isDesktop', true); + fixture.detectChanges(); + + expect( + fixture.nativeElement.querySelector( + '[data-test-id="external-player-double-click-setting"]' + ) + ).toBeNull(); + }); + + it.each([VideoPlayer.MPV, VideoPlayer.VLC])( + 'labels the double-click option as external-player behavior on desktop for %s', + (player) => { + fixture.componentRef.setInput('form', createForm(player)); + fixture.componentRef.setInput('isDesktop', true); + fixture.detectChanges(); + + expect( + fixture.nativeElement.querySelector( + '[data-test-id="external-player-double-click-setting"]' + ) + ).not.toBeNull(); + expect(fixture.nativeElement.textContent).toContain( + 'SETTINGS.OPEN_EXTERNAL_PLAYER_ON_DOUBLE_CLICK' + ); + } + ); + + it('updates the double-click option visibility when the selected player changes', () => { + const form = createForm(); + fixture.componentRef.setInput('form', form); + fixture.componentRef.setInput('isDesktop', true); + fixture.detectChanges(); + + expect( + fixture.nativeElement.querySelector( + '[data-test-id="external-player-double-click-setting"]' + ) + ).toBeNull(); + + form.controls['player'].setValue(VideoPlayer.MPV); + fixture.detectChanges(); + + expect( + fixture.nativeElement.querySelector( + '[data-test-id="external-player-double-click-setting"]' + ) + ).not.toBeNull(); + }); +}); + +function createForm(player = VideoPlayer.VideoJs): FormGroup { + return new FormGroup({ + player: new FormControl(player), + streamFormat: new FormControl(StreamFormat.M3u8StreamFormat), + openStreamOnDoubleClick: new FormControl(false), + showExternalPlaybackBar: new FormControl(true), + mpvPlayerPath: new FormControl(''), + mpvReuseInstance: new FormControl(false), + vlcPlayerPath: new FormControl(''), + vlcReuseInstance: new FormControl(false), + }); +} diff --git a/apps/web/src/app/settings/settings-playback-section.component.ts b/apps/web/src/app/settings/settings-playback-section.component.ts new file mode 100644 index 000000000..5db1a12d4 --- /dev/null +++ b/apps/web/src/app/settings/settings-playback-section.component.ts @@ -0,0 +1,40 @@ +import { CommonModule } from '@angular/common'; +import { Component, input, ViewEncapsulation } from '@angular/core'; +import { FormGroup, ReactiveFormsModule } from '@angular/forms'; +import { MatCheckboxModule } from '@angular/material/checkbox'; +import { MatFormFieldModule } from '@angular/material/form-field'; +import { MatIconModule } from '@angular/material/icon'; +import { MatInputModule } from '@angular/material/input'; +import { MatSelectModule } from '@angular/material/select'; +import { TranslateModule } from '@ngx-translate/core'; +import { StreamFormat, VideoPlayer } from 'shared-interfaces'; +import { SettingsPlayerOption } from './settings.models'; + +@Component({ + selector: 'app-settings-playback-section', + imports: [ + CommonModule, + MatCheckboxModule, + MatFormFieldModule, + MatIconModule, + MatInputModule, + MatSelectModule, + ReactiveFormsModule, + TranslateModule, + ], + templateUrl: './settings-playback-section.component.html', + encapsulation: ViewEncapsulation.None, + styles: [':host { display: contents; }'], +}) +export class SettingsPlaybackSectionComponent { + readonly form = input.required(); + readonly activeSection = input.required(); + readonly players = input.required(); + readonly streamFormatEnum = input.required(); + readonly isDesktop = input(false); + + isExternalPlayerSelected(): boolean { + const player = this.form().value.player; + return player === VideoPlayer.MPV || player === VideoPlayer.VLC; + } +} diff --git a/apps/web/src/app/settings/settings-remote-control-section.component.html b/apps/web/src/app/settings/settings-remote-control-section.component.html new file mode 100644 index 000000000..c49c44954 --- /dev/null +++ b/apps/web/src/app/settings/settings-remote-control-section.component.html @@ -0,0 +1,139 @@ +
+
+
+ settings_remote +
+
+

{{ 'SETTINGS.REMOTE_CONTROL' | translate }}

+

{{ 'SETTINGS.REMOTE_CONTROL_DESCRIPTION' | translate }}

+
+
+ +
+
+

{{ 'SETTINGS.REMOTE_CONTROL' | translate }}

+

{{ 'SETTINGS.REMOTE_CONTROL_DESCRIPTION' | translate }}

+
+
+ +
+
+ + @if (form().value.remoteControl === true) { +
+
+

{{ 'SETTINGS.REMOTE_CONTROL_PORT' | translate }}

+

+ {{ 'SETTINGS.REMOTE_CONTROL_PORT_DESCRIPTION' | translate }} +

+
+
+ + {{ + 'SETTINGS.PORT_RANGE_LABEL' | translate + }} + + @if ( + form().get('remoteControlPort')?.hasError('required') + ) { + {{ + 'SETTINGS.PORT_REQUIRED' | translate + }} + } + @if (form().get('remoteControlPort')?.hasError('min')) { + {{ + 'SETTINGS.PORT_MIN' | translate + }} + } + @if (form().get('remoteControlPort')?.hasError('max')) { + {{ + 'SETTINGS.PORT_MAX' | translate + }} + } + @if (form().get('remoteControlPort')?.hasError('pattern')) { + {{ + 'SETTINGS.PORT_PATTERN' | translate + }} + } + +
+
+ + @if (localIpAddresses().length > 0) { +
+
+

{{ 'SETTINGS.REMOTE_CONTROL_URL' | translate }}

+

+ {{ + 'SETTINGS.REMOTE_CONTROL_URL_DESCRIPTION' + | translate + }} +

+
+
+ @for (ip of localIpAddresses(); track ip) { +
+ + @if (visibleQrCodeIp() === ip) { +
+ +
+ } +
+ } +
+
+ } + } +
diff --git a/apps/web/src/app/settings/settings-remote-control-section.component.ts b/apps/web/src/app/settings/settings-remote-control-section.component.ts new file mode 100644 index 000000000..5d243e36a --- /dev/null +++ b/apps/web/src/app/settings/settings-remote-control-section.component.ts @@ -0,0 +1,36 @@ +import { Component, input, output, ViewEncapsulation } from '@angular/core'; +import { FormGroup, ReactiveFormsModule } from '@angular/forms'; +import { MatButtonModule } from '@angular/material/button'; +import { MatCheckboxModule } from '@angular/material/checkbox'; +import { MatFormFieldModule } from '@angular/material/form-field'; +import { MatIconModule } from '@angular/material/icon'; +import { MatInputModule } from '@angular/material/input'; +import { MatTooltipModule } from '@angular/material/tooltip'; +import { TranslateModule } from '@ngx-translate/core'; +import { QRCodeComponent } from 'angularx-qrcode'; + +@Component({ + selector: 'app-settings-remote-control-section', + imports: [ + MatButtonModule, + MatCheckboxModule, + MatFormFieldModule, + MatIconModule, + MatInputModule, + MatTooltipModule, + QRCodeComponent, + ReactiveFormsModule, + TranslateModule, + ], + templateUrl: './settings-remote-control-section.component.html', + encapsulation: ViewEncapsulation.None, + styles: [':host { display: contents; }'], +}) +export class SettingsRemoteControlSectionComponent { + readonly form = input.required(); + readonly activeSection = input.required(); + readonly localIpAddresses = input.required(); + readonly visibleQrCodeIp = input(null); + + readonly toggleQrCode = output(); +} diff --git a/apps/web/src/app/settings/settings-reset-section.component.html b/apps/web/src/app/settings/settings-reset-section.component.html new file mode 100644 index 000000000..608dee007 --- /dev/null +++ b/apps/web/src/app/settings/settings-reset-section.component.html @@ -0,0 +1,88 @@ +
+
+
+ delete_sweep +
+
+

{{ 'SETTINGS.RESET' | translate }}

+

{{ 'SETTINGS.RESET_DESCRIPTION' | translate }}

+
+
+ +
+
+
+

{{ 'SETTINGS.REMOVE_ALL' | translate }}

+

{{ 'SETTINGS.REMOVE_ALL_DESCRIPTION' | translate }}

+
+ +
+
+ + folder_open + {{ + 'HOME.PLAYLIST_TYPES.M3U' | translate + }} + {{ + playlistDeleteSummary().m3u + }} + + + cloud + {{ + 'HOME.PLAYLIST_TYPES.XTREAM' | translate + }} + {{ + playlistDeleteSummary().xtream + }} + + + cast + {{ + 'HOME.PLAYLIST_TYPES.STALKER' | translate + }} + {{ + playlistDeleteSummary().stalker + }} + +
+ @if (removeAllProgressLabel(); as progressLabel) { +

+ {{ progressLabel }} +

+ } +
+
diff --git a/apps/web/src/app/settings/settings-reset-section.component.ts b/apps/web/src/app/settings/settings-reset-section.component.ts new file mode 100644 index 000000000..7e42e0f40 --- /dev/null +++ b/apps/web/src/app/settings/settings-reset-section.component.ts @@ -0,0 +1,29 @@ +import { Component, input, output, ViewEncapsulation } from '@angular/core'; +import { MatButtonModule } from '@angular/material/button'; +import { MatIconModule } from '@angular/material/icon'; +import { MatProgressSpinnerModule } from '@angular/material/progress-spinner'; +import { TranslateModule } from '@ngx-translate/core'; +import { SettingsPlaylistDeleteSummary } from './settings.models'; + +@Component({ + selector: 'app-settings-reset-section', + imports: [ + MatButtonModule, + MatIconModule, + MatProgressSpinnerModule, + TranslateModule, + ], + templateUrl: './settings-reset-section.component.html', + encapsulation: ViewEncapsulation.None, + styles: [':host { display: contents; }'], +}) +export class SettingsResetSectionComponent { + readonly activeSection = input.required(); + readonly isRemovingAllPlaylists = input(false); + readonly canRemoveAllPlaylists = input(false); + readonly playlistDeleteSummary = + input.required(); + readonly removeAllProgressLabel = input(null); + + readonly removeAll = output(); +} diff --git a/apps/web/src/app/settings/settings-section-scroll.directive.ts b/apps/web/src/app/settings/settings-section-scroll.directive.ts new file mode 100644 index 000000000..1550d6d41 --- /dev/null +++ b/apps/web/src/app/settings/settings-section-scroll.directive.ts @@ -0,0 +1,270 @@ +import { + AfterViewInit, + Directive, + effect, + ElementRef, + inject, + Injector, + OnDestroy, +} from '@angular/core'; +import { SettingsContextService } from '@iptvnator/workspace/shell/util'; +import { ObservedSettingsSection } from './settings.models'; + +@Directive({ + selector: '[appSettingsSectionScroll]', +}) +export class SettingsSectionScrollDirective + implements AfterViewInit, OnDestroy +{ + private static readonly SECTION_SCROLL_TOP_GUTTER = 112; + private static readonly SECTION_SCROLL_BOTTOM_GUTTER = 124; + private static readonly PENDING_SCROLL_CLEAR_DELAY_MS = 600; + + private readonly elementRef = inject(ElementRef); + private readonly injector = inject(Injector); + private readonly settingsCtx = inject(SettingsContextService); + + private sectionObserver?: IntersectionObserver; + private pendingScrollClearTimer: ReturnType< + typeof window.setTimeout + > | null = null; + private pendingScrollClearRoot: HTMLElement | null = null; + private pendingScrollEndListener: (() => void) | null = null; + + constructor() { + effect( + () => { + const sectionId = this.settingsCtx.pendingScrollTarget(); + if (!sectionId || typeof document === 'undefined') { + return; + } + + const scrollRoot = this.scrollToSection(sectionId); + this.schedulePendingScrollTargetClear(scrollRoot); + }, + { injector: this.injector } + ); + + effect( + (onCleanup) => { + const activeSectionId = this.settingsCtx.activeSection(); + const activeSectionElement = + this.elementRef.nativeElement.querySelector( + `#${activeSectionId}` + ) as HTMLElement | null; + + if (!activeSectionElement) { + return; + } + + const animation = activeSectionElement.animate( + [ + { + boxShadow: + 'inset 0 0 0 1px var(--settings-group-active-ring), 0 8px 18px -24px var(--settings-group-active-glow)', + }, + { + boxShadow: + 'inset 0 0 0 1px var(--settings-group-active-ring), 0 12px 22px -24px var(--settings-group-active-glow)', + }, + { + boxShadow: + 'inset 0 0 0 1px var(--settings-group-active-ring), 0 8px 18px -24px var(--settings-group-active-glow)', + }, + ], + { + duration: 260, + easing: 'ease-out', + } + ); + + onCleanup(() => animation.cancel()); + }, + { injector: this.injector } + ); + } + + ngAfterViewInit(): void { + requestAnimationFrame(() => this.setupSectionObserver()); + } + + ngOnDestroy(): void { + this.cancelPendingScrollTargetClear(); + this.sectionObserver?.disconnect(); + } + + private setupSectionObserver(): void { + if (typeof IntersectionObserver === 'undefined') { + return; + } + + const scrollRoot = this.getScrollRoot(); + const contentSections = Array.from( + this.elementRef.nativeElement.querySelectorAll( + '.settings-group[id]' + ) + ) as HTMLElement[]; + const sections: ObservedSettingsSection[] = contentSections.map( + (section) => ({ + id: section.id, + element: section, + }) + ); + + if (sections.length === 0) { + return; + } + + this.sectionObserver?.disconnect(); + this.sectionObserver = new IntersectionObserver( + () => { + if (this.settingsCtx.pendingScrollTarget()) { + return; + } + + const activeSection = this.resolveActiveSection(sections); + if (activeSection) { + this.settingsCtx.setActiveSection(activeSection); + } + }, + { + root: scrollRoot, + threshold: [0.12, 0.24, 0.4, 0.6], + rootMargin: '-18% 0px -52% 0px', + } + ); + + sections.forEach((section) => + this.sectionObserver?.observe(section.element) + ); + + const initialSection = this.resolveActiveSection(sections); + if (initialSection) { + this.settingsCtx.setActiveSection(initialSection); + } + } + + private resolveActiveSection( + sections: ObservedSettingsSection[] + ): string | null { + const scrollRoot = this.getScrollRoot(); + const rootTop = scrollRoot?.getBoundingClientRect().top ?? 0; + const rootHeight = scrollRoot?.clientHeight ?? window.innerHeight; + const activationLine = rootTop + Math.min(rootHeight * 0.28, 220); + const sectionAtActivationLine = sections.find((section) => { + const rect = section.element.getBoundingClientRect(); + return rect.top <= activationLine && rect.bottom >= activationLine; + }); + + if (sectionAtActivationLine) { + return sectionAtActivationLine.id; + } + + const nearestSection = sections + .map((section) => ({ + id: section.id, + distance: Math.abs( + section.element.getBoundingClientRect().top - activationLine + ), + })) + .sort((a, b) => a.distance - b.distance)[0]; + + return nearestSection?.id ?? null; + } + + private getScrollRoot(): HTMLElement | null { + return this.elementRef.nativeElement.closest( + 'main.workspace-content' + ) as HTMLElement | null; + } + + private schedulePendingScrollTargetClear( + scrollRoot: HTMLElement | null + ): void { + const clearPendingScrollTarget = () => { + this.cancelPendingScrollTargetClear(); + this.settingsCtx.clearPendingScrollTarget(); + }; + + this.cancelPendingScrollTargetClear(); + this.pendingScrollClearTimer = window.setTimeout( + clearPendingScrollTarget, + SettingsSectionScrollDirective.PENDING_SCROLL_CLEAR_DELAY_MS + ); + this.pendingScrollClearRoot = scrollRoot; + this.pendingScrollEndListener = clearPendingScrollTarget; + scrollRoot?.addEventListener?.('scrollend', clearPendingScrollTarget, { + once: true, + }); + } + + private cancelPendingScrollTargetClear(): void { + if (this.pendingScrollClearTimer) { + clearTimeout(this.pendingScrollClearTimer); + this.pendingScrollClearTimer = null; + } + + if (this.pendingScrollClearRoot && this.pendingScrollEndListener) { + this.pendingScrollClearRoot.removeEventListener?.( + 'scrollend', + this.pendingScrollEndListener + ); + } + + this.pendingScrollClearRoot = null; + this.pendingScrollEndListener = null; + } + + private scrollToSection(sectionId: string): HTMLElement | null { + const sectionElement = document.getElementById(sectionId); + if (!sectionElement) { + return null; + } + + const scrollRoot = this.getScrollRoot(); + if (!scrollRoot) { + sectionElement.scrollIntoView({ + behavior: 'smooth', + block: 'start', + }); + return null; + } + + const rootRect = scrollRoot.getBoundingClientRect(); + const sectionRect = sectionElement.getBoundingClientRect(); + const sectionTop = + scrollRoot.scrollTop + (sectionRect.top - rootRect.top); + const sectionBottom = sectionTop + sectionRect.height; + const visibleTop = + scrollRoot.scrollTop + + SettingsSectionScrollDirective.SECTION_SCROLL_TOP_GUTTER; + const visibleBottom = + scrollRoot.scrollTop + + scrollRoot.clientHeight - + SettingsSectionScrollDirective.SECTION_SCROLL_BOTTOM_GUTTER; + let nextScrollTop = scrollRoot.scrollTop; + + if (sectionTop < visibleTop) { + nextScrollTop = + sectionTop - + SettingsSectionScrollDirective.SECTION_SCROLL_TOP_GUTTER; + } else if (sectionBottom > visibleBottom) { + nextScrollTop = + sectionBottom - + scrollRoot.clientHeight + + SettingsSectionScrollDirective.SECTION_SCROLL_BOTTOM_GUTTER; + } + + const maxScrollTop = Math.max( + 0, + scrollRoot.scrollHeight - scrollRoot.clientHeight + ); + + scrollRoot.scrollTo({ + top: Math.min(Math.max(nextScrollTop, 0), maxScrollTop), + behavior: 'smooth', + }); + + return scrollRoot; + } +} diff --git a/apps/web/src/app/settings/settings.component.html b/apps/web/src/app/settings/settings.component.html index 76f79c56e..a18a88f17 100644 --- a/apps/web/src/app/settings/settings.component.html +++ b/apps/web/src/app/settings/settings.component.html @@ -6,6 +6,7 @@ class="settings-container" data-test-id="settings-container" [class.dialog-mode]="isDialog" + appSettingsSectionScroll >
-
-
-
- settings -
-
-

{{ 'SETTINGS.GENERAL' | translate }}

-

{{ 'SETTINGS.GENERAL_SUBTITLE' | translate }}

-
-
+ -
-
-

{{ 'SETTINGS.LANGUAGE' | translate }}

-

{{ 'SETTINGS.LANGUAGE_DESCRIPTION' | translate }}

-
-
- - {{ - 'SETTINGS.VIDEO_PLAYER_PLACEHOLDER' | translate - }} - - @for ( - language of languageEnum | keyvalue; - track language - ) { - {{ - 'LANGUAGES.' + language.key - | translate - }} - } - - -
-
- -
-
-

{{ 'SETTINGS.THEME' | translate }}

-

{{ 'SETTINGS.THEME_DESCRIPTION' | translate }}

-
-
-
- @for ( - themeOption of themeOptions; - track themeOption.value - ) { - - } -
-
-
- -
-
-

{{ 'SETTINGS.SHOW_DASHBOARD' | translate }}

-

- {{ - 'SETTINGS.SHOW_DASHBOARD_DESCRIPTION' - | translate - }} -

-
-
- -
-
- -
-
-

{{ 'SETTINGS.STARTUP_BEHAVIOR' | translate }}

-

- {{ - 'SETTINGS.STARTUP_BEHAVIOR_DESCRIPTION' - | translate - }} -

-
-
- - {{ - 'SETTINGS.STARTUP_BEHAVIOR' | translate - }} - - @for ( - option of startupBehaviorOptions; - track option.value - ) { - - {{ option.labelKey | translate }} - - } - - -
-
- -
-
-

{{ 'SETTINGS.SHOW_CAPTIONS' | translate }}

-

- {{ - 'SETTINGS.SHOW_CAPTIONS_DESCRIPTION' | translate - }} -

-
-
- -
-
-
- -
-
-
- play_circle -
-
-

{{ 'SETTINGS.PLAYBACK' | translate }}

-

- {{ - 'SETTINGS.VIDEO_PLAYER_DESCRIPTION' | translate - }} -

-
-
- -
-
-

{{ 'SETTINGS.VIDEO_PLAYER_LABEL' | translate }}

-

- {{ - 'SETTINGS.VIDEO_PLAYER_DESCRIPTION' | translate - }} -

-
-
- - {{ - 'SETTINGS.VIDEO_PLAYER_PLACEHOLDER' | translate - }} - - @for (player of players(); track player.id) { - {{ player.labelKey | translate }} - - } - - -
-
- -
-
-

{{ 'SETTINGS.STREAM_FORMAT' | translate }}

-

- {{ - 'SETTINGS.STREAM_FORMAT_DESCRIPTION' | translate - }} -

-
-
- - {{ - 'SETTINGS.VIDEO_PLAYER_PLACEHOLDER' | translate - }} - - @for ( - streamFormat of streamFormatEnum | keyvalue; - track streamFormat - ) { - {{ streamFormat.value }} - - } - - -
-
- - @if (isDesktop) { -
-
-

- {{ - 'SETTINGS.SHOW_EXTERNAL_PLAYBACK_BAR' - | translate - }} -

-

- {{ - 'SETTINGS.SHOW_EXTERNAL_PLAYBACK_BAR_DESCRIPTION' - | translate - }} -

-
-
- -
-
- } - - @if (settingsForm.value.player === 'mpv') { -
-
-

- {{ - 'SETTINGS.MPV_PLAYER_PATH_LABEL' | translate - }} -

-

- {{ - 'SETTINGS.MPV_PLAYER_PATH_DESCRIPTION' - | translate - }} -

-
-
- - {{ - 'SETTINGS.MPV_PLAYER_PATH' | translate - }} - - -
-
- -
-
-

- {{ - 'SETTINGS.MPV_REUSE_INSTANCE_LABEL' - | translate - }} -

-

- {{ - 'SETTINGS.MPV_REUSE_INSTANCE_DESCRIPTION' - | translate - }} -

-
-
- -
-
- } - - @if (settingsForm.value.player === 'vlc') { -
-
-

- {{ - 'SETTINGS.VLC_PLAYER_PATH_LABEL' | translate - }} -

-

- {{ - 'SETTINGS.VLC_PLAYER_PATH_DESCRIPTION' - | translate - }} -

-
-
- - {{ - 'SETTINGS.VLC_PLAYER_PATH' | translate - }} - - -
-
- } -
+ @if (isDesktop) { -
-
-
- list_alt -
-
-

{{ 'SETTINGS.EPG_SOURCES' | translate }}

-

- {{ - 'SETTINGS.EPG_SOURCES_DESCRIPTION' - | translate - }} -

-
-
+ -
-
- - @if (epgUrl.length === 0) { -
- event_busy -

- {{ - 'SETTINGS.EPG_EMPTY_TITLE' - | translate - }} -

-

- {{ - 'SETTINGS.EPG_EMPTY_HINT' - | translate - }} -

-
- } @else { -
- @for ( - _ of epgUrl.controls; - track _; - let i = $index - ) { -
- - {{ - 'SETTINGS.EPG_URL_PLACEHOLDER' - | translate - }} - - {{ - 'SETTINGS.EPG_URL_ERROR' - | translate - }} - - @if (epgField.value) { - - } - - -
- } -
- } -
-
-
- -
-
- - - -
-
-
+ } - @if (isDesktop) { -
-
-
- settings_remote -
-
-

{{ 'SETTINGS.REMOTE_CONTROL' | translate }}

-

- {{ - 'SETTINGS.REMOTE_CONTROL_DESCRIPTION' - | translate - }} -

-
-
+ -
-
-

{{ 'SETTINGS.REMOTE_CONTROL' | translate }}

-

- {{ - 'SETTINGS.REMOTE_CONTROL_DESCRIPTION' - | translate - }} -

-
-
- -
-
+ - @if (settingsForm.value.remoteControl === true) { -
-
-

- {{ - 'SETTINGS.REMOTE_CONTROL_PORT' - | translate - }} -

-

- {{ - 'SETTINGS.REMOTE_CONTROL_PORT_DESCRIPTION' - | translate - }} -

-
-
- - {{ - 'SETTINGS.PORT_RANGE_LABEL' | translate - }} - - @if ( - settingsForm - .get('remoteControlPort') - ?.hasError('required') - ) { - {{ - 'SETTINGS.PORT_REQUIRED' | translate - }} - } - @if ( - settingsForm - .get('remoteControlPort') - ?.hasError('min') - ) { - {{ - 'SETTINGS.PORT_MIN' | translate - }} - } - @if ( - settingsForm - .get('remoteControlPort') - ?.hasError('max') - ) { - {{ - 'SETTINGS.PORT_MAX' | translate - }} - } - @if ( - settingsForm - .get('remoteControlPort') - ?.hasError('pattern') - ) { - {{ - 'SETTINGS.PORT_PATTERN' | translate - }} - } - -
-
- - @if (localIpAddresses().length > 0) { -
-
-

- {{ - 'SETTINGS.REMOTE_CONTROL_URL' - | translate - }} -

-

- {{ - 'SETTINGS.REMOTE_CONTROL_URL_DESCRIPTION' - | translate - }} -

-
-
- @for (ip of localIpAddresses(); track ip) { -
- - @if (visibleQrCodeIp() === ip) { -
- -
- } -
- } -
-
- } - } -
- } - -
-
-
- backup -
-
-

{{ 'SETTINGS.BACKUP' | translate }}

-

- {{ 'SETTINGS.BACKUP_DESCRIPTION' | translate }} -

-
-
- -
-
-

{{ 'SETTINGS.IMPORT_EXPORT_DATA' | translate }}

-

- {{ - 'SETTINGS.IMPORT_EXPORT_DATA_DESCRIPTION' - | translate - }} -

-
-
- - -
-
- - @if (isPwa) { -
- {{ 'SETTINGS.EPG_NOTE' | translate }} -  {{ 'SETTINGS.EPG_NOTE_URL_TEXT' | translate }} -
- } -
- -
-
-
- delete_sweep -
-
-

{{ 'SETTINGS.RESET' | translate }}

-

{{ 'SETTINGS.RESET_DESCRIPTION' | translate }}

-
-
- -
-
-
-

{{ 'SETTINGS.REMOVE_ALL' | translate }}

-

- {{ - 'SETTINGS.REMOVE_ALL_DESCRIPTION' - | translate - }} -

-
- -
-
- - folder_open - {{ - 'HOME.PLAYLIST_TYPES.M3U' | translate - }} - {{ - playlistDeleteSummary().m3u - }} - - - cloud - {{ - 'HOME.PLAYLIST_TYPES.XTREAM' | translate - }} - {{ - playlistDeleteSummary().xtream - }} - - - cast - {{ - 'HOME.PLAYLIST_TYPES.STALKER' | translate - }} - {{ - playlistDeleteSummary().stalker - }} - -
- @if (removeAllProgressLabel(); as progressLabel) { -

- {{ progressLabel }} -

- } -
-
- -
-
-
- info -
-
-

{{ 'SETTINGS.ABOUT' | translate }}

-

{{ 'SETTINGS.ABOUT_SUBTITLE' | translate }}

-
-
- -
-
-

{{ 'SETTINGS.VERSION' | translate }}

-

{{ 'SETTINGS.VERSION_DESCRIPTION' | translate }}

-
-
- {{ version }} - {{ updateMessage }} -
-
- - @if (isDesktop) { -
- {{ - 'SETTINGS.EMBEDDED_MPV_NOTICE_TITLE' | translate - }} - {{ - 'SETTINGS.EMBEDDED_MPV_NOTICE_DESCRIPTION' - | translate - }} -
- } - - - -
-
-
-
- favorite -
-
-

- {{ 'SETTINGS.SUPPORT_TITLE' | translate }} -

-

- {{ - 'SETTINGS.SUPPORT_DESCRIPTION' - | translate - }} -

-
-
- -
-
-
+