Merge origin/master into claude/mpv-player-seek-interval-c02a89
No files matched your search
@@ -55,8 +55,10 @@ The body is capped at 400 characters — depth belongs in the blog post.
|
||||
gives it a short, poster-worthy name. The 60-character cap keeps it roughly to
|
||||
one line on the hero card; card text wraps by estimated width, so a headline of
|
||||
unusually wide glyphs may still wrap or ellipsize rather than overflow. Highlights lead the Telegram/Reddit
|
||||
announcements (everything else collapses into a "+N more" counter) and become
|
||||
ready-made section headings in the blog scaffold. Set it on the two or three
|
||||
announcements (everything else collapses into a "+N more" counter) and open the
|
||||
blog scaffold: a row in its "What changed" table and a `##` section ahead of
|
||||
the themed sections, while fixes without a highlight collapse under a spoiler
|
||||
there. Set it on the two or three
|
||||
changes worth announcing — a release where everything is a highlight has none.
|
||||
Not allowed on `type: internal`.
|
||||
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
---
|
||||
type: feature
|
||||
area: embedded-mpv
|
||||
highlight: Embedded MPV reconnects dropped streams
|
||||
---
|
||||
|
||||
The embedded MPV player now reloads a live stream that drops mid-playback on
|
||||
its own, with increasing delays and an "attempt N of 6" line instead of a
|
||||
dead error screen; a new Settings > Playback toggle turns this off. The same
|
||||
section gains an advanced field for extra libmpv options (one key=value per
|
||||
line) that applies on every engine, with a short network timeout on by default.
|
||||
@@ -0,0 +1,10 @@
|
||||
---
|
||||
type: fix
|
||||
area: playback
|
||||
---
|
||||
|
||||
Keyboard shortcuts work again right after you click a button in the built-in
|
||||
player controls. Previously the clicked button kept the keyboard: after
|
||||
clicking fullscreen, Space left fullscreen instead of pausing, and the seek,
|
||||
volume and mute keys did nothing until you clicked the video. Applies to the
|
||||
shared controls in the web players and the Embedded MPV frame-copy engine.
|
||||
@@ -0,0 +1,10 @@
|
||||
---
|
||||
type: fix
|
||||
area: playback
|
||||
---
|
||||
|
||||
With IPTVnator's shared controls turned off, the Video.js player's keyboard
|
||||
shortcuts now work again right after you click a button in its control bar.
|
||||
Previously the clicked button kept the keyboard: after clicking fullscreen,
|
||||
Space left fullscreen instead of pausing, and the seek, volume and mute keys
|
||||
did nothing until you clicked the video.
|
||||
@@ -0,0 +1,10 @@
|
||||
---
|
||||
type: feature
|
||||
area: shell
|
||||
issues: [1455]
|
||||
---
|
||||
|
||||
Settings → General has a new "Window on startup" option: open the desktop app
|
||||
at its last size, maximized, or fullscreen — handy on a TV or HTPC. Starting
|
||||
with `--fullscreen` forces a single fullscreen launch without changing the
|
||||
setting, and F11 now toggles fullscreen anywhere in the app.
|
||||
@@ -0,0 +1,9 @@
|
||||
---
|
||||
type: fix
|
||||
area: stalker
|
||||
---
|
||||
|
||||
In Stalker portals, switching the Live TV or radio category in the sidebar no
|
||||
longer stops the channel that is playing. The category only changes which
|
||||
channels are listed, matching how Xtream portals and M3U playlists already
|
||||
behave.
|
||||
@@ -213,7 +213,18 @@ Key files:
|
||||
discarded on the first bar focus event it is asked about or on any
|
||||
`keydown`, a `pointerdown` inside the bar releases a keyboard pin, and a
|
||||
`keydown` bubbling out of a bar control re-pins it, since operating a
|
||||
focused control produces no focus event. Contract:
|
||||
focused control produces no focus event. A completed pointer click then
|
||||
releases the focus it left on the control (`onBarClick` →
|
||||
`ControlsSurface.releasePointerFocus`, attributed by `wasPointerClick`:
|
||||
non-empty click `pointerType`, else a recent press inside the clicked
|
||||
element), because a focused control captures the keyboard: Space and
|
||||
Enter re-activated the clicked button and `ControlsShortcuts` yields to
|
||||
any interactive element in the key's path, so after a click on fullscreen
|
||||
Space left fullscreen instead of pausing. Keyboard activation (empty
|
||||
`pointerType`) keeps focus, only buttons and range sliders are released,
|
||||
Chromium keeps its sequential-focus starting point at the blurred control
|
||||
so Tab continues from it, and the volume popover ignores the release's
|
||||
`focusout` (`wasPointerFocusRelease`). Contract:
|
||||
`docs/architecture/player-controls-contract.md` (auto-hide paragraph).
|
||||
- Persisted `Settings.webPlayerSharedControls` is default-ON (absent stored
|
||||
values coerce with `!== false`; only an explicit false opts out to the legacy
|
||||
@@ -446,7 +457,27 @@ Key files:
|
||||
path keeps the existing Video.js skin and legacy series navigation unchanged
|
||||
(still without `userActions.hotkeys`), while the playback keyboard shortcuts
|
||||
attach through `LegacyPlayerShortcuts` and drive the player API so the
|
||||
vendor control bar stays in sync (`vjs-legacy-shortcuts.ts`).
|
||||
vendor control bar stays in sync (`vjs-legacy-shortcuts.ts`). That chrome
|
||||
also releases the focus a pointer interaction leaves on a control
|
||||
(`vjs-pointer-focus-release.ts`, sharing `pointer-focus-release.ts`'s
|
||||
`blurFocusedControl` with `ControlsSurface`): a focused Video.js component
|
||||
stops every key before the document and turns Space/Enter into a click, so
|
||||
after a click on fullscreen Space left fullscreen instead of pausing. It is
|
||||
driven mainly by `focusin`, not the click, because choosing a menu item
|
||||
moves focus to the menu button a tick later and that click never bubbles to
|
||||
the shell: an eligible control (button/`role=button`/slider, never a menu
|
||||
item) is released when its focus is attributable to a recent shell
|
||||
`pointerdown` not yet ended by a document `keydown`, so `Tab` focus is kept.
|
||||
A `click` runs the same release for a control clicked while already focused
|
||||
(Tab, then a mouse click), which fires no `focusin`. The release is scoped
|
||||
to `.vjs-control-bar`, so the caption-settings dialog (a modal sibling of
|
||||
the bar) keeps its focus trap. Menu buttons live in the bar and are not
|
||||
exempt: a popup is navigated through its focused item, so releasing the
|
||||
button never disturbs an open menu, and the button focus a pointer moves
|
||||
through (open, item selection, toggling an open menu shut) is released so
|
||||
Space works again after the menu closes. ArtPlayer
|
||||
(non-focusable divs) and the native HTML5 controls (focus lands on the
|
||||
`<video>`) need no counterpart.
|
||||
- ArtPlayer is the fourth guarded consumer. `ArtPlayerComponent` provides a
|
||||
component-scoped `WebVideoControlsAdapter`; `ArtPlayerSourceSession` owns
|
||||
HLS/DASH(Shaka)/MPEG-TS/native sources, the neutral web-video bridge, exact cleanup, and
|
||||
|
||||
@@ -32,7 +32,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
|
||||
- Name it `<area>-<short-slug>.md`; `area` matches the conventional-commit scope. There is no version field — the release version is chosen at release time.
|
||||
- Write the body for a user, not a reviewer: "the player now remembers volume between episodes", not "hoist volume state into the session". Max 400 characters; depth belongs in the release blog post.
|
||||
- `type: internal` records invisible maintenance. Internal notes stay collapsed in `CHANGELOG.md`, are omitted from the blog scaffold, and are removed from the authored public GitHub body by `extract-changelog-section.mjs --public`; GitHub's generated commit list remains separate, so an internal-only release can have an empty authored body.
|
||||
- `highlight: <short headline>` (max 60 characters, rejected on `type: internal`) marks a note as one of the release's two or three headline changes. Highlights lead the Telegram/Reddit announcement drafts, become ready-made blog section headings, and are the input the highlight-card generator renders from. A release where everything is a highlight has none.
|
||||
- `highlight: <short headline>` (max 60 characters, rejected on `type: internal`) marks a note as one of the release's two or three headline changes. Highlights lead the Telegram/Reddit announcement drafts, open the blog scaffold (a "What changed" table row plus a leading `##` section each, while the remaining features fold into themed sections and non-highlighted fixes collapse under a spoiler — `tools/release/release-notes-blog.mjs`), and are the input the highlight-card generator renders from. A release where everything is a highlight has none.
|
||||
- Skip the note for test-only changes, docs, CI/workflow plumbing, and pure refactors with no behavior change. When skipping on a PR that touches `apps/**` or `libs/**`, apply the `no-release-note` label.
|
||||
- CI enforces this: the "Release note gate" job in `.github/workflows/ci.yml` fails PRs that change runtime code without an added `.changes/*.md` or the label (policy in `tools/release/check-release-note-gate.mjs`; tests/e2e/website/mock-server/docs paths are auto-exempt).
|
||||
- The `release-notes` skill covers writing notes; the `release-cut` skill covers the full release sequence. Canonical contract — surfaces, ordering constraints, the required draft asset set: `docs/architecture/release-pipeline.md`.
|
||||
@@ -41,7 +41,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
|
||||
- Pushes to `master` and `v*` can publish Docker images. A `v*` tag build creates a draft GitHub release.
|
||||
- `pnpm run release:verify:draft` waits for that tag build (polling until the run is indexed, then `gh run watch`) and verifies the draft's status, authored body, and complete required asset set. It is read-only and deliberately fails on an already-published release, because it is the gate that runs before publication.
|
||||
- Publishing the GitHub release verifies its Snap assets and automatically uploads them to `edge`; installed-Snap smoke and candidate/stable promotion remain manual.
|
||||
- Release-post screenshots come only from the release capture script running against the mock servers. Never add a screenshot taken from a real playlist or account to `apps/website/public/blog/**` — real streams, logos, and metadata are copyrighted, and credentials must never reach a published image.
|
||||
- Release-post screenshots come only from the release capture script running against the mock servers. Never add a screenshot taken from a real playlist or account to `apps/website/public/blog/**` — real streams, logos, and metadata are copyrighted, and credentials must never reach a published image. Website guide screenshots use the same script: manifest shots with `"group": "guides"` are captured only by `pnpm release:screenshots --group guides` and land in `apps/website/public/blog/guides/screenshots/`.
|
||||
- Final task summaries should state whether a release note was added or why it was skipped.
|
||||
|
||||
## Regression Prevention And Test Updates
|
||||
@@ -187,7 +187,7 @@ Useful narrower flags:
|
||||
- `IPTVNATOR_TRACE_DB=1` traces DB worker requests and DB progress events
|
||||
- `IPTVNATOR_TRACE_SQL=1` traces SQLite statements in both main and worker connections
|
||||
- `IPTVNATOR_TRACE_WINDOW=1` traces BrowserWindow navigation/load lifecycle
|
||||
- `IPTVNATOR_TRACE_PLAYER=1` traces external-player activity and bounded Embedded MPV runtime-probe stderr
|
||||
- `IPTVNATOR_TRACE_PLAYER=1` traces external-player activity, bounded Embedded MPV runtime-probe stderr, and embedded MPV session status transitions (the input of the reconnect policy; never the stream URL)
|
||||
- `IPTVNATOR_TRACE_RENDERER_CONSOLE=1` mirrors renderer console logs into the Electron terminal
|
||||
- `IPTVNATOR_PERF_CAPTURE=1` enables development/test-only, redacted M3U and Xtream preload IPC request/completion markers plus count-only M3U acquire/parse/normalize, Xtream main network/JSON-transform/success-response-ready/cancel-dispatch, and renderer store phase capture; renderer wrappers emit only while the benchmark installs its Symbol hook, benchmark tooling sets the flag explicitly, and production launches must leave it unset
|
||||
- `IPTVNATOR_PERF_WORKER_PROFILING=1` enables development/test-only, request-scoped worker receive/work/response-post timestamps, thread CPU, event-loop utilization/delay, count-only playlist serialization/SQLite write/read/deserialization plus Xtream category/content/cache-clear/delete/in-source-search phase events, profiling-only worker cancel-receipt acknowledgements, valid-sample-counted isolate peak memory, and the database worker's idle-only one-shot post-GC heap probe; overlapping database requests are explicitly invalidated instead of misattributed, the performance benchmark sets the flag automatically, and production launches must leave it unset
|
||||
@@ -337,7 +337,7 @@ This is an Nx monorepo with the following structure:
|
||||
- **shared/host-health** - Per-host circuit breaker for portal requests (`HostConnectivityGuard`), shared by the Electron main process and the web backend; transport-free, the owning app supplies the clock and owns the instance
|
||||
- **shared/database** - Canonical Drizzle schema and DB connection (used by the Electron backend)
|
||||
- **shared/m3u-utils** - M3U playlist utilities
|
||||
- **shared/marketing-fixtures** - Provider-neutral fictional movie metadata shared by the Xtream and Stalker marketing mocks
|
||||
- **shared/marketing-fixtures** - Provider-neutral fictional movie metadata, live channel list and the generated channel-logo SVG renderer shared by the Xtream and Stalker marketing mocks (both serve `/assets/marketing/logo/<slug>.svg`)
|
||||
- **shared/testing** - Shared test helpers
|
||||
- **ui/components** - Reusable UI components (incl. channel list)
|
||||
- **ui/epg** - EPG UI (timeline ribbon, multi-EPG, progress panel, program dialogs)
|
||||
@@ -566,7 +566,7 @@ See `docs/architecture/m3u-playlist-module.md` for complete documentation.
|
||||
`/workspace/xtreams/:id/downloads/:downloadId` and
|
||||
`/workspace/stalker/:id/downloads/:downloadId`. Focused download details hide
|
||||
the workspace context panel.
|
||||
- Settings: `/workspace/settings/:section` — one page per section (`general`, `playback`, `epg`, `dashboard`, `remote-control`, `tmdb`, `backup`, `reset`, `about`); `/workspace/settings` redirects to `general`, unknown or capability-gated sections redirect there too, and `/settings` redirects into the workspace. The shared form lives on the parent `SettingsComponent`, so edits survive section switches; a floating unsaved-changes bar (Save/Discard) replaces the old always-visible footer Save button. Leaving the settings AREA with a dirty form triggers `settingsUnsavedChangesGuard` (canDeactivate) and a save/discard/stay dialog — section switches deliberately bypass it, and a failed save cancels the navigation. Non-router exits are covered too: `SettingsUnloadGuardService` (provided by `SettingsComponent`) arms a `beforeunload` handler while the form is dirty (native leave prompt in the PWA) and arms an Electron main-process close guard (`window-close-guard.service.ts`) for the whole settings mount — mount-long on purpose, since arming on the first edit would race the close it protects against. The guard intercepts window close/app quit before `beforeunload` fires and completes the original intent only after the renderer confirms through the same dialog (a pristine form auto-confirms); Electron reloads are cancelled and re-triggered the same way, a failed save always keeps the window open, and installing an app update suspends the whole guard so the updater's quit passes unchallenged — every install entry point (settings About section and the global update notification panel) must go through the root `AppUpdateInstallService`, which owns that suspend/restore choreography
|
||||
- Settings: `/workspace/settings/:section` — one page per section (`general`, `playback`, `epg`, `dashboard`, `remote-control`, `tmdb`, `backup`, `reset`, `about`); `/workspace/settings` redirects to `general`, unknown or capability-gated sections redirect there too, and `/settings` redirects into the workspace. The `general` section's "Window on startup" select (`Settings.startupWindowMode`: `normal` / `maximized` / `fullscreen`, Electron only, gated on `RuntimeCapabilitiesService.supportsStartupWindowMode`) is mirrored into the main-process config by the `SETTINGS_UPDATE` handler and read synchronously at the next window creation — the renderer's IndexedDB is unreachable then, so it is the same pattern as `embeddedMpvFrameCopy`; `iptvnator --fullscreen` forces one fullscreen launch without persisting it, and F11 (`WINDOW:TOGGLE_FULLSCREEN`, bound in `WorkspaceKeyboardShortcutsService`, skipped while the player owns `document.fullscreenElement`) is the exit path on Windows/Linux, where the title bar is hidden (contract: `docs/architecture/workspace-shell.md`, "Startup window mode"). The shared form lives on the parent `SettingsComponent`, so edits survive section switches; a floating unsaved-changes bar (Save/Discard) replaces the old always-visible footer Save button. Leaving the settings AREA with a dirty form triggers `settingsUnsavedChangesGuard` (canDeactivate) and a save/discard/stay dialog — section switches deliberately bypass it, and a failed save cancels the navigation. Non-router exits are covered too: `SettingsUnloadGuardService` (provided by `SettingsComponent`) arms a `beforeunload` handler while the form is dirty (native leave prompt in the PWA) and arms an Electron main-process close guard (`window-close-guard.service.ts`) for the whole settings mount — mount-long on purpose, since arming on the first edit would race the close it protects against. The guard intercepts window close/app quit before `beforeunload` fires and completes the original intent only after the renderer confirms through the same dialog (a pristine form auto-confirms); Electron reloads are cancelled and re-triggered the same way, a failed save always keeps the window open, and installing an app update suspends the whole guard so the updater's quit passes unchallenged — every install entry point (settings About section and the global update notification panel) must go through the root `AppUpdateInstallService`, which owns that suspend/restore choreography
|
||||
|
||||
**Service Architecture** (Factory Pattern):
|
||||
|
||||
@@ -700,6 +700,7 @@ This project uses modern Angular signal-based APIs and patterns. **ALWAYS** use
|
||||
|
||||
- Bootstraps Electron app and initializes database
|
||||
- Registers event handlers for IPC communication
|
||||
- Creates the main window per the startup window mode (`app/app.ts` `initMainWindow`, resolver in `app/services/startup-window-mode.ts`): the electron-conf `STARTUP_WINDOW_MODE` mirror or the one-shot `--fullscreen` switch (consumed by the first window, so a window the macOS Dock re-creates in the same process follows the stored setting); `fullscreen: true` is a constructor option that Windows/Linux honour before the first paint, while macOS ignores it on a hidden window, so `ready-to-show` repeats the request after `show()` only when `isFullScreen()` is still false, through the same tracker the F11 toggle uses (`app/services/native-fullscreen-transitions.ts`: per-window fullscreen state seeded once at creation via `trackNativeFullScreen` and fed only by the enter/leave events afterwards, plus a pending record holding the latest target, cleared when an event lands on it, kept when an event lands on the other state, and ignored after 2 s; the tracker only observes and never issues a request itself, since a "repeat on mismatch" cannot be told apart from reversing the user's own green-button action — a toggle is never decided against `isFullScreen()`, which is stale mid-transition and, on Windows, even during the event), so F11 during the startup animation exits instead of re-requesting; `maximize()` waits for `ready-to-show` too (it would show a hidden window early). `attachWindowStateEvents` tracks native and HTML-element fullscreen as two flags OR-ed into `WINDOW:STATE_CHANGED`, because Electron leaves only the HTML state when the window was already natively fullscreen
|
||||
- Holds a single-instance lock (`app/services/single-instance.ts`), requested after the `userData` override so E2E runs with their own data dir keep independent locks. A second launch quits and focuses the running window; concurrent instances would otherwise share a Chromium profile whose IndexedDB only one of them can lock, silently breaking renderer-side settings persistence. `IPTVNATOR_ALLOW_MULTIPLE_INSTANCES=1` opts out for local debugging. The guard also forwards that launch's argv and working directory, so `iptvnator playlist.m3u` against a running app opens the playlist instead of being discarded.
|
||||
|
||||
**Database**:
|
||||
@@ -996,7 +997,7 @@ app as a real argument, so it is not an option.
|
||||
API. Radio's `<audio>` deliberately never blocks display sleep. Embedded
|
||||
MPV holds its own blocker in `EmbeddedMpvNativeService`; external MPV/VLC
|
||||
inhibit the screensaver themselves.
|
||||
- Embedded MPV (experimental, macOS/Windows/Linux): renders mpv video inside the Electron window through a native addon. macOS uses the libmpv render API in an `NSOpenGLView`; Windows uses in-process libmpv with `--wid` against an app-owned child `HWND`; Linux spawns an out-of-process `mpv --wid=<x11-window>` controlled over a JSON IPC socket (X11/XWayland only, requires system `mpv` on PATH; subtitles/speed/aspect/recording are not exported there). mpv's own screensaver inhibition does not apply to any of these paths, so `EmbeddedMpvNativeService` holds an Electron `powerSaveBlocker` (`prevent-display-sleep`) whenever any session's status is `playing`, and releases it on pause, dispose, or shutdown. Renderer bounds are CSS pixels; the service converts them to native units in the main process (`embedded-mpv-bounds.util.ts`: × page zoom everywhere, × display scale on Windows/Linux whose child windows are positioned in physical pixels; frame-copy bounds stay unscaled), and the session controller re-syncs bounds when `devicePixelRatio` changes and polls (500 ms, drift-gated) for position-only layout shifts that `ResizeObserver` cannot observe. Arrow-key and ±10 s button steps go through the relative `seekEmbeddedMpvBy` IPC (mpv `seek <delta> relative+exact`; addon export `seekBy`, helper stdin command `seek-by`, Linux JSON IPC), never an absolute target computed from the renderer's whole-second, 500 ms-polled `positionSeconds` — that stale base collapsed rapid presses onto one target (about 1 s of progress per press); only the timeline scrub commits an absolute `seek`. Service: `apps/electron-backend/src/app/services/embedded-mpv-native.service.ts`; full architecture: `docs/architecture/embedded-mpv-native.md`.
|
||||
- Embedded MPV (experimental, macOS/Windows/Linux): renders mpv video inside the Electron window through a native addon. Two per-session knobs are captured at session creation from the main-process settings mirror (`readEmbeddedMpvSessionOptions()`, read in the `EMBEDDED_MPV_CREATE_SESSION` handler — never inside the service, whose specs would otherwise construct electron-conf): `Settings.embeddedMpvExtraOptions` (free-form `key=value` libmpv lines, forbidden embed-critical keys refused by the form, network defaults `network-timeout=10` + ffmpeg `reconnect` prepended, applied on every engine after its built-ins — Windows/macOS via `mpv_set_option_string`, Linux through a user-only `--include` config file, frame-copy as the helper's first stdin line — never on a command line, where `ps` could read a credential-bearing header option) and `Settings.embeddedMpvAutoReconnect` (default on: `EmbeddedMpvReconnectCoordinator` in `embedded-mpv-reconnect.ts` reloads the last playback on `error`, or `ended` for live, only if it had played, with 2 s→30 s backoff, six attempts per outage, budget reset after 30 s stable playing, cancelled by user loads/pause/dispose; a recording running at the drop is finalized as an interrupted partial when the reload actually replaces the stream (a stream that recovers on its own keeps recording) and restarted into a new file once that reload plays, and an external subtitle file added through `sub-add` is re-added once the reload plays unless the user picked another track or loaded something else since; the renderer only displays `EmbeddedMpvSession.reconnect`). Contract: `docs/architecture/embedded-mpv-native.md` ("Session Options", "Network Auto-Reconnect"). macOS uses the libmpv render API in an `NSOpenGLView`; Windows uses in-process libmpv with `--wid` against an app-owned child `HWND`; Linux spawns an out-of-process `mpv --wid=<x11-window>` controlled over a JSON IPC socket (X11/XWayland only, requires system `mpv` on PATH; subtitles/speed/aspect/recording are not exported there). mpv's own screensaver inhibition does not apply to any of these paths, so `EmbeddedMpvNativeService` holds an Electron `powerSaveBlocker` (`prevent-display-sleep`) whenever any session's status is `playing`, and releases it on pause, dispose, or shutdown. Renderer bounds are CSS pixels; the service converts them to native units in the main process (`embedded-mpv-bounds.util.ts`: × page zoom everywhere, × display scale on Windows/Linux whose child windows are positioned in physical pixels; frame-copy bounds stay unscaled), and the session controller re-syncs bounds when `devicePixelRatio` changes and polls (500 ms, drift-gated) for position-only layout shifts that `ResizeObserver` cannot observe. Arrow-key and ±10 s button steps go through the relative `seekEmbeddedMpvBy` IPC (mpv `seek <delta> relative+exact`; addon export `seekBy`, helper stdin command `seek-by`, Linux JSON IPC), never an absolute target computed from the renderer's whole-second, 500 ms-polled `positionSeconds` — that stale base collapsed rapid presses onto one target (about 1 s of progress per press); only the timeline scrub commits an absolute `seek`. Service: `apps/electron-backend/src/app/services/embedded-mpv-native.service.ts`; full architecture: `docs/architecture/embedded-mpv-native.md`.
|
||||
- Embedded MPV frame-copy engine (experimental, macOS Apple Silicon + Linux
|
||||
x64 + Windows; enabled via `Settings > Playback > Embedded MPV: frame-copy
|
||||
engine` (restart required) or
|
||||
@@ -1129,7 +1130,7 @@ engine` (restart required) or
|
||||
helper: `apps/electron-backend/native/helper/`; canonical packaging/runtime
|
||||
contracts: `docs/architecture/embedded-mpv-native.md` and
|
||||
`tools/embedded-mpv/README.md`.
|
||||
- Shared player-controls layer: `libs/ui/playback/src/lib/player-controls/` exports the engine-neutral `PlayerController` contract, standalone `app-player-controls`, a generic web-video adapter/helper, and component-scoped `WEB_PLAYER_SHARED_CONTROLS` rollout token. Its subtitle menu carries capability-gated advanced subtitle support (#1408): external subtitle file loading, a ±0.5 s timing-offset row, and size/color styling persisted in the shared `subtitleStyle` localStorage key. HTML5/ArtPlayer implement it through the neutral source bridge (`.srt`/`.vtt` via a DOM file picker with encoding detection, native `TextTrack` rendering, `::cue` styling, delay only while the loaded file is the selected track; picks are source-generation-guarded and engine deselection precedes external track activation); the canonical style shape and clamp/normalize rules are shared with the main process via `@iptvnator/shared/interfaces` (`subtitle-style.util.ts`). Embedded MPV frame-copy implements it through new helper protocol commands (`sub-add`/`sub-delay`/`sub-scale`/`sub-color`, main-process file dialog, ASS supported, delay for all tracks). Video.js shared mode, vendor-chrome paths, native-view, and the Linux out-of-process path advertise no such capability and render no UI. Contract details: `docs/architecture/player-controls-contract.md` ("Advanced subtitle support"). Shared controls include a per-session quality menu (Auto + “1080p”-style levels via `setQualityLevel`; `AUTO_QUALITY_LEVEL_ID` restores ABR): the capability derives from the manifest — advertised only when the source exposes >1 video rendition (multi-variant HLS via hls.js `nextLevel`/`manualLevel`, DASH via Shaka variant tracks pinned to the active variant's exact audio stream (`audioId`, language fallback) with ABR toggled off for manual picks, Video.js via videojs-contrib-quality-levels) — so single-bitrate VOD and raw MPEG-TS never show it, nothing persists to Settings, and Embedded MPV/external players report the capability false. In fullscreen, `app-player-controls` shows a pointer-transparent media-title overlay at the top while controls are revealed (`mediaTitle` input: movie/channel/series name, plus an `S01E03` second line for episodes; series names flow from the detail views through `PortalInlinePlayerComponent.seriesTitle` and `WebPlayerViewComponent.mediaTitle`). Persisted `Settings.webPlayerSharedControls` is default-ON (absent stored values coerce with `!== false` in every normalization site; only an explicit false — the Settings > Playback checkbox — opts out to the legacy vendor chrome), and its checkbox appears only when HTML5, Video.js, or ArtPlayer is selected. The shared surface has explicit touch semantics (`ControlsSurface.wasTouchInteraction`): viewport taps toggle overlay visibility instead of pausing, the volume popover opens on tap instead of hover, coarse pointers get a taller scrub strip, and at container widths ≤640px the bar reflows to two rows (full-width timeline above transport + an end-aligned, wrapping actions cluster with 40px buttons whose panels remain unclipped). Only keyboard-originated focus pins the bar open: Chromium also focuses a clicked `<button>`, so `ControlsSurface.wasPointerInteraction` attributes a `focusin` to a recent `pointerdown` inside the focused element and such focus reveals without blocking auto-hide (otherwise the fullscreen button left the controls on screen until a click-to-pause on the viewport); the press record is discarded on the first bar focus event or any `keydown`, a `pointerdown` inside the bar releases a keyboard pin, and a `keydown` bubbling out of a bar control re-pins it, since operating a focused control produces no focus event. Deliberately dropped vs. vendor chrome (opt-out retains them): Video.js spatial navigation, ArtPlayer screenshot/AirPlay/web-fullscreen/mini-progress/vendor gestures — listed in the contract doc's "Known differences" section. `WebPlayerViewComponent` snapshots the preference into the immutable token for each new player host. The parent `/workspace` route awaits the initial `SettingsStore` load, including cold-start direct links, before this snapshot can occur. Saving applies to the next host without an application restart; an existing session never changes controls mode in place. Embedded MPV ignores the web-player preference: frame-copy always uses shared DOM controls through `EmbeddedMpvControlsAdapter`, native-view retains its compositor-safe legacy dock, and external MPV/VLC retain their own UI. The Embedded MPV host selects exactly one controls UI for its reported engine. `showControls=false` detaches the shared surface, modal overlays gate frame-copy playback shortcuts, fullscreen remains DOM-based with Embedded MPV bounds sync — its owner is the `app-web-player-view` host (`WebPlayerViewComponent.fullscreenSurface`, passed to every engine as `fullscreenTarget`), not the engine shell, because the view remounts the engine componeLine truncated
|
||||
- Shared player-controls layer: `libs/ui/playback/src/lib/player-controls/` exports the engine-neutral `PlayerController` contract, standalone `app-player-controls`, a generic web-video adapter/helper, and component-scoped `WEB_PLAYER_SHARED_CONTROLS` rollout token. Its subtitle menu carries capability-gated advanced subtitle support (#1408): external subtitle file loading, a ±0.5 s timing-offset row, and size/color styling persisted in the shared `subtitleStyle` localStorage key. HTML5/ArtPlayer implement it through the neutral source bridge (`.srt`/`.vtt` via a DOM file picker with encoding detection, native `TextTrack` rendering, `::cue` styling, delay only while the loaded file is the selected track; picks are source-generation-guarded and engine deselection precedes external track activation); the canonical style shape and clamp/normalize rules are shared with the main process via `@iptvnator/shared/interfaces` (`subtitle-style.util.ts`). Embedded MPV frame-copy implements it through new helper protocol commands (`sub-add`/`sub-delay`/`sub-scale`/`sub-color`, main-process file dialog, ASS supported, delay for all tracks). Video.js shared mode, vendor-chrome paths, native-view, and the Linux out-of-process path advertise no such capability and render no UI. Contract details: `docs/architecture/player-controls-contract.md` ("Advanced subtitle support"). Shared controls include a per-session quality menu (Auto + “1080p”-style levels via `setQualityLevel`; `AUTO_QUALITY_LEVEL_ID` restores ABR): the capability derives from the manifest — advertised only when the source exposes >1 video rendition (multi-variant HLS via hls.js `nextLevel`/`manualLevel`, DASH via Shaka variant tracks pinned to the active variant's exact audio stream (`audioId`, language fallback) with ABR toggled off for manual picks, Video.js via videojs-contrib-quality-levels) — so single-bitrate VOD and raw MPEG-TS never show it, nothing persists to Settings, and Embedded MPV/external players report the capability false. In fullscreen, `app-player-controls` shows a pointer-transparent media-title overlay at the top while controls are revealed (`mediaTitle` input: movie/channel/series name, plus an `S01E03` second line for episodes; series names flow from the detail views through `PortalInlinePlayerComponent.seriesTitle` and `WebPlayerViewComponent.mediaTitle`). Persisted `Settings.webPlayerSharedControls` is default-ON (absent stored values coerce with `!== false` in every normalization site; only an explicit false — the Settings > Playback checkbox — opts out to the legacy vendor chrome), and its checkbox appears only when HTML5, Video.js, or ArtPlayer is selected. The shared surface has explicit touch semantics (`ControlsSurface.wasTouchInteraction`): viewport taps toggle overlay visibility instead of pausing, the volume popover opens on tap instead of hover, coarse pointers get a taller scrub strip, and at container widths ≤640px the bar reflows to two rows (full-width timeline above transport + an end-aligned, wrapping actions cluster with 40px buttons whose panels remain unclipped). Only keyboard-originated focus pins the bar open: Chromium also focuses a clicked `<button>`, so `ControlsSurface.wasPointerInteraction` attributes a `focusin` to a recent `pointerdown` inside the focused element and such focus reveals without blocking auto-hide (otherwise the fullscreen button left the controls on screen until a click-to-pause on the viewport); the press record is discarded on the first bar focus event or any `keydown`, a `pointerdown` inside the bar releases a keyboard pin, and a `keydown` bubbling out of a bar control re-pins it, since operating a focused control produces no focus event. A completed pointer click then releases the focus it left on the control (`onBarClick` → `ControlsSurface.releasePointerFocus`, attributed by `wasPointerClick`: non-empty click `pointerType`, else a recent press inside the clicked element): a focused control captures the keyboard — Space and Enter re-activated the clicked button and `ControlsShortcuts` yields to any interactive element in the key's path, so after a click on fullscreen Space left fullscreen instead of pausing. Keyboard activation (empty `pointerType`) keeps focus, only buttons and range sliders are released, Chromium keeps its sequential-focus starting point at the blurred control so Tab continues from it, and the volume popover ignores the release's `focusout` (`wasPointerFocusRelease`). Deliberately dropped vs. vendor chrome (opt-out retains them): Video.js spatial navigation, ArtPlayer screenshot/AirPlay/web-fullscreen/mini-progress/vendor gestures — listed in the contract doc's "Known differences" section. `WebPlayerViewComponent` snapshots the preference into the immutable token for each new player host. The parent `/workspace` route awaits the initial `SettingsStore` load, including cold-start direct links, before this snapshot can occur. Saving applies to the Line truncated
|
||||
- Shared web picture-in-picture stays inside that default-on rollout.
|
||||
`PlayerController` exposes capability `pictureInPicture`, state
|
||||
`pictureInPictureActive`/`canPictureInPicture`, and command
|
||||
|
||||
@@ -79,6 +79,7 @@ Press `?` or `Shift+/` in the workspace to open the in-app shortcuts list.
|
||||
| Global | `Ctrl/Cmd+F` | Open global search in the desktop app |
|
||||
| Global | `Ctrl/Cmd+R` | Open recently viewed in the desktop app |
|
||||
| Global | `Enter` in workspace search | Submit the current search |
|
||||
| Global | `F11` | Toggle app window fullscreen in the desktop app |
|
||||
| Navigation | `Ctrl/Cmd+B` | Toggle the live sidebar |
|
||||
| Navigation | `0-9` | Select an M3U channel by number |
|
||||
| Playback | `Space` / `K` | Play or pause playback |
|
||||
@@ -90,6 +91,11 @@ Press `?` or `Shift+/` in the workspace to open the in-app shortcuts list.
|
||||
| Dialogs and lists | `Enter` | Run the selected command or open a focused item |
|
||||
| Dialogs and lists | `Escape` | Close dialogs and dismiss overlays |
|
||||
|
||||
The desktop app can also open at its last size, maximized, or fullscreen on
|
||||
every launch (Settings → General → "Window on startup"), and `iptvnator
|
||||
--fullscreen` forces a single fullscreen launch for TV or HTPC autostart
|
||||
scripts without changing that setting.
|
||||
|
||||
## Screenshots:
|
||||
|
||||
| Dashboard with recently watched content | Live channels with inline player and EPG |
|
||||
|
||||
@@ -837,8 +837,129 @@ test.describe('Electron Settings', () => {
|
||||
}
|
||||
});
|
||||
|
||||
test('@settings @persistence @electron launches fullscreen once the startup window mode is saved and toggles it with F11', async ({
|
||||
dataDir,
|
||||
}) => {
|
||||
let app = await launchElectronApp(dataDir);
|
||||
|
||||
try {
|
||||
await openSettings(app.mainWindow);
|
||||
await expect(
|
||||
app.mainWindow.getByTestId('startup-window-mode-setting')
|
||||
).toBeVisible();
|
||||
await selectSettingsOption(
|
||||
app.mainWindow,
|
||||
'select-startup-window-mode',
|
||||
'startup-window-mode-fullscreen'
|
||||
);
|
||||
await saveSettings(app.mainWindow);
|
||||
|
||||
// The mode is read when the window is created, so saving it
|
||||
// leaves the running window alone.
|
||||
expect(await isMainWindowFullScreen(app)).toBe(false);
|
||||
} finally {
|
||||
app = await restartElectronApp(app, dataDir);
|
||||
}
|
||||
|
||||
try {
|
||||
await expect
|
||||
.poll(() => isMainWindowFullScreen(app), { timeout: 10_000 })
|
||||
.toBe(true);
|
||||
await openSettings(app.mainWindow);
|
||||
await expect(
|
||||
app.mainWindow.getByTestId('select-startup-window-mode')
|
||||
).toContainText(/Fullscreen/);
|
||||
|
||||
// F11 is the way out on Windows/Linux, where the title bar is
|
||||
// hidden and the custom window controls hide while fullscreen.
|
||||
await app.mainWindow.keyboard.press('F11');
|
||||
await expect
|
||||
.poll(() => isMainWindowFullScreen(app), { timeout: 10_000 })
|
||||
.toBe(false);
|
||||
|
||||
await app.mainWindow.keyboard.press('F11');
|
||||
await expect
|
||||
.poll(() => isMainWindowFullScreen(app), { timeout: 10_000 })
|
||||
.toBe(true);
|
||||
} finally {
|
||||
await closeElectronApp(app);
|
||||
}
|
||||
});
|
||||
|
||||
test('@settings @electron --fullscreen forces one fullscreen launch without persisting it', async ({
|
||||
dataDir,
|
||||
}) => {
|
||||
let app = await launchElectronApp(dataDir, {
|
||||
appArgs: ['--fullscreen'],
|
||||
});
|
||||
|
||||
try {
|
||||
await expect
|
||||
.poll(() => isMainWindowFullScreen(app), { timeout: 10_000 })
|
||||
.toBe(true);
|
||||
} finally {
|
||||
app = await restartElectronApp(app, dataDir);
|
||||
}
|
||||
|
||||
try {
|
||||
// The switch is a one-shot: the next plain launch follows the
|
||||
// stored setting, which is still the default.
|
||||
expect(await isMainWindowFullScreen(app)).toBe(false);
|
||||
await openSettings(app.mainWindow);
|
||||
await expect(
|
||||
app.mainWindow.getByTestId('select-startup-window-mode')
|
||||
).toContainText(/Last window size/);
|
||||
} finally {
|
||||
await closeElectronApp(app);
|
||||
}
|
||||
});
|
||||
|
||||
test('@settings @persistence @electron launches maximized when the startup window mode says so', async ({
|
||||
dataDir,
|
||||
}) => {
|
||||
test.skip(
|
||||
process.platform === 'linux' && !!process.env['CI'],
|
||||
'xvfb on Linux CI has no window manager, so maximize never takes effect'
|
||||
);
|
||||
let app = await launchElectronApp(dataDir);
|
||||
|
||||
try {
|
||||
await openSettings(app.mainWindow);
|
||||
await selectSettingsOption(
|
||||
app.mainWindow,
|
||||
'select-startup-window-mode',
|
||||
'startup-window-mode-maximized'
|
||||
);
|
||||
await saveSettings(app.mainWindow);
|
||||
} finally {
|
||||
app = await restartElectronApp(app, dataDir);
|
||||
}
|
||||
|
||||
try {
|
||||
await expect
|
||||
.poll(
|
||||
() =>
|
||||
app.electronApp.evaluate(({ BrowserWindow }) => {
|
||||
const mainWindow = BrowserWindow.getAllWindows()[0];
|
||||
return mainWindow ? mainWindow.isMaximized() : false;
|
||||
}),
|
||||
{ timeout: 10_000 }
|
||||
)
|
||||
.toBe(true);
|
||||
expect(await isMainWindowFullScreen(app)).toBe(false);
|
||||
} finally {
|
||||
await closeElectronApp(app);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
function isMainWindowFullScreen(app: LaunchedElectronApp): Promise<boolean> {
|
||||
return app.electronApp.evaluate(({ BrowserWindow }) => {
|
||||
const mainWindow = BrowserWindow.getAllWindows()[0];
|
||||
return mainWindow ? mainWindow.isFullScreen() : false;
|
||||
});
|
||||
}
|
||||
|
||||
async function selectSettingsOption(
|
||||
page: Page,
|
||||
selectTestId: string,
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
* Usage:
|
||||
* iptvnator_mpv_helper --shm-base /impv-<id> --width 1280 --height 720
|
||||
* [--volume 0..1] [--hwdec auto]
|
||||
* [--mpv-options-stdin]
|
||||
*/
|
||||
#include <mpv/client.h>
|
||||
|
||||
@@ -72,6 +73,9 @@ struct SnapshotState {
|
||||
std::string recordingStartedAt;
|
||||
std::string recordingError;
|
||||
std::string error;
|
||||
/* True when `error` is the engine's own failure (a fatal libmpv log)
|
||||
* rather than the stream's: the app must not reload media for it. */
|
||||
bool engineError = false;
|
||||
};
|
||||
|
||||
struct HelperState {
|
||||
@@ -173,7 +177,10 @@ std::string composeSnapshotLocked() {
|
||||
recording.str("startedAt", s.recordingStartedAt);
|
||||
if (!s.recordingError.empty()) recording.str("error", s.recordingError);
|
||||
writer.raw("recording", recording.finish());
|
||||
if (!s.error.empty()) writer.str("error", s.error);
|
||||
if (!s.error.empty()) {
|
||||
writer.str("error", s.error);
|
||||
if (s.engineError) writer.str("errorOrigin", "engine");
|
||||
}
|
||||
return writer.finish();
|
||||
}
|
||||
|
||||
@@ -382,6 +389,7 @@ void runMpvEventLoop() {
|
||||
case MPV_EVENT_START_FILE:
|
||||
s.status = "loading";
|
||||
s.error.clear();
|
||||
s.engineError = false;
|
||||
s.audioTracks.clear();
|
||||
s.selectedAudioTrackId = -1;
|
||||
s.subtitleTracks.clear();
|
||||
@@ -413,6 +421,7 @@ void runMpvEventLoop() {
|
||||
g_state.running.load()) {
|
||||
s.status = "loading";
|
||||
s.error.clear();
|
||||
s.engineError = false;
|
||||
g_state.loadedPath = false;
|
||||
} else if (g_state.running.load()) {
|
||||
s.status = "idle";
|
||||
@@ -440,6 +449,7 @@ void runMpvEventLoop() {
|
||||
}
|
||||
if (level == "fatal") {
|
||||
s.status = "error";
|
||||
s.engineError = true;
|
||||
}
|
||||
emitLine(JsonWriter()
|
||||
.str("event", "log")
|
||||
@@ -486,6 +496,7 @@ void handleLoadCommand(const Command& command) {
|
||||
}
|
||||
g_state.snapshot.streamUrl = url;
|
||||
g_state.snapshot.error.clear();
|
||||
g_state.snapshot.engineError = false;
|
||||
g_state.snapshot.status = "loading";
|
||||
g_state.dirty = true;
|
||||
}
|
||||
@@ -640,6 +651,12 @@ struct HelperArgs {
|
||||
std::string shmBase = "/impv";
|
||||
std::string hwdec = "auto";
|
||||
std::string audioDelay; /* seconds, mpv audio-delay passthrough */
|
||||
/* Read the session's "key=value" libmpv options (network defaults plus
|
||||
* the user's Settings > Playback lines) from the first stdin line and
|
||||
* apply them after the built-in block so they override it. They never
|
||||
* travel on argv: a credential-bearing option must not be readable
|
||||
* through `ps` or /proc/<pid>/cmdline. */
|
||||
bool mpvOptionsOnStdin = false;
|
||||
int width = 1280;
|
||||
int height = 720;
|
||||
double volume = 1;
|
||||
@@ -659,6 +676,7 @@ HelperArgs parseArgs(int argc, char** argv) {
|
||||
else if (arg == "--volume") args.volume = std::atof(next().c_str());
|
||||
else if (arg == "--hwdec") args.hwdec = next();
|
||||
else if (arg == "--audio-delay") args.audioDelay = next();
|
||||
else if (arg == "--mpv-options-stdin") args.mpvOptionsOnStdin = true;
|
||||
else if (arg == "--runtime-probe") args.runtimeProbe = true;
|
||||
}
|
||||
args.width = std::max(16, args.width);
|
||||
@@ -822,6 +840,53 @@ int main(int argc, char** argv) {
|
||||
mpv_set_option_string(g_state.mpv, "audio-delay",
|
||||
args.audioDelay.c_str());
|
||||
}
|
||||
if (args.mpvOptionsOnStdin) {
|
||||
/* `mpv-options\to000=<key=value>\to001=...` in the regular command
|
||||
* encoding; zero-padded keys keep the application order (network
|
||||
* defaults first, then the user's lines) through the field map. */
|
||||
std::string preamble;
|
||||
if (!std::getline(std::cin, preamble)) {
|
||||
emitLine(JsonWriter()
|
||||
.str("event", "fatal")
|
||||
.str("error", "mpv-options preamble missing on stdin")
|
||||
.finish());
|
||||
return 1;
|
||||
}
|
||||
const frame_helper::Command preambleCommand =
|
||||
frame_helper::parseCommandLine(preamble);
|
||||
if (preambleCommand.name != "mpv-options") {
|
||||
emitLine(JsonWriter()
|
||||
.str("event", "fatal")
|
||||
.str("error", "expected the mpv-options preamble on stdin")
|
||||
.finish());
|
||||
return 1;
|
||||
}
|
||||
std::vector<std::pair<std::string, std::string>> entries(
|
||||
preambleCommand.args.begin(), preambleCommand.args.end());
|
||||
std::sort(entries.begin(), entries.end());
|
||||
for (const auto& entry : entries) {
|
||||
const std::string& option = entry.second;
|
||||
const size_t separator = option.find('=');
|
||||
if (separator == std::string::npos || separator == 0 ||
|
||||
separator + 1 >= option.size()) {
|
||||
continue;
|
||||
}
|
||||
const std::string key = option.substr(0, separator);
|
||||
const std::string value = option.substr(separator + 1);
|
||||
const int optionResult = mpv_set_option_string(
|
||||
g_state.mpv, key.c_str(), value.c_str());
|
||||
if (optionResult < 0) {
|
||||
emitLine(JsonWriter()
|
||||
.str("event", "log")
|
||||
.str("level", "warn")
|
||||
.str("prefix", "iptvnator")
|
||||
.str("text", "rejected session option " + key +
|
||||
": " +
|
||||
mpv_error_string(optionResult))
|
||||
.finish());
|
||||
}
|
||||
}
|
||||
}
|
||||
if (mpv_initialize(g_state.mpv) < 0) {
|
||||
emitLine(JsonWriter()
|
||||
.str("event", "fatal")
|
||||
|
||||
@@ -13,6 +13,8 @@
|
||||
#include <mpv/render.h>
|
||||
#include <mpv/render_gl.h>
|
||||
|
||||
#include "embedded_mpv_extra_options.h"
|
||||
|
||||
#include <algorithm>
|
||||
#include <atomic>
|
||||
#include <chrono>
|
||||
@@ -96,6 +98,12 @@ struct SessionSnapshot {
|
||||
double volumePercent = 100.0;
|
||||
std::string streamUrl;
|
||||
std::string error;
|
||||
// True when `error` is the engine's own failure (render context, fatal
|
||||
// libmpv log) rather than the stream's: the app must not reload for it.
|
||||
bool engineError = false;
|
||||
// Keys of session options libmpv refused at creation (names only, never
|
||||
// values: a value may carry credentials). Reported once by the service.
|
||||
std::vector<std::string> rejectedOptionKeys;
|
||||
std::vector<AudioTrack> audioTracks;
|
||||
int64_t selectedAudioTrackId = -1;
|
||||
std::vector<AudioTrack> subtitleTracks;
|
||||
@@ -203,7 +211,11 @@ std::shared_ptr<Session> getSessionOrThrow(
|
||||
|
||||
void scheduleRender(const std::shared_ptr<Session>& session);
|
||||
void requestRender(const std::shared_ptr<Session>& session);
|
||||
void updateSessionError(const std::shared_ptr<Session>& session, const std::string& error);
|
||||
void updateSessionError(
|
||||
const std::shared_ptr<Session>& session,
|
||||
const std::string& error,
|
||||
bool engineError = false
|
||||
);
|
||||
|
||||
bool isEmbeddedMpvTraceEnabled()
|
||||
{
|
||||
@@ -550,7 +562,8 @@ void renderSoftwareFrame(const std::shared_ptr<Session>& session)
|
||||
if (result < 0) {
|
||||
updateSessionError(
|
||||
session,
|
||||
std::string("Failed to render frame: ") + mpv_error_string(result)
|
||||
std::string("Failed to render frame: ") + mpv_error_string(result),
|
||||
true
|
||||
);
|
||||
return;
|
||||
}
|
||||
@@ -670,7 +683,8 @@ void renderOpenGLFrame(const std::shared_ptr<Session>& session)
|
||||
updateSessionError(
|
||||
session,
|
||||
std::string("Failed to render OpenGL frame: ") +
|
||||
mpv_error_string(result)
|
||||
mpv_error_string(result),
|
||||
true
|
||||
);
|
||||
return;
|
||||
}
|
||||
@@ -1035,11 +1049,16 @@ void updateSubtitleTracksFromNode(SessionSnapshot& snapshot, const mpv_node& nod
|
||||
);
|
||||
}
|
||||
|
||||
void updateSessionError(const std::shared_ptr<Session>& session, const std::string& error)
|
||||
void updateSessionError(
|
||||
const std::shared_ptr<Session>& session,
|
||||
const std::string& error,
|
||||
bool engineError
|
||||
)
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(session->mutex);
|
||||
session->snapshot.status = SessionStatus::Error;
|
||||
session->snapshot.error = error;
|
||||
session->snapshot.engineError = engineError;
|
||||
}
|
||||
|
||||
uint64_t nextAsyncRequestId()
|
||||
@@ -1154,6 +1173,7 @@ void runEventLoop(const std::shared_ptr<Session>& session)
|
||||
case MPV_EVENT_START_FILE:
|
||||
session->snapshot.status = SessionStatus::Loading;
|
||||
session->snapshot.error.clear();
|
||||
session->snapshot.engineError = false;
|
||||
session->snapshot.audioTracks.clear();
|
||||
session->snapshot.selectedAudioTrackId = -1;
|
||||
session->snapshot.subtitleTracks.clear();
|
||||
@@ -1186,6 +1206,7 @@ void runEventLoop(const std::shared_ptr<Session>& session)
|
||||
session->running.load()) {
|
||||
session->snapshot.status = SessionStatus::Loading;
|
||||
session->snapshot.error.clear();
|
||||
session->snapshot.engineError = false;
|
||||
session->loadedPath = false;
|
||||
} else if (session->running.load()) {
|
||||
session->snapshot.status = SessionStatus::Idle;
|
||||
@@ -1353,6 +1374,7 @@ void runEventLoop(const std::shared_ptr<Session>& session)
|
||||
}
|
||||
if (level == "fatal") {
|
||||
session->snapshot.status = SessionStatus::Error;
|
||||
session->snapshot.engineError = true;
|
||||
}
|
||||
break;
|
||||
}
|
||||
@@ -1657,6 +1679,25 @@ Napi::Value CreateSession(const Napi::CallbackInfo& info)
|
||||
std::to_string(session->snapshot.volumePercent);
|
||||
mpv_set_option_string(session->handle, "volume", initialVolume.c_str());
|
||||
|
||||
// Session options from Settings (network defaults first, then the
|
||||
// user's lines). Applied last so a user value overrides a built-in one,
|
||||
// and before mpv_initialize because most of them are init-only.
|
||||
for (const auto& option : iptvnator::readEmbeddedMpvExtraOptions(info, 4)) {
|
||||
const int optionResult = mpv_set_option_string(
|
||||
session->handle,
|
||||
option.first.c_str(),
|
||||
option.second.c_str()
|
||||
);
|
||||
if (optionResult < 0) {
|
||||
traceEmbeddedMpv(
|
||||
session,
|
||||
"rejected session option " + option.first + ": " +
|
||||
mpv_error_string(optionResult)
|
||||
);
|
||||
session->snapshot.rejectedOptionKeys.push_back(option.first);
|
||||
}
|
||||
}
|
||||
|
||||
mpv_request_log_messages(session->handle, "warn");
|
||||
|
||||
const int initializeResult = mpv_initialize(session->handle);
|
||||
@@ -1920,6 +1961,7 @@ Napi::Value LoadPlayback(const Napi::CallbackInfo& info)
|
||||
std::lock_guard<std::mutex> lock(session->mutex);
|
||||
session->snapshot.streamUrl = streamUrl;
|
||||
session->snapshot.error.clear();
|
||||
session->snapshot.engineError = false;
|
||||
session->snapshot.status = SessionStatus::Loading;
|
||||
session->snapshot.recordingActive = false;
|
||||
session->snapshot.recordingTargetPath.clear();
|
||||
@@ -2000,9 +2042,13 @@ Napi::Value SetPaused(const Napi::CallbackInfo& info)
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(session->mutex);
|
||||
session->paused = paused != 0;
|
||||
// Optimistic only while playing/paused: a load in flight keeps
|
||||
// `loading` until MPV_EVENT_FILE_LOADED, otherwise a reconnect's
|
||||
// unpause right after loadfile would report the replacement stream
|
||||
// as playing before it opened (and clear the attempt indicator).
|
||||
if (session->loadedPath &&
|
||||
session->snapshot.status != SessionStatus::Ended &&
|
||||
session->snapshot.status != SessionStatus::Error) {
|
||||
(session->snapshot.status == SessionStatus::Playing ||
|
||||
session->snapshot.status == SessionStatus::Paused)) {
|
||||
session->snapshot.status = session->paused
|
||||
? SessionStatus::Paused
|
||||
: SessionStatus::Playing;
|
||||
@@ -2547,6 +2593,20 @@ Napi::Value GetSessionSnapshot(const Napi::CallbackInfo& info)
|
||||
|
||||
if (!snapshot.error.empty()) {
|
||||
result.Set("error", Napi::String::New(env, snapshot.error));
|
||||
if (snapshot.engineError) {
|
||||
result.Set("errorOrigin", Napi::String::New(env, "engine"));
|
||||
}
|
||||
}
|
||||
if (!snapshot.rejectedOptionKeys.empty()) {
|
||||
Napi::Array keys =
|
||||
Napi::Array::New(env, snapshot.rejectedOptionKeys.size());
|
||||
for (size_t i = 0; i < snapshot.rejectedOptionKeys.size(); ++i) {
|
||||
keys.Set(
|
||||
static_cast<uint32_t>(i),
|
||||
Napi::String::New(env, snapshot.rejectedOptionKeys[i])
|
||||
);
|
||||
}
|
||||
result.Set("rejectedOptionKeys", keys);
|
||||
}
|
||||
|
||||
return result;
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
#pragma once
|
||||
|
||||
#include <napi.h>
|
||||
|
||||
#include <cstddef>
|
||||
#include <cstdint>
|
||||
#include <string>
|
||||
#include <utility>
|
||||
#include <vector>
|
||||
|
||||
namespace iptvnator {
|
||||
|
||||
/** One libmpv option name/value pair handed to a session at creation. */
|
||||
using EmbeddedMpvExtraOption = std::pair<std::string, std::string>;
|
||||
|
||||
/**
|
||||
* Reads the optional `key=value` string array `createSession()` receives at
|
||||
* `index`. The Electron side already validated and ordered the lines
|
||||
* (network defaults first, then the user's options minus the keys the embed
|
||||
* depends on — see `resolveEmbeddedMpvSessionOptionArguments`), so this only
|
||||
* splits on the first `=` and skips anything that is not a non-empty pair.
|
||||
* No libmpv call lives here: each engine applies the pairs itself, because
|
||||
* on Linux the libmpv symbols are macros over dynamically resolved pointers.
|
||||
*/
|
||||
inline std::vector<EmbeddedMpvExtraOption> readEmbeddedMpvExtraOptions(
|
||||
const Napi::CallbackInfo& info,
|
||||
size_t index
|
||||
)
|
||||
{
|
||||
std::vector<EmbeddedMpvExtraOption> options;
|
||||
if (info.Length() <= index || !info[index].IsArray()) {
|
||||
return options;
|
||||
}
|
||||
|
||||
const auto entries = info[index].As<Napi::Array>();
|
||||
for (uint32_t i = 0; i < entries.Length(); ++i) {
|
||||
const Napi::Value entry = entries.Get(i);
|
||||
if (!entry.IsString()) {
|
||||
continue;
|
||||
}
|
||||
const std::string line = entry.As<Napi::String>().Utf8Value();
|
||||
const auto separator = line.find('=');
|
||||
if (
|
||||
separator == std::string::npos ||
|
||||
separator == 0 ||
|
||||
separator + 1 >= line.size()
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
options.emplace_back(
|
||||
line.substr(0, separator),
|
||||
line.substr(separator + 1)
|
||||
);
|
||||
}
|
||||
return options;
|
||||
}
|
||||
|
||||
} // namespace iptvnator
|
||||
@@ -5,6 +5,9 @@
|
||||
#include <napi.h>
|
||||
|
||||
#include <mpv/client.h>
|
||||
#include <string>
|
||||
|
||||
#include "embedded_mpv_extra_options.h"
|
||||
|
||||
#ifdef IPTVNATOR_DYNAMIC_LIBMPV
|
||||
#ifndef IPTVNATOR_MPV_SELECTANY
|
||||
@@ -116,6 +119,8 @@ struct SessionSnapshot {
|
||||
double volumePercent = 100.0;
|
||||
std::string streamUrl;
|
||||
std::string error;
|
||||
/** Keys of session options libmpv refused at creation (names only). */
|
||||
std::vector<std::string> rejectedOptionKeys;
|
||||
std::vector<AudioTrack> audioTracks;
|
||||
int64_t selectedAudioTrackId = -1;
|
||||
std::vector<AudioTrack> subtitleTracks;
|
||||
@@ -142,6 +147,8 @@ struct Session {
|
||||
std::string pendingRecordingStartedAt;
|
||||
std::string pendingRecordingStopStartedAt;
|
||||
uint64_t pendingPlaybackLoadRequestId = 0;
|
||||
/** Session options from Settings (network defaults + user lines). */
|
||||
std::vector<iptvnator::EmbeddedMpvExtraOption> extraOptions;
|
||||
#ifdef __linux__
|
||||
pid_t mpvProcessId = -1;
|
||||
std::string mpvIpcSocketPath;
|
||||
@@ -607,6 +614,12 @@ std::vector<std::string> buildLinuxMpvArguments(
|
||||
arguments.push_back("--really-quiet");
|
||||
}
|
||||
|
||||
// Session options come before the per-playback ones below, so a
|
||||
// playlist's own user-agent/headers still win over a global line.
|
||||
for (const auto& option : session->extraOptions) {
|
||||
arguments.push_back("--" + option.first + "=" + option.second);
|
||||
}
|
||||
|
||||
appendLinuxMpvOption(arguments, "force-media-title", title);
|
||||
appendLinuxMpvOption(arguments, "user-agent", userAgent);
|
||||
appendLinuxMpvOption(arguments, "referrer", referer);
|
||||
@@ -983,6 +996,7 @@ void refreshLinuxMpvSnapshot(const std::shared_ptr<Session>& session)
|
||||
const auto duration = queryLinuxMpvNumber(socketPath, "duration");
|
||||
const auto volume = queryLinuxMpvNumber(socketPath, "volume");
|
||||
const auto paused = queryLinuxMpvBoolean(socketPath, "pause");
|
||||
const auto eofReached = queryLinuxMpvBoolean(socketPath, "eof-reached");
|
||||
const auto path = queryLinuxMpvString(socketPath, "path");
|
||||
const auto trackCount =
|
||||
queryLinuxMpvInteger(socketPath, "track-list/count");
|
||||
@@ -1018,10 +1032,24 @@ void refreshLinuxMpvSnapshot(const std::shared_ptr<Session>& session)
|
||||
std::max(0.0, std::min(100.0, *volume));
|
||||
}
|
||||
if (paused) {
|
||||
session->snapshot.status =
|
||||
*paused ? SessionStatus::Paused : SessionStatus::Playing;
|
||||
if (*paused) {
|
||||
session->snapshot.status = SessionStatus::Paused;
|
||||
} else if (
|
||||
position ||
|
||||
session->snapshot.status != SessionStatus::Loading
|
||||
) {
|
||||
// `time-pos` only answers once the media is decoding; until then
|
||||
// an unpaused freshly spawned process is still loading.
|
||||
session->snapshot.status = SessionStatus::Playing;
|
||||
}
|
||||
session->snapshot.error.clear();
|
||||
}
|
||||
if (eofReached && *eofReached) {
|
||||
// keep-open=yes parks mpv paused at the end of the stream. Report
|
||||
// that as `ended` like the in-process engines do, so the reconnect
|
||||
// policy can tell a finished live stream from a user pause.
|
||||
session->snapshot.status = SessionStatus::Ended;
|
||||
}
|
||||
if (path && !path->empty()) {
|
||||
session->snapshot.streamUrl = *path;
|
||||
}
|
||||
@@ -1149,7 +1177,10 @@ void loadLinuxProcessPlayback(
|
||||
session->snapshot.durationSeconds = -1.0;
|
||||
session->snapshot.streamUrl = streamUrl;
|
||||
session->snapshot.error.clear();
|
||||
session->snapshot.status = SessionStatus::Playing;
|
||||
// A spawned process has not opened the URL yet: stay `loading` until
|
||||
// the poller sees a decoded position, so a URL that never plays is
|
||||
// never mistaken for one that did (the reconnect policy keys on it).
|
||||
session->snapshot.status = SessionStatus::Loading;
|
||||
session->snapshot.audioTracks.clear();
|
||||
session->snapshot.selectedAudioTrackId = -1;
|
||||
session->snapshot.subtitleTracks.clear();
|
||||
@@ -1654,6 +1685,7 @@ Napi::Value CreateSession(const Napi::CallbackInfo& info)
|
||||
throw Napi::Error::New(env, NativeVideoHost::lastError());
|
||||
}
|
||||
traceMpvCommon("native video host created");
|
||||
session->extraOptions = iptvnator::readEmbeddedMpvExtraOptions(info, 4);
|
||||
|
||||
#ifdef __linux__
|
||||
session->host.setBounds(bounds);
|
||||
@@ -1705,6 +1737,24 @@ Napi::Value CreateSession(const Napi::CallbackInfo& info)
|
||||
session->snapshot.volumePercent
|
||||
);
|
||||
mpv_set_option_string(session->handle, "volume", initialVolume.c_str());
|
||||
|
||||
// Session options from Settings (network defaults first, then the
|
||||
// user's lines). Applied last so a user value overrides a built-in one,
|
||||
// and before mpv_initialize because most of them are init-only.
|
||||
for (const auto& option : session->extraOptions) {
|
||||
const int optionResult = mpv_set_option_string(
|
||||
session->handle,
|
||||
option.first.c_str(),
|
||||
option.second.c_str()
|
||||
);
|
||||
if (optionResult < 0) {
|
||||
traceMpvCommon(
|
||||
"rejected session option " + option.first + ": " +
|
||||
mpv_error_string(optionResult)
|
||||
);
|
||||
session->snapshot.rejectedOptionKeys.push_back(option.first);
|
||||
}
|
||||
}
|
||||
mpv_request_log_messages(session->handle, "warn");
|
||||
|
||||
traceMpvCommon("initializing libmpv");
|
||||
@@ -1936,8 +1986,16 @@ Napi::Value SetPaused(const Napi::CallbackInfo& info)
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(session->mutex);
|
||||
socketPath = session->mpvIpcSocketPath;
|
||||
session->snapshot.status =
|
||||
paused ? SessionStatus::Paused : SessionStatus::Playing;
|
||||
// Optimistic only while playing/paused: a load in flight or a
|
||||
// finished/failed stream keeps its status until the poller says
|
||||
// otherwise, like the in-process engines' pause handling.
|
||||
if (
|
||||
session->snapshot.status == SessionStatus::Playing ||
|
||||
session->snapshot.status == SessionStatus::Paused
|
||||
) {
|
||||
session->snapshot.status =
|
||||
paused ? SessionStatus::Paused : SessionStatus::Playing;
|
||||
}
|
||||
}
|
||||
if (!socketPath.empty()) {
|
||||
sendLinuxMpvCommand(
|
||||
@@ -2376,6 +2434,17 @@ Napi::Value GetSessionSnapshot(const Napi::CallbackInfo& info)
|
||||
if (!snapshot.error.empty()) {
|
||||
result.Set("error", Napi::String::New(env, snapshot.error));
|
||||
}
|
||||
if (!snapshot.rejectedOptionKeys.empty()) {
|
||||
Napi::Array keys =
|
||||
Napi::Array::New(env, snapshot.rejectedOptionKeys.size());
|
||||
for (size_t i = 0; i < snapshot.rejectedOptionKeys.size(); ++i) {
|
||||
keys.Set(
|
||||
static_cast<uint32_t>(i),
|
||||
Napi::String::New(env, snapshot.rejectedOptionKeys[i])
|
||||
);
|
||||
}
|
||||
result.Set("rejectedOptionKeys", keys);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
|
||||
@@ -80,6 +80,7 @@ const DB_OPERATION_EVENT = 'DB_OPERATION_EVENT';
|
||||
const PLAYLIST_REFRESH_EVENT = 'PLAYLIST:REFRESH_EVENT';
|
||||
const WINDOW_MINIMIZE = 'WINDOW:MINIMIZE';
|
||||
const WINDOW_TOGGLE_MAXIMIZE = 'WINDOW:TOGGLE_MAXIMIZE';
|
||||
const WINDOW_TOGGLE_FULLSCREEN = 'WINDOW:TOGGLE_FULLSCREEN';
|
||||
const WINDOW_CLOSE = 'WINDOW:CLOSE';
|
||||
const WINDOW_GET_STATE = 'WINDOW:GET_STATE';
|
||||
const WINDOW_STATE_CHANGED = 'WINDOW:STATE_CHANGED';
|
||||
@@ -414,6 +415,7 @@ const electronApi: ElectronBridgeApi = {
|
||||
},
|
||||
minimizeWindow: () => ipcRenderer.invoke(WINDOW_MINIMIZE),
|
||||
toggleMaximizeWindow: () => ipcRenderer.invoke(WINDOW_TOGGLE_MAXIMIZE),
|
||||
toggleFullScreenWindow: () => ipcRenderer.invoke(WINDOW_TOGGLE_FULLSCREEN),
|
||||
closeWindow: () => ipcRenderer.invoke(WINDOW_CLOSE),
|
||||
getWindowState: () => ipcRenderer.invoke(WINDOW_GET_STATE),
|
||||
onWindowStateChange: (
|
||||
|
||||
@@ -201,6 +201,69 @@ describe('window state change pushes', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps isFullScreen=true when the player leaves HTML fullscreen inside a natively fullscreen window', () => {
|
||||
const win = createMockStateWindow();
|
||||
// A fullscreen launch (or F11 before attach) — the window is
|
||||
// natively fullscreen when the events are wired up.
|
||||
win.isFullScreen.mockReturnValue(true);
|
||||
attachWindowStateEvents(win);
|
||||
|
||||
// Electron sees the window is already fullscreen, so entering HTML
|
||||
// fullscreen only emits the html variant …
|
||||
fireWindowEvent(win, 'enter-html-full-screen');
|
||||
expect(win.webContents.send).toHaveBeenLastCalledWith(
|
||||
WINDOW_STATE_CHANGED,
|
||||
{ isMaximized: false, isFullScreen: true }
|
||||
);
|
||||
|
||||
// … and leaving it restores ONLY the HTML state: no
|
||||
// 'leave-full-screen' fires and the window stays fullscreen. A
|
||||
// single flag would un-hide the window controls here.
|
||||
fireWindowEvent(win, 'leave-html-full-screen');
|
||||
expect(win.webContents.send).toHaveBeenLastCalledWith(
|
||||
WINDOW_STATE_CHANGED,
|
||||
{ isMaximized: false, isFullScreen: true }
|
||||
);
|
||||
|
||||
// F11 afterwards is what actually leaves fullscreen.
|
||||
fireWindowEvent(win, 'leave-full-screen');
|
||||
expect(win.webContents.send).toHaveBeenLastCalledWith(
|
||||
WINDOW_STATE_CHANGED,
|
||||
{ isMaximized: false, isFullScreen: false }
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps a native fullscreen entered by event across a player HTML fullscreen round-trip', () => {
|
||||
const win = createMockStateWindow();
|
||||
attachWindowStateEvents(win);
|
||||
|
||||
fireWindowEvent(win, 'enter-full-screen');
|
||||
fireWindowEvent(win, 'enter-html-full-screen');
|
||||
fireWindowEvent(win, 'leave-html-full-screen');
|
||||
|
||||
expect(win.webContents.send).toHaveBeenLastCalledWith(
|
||||
WINDOW_STATE_CHANGED,
|
||||
{ isMaximized: false, isFullScreen: true }
|
||||
);
|
||||
});
|
||||
|
||||
it('clears isFullScreen when both the native and the HTML state are left', () => {
|
||||
const win = createMockStateWindow();
|
||||
attachWindowStateEvents(win);
|
||||
|
||||
// Windows/Linux HTML fullscreen without prior native fullscreen:
|
||||
// Electron toggles the native window too, so both pairs fire.
|
||||
fireWindowEvent(win, 'enter-full-screen');
|
||||
fireWindowEvent(win, 'enter-html-full-screen');
|
||||
fireWindowEvent(win, 'leave-full-screen');
|
||||
fireWindowEvent(win, 'leave-html-full-screen');
|
||||
|
||||
expect(win.webContents.send).toHaveBeenLastCalledWith(
|
||||
WINDOW_STATE_CHANGED,
|
||||
{ isMaximized: false, isFullScreen: false }
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps the fullscreen flag when a maximize event arrives mid-transition', () => {
|
||||
const win = createMockStateWindow();
|
||||
win.isFullScreen.mockReturnValue(true);
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
const mockClearStorageData = jest.fn();
|
||||
const mockIsFrameCopyRuntimeUsable = jest.fn<boolean, []>();
|
||||
const mockIsEmbeddedMpvFeatureEnabled = jest.fn<boolean, []>();
|
||||
const mockHasSwitch = jest.fn<boolean, [string]>();
|
||||
|
||||
jest.mock('electron', () => ({
|
||||
app: {
|
||||
commandLine: {
|
||||
hasSwitch: mockHasSwitch,
|
||||
},
|
||||
getPath: jest.fn(() => '/tmp'),
|
||||
isPackaged: false,
|
||||
isReady: jest.fn(() => false),
|
||||
@@ -31,6 +35,7 @@ jest.mock('./services/store.service', () => ({
|
||||
get: jest.fn(),
|
||||
set: jest.fn(),
|
||||
},
|
||||
STARTUP_WINDOW_MODE: 'startupWindowMode',
|
||||
WINDOW_BOUNDS: 'windowBounds',
|
||||
}));
|
||||
|
||||
@@ -53,6 +58,7 @@ import { app as electronApp, BrowserWindow, screen } from 'electron';
|
||||
import * as path from 'path';
|
||||
import { pathToFileURL } from 'url';
|
||||
import { store } from './services/store.service';
|
||||
import { getPendingFullScreenTarget } from './services/native-fullscreen-transitions';
|
||||
|
||||
type MockMainWindow = {
|
||||
center: jest.Mock<void, []>;
|
||||
@@ -64,8 +70,10 @@ type MockMainWindow = {
|
||||
isMaximized: jest.Mock<boolean, []>;
|
||||
loadFile: jest.Mock<Promise<void>, [string]>;
|
||||
loadURL: jest.Mock<Promise<void>, [string]>;
|
||||
maximize: jest.Mock<void, []>;
|
||||
on: jest.Mock<void, [string, (...args: unknown[]) => void]>;
|
||||
once: jest.Mock<void, [string, (...args: unknown[]) => void]>;
|
||||
setFullScreen: jest.Mock<void, [boolean]>;
|
||||
setMenu: jest.Mock<void, [unknown]>;
|
||||
show: jest.Mock<void, []>;
|
||||
webContents: {
|
||||
@@ -83,9 +91,11 @@ function createMockMainWindow(): MockMainWindow {
|
||||
isMaximized: jest.fn<boolean, []>().mockReturnValue(false),
|
||||
loadFile: jest.fn<Promise<void>, [string]>().mockResolvedValue(),
|
||||
loadURL: jest.fn<Promise<void>, [string]>().mockResolvedValue(),
|
||||
maximize: jest.fn<void, []>(),
|
||||
on: jest.fn<void, [string, (...args: unknown[]) => void]>(),
|
||||
once: jest.fn<void, [string, (...args: unknown[]) => void]>(),
|
||||
isDestroyed: jest.fn<boolean, []>().mockReturnValue(false),
|
||||
setFullScreen: jest.fn<void, [boolean]>(),
|
||||
setMenu: jest.fn<void, [unknown]>(),
|
||||
show: jest.fn<void, []>(),
|
||||
webContents: {
|
||||
@@ -97,6 +107,7 @@ function createMockMainWindow(): MockMainWindow {
|
||||
}
|
||||
|
||||
type AppInternals = {
|
||||
launchFullscreenSwitchConsumed: boolean;
|
||||
loadedMainWindow: MockMainWindow | null;
|
||||
mainWindow: MockMainWindow | null;
|
||||
mainWindowListeners: Array<(mainWindow: MockMainWindow) => void>;
|
||||
@@ -119,6 +130,7 @@ describe('Electron app security helpers', () => {
|
||||
mockIsEmbeddedMpvFeatureEnabled.mockReturnValue(false);
|
||||
mockIsFrameCopyRuntimeUsable.mockReturnValue(false);
|
||||
const appInternals = getAppInternals();
|
||||
appInternals.launchFullscreenSwitchConsumed = false;
|
||||
appInternals.loadedMainWindow = null;
|
||||
appInternals.mainWindow = null;
|
||||
appInternals.mainWindowLoadPromise = null;
|
||||
@@ -129,6 +141,7 @@ describe('Electron app security helpers', () => {
|
||||
workAreaSize: { height: 720, width: 1280 },
|
||||
});
|
||||
(store.get as jest.Mock).mockReturnValue(undefined);
|
||||
mockHasSwitch.mockReturnValue(false);
|
||||
});
|
||||
|
||||
it('creates an explicitly hardened BrowserWindow webPreferences object', () => {
|
||||
@@ -310,6 +323,170 @@ describe('Electron app security helpers', () => {
|
||||
);
|
||||
});
|
||||
|
||||
describe('startup window mode', () => {
|
||||
function createWindowViaOnReady(): MockMainWindow {
|
||||
const mainWindow = createMockMainWindow();
|
||||
(BrowserWindow as unknown as jest.Mock).mockReturnValue(mainWindow);
|
||||
getAppInternals().onReady();
|
||||
return mainWindow;
|
||||
}
|
||||
|
||||
function fireReadyToShow(mainWindow: MockMainWindow): void {
|
||||
const handlers = mainWindow.once.mock.calls
|
||||
.filter(([eventName]) => eventName === 'ready-to-show')
|
||||
.map(([, handler]) => handler);
|
||||
|
||||
expect(handlers).toHaveLength(1);
|
||||
handlers[0]();
|
||||
}
|
||||
|
||||
function storeStartupWindowMode(mode: string): void {
|
||||
(store.get as jest.Mock).mockImplementation((key: string) =>
|
||||
key === 'startupWindowMode' ? mode : undefined
|
||||
);
|
||||
}
|
||||
|
||||
it('creates a plain window when nothing is stored', () => {
|
||||
const mainWindow = createWindowViaOnReady();
|
||||
|
||||
expect(BrowserWindow).toHaveBeenCalledWith(
|
||||
expect.not.objectContaining({ fullscreen: true })
|
||||
);
|
||||
|
||||
fireReadyToShow(mainWindow);
|
||||
|
||||
expect(mainWindow.maximize).not.toHaveBeenCalled();
|
||||
expect(mainWindow.setFullScreen).not.toHaveBeenCalled();
|
||||
expect(mainWindow.show).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('creates the window fullscreen when the stored mode says so', () => {
|
||||
storeStartupWindowMode('fullscreen');
|
||||
|
||||
const mainWindow = createWindowViaOnReady();
|
||||
// Windows/Linux honour the constructor option while the window
|
||||
// is still hidden.
|
||||
mainWindow.isFullScreen.mockReturnValue(true);
|
||||
|
||||
// Still created hidden: fullscreen is entered before the first
|
||||
// paint, and ready-to-show reveals it like any other launch.
|
||||
expect(BrowserWindow).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ fullscreen: true, show: false })
|
||||
);
|
||||
fireReadyToShow(mainWindow);
|
||||
expect(mainWindow.maximize).not.toHaveBeenCalled();
|
||||
expect(mainWindow.show).toHaveBeenCalledTimes(1);
|
||||
// Already fullscreen: no second request, which would animate a
|
||||
// toggle on top of the one that took.
|
||||
expect(mainWindow.setFullScreen).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('repeats the fullscreen request after show() where the constructor option did not take', () => {
|
||||
storeStartupWindowMode('fullscreen');
|
||||
|
||||
const mainWindow = createWindowViaOnReady();
|
||||
// macOS: an NSWindow only toggles fullscreen once it is on
|
||||
// screen, so a hidden window ignores the constructor option.
|
||||
mainWindow.isFullScreen.mockReturnValue(false);
|
||||
|
||||
fireReadyToShow(mainWindow);
|
||||
|
||||
expect(mainWindow.setFullScreen).toHaveBeenCalledTimes(1);
|
||||
expect(mainWindow.setFullScreen).toHaveBeenCalledWith(true);
|
||||
// Registered with the transition tracker, so an F11 pressed
|
||||
// during the animation is decided against this target and
|
||||
// leaves fullscreen instead of requesting it again.
|
||||
expect(
|
||||
getPendingFullScreenTarget(
|
||||
mainWindow as unknown as Electron.BrowserWindow
|
||||
)
|
||||
).toBe(true);
|
||||
expect(mainWindow.show.mock.invocationCallOrder[0]).toBeLessThan(
|
||||
mainWindow.setFullScreen.mock.invocationCallOrder[0]
|
||||
);
|
||||
});
|
||||
|
||||
it('maximizes only once the window is ready to show, before revealing it', () => {
|
||||
storeStartupWindowMode('maximized');
|
||||
|
||||
const mainWindow = createWindowViaOnReady();
|
||||
|
||||
expect(BrowserWindow).toHaveBeenCalledWith(
|
||||
expect.not.objectContaining({ fullscreen: true })
|
||||
);
|
||||
// maximize() on a hidden window shows it — a blank flash if it
|
||||
// ran here.
|
||||
expect(mainWindow.maximize).not.toHaveBeenCalled();
|
||||
|
||||
fireReadyToShow(mainWindow);
|
||||
|
||||
expect(mainWindow.maximize).toHaveBeenCalledTimes(1);
|
||||
expect(mainWindow.show).toHaveBeenCalledTimes(1);
|
||||
expect(
|
||||
mainWindow.maximize.mock.invocationCallOrder[0]
|
||||
).toBeLessThan(mainWindow.show.mock.invocationCallOrder[0]);
|
||||
});
|
||||
|
||||
it('lets --fullscreen override the stored mode for this launch without persisting it', () => {
|
||||
storeStartupWindowMode('maximized');
|
||||
mockHasSwitch.mockImplementation((name) => name === 'fullscreen');
|
||||
|
||||
const mainWindow = createWindowViaOnReady();
|
||||
|
||||
expect(BrowserWindow).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ fullscreen: true })
|
||||
);
|
||||
fireReadyToShow(mainWindow);
|
||||
expect(mainWindow.maximize).not.toHaveBeenCalled();
|
||||
expect(store.set).not.toHaveBeenCalledWith(
|
||||
'startupWindowMode',
|
||||
expect.anything()
|
||||
);
|
||||
});
|
||||
|
||||
it('consumes --fullscreen with the first window so a window re-created from the macOS Dock follows the stored setting', () => {
|
||||
storeStartupWindowMode('maximized');
|
||||
mockHasSwitch.mockImplementation((name) => name === 'fullscreen');
|
||||
|
||||
const firstWindow = createWindowViaOnReady();
|
||||
expect(BrowserWindow).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({ fullscreen: true })
|
||||
);
|
||||
fireReadyToShow(firstWindow);
|
||||
|
||||
// The user left fullscreen and closed the only window; the
|
||||
// process survived and the Dock brings the window back.
|
||||
const appInternals = getAppInternals();
|
||||
appInternals.mainWindow = null;
|
||||
const secondWindow = createMockMainWindow();
|
||||
(BrowserWindow as unknown as jest.Mock).mockReturnValue(
|
||||
secondWindow
|
||||
);
|
||||
|
||||
App.ensureMainWindow();
|
||||
|
||||
expect(BrowserWindow).toHaveBeenCalledTimes(2);
|
||||
expect(BrowserWindow).toHaveBeenLastCalledWith(
|
||||
expect.not.objectContaining({ fullscreen: true })
|
||||
);
|
||||
fireReadyToShow(secondWindow);
|
||||
expect(secondWindow.setFullScreen).not.toHaveBeenCalled();
|
||||
expect(secondWindow.maximize).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('treats a hand-edited unknown stored mode as a plain window', () => {
|
||||
storeStartupWindowMode('kiosk');
|
||||
|
||||
const mainWindow = createWindowViaOnReady();
|
||||
|
||||
expect(BrowserWindow).toHaveBeenCalledWith(
|
||||
expect.not.objectContaining({ fullscreen: true })
|
||||
);
|
||||
fireReadyToShow(mainWindow);
|
||||
expect(mainWindow.maximize).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it('creates the main window immediately when Electron is already ready', () => {
|
||||
const mainWindow = createMockMainWindow();
|
||||
(BrowserWindow as unknown as jest.Mock).mockReturnValue(mainWindow);
|
||||
|
||||
@@ -12,9 +12,21 @@ import {
|
||||
isWindowTraceEnabled,
|
||||
trace,
|
||||
} from './services/debug-trace';
|
||||
import { store, WINDOW_BOUNDS } from './services/store.service';
|
||||
import {
|
||||
STARTUP_WINDOW_MODE,
|
||||
store,
|
||||
WINDOW_BOUNDS,
|
||||
} from './services/store.service';
|
||||
import { isFrameCopyRuntimeUsable } from './services/embedded-mpv-frame-copy-platform.util';
|
||||
import { isEmbeddedMpvFeatureEnabled } from './services/embedded-mpv-runtime-policy.util';
|
||||
import {
|
||||
FULLSCREEN_LAUNCH_SWITCH,
|
||||
resolveStartupWindowMode,
|
||||
} from './services/startup-window-mode';
|
||||
import {
|
||||
requestFullScreen,
|
||||
trackNativeFullScreen,
|
||||
} from './services/native-fullscreen-transitions';
|
||||
|
||||
const externalBrowserProtocols = new Set(['http:', 'https:']);
|
||||
const trustedDevRendererHosts = new Set([
|
||||
@@ -223,6 +235,13 @@ export default class App {
|
||||
static BrowserWindow;
|
||||
private static loadedMainWindow: Electron.BrowserWindow | null = null;
|
||||
private static mainWindowLoadPromise: Promise<void> | null = null;
|
||||
/**
|
||||
* Whether the `--fullscreen` launch switch has already shaped a window.
|
||||
* See initMainWindow: the switch is consumed by the first window so a
|
||||
* window re-created later in the same process (macOS Dock) follows the
|
||||
* stored setting instead.
|
||||
*/
|
||||
private static launchFullscreenSwitchConsumed = false;
|
||||
private static rendererLoadingEnabled = false;
|
||||
|
||||
private static shouldOpenDevTools() {
|
||||
@@ -384,6 +403,16 @@ export default class App {
|
||||
isFullScreen: win.isFullScreen(),
|
||||
};
|
||||
|
||||
// Native (OS-level) and HTML-element fullscreen are tracked apart
|
||||
// and OR-ed into the pushed flag. Electron remembers when the window
|
||||
// was already natively fullscreen before the player entered HTML
|
||||
// fullscreen and then leaves ONLY the HTML state on exit — no
|
||||
// 'leave-full-screen' fires and the window stays fullscreen. A single
|
||||
// flag cleared by 'leave-html-full-screen' would un-hide the window
|
||||
// controls over a window that is still fullscreen, which a fullscreen
|
||||
// launch or F11 followed by the player's F → Esc makes routine.
|
||||
const fullscreen = { native: state.isFullScreen, html: false };
|
||||
|
||||
const push = (patch: Partial<ElectronBridgeWindowState>) => {
|
||||
Object.assign(state, patch);
|
||||
|
||||
@@ -395,16 +424,30 @@ export default class App {
|
||||
// mutations of the tracked state.
|
||||
win.webContents.send(WINDOW_STATE_CHANGED, { ...state });
|
||||
};
|
||||
const pushFullScreen = () =>
|
||||
push({ isFullScreen: fullscreen.native || fullscreen.html });
|
||||
|
||||
win.on('maximize', () => push({ isMaximized: true }));
|
||||
win.on('unmaximize', () => push({ isMaximized: false }));
|
||||
// The html variants cover HTML-element fullscreen; not every
|
||||
// platform/trigger emits both pairs, and duplicate pushes with the
|
||||
// same payload are harmless.
|
||||
win.on('enter-full-screen', () => push({ isFullScreen: true }));
|
||||
win.on('enter-html-full-screen', () => push({ isFullScreen: true }));
|
||||
win.on('leave-full-screen', () => push({ isFullScreen: false }));
|
||||
win.on('leave-html-full-screen', () => push({ isFullScreen: false }));
|
||||
win.on('enter-full-screen', () => {
|
||||
fullscreen.native = true;
|
||||
pushFullScreen();
|
||||
});
|
||||
win.on('leave-full-screen', () => {
|
||||
fullscreen.native = false;
|
||||
pushFullScreen();
|
||||
});
|
||||
win.on('enter-html-full-screen', () => {
|
||||
fullscreen.html = true;
|
||||
pushFullScreen();
|
||||
});
|
||||
win.on('leave-html-full-screen', () => {
|
||||
fullscreen.html = false;
|
||||
pushFullScreen();
|
||||
});
|
||||
}
|
||||
|
||||
private static initMainWindow() {
|
||||
@@ -413,6 +456,17 @@ export default class App {
|
||||
const height = Math.min(720, workAreaSize.height || 720);
|
||||
|
||||
const savedWindowBounds = store.get(WINDOW_BOUNDS);
|
||||
const startupWindowMode = resolveStartupWindowMode({
|
||||
// One-shot: the switch describes the launch, not every window
|
||||
// this process ever opens. On macOS the process outlives its
|
||||
// last window and the Dock re-creates it through this same
|
||||
// path, which must then follow the stored setting only.
|
||||
cliHasFullscreenSwitch:
|
||||
!App.launchFullscreenSwitchConsumed &&
|
||||
app.commandLine.hasSwitch(FULLSCREEN_LAUNCH_SWITCH),
|
||||
storedMode: store.get(STARTUP_WINDOW_MODE),
|
||||
});
|
||||
App.launchFullscreenSwitchConsumed = true;
|
||||
|
||||
// Create the browser window.
|
||||
App.mainWindow = new BrowserWindow({
|
||||
@@ -422,6 +476,12 @@ export default class App {
|
||||
show: false,
|
||||
webPreferences: getMainWindowWebPreferences(),
|
||||
...savedWindowBounds,
|
||||
// Fullscreen is a constructor option: the window is created
|
||||
// hidden and enters fullscreen before its first paint. The saved
|
||||
// bounds stay spread in above — they are the normal bounds the
|
||||
// window returns to, and the close handler keeps persisting
|
||||
// getNormalBounds(), so a fullscreen session never corrupts them.
|
||||
...(startupWindowMode === 'fullscreen' ? { fullscreen: true } : {}),
|
||||
minHeight: 600,
|
||||
minWidth: 900,
|
||||
...App.getPlatformTitleBarOptions(),
|
||||
@@ -429,6 +489,9 @@ export default class App {
|
||||
App.mainWindow.setMenu(null);
|
||||
attachWindowTrace(App.mainWindow);
|
||||
App.attachWindowStateEvents(App.mainWindow);
|
||||
// Seeds the F11 tracker's fullscreen state now, while no transition
|
||||
// can be in flight; from here on it follows the window's events.
|
||||
trackNativeFullScreen(App.mainWindow);
|
||||
App.notifyMainWindowCreated(App.mainWindow);
|
||||
if (!savedWindowBounds) {
|
||||
App.mainWindow.center();
|
||||
@@ -436,7 +499,27 @@ export default class App {
|
||||
|
||||
// if main window is ready to show, close the splash window and show the main window
|
||||
App.mainWindow.once('ready-to-show', () => {
|
||||
// maximize() on a hidden window shows it (Electron docs), so it
|
||||
// has to wait for ready-to-show like show() does — any earlier
|
||||
// and a blank window flashes before the renderer paints.
|
||||
if (startupWindowMode === 'maximized') {
|
||||
App.mainWindow.maximize();
|
||||
}
|
||||
App.mainWindow.show();
|
||||
// macOS ignores the constructor's `fullscreen` while the window
|
||||
// is hidden — an NSWindow can only toggle fullscreen once it is
|
||||
// on screen — so the request is repeated after show() wherever
|
||||
// it has not taken yet. Windows/Linux honoured it at creation
|
||||
// (before the first paint) and are left alone. It goes through
|
||||
// the transition tracker so an F11 pressed during the animation
|
||||
// reads the pending target and leaves fullscreen instead of
|
||||
// asking for it again.
|
||||
if (
|
||||
startupWindowMode === 'fullscreen' &&
|
||||
!App.mainWindow.isFullScreen()
|
||||
) {
|
||||
requestFullScreen(App.mainWindow, true);
|
||||
}
|
||||
});
|
||||
|
||||
// Route target="_blank" / window.open() to the OS default browser
|
||||
|
||||
@@ -5,17 +5,26 @@ jest.mock('electron', () => ({
|
||||
}));
|
||||
|
||||
const mockEmbeddedMpvService = {
|
||||
createSession: jest.fn(),
|
||||
getSupport: jest.fn(),
|
||||
setPaused: jest.fn(),
|
||||
};
|
||||
const mockSessionOptions = {
|
||||
extraOptions: ['network-timeout=10', 'hwdec=no'],
|
||||
autoReconnect: false,
|
||||
};
|
||||
|
||||
jest.mock('../services/embedded-mpv-native.service', () => ({
|
||||
EmbeddedMpvNativeService: class {},
|
||||
embeddedMpvNativeService: mockEmbeddedMpvService,
|
||||
}));
|
||||
jest.mock('../services/embedded-mpv-session-options', () => ({
|
||||
readEmbeddedMpvSessionOptions: () => mockSessionOptions,
|
||||
}));
|
||||
|
||||
import { ipcMain } from 'electron';
|
||||
import {
|
||||
EMBEDDED_MPV_CREATE_SESSION,
|
||||
EMBEDDED_MPV_SET_PAUSED,
|
||||
EMBEDDED_MPV_SUPPORT,
|
||||
} from '@iptvnator/shared/interfaces';
|
||||
@@ -41,10 +50,29 @@ function getIpcMainHandler(
|
||||
|
||||
describe('EmbeddedMpvEvents IPC handlers', () => {
|
||||
beforeEach(() => {
|
||||
mockEmbeddedMpvService.createSession.mockReset();
|
||||
mockEmbeddedMpvService.getSupport.mockReset();
|
||||
mockEmbeddedMpvService.setPaused.mockReset();
|
||||
});
|
||||
|
||||
it('creates a session with the options read from the settings mirror', async () => {
|
||||
const session = { id: 'session-1', status: 'idle' };
|
||||
mockEmbeddedMpvService.createSession.mockReturnValue(session);
|
||||
const bounds = { x: 0, y: 0, width: 640, height: 360 };
|
||||
|
||||
const handler = getIpcMainHandler(EMBEDDED_MPV_CREATE_SESSION);
|
||||
|
||||
await expect(handler({}, bounds, 'Title', 0.5)).resolves.toEqual(
|
||||
session
|
||||
);
|
||||
expect(mockEmbeddedMpvService.createSession).toHaveBeenCalledWith(
|
||||
bounds,
|
||||
'Title',
|
||||
0.5,
|
||||
mockSessionOptions
|
||||
);
|
||||
});
|
||||
|
||||
it('forwards arguments to the native service and returns its result', async () => {
|
||||
const session = { id: 'session-1', status: 'paused' };
|
||||
mockEmbeddedMpvService.setPaused.mockReturnValue(session);
|
||||
|
||||
@@ -32,6 +32,7 @@ import {
|
||||
EmbeddedMpvNativeService,
|
||||
embeddedMpvNativeService,
|
||||
} from '../services/embedded-mpv-native.service';
|
||||
import { readEmbeddedMpvSessionOptions } from '../services/embedded-mpv-session-options';
|
||||
|
||||
export default class EmbeddedMpvEvents {
|
||||
static bootstrapEmbeddedMpvEvents(): Electron.IpcMain {
|
||||
@@ -57,10 +58,7 @@ function handleEmbeddedMpv<Args extends unknown[]>(
|
||||
try {
|
||||
return await handler(...(args as Args));
|
||||
} catch (error) {
|
||||
console.error(
|
||||
`[Embedded MPV] ${channel} handler failed:`,
|
||||
error
|
||||
);
|
||||
console.error(`[Embedded MPV] ${channel} handler failed:`, error);
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
@@ -73,7 +71,14 @@ handleEmbeddedMpv(EMBEDDED_MPV_PREPARE, () => getService().prepareAddon());
|
||||
handleEmbeddedMpv(
|
||||
EMBEDDED_MPV_CREATE_SESSION,
|
||||
(bounds: EmbeddedMpvBounds, title?: string, initialVolume?: number) =>
|
||||
getService().createSession(bounds, title, initialVolume)
|
||||
// Settings are read here, per session, so the service itself never
|
||||
// touches the config store (constructed at module load).
|
||||
getService().createSession(
|
||||
bounds,
|
||||
title,
|
||||
initialVolume,
|
||||
readEmbeddedMpvSessionOptions()
|
||||
)
|
||||
);
|
||||
|
||||
handleEmbeddedMpv(
|
||||
@@ -144,9 +149,8 @@ handleEmbeddedMpv(EMBEDDED_MPV_SELECT_SUBTITLE_FILE, () =>
|
||||
getService().selectSubtitleFile()
|
||||
);
|
||||
|
||||
handleEmbeddedMpv(
|
||||
EMBEDDED_MPV_SET_SPEED,
|
||||
(sessionId: string, speed: number) => getService().setSpeed(sessionId, speed)
|
||||
handleEmbeddedMpv(EMBEDDED_MPV_SET_SPEED, (sessionId: string, speed: number) =>
|
||||
getService().setSpeed(sessionId, speed)
|
||||
);
|
||||
|
||||
handleEmbeddedMpv(
|
||||
|
||||
@@ -6,11 +6,13 @@ type SettingsUpdatePayload = Omit<
|
||||
| 'embeddedMpvFrameCopy'
|
||||
| 'language'
|
||||
| 'mpvPlayerArguments'
|
||||
| 'startupWindowMode'
|
||||
| 'vlcPlayerArguments'
|
||||
> & {
|
||||
embeddedMpvFrameCopy?: boolean | number;
|
||||
language?: string;
|
||||
mpvPlayerArguments?: ExternalPlayerArgumentsSetting;
|
||||
startupWindowMode?: string;
|
||||
vlcPlayerArguments?: ExternalPlayerArgumentsSetting;
|
||||
};
|
||||
type SettingsUpdateHandler = (
|
||||
@@ -20,9 +22,12 @@ type SettingsUpdateHandler = (
|
||||
|
||||
const SETTINGS_UPDATE = 'SETTINGS_UPDATE';
|
||||
const STORE_KEYS = {
|
||||
EMBEDDED_MPV_AUTO_RECONNECT: 'EMBEDDED_MPV_AUTO_RECONNECT',
|
||||
EMBEDDED_MPV_EXTRA_OPTIONS: 'EMBEDDED_MPV_EXTRA_OPTIONS',
|
||||
EMBEDDED_MPV_FRAME_COPY: 'EMBEDDED_MPV_FRAME_COPY',
|
||||
MPV_PLAYER_ARGUMENTS: 'MPV_PLAYER_ARGUMENTS',
|
||||
MPV_REUSE_INSTANCE: 'MPV_REUSE_INSTANCE',
|
||||
STARTUP_WINDOW_MODE: 'STARTUP_WINDOW_MODE',
|
||||
VLC_PLAYER_ARGUMENTS: 'VLC_PLAYER_ARGUMENTS',
|
||||
VLC_REUSE_INSTANCE: 'VLC_REUSE_INSTANCE',
|
||||
} as const;
|
||||
@@ -44,9 +49,12 @@ jest.mock('electron', () => ({
|
||||
}));
|
||||
|
||||
jest.mock('../services/store.service', () => ({
|
||||
EMBEDDED_MPV_AUTO_RECONNECT: STORE_KEYS.EMBEDDED_MPV_AUTO_RECONNECT,
|
||||
EMBEDDED_MPV_EXTRA_OPTIONS: STORE_KEYS.EMBEDDED_MPV_EXTRA_OPTIONS,
|
||||
EMBEDDED_MPV_FRAME_COPY: STORE_KEYS.EMBEDDED_MPV_FRAME_COPY,
|
||||
MPV_PLAYER_ARGUMENTS: STORE_KEYS.MPV_PLAYER_ARGUMENTS,
|
||||
MPV_REUSE_INSTANCE: STORE_KEYS.MPV_REUSE_INSTANCE,
|
||||
STARTUP_WINDOW_MODE: STORE_KEYS.STARTUP_WINDOW_MODE,
|
||||
VLC_PLAYER_ARGUMENTS: STORE_KEYS.VLC_PLAYER_ARGUMENTS,
|
||||
VLC_REUSE_INSTANCE: STORE_KEYS.VLC_REUSE_INSTANCE,
|
||||
store: {
|
||||
@@ -136,6 +144,48 @@ describe('SETTINGS_UPDATE', () => {
|
||||
expect(mockUpdateSettings).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('mirrors the embedded MPV extra options in canonical form', () => {
|
||||
settingsUpdateHandler(
|
||||
{},
|
||||
{ embeddedMpvExtraOptions: ' --hwdec = auto \r\n\nvo=null\n' }
|
||||
);
|
||||
|
||||
expect(mockStoreSet.mock.calls).toEqual([
|
||||
[STORE_KEYS.EMBEDDED_MPV_EXTRA_OPTIONS, 'hwdec=auto\nvo=null'],
|
||||
]);
|
||||
expect(mockUpdateSettings).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('mirrors the embedded MPV auto-reconnect toggle as a boolean', () => {
|
||||
settingsUpdateHandler({}, { embeddedMpvAutoReconnect: false });
|
||||
expect(mockStoreSet.mock.calls).toEqual([
|
||||
[STORE_KEYS.EMBEDDED_MPV_AUTO_RECONNECT, false],
|
||||
]);
|
||||
|
||||
mockStoreSet.mockClear();
|
||||
settingsUpdateHandler({}, { embeddedMpvAutoReconnect: true });
|
||||
expect(mockStoreSet.mock.calls).toEqual([
|
||||
[STORE_KEYS.EMBEDDED_MPV_AUTO_RECONNECT, true],
|
||||
]);
|
||||
});
|
||||
|
||||
it('mirrors the startup window mode into the main-process store', () => {
|
||||
settingsUpdateHandler({}, { startupWindowMode: 'fullscreen' });
|
||||
|
||||
expect(mockStoreSet.mock.calls).toEqual([
|
||||
[STORE_KEYS.STARTUP_WINDOW_MODE, 'fullscreen'],
|
||||
]);
|
||||
expect(mockUpdateSettings).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('normalizes an unknown startup window mode to normal instead of storing junk', () => {
|
||||
settingsUpdateHandler({}, { startupWindowMode: 'kiosk' });
|
||||
|
||||
expect(mockStoreSet.mock.calls).toEqual([
|
||||
[STORE_KEYS.STARTUP_WINDOW_MODE, 'normal'],
|
||||
]);
|
||||
});
|
||||
|
||||
it('reconciles an enabled remote-control update with the stored port', () => {
|
||||
mockStoreGet.mockImplementation(
|
||||
(key: string, fallbackValue: unknown): unknown =>
|
||||
@@ -197,6 +247,26 @@ describe('SETTINGS_UPDATE', () => {
|
||||
expect(output).toContain('enabled');
|
||||
});
|
||||
|
||||
it('does not print credentials embedded in embedded MPV extra options', () => {
|
||||
const headerSecret = 'embedded-mpv-header-secret';
|
||||
|
||||
settingsUpdateHandler(
|
||||
{},
|
||||
{
|
||||
language: 'de',
|
||||
embeddedMpvExtraOptions: `cache-secs=5\nhttp-header-fields=X-Playback-Key: ${headerSecret}`,
|
||||
}
|
||||
);
|
||||
|
||||
const output = JSON.stringify(consoleLogSpy.mock.calls);
|
||||
expect(output).not.toContain(headerSecret);
|
||||
expect(output).toContain('language');
|
||||
expect(mockStoreSet).toHaveBeenCalledWith(
|
||||
STORE_KEYS.EMBEDDED_MPV_EXTRA_OPTIONS,
|
||||
`cache-secs=5\nhttp-header-fields=X-Playback-Key: ${headerSecret}`
|
||||
);
|
||||
});
|
||||
|
||||
it('does not print credentials embedded in external player arguments', () => {
|
||||
const authorizationSecret = 'player-authorization-secret';
|
||||
const cookieSecret = 'player-cookie-secret';
|
||||
|
||||
@@ -1,10 +1,17 @@
|
||||
import { ipcMain } from 'electron';
|
||||
import { normalizeExternalPlayerArguments } from '@iptvnator/shared/interfaces';
|
||||
import {
|
||||
normalizeEmbeddedMpvExtraOptions,
|
||||
normalizeExternalPlayerArguments,
|
||||
normalizeStartupWindowMode,
|
||||
} from '@iptvnator/shared/interfaces';
|
||||
import { redactSensitiveData } from '@iptvnator/shared/logging';
|
||||
import {
|
||||
EMBEDDED_MPV_AUTO_RECONNECT,
|
||||
EMBEDDED_MPV_EXTRA_OPTIONS,
|
||||
EMBEDDED_MPV_FRAME_COPY,
|
||||
MPV_PLAYER_ARGUMENTS,
|
||||
MPV_REUSE_INSTANCE,
|
||||
STARTUP_WINDOW_MODE,
|
||||
store,
|
||||
VLC_PLAYER_ARGUMENTS,
|
||||
VLC_REUSE_INSTANCE,
|
||||
@@ -46,6 +53,28 @@ ipcMain.handle('SETTINGS_UPDATE', (_event, arg) => {
|
||||
if (arg.embeddedMpvFrameCopy !== undefined) {
|
||||
store.set(EMBEDDED_MPV_FRAME_COPY, !!arg.embeddedMpvFrameCopy);
|
||||
}
|
||||
if (arg.embeddedMpvExtraOptions !== undefined) {
|
||||
store.set(
|
||||
EMBEDDED_MPV_EXTRA_OPTIONS,
|
||||
normalizeEmbeddedMpvExtraOptions(arg.embeddedMpvExtraOptions)
|
||||
);
|
||||
}
|
||||
if (arg.embeddedMpvAutoReconnect !== undefined) {
|
||||
store.set(
|
||||
EMBEDDED_MPV_AUTO_RECONNECT,
|
||||
arg.embeddedMpvAutoReconnect !== false
|
||||
);
|
||||
}
|
||||
|
||||
// Read by initMainWindow before any renderer exists, so it applies on
|
||||
// the next launch. Normalized here so a junk value never reaches the
|
||||
// config file.
|
||||
if (arg.startupWindowMode !== undefined) {
|
||||
store.set(
|
||||
STARTUP_WINDOW_MODE,
|
||||
normalizeStartupWindowMode(arg.startupWindowMode)
|
||||
);
|
||||
}
|
||||
|
||||
if (arg.vlcReuseInstance !== undefined) {
|
||||
store.set(VLC_REUSE_INSTANCE, arg.vlcReuseInstance);
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { FULLSCREEN_TRANSITION_TIMEOUT_MS } from '../services/native-fullscreen-transitions';
|
||||
|
||||
const mockHandlers = new Map<
|
||||
string,
|
||||
(event: unknown, ...args: unknown[]) => unknown
|
||||
@@ -21,14 +23,25 @@ jest.mock('electron', () => ({
|
||||
}));
|
||||
|
||||
function createFakeWindow(
|
||||
initial: { maximized?: boolean; fullScreen?: boolean } = {}
|
||||
initial: {
|
||||
maximized?: boolean;
|
||||
fullScreen?: boolean;
|
||||
/**
|
||||
* Windows/Linux flip isFullScreen() inside setFullScreen(); macOS
|
||||
* animates and reports the old value until the transition lands,
|
||||
* which the test then signals through `emit`.
|
||||
*/
|
||||
asyncFullScreen?: boolean;
|
||||
} = {}
|
||||
) {
|
||||
const state = {
|
||||
maximized: initial.maximized ?? false,
|
||||
fullScreen: initial.fullScreen ?? false,
|
||||
};
|
||||
const listeners = new Map<string, Set<() => void>>();
|
||||
|
||||
return {
|
||||
state,
|
||||
isDestroyed: jest.fn(() => false),
|
||||
isMaximized: jest.fn(() => state.maximized),
|
||||
isFullScreen: jest.fn(() => state.fullScreen),
|
||||
@@ -39,6 +52,25 @@ function createFakeWindow(
|
||||
unmaximize: jest.fn(() => {
|
||||
state.maximized = false;
|
||||
}),
|
||||
setFullScreen: jest.fn((fullScreen: boolean) => {
|
||||
if (!initial.asyncFullScreen) {
|
||||
state.fullScreen = fullScreen;
|
||||
}
|
||||
}),
|
||||
on: jest.fn((event: string, listener: () => void) => {
|
||||
listeners.set(
|
||||
event,
|
||||
(listeners.get(event) ?? new Set()).add(listener)
|
||||
);
|
||||
}),
|
||||
off: jest.fn((event: string, listener: () => void) => {
|
||||
listeners.get(event)?.delete(listener);
|
||||
}),
|
||||
emit(event: string): void {
|
||||
for (const listener of [...(listeners.get(event) ?? [])]) {
|
||||
listener();
|
||||
}
|
||||
},
|
||||
close: jest.fn(),
|
||||
};
|
||||
}
|
||||
@@ -59,6 +91,7 @@ describe('WindowEvents', () => {
|
||||
'WINDOW:CLOSE',
|
||||
'WINDOW:GET_STATE',
|
||||
'WINDOW:MINIMIZE',
|
||||
'WINDOW:TOGGLE_FULLSCREEN',
|
||||
'WINDOW:TOGGLE_MAXIMIZE',
|
||||
]);
|
||||
});
|
||||
@@ -94,6 +127,271 @@ describe('WindowEvents', () => {
|
||||
expect(result).toEqual({ isMaximized: false, isFullScreen: false });
|
||||
});
|
||||
|
||||
it('enters fullscreen from a windowed state and reports the requested state', () => {
|
||||
const win = createFakeWindow({ maximized: true, fullScreen: false });
|
||||
mockFromWebContents.mockReturnValue(win);
|
||||
|
||||
const result = mockHandlers.get('WINDOW:TOGGLE_FULLSCREEN')!(fakeEvent);
|
||||
|
||||
expect(win.setFullScreen).toHaveBeenCalledWith(true);
|
||||
expect(result).toEqual({ isMaximized: true, isFullScreen: true });
|
||||
});
|
||||
|
||||
it('leaves fullscreen and reports the requested state', () => {
|
||||
const win = createFakeWindow({ fullScreen: true });
|
||||
mockFromWebContents.mockReturnValue(win);
|
||||
|
||||
const result = mockHandlers.get('WINDOW:TOGGLE_FULLSCREEN')!(fakeEvent);
|
||||
|
||||
expect(win.setFullScreen).toHaveBeenCalledWith(false);
|
||||
expect(result).toEqual({ isMaximized: false, isFullScreen: false });
|
||||
});
|
||||
|
||||
it('keeps toggle parity when a second press arrives before the transition lands', () => {
|
||||
const win = createFakeWindow({ asyncFullScreen: true });
|
||||
mockFromWebContents.mockReturnValue(win);
|
||||
const toggle = mockHandlers.get('WINDOW:TOGGLE_FULLSCREEN')!;
|
||||
|
||||
toggle(fakeEvent);
|
||||
// Still animating: the window reports the old state.
|
||||
expect(win.isFullScreen()).toBe(false);
|
||||
|
||||
const second = toggle(fakeEvent);
|
||||
|
||||
// Decided against the pending target, not the stale getter.
|
||||
expect(win.setFullScreen.mock.calls).toEqual([[true], [false]]);
|
||||
expect(second).toEqual({ isMaximized: false, isFullScreen: false });
|
||||
|
||||
// The first transition lands on the other state: the exit is still
|
||||
// queued on the platform, so the record stays and nothing is
|
||||
// re-issued — the tracker never requests on its own.
|
||||
win.state.fullScreen = true;
|
||||
win.emit('enter-full-screen');
|
||||
expect(win.setFullScreen).toHaveBeenCalledTimes(2);
|
||||
|
||||
win.state.fullScreen = false;
|
||||
win.emit('leave-full-screen');
|
||||
|
||||
// Back to deciding from the tracked state.
|
||||
toggle(fakeEvent);
|
||||
expect(win.setFullScreen).toHaveBeenLastCalledWith(true);
|
||||
});
|
||||
|
||||
it('stops tracking once the transition reports the requested state', () => {
|
||||
const win = createFakeWindow({ asyncFullScreen: true });
|
||||
mockFromWebContents.mockReturnValue(win);
|
||||
const toggle = mockHandlers.get('WINDOW:TOGGLE_FULLSCREEN')!;
|
||||
|
||||
toggle(fakeEvent);
|
||||
win.state.fullScreen = true;
|
||||
win.emit('enter-full-screen');
|
||||
// Reported the requested state: nothing re-issued.
|
||||
expect(win.setFullScreen).toHaveBeenCalledTimes(1);
|
||||
|
||||
toggle(fakeEvent);
|
||||
expect(win.setFullScreen).toHaveBeenLastCalledWith(false);
|
||||
});
|
||||
|
||||
it('settles against the state the event conveys, not a stale getter', () => {
|
||||
const win = createFakeWindow({ asyncFullScreen: true });
|
||||
mockFromWebContents.mockReturnValue(win);
|
||||
const toggle = mockHandlers.get('WINDOW:TOGGLE_FULLSCREEN')!;
|
||||
|
||||
toggle(fakeEvent);
|
||||
// Windows: the enter event fires while isFullScreen() still reports
|
||||
// the pre-transition false. Read the getter here and the landed
|
||||
// enter would not match the requested enter, leaving the record in
|
||||
// place to misdirect the next press.
|
||||
expect(win.isFullScreen()).toBe(false);
|
||||
win.emit('enter-full-screen');
|
||||
|
||||
toggle(fakeEvent);
|
||||
expect(win.setFullScreen.mock.calls).toEqual([[true], [false]]);
|
||||
});
|
||||
|
||||
it('decides a press that lands between the event and the getter update from the tracked state', () => {
|
||||
const win = createFakeWindow({ asyncFullScreen: true });
|
||||
mockFromWebContents.mockReturnValue(win);
|
||||
const toggle = mockHandlers.get('WINDOW:TOGGLE_FULLSCREEN')!;
|
||||
|
||||
toggle(fakeEvent);
|
||||
// The enter event landed and settled the pending target …
|
||||
win.emit('enter-full-screen');
|
||||
// … but the getter has not caught up yet (Windows).
|
||||
expect(win.isFullScreen()).toBe(false);
|
||||
|
||||
toggle(fakeEvent);
|
||||
|
||||
// Decided against the event-fed state: this press is the exit.
|
||||
expect(win.setFullScreen.mock.calls).toEqual([[true], [false]]);
|
||||
});
|
||||
|
||||
it('follows fullscreen changes the app did not request', () => {
|
||||
const win = createFakeWindow({ asyncFullScreen: true });
|
||||
mockFromWebContents.mockReturnValue(win);
|
||||
const toggle = mockHandlers.get('WINDOW:TOGGLE_FULLSCREEN')!;
|
||||
|
||||
// Green button / Ctrl+Cmd+F: only the event tells the tracker.
|
||||
toggle(fakeEvent);
|
||||
win.state.fullScreen = true;
|
||||
win.emit('enter-full-screen');
|
||||
win.emit('leave-full-screen');
|
||||
|
||||
toggle(fakeEvent);
|
||||
expect(win.setFullScreen).toHaveBeenLastCalledWith(true);
|
||||
});
|
||||
|
||||
it('leaves no debt behind when a platform coalesces a burst into one event', () => {
|
||||
const win = createFakeWindow({ asyncFullScreen: true });
|
||||
mockFromWebContents.mockReturnValue(win);
|
||||
const toggle = mockHandlers.get('WINDOW:TOGGLE_FULLSCREEN')!;
|
||||
|
||||
toggle(fakeEvent);
|
||||
toggle(fakeEvent);
|
||||
toggle(fakeEvent);
|
||||
expect(win.setFullScreen.mock.calls).toEqual([[true], [false], [true]]);
|
||||
|
||||
// A single enter is all the platform reports for the whole burst.
|
||||
// It lands on the final target, so the record is done — events
|
||||
// cannot say which request they belong to, and counting them would
|
||||
// leave phantom debt that reverses the user's next action.
|
||||
win.emit('enter-full-screen');
|
||||
expect(win.setFullScreen).toHaveBeenCalledTimes(3);
|
||||
|
||||
// Green button right after: an unrelated action, honoured as is.
|
||||
win.emit('leave-full-screen');
|
||||
expect(win.setFullScreen).toHaveBeenCalledTimes(3);
|
||||
|
||||
toggle(fakeEvent);
|
||||
expect(win.setFullScreen).toHaveBeenLastCalledWith(true);
|
||||
});
|
||||
|
||||
it('self-heals through the timeout if a platform ever drops a queued request', () => {
|
||||
const win = createFakeWindow({ asyncFullScreen: true });
|
||||
mockFromWebContents.mockReturnValue(win);
|
||||
const toggle = mockHandlers.get('WINDOW:TOGGLE_FULLSCREEN')!;
|
||||
const now = jest.spyOn(Date, 'now');
|
||||
|
||||
try {
|
||||
now.mockReturnValue(1_000);
|
||||
toggle(fakeEvent);
|
||||
toggle(fakeEvent);
|
||||
win.emit('enter-full-screen');
|
||||
// The queued exit never happens and nothing is re-issued …
|
||||
expect(win.setFullScreen).toHaveBeenCalledTimes(2);
|
||||
|
||||
// … so once the record expires, the event-fed state governs and
|
||||
// the next press is the exit the user still wants.
|
||||
now.mockReturnValue(1_000 + FULLSCREEN_TRANSITION_TIMEOUT_MS + 1);
|
||||
toggle(fakeEvent);
|
||||
expect(win.setFullScreen).toHaveBeenLastCalledWith(false);
|
||||
} finally {
|
||||
now.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it('honours a native action opposite to a pending target instead of reversing it', () => {
|
||||
const win = createFakeWindow({ asyncFullScreen: true });
|
||||
mockFromWebContents.mockReturnValue(win);
|
||||
const toggle = mockHandlers.get('WINDOW:TOGGLE_FULLSCREEN')!;
|
||||
|
||||
toggle(fakeEvent);
|
||||
toggle(fakeEvent);
|
||||
expect(win.setFullScreen.mock.calls).toEqual([[true], [false]]);
|
||||
|
||||
// Whatever produced this enter — the first press's transition or
|
||||
// the user's own green button — the tracker must not answer it
|
||||
// with a request of its own.
|
||||
win.emit('enter-full-screen');
|
||||
expect(win.setFullScreen).toHaveBeenCalledTimes(2);
|
||||
|
||||
// The exit lands: the record is done, later native actions are
|
||||
// simply followed.
|
||||
win.emit('leave-full-screen');
|
||||
win.emit('enter-full-screen');
|
||||
expect(win.setFullScreen).toHaveBeenCalledTimes(2);
|
||||
|
||||
toggle(fakeEvent);
|
||||
expect(win.setFullScreen).toHaveBeenLastCalledWith(false);
|
||||
});
|
||||
|
||||
it('forgets a request that never reports back after the transition timeout', () => {
|
||||
const win = createFakeWindow({ asyncFullScreen: true });
|
||||
mockFromWebContents.mockReturnValue(win);
|
||||
const toggle = mockHandlers.get('WINDOW:TOGGLE_FULLSCREEN')!;
|
||||
const now = jest.spyOn(Date, 'now');
|
||||
|
||||
try {
|
||||
now.mockReturnValue(1_000);
|
||||
toggle(fakeEvent);
|
||||
expect(win.setFullScreen).toHaveBeenLastCalledWith(true);
|
||||
|
||||
// No event ever arrived: the window really is still windowed,
|
||||
// so the stale target must not flip this press into an exit.
|
||||
now.mockReturnValue(1_000 + FULLSCREEN_TRANSITION_TIMEOUT_MS + 1);
|
||||
toggle(fakeEvent);
|
||||
expect(win.setFullScreen).toHaveBeenLastCalledWith(true);
|
||||
} finally {
|
||||
now.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it('does not revive an expired target when its transition event arrives late', () => {
|
||||
const win = createFakeWindow({ asyncFullScreen: true });
|
||||
mockFromWebContents.mockReturnValue(win);
|
||||
const toggle = mockHandlers.get('WINDOW:TOGGLE_FULLSCREEN')!;
|
||||
const now = jest.spyOn(Date, 'now');
|
||||
|
||||
try {
|
||||
now.mockReturnValue(1_000);
|
||||
toggle(fakeEvent);
|
||||
toggle(fakeEvent);
|
||||
expect(win.setFullScreen.mock.calls).toEqual([[true], [false]]);
|
||||
|
||||
// The enter lands long after the timeout, on the other state
|
||||
// than the expired "windowed" target. Re-issuing it here would
|
||||
// resurrect intent the user has long moved past.
|
||||
now.mockReturnValue(1_000 + FULLSCREEN_TRANSITION_TIMEOUT_MS + 1);
|
||||
win.state.fullScreen = true;
|
||||
win.emit('enter-full-screen');
|
||||
expect(win.setFullScreen).toHaveBeenCalledTimes(2);
|
||||
|
||||
// The next press is decided from the live state.
|
||||
toggle(fakeEvent);
|
||||
expect(win.setFullScreen).toHaveBeenLastCalledWith(false);
|
||||
} finally {
|
||||
now.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it('keeps deciding against the latest intent through a burst whose events lag behind', () => {
|
||||
const win = createFakeWindow({ asyncFullScreen: true });
|
||||
mockFromWebContents.mockReturnValue(win);
|
||||
const toggle = mockHandlers.get('WINDOW:TOGGLE_FULLSCREEN')!;
|
||||
|
||||
toggle(fakeEvent);
|
||||
toggle(fakeEvent);
|
||||
// The first enter lands while the exit is still queued.
|
||||
win.state.fullScreen = true;
|
||||
win.emit('enter-full-screen');
|
||||
|
||||
// A press now is decided against the still-pending exit, so the
|
||||
// window is asked back into fullscreen — the user's third intent.
|
||||
toggle(fakeEvent);
|
||||
expect(win.setFullScreen.mock.calls).toEqual([[true], [false], [true]]);
|
||||
|
||||
// The queued exit lands: not the latest target, record kept.
|
||||
win.state.fullScreen = false;
|
||||
win.emit('leave-full-screen');
|
||||
expect(win.setFullScreen).toHaveBeenCalledTimes(3);
|
||||
|
||||
// The third request lands: record cleared, the next press exits.
|
||||
win.state.fullScreen = true;
|
||||
win.emit('enter-full-screen');
|
||||
toggle(fakeEvent);
|
||||
expect(win.setFullScreen).toHaveBeenLastCalledWith(false);
|
||||
});
|
||||
|
||||
it('closes the sender window', () => {
|
||||
const win = createFakeWindow();
|
||||
mockFromWebContents.mockReturnValue(win);
|
||||
@@ -122,6 +420,12 @@ describe('WindowEvents', () => {
|
||||
isMaximized: false,
|
||||
isFullScreen: false,
|
||||
});
|
||||
expect(
|
||||
mockHandlers.get('WINDOW:TOGGLE_FULLSCREEN')!(fakeEvent)
|
||||
).toEqual({
|
||||
isMaximized: false,
|
||||
isFullScreen: false,
|
||||
});
|
||||
expect(mockHandlers.get('WINDOW:GET_STATE')!(fakeEvent)).toEqual({
|
||||
isMaximized: false,
|
||||
isFullScreen: false,
|
||||
|
||||
@@ -10,8 +10,10 @@ import {
|
||||
WINDOW_CLOSE,
|
||||
WINDOW_GET_STATE,
|
||||
WINDOW_MINIMIZE,
|
||||
WINDOW_TOGGLE_FULLSCREEN,
|
||||
WINDOW_TOGGLE_MAXIMIZE,
|
||||
} from '@iptvnator/shared/interfaces';
|
||||
import { toggleFullScreen } from '../services/native-fullscreen-transitions';
|
||||
|
||||
interface WindowState {
|
||||
isMaximized: boolean;
|
||||
@@ -67,6 +69,26 @@ ipcMain.handle(WINDOW_TOGGLE_MAXIMIZE, (event): WindowState => {
|
||||
};
|
||||
});
|
||||
|
||||
// F11. This is the exit path from a fullscreen launch on Windows/Linux,
|
||||
// where the title bar is hidden and the custom controls hide themselves
|
||||
// while fullscreen. The transition tracker decides against the pending
|
||||
// target while a transition (including the startup fullscreen) is still in
|
||||
// flight, so two quick presses stay an enter-then-exit. Like the maximize
|
||||
// toggle it reports the requested state and leaves WINDOW:STATE_CHANGED
|
||||
// authoritative.
|
||||
ipcMain.handle(WINDOW_TOGGLE_FULLSCREEN, (event): WindowState => {
|
||||
const win = getSenderWindow(event);
|
||||
|
||||
if (!win) {
|
||||
return getWindowState(win);
|
||||
}
|
||||
|
||||
return {
|
||||
isMaximized: win.isMaximized(),
|
||||
isFullScreen: toggleFullScreen(win),
|
||||
};
|
||||
});
|
||||
|
||||
// win.close() (instead of app.quit()) so the window's 'close' handler still
|
||||
// persists the window bounds before shutdown.
|
||||
ipcMain.handle(WINDOW_CLOSE, (event) => {
|
||||
|
||||
@@ -25,6 +25,23 @@ export function encodeProtocolValue(value: string): string {
|
||||
.replace(/\r/g, '%0D');
|
||||
}
|
||||
|
||||
/**
|
||||
* The first line the adapter writes to a helper started with
|
||||
* `--mpv-options-stdin`: the session's libmpv options in application order.
|
||||
* They travel over stdin rather than argv so a credential-bearing option is
|
||||
* never visible to `ps`; zero-padded keys keep the order through the
|
||||
* helper's unordered field map.
|
||||
*/
|
||||
export function buildMpvOptionsPreamble(options: readonly string[]): string {
|
||||
return [
|
||||
'mpv-options',
|
||||
...options.map(
|
||||
(option, index) =>
|
||||
`o${String(index).padStart(3, '0')}=${encodeProtocolValue(option)}`
|
||||
),
|
||||
].join('\t');
|
||||
}
|
||||
|
||||
export function createInitialSnapshot(): NativeEmbeddedMpvSessionSnapshot {
|
||||
return {
|
||||
status: 'loading',
|
||||
@@ -53,7 +70,9 @@ export function buildLoadPlaybackCommand(
|
||||
);
|
||||
}
|
||||
if (playback.userAgent) {
|
||||
fields.push(`opt.user-agent=${encodeProtocolValue(playback.userAgent)}`);
|
||||
fields.push(
|
||||
`opt.user-agent=${encodeProtocolValue(playback.userAgent)}`
|
||||
);
|
||||
}
|
||||
if (playback.referer) {
|
||||
fields.push(`opt.referrer=${encodeProtocolValue(playback.referer)}`);
|
||||
@@ -128,6 +147,7 @@ export function applyHelperEvent(
|
||||
session.snapshot.error = String(
|
||||
event.error ?? 'Embedded MPV helper failed.'
|
||||
);
|
||||
session.snapshot.errorOrigin = 'engine';
|
||||
break;
|
||||
case 'log':
|
||||
if (event.level === 'error' || event.level === 'fatal') {
|
||||
@@ -136,6 +156,16 @@ export function applyHelperEvent(
|
||||
event.prefix ?? ''
|
||||
)}] ${String(event.text ?? '').trim()}`
|
||||
);
|
||||
} else if (event.level === 'warn' && event.prefix === 'iptvnator') {
|
||||
// The helper's own warnings (a session option libmpv
|
||||
// rejected) must reach the main-process log like the
|
||||
// native-view trace does; mpv's warn-level chatter stays
|
||||
// filtered.
|
||||
console.warn(
|
||||
`[embedded-mpv-fc][${session.id}] ${String(
|
||||
event.text ?? ''
|
||||
).trim()}`
|
||||
);
|
||||
}
|
||||
break;
|
||||
default:
|
||||
|
||||
@@ -61,6 +61,9 @@ export function resolveFrameCopyHelperSpawn(
|
||||
...(process.env.IPTVNATOR_EMBEDDED_MPV_AUDIO_DELAY
|
||||
? ['--audio-delay', process.env.IPTVNATOR_EMBEDDED_MPV_AUDIO_DELAY]
|
||||
: []),
|
||||
// Session options follow as the first stdin line (see
|
||||
// buildMpvOptionsPreamble), never as arguments `ps` could read.
|
||||
'--mpv-options-stdin',
|
||||
];
|
||||
|
||||
if (process.platform !== 'linux') {
|
||||
@@ -81,7 +84,9 @@ export function resolveFrameCopyHelperSpawn(
|
||||
fileSystem: helperLaunchFileSystem,
|
||||
});
|
||||
if (!launch.usable) {
|
||||
throw new Error('The connected Snap graphics provider is not available.');
|
||||
throw new Error(
|
||||
'The connected Snap graphics provider is not available.'
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
|
||||
@@ -1,6 +1,17 @@
|
||||
import type { Stats } from 'fs';
|
||||
import path from 'path';
|
||||
|
||||
// The adapter reaches `electron` through the platform util for packaged
|
||||
// paths only; mocking it keeps this suite independent of the Electron binary
|
||||
// being present (a CI runner can restore node_modules without it).
|
||||
jest.mock('electron', () => ({
|
||||
app: {
|
||||
isPackaged: false,
|
||||
getAppPath: () => '/mock/app',
|
||||
getPath: () => '/mock/user-data',
|
||||
},
|
||||
}));
|
||||
|
||||
const spawnMock = jest.fn();
|
||||
jest.mock('child_process', () => ({
|
||||
spawn: (...args: unknown[]) => spawnMock(...args),
|
||||
@@ -98,7 +109,8 @@ describe('EmbeddedMpvFrameCopyAdapter Linux loader environment', () => {
|
||||
GBM_BACKENDS_PATH: '/tmp/hostile-gbm',
|
||||
LIBGL_DRIVERS_PATH: '/tmp/hostile-dri',
|
||||
LIBVA_DRIVERS_PATH: '/tmp/hostile-va',
|
||||
__EGL_EXTERNAL_PLATFORM_CONFIG_DIRS: '/tmp/hostile-egl-platform',
|
||||
__EGL_EXTERNAL_PLATFORM_CONFIG_DIRS:
|
||||
'/tmp/hostile-egl-platform',
|
||||
__EGL_VENDOR_LIBRARY_DIRS: '/tmp/hostile-egl-vendor',
|
||||
VK_LAYER_PATH: '/tmp/hostile-vulkan',
|
||||
XDG_CONFIG_HOME: '/tmp/hostile-xdg-config-home',
|
||||
|
||||
@@ -1,11 +1,23 @@
|
||||
import path from 'path';
|
||||
|
||||
// The adapter reaches `electron` through the platform util for packaged
|
||||
// paths only; mocking it keeps this suite independent of the Electron binary
|
||||
// being present (a CI runner can restore node_modules without it).
|
||||
jest.mock('electron', () => ({
|
||||
app: {
|
||||
isPackaged: false,
|
||||
getAppPath: () => '/mock/app',
|
||||
getPath: () => '/mock/user-data',
|
||||
},
|
||||
}));
|
||||
|
||||
const spawnMock = jest.fn();
|
||||
jest.mock('child_process', () => ({
|
||||
spawn: (...args: unknown[]) => spawnMock(...args),
|
||||
}));
|
||||
|
||||
import type { EmbeddedMpvFrameCopyAdapter } from './embedded-mpv-frame-copy.adapter';
|
||||
import { EmbeddedMpvSessionGoneError } from './embedded-mpv-session-errors';
|
||||
import {
|
||||
createFrameCopyAdapter,
|
||||
createFrameCopySession,
|
||||
@@ -48,7 +60,9 @@ describe('EmbeddedMpvFrameCopyAdapter', () => {
|
||||
'720',
|
||||
'--volume',
|
||||
'0.8',
|
||||
'--mpv-options-stdin',
|
||||
]);
|
||||
expect(child.stdin.written[0]).toBe('mpv-options\n');
|
||||
});
|
||||
|
||||
it('caches helper snapshot events for getSessionSnapshot', () => {
|
||||
@@ -93,6 +107,116 @@ describe('EmbeddedMpvFrameCopyAdapter', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('sends the session options as the first stdin line, never on argv', () => {
|
||||
adapter.createSession(
|
||||
Buffer.alloc(0),
|
||||
{ x: 0, y: 0, width: 640, height: 360 },
|
||||
'Title',
|
||||
0.8,
|
||||
['network-timeout=10', 'http-header-fields=X-Key: s%cr\tet']
|
||||
);
|
||||
const [, args] = spawnMock.mock.calls[0];
|
||||
expect(args).toContain('--mpv-options-stdin');
|
||||
expect(args.join(' ')).not.toContain('network-timeout');
|
||||
expect(args.join(' ')).not.toContain('X-Key');
|
||||
expect(child.stdin.written[0]).toBe(
|
||||
'mpv-options\to000=network-timeout=10\to001=http-header-fields=X-Key: s%25cr%09et\n'
|
||||
);
|
||||
});
|
||||
|
||||
it('flips the cached snapshot to loading as soon as a load is sent', () => {
|
||||
const sessionId = createSession();
|
||||
child.emitStdout({
|
||||
event: 'snapshot',
|
||||
status: 'error',
|
||||
error: 'connection reset',
|
||||
positionSeconds: 0,
|
||||
durationSeconds: null,
|
||||
volume: 0.8,
|
||||
streamUrl: 'http://stream',
|
||||
audioTracks: [],
|
||||
selectedAudioTrackId: null,
|
||||
subtitleTracks: [],
|
||||
selectedSubtitleTrackId: null,
|
||||
playbackSpeed: 1,
|
||||
aspectOverride: 'no',
|
||||
recording: { active: false },
|
||||
});
|
||||
expect(adapter.getSessionSnapshot(sessionId)?.status).toBe('error');
|
||||
|
||||
adapter.loadPlayback(sessionId, {
|
||||
streamUrl: 'http://stream',
|
||||
title: 'Live',
|
||||
});
|
||||
|
||||
const snapshot = adapter.getSessionSnapshot(sessionId);
|
||||
expect(snapshot?.status).toBe('loading');
|
||||
expect(snapshot?.error).toBeUndefined();
|
||||
expect(snapshot?.streamUrl).toBe('http://stream');
|
||||
});
|
||||
|
||||
it('refuses a load once the helper process has exited', () => {
|
||||
const sessionId = createSession();
|
||||
child.exitCode = 1;
|
||||
child.emit('exit', 1, null);
|
||||
expect(adapter.getSessionSnapshot(sessionId)?.status).toBe('error');
|
||||
|
||||
expect(() =>
|
||||
adapter.loadPlayback(sessionId, {
|
||||
streamUrl: 'http://stream',
|
||||
title: 'Live',
|
||||
})
|
||||
).toThrow(EmbeddedMpvSessionGoneError);
|
||||
expect(adapter.getSessionSnapshot(sessionId)?.status).toBe('error');
|
||||
expect(
|
||||
child.stdin.written.some((line) => line.startsWith('load\t'))
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('refuses a load once the helper died from a signal', () => {
|
||||
const sessionId = createSession();
|
||||
child.signalCode = 'SIGKILL';
|
||||
child.emit('exit', null, 'SIGKILL');
|
||||
expect(adapter.getSessionSnapshot(sessionId)?.status).toBe('error');
|
||||
|
||||
expect(() =>
|
||||
adapter.loadPlayback(sessionId, {
|
||||
streamUrl: 'http://stream',
|
||||
title: 'Live',
|
||||
})
|
||||
).toThrow(EmbeddedMpvSessionGoneError);
|
||||
expect(adapter.getSessionSnapshot(sessionId)?.status).toBe('error');
|
||||
});
|
||||
|
||||
it('surfaces the helper warning for a session option libmpv rejected', () => {
|
||||
const sessionId = createSession();
|
||||
const warn = jest
|
||||
.spyOn(console, 'warn')
|
||||
.mockImplementation(() => undefined);
|
||||
try {
|
||||
child.emitStdout({
|
||||
event: 'log',
|
||||
level: 'warn',
|
||||
prefix: 'iptvnator',
|
||||
text: 'rejected session option nonexistent-option: option not found',
|
||||
});
|
||||
child.emitStdout({
|
||||
event: 'log',
|
||||
level: 'warn',
|
||||
prefix: 'ffmpeg/demuxer',
|
||||
text: 'mpegts: PES packet size mismatch',
|
||||
});
|
||||
|
||||
expect(warn).toHaveBeenCalledTimes(1);
|
||||
expect(warn.mock.calls[0][0]).toContain(sessionId);
|
||||
expect(warn.mock.calls[0][0]).toContain(
|
||||
'rejected session option nonexistent-option'
|
||||
);
|
||||
} finally {
|
||||
warn.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it('encodes loadfile options with percent-escaping', () => {
|
||||
const sessionId = createSession();
|
||||
adapter.loadPlayback(sessionId, {
|
||||
|
||||
@@ -65,6 +65,7 @@ export function fakeStat(
|
||||
|
||||
export class FakeHelperProcess extends EventEmitter {
|
||||
exitCode: number | null = null;
|
||||
signalCode: string | null = null;
|
||||
readonly stdout = new EventEmitter();
|
||||
readonly stderr = new EventEmitter();
|
||||
readonly stdin = {
|
||||
|
||||
@@ -9,11 +9,13 @@ import {
|
||||
import { isFrameCopyPlatformSupported } from './embedded-mpv-frame-copy-platform.util';
|
||||
import {
|
||||
applyHelperEvent,
|
||||
buildMpvOptionsPreamble,
|
||||
buildLoadPlaybackCommand,
|
||||
createInitialSnapshot,
|
||||
encodeProtocolValue,
|
||||
} from './embedded-mpv-frame-copy-protocol';
|
||||
import { resolveFrameCopyHelperSpawn } from './embedded-mpv-frame-copy-spawn';
|
||||
import { EmbeddedMpvSessionGoneError } from './embedded-mpv-session-errors';
|
||||
import type {
|
||||
EmbeddedMpvFrameCopyRuntimeMode,
|
||||
LinuxFrameCopyHelperLaunchFileSystem,
|
||||
@@ -83,7 +85,8 @@ export class EmbeddedMpvFrameCopyAdapter implements NativeEmbeddedMpvAddon {
|
||||
_windowHandle: Buffer,
|
||||
bounds: EmbeddedMpvBounds,
|
||||
_title?: string,
|
||||
initialVolume?: number
|
||||
initialVolume?: number,
|
||||
extraOptions?: string[]
|
||||
): string {
|
||||
const helperPath = this.options.resolveHelperPath();
|
||||
if (!helperPath) {
|
||||
@@ -122,6 +125,13 @@ export class EmbeddedMpvFrameCopyAdapter implements NativeEmbeddedMpvAddon {
|
||||
killTimers: [],
|
||||
};
|
||||
this.sessions.set(sessionId, session);
|
||||
// The helper blocks on this line before initialising libmpv; it is
|
||||
// the private channel for options that may carry credentials.
|
||||
if (child.stdin.writable) {
|
||||
child.stdin.write(
|
||||
`${buildMpvOptionsPreamble(extraOptions ?? [])}\n`
|
||||
);
|
||||
}
|
||||
|
||||
child.stdout.on('data', (chunk: Buffer) =>
|
||||
this.consumeStdout(session, chunk)
|
||||
@@ -134,6 +144,7 @@ export class EmbeddedMpvFrameCopyAdapter implements NativeEmbeddedMpvAddon {
|
||||
child.on('error', (error) => {
|
||||
session.snapshot.status = 'error';
|
||||
session.snapshot.error = `Helper process failed: ${error.message}`;
|
||||
session.snapshot.errorOrigin = 'engine';
|
||||
});
|
||||
child.on('exit', (code, signal) => {
|
||||
console.log(
|
||||
@@ -151,12 +162,41 @@ export class EmbeddedMpvFrameCopyAdapter implements NativeEmbeddedMpvAddon {
|
||||
session.snapshot.error = `The embedded MPV helper exited unexpectedly (${
|
||||
signal ?? code ?? 'unknown'
|
||||
}).`;
|
||||
session.snapshot.errorOrigin = 'engine';
|
||||
});
|
||||
|
||||
return sessionId;
|
||||
}
|
||||
|
||||
loadPlayback(sessionId: string, playback: ResolvedPortalPlayback): void {
|
||||
const session = this.sessions.get(sessionId);
|
||||
if (session && !session.disposed) {
|
||||
if (
|
||||
session.child.exitCode !== null ||
|
||||
session.child.signalCode !== null ||
|
||||
!session.child.stdin.writable
|
||||
) {
|
||||
// A dead helper (exit code, signal death, or a closed stdin)
|
||||
// cannot take a load: `send()` would drop the command
|
||||
// silently and a reconnect would wait forever on a
|
||||
// `loading` that nothing can ever advance.
|
||||
throw new EmbeddedMpvSessionGoneError(
|
||||
sessionId,
|
||||
'the frame-copy helper process has exited'
|
||||
);
|
||||
}
|
||||
// The native addons flip to `loading` synchronously; mirror that
|
||||
// so the reconnect coordinator always observes loading → loss
|
||||
// for a failed attempt, even when the helper folds START_FILE
|
||||
// and the END_FILE error into one snapshot.
|
||||
const snapshot = {
|
||||
...session.snapshot,
|
||||
status: 'loading' as const,
|
||||
};
|
||||
delete snapshot.error;
|
||||
delete snapshot.errorOrigin;
|
||||
session.snapshot = snapshot;
|
||||
}
|
||||
this.send(sessionId, buildLoadPlaybackCommand(playback));
|
||||
}
|
||||
|
||||
@@ -197,10 +237,7 @@ export class EmbeddedMpvFrameCopyAdapter implements NativeEmbeddedMpvAddon {
|
||||
}
|
||||
|
||||
addSubtitle(sessionId: string, filePath: string): void {
|
||||
this.send(
|
||||
sessionId,
|
||||
`sub-add\tpath=${encodeProtocolValue(filePath)}`
|
||||
);
|
||||
this.send(sessionId, `sub-add\tpath=${encodeProtocolValue(filePath)}`);
|
||||
}
|
||||
|
||||
setSubtitleDelay(sessionId: string, seconds: number): void {
|
||||
@@ -281,7 +318,11 @@ export class EmbeddedMpvFrameCopyAdapter implements NativeEmbeddedMpvAddon {
|
||||
`Embedded MPV frame-copy session "${sessionId}" was not found.`
|
||||
);
|
||||
}
|
||||
if (session.child.exitCode !== null || !session.child.stdin.writable) {
|
||||
if (
|
||||
session.child.exitCode !== null ||
|
||||
session.child.signalCode !== null ||
|
||||
!session.child.stdin.writable
|
||||
) {
|
||||
return;
|
||||
}
|
||||
session.child.stdin.write(`${line}\n`);
|
||||
|
||||
@@ -0,0 +1,711 @@
|
||||
/**
|
||||
* Reconnect and session-option behaviour of EmbeddedMpvNativeService. Kept
|
||||
* apart from embedded-mpv-native.service.spec.ts, which is close to the
|
||||
* 1200-line spec limit; the mocks mirror that file.
|
||||
*/
|
||||
import type {
|
||||
EmbeddedMpvBounds,
|
||||
EmbeddedMpvSession,
|
||||
EmbeddedMpvSessionStatus,
|
||||
ResolvedPortalPlayback,
|
||||
} from '@iptvnator/shared/interfaces';
|
||||
import {
|
||||
existsSync,
|
||||
mkdirSync,
|
||||
mkdtempSync,
|
||||
readFileSync,
|
||||
rmSync,
|
||||
statSync,
|
||||
writeFileSync,
|
||||
} from 'fs';
|
||||
import { tmpdir } from 'os';
|
||||
import path from 'path';
|
||||
import type { EmbeddedMpvNativeService as EmbeddedMpvNativeServiceType } from './embedded-mpv-native.service';
|
||||
|
||||
const mockSpawnSync = jest.fn();
|
||||
const mockIsFrameCopyRuntimeUsable = jest.fn<boolean, []>();
|
||||
const mockGetFrameCopyRuntimeAvailability = jest.fn();
|
||||
|
||||
jest.mock('child_process', () => ({
|
||||
spawnSync: mockSpawnSync,
|
||||
}));
|
||||
|
||||
const recordingTrackerMock = {
|
||||
onRecordingStarted: jest.fn(),
|
||||
onRecordingStopped: jest.fn(),
|
||||
onRecordingInterrupted: jest.fn(),
|
||||
observeSnapshot: jest.fn(),
|
||||
};
|
||||
jest.mock('./embedded-mpv-recording-tracker', () => ({
|
||||
embeddedMpvRecordingTracker: recordingTrackerMock,
|
||||
}));
|
||||
|
||||
jest.mock('./embedded-mpv-frame-copy-platform.util', () => {
|
||||
const actual = jest.requireActual(
|
||||
'./embedded-mpv-frame-copy-platform.util'
|
||||
);
|
||||
return {
|
||||
...actual,
|
||||
getFrameCopyRuntimeAvailability: mockGetFrameCopyRuntimeAvailability,
|
||||
isFrameCopyRuntimeUsable: mockIsFrameCopyRuntimeUsable,
|
||||
};
|
||||
});
|
||||
|
||||
const powerSaveBlockerMock = {
|
||||
start: jest.fn<number, [string]>(() => 1),
|
||||
stop: jest.fn<void, [number]>(),
|
||||
isStarted: jest.fn<boolean, [number]>(() => true),
|
||||
};
|
||||
let userDataDir = '';
|
||||
const appMock = {
|
||||
isPackaged: true,
|
||||
getAppPath: () => '/mock/app.asar',
|
||||
getPath: () => userDataDir,
|
||||
commandLine: { getSwitchValue: jest.fn<string, [string]>(() => '') },
|
||||
};
|
||||
|
||||
jest.mock('electron', () => ({
|
||||
app: appMock,
|
||||
powerSaveBlocker: powerSaveBlockerMock,
|
||||
screen: { getDisplayMatching: jest.fn(() => ({ scaleFactor: 1 })) },
|
||||
}));
|
||||
|
||||
const mainWindowSendMock = jest.fn();
|
||||
const mainWindowMock = {
|
||||
isDestroyed: () => false,
|
||||
getNativeWindowHandle: () => Buffer.alloc(8),
|
||||
getBounds: () => ({ x: 0, y: 0, width: 1280, height: 720 }),
|
||||
webContents: {
|
||||
send: mainWindowSendMock,
|
||||
on: jest.fn(),
|
||||
getZoomFactor: () => 1,
|
||||
},
|
||||
};
|
||||
|
||||
jest.mock('../app', () => ({
|
||||
__esModule: true,
|
||||
default: {
|
||||
get mainWindow() {
|
||||
return mainWindowMock;
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
interface MockSnapshot {
|
||||
status: EmbeddedMpvSessionStatus;
|
||||
positionSeconds: number;
|
||||
durationSeconds: number | null;
|
||||
volume: number;
|
||||
streamUrl: string;
|
||||
recording?: { active: boolean; targetPath?: string };
|
||||
error?: string;
|
||||
errorOrigin?: 'playback' | 'engine';
|
||||
rejectedOptionKeys?: string[];
|
||||
}
|
||||
|
||||
const BOUNDS: EmbeddedMpvBounds = { x: 0, y: 0, width: 100, height: 100 };
|
||||
const LIVE: ResolvedPortalPlayback = {
|
||||
streamUrl: 'http://host/live.ts',
|
||||
title: 'Live channel',
|
||||
isLive: true,
|
||||
};
|
||||
const VOD: ResolvedPortalPlayback = {
|
||||
streamUrl: 'http://host/movie.mkv',
|
||||
title: 'Movie',
|
||||
isLive: false,
|
||||
startTime: 30,
|
||||
contentInfo: {
|
||||
playlistId: 'playlist-1',
|
||||
contentXtreamId: 7,
|
||||
contentType: 'vod',
|
||||
},
|
||||
};
|
||||
|
||||
describe('EmbeddedMpvNativeService reconnect', () => {
|
||||
let EmbeddedMpvNativeService: typeof EmbeddedMpvNativeServiceType;
|
||||
let service: EmbeddedMpvNativeServiceType;
|
||||
let addon: {
|
||||
isSupported: jest.Mock<boolean, []>;
|
||||
createSession: jest.Mock<string, unknown[]>;
|
||||
loadPlayback: jest.Mock<void, [string, ResolvedPortalPlayback]>;
|
||||
getSessionSnapshot: jest.Mock<MockSnapshot | null, [string]>;
|
||||
disposeSession: jest.Mock<void, [string]>;
|
||||
setBounds: jest.Mock;
|
||||
setPaused: jest.Mock;
|
||||
seek: jest.Mock;
|
||||
setVolume: jest.Mock;
|
||||
setAudioTrack: jest.Mock;
|
||||
startRecording: jest.Mock;
|
||||
stopRecording: jest.Mock;
|
||||
setSubtitleTrack: jest.Mock;
|
||||
addSubtitle: jest.Mock;
|
||||
};
|
||||
let status: EmbeddedMpvSessionStatus;
|
||||
let positionSeconds: number;
|
||||
let recordingActive: boolean;
|
||||
let errorOrigin: 'playback' | 'engine' | undefined;
|
||||
let rejectedOptionKeys: string[] | undefined;
|
||||
let originalPlatform: NodeJS.Platform;
|
||||
let originalArch: string;
|
||||
let originalExperiment: string | undefined;
|
||||
|
||||
beforeEach(async () => {
|
||||
jest.resetModules();
|
||||
jest.useFakeTimers();
|
||||
jest.setSystemTime(new Date('2026-09-03T10:00:00Z'));
|
||||
mainWindowSendMock.mockReset();
|
||||
mockSpawnSync.mockReset();
|
||||
mockSpawnSync.mockReturnValue({ status: 0 });
|
||||
mockIsFrameCopyRuntimeUsable.mockReset();
|
||||
mockIsFrameCopyRuntimeUsable.mockReturnValue(false);
|
||||
mockGetFrameCopyRuntimeAvailability.mockReset();
|
||||
mockGetFrameCopyRuntimeAvailability.mockReturnValue({
|
||||
usable: false,
|
||||
reason: 'helper-probe-failed',
|
||||
});
|
||||
originalPlatform = process.platform;
|
||||
originalArch = process.arch;
|
||||
Object.defineProperty(process, 'platform', { value: 'darwin' });
|
||||
Object.defineProperty(process, 'arch', { value: 'arm64' });
|
||||
originalExperiment =
|
||||
process.env.IPTVNATOR_ENABLE_EMBEDDED_MPV_EXPERIMENT;
|
||||
process.env.IPTVNATOR_ENABLE_EMBEDDED_MPV_EXPERIMENT = '1';
|
||||
userDataDir = mkdtempSync(
|
||||
path.join(tmpdir(), 'iptvnator-mpv-options-')
|
||||
);
|
||||
|
||||
({ EmbeddedMpvNativeService } =
|
||||
await import('./embedded-mpv-native.service'));
|
||||
service = new EmbeddedMpvNativeService();
|
||||
status = 'idle';
|
||||
positionSeconds = 0;
|
||||
recordingActive = false;
|
||||
errorOrigin = undefined;
|
||||
rejectedOptionKeys = undefined;
|
||||
recordingTrackerMock.onRecordingStarted.mockReset();
|
||||
recordingTrackerMock.onRecordingStopped.mockReset();
|
||||
recordingTrackerMock.onRecordingInterrupted.mockReset();
|
||||
recordingTrackerMock.observeSnapshot.mockReset();
|
||||
addon = {
|
||||
isSupported: jest.fn<boolean, []>(() => true),
|
||||
createSession: jest.fn<string, unknown[]>(() => 'session-1'),
|
||||
loadPlayback: jest.fn(),
|
||||
getSessionSnapshot: jest.fn<MockSnapshot | null, [string]>(() => ({
|
||||
status,
|
||||
positionSeconds,
|
||||
durationSeconds: null,
|
||||
volume: 1,
|
||||
streamUrl: LIVE.streamUrl,
|
||||
recording: { active: recordingActive },
|
||||
...(errorOrigin ? { errorOrigin } : {}),
|
||||
...(rejectedOptionKeys ? { rejectedOptionKeys } : {}),
|
||||
})),
|
||||
disposeSession: jest.fn(),
|
||||
setBounds: jest.fn(),
|
||||
setPaused: jest.fn(),
|
||||
seek: jest.fn(),
|
||||
setVolume: jest.fn(),
|
||||
setAudioTrack: jest.fn(),
|
||||
startRecording: jest.fn(),
|
||||
stopRecording: jest.fn(),
|
||||
setSubtitleTrack: jest.fn(),
|
||||
addSubtitle: jest.fn(),
|
||||
};
|
||||
(service as unknown as { addon: typeof addon }).addon = addon;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
service.shutdown();
|
||||
jest.useRealTimers();
|
||||
rmSync(userDataDir, { recursive: true, force: true });
|
||||
Object.defineProperty(process, 'platform', { value: originalPlatform });
|
||||
Object.defineProperty(process, 'arch', { value: originalArch });
|
||||
if (originalExperiment === undefined) {
|
||||
delete process.env.IPTVNATOR_ENABLE_EMBEDDED_MPV_EXPERIMENT;
|
||||
} else {
|
||||
process.env.IPTVNATOR_ENABLE_EMBEDDED_MPV_EXPERIMENT =
|
||||
originalExperiment;
|
||||
}
|
||||
});
|
||||
|
||||
/** Lets the 500 ms poll observe the addon status once. */
|
||||
const poll = (next: EmbeddedMpvSessionStatus) => {
|
||||
status = next;
|
||||
jest.advanceTimersByTime(500);
|
||||
};
|
||||
|
||||
const lastUpdate = (): EmbeddedMpvSession | undefined =>
|
||||
mainWindowSendMock.mock.calls.at(-1)?.[1] as
|
||||
EmbeddedMpvSession | undefined;
|
||||
|
||||
const startPlayingLive = (autoReconnect = true) => {
|
||||
service.createSession(BOUNDS, 'Title', 0.5, {
|
||||
extraOptions: ['network-timeout=10'],
|
||||
autoReconnect,
|
||||
});
|
||||
service.loadPlayback('session-1', LIVE);
|
||||
poll('loading');
|
||||
poll('playing');
|
||||
expect(addon.loadPlayback).toHaveBeenCalledTimes(1);
|
||||
};
|
||||
|
||||
it('hands the session options to the addon', () => {
|
||||
service.createSession(BOUNDS, 'Title', 0.5, {
|
||||
extraOptions: ['network-timeout=10', 'hwdec=no'],
|
||||
autoReconnect: true,
|
||||
});
|
||||
|
||||
expect(addon.createSession).toHaveBeenCalledWith(
|
||||
expect.any(Buffer),
|
||||
expect.objectContaining({ width: 100, height: 100 }),
|
||||
'Title',
|
||||
0.5,
|
||||
['network-timeout=10', 'hwdec=no']
|
||||
);
|
||||
});
|
||||
|
||||
it('hands Linux native-view its options through a user-only include file', () => {
|
||||
Object.defineProperty(process, 'platform', { value: 'linux' });
|
||||
Object.defineProperty(process, 'arch', { value: 'x64' });
|
||||
|
||||
service.createSession(BOUNDS, 'Title', 0.5, {
|
||||
extraOptions: [
|
||||
'network-timeout=10',
|
||||
'http-header-fields=X-Key: secret',
|
||||
],
|
||||
autoReconnect: true,
|
||||
});
|
||||
|
||||
const addonOptions = addon.createSession.mock.calls[0][4] as string[];
|
||||
expect(addonOptions).toHaveLength(1);
|
||||
expect(addonOptions[0]).toMatch(/^include=.*session-options-.*\.conf$/);
|
||||
const file = addonOptions[0].slice('include='.length);
|
||||
expect(path.dirname(file)).toBe(
|
||||
path.join(userDataDir, 'embedded-mpv', `options-${process.pid}`)
|
||||
);
|
||||
expect(readFileSync(file, 'utf8')).toBe(
|
||||
'network-timeout=10\nhttp-header-fields=X-Key: secret\n'
|
||||
);
|
||||
expect(statSync(file).mode & 0o777).toBe(0o600);
|
||||
|
||||
service.disposeSession('session-1');
|
||||
expect(existsSync(file)).toBe(false);
|
||||
});
|
||||
|
||||
it('sweeps only option directories whose owning process is gone', () => {
|
||||
Object.defineProperty(process, 'platform', { value: 'linux' });
|
||||
Object.defineProperty(process, 'arch', { value: 'x64' });
|
||||
const root = path.join(userDataDir, 'embedded-mpv');
|
||||
const staleDir = path.join(root, 'options-2147483646');
|
||||
const liveDir = path.join(root, `options-${process.ppid}`);
|
||||
mkdirSync(staleDir, { recursive: true });
|
||||
mkdirSync(liveDir, { recursive: true });
|
||||
writeFileSync(path.join(staleDir, 'session-options-old.conf'), 'x=1\n');
|
||||
writeFileSync(path.join(liveDir, 'session-options-live.conf'), 'y=1\n');
|
||||
|
||||
service.createSession(BOUNDS, 'Title', 0.5, {
|
||||
extraOptions: ['network-timeout=10'],
|
||||
autoReconnect: true,
|
||||
});
|
||||
|
||||
expect(existsSync(staleDir)).toBe(false);
|
||||
expect(
|
||||
existsSync(path.join(liveDir, 'session-options-live.conf'))
|
||||
).toBe(true);
|
||||
const ownDir = path.join(root, `options-${process.pid}`);
|
||||
expect(existsSync(ownDir)).toBe(true);
|
||||
|
||||
service.shutdown();
|
||||
expect(existsSync(ownDir)).toBe(false);
|
||||
});
|
||||
|
||||
it('restarts a recording that was running when the stream dropped', () => {
|
||||
startPlayingLive();
|
||||
service.startRecording('session-1', { title: 'Live channel' });
|
||||
expect(addon.startRecording).toHaveBeenCalledTimes(1);
|
||||
const firstTarget = addon.startRecording.mock.calls[0][1] as string;
|
||||
recordingActive = true;
|
||||
poll('playing');
|
||||
|
||||
recordingActive = false;
|
||||
poll('error');
|
||||
expect(lastUpdate()?.reconnect?.attempt).toBe(1);
|
||||
expect(
|
||||
recordingTrackerMock.onRecordingInterrupted
|
||||
).not.toHaveBeenCalled();
|
||||
|
||||
status = 'loading';
|
||||
jest.advanceTimersByTime(2_000);
|
||||
expect(
|
||||
recordingTrackerMock.onRecordingInterrupted
|
||||
).toHaveBeenCalledWith('session-1');
|
||||
poll('playing');
|
||||
// The restart is deferred with a 0 ms timer scheduled from inside
|
||||
// the poll tick, which the fake clock parks one millisecond out.
|
||||
jest.advanceTimersByTime(1);
|
||||
|
||||
expect(addon.startRecording).toHaveBeenCalledTimes(2);
|
||||
const secondTarget = addon.startRecording.mock.calls[1][1] as string;
|
||||
expect(secondTarget).not.toBe(firstTarget);
|
||||
expect(path.dirname(secondTarget)).toBe(path.dirname(firstTarget));
|
||||
expect(recordingTrackerMock.onRecordingStarted).toHaveBeenCalledTimes(
|
||||
2
|
||||
);
|
||||
});
|
||||
|
||||
it('sees a recording that became active only in the loss snapshot', () => {
|
||||
startPlayingLive();
|
||||
// Recording starts asynchronously: the immediate refresh after
|
||||
// startRecording() still reports it inactive.
|
||||
service.startRecording('session-1', { title: 'Live channel' });
|
||||
expect(lastUpdate()?.recording?.active).toBeFalsy();
|
||||
|
||||
// The next poll carries the activated recording and the drop together.
|
||||
recordingActive = true;
|
||||
poll('error');
|
||||
expect(lastUpdate()?.reconnect?.attempt).toBe(1);
|
||||
|
||||
status = 'loading';
|
||||
recordingActive = false;
|
||||
jest.advanceTimersByTime(2_000);
|
||||
expect(
|
||||
recordingTrackerMock.onRecordingInterrupted
|
||||
).toHaveBeenCalledWith('session-1');
|
||||
poll('playing');
|
||||
jest.advanceTimersByTime(1);
|
||||
|
||||
expect(addon.startRecording).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('captures a recording acknowledged one poll after the drop', () => {
|
||||
startPlayingLive();
|
||||
service.startRecording('session-1', { title: 'Live channel' });
|
||||
|
||||
// First loss poll: the drop arrives before the recording is
|
||||
// acknowledged; the next poll still reports the error, now with
|
||||
// the recording active.
|
||||
poll('error');
|
||||
expect(lastUpdate()?.reconnect?.attempt).toBe(1);
|
||||
recordingActive = true;
|
||||
poll('error');
|
||||
|
||||
status = 'loading';
|
||||
recordingActive = false;
|
||||
jest.advanceTimersByTime(2_000);
|
||||
expect(
|
||||
recordingTrackerMock.onRecordingInterrupted
|
||||
).toHaveBeenCalledWith('session-1');
|
||||
poll('playing');
|
||||
jest.advanceTimersByTime(1);
|
||||
|
||||
expect(addon.startRecording).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('warns once, by key only, when libmpv rejected a session option', () => {
|
||||
const warn = jest
|
||||
.spyOn(console, 'warn')
|
||||
.mockImplementation(() => undefined);
|
||||
try {
|
||||
rejectedOptionKeys = ['http-header-fields', 'no-such-option'];
|
||||
startPlayingLive();
|
||||
poll('playing');
|
||||
poll('playing');
|
||||
|
||||
const rejectedWarnings = warn.mock.calls.filter((call) =>
|
||||
String(call[0]).includes('rejected extra option')
|
||||
);
|
||||
expect(rejectedWarnings).toHaveLength(1);
|
||||
expect(rejectedWarnings[0][0]).toContain(
|
||||
'http-header-fields, no-such-option'
|
||||
);
|
||||
expect(rejectedWarnings[0][0]).toContain('session-1');
|
||||
} finally {
|
||||
warn.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
describe('external subtitles', () => {
|
||||
let subtitleFile: string;
|
||||
|
||||
beforeEach(() => {
|
||||
subtitleFile = path.join(userDataDir, 'movie.srt');
|
||||
writeFileSync(
|
||||
subtitleFile,
|
||||
'1\n00:00:01,000 --> 00:00:02,000\nHi\n'
|
||||
);
|
||||
});
|
||||
|
||||
function dropAndReload(): void {
|
||||
poll('error');
|
||||
expect(lastUpdate()?.reconnect?.attempt).toBe(1);
|
||||
status = 'loading';
|
||||
jest.advanceTimersByTime(2_000);
|
||||
poll('playing');
|
||||
jest.advanceTimersByTime(1);
|
||||
}
|
||||
|
||||
it('re-adds the external subtitle file once the automatic reload plays', () => {
|
||||
startPlayingLive();
|
||||
service.addSubtitle('session-1', subtitleFile);
|
||||
poll('playing');
|
||||
|
||||
dropAndReload();
|
||||
|
||||
expect(addon.addSubtitle).toHaveBeenCalledTimes(2);
|
||||
expect(addon.addSubtitle).toHaveBeenLastCalledWith(
|
||||
'session-1',
|
||||
subtitleFile
|
||||
);
|
||||
});
|
||||
|
||||
it('does not re-select a file the user had switched away from', () => {
|
||||
startPlayingLive();
|
||||
service.addSubtitle('session-1', subtitleFile);
|
||||
service.setSubtitleTrack('session-1', 1);
|
||||
poll('playing');
|
||||
|
||||
dropAndReload();
|
||||
|
||||
expect(addon.addSubtitle).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('forgets the file after a user-driven load', () => {
|
||||
startPlayingLive();
|
||||
service.addSubtitle('session-1', subtitleFile);
|
||||
service.loadPlayback('session-1', LIVE);
|
||||
poll('playing');
|
||||
|
||||
dropAndReload();
|
||||
|
||||
expect(addon.addSubtitle).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
it('files the interruption once even when the first reload attempt fails', () => {
|
||||
startPlayingLive();
|
||||
service.startRecording('session-1', { title: 'Live channel' });
|
||||
recordingActive = true;
|
||||
poll('playing');
|
||||
|
||||
recordingActive = false;
|
||||
poll('error');
|
||||
status = 'loading';
|
||||
jest.advanceTimersByTime(2_000);
|
||||
poll('error');
|
||||
jest.advanceTimersByTime(4_000);
|
||||
|
||||
expect(addon.loadPlayback).toHaveBeenCalledTimes(3);
|
||||
expect(
|
||||
recordingTrackerMock.onRecordingInterrupted
|
||||
).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('leaves a recording alone when the stream recovers before the reload fires', () => {
|
||||
startPlayingLive();
|
||||
service.startRecording('session-1', { title: 'Live channel' });
|
||||
recordingActive = true;
|
||||
poll('playing');
|
||||
|
||||
poll('error');
|
||||
expect(lastUpdate()?.reconnect?.attempt).toBe(1);
|
||||
// ffmpeg's own reconnect brings the stream back within the backoff;
|
||||
// mpv never stopped recording.
|
||||
poll('playing');
|
||||
jest.advanceTimersByTime(60_000);
|
||||
|
||||
expect(addon.loadPlayback).toHaveBeenCalledTimes(1);
|
||||
expect(addon.startRecording).toHaveBeenCalledTimes(1);
|
||||
expect(
|
||||
recordingTrackerMock.onRecordingInterrupted
|
||||
).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not restart a recording the user had stopped or replaced', () => {
|
||||
startPlayingLive();
|
||||
service.startRecording('session-1', { title: 'Live channel' });
|
||||
recordingActive = true;
|
||||
poll('playing');
|
||||
service.stopRecording('session-1');
|
||||
recordingActive = false;
|
||||
poll('playing');
|
||||
|
||||
poll('error');
|
||||
status = 'loading';
|
||||
jest.advanceTimersByTime(2_000);
|
||||
poll('playing');
|
||||
// The restart is deferred with a 0 ms timer scheduled from inside
|
||||
// the poll tick, which the fake clock parks one millisecond out.
|
||||
jest.advanceTimersByTime(1);
|
||||
|
||||
expect(addon.startRecording).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('sends an empty option list when no options were captured', () => {
|
||||
service.createSession(BOUNDS);
|
||||
|
||||
expect(addon.createSession.mock.calls[0][4]).toEqual([]);
|
||||
});
|
||||
|
||||
it('reloads a dropped live stream, tells the renderer, and clears once it plays again', () => {
|
||||
startPlayingLive();
|
||||
|
||||
poll('error');
|
||||
|
||||
expect(lastUpdate()?.status).toBe('error');
|
||||
expect(lastUpdate()?.reconnect).toEqual({
|
||||
attempt: 1,
|
||||
maxAttempts: 6,
|
||||
nextAttemptAt: new Date(Date.now() + 2_000).toISOString(),
|
||||
});
|
||||
expect(addon.loadPlayback).toHaveBeenCalledTimes(1);
|
||||
|
||||
status = 'loading';
|
||||
jest.advanceTimersByTime(2_000);
|
||||
|
||||
expect(addon.loadPlayback).toHaveBeenCalledTimes(2);
|
||||
expect(addon.loadPlayback).toHaveBeenLastCalledWith('session-1', LIVE);
|
||||
// keep-open leaves mpv paused after EOF; the reload must clear it.
|
||||
expect(addon.setPaused).toHaveBeenCalledWith('session-1', false);
|
||||
expect(lastUpdate()?.status).toBe('loading');
|
||||
expect(lastUpdate()?.reconnect?.attempt).toBe(1);
|
||||
|
||||
poll('playing');
|
||||
|
||||
expect(lastUpdate()?.status).toBe('playing');
|
||||
expect(lastUpdate()?.reconnect).toBeUndefined();
|
||||
});
|
||||
|
||||
it('reloads a dropped movie from the position it reached', () => {
|
||||
service.createSession(BOUNDS, 'Title', 0.5, {
|
||||
extraOptions: [],
|
||||
autoReconnect: true,
|
||||
});
|
||||
service.loadPlayback('session-1', VOD);
|
||||
poll('loading');
|
||||
positionSeconds = 640;
|
||||
poll('playing');
|
||||
poll('playing');
|
||||
|
||||
poll('error');
|
||||
status = 'loading';
|
||||
jest.advanceTimersByTime(2_000);
|
||||
|
||||
expect(addon.loadPlayback).toHaveBeenLastCalledWith('session-1', {
|
||||
...VOD,
|
||||
startTime: 640,
|
||||
});
|
||||
});
|
||||
|
||||
it('treats EOF on a live stream as a drop', () => {
|
||||
startPlayingLive();
|
||||
|
||||
poll('ended');
|
||||
expect(lastUpdate()?.reconnect?.attempt).toBe(1);
|
||||
|
||||
status = 'loading';
|
||||
jest.advanceTimersByTime(2_000);
|
||||
expect(addon.loadPlayback).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('leaves an error the engine attributes to itself alone', () => {
|
||||
startPlayingLive();
|
||||
|
||||
errorOrigin = 'engine';
|
||||
poll('error');
|
||||
jest.advanceTimersByTime(120_000);
|
||||
|
||||
expect(lastUpdate()?.status).toBe('error');
|
||||
expect(lastUpdate()?.reconnect).toBeUndefined();
|
||||
expect(addon.loadPlayback).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('stays quiet when the setting is off', () => {
|
||||
startPlayingLive(false);
|
||||
|
||||
poll('error');
|
||||
jest.advanceTimersByTime(120_000);
|
||||
|
||||
expect(lastUpdate()?.reconnect).toBeUndefined();
|
||||
expect(addon.loadPlayback).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('drops the pending reload when the user loads something else', () => {
|
||||
startPlayingLive();
|
||||
poll('error');
|
||||
|
||||
const next: ResolvedPortalPlayback = {
|
||||
...LIVE,
|
||||
streamUrl: 'http://host/other.ts',
|
||||
};
|
||||
status = 'loading';
|
||||
service.loadPlayback('session-1', next);
|
||||
jest.advanceTimersByTime(60_000);
|
||||
|
||||
expect(addon.loadPlayback).toHaveBeenCalledTimes(2);
|
||||
expect(addon.loadPlayback).toHaveBeenLastCalledWith('session-1', next);
|
||||
});
|
||||
|
||||
it('drops the pending reload when the user pauses from the error state', () => {
|
||||
startPlayingLive();
|
||||
poll('error');
|
||||
expect(lastUpdate()?.reconnect?.attempt).toBe(1);
|
||||
|
||||
service.setPaused('session-1', true);
|
||||
expect(addon.setPaused).toHaveBeenCalledWith('session-1', true);
|
||||
expect(lastUpdate()?.reconnect).toBeUndefined();
|
||||
jest.advanceTimersByTime(60_000);
|
||||
|
||||
expect(addon.loadPlayback).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('drops the pending reload when the session is disposed', () => {
|
||||
startPlayingLive();
|
||||
poll('error');
|
||||
|
||||
service.disposeSession('session-1');
|
||||
jest.advanceTimersByTime(60_000);
|
||||
|
||||
expect(addon.loadPlayback).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('still counts the attempt when only the unpause fails', () => {
|
||||
startPlayingLive();
|
||||
poll('error');
|
||||
addon.setPaused.mockImplementationOnce(() => {
|
||||
throw new Error('socket not ready');
|
||||
});
|
||||
const warn = jest
|
||||
.spyOn(console, 'warn')
|
||||
.mockImplementation(() => undefined);
|
||||
|
||||
try {
|
||||
status = 'loading';
|
||||
jest.advanceTimersByTime(2_000);
|
||||
|
||||
expect(addon.loadPlayback).toHaveBeenCalledTimes(2);
|
||||
expect(lastUpdate()?.reconnect?.attempt).toBe(1);
|
||||
poll('playing');
|
||||
expect(lastUpdate()?.reconnect).toBeUndefined();
|
||||
} finally {
|
||||
warn.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it('keeps the main process alive when a reload attempt throws', () => {
|
||||
startPlayingLive();
|
||||
poll('error');
|
||||
addon.loadPlayback.mockImplementationOnce(() => {
|
||||
throw new Error('addon exploded');
|
||||
});
|
||||
const warn = jest
|
||||
.spyOn(console, 'warn')
|
||||
.mockImplementation(() => undefined);
|
||||
|
||||
try {
|
||||
expect(() => jest.advanceTimersByTime(2_000)).not.toThrow();
|
||||
expect(lastUpdate()?.reconnect?.attempt).toBe(2);
|
||||
} finally {
|
||||
warn.mockRestore();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -444,7 +444,8 @@ describe('EmbeddedMpvNativeService power blocker', () => {
|
||||
Buffer.alloc(0),
|
||||
BOUNDS,
|
||||
'',
|
||||
1
|
||||
1,
|
||||
[]
|
||||
);
|
||||
|
||||
// Dispose while the frame-copy env is still set so teardown
|
||||
@@ -478,7 +479,8 @@ describe('EmbeddedMpvNativeService power blocker', () => {
|
||||
expect.any(Buffer),
|
||||
physicalBounds,
|
||||
'',
|
||||
1
|
||||
1,
|
||||
[]
|
||||
);
|
||||
expect(addon.setBounds).toHaveBeenCalledWith(
|
||||
's-scaled',
|
||||
@@ -495,13 +497,18 @@ describe('EmbeddedMpvNativeService power blocker', () => {
|
||||
addon.createSession.mockReturnValueOnce('s-zoom');
|
||||
addon.getSessionSnapshot.mockReturnValue(snapshot('loading'));
|
||||
|
||||
service.createSession({ x: 0, y: 0, width: 100, height: 100 }, '', 1);
|
||||
service.createSession(
|
||||
{ x: 0, y: 0, width: 100, height: 100 },
|
||||
'',
|
||||
1
|
||||
);
|
||||
|
||||
expect(addon.createSession).toHaveBeenCalledWith(
|
||||
expect.any(Buffer),
|
||||
{ x: 0, y: 0, width: 125, height: 125 },
|
||||
'',
|
||||
1
|
||||
1,
|
||||
[]
|
||||
);
|
||||
});
|
||||
|
||||
@@ -532,7 +539,8 @@ describe('EmbeddedMpvNativeService power blocker', () => {
|
||||
Buffer.alloc(0),
|
||||
BOUNDS,
|
||||
'',
|
||||
1
|
||||
1,
|
||||
[]
|
||||
);
|
||||
expect(frameCopyAddon.setBounds).toHaveBeenCalledWith(
|
||||
's-fc-bounds',
|
||||
|
||||
@@ -30,8 +30,23 @@ import {
|
||||
clampSubtitleDelay,
|
||||
normalizeSubtitleStyle,
|
||||
} from '@iptvnator/shared/interfaces';
|
||||
import { isExternalPlayerTraceEnabled, trace } from './debug-trace';
|
||||
import { toNativeViewBounds } from './embedded-mpv-bounds.util';
|
||||
import { embeddedMpvRecordingTracker } from './embedded-mpv-recording-tracker';
|
||||
import {
|
||||
createEmbeddedMpvReconnectState,
|
||||
EmbeddedMpvReconnectCoordinator,
|
||||
EmbeddedMpvReconnectState,
|
||||
} from './embedded-mpv-reconnect';
|
||||
import type { EmbeddedMpvSessionOptions } from './embedded-mpv-session-options';
|
||||
import {
|
||||
removeSessionOptionsDirectory,
|
||||
removeSessionOptionsFile,
|
||||
resolveSessionOptionsDirectory,
|
||||
resolveSessionOptionsRoot,
|
||||
sweepSessionOptionsFiles,
|
||||
writeSessionOptionsFile,
|
||||
} from './embedded-mpv-session-options-file';
|
||||
import { EmbeddedMpvFrameCopyAdapter } from './embedded-mpv-frame-copy.adapter';
|
||||
import {
|
||||
getFrameCopyRuntimeAvailability,
|
||||
@@ -61,6 +76,17 @@ export interface NativeEmbeddedMpvSessionSnapshot {
|
||||
videoHeight?: number;
|
||||
recording?: EmbeddedMpvRecordingState;
|
||||
error?: string;
|
||||
/**
|
||||
* `engine` when the error is the engine's own failure (a render context
|
||||
* or a fatal libmpv log) rather than the stream's; absent means the
|
||||
* stream failed and a reload can help.
|
||||
*/
|
||||
errorOrigin?: 'playback' | 'engine';
|
||||
/**
|
||||
* Session option keys libmpv refused at creation (macOS/Windows
|
||||
* native-view). Names only; the service warns once per session.
|
||||
*/
|
||||
rejectedOptionKeys?: string[];
|
||||
}
|
||||
|
||||
export interface NativeEmbeddedMpvAddon {
|
||||
@@ -69,7 +95,9 @@ export interface NativeEmbeddedMpvAddon {
|
||||
windowHandle: Buffer,
|
||||
bounds: EmbeddedMpvBounds,
|
||||
title?: string,
|
||||
initialVolume?: number
|
||||
initialVolume?: number,
|
||||
/** `key=value` libmpv options applied after the engine's built-ins. */
|
||||
extraOptions?: string[]
|
||||
): string;
|
||||
loadPlayback(sessionId: string, playback: ResolvedPortalPlayback): void;
|
||||
setBounds(sessionId: string, bounds: EmbeddedMpvBounds): void;
|
||||
@@ -105,6 +133,27 @@ interface EmbeddedMpvRuntimeSession {
|
||||
updatedAt: string;
|
||||
lastPayloadKey: string;
|
||||
lastStatus: EmbeddedMpvSessionStatus | null;
|
||||
reconnect: EmbeddedMpvReconnectState;
|
||||
/** Linux native-view only: the `--include` file carrying the options. */
|
||||
optionsFile: string | null;
|
||||
/** What `startRecording` was last asked for, until an explicit stop. */
|
||||
lastRecordingStart: EmbeddedMpvRecordingStartOptions | null;
|
||||
/** Whether the previous refresh reported an active recording. */
|
||||
lastRecordingActive: boolean;
|
||||
/** A recording was running when the outage began. */
|
||||
recordingRunningAtLoss: boolean;
|
||||
/** The reload replaced the recorded stream; restart the recording once it plays. */
|
||||
restartRecordingAfterReconnect: boolean;
|
||||
/** The rejected-options warning was logged for this session. */
|
||||
rejectedOptionsReported: boolean;
|
||||
/**
|
||||
* External subtitle file added through `addSubtitle` and still the
|
||||
* track that add selected: an explicit track pick or a user-driven load
|
||||
* hands selection back to the user and clears it.
|
||||
*/
|
||||
externalSubtitlePath: string | null;
|
||||
/** The automatic reload dropped the external subtitle; re-add it once the reload plays. */
|
||||
restoreSubtitleAfterReconnect: boolean;
|
||||
}
|
||||
|
||||
const EMBEDDED_MPV_FRAME_COPY_ENV = 'IPTVNATOR_ENABLE_EMBEDDED_MPV_FRAME_COPY';
|
||||
@@ -124,6 +173,59 @@ export class EmbeddedMpvNativeService {
|
||||
private readonly loadAddonModule = createRequire(__filename);
|
||||
private cachedLinuxMpvExecutableReason: string | null | undefined;
|
||||
private frameCopyAdapter: EmbeddedMpvFrameCopyAdapter | null = null;
|
||||
private sessionOptionsDirectory: string | null = null;
|
||||
/**
|
||||
* Reloads a dropped stream (see embedded-mpv-reconnect.ts). `publish`
|
||||
* runs from a timer, outside the polling try/catch, so it must swallow
|
||||
* addon failures itself instead of surfacing an uncaughtException.
|
||||
*/
|
||||
private readonly reconnect = new EmbeddedMpvReconnectCoordinator({
|
||||
reload: (sessionId, playback) => {
|
||||
const addon = this.getAddon();
|
||||
const session = this.sessions.get(sessionId);
|
||||
if (session?.recordingRunningAtLoss && session.lastRecordingStart) {
|
||||
// This reload replaces the recorded stream: the file mpv was
|
||||
// writing is over (file it as interrupted, not completed),
|
||||
// and the recording is started again once the reload plays.
|
||||
// A stream that recovers by itself before this fires keeps
|
||||
// its recording running and never reaches this branch.
|
||||
embeddedMpvRecordingTracker.onRecordingInterrupted(sessionId);
|
||||
session.restartRecordingAfterReconnect = true;
|
||||
// Consumed: a failed attempt's next reload must not re-arm
|
||||
// the tracker's flush window and postpone the verdict.
|
||||
session.recordingRunningAtLoss = false;
|
||||
}
|
||||
if (session?.externalSubtitlePath) {
|
||||
// The engines drop external subtitle tracks with the file
|
||||
// they were added to (START_FILE clears them); re-add the
|
||||
// user's file once the reload plays.
|
||||
session.restoreSubtitleAfterReconnect = true;
|
||||
}
|
||||
addon.loadPlayback(sessionId, playback);
|
||||
// Sessions run with keep-open=yes, so EOF on a live stream leaves
|
||||
// mpv paused at the end of the old file and a plain loadfile
|
||||
// inherits that pause: the reload then buffers, reports
|
||||
// `paused` and never plays. Unpausing is part of the reload.
|
||||
try {
|
||||
addon.setPaused(sessionId, false);
|
||||
} catch (error) {
|
||||
console.warn(
|
||||
`[Embedded MPV][reconnect] session ${sessionId}: could not clear the keep-open pause after the reload:`,
|
||||
error
|
||||
);
|
||||
}
|
||||
},
|
||||
publish: (sessionId) => {
|
||||
try {
|
||||
this.refreshSession(sessionId);
|
||||
} catch (error) {
|
||||
console.error(
|
||||
`[Embedded MPV][reconnect] Refreshing session "${sessionId}" failed:`,
|
||||
error
|
||||
);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* Frame-copy engine: helper process + shm ring + renderer canvas.
|
||||
@@ -459,7 +561,8 @@ export class EmbeddedMpvNativeService {
|
||||
createSession(
|
||||
bounds: EmbeddedMpvBounds,
|
||||
title = '',
|
||||
initialVolume = 1
|
||||
initialVolume = 1,
|
||||
options?: EmbeddedMpvSessionOptions
|
||||
): EmbeddedMpvSession {
|
||||
this.assertEmbeddedMpvEnabled();
|
||||
const addon = this.getAddon();
|
||||
@@ -475,12 +578,25 @@ export class EmbeddedMpvNativeService {
|
||||
? Buffer.alloc(0)
|
||||
: this.getMainWindowHandle();
|
||||
const startedAt = new Date().toISOString();
|
||||
const sessionId = addon.createSession(
|
||||
windowHandle,
|
||||
usesFrameCopyAddon ? bounds : this.scaleBoundsForNativeView(bounds),
|
||||
title,
|
||||
initialVolume
|
||||
const { addonOptions, optionsFile } = this.prepareSessionOptions(
|
||||
options?.extraOptions ?? [],
|
||||
usesFrameCopyAddon
|
||||
);
|
||||
let sessionId: string;
|
||||
try {
|
||||
sessionId = addon.createSession(
|
||||
windowHandle,
|
||||
usesFrameCopyAddon
|
||||
? bounds
|
||||
: this.scaleBoundsForNativeView(bounds),
|
||||
title,
|
||||
initialVolume,
|
||||
addonOptions
|
||||
);
|
||||
} catch (error) {
|
||||
removeSessionOptionsFile(optionsFile);
|
||||
throw error;
|
||||
}
|
||||
|
||||
this.sessions.set(sessionId, {
|
||||
id: sessionId,
|
||||
@@ -490,6 +606,17 @@ export class EmbeddedMpvNativeService {
|
||||
updatedAt: startedAt,
|
||||
lastPayloadKey: '',
|
||||
lastStatus: null,
|
||||
reconnect: createEmbeddedMpvReconnectState(
|
||||
options?.autoReconnect ?? true
|
||||
),
|
||||
optionsFile,
|
||||
lastRecordingStart: null,
|
||||
lastRecordingActive: false,
|
||||
recordingRunningAtLoss: false,
|
||||
restartRecordingAfterReconnect: false,
|
||||
rejectedOptionsReported: false,
|
||||
externalSubtitlePath: null,
|
||||
restoreSubtitleAfterReconnect: false,
|
||||
});
|
||||
|
||||
this.ensurePolling();
|
||||
@@ -523,6 +650,15 @@ export class EmbeddedMpvNativeService {
|
||||
session.title = playback.title ?? session.title;
|
||||
session.streamUrl = playback.streamUrl ?? session.streamUrl;
|
||||
session.updatedAt = new Date().toISOString();
|
||||
this.reconnect.onUserLoad(session.reconnect, playback);
|
||||
// A user-driven replacement stops an active recording by design
|
||||
// (see "Live Stream Recording"); only an automatic reload restarts it.
|
||||
session.lastRecordingStart = null;
|
||||
session.recordingRunningAtLoss = false;
|
||||
session.restartRecordingAfterReconnect = false;
|
||||
// External subtitles are source-scoped: a user-driven load drops them.
|
||||
session.externalSubtitlePath = null;
|
||||
session.restoreSubtitleAfterReconnect = false;
|
||||
addon.loadPlayback(sessionId, playback);
|
||||
this.refreshSession(sessionId);
|
||||
}
|
||||
@@ -540,6 +676,15 @@ export class EmbeddedMpvNativeService {
|
||||
|
||||
setPaused(sessionId: string, paused: boolean): EmbeddedMpvSession | null {
|
||||
this.assertEmbeddedMpvEnabled();
|
||||
if (paused) {
|
||||
// A pause sent while the session sits in a loss state never
|
||||
// surfaces as a `paused` status, so the command itself has to
|
||||
// call off a scheduled reload (see embedded-mpv-reconnect.ts).
|
||||
const session = this.sessions.get(sessionId);
|
||||
if (session) {
|
||||
this.reconnect.onUserPause(session.reconnect);
|
||||
}
|
||||
}
|
||||
this.getAddon().setPaused(sessionId, paused);
|
||||
return this.refreshSession(sessionId);
|
||||
}
|
||||
@@ -602,10 +747,20 @@ export class EmbeddedMpvNativeService {
|
||||
);
|
||||
}
|
||||
addon.setSubtitleTrack(sessionId, trackId);
|
||||
// An explicit pick after an external file was added hands the
|
||||
// selection back to the user: nothing is re-selected for them later.
|
||||
const session = this.sessions.get(sessionId);
|
||||
if (session) {
|
||||
session.externalSubtitlePath = null;
|
||||
session.restoreSubtitleAfterReconnect = false;
|
||||
}
|
||||
return this.refreshSession(sessionId);
|
||||
}
|
||||
|
||||
addSubtitle(sessionId: string, filePath: string): EmbeddedMpvSession | null {
|
||||
addSubtitle(
|
||||
sessionId: string,
|
||||
filePath: string
|
||||
): EmbeddedMpvSession | null {
|
||||
this.assertEmbeddedMpvEnabled();
|
||||
const addon = this.getAddon();
|
||||
if (typeof addon.addSubtitle !== 'function') {
|
||||
@@ -618,6 +773,10 @@ export class EmbeddedMpvNativeService {
|
||||
throw new Error('The selected subtitle file was not found.');
|
||||
}
|
||||
addon.addSubtitle(sessionId, normalized);
|
||||
const session = this.sessions.get(sessionId);
|
||||
if (session) {
|
||||
session.externalSubtitlePath = normalized;
|
||||
}
|
||||
return this.refreshSession(sessionId);
|
||||
}
|
||||
|
||||
@@ -738,6 +897,7 @@ export class EmbeddedMpvNativeService {
|
||||
fallbackChannelName,
|
||||
metadata: options.metadata,
|
||||
});
|
||||
session.lastRecordingStart = { ...options };
|
||||
return this.refreshSession(sessionId);
|
||||
}
|
||||
|
||||
@@ -751,9 +911,39 @@ export class EmbeddedMpvNativeService {
|
||||
}
|
||||
addon.stopRecording(sessionId);
|
||||
embeddedMpvRecordingTracker.onRecordingStopped(sessionId);
|
||||
const session = this.sessions.get(sessionId);
|
||||
if (session) {
|
||||
session.lastRecordingStart = null;
|
||||
session.recordingRunningAtLoss = false;
|
||||
session.restartRecordingAfterReconnect = false;
|
||||
}
|
||||
return this.refreshSession(sessionId);
|
||||
}
|
||||
|
||||
/**
|
||||
* An automatic reload replaces the stream, which stops mpv's
|
||||
* `stream-record` and lets the tracker finalize that file as an
|
||||
* interrupted partial. Once the reload plays, the recording the user had
|
||||
* running is started again into a fresh file with the same folder,
|
||||
* title and metadata, so a transient outage costs seconds, not the rest
|
||||
* of the programme. Deferred out of `refreshSession`, which must not
|
||||
* re-enter itself.
|
||||
*/
|
||||
private restartRecordingAfterReconnect(sessionId: string): void {
|
||||
const session = this.sessions.get(sessionId);
|
||||
if (!session?.lastRecordingStart) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
this.startRecording(sessionId, session.lastRecordingStart);
|
||||
} catch (error) {
|
||||
console.warn(
|
||||
`[Embedded MPV][reconnect] session ${sessionId}: could not restart the recording after the reload:`,
|
||||
error
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
getDefaultRecordingFolder(): string {
|
||||
return app.getPath('downloads');
|
||||
}
|
||||
@@ -772,11 +962,49 @@ export class EmbeddedMpvNativeService {
|
||||
return result.filePaths[0];
|
||||
}
|
||||
|
||||
/**
|
||||
* How the session options reach the engine. In-process engines take the
|
||||
* list directly; the frame-copy adapter sends it over the helper's
|
||||
* stdin; Linux native-view runs a separate `mpv --wid` process whose
|
||||
* argv is world-readable, so there the list goes into a user-only
|
||||
* config file referenced with `--include`.
|
||||
*/
|
||||
private prepareSessionOptions(
|
||||
extraOptions: string[],
|
||||
usesFrameCopyAddon: boolean
|
||||
): { addonOptions: string[]; optionsFile: string | null } {
|
||||
if (
|
||||
process.platform !== 'linux' ||
|
||||
usesFrameCopyAddon ||
|
||||
extraOptions.length === 0
|
||||
) {
|
||||
return { addonOptions: extraOptions, optionsFile: null };
|
||||
}
|
||||
if (this.sessionOptionsDirectory === null) {
|
||||
const root = resolveSessionOptionsRoot(app.getPath('userData'));
|
||||
sweepSessionOptionsFiles(root);
|
||||
this.sessionOptionsDirectory = resolveSessionOptionsDirectory(root);
|
||||
}
|
||||
const optionsFile = writeSessionOptionsFile(
|
||||
this.sessionOptionsDirectory,
|
||||
extraOptions
|
||||
);
|
||||
return { addonOptions: [`include=${optionsFile}`], optionsFile };
|
||||
}
|
||||
|
||||
disposeSession(sessionId: string): EmbeddedMpvSession | null {
|
||||
const session = this.sessions.get(sessionId);
|
||||
if (!session) {
|
||||
return null;
|
||||
}
|
||||
this.reconnect.cancel(session.reconnect);
|
||||
session.lastRecordingStart = null;
|
||||
session.recordingRunningAtLoss = false;
|
||||
session.restartRecordingAfterReconnect = false;
|
||||
session.externalSubtitlePath = null;
|
||||
session.restoreSubtitleAfterReconnect = false;
|
||||
removeSessionOptionsFile(session.optionsFile);
|
||||
session.optionsFile = null;
|
||||
|
||||
let lastRecording: EmbeddedMpvRecordingState | undefined;
|
||||
try {
|
||||
@@ -821,6 +1049,8 @@ export class EmbeddedMpvNativeService {
|
||||
shutdown(): void {
|
||||
const sessionIds = [...this.sessions.keys()];
|
||||
sessionIds.forEach((sessionId) => this.disposeSession(sessionId));
|
||||
removeSessionOptionsDirectory(this.sessionOptionsDirectory);
|
||||
this.sessionOptionsDirectory = null;
|
||||
if (this.pollingTimer) {
|
||||
clearInterval(this.pollingTimer);
|
||||
this.pollingTimer = null;
|
||||
@@ -911,6 +1141,62 @@ export class EmbeddedMpvNativeService {
|
||||
this.pollingTimer = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-adds the external subtitle file an automatic reload dropped. The
|
||||
* helper's `sub-add` selects the added track, which restores what the
|
||||
* user saw before the drop; `sub-delay` is an mpv-global property and
|
||||
* survives the reload on its own.
|
||||
*/
|
||||
private restoreSubtitleAfterReconnect(sessionId: string): void {
|
||||
const session = this.sessions.get(sessionId);
|
||||
const filePath = session?.externalSubtitlePath;
|
||||
if (!session || !filePath) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const addon = this.getAddon();
|
||||
if (typeof addon.addSubtitle !== 'function') {
|
||||
return;
|
||||
}
|
||||
if (!existsSync(filePath)) {
|
||||
session.externalSubtitlePath = null;
|
||||
console.warn(
|
||||
`[Embedded MPV][reconnect] session ${sessionId}: the external subtitle file is gone; not restoring it after the reload`
|
||||
);
|
||||
return;
|
||||
}
|
||||
addon.addSubtitle(sessionId, filePath);
|
||||
} catch (error) {
|
||||
console.warn(
|
||||
`[Embedded MPV][reconnect] session ${sessionId}: could not restore the external subtitle after the reload:`,
|
||||
error
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A syntactically valid option libmpv still refused (unknown name, bad
|
||||
* value) is otherwise invisible outside a trace run; log the key once so
|
||||
* the user learns the line did not apply. Keys only — a value may carry
|
||||
* credentials (`http-header-fields`, proxies).
|
||||
*/
|
||||
private reportRejectedOptions(
|
||||
session: EmbeddedMpvRuntimeSession,
|
||||
snapshot: NativeEmbeddedMpvSessionSnapshot
|
||||
): void {
|
||||
if (
|
||||
session.rejectedOptionsReported ||
|
||||
!Array.isArray(snapshot.rejectedOptionKeys) ||
|
||||
snapshot.rejectedOptionKeys.length === 0
|
||||
) {
|
||||
return;
|
||||
}
|
||||
session.rejectedOptionsReported = true;
|
||||
console.warn(
|
||||
`[Embedded MPV] session ${session.id}: libmpv rejected extra option(s) ${snapshot.rejectedOptionKeys.join(', ')} (Settings > Playback > Embedded MPV)`
|
||||
);
|
||||
}
|
||||
|
||||
private refreshSession(sessionId: string): EmbeddedMpvSession | null {
|
||||
const addon = this.getAddon();
|
||||
const session = this.sessions.get(sessionId);
|
||||
@@ -922,6 +1208,7 @@ export class EmbeddedMpvNativeService {
|
||||
if (!snapshot) {
|
||||
return null;
|
||||
}
|
||||
this.reportRejectedOptions(session, snapshot);
|
||||
|
||||
const payload: EmbeddedMpvSession = {
|
||||
id: session.id,
|
||||
@@ -969,9 +1256,78 @@ export class EmbeddedMpvNativeService {
|
||||
...(snapshot.error ? { error: snapshot.error } : {}),
|
||||
};
|
||||
|
||||
const previousStatus = session.lastStatus;
|
||||
session.streamUrl = payload.streamUrl;
|
||||
session.updatedAt = payload.updatedAt;
|
||||
session.lastStatus = payload.status;
|
||||
if (
|
||||
previousStatus !== payload.status &&
|
||||
isExternalPlayerTraceEnabled()
|
||||
) {
|
||||
// Status transitions are the whole input of the reconnect policy;
|
||||
// tracing them (never the URL) is what makes a "why did it not
|
||||
// reconnect" report answerable.
|
||||
trace(
|
||||
'embedded-mpv',
|
||||
`session ${sessionId} status ${previousStatus ?? 'none'} -> ${payload.status}`,
|
||||
payload.error ? { error: payload.error } : undefined
|
||||
);
|
||||
}
|
||||
const reconnect = this.reconnect.observe(
|
||||
sessionId,
|
||||
session.reconnect,
|
||||
payload.status,
|
||||
previousStatus,
|
||||
payload.positionSeconds,
|
||||
snapshot.errorOrigin
|
||||
);
|
||||
if (reconnect) {
|
||||
payload.reconnect = reconnect;
|
||||
}
|
||||
if (
|
||||
reconnect &&
|
||||
session.lastRecordingStart &&
|
||||
!session.restartRecordingAfterReconnect &&
|
||||
(payload.recording?.active === true ||
|
||||
(session.lastRecordingActive &&
|
||||
(previousStatus === 'playing' ||
|
||||
previousStatus === 'paused')))
|
||||
) {
|
||||
// The outage began while a recording was running. Whether that
|
||||
// recording is over is decided by the reload hook: only an
|
||||
// actual reload replaces the stream and stops it. Recording
|
||||
// starts asynchronously on Windows native-view and frame-copy,
|
||||
// so a recording started shortly before the drop is acknowledged
|
||||
// as active only in a later poll of the pending outage — accept
|
||||
// it whenever the engine reports it, not just on the transition.
|
||||
session.recordingRunningAtLoss = true;
|
||||
} else if (
|
||||
reconnect === null &&
|
||||
payload.status === 'playing' &&
|
||||
previousStatus !== 'playing'
|
||||
) {
|
||||
// Back to playing: either our reload succeeded (restart the
|
||||
// recording it stopped) or the stream recovered on its own
|
||||
// (the recording never stopped; nothing to do).
|
||||
const restart = session.restartRecordingAfterReconnect;
|
||||
session.recordingRunningAtLoss = false;
|
||||
session.restartRecordingAfterReconnect = false;
|
||||
if (restart) {
|
||||
setTimeout(
|
||||
() => this.restartRecordingAfterReconnect(sessionId),
|
||||
0
|
||||
);
|
||||
}
|
||||
const restoreSubtitle = session.restoreSubtitleAfterReconnect;
|
||||
session.restoreSubtitleAfterReconnect = false;
|
||||
if (restoreSubtitle) {
|
||||
setTimeout(
|
||||
() => this.restoreSubtitleAfterReconnect(sessionId),
|
||||
0
|
||||
);
|
||||
}
|
||||
}
|
||||
session.lastRecordingActive = payload.recording?.active === true;
|
||||
// The refresh timestamp must not participate in the diff key,
|
||||
// otherwise every poll tick looks like a change and the renderer
|
||||
// receives an IPC update every 500 ms even while paused.
|
||||
|
||||
@@ -0,0 +1,414 @@
|
||||
import type { ResolvedPortalPlayback } from '@iptvnator/shared/interfaces';
|
||||
import { EmbeddedMpvSessionGoneError } from './embedded-mpv-session-errors';
|
||||
import {
|
||||
createEmbeddedMpvReconnectState,
|
||||
EMBEDDED_MPV_RECONNECT_MAX_ATTEMPTS,
|
||||
EMBEDDED_MPV_RECONNECT_STABLE_PLAYBACK_MS,
|
||||
EmbeddedMpvReconnectCoordinator,
|
||||
EmbeddedMpvReconnectState,
|
||||
isLiveEmbeddedMpvPlayback,
|
||||
resolveEmbeddedMpvReconnectDelayMs,
|
||||
} from './embedded-mpv-reconnect';
|
||||
|
||||
const LIVE: ResolvedPortalPlayback = {
|
||||
streamUrl: 'http://host/live.ts',
|
||||
title: 'Live channel',
|
||||
isLive: true,
|
||||
};
|
||||
const VOD: ResolvedPortalPlayback = {
|
||||
streamUrl: 'http://host/movie.mkv',
|
||||
title: 'Movie',
|
||||
isLive: false,
|
||||
contentInfo: {
|
||||
playlistId: 'playlist-1',
|
||||
contentXtreamId: 7,
|
||||
contentType: 'vod',
|
||||
},
|
||||
};
|
||||
const SESSION = 'session-1';
|
||||
|
||||
describe('embedded MPV reconnect policy helpers', () => {
|
||||
it('backs off 2 s → 30 s and caps there', () => {
|
||||
expect(
|
||||
[0, 1, 2, 3, 4, 5].map(resolveEmbeddedMpvReconnectDelayMs)
|
||||
).toEqual([2_000, 4_000, 8_000, 16_000, 30_000, 30_000]);
|
||||
});
|
||||
|
||||
it('treats an explicit flag, else missing content info, as live', () => {
|
||||
expect(isLiveEmbeddedMpvPlayback(LIVE)).toBe(true);
|
||||
expect(isLiveEmbeddedMpvPlayback(VOD)).toBe(false);
|
||||
expect(
|
||||
isLiveEmbeddedMpvPlayback({ streamUrl: 'http://h/x', title: 'x' })
|
||||
).toBe(true);
|
||||
expect(isLiveEmbeddedMpvPlayback({ ...VOD, isLive: undefined })).toBe(
|
||||
false
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('EmbeddedMpvReconnectCoordinator', () => {
|
||||
let reload: jest.Mock<void, [string, ResolvedPortalPlayback]>;
|
||||
let publish: jest.Mock<void, [string]>;
|
||||
let log: { log: jest.Mock; warn: jest.Mock };
|
||||
let coordinator: EmbeddedMpvReconnectCoordinator;
|
||||
let state: EmbeddedMpvReconnectState;
|
||||
|
||||
beforeEach(() => {
|
||||
jest.useFakeTimers();
|
||||
jest.setSystemTime(new Date('2026-09-03T10:00:00Z'));
|
||||
reload = jest.fn();
|
||||
publish = jest.fn();
|
||||
log = { log: jest.fn(), warn: jest.fn() };
|
||||
coordinator = new EmbeddedMpvReconnectCoordinator({
|
||||
reload,
|
||||
publish,
|
||||
log,
|
||||
});
|
||||
state = createEmbeddedMpvReconnectState(true);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
coordinator.cancel(state);
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
/** A user load that reached `playing`. */
|
||||
const startPlaying = (playback: ResolvedPortalPlayback = LIVE) => {
|
||||
coordinator.onUserLoad(state, playback);
|
||||
expect(coordinator.observe(SESSION, state, 'loading', null)).toBeNull();
|
||||
expect(
|
||||
coordinator.observe(SESSION, state, 'playing', 'loading')
|
||||
).toBeNull();
|
||||
};
|
||||
|
||||
it('never retries a load that has not played yet', () => {
|
||||
coordinator.onUserLoad(state, LIVE);
|
||||
coordinator.observe(SESSION, state, 'loading', null);
|
||||
|
||||
expect(
|
||||
coordinator.observe(SESSION, state, 'error', 'loading')
|
||||
).toBeNull();
|
||||
jest.advanceTimersByTime(120_000);
|
||||
|
||||
expect(reload).not.toHaveBeenCalled();
|
||||
expect(state.timer).toBeNull();
|
||||
});
|
||||
|
||||
it('reloads the last playback after a drop and reports the attempt', () => {
|
||||
startPlaying();
|
||||
|
||||
const info = coordinator.observe(SESSION, state, 'error', 'playing');
|
||||
|
||||
expect(info).toEqual({
|
||||
attempt: 1,
|
||||
maxAttempts: EMBEDDED_MPV_RECONNECT_MAX_ATTEMPTS,
|
||||
nextAttemptAt: new Date(Date.now() + 2_000).toISOString(),
|
||||
});
|
||||
expect(reload).not.toHaveBeenCalled();
|
||||
// The poll sees the same error again: no second schedule.
|
||||
expect(coordinator.observe(SESSION, state, 'error', 'error')).toBe(
|
||||
info
|
||||
);
|
||||
|
||||
jest.advanceTimersByTime(1_999);
|
||||
expect(reload).not.toHaveBeenCalled();
|
||||
jest.advanceTimersByTime(1);
|
||||
expect(reload).toHaveBeenCalledWith(SESSION, LIVE);
|
||||
expect(publish).toHaveBeenCalledWith(SESSION);
|
||||
// Still pending while the attempt is loading.
|
||||
expect(coordinator.observe(SESSION, state, 'loading', 'error')).toEqual(
|
||||
info
|
||||
);
|
||||
});
|
||||
|
||||
it('doubles the delay per failed attempt and gives up after the sixth', () => {
|
||||
startPlaying();
|
||||
const delays: number[] = [];
|
||||
let previous: 'playing' | 'loading' = 'playing';
|
||||
|
||||
for (
|
||||
let attempt = 1;
|
||||
attempt <= EMBEDDED_MPV_RECONNECT_MAX_ATTEMPTS;
|
||||
attempt++
|
||||
) {
|
||||
const before = Date.now();
|
||||
const info = coordinator.observe(SESSION, state, 'error', previous);
|
||||
expect(info?.attempt).toBe(attempt);
|
||||
delays.push(Date.parse(info?.nextAttemptAt ?? '') - before);
|
||||
jest.advanceTimersByTime(delays[delays.length - 1]);
|
||||
expect(reload).toHaveBeenCalledTimes(attempt);
|
||||
coordinator.observe(SESSION, state, 'loading', 'error');
|
||||
previous = 'loading';
|
||||
}
|
||||
|
||||
expect(delays).toEqual([2_000, 4_000, 8_000, 16_000, 30_000, 30_000]);
|
||||
expect(
|
||||
coordinator.observe(SESSION, state, 'error', 'loading')
|
||||
).toBeNull();
|
||||
jest.advanceTimersByTime(60_000);
|
||||
expect(reload).toHaveBeenCalledTimes(
|
||||
EMBEDDED_MPV_RECONNECT_MAX_ATTEMPTS
|
||||
);
|
||||
expect(log.warn).toHaveBeenCalledWith(
|
||||
expect.stringContaining('giving up')
|
||||
);
|
||||
});
|
||||
|
||||
it('spends the budget across short-lived recoveries and resets it after stable playback', () => {
|
||||
startPlaying();
|
||||
coordinator.observe(SESSION, state, 'error', 'playing');
|
||||
jest.advanceTimersByTime(2_000);
|
||||
coordinator.observe(SESSION, state, 'loading', 'error');
|
||||
coordinator.observe(SESSION, state, 'playing', 'loading');
|
||||
|
||||
// Drops again after 10 s of playing: still the same outage.
|
||||
jest.advanceTimersByTime(10_000);
|
||||
coordinator.observe(SESSION, state, 'playing', 'playing');
|
||||
expect(
|
||||
coordinator.observe(SESSION, state, 'error', 'playing')?.attempt
|
||||
).toBe(2);
|
||||
jest.advanceTimersByTime(4_000);
|
||||
coordinator.observe(SESSION, state, 'loading', 'error');
|
||||
coordinator.observe(SESSION, state, 'playing', 'loading');
|
||||
|
||||
// Plays for the stable window: the next drop starts a fresh outage.
|
||||
jest.advanceTimersByTime(EMBEDDED_MPV_RECONNECT_STABLE_PLAYBACK_MS);
|
||||
coordinator.observe(SESSION, state, 'playing', 'playing');
|
||||
expect(
|
||||
coordinator.observe(SESSION, state, 'error', 'playing')?.attempt
|
||||
).toBe(1);
|
||||
});
|
||||
|
||||
it('treats EOF as a loss for live playback only', () => {
|
||||
startPlaying(LIVE);
|
||||
expect(
|
||||
coordinator.observe(SESSION, state, 'ended', 'playing')?.attempt
|
||||
).toBe(1);
|
||||
jest.advanceTimersByTime(2_000);
|
||||
expect(reload).toHaveBeenCalledWith(SESSION, LIVE);
|
||||
|
||||
reload.mockClear();
|
||||
state = createEmbeddedMpvReconnectState(true);
|
||||
startPlaying(VOD);
|
||||
expect(
|
||||
coordinator.observe(SESSION, state, 'ended', 'playing')
|
||||
).toBeNull();
|
||||
jest.advanceTimersByTime(60_000);
|
||||
expect(reload).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('resumes a VOD reload where it dropped but keeps live at the live edge', () => {
|
||||
startPlaying(VOD);
|
||||
coordinator.observe(SESSION, state, 'playing', 'playing', 95);
|
||||
coordinator.observe(SESSION, state, 'playing', 'playing', 120);
|
||||
coordinator.observe(SESSION, state, 'error', 'playing', 0);
|
||||
jest.advanceTimersByTime(2_000);
|
||||
expect(reload).toHaveBeenLastCalledWith(SESSION, {
|
||||
...VOD,
|
||||
startTime: 120,
|
||||
});
|
||||
|
||||
// A seek back to the very start is a valid resume point as well.
|
||||
reload.mockClear();
|
||||
state = createEmbeddedMpvReconnectState(true);
|
||||
startPlaying(VOD);
|
||||
coordinator.observe(SESSION, state, 'playing', 'playing', 120);
|
||||
coordinator.observe(SESSION, state, 'playing', 'playing', 0);
|
||||
coordinator.observe(SESSION, state, 'error', 'playing');
|
||||
jest.advanceTimersByTime(2_000);
|
||||
expect(reload).toHaveBeenLastCalledWith(SESSION, {
|
||||
...VOD,
|
||||
startTime: 0,
|
||||
});
|
||||
|
||||
// The snapshot's placeholder zero before the first time-pos must not
|
||||
// beat the playback's own resume offset.
|
||||
reload.mockClear();
|
||||
state = createEmbeddedMpvReconnectState(true);
|
||||
startPlaying({ ...VOD, startTime: 30 });
|
||||
coordinator.observe(SESSION, state, 'playing', 'playing', 0);
|
||||
coordinator.observe(SESSION, state, 'error', 'playing');
|
||||
jest.advanceTimersByTime(2_000);
|
||||
expect(reload).toHaveBeenLastCalledWith(SESSION, {
|
||||
...VOD,
|
||||
startTime: 30,
|
||||
});
|
||||
|
||||
reload.mockClear();
|
||||
state = createEmbeddedMpvReconnectState(true);
|
||||
startPlaying(LIVE);
|
||||
coordinator.observe(SESSION, state, 'playing', 'playing', 3_600);
|
||||
coordinator.observe(SESSION, state, 'error', 'playing', 3_600);
|
||||
jest.advanceTimersByTime(2_000);
|
||||
expect(reload).toHaveBeenLastCalledWith(SESSION, LIVE);
|
||||
expect(reload.mock.calls[0][1]).not.toHaveProperty('startTime');
|
||||
});
|
||||
|
||||
it('counts a reload that fails before the poll ever sees it loading', () => {
|
||||
startPlaying();
|
||||
coordinator.observe(SESSION, state, 'error', 'playing');
|
||||
jest.advanceTimersByTime(2_000);
|
||||
expect(reload).toHaveBeenCalledTimes(1);
|
||||
|
||||
// The engine failed the reload so fast that the poll still reads
|
||||
// `error` on both sides of the transition.
|
||||
const info = coordinator.observe(SESSION, state, 'error', 'error');
|
||||
|
||||
expect(info?.attempt).toBe(2);
|
||||
jest.advanceTimersByTime(4_000);
|
||||
expect(reload).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('cancels and disarms on an explicit pause command sent from a loss state', () => {
|
||||
startPlaying();
|
||||
expect(
|
||||
coordinator.observe(SESSION, state, 'error', 'playing')?.attempt
|
||||
).toBe(1);
|
||||
|
||||
coordinator.onUserPause(state);
|
||||
|
||||
expect(state.pending).toBeNull();
|
||||
expect(state.timer).toBeNull();
|
||||
expect(
|
||||
coordinator.observe(SESSION, state, 'error', 'error')
|
||||
).toBeNull();
|
||||
jest.advanceTimersByTime(60_000);
|
||||
expect(reload).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('leaves an error the engine attributes to itself alone', () => {
|
||||
startPlaying();
|
||||
|
||||
expect(
|
||||
coordinator.observe(
|
||||
SESSION,
|
||||
state,
|
||||
'error',
|
||||
'playing',
|
||||
42,
|
||||
'engine'
|
||||
)
|
||||
).toBeNull();
|
||||
jest.advanceTimersByTime(60_000);
|
||||
|
||||
expect(reload).not.toHaveBeenCalled();
|
||||
expect(state.pending).toBeNull();
|
||||
});
|
||||
|
||||
it('does not resume a stream the user paused when it drops', () => {
|
||||
startPlaying();
|
||||
expect(
|
||||
coordinator.observe(SESSION, state, 'paused', 'playing')
|
||||
).toBeNull();
|
||||
|
||||
expect(
|
||||
coordinator.observe(SESSION, state, 'error', 'paused')
|
||||
).toBeNull();
|
||||
jest.advanceTimersByTime(60_000);
|
||||
expect(reload).not.toHaveBeenCalled();
|
||||
|
||||
// Playing again re-arms the policy.
|
||||
coordinator.observe(SESSION, state, 'loading', 'error');
|
||||
coordinator.observe(SESSION, state, 'playing', 'loading');
|
||||
expect(
|
||||
coordinator.observe(SESSION, state, 'error', 'playing')?.attempt
|
||||
).toBe(1);
|
||||
});
|
||||
|
||||
it('drops a pending attempt when the stream recovers on its own', () => {
|
||||
startPlaying();
|
||||
coordinator.observe(SESSION, state, 'error', 'playing');
|
||||
|
||||
expect(
|
||||
coordinator.observe(SESSION, state, 'playing', 'error')
|
||||
).toBeNull();
|
||||
jest.advanceTimersByTime(60_000);
|
||||
|
||||
expect(reload).not.toHaveBeenCalled();
|
||||
expect(state.pending).toBeNull();
|
||||
});
|
||||
|
||||
it('is cancelled by a new user load, a pause, and an explicit cancel', () => {
|
||||
startPlaying();
|
||||
coordinator.observe(SESSION, state, 'error', 'playing');
|
||||
coordinator.onUserLoad(state, VOD);
|
||||
expect(state.pending).toBeNull();
|
||||
expect(state.hadPlayed).toBe(false);
|
||||
|
||||
startPlaying();
|
||||
coordinator.observe(SESSION, state, 'error', 'playing');
|
||||
expect(
|
||||
coordinator.observe(SESSION, state, 'paused', 'error')
|
||||
).toBeNull();
|
||||
expect(state.timer).toBeNull();
|
||||
|
||||
coordinator.observe(SESSION, state, 'playing', 'paused');
|
||||
coordinator.observe(SESSION, state, 'error', 'playing');
|
||||
coordinator.cancel(state);
|
||||
jest.advanceTimersByTime(60_000);
|
||||
|
||||
expect(reload).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does nothing when the setting is off', () => {
|
||||
state = createEmbeddedMpvReconnectState(false);
|
||||
startPlaying();
|
||||
|
||||
expect(
|
||||
coordinator.observe(SESSION, state, 'error', 'playing')
|
||||
).toBeNull();
|
||||
jest.advanceTimersByTime(60_000);
|
||||
|
||||
expect(reload).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('gives up at once when the engine behind the session is gone', () => {
|
||||
startPlaying();
|
||||
reload.mockImplementationOnce(() => {
|
||||
throw new EmbeddedMpvSessionGoneError(SESSION, 'helper exited');
|
||||
});
|
||||
coordinator.observe(SESSION, state, 'error', 'playing');
|
||||
|
||||
jest.advanceTimersByTime(2_000);
|
||||
|
||||
expect(reload).toHaveBeenCalledTimes(1);
|
||||
expect(publish).toHaveBeenCalledTimes(1);
|
||||
expect(state.pending).toBeNull();
|
||||
expect(state.timer).toBeNull();
|
||||
expect(
|
||||
coordinator.observe(SESSION, state, 'error', 'error')
|
||||
).toBeNull();
|
||||
expect(log.warn).toHaveBeenCalledWith(
|
||||
expect.stringContaining('engine gone'),
|
||||
expect.any(Error)
|
||||
);
|
||||
|
||||
jest.advanceTimersByTime(120_000);
|
||||
expect(reload).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('continues the backoff when the addon refuses the reload outright', () => {
|
||||
startPlaying();
|
||||
reload.mockImplementationOnce(() => {
|
||||
throw new Error('addon exploded');
|
||||
});
|
||||
coordinator.observe(SESSION, state, 'error', 'playing');
|
||||
|
||||
jest.advanceTimersByTime(2_000);
|
||||
|
||||
expect(reload).toHaveBeenCalledTimes(1);
|
||||
expect(publish).toHaveBeenCalledTimes(1);
|
||||
expect(log.warn).toHaveBeenCalledWith(
|
||||
expect.stringContaining('could not start'),
|
||||
expect.any(Error)
|
||||
);
|
||||
expect(state.pending?.attempt).toBe(2);
|
||||
// No status transition ever arrives for a refused load.
|
||||
expect(
|
||||
coordinator.observe(SESSION, state, 'error', 'error')?.attempt
|
||||
).toBe(2);
|
||||
|
||||
jest.advanceTimersByTime(4_000);
|
||||
expect(reload).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,352 @@
|
||||
import type {
|
||||
EmbeddedMpvReconnectInfo,
|
||||
EmbeddedMpvSessionStatus,
|
||||
ResolvedPortalPlayback,
|
||||
} from '@iptvnator/shared/interfaces';
|
||||
import { isEmbeddedMpvSessionGoneError } from './embedded-mpv-session-errors';
|
||||
|
||||
/**
|
||||
* Automatic reconnect for embedded MPV sessions.
|
||||
*
|
||||
* The native engines report a dropped IPTV connection as an mpv error, or as
|
||||
* EOF when the server simply closed the socket. Either leaves the viewer on a
|
||||
* dead player, so the main process reloads the last user-requested playback
|
||||
* with capped exponential backoff. The policy is deliberately narrow:
|
||||
*
|
||||
* - Only a load that already reached `playing` is retried. A URL that never
|
||||
* worked (404, refused credentials, unsupported container) keeps the manual
|
||||
* Retry; hammering a panel six times with the same bad request helps nobody.
|
||||
* - EOF counts as a loss only for live playback — a broadcast never ends on
|
||||
* its own, a movie does.
|
||||
* - The attempt budget belongs to one outage: it resets after
|
||||
* {@link EMBEDDED_MPV_RECONNECT_STABLE_PLAYBACK_MS} of uninterrupted playing,
|
||||
* not on the first `playing`, so a stream that flaps every few seconds runs
|
||||
* out of attempts instead of being retried forever.
|
||||
* - A user-driven load, a pause, or teardown cancels everything. A pause
|
||||
* also disarms the policy until the stream plays again: a drop while the
|
||||
* user has the stream paused must not resume playback behind their back.
|
||||
* - An attempt is tracked explicitly from the moment its reload is issued,
|
||||
* so a reload that fails before the poll ever sees it `loading` is still
|
||||
* counted as a failed attempt rather than mistaken for the original loss.
|
||||
* - A VOD reload resumes at the last position observed while playing; a live
|
||||
* reload goes back to the live edge.
|
||||
* - An `error` the engine attributes to itself (a macOS render failure, a
|
||||
* fatal libmpv log in the helper) is not a stream loss: reloading media
|
||||
* cannot repair a broken render context, so it stays a terminal error.
|
||||
* - A reload the engine cannot take at all (a crashed frame-copy helper,
|
||||
* reported as `EmbeddedMpvSessionGoneError`) ends the reconnect: only a
|
||||
* new session can recover, and the renderer's Retry creates one.
|
||||
*
|
||||
* Status transitions arrive from `EmbeddedMpvNativeService.refreshSession()`
|
||||
* (polled every 500 ms); every engine flips to `loading` synchronously on a
|
||||
* load, so a failed attempt is always observed as `loading` → loss and never
|
||||
* as loss → loss. The coordinator publishes {@link EmbeddedMpvReconnectInfo}
|
||||
* for the renderer, which displays it and never schedules anything itself.
|
||||
*/
|
||||
|
||||
export const EMBEDDED_MPV_RECONNECT_MAX_ATTEMPTS = 6;
|
||||
export const EMBEDDED_MPV_RECONNECT_BASE_DELAY_MS = 2_000;
|
||||
export const EMBEDDED_MPV_RECONNECT_MAX_DELAY_MS = 30_000;
|
||||
export const EMBEDDED_MPV_RECONNECT_STABLE_PLAYBACK_MS = 30_000;
|
||||
|
||||
export interface EmbeddedMpvReconnectState {
|
||||
/** `Settings.embeddedMpvAutoReconnect`, captured at session creation. */
|
||||
enabled: boolean;
|
||||
/** The last playback the user asked for; what every attempt reloads. */
|
||||
playback: ResolvedPortalPlayback | null;
|
||||
/** Whether that playback reached `playing` at least once. */
|
||||
hadPlayed: boolean;
|
||||
/** Attempts spent on the current outage. */
|
||||
attempts: number;
|
||||
/** Start of the current uninterrupted `playing` stretch. */
|
||||
playingSince: number | null;
|
||||
/** A reload was issued and neither `playing` nor a loss has answered it yet. */
|
||||
attemptInFlight: boolean;
|
||||
/**
|
||||
* Last position reported while the stream played, seeded from the
|
||||
* playback's own `startTime`. Non-live reloads carry it as `startTime`,
|
||||
* otherwise mpv would restart at the playback's original resume offset.
|
||||
*/
|
||||
lastPositionSeconds: number | null;
|
||||
/**
|
||||
* Whether a positive position has been reported for this load. Engine
|
||||
* snapshots start at zero before the first `time-pos` update, so a zero
|
||||
* is trusted as a real seek-to-start only once that has happened.
|
||||
*/
|
||||
positionReported: boolean;
|
||||
timer: NodeJS.Timeout | null;
|
||||
/** What the renderer is shown while an attempt is scheduled or in flight. */
|
||||
pending: EmbeddedMpvReconnectInfo | null;
|
||||
}
|
||||
|
||||
export interface EmbeddedMpvReconnectHooks {
|
||||
/** Issue the reload against the addon; may throw. */
|
||||
reload(sessionId: string, playback: ResolvedPortalPlayback): void;
|
||||
/** Re-read the session so the renderer sees the attempt; must not throw. */
|
||||
publish(sessionId: string): void;
|
||||
now?(): number;
|
||||
log?: Pick<Console, 'log' | 'warn'>;
|
||||
}
|
||||
|
||||
export function createEmbeddedMpvReconnectState(
|
||||
enabled: boolean
|
||||
): EmbeddedMpvReconnectState {
|
||||
return {
|
||||
enabled,
|
||||
playback: null,
|
||||
hadPlayed: false,
|
||||
attempts: 0,
|
||||
playingSince: null,
|
||||
attemptInFlight: false,
|
||||
lastPositionSeconds: null,
|
||||
positionReported: false,
|
||||
timer: null,
|
||||
pending: null,
|
||||
};
|
||||
}
|
||||
|
||||
/** Same rule as the renderer's `isLivePlayback`: explicit flag, else "no content info". */
|
||||
export function isLiveEmbeddedMpvPlayback(
|
||||
playback: ResolvedPortalPlayback
|
||||
): boolean {
|
||||
return typeof playback.isLive === 'boolean'
|
||||
? playback.isLive
|
||||
: !playback.contentInfo;
|
||||
}
|
||||
|
||||
/** 2 s, 4 s, 8 s, 16 s, 30 s, 30 s for attempts 1–6. */
|
||||
export function resolveEmbeddedMpvReconnectDelayMs(
|
||||
completedAttempts: number
|
||||
): number {
|
||||
return Math.min(
|
||||
EMBEDDED_MPV_RECONNECT_BASE_DELAY_MS * 2 ** completedAttempts,
|
||||
EMBEDDED_MPV_RECONNECT_MAX_DELAY_MS
|
||||
);
|
||||
}
|
||||
|
||||
export function isEmbeddedMpvStreamLoss(
|
||||
status: EmbeddedMpvSessionStatus,
|
||||
isLive: boolean
|
||||
): boolean {
|
||||
return status === 'error' || (status === 'ended' && isLive);
|
||||
}
|
||||
|
||||
export class EmbeddedMpvReconnectCoordinator {
|
||||
private readonly now: () => number;
|
||||
private readonly log: Pick<Console, 'log' | 'warn'>;
|
||||
|
||||
constructor(private readonly hooks: EmbeddedMpvReconnectHooks) {
|
||||
this.now = hooks.now ?? (() => Date.now());
|
||||
this.log = hooks.log ?? console;
|
||||
}
|
||||
|
||||
/** The user (or the renderer on their behalf) asked for a playback. */
|
||||
onUserLoad(
|
||||
state: EmbeddedMpvReconnectState,
|
||||
playback: ResolvedPortalPlayback
|
||||
): void {
|
||||
this.clearTimer(state);
|
||||
state.playback = playback;
|
||||
state.hadPlayed = false;
|
||||
state.attempts = 0;
|
||||
state.playingSince = null;
|
||||
state.attemptInFlight = false;
|
||||
state.lastPositionSeconds =
|
||||
typeof playback.startTime === 'number' &&
|
||||
Number.isFinite(playback.startTime) &&
|
||||
playback.startTime >= 0
|
||||
? playback.startTime
|
||||
: null;
|
||||
state.positionReported = false;
|
||||
state.pending = null;
|
||||
}
|
||||
|
||||
cancel(state: EmbeddedMpvReconnectState): void {
|
||||
this.clearTimer(state);
|
||||
state.attemptInFlight = false;
|
||||
state.pending = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The user asked to pause. Observing `paused` cannot cover a pause sent
|
||||
* while the session already sits in a loss state (the engines keep
|
||||
* `error`/`ended` rather than flipping to `paused`), so the command
|
||||
* itself cancels and disarms: a reload would resume playback unasked.
|
||||
*/
|
||||
onUserPause(state: EmbeddedMpvReconnectState): void {
|
||||
this.cancel(state);
|
||||
state.hadPlayed = false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Feed one observed status (and the position that came with it). Returns
|
||||
* what the renderer should be shown (`null` when nothing is pending).
|
||||
*/
|
||||
observe(
|
||||
sessionId: string,
|
||||
state: EmbeddedMpvReconnectState,
|
||||
status: EmbeddedMpvSessionStatus,
|
||||
previousStatus: EmbeddedMpvSessionStatus | null,
|
||||
positionSeconds?: number,
|
||||
errorOrigin?: 'playback' | 'engine'
|
||||
): EmbeddedMpvReconnectInfo | null {
|
||||
const now = this.now();
|
||||
|
||||
if (status === 'error' && errorOrigin === 'engine') {
|
||||
this.cancel(state);
|
||||
return null;
|
||||
}
|
||||
|
||||
if (
|
||||
(status === 'playing' || status === 'paused') &&
|
||||
typeof positionSeconds === 'number' &&
|
||||
Number.isFinite(positionSeconds)
|
||||
) {
|
||||
if (positionSeconds > 0) {
|
||||
state.lastPositionSeconds = positionSeconds;
|
||||
state.positionReported = true;
|
||||
} else if (positionSeconds === 0 && state.positionReported) {
|
||||
// A real seek back to the start. Before any positive
|
||||
// position the zero is only the snapshot's placeholder and
|
||||
// the seeded `startTime` stays the better resume point.
|
||||
state.lastPositionSeconds = 0;
|
||||
}
|
||||
}
|
||||
|
||||
if (status === 'playing') {
|
||||
state.hadPlayed = true;
|
||||
if (state.playingSince === null) {
|
||||
state.playingSince = now;
|
||||
} else if (
|
||||
state.attempts > 0 &&
|
||||
now - state.playingSince >=
|
||||
EMBEDDED_MPV_RECONNECT_STABLE_PLAYBACK_MS
|
||||
) {
|
||||
state.attempts = 0;
|
||||
}
|
||||
// Either our attempt succeeded or the stream recovered on its
|
||||
// own (ffmpeg-level reconnect) while a retry was still pending.
|
||||
this.cancel(state);
|
||||
return null;
|
||||
}
|
||||
|
||||
state.playingSince = null;
|
||||
|
||||
if (status === 'loading') {
|
||||
// A fresh user load, or our own attempt in flight.
|
||||
return state.pending;
|
||||
}
|
||||
|
||||
const isLive =
|
||||
state.playback !== null &&
|
||||
isLiveEmbeddedMpvPlayback(state.playback);
|
||||
if (!isEmbeddedMpvStreamLoss(status, isLive)) {
|
||||
// paused/idle are user actions, closed is teardown, ended on
|
||||
// VOD is the movie finishing — none of them is an outage.
|
||||
if (status === 'paused') {
|
||||
// Disarmed until the stream plays again: reloading a stream
|
||||
// the user paused would resume it without them asking.
|
||||
state.hadPlayed = false;
|
||||
}
|
||||
this.cancel(state);
|
||||
return null;
|
||||
}
|
||||
|
||||
const answersAttempt = state.attemptInFlight;
|
||||
if (
|
||||
(status === previousStatus && !answersAttempt) ||
|
||||
state.timer !== null
|
||||
) {
|
||||
// Same loss observed again by the poll, or already scheduled.
|
||||
return state.pending;
|
||||
}
|
||||
|
||||
state.attemptInFlight = false;
|
||||
return this.schedule(sessionId, state, now);
|
||||
}
|
||||
|
||||
private schedule(
|
||||
sessionId: string,
|
||||
state: EmbeddedMpvReconnectState,
|
||||
now: number
|
||||
): EmbeddedMpvReconnectInfo | null {
|
||||
if (!state.enabled || state.playback === null || !state.hadPlayed) {
|
||||
state.pending = null;
|
||||
return null;
|
||||
}
|
||||
if (state.attempts >= EMBEDDED_MPV_RECONNECT_MAX_ATTEMPTS) {
|
||||
this.log.warn(
|
||||
`[Embedded MPV][reconnect] session ${sessionId}: giving up after ${state.attempts} attempts`
|
||||
);
|
||||
state.pending = null;
|
||||
return null;
|
||||
}
|
||||
|
||||
const delay = resolveEmbeddedMpvReconnectDelayMs(state.attempts);
|
||||
const attempt = state.attempts + 1;
|
||||
const playback = this.playbackForReload(state.playback, state);
|
||||
state.pending = {
|
||||
attempt,
|
||||
maxAttempts: EMBEDDED_MPV_RECONNECT_MAX_ATTEMPTS,
|
||||
nextAttemptAt: new Date(now + delay).toISOString(),
|
||||
};
|
||||
this.log.log(
|
||||
`[Embedded MPV][reconnect] session ${sessionId}: stream lost, attempt ${attempt}/${EMBEDDED_MPV_RECONNECT_MAX_ATTEMPTS} in ${delay} ms`
|
||||
);
|
||||
state.timer = setTimeout(() => {
|
||||
state.timer = null;
|
||||
state.attempts = attempt;
|
||||
state.attemptInFlight = true;
|
||||
try {
|
||||
this.hooks.reload(sessionId, playback);
|
||||
} catch (error) {
|
||||
state.attemptInFlight = false;
|
||||
if (isEmbeddedMpvSessionGoneError(error)) {
|
||||
// Nothing behind the session can take a load any more;
|
||||
// stop so the renderer shows the actionable error
|
||||
// instead of a reconnect spinner that never resolves.
|
||||
this.log.warn(
|
||||
`[Embedded MPV][reconnect] session ${sessionId}: engine gone, giving up`,
|
||||
error
|
||||
);
|
||||
state.pending = null;
|
||||
} else {
|
||||
this.log.warn(
|
||||
`[Embedded MPV][reconnect] session ${sessionId}: attempt ${attempt} could not start`,
|
||||
error
|
||||
);
|
||||
// The addon refused the load outright, so no loading→loss
|
||||
// transition will ever arrive: continue the backoff here.
|
||||
this.schedule(sessionId, state, this.now());
|
||||
}
|
||||
}
|
||||
this.hooks.publish(sessionId);
|
||||
}, delay);
|
||||
return state.pending;
|
||||
}
|
||||
|
||||
/**
|
||||
* Live streams reload at the live edge. Anything else resumes where the
|
||||
* connection dropped: the stored playback still carries the offset the
|
||||
* user originally resumed from, and reloading it would rewind the movie.
|
||||
*/
|
||||
private playbackForReload(
|
||||
playback: ResolvedPortalPlayback,
|
||||
state: EmbeddedMpvReconnectState
|
||||
): ResolvedPortalPlayback {
|
||||
if (
|
||||
isLiveEmbeddedMpvPlayback(playback) ||
|
||||
state.lastPositionSeconds === null
|
||||
) {
|
||||
return playback;
|
||||
}
|
||||
return { ...playback, startTime: state.lastPositionSeconds };
|
||||
}
|
||||
|
||||
private clearTimer(state: EmbeddedMpvReconnectState): void {
|
||||
if (state.timer !== null) {
|
||||
clearTimeout(state.timer);
|
||||
state.timer = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -511,6 +511,65 @@ describe('EmbeddedMpvRecordingTracker', () => {
|
||||
expect(db.updateSet.mock.calls[0][0].status).toBe('completed');
|
||||
});
|
||||
|
||||
it('finalizes a recording as interrupted when the stream drops and a reload replaces it', async () => {
|
||||
started(tracker);
|
||||
tracker.observeSnapshot(activeSnapshot());
|
||||
|
||||
jest.useFakeTimers();
|
||||
try {
|
||||
tracker.onRecordingInterrupted('session-1');
|
||||
tracker.onRecordingStarted({
|
||||
sessionId: 'session-1',
|
||||
targetPath: '/rec/News-20260815-213500.ts',
|
||||
fallbackChannelName: 'Channel One',
|
||||
});
|
||||
await jest.advanceTimersByTimeAsync(1_600);
|
||||
} finally {
|
||||
jest.useRealTimers();
|
||||
}
|
||||
await flush();
|
||||
|
||||
expect(db.updateSet).toHaveBeenCalledTimes(1);
|
||||
expect(db.updateSet.mock.calls[0][0].status).toBe('interrupted');
|
||||
});
|
||||
|
||||
it('keeps the interruption when the engine still shows the old recording active for a tick', async () => {
|
||||
started(tracker);
|
||||
tracker.observeSnapshot(activeSnapshot());
|
||||
|
||||
jest.useFakeTimers();
|
||||
try {
|
||||
tracker.onRecordingInterrupted('session-1');
|
||||
// A clean live EOF can carry the old recording as active once more.
|
||||
tracker.observeSnapshot(activeSnapshot());
|
||||
tracker.observeSnapshot(inactiveSnapshot());
|
||||
await jest.advanceTimersByTimeAsync(2_600);
|
||||
} finally {
|
||||
jest.useRealTimers();
|
||||
}
|
||||
await flush();
|
||||
|
||||
expect(db.updateSet).toHaveBeenCalledTimes(1);
|
||||
expect(db.updateSet.mock.calls[0][0].status).toBe('interrupted');
|
||||
});
|
||||
|
||||
it('finalizes an interrupted recording on its own when no reload follows', async () => {
|
||||
started(tracker);
|
||||
tracker.observeSnapshot(activeSnapshot());
|
||||
|
||||
jest.useFakeTimers();
|
||||
try {
|
||||
tracker.onRecordingInterrupted('session-1');
|
||||
await jest.advanceTimersByTimeAsync(2_600);
|
||||
} finally {
|
||||
jest.useRealTimers();
|
||||
}
|
||||
await flush();
|
||||
|
||||
expect(db.updateSet).toHaveBeenCalledTimes(1);
|
||||
expect(db.updateSet.mock.calls[0][0].status).toBe('interrupted');
|
||||
});
|
||||
|
||||
it('keeps a finalizing row in the ledger until its update commits', async () => {
|
||||
// finalize() removes the entry from the open map immediately, but
|
||||
// the row stays persisted as 'recording' until the queued terminal
|
||||
|
||||
@@ -31,6 +31,8 @@ interface OpenRecordingEntry {
|
||||
stopFallbackTimer?: ReturnType<typeof setTimeout>;
|
||||
/** Armed by an inactive snapshot; finalizes once the stop looks settled. */
|
||||
settleTimer?: ReturnType<typeof setTimeout>;
|
||||
/** The stream dropped under this recording; it must not end as `completed`. */
|
||||
interrupted: boolean;
|
||||
/**
|
||||
* Set by the first finalize. Timers hold the entry itself, so an entry
|
||||
* replaced in the map (stop → immediate restart on the same session) can
|
||||
@@ -86,9 +88,7 @@ const TEARDOWN_FLUSH_WINDOW_MS = 2_500;
|
||||
const FINALIZE_STAT_TIMEOUT_MS = 3_000;
|
||||
|
||||
type FinalizeFileProbe =
|
||||
| { kind: 'size'; size: number }
|
||||
| { kind: 'missing' }
|
||||
| { kind: 'unknown' };
|
||||
{ kind: 'size'; size: number } | { kind: 'missing' } | { kind: 'unknown' };
|
||||
|
||||
/**
|
||||
* Persists the lifecycle of embedded-MPV live recordings into the
|
||||
@@ -129,7 +129,10 @@ export class EmbeddedMpvRecordingTracker {
|
||||
clearTimeout(replaced.settleTimer);
|
||||
}
|
||||
replaced.settleTimer = setTimeout(() => {
|
||||
this.finalize(replaced, 'completed');
|
||||
this.finalize(
|
||||
replaced,
|
||||
replaced.interrupted ? 'interrupted' : 'completed'
|
||||
);
|
||||
}, STOP_SETTLE_WINDOW_MS);
|
||||
}
|
||||
|
||||
@@ -165,6 +168,7 @@ export class EmbeddedMpvRecordingTracker {
|
||||
targetPath: event.targetPath,
|
||||
startedAt,
|
||||
sawActive: false,
|
||||
interrupted: false,
|
||||
stopRequested: false,
|
||||
finalized: false,
|
||||
rowId,
|
||||
@@ -182,6 +186,27 @@ export class EmbeddedMpvRecordingTracker {
|
||||
* statting (or unlinking) the file before mpv flushed it would report a
|
||||
* short recording as failed and could delete bytes still being written.
|
||||
*/
|
||||
/**
|
||||
* The stream carrying this recording dropped and an automatic reload is
|
||||
* about to replace it (see embedded-mpv-reconnect.ts). The file mpv was
|
||||
* writing is over: finalize it as `interrupted`. Without this, a clean
|
||||
* EOF never reaches the error path above and the reload's own
|
||||
* `onRecordingStarted` would file the partial as `completed`.
|
||||
*/
|
||||
onRecordingInterrupted(sessionId: string): void {
|
||||
const entry = this.open.get(sessionId);
|
||||
if (!entry || entry.finalized) {
|
||||
return;
|
||||
}
|
||||
entry.interrupted = true;
|
||||
if (entry.settleTimer !== undefined) {
|
||||
clearTimeout(entry.settleTimer);
|
||||
}
|
||||
entry.settleTimer = setTimeout(() => {
|
||||
this.finalize(entry, 'interrupted');
|
||||
}, TEARDOWN_FLUSH_WINDOW_MS);
|
||||
}
|
||||
|
||||
onRecordingStopped(sessionId: string): void {
|
||||
const entry = this.open.get(sessionId);
|
||||
if (!entry || entry.stopRequested) {
|
||||
@@ -228,7 +253,10 @@ export class EmbeddedMpvRecordingTracker {
|
||||
entry.sawActive = true;
|
||||
// A rejected stop puts the recording back: abandon the pending
|
||||
// finalization instead of ending a row that is still recording.
|
||||
if (entry.settleTimer !== undefined) {
|
||||
// Not after an interruption, though: the engine may still show
|
||||
// the old recording as active for a tick after a clean live EOF,
|
||||
// and the reload replacing it is already on its way.
|
||||
if (entry.settleTimer !== undefined && !entry.interrupted) {
|
||||
clearTimeout(entry.settleTimer);
|
||||
entry.settleTimer = undefined;
|
||||
}
|
||||
@@ -244,7 +272,10 @@ export class EmbeddedMpvRecordingTracker {
|
||||
// Finalize only once it survived the settle window.
|
||||
if (entry.settleTimer === undefined) {
|
||||
entry.settleTimer = setTimeout(() => {
|
||||
this.finalize(entry, 'completed');
|
||||
this.finalize(
|
||||
entry,
|
||||
entry.interrupted ? 'interrupted' : 'completed'
|
||||
);
|
||||
}, STOP_SETTLE_WINDOW_MS);
|
||||
}
|
||||
} else if (recording.error) {
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
/**
|
||||
* Thrown by an engine adapter when a session can no longer take commands
|
||||
* because the process behind it is gone (a crashed frame-copy helper). The
|
||||
* reconnect coordinator treats it as terminal: only a new session can
|
||||
* recover, so retrying the same session would just spin.
|
||||
*/
|
||||
export class EmbeddedMpvSessionGoneError extends Error {
|
||||
constructor(sessionId: string, reason: string) {
|
||||
super(
|
||||
`Embedded MPV session "${sessionId}" cannot be reused: ${reason}.`
|
||||
);
|
||||
this.name = 'EmbeddedMpvSessionGoneError';
|
||||
}
|
||||
}
|
||||
|
||||
export function isEmbeddedMpvSessionGoneError(error: unknown): boolean {
|
||||
return (
|
||||
error instanceof Error && error.name === 'EmbeddedMpvSessionGoneError'
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
import { randomUUID } from 'crypto';
|
||||
import { mkdirSync, readdirSync, rmSync, unlinkSync, writeFileSync } from 'fs';
|
||||
import path from 'path';
|
||||
|
||||
const OPTIONS_ROOT = 'embedded-mpv';
|
||||
const INSTANCE_DIRECTORY_PREFIX = 'options-';
|
||||
const OPTIONS_FILE_PREFIX = 'session-options-';
|
||||
|
||||
/**
|
||||
* Linux native-view runs mpv as a separate process whose command line is
|
||||
* readable by every local user through /proc/<pid>/cmdline, so the session
|
||||
* options (which may carry request headers with credentials) are handed
|
||||
* over as an mpv config file readable by the owner only and referenced with
|
||||
* `--include=<path>`. Files live under a per-instance directory named by the
|
||||
* owning process id: a session removes its file on dispose, the instance
|
||||
* removes its directory on shutdown, and a crashed instance's leftovers are
|
||||
* swept by the next one — but only once its process is gone, because two
|
||||
* instances may share one `userData` (IPTVNATOR_ALLOW_MULTIPLE_INSTANCES=1).
|
||||
*/
|
||||
export function resolveSessionOptionsRoot(userDataPath: string): string {
|
||||
return path.join(userDataPath, OPTIONS_ROOT);
|
||||
}
|
||||
|
||||
export function resolveSessionOptionsDirectory(
|
||||
root: string,
|
||||
pid: number = process.pid
|
||||
): string {
|
||||
return path.join(root, `${INSTANCE_DIRECTORY_PREFIX}${pid}`);
|
||||
}
|
||||
|
||||
export function isProcessAlive(pid: number): boolean {
|
||||
try {
|
||||
process.kill(pid, 0);
|
||||
return true;
|
||||
} catch (error) {
|
||||
// EPERM: the process exists but belongs to someone else.
|
||||
return (error as NodeJS.ErrnoException).code === 'EPERM';
|
||||
}
|
||||
}
|
||||
|
||||
export function sweepSessionOptionsFiles(
|
||||
root: string,
|
||||
options: { ownPid?: number; isAlive?: (pid: number) => boolean } = {}
|
||||
): void {
|
||||
const ownPid = options.ownPid ?? process.pid;
|
||||
const isAlive = options.isAlive ?? isProcessAlive;
|
||||
let names: string[];
|
||||
try {
|
||||
names = readdirSync(root);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
for (const name of names) {
|
||||
if (!name.startsWith(INSTANCE_DIRECTORY_PREFIX)) {
|
||||
continue;
|
||||
}
|
||||
const pid = Number(name.slice(INSTANCE_DIRECTORY_PREFIX.length));
|
||||
if (!Number.isInteger(pid) || pid === ownPid || isAlive(pid)) {
|
||||
continue;
|
||||
}
|
||||
rmSync(path.join(root, name), { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
export function writeSessionOptionsFile(
|
||||
directory: string,
|
||||
options: readonly string[]
|
||||
): string {
|
||||
mkdirSync(directory, { recursive: true, mode: 0o700 });
|
||||
const file = path.join(
|
||||
directory,
|
||||
`${OPTIONS_FILE_PREFIX}${randomUUID()}.conf`
|
||||
);
|
||||
writeFileSync(file, options.map((option) => `${option}\n`).join(''), {
|
||||
mode: 0o600,
|
||||
});
|
||||
return file;
|
||||
}
|
||||
|
||||
export function removeSessionOptionsFile(file: string | null): void {
|
||||
if (!file) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
unlinkSync(file);
|
||||
} catch {
|
||||
// Already gone (a swept or never-written file); nothing to keep.
|
||||
}
|
||||
}
|
||||
|
||||
export function removeSessionOptionsDirectory(directory: string | null): void {
|
||||
if (!directory) {
|
||||
return;
|
||||
}
|
||||
rmSync(directory, { recursive: true, force: true });
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import { resolveEmbeddedMpvSessionOptionArguments } from '@iptvnator/shared/interfaces';
|
||||
import {
|
||||
EMBEDDED_MPV_AUTO_RECONNECT,
|
||||
EMBEDDED_MPV_EXTRA_OPTIONS,
|
||||
store,
|
||||
} from './store.service';
|
||||
|
||||
/**
|
||||
* Per-session knobs captured when an embedded MPV session is created. They
|
||||
* come from the main-process settings mirror (see `store.service.ts`), so a
|
||||
* settings change applies to the next session, never to a running one.
|
||||
*
|
||||
* Read here rather than inside `EmbeddedMpvNativeService`: the config store
|
||||
* is constructed at module load and would drag electron-conf into every
|
||||
* consumer of the service, including its unit tests.
|
||||
*/
|
||||
export interface EmbeddedMpvSessionOptions {
|
||||
/**
|
||||
* `key=value` lines the addon applies after its built-in options: the
|
||||
* network defaults first, then the user's allowed lines.
|
||||
*/
|
||||
extraOptions: string[];
|
||||
/** Reload a dropped stream automatically (see `embedded-mpv-reconnect.ts`). */
|
||||
autoReconnect: boolean;
|
||||
}
|
||||
|
||||
export function readEmbeddedMpvSessionOptions(): EmbeddedMpvSessionOptions {
|
||||
return {
|
||||
extraOptions: resolveEmbeddedMpvSessionOptionArguments(
|
||||
store.get(EMBEDDED_MPV_EXTRA_OPTIONS, '')
|
||||
),
|
||||
autoReconnect: store.get(EMBEDDED_MPV_AUTO_RECONNECT, true) !== false,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
/**
|
||||
* Native fullscreen state and in-flight transitions, per window.
|
||||
*
|
||||
* `BrowserWindow.isFullScreen()` cannot drive a toggle: setFullScreen()
|
||||
* completes asynchronously (the macOS animation, Linux window managers),
|
||||
* and on Windows the getter can still report the pre-transition value even
|
||||
* while the matching event fires (see attachWindowStateEvents). A toggle
|
||||
* decided against the getter would request the same target twice — two
|
||||
* quick F11 presses would land fullscreen instead of returning to the
|
||||
* window, and F11 during the startup fullscreen animation would ask for
|
||||
* fullscreen again instead of leaving it.
|
||||
*
|
||||
* So the tracker keeps its own notion of the window's fullscreen state: it
|
||||
* is seeded once from the getter when tracking starts — at window creation,
|
||||
* while no transition is in flight — and afterwards fed only by the
|
||||
* `enter-full-screen` / `leave-full-screen` events, which also cover
|
||||
* transitions the app did not request (macOS green button, Ctrl+Cmd+F,
|
||||
* HTML fullscreen on Windows). Every native fullscreen request goes through
|
||||
* `requestFullScreen`, and a toggle is decided against the pending target
|
||||
* if one is in flight, else against that tracked state — never the getter.
|
||||
*
|
||||
* The tracker observes; it never issues a request on its own. The pending
|
||||
* record is the LATEST requested target: an event landing on it clears it,
|
||||
* an event landing on the other state (an earlier request of a burst
|
||||
* landed first; ours is still queued) leaves it in place so the next press
|
||||
* is still decided against the user's latest intent, and a record older
|
||||
* than the transition timeout is ignored outright, since no native
|
||||
* transition takes that long. Events cannot say which request they belong
|
||||
* to, so any automatic "repeat the target" on a mismatch is
|
||||
* indistinguishable from reversing the user's own green-button or
|
||||
* Ctrl+Cmd+F action, and is deliberately not done: should a platform ever
|
||||
* drop a queued request, the record expires within the timeout, the
|
||||
* event-fed state takes over and the next press corrects the window.
|
||||
* Electron queues such requests on macOS and applies them synchronously
|
||||
* elsewhere, so that case is theoretical.
|
||||
*/
|
||||
interface PendingFullScreenTransition {
|
||||
target: boolean;
|
||||
requestedAt: number;
|
||||
}
|
||||
|
||||
interface TrackedFullScreenState {
|
||||
/** Event-fed fullscreen state; seeded from the getter once. */
|
||||
fullScreen: boolean;
|
||||
pending?: PendingFullScreenTransition;
|
||||
}
|
||||
|
||||
/** Longest a native transition is trusted to still be in flight. */
|
||||
export const FULLSCREEN_TRANSITION_TIMEOUT_MS = 2000;
|
||||
|
||||
const trackedWindows = new WeakMap<
|
||||
Electron.BrowserWindow,
|
||||
TrackedFullScreenState
|
||||
>();
|
||||
|
||||
function getFreshPending(
|
||||
state: TrackedFullScreenState,
|
||||
now: number
|
||||
): PendingFullScreenTransition | undefined {
|
||||
const pending = state.pending;
|
||||
|
||||
if (!pending) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (now - pending.requestedAt > FULLSCREEN_TRANSITION_TIMEOUT_MS) {
|
||||
state.pending = undefined;
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return pending;
|
||||
}
|
||||
|
||||
/**
|
||||
* Starts following the window's native fullscreen events. Call it right
|
||||
* after the window is created, when no transition can be in flight, so the
|
||||
* seed read is the one time the getter is trusted; later callers get the
|
||||
* existing record.
|
||||
*/
|
||||
export function trackNativeFullScreen(
|
||||
win: Electron.BrowserWindow
|
||||
): TrackedFullScreenState {
|
||||
const existing = trackedWindows.get(win);
|
||||
if (existing) {
|
||||
return existing;
|
||||
}
|
||||
|
||||
const state: TrackedFullScreenState = { fullScreen: win.isFullScreen() };
|
||||
trackedWindows.set(win, state);
|
||||
|
||||
// `landed` is the state the event itself conveys, never a re-read of
|
||||
// the getter.
|
||||
const settle = (landed: boolean) => {
|
||||
state.fullScreen = landed;
|
||||
|
||||
// Through the expiry check so a late event cannot be matched
|
||||
// against a request the user has long moved past.
|
||||
const pending = getFreshPending(state, Date.now());
|
||||
if (pending && landed === pending.target) {
|
||||
state.pending = undefined;
|
||||
}
|
||||
};
|
||||
win.on('enter-full-screen', () => settle(true));
|
||||
win.on('leave-full-screen', () => settle(false));
|
||||
|
||||
return state;
|
||||
}
|
||||
|
||||
export function getPendingFullScreenTarget(
|
||||
win: Electron.BrowserWindow,
|
||||
now: number = Date.now()
|
||||
): boolean | undefined {
|
||||
const state = trackedWindows.get(win);
|
||||
return state ? getFreshPending(state, now)?.target : undefined;
|
||||
}
|
||||
|
||||
export function requestFullScreen(
|
||||
win: Electron.BrowserWindow,
|
||||
target: boolean,
|
||||
now: number = Date.now()
|
||||
): void {
|
||||
const state = trackNativeFullScreen(win);
|
||||
state.pending = { target, requestedAt: now };
|
||||
win.setFullScreen(target);
|
||||
}
|
||||
|
||||
/**
|
||||
* Requests the opposite of the pending target, or of the tracked state when
|
||||
* no transition is in flight, and returns the requested state.
|
||||
*/
|
||||
export function toggleFullScreen(
|
||||
win: Electron.BrowserWindow,
|
||||
now: number = Date.now()
|
||||
): boolean {
|
||||
const state = trackNativeFullScreen(win);
|
||||
const target = !(getPendingFullScreenTarget(win, now) ?? state.fullScreen);
|
||||
requestFullScreen(win, target, now);
|
||||
return target;
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import {
|
||||
FULLSCREEN_LAUNCH_SWITCH,
|
||||
resolveStartupWindowMode,
|
||||
} from './startup-window-mode';
|
||||
|
||||
describe('resolveStartupWindowMode', () => {
|
||||
it('is exposed as the bare switch name Electron parses from argv', () => {
|
||||
// app.commandLine.hasSwitch() takes the name without dashes.
|
||||
expect(FULLSCREEN_LAUNCH_SWITCH).toBe('fullscreen');
|
||||
});
|
||||
|
||||
it('defaults to a normal window on a fresh profile', () => {
|
||||
expect(
|
||||
resolveStartupWindowMode({
|
||||
cliHasFullscreenSwitch: false,
|
||||
storedMode: undefined,
|
||||
})
|
||||
).toBe('normal');
|
||||
});
|
||||
|
||||
it.each(['normal', 'maximized', 'fullscreen'] as const)(
|
||||
'honours the stored %s mode',
|
||||
(storedMode) => {
|
||||
expect(
|
||||
resolveStartupWindowMode({
|
||||
cliHasFullscreenSwitch: false,
|
||||
storedMode,
|
||||
})
|
||||
).toBe(storedMode);
|
||||
}
|
||||
);
|
||||
|
||||
it.each(['kiosk', '', 1, true, null, {}])(
|
||||
'collapses a hand-edited config value %p to normal',
|
||||
(storedMode) => {
|
||||
expect(
|
||||
resolveStartupWindowMode({
|
||||
cliHasFullscreenSwitch: false,
|
||||
storedMode,
|
||||
})
|
||||
).toBe('normal');
|
||||
}
|
||||
);
|
||||
|
||||
it('lets the --fullscreen switch win over any stored mode', () => {
|
||||
expect(
|
||||
resolveStartupWindowMode({
|
||||
cliHasFullscreenSwitch: true,
|
||||
storedMode: 'maximized',
|
||||
})
|
||||
).toBe('fullscreen');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,32 @@
|
||||
import {
|
||||
normalizeStartupWindowMode,
|
||||
StartupWindowMode,
|
||||
} from '@iptvnator/shared/interfaces';
|
||||
|
||||
/**
|
||||
* Command-line switch that forces one fullscreen launch:
|
||||
* `iptvnator --fullscreen`. Meant for HTPC autostart scripts. It is read
|
||||
* through Electron's parsed command line, so it can sit anywhere in argv;
|
||||
* the playlist-path extractor already skips every `-`-prefixed argument.
|
||||
*/
|
||||
export const FULLSCREEN_LAUNCH_SWITCH = 'fullscreen';
|
||||
|
||||
/**
|
||||
* Decides how the main window is presented at launch.
|
||||
*
|
||||
* The switch wins over the stored setting but is never persisted — the next
|
||||
* launch without it falls back to whatever Settings say. The stored value is
|
||||
* whatever the SETTINGS_UPDATE mirror wrote (or nothing on a fresh profile)
|
||||
* and is normalized again here, so a hand-edited config file cannot pass an
|
||||
* unknown mode into the window options.
|
||||
*/
|
||||
export function resolveStartupWindowMode(input: {
|
||||
cliHasFullscreenSwitch: boolean;
|
||||
storedMode: unknown;
|
||||
}): StartupWindowMode {
|
||||
if (input.cliHasFullscreenSwitch) {
|
||||
return 'fullscreen';
|
||||
}
|
||||
|
||||
return normalizeStartupWindowMode(input.storedMode);
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Conf } from 'electron-conf/main';
|
||||
import { getElectronConfigDirectory } from '@iptvnator/shared/database';
|
||||
import type { StartupWindowMode } from '@iptvnator/shared/interfaces';
|
||||
|
||||
export const WINDOW_BOUNDS = 'WINDOW_BOUNDS';
|
||||
export const MPV_PLAYER_PATH = 'MPV_PLAYER_PATH';
|
||||
@@ -8,6 +9,7 @@ export const VLC_PLAYER_PATH = 'VLC_PLAYER_PATH';
|
||||
export const VLC_PLAYER_ARGUMENTS = 'VLC_PLAYER_ARGUMENTS';
|
||||
export const MPV_REUSE_INSTANCE = 'MPV_REUSE_INSTANCE';
|
||||
export const VLC_REUSE_INSTANCE = 'VLC_REUSE_INSTANCE';
|
||||
|
||||
/**
|
||||
* Embedded MPV frame-copy engine opt-in (macOS arm64, Linux x64). Lives in the
|
||||
* main process config file because it must be readable synchronously before
|
||||
@@ -15,6 +17,28 @@ export const VLC_REUSE_INSTANCE = 'VLC_REUSE_INSTANCE';
|
||||
* its preload frame pump, which cannot change after window creation.
|
||||
*/
|
||||
export const EMBEDDED_MPV_FRAME_COPY = 'EMBEDDED_MPV_FRAME_COPY';
|
||||
/**
|
||||
* Window presentation at launch (`normal` / `maximized` / `fullscreen`).
|
||||
* Mirrored here from the renderer's settings by the SETTINGS_UPDATE handler
|
||||
* for the same reason as the frame-copy flag: `initMainWindow` needs it
|
||||
* synchronously, before any renderer exists to ask.
|
||||
*/
|
||||
export const STARTUP_WINDOW_MODE = 'STARTUP_WINDOW_MODE';
|
||||
|
||||
/**
|
||||
* Extra libmpv options for embedded sessions, one "key=value" per line, as
|
||||
* typed in Settings > Playback. Mirrored here by the SETTINGS_UPDATE handler
|
||||
* because sessions are created in the main process, where the renderer's
|
||||
* IndexedDB settings are unreachable. Turned into addon arguments by
|
||||
* `readEmbeddedMpvSessionOptions()`.
|
||||
*/
|
||||
export const EMBEDDED_MPV_EXTRA_OPTIONS = 'EMBEDDED_MPV_EXTRA_OPTIONS';
|
||||
|
||||
/**
|
||||
* Whether a dropped embedded MPV stream is reloaded automatically. Same
|
||||
* mirror as above; absent means enabled.
|
||||
*/
|
||||
export const EMBEDDED_MPV_AUTO_RECONNECT = 'EMBEDDED_MPV_AUTO_RECONNECT';
|
||||
|
||||
export type StoreType = {
|
||||
[WINDOW_BOUNDS]: Electron.Rectangle;
|
||||
@@ -25,6 +49,9 @@ export type StoreType = {
|
||||
[MPV_REUSE_INSTANCE]: boolean;
|
||||
[VLC_REUSE_INSTANCE]: boolean;
|
||||
[EMBEDDED_MPV_FRAME_COPY]: boolean;
|
||||
[EMBEDDED_MPV_EXTRA_OPTIONS]: string;
|
||||
[EMBEDDED_MPV_AUTO_RECONNECT]: boolean;
|
||||
[STARTUP_WINDOW_MODE]: StartupWindowMode;
|
||||
};
|
||||
|
||||
// Export singleton store instance
|
||||
|
||||
@@ -84,7 +84,7 @@ actually get wrong:
|
||||
| `00:1A:79:00:00:04` | **is-series** | 60% of VOD items have `is_series=1` — tests the Ministra lazy-season flow |
|
||||
| `00:1A:79:00:00:05` | **embedded-series** | 50% of VOD items have embedded `series[]` arrays — tests the embedded series flow |
|
||||
| `00:1A:79:00:00:06` | **legacy-pagination** | No `get_all_channels` support — tests the paginated `get_ordered_list` crawl fallback for the full ITV channel list |
|
||||
| `00:1A:79:00:00:07` | **marketing-demo** | 35 original poster movies with the newest 20 first — safe for screenshots and marketing |
|
||||
| `00:1A:79:00:00:07` | **marketing-demo** | 35 original poster movies with the newest 20 first, plus the fictional live channels shared with the Xtream mock (logos served from `/assets/marketing/logo/<slug>.svg`, no censored category) — safe for screenshots and marketing |
|
||||
| `00:1A:79:00:00:08` | **login-required** | `get_profile` answers `status: 2` until the client completes `do_auth` with non-empty credentials. The app drives this end to end: enter a username and password in the Stalker import dialog and it runs `do_auth`, then retries `get_profile` with `auth_second_step=1`. Covered by `stalker.e2e.ts` (`@stalker full portal authentication`) |
|
||||
| `00:1A:79:AE:<slot>:02` | **login-required** | Parallel-slot-scoped alias range used by concurrent auth E2E runs; it has the same login contract as `00:1A:79:00:00:08` but independent per-MAC state |
|
||||
| `00:1A:79:00:00:09` | **gated-stream** | `create_link` returns a local `/stream/gated/…` URL that answers 403 without the mac cookie and the MAC's current Bearer token — proves a player's media requests really carry the portal credentials |
|
||||
@@ -114,6 +114,7 @@ Besides the portal paths above, `src/main.ts` mounts:
|
||||
| `/stalker?url=<portal_url>&macAddress=<mac>&action=<action>&…` | `GET` | CORS-proxy mirror of the IPTVnator web-backend `/stalker` endpoint, used by PWA/Playwright runs. `macAddress`, `token` and `serialNumber` are control params turned into headers and stripped from the portal query (except `handshake`'s candidate token); the response is wrapped as `{ payload }`. Strictness follows the proxied `url`'s shape |
|
||||
| `/stream/gated/:file` | `GET` | `video.mp4` / `audio.mp4` fixtures for the `gated-stream` scenario — 403 without the mac cookie **and** the MAC's current Bearer token |
|
||||
| `/assets/marketing/poster/<slug>.png` | `GET` | The committed screenshot-safe poster catalog shared with the Xtream mock, served from this process so `marketing-demo` needs no second server |
|
||||
| `/assets/marketing/logo/<slug>.svg[?size=WxH]` | `GET` | Generated channel logo (initials on a gradient) for the `marketing-demo` live channels and radio stations, rendered by `@iptvnator/shared/marketing-fixtures` |
|
||||
| `/health` | `GET` | Health check — returns `{ status: "ok" }` |
|
||||
| `/reset[?macAddress=<mac>&macAddress=…]` | `POST` | Clear generated data, favorites, session/auth state (including pinned device IDs) and watchdog counters. **Pass the MACs you own**: state is per-MAC and parallel spec files share this process, so a bare `/reset` wipes their state mid-test. Repeated params clear several MACs in one request |
|
||||
| `/invalidate-session?macAddress=<mac>` | `POST` | Drop that MAC's tokens so the next portal call fails with `Authorization failed.` — lets tests assert the client re-handshakes and retries. Pinned device identity survives, as on a real portal |
|
||||
|
||||
@@ -3,6 +3,13 @@ import {
|
||||
MarketingMovieCategoryKey,
|
||||
POSTER_SHOWCASE_MOVIES,
|
||||
} from '@iptvnator/shared/marketing-fixtures';
|
||||
import {
|
||||
generateMarketingChannels,
|
||||
generateMarketingEpg,
|
||||
generateMarketingItvCategories,
|
||||
marketingEpgTitlesFor,
|
||||
marketingLogoPath,
|
||||
} from './marketing-live.generator.js';
|
||||
import { ScenarioConfig } from './scenarios.js';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -235,30 +242,53 @@ export function generatePortalData(config: ScenarioConfig): GeneratedPortalData
|
||||
};
|
||||
|
||||
// ------ ITV categories + channels ------
|
||||
data.itvCategories = generateCategories('itv', config.categoryCount.itv);
|
||||
// Real Ministra portals mark adult genres as censored and EXCLUDE their
|
||||
// channels from get_all_channels / the "*" listing; the channels are only
|
||||
// served by paging the specific genre. Mirror that with one extra
|
||||
// censored category so clients can exercise the fallback path.
|
||||
data.itvCategories.push({
|
||||
id: '1099',
|
||||
title: 'For adults',
|
||||
alias: 'for_adults',
|
||||
censored: '1',
|
||||
});
|
||||
let channelIndex = 0;
|
||||
for (const cat of data.itvCategories) {
|
||||
const channels = generateChannels(
|
||||
cat.id,
|
||||
config.itemsPerCategory,
|
||||
channelIndex,
|
||||
config.staticChannelCmd === true
|
||||
);
|
||||
data.channels.set(cat.id, channels);
|
||||
for (const ch of channels) {
|
||||
data.epg.set(ch.id, generateEpg(ch.name));
|
||||
if (config.marketingFixture) {
|
||||
// Screenshot-safe: shared fictional channels, logos served by this
|
||||
// process, no censored category to keep out of a published frame.
|
||||
data.itvCategories = generateMarketingItvCategories();
|
||||
const marketingChannels = generateMarketingChannels();
|
||||
for (const cat of data.itvCategories) {
|
||||
const channels = marketingChannels.filter(
|
||||
(channel) => channel.category_id === cat.id
|
||||
);
|
||||
data.channels.set(cat.id, channels);
|
||||
for (const ch of channels) {
|
||||
data.epg.set(
|
||||
ch.id,
|
||||
generateMarketingEpg(ch.name, marketingEpgTitlesFor(ch.name))
|
||||
);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
data.itvCategories = generateCategories(
|
||||
'itv',
|
||||
config.categoryCount.itv
|
||||
);
|
||||
// Real Ministra portals mark adult genres as censored and EXCLUDE
|
||||
// their channels from get_all_channels / the "*" listing; the
|
||||
// channels are only served by paging the specific genre. Mirror that
|
||||
// with one extra censored category so clients can exercise the
|
||||
// fallback path.
|
||||
data.itvCategories.push({
|
||||
id: '1099',
|
||||
title: 'For adults',
|
||||
alias: 'for_adults',
|
||||
censored: '1',
|
||||
});
|
||||
let channelIndex = 0;
|
||||
for (const cat of data.itvCategories) {
|
||||
const channels = generateChannels(
|
||||
cat.id,
|
||||
config.itemsPerCategory,
|
||||
channelIndex,
|
||||
config.staticChannelCmd === true
|
||||
);
|
||||
data.channels.set(cat.id, channels);
|
||||
for (const ch of channels) {
|
||||
data.epg.set(ch.id, generateEpg(ch.name));
|
||||
}
|
||||
channelIndex += config.itemsPerCategory;
|
||||
}
|
||||
channelIndex += config.itemsPerCategory;
|
||||
}
|
||||
|
||||
// ------ Radio categories + stations ------
|
||||
@@ -271,7 +301,8 @@ export function generatePortalData(config: ScenarioConfig): GeneratedPortalData
|
||||
const stations = generateRadioStations(
|
||||
cat.id,
|
||||
config.itemsPerCategory,
|
||||
radioIndex
|
||||
radioIndex,
|
||||
config.marketingFixture === true
|
||||
);
|
||||
data.radio.set(cat.id, stations);
|
||||
radioIndex += config.itemsPerCategory;
|
||||
@@ -426,7 +457,8 @@ function generateChannels(
|
||||
function generateRadioStations(
|
||||
categoryId: string,
|
||||
count: number,
|
||||
startIndex: number
|
||||
startIndex: number,
|
||||
localLogos = false
|
||||
): RawRadioStation[] {
|
||||
return Array.from({ length: count }, (_, i) => {
|
||||
const globalIndex = startIndex + i;
|
||||
@@ -437,7 +469,7 @@ function generateRadioStations(
|
||||
name,
|
||||
o_name: name,
|
||||
cmd: `ffrt4://radio/${id}/index.mp3`,
|
||||
logo: logoUrl(`radio-${id}`),
|
||||
logo: localLogos ? marketingLogoPath(name) : logoUrl(`radio-${id}`),
|
||||
category_id: categoryId,
|
||||
tv_genre_id: categoryId,
|
||||
number: String(globalIndex + 1),
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
import {
|
||||
MARKETING_LIVE_CATEGORIES,
|
||||
MARKETING_LIVE_CHANNELS,
|
||||
MARKETING_LOGO_ASSET_PATH,
|
||||
MarketingLiveCategoryKey,
|
||||
marketingSlug,
|
||||
} from '@iptvnator/shared/marketing-fixtures';
|
||||
import type { RawCategory, RawChannel, RawEpgProgram } from './data-generator.js';
|
||||
|
||||
/**
|
||||
* ITV side of the `marketing-demo` scenario: the provider-neutral channel
|
||||
* list shared with the Xtream mock, logos served by this process from
|
||||
* `/assets/marketing/logo/<slug>.svg`, and a schedule built from the
|
||||
* channels' own fictional programme titles. Nothing here reaches a
|
||||
* third-party host, which is what lets the release screenshot guards accept
|
||||
* a Stalker live-TV frame.
|
||||
*/
|
||||
|
||||
const STALKER_MARKETING_ITV_CATEGORY_IDS: Record<
|
||||
MarketingLiveCategoryKey,
|
||||
string
|
||||
> = {
|
||||
newsroom: '1101',
|
||||
sports: '1102',
|
||||
family: '1103',
|
||||
culture: '1104',
|
||||
};
|
||||
|
||||
const MARKETING_CHANNEL_ID_BASE = 11_000;
|
||||
const SLOT_MINUTES = 60;
|
||||
const TOTAL_DAYS = 7;
|
||||
|
||||
export function marketingLogoPath(name: string): string {
|
||||
return `${MARKETING_LOGO_ASSET_PATH}/${marketingSlug(name)}.svg?size=256x256`;
|
||||
}
|
||||
|
||||
export function generateMarketingItvCategories(): RawCategory[] {
|
||||
return MARKETING_LIVE_CATEGORIES.map((category) => ({
|
||||
id: STALKER_MARKETING_ITV_CATEGORY_IDS[category.key],
|
||||
title: category.name,
|
||||
alias: marketingSlug(category.name).replace(/-/g, '_'),
|
||||
}));
|
||||
}
|
||||
|
||||
export function generateMarketingChannels(): RawChannel[] {
|
||||
return MARKETING_LIVE_CHANNELS.map((channel, index) => {
|
||||
const id = String(MARKETING_CHANNEL_ID_BASE + index);
|
||||
|
||||
return {
|
||||
id,
|
||||
name: channel.name,
|
||||
o_name: channel.name,
|
||||
cmd: `ffrt4://ch/live/${id}/index.m3u8`,
|
||||
logo: marketingLogoPath(channel.name),
|
||||
category_id: STALKER_MARKETING_ITV_CATEGORY_IDS[channel.categoryKey],
|
||||
tv_genre_id: STALKER_MARKETING_ITV_CATEGORY_IDS[channel.categoryKey],
|
||||
xmltv_id: `${marketingSlug(channel.name)}.fictional`,
|
||||
use_http_tmp_link: '1',
|
||||
use_load_balancing: '0',
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/** Hourly slots for a week, cycling the channel's fixture titles. */
|
||||
export function generateMarketingEpg(
|
||||
channelName: string,
|
||||
titles: readonly string[]
|
||||
): RawEpgProgram[] {
|
||||
const dayStart = new Date();
|
||||
dayStart.setUTCHours(0, 0, 0, 0);
|
||||
const slotsPerDay = (24 * 60) / SLOT_MINUTES;
|
||||
|
||||
return Array.from({ length: slotsPerDay * TOTAL_DAYS }, (_, index) => {
|
||||
const start = new Date(dayStart.getTime() + index * SLOT_MINUTES * 60_000);
|
||||
const stop = new Date(start.getTime() + SLOT_MINUTES * 60_000);
|
||||
const title = titles[index % titles.length] ?? channelName;
|
||||
|
||||
return {
|
||||
id: String(index + 1),
|
||||
name: title,
|
||||
start: start.toISOString(),
|
||||
stop: stop.toISOString(),
|
||||
start_timestamp: Math.floor(start.getTime() / 1000),
|
||||
stop_timestamp: Math.floor(stop.getTime() / 1000),
|
||||
descr: `${title} on ${channelName}, part of the fictional IPTVnator demo schedule.`,
|
||||
category: 'Fictional',
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export function marketingEpgTitlesFor(channelName: string): readonly string[] {
|
||||
return (
|
||||
MARKETING_LIVE_CHANNELS.find((channel) => channel.name === channelName)
|
||||
?.epgTitles ?? [channelName]
|
||||
);
|
||||
}
|
||||
@@ -49,6 +49,19 @@ describe('marketing poster URLs', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('resolves channel logo paths the same way as posters', () => {
|
||||
const req = request('http', { host: 'localhost:3210' });
|
||||
|
||||
expect(
|
||||
resolveMarketingPosterUrls(
|
||||
{ logo: '/assets/marketing/logo/aurora-news.svg?size=256x256' },
|
||||
buildRequestOrigin(req)
|
||||
)
|
||||
).toEqual({
|
||||
logo: 'http://localhost:3210/assets/marketing/logo/aurora-news.svg?size=256x256',
|
||||
});
|
||||
});
|
||||
|
||||
it('preserves unrelated URLs and values', () => {
|
||||
const payload = {
|
||||
cover: 'https://picsum.photos/seed/movie/300/450',
|
||||
|
||||
@@ -3,7 +3,8 @@ type RequestOriginSource = {
|
||||
get(name: string): string | undefined;
|
||||
};
|
||||
|
||||
const MARKETING_POSTER_PATH_PREFIX = '/assets/marketing/poster/';
|
||||
/** Posters and channel logos alike: every asset this process serves itself. */
|
||||
const MARKETING_POSTER_PATH_PREFIX = '/assets/marketing/';
|
||||
|
||||
export function buildRequestOrigin(request: RequestOriginSource): string {
|
||||
const protocol =
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
} from './app/auth-store.js';
|
||||
import { resetWatchdogPings } from './app/handlers/get-events.handler.js';
|
||||
import { resetAll, resetMac } from './app/data-store.js';
|
||||
import { renderMarketingLogoSvg } from '@iptvnator/shared/marketing-fixtures';
|
||||
import { SCENARIOS } from './app/scenarios.js';
|
||||
import {
|
||||
buildRequestOrigin,
|
||||
@@ -53,9 +54,11 @@ app.use((req, _res, next) => {
|
||||
|
||||
// Marketing fixtures store deployment-neutral asset paths. Resolve them
|
||||
// against the public origin of each request, including reverse-proxy headers.
|
||||
// `get_all_channels` is the full ITV list the app caches per session, so the
|
||||
// live-TV channel logos of the marketing scenario ride on it as well.
|
||||
app.use((req, res, next) => {
|
||||
if (
|
||||
!['get_ordered_list', 'favorites'].includes(
|
||||
!['get_ordered_list', 'get_all_channels', 'favorites'].includes(
|
||||
String(req.query['action'])
|
||||
)
|
||||
) {
|
||||
@@ -87,6 +90,17 @@ app.use(
|
||||
})
|
||||
);
|
||||
|
||||
// Channel logos of the marketing-demo scenario, rendered on the fly with the
|
||||
// same generator the Xtream mock uses, so a live-TV frame never fetches a
|
||||
// third-party image.
|
||||
app.get('/assets/marketing/logo/:slug', (req: Request, res: Response) => {
|
||||
const size =
|
||||
typeof req.query['size'] === 'string' ? req.query['size'] : undefined;
|
||||
res.type('image/svg+xml')
|
||||
.set('Cache-Control', 'public, max-age=3600')
|
||||
.send(renderMarketingLogoSvg(String(req.params['slug'] ?? ''), size));
|
||||
});
|
||||
|
||||
// Stalker portal.php endpoint (reseller-panel alias — tolerant, no token check)
|
||||
app.use('/portal.php', portalRouter);
|
||||
|
||||
|
||||
@@ -462,6 +462,44 @@ test('@stalker PWA hides EPG for ITV channel', async ({ page }) => {
|
||||
expect(shortEpgRequests).toHaveLength(0);
|
||||
});
|
||||
|
||||
test('@stalker ITV playback survives a category switch', async ({ page }) => {
|
||||
// Regression: the shell context panel used to clear the selected Stalker
|
||||
// item on every category click, tearing down the player for a channel the
|
||||
// user never switched away from. Xtream live (#936) and M3U groups keep
|
||||
// playing across a category/group switch; Stalker must too.
|
||||
await addStalkerPortal(page);
|
||||
|
||||
await page.getByRole('link', { name: /live|itv/i }).click();
|
||||
await page.waitForURL(/stalker.*itv/);
|
||||
|
||||
const categories = page.locator('.category-item');
|
||||
await expect(categories.nth(1)).toBeVisible({ timeout: 10_000 });
|
||||
await categories.nth(1).click();
|
||||
|
||||
const sidebar = page.locator('app-stalker-live-stream-layout .sidebar');
|
||||
const sidebarTitle = sidebar.locator('.category-title');
|
||||
const channels = page.locator('[data-test-id="channel-item"]');
|
||||
await expect(channels.first()).toBeVisible({ timeout: 20_000 });
|
||||
const firstCategoryTitle = (await sidebarTitle.textContent())?.trim() ?? '';
|
||||
expect(firstCategoryTitle).not.toBe('');
|
||||
|
||||
await channels.first().click();
|
||||
await expect(channels.first()).toHaveClass(/active/, { timeout: 20_000 });
|
||||
const player = page.locator('app-web-player-view');
|
||||
await expect(player).toBeVisible({ timeout: 20_000 });
|
||||
|
||||
await categories.nth(2).click();
|
||||
|
||||
// The sidebar re-filters to the new category (proves the click landed and
|
||||
// change detection ran)…
|
||||
await expect(sidebarTitle).not.toHaveText(firstCategoryTitle, {
|
||||
timeout: 20_000,
|
||||
});
|
||||
await expect(channels.first()).toBeVisible({ timeout: 20_000 });
|
||||
// …while the channel picked from the previous category keeps playing.
|
||||
await expect(player).toBeVisible();
|
||||
});
|
||||
|
||||
test('@stalker radio — stations use the inline audio player without EPG', async ({
|
||||
page,
|
||||
}) => {
|
||||
@@ -514,6 +552,44 @@ test('@stalker radio — stations use the inline audio player without EPG', asyn
|
||||
expect(epgRequests).toHaveLength(0);
|
||||
});
|
||||
|
||||
test('@stalker radio playback survives a category switch', async ({ page }) => {
|
||||
// Radio shares the ITV layout and the same context-panel handler that
|
||||
// used to clear the selected item on every category click (#1517). The
|
||||
// inline audio player is gated on that selection, so a category switch
|
||||
// silenced the station the user never switched away from.
|
||||
await addStalkerPortal(page);
|
||||
|
||||
await page.getByRole('link', { name: /radio/i }).click();
|
||||
await page.waitForURL(/stalker.*radio/);
|
||||
|
||||
const categories = page.locator('.category-item');
|
||||
await expect(categories.nth(2)).toBeVisible({ timeout: 10_000 });
|
||||
await categories.nth(1).click();
|
||||
|
||||
const sidebar = page.locator('app-stalker-live-stream-layout .sidebar');
|
||||
const sidebarTitle = sidebar.locator('.category-title');
|
||||
const stations = page.locator('[data-test-id="channel-item"]');
|
||||
await expect(stations.first()).toBeVisible({ timeout: 20_000 });
|
||||
const firstCategoryTitle = (await sidebarTitle.textContent())?.trim() ?? '';
|
||||
expect(firstCategoryTitle).not.toBe('');
|
||||
|
||||
await stations.first().click();
|
||||
await expect(stations.first()).toHaveClass(/active/, { timeout: 20_000 });
|
||||
const audioPlayer = page.locator('app-audio-player');
|
||||
await expect(audioPlayer).toBeVisible({ timeout: 20_000 });
|
||||
|
||||
await categories.nth(2).click();
|
||||
|
||||
// The sidebar re-filters to the new category (proves the click landed and
|
||||
// change detection ran)…
|
||||
await expect(sidebarTitle).not.toHaveText(firstCategoryTitle, {
|
||||
timeout: 20_000,
|
||||
});
|
||||
await expect(stations.first()).toBeVisible({ timeout: 20_000 });
|
||||
// …while the station picked from the previous category keeps playing.
|
||||
await expect(audioPlayer).toBeVisible();
|
||||
});
|
||||
|
||||
test('@stalker PWA skips bulk EPG across channel switches', async ({
|
||||
page,
|
||||
}) => {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import type { APIRequestContext, Page } from '@playwright/test';
|
||||
import type { APIRequestContext, Locator, Page } from '@playwright/test';
|
||||
import { expect, test } from './fixtures';
|
||||
import { setInputValue } from './e2e-helpers';
|
||||
import {
|
||||
@@ -929,6 +929,159 @@ function formatXtreamDateTime(timestampSeconds: number): string {
|
||||
// now the app-web-player-view host, which spans all applications of one mount.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Inline series playback helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Serve a tiny VP8 clip for every episode stream. Chromium ships no
|
||||
* proprietary codecs, so the clip stands in for the mock's external HLS
|
||||
* redirect; the browser sniffs the WebM container from the bytes. Registered
|
||||
* after the beforeEach proxy route, so it runs first and fetches from the
|
||||
* mock itself (the mock ignores the url parameter).
|
||||
*/
|
||||
async function routeEpisodeClip(page: Page): Promise<void> {
|
||||
const episodeClip = readFileSync(
|
||||
join(__dirname, 'fixtures/playback/episode.webm')
|
||||
);
|
||||
await page.route(
|
||||
(url) =>
|
||||
url.origin === MOCK_SERVER && url.pathname.startsWith('/series/'),
|
||||
async (route) => {
|
||||
// Chromium's media pipeline seeks through byte ranges; a plain
|
||||
// 200 to a Range request clamps every seek to the start, which
|
||||
// would defeat end-of-episode steps.
|
||||
const range = /^bytes=(\d*)-(\d*)$/.exec(
|
||||
route.request().headers()['range'] ?? ''
|
||||
);
|
||||
const last = episodeClip.length - 1;
|
||||
const start = range?.[1]
|
||||
? Number(range[1])
|
||||
: range?.[2]
|
||||
? Math.max(0, episodeClip.length - Number(range[2]))
|
||||
: 0;
|
||||
const end =
|
||||
range?.[1] && range[2]
|
||||
? Math.min(Number(range[2]), last)
|
||||
: last;
|
||||
await route.fulfill({
|
||||
status: range ? 206 : 200,
|
||||
headers: {
|
||||
'accept-ranges': 'bytes',
|
||||
'content-length': String(end - start + 1),
|
||||
'content-type': 'video/webm',
|
||||
...(range
|
||||
? {
|
||||
'content-range': `bytes ${start}-${end}/${episodeClip.length}`,
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
body: episodeClip.subarray(start, end + 1),
|
||||
});
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The mock's episodes are .mkv, which the HTML5 player would hand to hls.js;
|
||||
* get_series_info is rewritten to .mp4 — the extension the player gives to
|
||||
* the native source path.
|
||||
*/
|
||||
async function rewriteSeriesEpisodesToMp4(page: Page): Promise<void> {
|
||||
await page.route('**/localhost:3000/xtream**', async (route) => {
|
||||
const original = new URL(route.request().url());
|
||||
if (original.searchParams.get('action') !== 'get_series_info') {
|
||||
await route.fallback();
|
||||
return;
|
||||
}
|
||||
const mockUrl = new URL(`${MOCK_SERVER}/xtream`);
|
||||
original.searchParams.forEach((value, key) => {
|
||||
if (key !== 'targetId') {
|
||||
mockUrl.searchParams.set(key, value);
|
||||
}
|
||||
});
|
||||
const response = await route.fetch({ url: mockUrl.toString() });
|
||||
const body = (await response.json()) as {
|
||||
payload: {
|
||||
episodes?: Record<
|
||||
string,
|
||||
Array<{ container_extension: string }>
|
||||
>;
|
||||
};
|
||||
};
|
||||
for (const episodes of Object.values(body.payload.episodes ?? {})) {
|
||||
for (const episode of episodes) {
|
||||
episode.container_extension = 'mp4';
|
||||
}
|
||||
}
|
||||
await route.fulfill({ response, json: body });
|
||||
});
|
||||
}
|
||||
|
||||
/** Persist the web player engine and the shared-controls preference. */
|
||||
async function selectWebPlayer(
|
||||
page: Page,
|
||||
engine: string,
|
||||
sharedControls = true
|
||||
): Promise<void> {
|
||||
await page.goto('/workspace/settings/playback');
|
||||
await page.locator('[data-test-id="select-video-player"]').click();
|
||||
await page.getByRole('option', { name: engine, exact: true }).click();
|
||||
const toggle = page.locator(
|
||||
'[data-test-id="web-player-shared-controls-toggle"]'
|
||||
);
|
||||
if ((await toggle.locator('input').isChecked()) !== sharedControls) {
|
||||
await toggle.click();
|
||||
}
|
||||
const saveButton = page.getByRole('button', { name: 'Save changes' });
|
||||
await saveButton.click();
|
||||
await expect(saveButton).toBeHidden();
|
||||
}
|
||||
|
||||
/** Add the mock portal and start the first episode of the first series. */
|
||||
async function playFirstSeriesEpisode(
|
||||
page: Page,
|
||||
request: APIRequestContext
|
||||
): Promise<{ playerView: Locator; video: Locator }> {
|
||||
const categories = (await (
|
||||
await request.get(
|
||||
`${MOCK_SERVER}/player_api.php?username=${DEFAULT_USERNAME}&password=${DEFAULT_PASSWORD}&action=get_series_categories`
|
||||
)
|
||||
).json()) as Array<{ category_id: string; category_name: string }>;
|
||||
const category = categories[0];
|
||||
const seriesItems = (await (
|
||||
await request.get(
|
||||
`${MOCK_SERVER}/player_api.php?username=${DEFAULT_USERNAME}&password=${DEFAULT_PASSWORD}&action=get_series&category_id=${category.category_id}`
|
||||
)
|
||||
).json()) as Array<{ name: string; series_id: number }>;
|
||||
const targetSeries = seriesItems[0];
|
||||
|
||||
await page.goto('/');
|
||||
await addXtreamPortal(page);
|
||||
await page.goto(page.url().replace(/\/vod.*$/, '/series'));
|
||||
const categoryItem = page
|
||||
.locator('.context-panel .category-item')
|
||||
.filter({ hasText: category.category_name })
|
||||
.first();
|
||||
await expect(categoryItem).toBeVisible({ timeout: 10_000 });
|
||||
await categoryItem.click();
|
||||
const seriesCard = page
|
||||
.locator('app-grid-list mat-card')
|
||||
.filter({ hasText: targetSeries.name })
|
||||
.first();
|
||||
await expect(seriesCard).toBeVisible({ timeout: 10_000 });
|
||||
await seriesCard.click();
|
||||
|
||||
const episodeCards = page.locator('.episode-card');
|
||||
await expect(episodeCards).toHaveCount(8, { timeout: 15_000 });
|
||||
await episodeCards.first().click();
|
||||
|
||||
const playerView = page.locator(
|
||||
'app-portal-inline-player app-web-player-view'
|
||||
);
|
||||
return { playerView, video: playerView.locator('video') };
|
||||
}
|
||||
|
||||
test.describe('@xtream inline series fullscreen', () => {
|
||||
// No autoplay-policy flag is needed: the episode click is a user
|
||||
// activation and the fixture clip carries no audio track.
|
||||
@@ -941,133 +1094,19 @@ test.describe('@xtream inline series fullscreen', () => {
|
||||
page,
|
||||
request,
|
||||
}) => {
|
||||
// Chromium ships no proprietary codecs, so a tiny VP8 clip stands in
|
||||
// for the mock's external HLS redirect; the browser sniffs the WebM
|
||||
// container from the bytes. The mock's episodes are .mkv, which the
|
||||
// HTML5 player would hand to hls.js, so get_series_info is rewritten
|
||||
// to .mp4 — the extension the player gives to the native source path.
|
||||
// Registered after the beforeEach proxy route, so it runs first and
|
||||
// fetches from the mock itself (the mock ignores the url parameter).
|
||||
const episodeClip = readFileSync(
|
||||
join(__dirname, 'fixtures/playback/episode.webm')
|
||||
);
|
||||
await page.route(
|
||||
(url) =>
|
||||
url.origin === MOCK_SERVER &&
|
||||
url.pathname.startsWith('/series/'),
|
||||
async (route) => {
|
||||
// Chromium's media pipeline seeks through byte ranges; a
|
||||
// plain 200 to a Range request clamps every seek to the
|
||||
// start, which would defeat the end-of-episode step below.
|
||||
const range = /^bytes=(\d*)-(\d*)$/.exec(
|
||||
route.request().headers()['range'] ?? ''
|
||||
);
|
||||
const last = episodeClip.length - 1;
|
||||
const start = range?.[1]
|
||||
? Number(range[1])
|
||||
: range?.[2]
|
||||
? Math.max(0, episodeClip.length - Number(range[2]))
|
||||
: 0;
|
||||
const end =
|
||||
range?.[1] && range[2]
|
||||
? Math.min(Number(range[2]), last)
|
||||
: last;
|
||||
await route.fulfill({
|
||||
status: range ? 206 : 200,
|
||||
headers: {
|
||||
'accept-ranges': 'bytes',
|
||||
'content-length': String(end - start + 1),
|
||||
'content-type': 'video/webm',
|
||||
...(range
|
||||
? {
|
||||
'content-range': `bytes ${start}-${end}/${episodeClip.length}`,
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
body: episodeClip.subarray(start, end + 1),
|
||||
});
|
||||
}
|
||||
);
|
||||
await page.route('**/localhost:3000/xtream**', async (route) => {
|
||||
const original = new URL(route.request().url());
|
||||
if (original.searchParams.get('action') !== 'get_series_info') {
|
||||
await route.fallback();
|
||||
return;
|
||||
}
|
||||
const mockUrl = new URL(`${MOCK_SERVER}/xtream`);
|
||||
original.searchParams.forEach((value, key) => {
|
||||
if (key !== 'targetId') {
|
||||
mockUrl.searchParams.set(key, value);
|
||||
}
|
||||
});
|
||||
const response = await route.fetch({ url: mockUrl.toString() });
|
||||
const body = (await response.json()) as {
|
||||
payload: {
|
||||
episodes?: Record<
|
||||
string,
|
||||
Array<{ container_extension: string }>
|
||||
>;
|
||||
};
|
||||
};
|
||||
for (const episodes of Object.values(body.payload.episodes ?? {})) {
|
||||
for (const episode of episodes) {
|
||||
episode.container_extension = 'mp4';
|
||||
}
|
||||
}
|
||||
await route.fulfill({ response, json: body });
|
||||
});
|
||||
await routeEpisodeClip(page);
|
||||
await rewriteSeriesEpisodesToMp4(page);
|
||||
|
||||
// Persist the HTML5 engine first: Video.js rejects the mock's
|
||||
// video/matroska source type before any bytes are sniffed.
|
||||
await page.goto('/workspace/settings/playback');
|
||||
await page.locator('[data-test-id="select-video-player"]').click();
|
||||
await page
|
||||
.getByRole('option', { name: 'HTML5 video player', exact: true })
|
||||
.click();
|
||||
const saveButton = page.getByRole('button', { name: 'Save changes' });
|
||||
await saveButton.click();
|
||||
await expect(saveButton).toBeHidden();
|
||||
|
||||
const categories = (await (
|
||||
await request.get(
|
||||
`${MOCK_SERVER}/player_api.php?username=${DEFAULT_USERNAME}&password=${DEFAULT_PASSWORD}&action=get_series_categories`
|
||||
)
|
||||
).json()) as Array<{ category_id: string; category_name: string }>;
|
||||
const category = categories[0];
|
||||
const seriesItems = (await (
|
||||
await request.get(
|
||||
`${MOCK_SERVER}/player_api.php?username=${DEFAULT_USERNAME}&password=${DEFAULT_PASSWORD}&action=get_series&category_id=${category.category_id}`
|
||||
)
|
||||
).json()) as Array<{ name: string; series_id: number }>;
|
||||
const targetSeries = seriesItems[0];
|
||||
|
||||
await page.goto('/');
|
||||
await addXtreamPortal(page);
|
||||
await page.goto(page.url().replace(/\/vod.*$/, '/series'));
|
||||
const categoryItem = page
|
||||
.locator('.context-panel .category-item')
|
||||
.filter({ hasText: category.category_name })
|
||||
.first();
|
||||
await expect(categoryItem).toBeVisible({ timeout: 10_000 });
|
||||
await categoryItem.click();
|
||||
const seriesCard = page
|
||||
.locator('app-grid-list mat-card')
|
||||
.filter({ hasText: targetSeries.name })
|
||||
.first();
|
||||
await expect(seriesCard).toBeVisible({ timeout: 10_000 });
|
||||
await seriesCard.click();
|
||||
|
||||
const episodeCards = page.locator('.episode-card');
|
||||
await expect(episodeCards).toHaveCount(8, { timeout: 15_000 });
|
||||
await episodeCards.first().click();
|
||||
|
||||
const playerView = page.locator(
|
||||
'app-portal-inline-player app-web-player-view'
|
||||
await selectWebPlayer(page, 'HTML5 video player');
|
||||
const { playerView, video } = await playFirstSeriesEpisode(
|
||||
page,
|
||||
request
|
||||
);
|
||||
await expect(playerView.locator('app-html-video-player')).toBeVisible({
|
||||
timeout: 15_000,
|
||||
});
|
||||
const video = playerView.locator('video');
|
||||
const waitForMetadata = () =>
|
||||
expect
|
||||
.poll(() =>
|
||||
@@ -1093,6 +1132,19 @@ test.describe('@xtream inline series fullscreen', () => {
|
||||
await expect.poll(fullscreenOwner).not.toBeNull();
|
||||
await expect(overlayTitle).toContainText('S01E01');
|
||||
|
||||
// Chromium leaves the clicked button focused; the shared controls
|
||||
// release that focus so Space reaches the playback shortcut instead
|
||||
// of activating the button again (which left fullscreen while the
|
||||
// video kept playing).
|
||||
const paused = () =>
|
||||
video.evaluate((el) => (el as HTMLVideoElement).paused);
|
||||
await expect.poll(paused).toBe(false);
|
||||
await page.keyboard.press('Space');
|
||||
await expect.poll(paused).toBe(true);
|
||||
expect(await fullscreenOwner()).not.toBeNull();
|
||||
await page.keyboard.press('Space');
|
||||
await expect.poll(paused).toBe(false);
|
||||
|
||||
// Manual switch from the shared controls' own next-episode button.
|
||||
await playerView.hover();
|
||||
await playerView
|
||||
@@ -1119,3 +1171,51 @@ test.describe('@xtream inline series fullscreen', () => {
|
||||
expect(await fullscreenOwner()).not.toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('@xtream vendor-chrome Video.js shortcuts', () => {
|
||||
test.skip(
|
||||
({ browserName }) => browserName !== 'chromium',
|
||||
'DOM fullscreen assertions target Chromium'
|
||||
);
|
||||
|
||||
test('keep working after a mouse click on a control-bar button', async ({
|
||||
page,
|
||||
request,
|
||||
}) => {
|
||||
await routeEpisodeClip(page);
|
||||
await rewriteSeriesEpisodesToMp4(page);
|
||||
// Video.js with the shared controls opted out: the vendor control bar.
|
||||
await selectWebPlayer(page, 'Video.js player', false);
|
||||
const { playerView, video } = await playFirstSeriesEpisode(
|
||||
page,
|
||||
request
|
||||
);
|
||||
await expect(playerView.locator('app-vjs-player')).toBeVisible({
|
||||
timeout: 15_000,
|
||||
});
|
||||
await expect(playerView.locator('app-player-controls')).toHaveCount(0);
|
||||
const paused = () =>
|
||||
video.evaluate((el) => (el as HTMLVideoElement).paused);
|
||||
const fullscreenOwner = () =>
|
||||
page.evaluate(() => document.fullscreenElement?.tagName ?? null);
|
||||
await expect.poll(paused).toBe(false);
|
||||
|
||||
// Chromium leaves the clicked Video.js button focused, and a focused
|
||||
// Video.js component swallows every key; the legacy chrome releases
|
||||
// that focus so Space and M reach the playback shortcuts instead of
|
||||
// Space activating the button again (which left fullscreen while the
|
||||
// video kept playing).
|
||||
await playerView.hover();
|
||||
await playerView.locator('.vjs-fullscreen-control').click();
|
||||
await expect.poll(fullscreenOwner).not.toBeNull();
|
||||
await page.keyboard.press('Space');
|
||||
await expect.poll(paused).toBe(true);
|
||||
expect(await fullscreenOwner()).not.toBeNull();
|
||||
await page.keyboard.press('Space');
|
||||
await expect.poll(paused).toBe(false);
|
||||
await page.keyboard.press('m');
|
||||
await expect
|
||||
.poll(() => video.evaluate((el) => (el as HTMLVideoElement).muted))
|
||||
.toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,5 @@
|
||||
import { FormBuilder } from '@angular/forms';
|
||||
import { Settings } from '@iptvnator/shared/interfaces';
|
||||
import { Settings, VideoPlayer } from '@iptvnator/shared/interfaces';
|
||||
import {
|
||||
createSettingsForm,
|
||||
createSettingsFromFormValue,
|
||||
@@ -34,3 +34,113 @@ describe('settings form utils — strip country prefix', () => {
|
||||
expect(settings.stripCountryPrefix).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('settings form utils — startup window mode', () => {
|
||||
const formBuilder = new FormBuilder();
|
||||
|
||||
it('defaults the form control to a normal window', () => {
|
||||
const form = createSettingsForm(formBuilder, true);
|
||||
|
||||
expect(form.getRawValue().startupWindowMode).toBe('normal');
|
||||
});
|
||||
|
||||
it('carries the chosen mode into the settings object', () => {
|
||||
const form = createSettingsForm(formBuilder, true);
|
||||
form.patchValue({ startupWindowMode: 'fullscreen' });
|
||||
|
||||
const settings = createSettingsFromFormValue(form, {} as Settings);
|
||||
|
||||
expect(settings.startupWindowMode).toBe('fullscreen');
|
||||
});
|
||||
|
||||
it('collapses a missing or unknown form value to normal', () => {
|
||||
const form = createSettingsForm(formBuilder, true);
|
||||
form.patchValue({
|
||||
startupWindowMode: null as unknown as 'normal',
|
||||
});
|
||||
expect(
|
||||
createSettingsFromFormValue(form, {} as Settings).startupWindowMode
|
||||
).toBe('normal');
|
||||
|
||||
form.patchValue({
|
||||
startupWindowMode: 'kiosk' as unknown as 'normal',
|
||||
});
|
||||
expect(
|
||||
createSettingsFromFormValue(form, {} as Settings).startupWindowMode
|
||||
).toBe('normal');
|
||||
});
|
||||
});
|
||||
|
||||
describe('settings form utils — embedded MPV session options', () => {
|
||||
const formBuilder = new FormBuilder();
|
||||
|
||||
it('defaults to no extra options and auto-reconnect on', () => {
|
||||
const form = createSettingsForm(formBuilder, true);
|
||||
|
||||
expect(form.getRawValue().embeddedMpvExtraOptions).toBe('');
|
||||
expect(form.getRawValue().embeddedMpvAutoReconnect).toBe(true);
|
||||
expect(form.valid).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects forbidden keys and malformed lines in the options field', () => {
|
||||
const form = createSettingsForm(formBuilder, true);
|
||||
form.patchValue({ player: VideoPlayer.EmbeddedMpv });
|
||||
const control = form.get('embeddedMpvExtraOptions');
|
||||
|
||||
control?.setValue('hwdec=auto\nvo=null\nnot an option');
|
||||
|
||||
expect(control?.errors).toEqual({
|
||||
invalidLines: ['not an option'],
|
||||
forbiddenKeys: ['vo'],
|
||||
});
|
||||
expect(form.valid).toBe(false);
|
||||
|
||||
control?.setValue('hwdec=auto\ncache-secs=30');
|
||||
|
||||
expect(control?.errors).toBeNull();
|
||||
});
|
||||
|
||||
it('ignores extra-option errors while another player is selected', () => {
|
||||
const form = createSettingsForm(formBuilder, true);
|
||||
form.patchValue({ player: VideoPlayer.EmbeddedMpv });
|
||||
const control = form.get('embeddedMpvExtraOptions');
|
||||
control?.setValue('vo=null');
|
||||
expect(form.valid).toBe(false);
|
||||
|
||||
form.patchValue({ player: VideoPlayer.VideoJs });
|
||||
|
||||
expect(control?.errors).toBeNull();
|
||||
expect(form.valid).toBe(true);
|
||||
|
||||
form.patchValue({ player: VideoPlayer.EmbeddedMpv });
|
||||
|
||||
expect(control?.errors).toEqual({ forbiddenKeys: ['vo'] });
|
||||
expect(form.valid).toBe(false);
|
||||
});
|
||||
|
||||
it('carries canonical options and the reconnect opt-out into the settings object', () => {
|
||||
const form = createSettingsForm(formBuilder, true);
|
||||
form.patchValue({
|
||||
embeddedMpvExtraOptions: ' --hwdec = auto \r\n\ncache-secs=30 ',
|
||||
embeddedMpvAutoReconnect: false,
|
||||
});
|
||||
|
||||
const settings = createSettingsFromFormValue(form, {} as Settings);
|
||||
|
||||
expect(settings.embeddedMpvExtraOptions).toBe(
|
||||
'hwdec=auto\ncache-secs=30'
|
||||
);
|
||||
expect(settings.embeddedMpvAutoReconnect).toBe(false);
|
||||
});
|
||||
|
||||
it('falls back to auto-reconnect on when the form value is missing', () => {
|
||||
const form = createSettingsForm(formBuilder, true);
|
||||
form.patchValue({
|
||||
embeddedMpvAutoReconnect: null as unknown as boolean,
|
||||
});
|
||||
|
||||
const settings = createSettingsFromFormValue(form, {} as Settings);
|
||||
|
||||
expect(settings.embeddedMpvAutoReconnect).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -1,7 +1,9 @@
|
||||
import {
|
||||
AbstractControl,
|
||||
FormArray,
|
||||
FormBuilder,
|
||||
FormControl,
|
||||
ValidationErrors,
|
||||
Validators,
|
||||
} from '@angular/forms';
|
||||
import {
|
||||
@@ -11,16 +13,36 @@ import {
|
||||
EpgViewMode,
|
||||
Language,
|
||||
normalizeDashboardRailsSettings,
|
||||
normalizeEmbeddedMpvExtraOptions,
|
||||
normalizeExternalPlayerArguments,
|
||||
normalizeStartupWindowMode,
|
||||
Settings,
|
||||
StartupBehavior,
|
||||
StartupWindowMode,
|
||||
StreamFormat,
|
||||
Theme,
|
||||
validateEmbeddedMpvExtraOptions,
|
||||
VideoPlayer,
|
||||
} from '@iptvnator/shared/interfaces';
|
||||
|
||||
export const EPG_URL_PATTERN = /^(http|https|file):\/\/[^ "]+$/;
|
||||
|
||||
/**
|
||||
* Rejects malformed lines and the option keys the embed depends on, so a
|
||||
* value that the main process would have to drop never gets saved.
|
||||
*/
|
||||
export function embeddedMpvExtraOptionsValidator(
|
||||
control: AbstractControl
|
||||
): ValidationErrors | null {
|
||||
// Only the embedded MPV player consumes these lines. While another player
|
||||
// is selected the field is hidden, and a stale error must not keep the
|
||||
// whole settings form unsaveable.
|
||||
if (control.parent?.get('player')?.value !== VideoPlayer.EmbeddedMpv) {
|
||||
return null;
|
||||
}
|
||||
return validateEmbeddedMpvExtraOptions(control.value);
|
||||
}
|
||||
|
||||
export function createEpgUrlControl(value = ''): FormControl<string | null> {
|
||||
return new FormControl(value, [Validators.pattern(EPG_URL_PATTERN)]);
|
||||
}
|
||||
@@ -29,7 +51,7 @@ export function createSettingsForm(
|
||||
formBuilder: FormBuilder,
|
||||
supportsEpg: boolean
|
||||
) {
|
||||
return formBuilder.group({
|
||||
const form = formBuilder.group({
|
||||
player: [VideoPlayer.VideoJs],
|
||||
webPlayerSharedControls: true,
|
||||
playerAmbientMode: false,
|
||||
@@ -60,6 +82,7 @@ export function createSettingsForm(
|
||||
DEFAULT_DASHBOARD_RAILS_SETTINGS.tmdbRecommendations,
|
||||
}),
|
||||
startupBehavior: StartupBehavior.FirstView,
|
||||
startupWindowMode: 'normal' as StartupWindowMode,
|
||||
showExternalPlaybackBar: true,
|
||||
stripCountryPrefix: false,
|
||||
theme: Theme.SystemTheme,
|
||||
@@ -81,6 +104,8 @@ export function createSettingsForm(
|
||||
],
|
||||
recordingFolder: '',
|
||||
embeddedMpvFrameCopy: false,
|
||||
embeddedMpvExtraOptions: ['', [embeddedMpvExtraOptionsValidator]],
|
||||
embeddedMpvAutoReconnect: true,
|
||||
coverSize: 'medium' as CoverSize,
|
||||
...(supportsEpg
|
||||
? {
|
||||
@@ -93,6 +118,13 @@ export function createSettingsForm(
|
||||
apiKey: DEFAULT_TMDB_SETTINGS.apiKey ?? '',
|
||||
}),
|
||||
});
|
||||
// The extra-options validator reads the sibling `player` control, which
|
||||
// Angular does not track as a dependency: re-validate on player changes
|
||||
// so a hidden field's stale error cannot block Save.
|
||||
form.get('player')?.valueChanges.subscribe(() =>
|
||||
form.get('embeddedMpvExtraOptions')?.updateValueAndValidity()
|
||||
);
|
||||
return form;
|
||||
}
|
||||
|
||||
export type SettingsForm = ReturnType<typeof createSettingsForm>;
|
||||
@@ -134,6 +166,7 @@ export function createSettingsFromFormValue(
|
||||
showDashboard: value.showDashboard ?? true,
|
||||
dashboardRails: normalizeDashboardRailsSettings(value.dashboardRails),
|
||||
startupBehavior: value.startupBehavior ?? StartupBehavior.FirstView,
|
||||
startupWindowMode: normalizeStartupWindowMode(value.startupWindowMode),
|
||||
showExternalPlaybackBar: value.showExternalPlaybackBar ?? true,
|
||||
stripCountryPrefix: value.stripCountryPrefix ?? false,
|
||||
theme: value.theme ?? Theme.SystemTheme,
|
||||
@@ -151,6 +184,10 @@ export function createSettingsFromFormValue(
|
||||
remoteControlPort: Number(value.remoteControlPort ?? 8765),
|
||||
recordingFolder: value.recordingFolder ?? '',
|
||||
embeddedMpvFrameCopy: value.embeddedMpvFrameCopy ?? false,
|
||||
embeddedMpvExtraOptions: normalizeEmbeddedMpvExtraOptions(
|
||||
value.embeddedMpvExtraOptions
|
||||
),
|
||||
embeddedMpvAutoReconnect: value.embeddedMpvAutoReconnect ?? true,
|
||||
coverSize: value.coverSize ?? 'medium',
|
||||
epgUrl,
|
||||
preferUploadedEpgOverXtream:
|
||||
|
||||
@@ -144,6 +144,42 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (supportsStartupWindowMode()) {
|
||||
<div class="setting-item" data-test-id="startup-window-mode-setting">
|
||||
<div class="setting-item__meta">
|
||||
<h4>{{ 'SETTINGS.STARTUP_WINDOW_MODE' | translate }}</h4>
|
||||
<p>
|
||||
{{ 'SETTINGS.STARTUP_WINDOW_MODE_DESCRIPTION' | translate }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="setting-item__control">
|
||||
<mat-form-field appearance="outline" subscriptSizing="dynamic">
|
||||
<mat-label>{{
|
||||
'SETTINGS.STARTUP_WINDOW_MODE' | translate
|
||||
}}</mat-label>
|
||||
<mat-select
|
||||
formControlName="startupWindowMode"
|
||||
data-test-id="select-startup-window-mode"
|
||||
>
|
||||
@for (
|
||||
option of startupWindowModeOptions();
|
||||
track option.value
|
||||
) {
|
||||
<mat-option
|
||||
[value]="option.value"
|
||||
[attr.data-test-id]="
|
||||
'startup-window-mode-' + option.value
|
||||
"
|
||||
>
|
||||
{{ option.labelKey | translate }}
|
||||
</mat-option>
|
||||
}
|
||||
</mat-select>
|
||||
</mat-form-field>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
<div class="setting-item">
|
||||
<div class="setting-item__meta">
|
||||
<h4>{{ 'SETTINGS.SHOW_CAPTIONS' | translate }}</h4>
|
||||
|
||||
@@ -10,6 +10,7 @@ import { CoverSize, Language, Theme } from '@iptvnator/shared/interfaces';
|
||||
import {
|
||||
CoverSizeOption,
|
||||
StartupBehaviorOption,
|
||||
StartupWindowModeOption,
|
||||
ThemeOption,
|
||||
} from './settings.models';
|
||||
|
||||
@@ -34,6 +35,10 @@ export class SettingsGeneralSectionComponent {
|
||||
readonly themeOptions = input.required<ThemeOption[]>();
|
||||
readonly coverSizeOptions = input.required<CoverSizeOption[]>();
|
||||
readonly startupBehaviorOptions = input.required<StartupBehaviorOption[]>();
|
||||
readonly startupWindowModeOptions =
|
||||
input.required<StartupWindowModeOption[]>();
|
||||
/** Desktop only: the window-mode select needs the main-process mirror and F11 */
|
||||
readonly supportsStartupWindowMode = input(false);
|
||||
|
||||
readonly selectTheme = output<Theme>();
|
||||
readonly selectCoverSize = output<CoverSize>();
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
SettingsPlayerOption,
|
||||
SettingsSection,
|
||||
StartupBehaviorOption,
|
||||
StartupWindowModeOption,
|
||||
ThemeOption,
|
||||
} from './settings.models';
|
||||
|
||||
@@ -74,6 +75,21 @@ export const SETTINGS_STARTUP_BEHAVIOR_OPTIONS: StartupBehaviorOption[] = [
|
||||
},
|
||||
];
|
||||
|
||||
export const SETTINGS_STARTUP_WINDOW_MODE_OPTIONS: StartupWindowModeOption[] = [
|
||||
{
|
||||
value: 'normal',
|
||||
labelKey: 'SETTINGS.STARTUP_WINDOW_MODE_NORMAL',
|
||||
},
|
||||
{
|
||||
value: 'maximized',
|
||||
labelKey: 'SETTINGS.STARTUP_WINDOW_MODE_MAXIMIZED',
|
||||
},
|
||||
{
|
||||
value: 'fullscreen',
|
||||
labelKey: 'SETTINGS.STARTUP_WINDOW_MODE_FULLSCREEN',
|
||||
},
|
||||
];
|
||||
|
||||
export const SETTINGS_OS_PLAYER_OPTIONS: SettingsPlayerOption[] = [
|
||||
{
|
||||
id: VideoPlayer.MPV,
|
||||
|
||||
@@ -1,8 +1,4 @@
|
||||
<section
|
||||
class="settings-group"
|
||||
id="playback"
|
||||
[formGroup]="form()"
|
||||
>
|
||||
<section class="settings-group" id="playback" [formGroup]="form()">
|
||||
<header class="settings-group__header">
|
||||
<div class="settings-group__header-icon">
|
||||
<mat-icon>play_circle</mat-icon>
|
||||
@@ -235,6 +231,101 @@
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (form().value.player === 'embedded-mpv') {
|
||||
<div
|
||||
class="setting-item"
|
||||
data-test-id="embedded-mpv-extra-options-setting"
|
||||
>
|
||||
<div class="setting-item__meta">
|
||||
<h4>
|
||||
{{ 'SETTINGS.EMBEDDED_MPV_EXTRA_OPTIONS' | translate }}
|
||||
</h4>
|
||||
<p>
|
||||
{{
|
||||
'SETTINGS.EMBEDDED_MPV_EXTRA_OPTIONS_DESCRIPTION'
|
||||
| translate
|
||||
}}
|
||||
</p>
|
||||
</div>
|
||||
<div class="setting-item__control">
|
||||
<mat-form-field
|
||||
appearance="outline"
|
||||
subscriptSizing="dynamic"
|
||||
>
|
||||
<mat-label for="embeddedMpvExtraOptions">{{
|
||||
'SETTINGS.EMBEDDED_MPV_EXTRA_OPTIONS' | translate
|
||||
}}</mat-label>
|
||||
<textarea
|
||||
matInput
|
||||
id="embeddedMpvExtraOptions"
|
||||
formControlName="embeddedMpvExtraOptions"
|
||||
placeholder="hwdec=auto-safe"
|
||||
rows="3"
|
||||
spellcheck="false"
|
||||
autocomplete="off"
|
||||
></textarea>
|
||||
<mat-hint>{{
|
||||
'SETTINGS.EMBEDDED_MPV_EXTRA_OPTIONS_HINT'
|
||||
| translate
|
||||
}}</mat-hint>
|
||||
@if (
|
||||
form().get('embeddedMpvExtraOptions')?.errors;
|
||||
as errors
|
||||
) {
|
||||
<mat-error
|
||||
data-test-id="embedded-mpv-extra-options-error"
|
||||
>
|
||||
@if (errors['forbiddenKeys']) {
|
||||
{{
|
||||
'SETTINGS.EMBEDDED_MPV_EXTRA_OPTIONS_FORBIDDEN_KEYS'
|
||||
| translate
|
||||
: {
|
||||
keys: errors[
|
||||
'forbiddenKeys'
|
||||
].join(', '),
|
||||
}
|
||||
}}
|
||||
} @else if (errors['invalidLines']) {
|
||||
{{
|
||||
'SETTINGS.EMBEDDED_MPV_EXTRA_OPTIONS_INVALID_LINES'
|
||||
| translate
|
||||
: {
|
||||
lines: errors[
|
||||
'invalidLines'
|
||||
].join(', '),
|
||||
}
|
||||
}}
|
||||
}
|
||||
</mat-error>
|
||||
}
|
||||
</mat-form-field>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="setting-item"
|
||||
data-test-id="embedded-mpv-auto-reconnect-setting"
|
||||
>
|
||||
<div class="setting-item__meta">
|
||||
<h4>
|
||||
{{ 'SETTINGS.EMBEDDED_MPV_AUTO_RECONNECT' | translate }}
|
||||
</h4>
|
||||
<p>
|
||||
{{
|
||||
'SETTINGS.EMBEDDED_MPV_AUTO_RECONNECT_DESCRIPTION'
|
||||
| translate
|
||||
}}
|
||||
</p>
|
||||
</div>
|
||||
<div class="setting-item__control setting-item__toggle">
|
||||
<mat-checkbox
|
||||
formControlName="embeddedMpvAutoReconnect"
|
||||
data-test-id="embedded-mpv-auto-reconnect-toggle"
|
||||
></mat-checkbox>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
<div class="setting-item" data-test-id="recording-folder-setting">
|
||||
<div class="setting-item__meta">
|
||||
<h4>{{ 'SETTINGS.RECORDING_FOLDER_LABEL' | translate }}</h4>
|
||||
|
||||
@@ -186,6 +186,9 @@ describe('SettingsComponent form', () => {
|
||||
expect(component.settingsForm.value.startupBehavior).toBe(
|
||||
StartupBehavior.FirstView
|
||||
);
|
||||
expect(component.settingsForm.value.startupWindowMode).toBe(
|
||||
'normal'
|
||||
);
|
||||
});
|
||||
|
||||
it('disables dashboard surface controls when the dashboard is off', async () => {
|
||||
|
||||
@@ -110,6 +110,8 @@
|
||||
[themeOptions]="themeOptions"
|
||||
[coverSizeOptions]="coverSizeOptions"
|
||||
[startupBehaviorOptions]="startupBehaviorOptions"
|
||||
[startupWindowModeOptions]="startupWindowModeOptions"
|
||||
[supportsStartupWindowMode]="supportsStartupWindowMode"
|
||||
(selectTheme)="form.selectTheme($event)"
|
||||
(selectCoverSize)="form.selectCoverSize($event)"
|
||||
/>
|
||||
|
||||
@@ -37,6 +37,7 @@ import {
|
||||
SETTINGS_COVER_SIZE_OPTIONS,
|
||||
SETTINGS_EPG_VIEW_MODE_OPTIONS,
|
||||
SETTINGS_STARTUP_BEHAVIOR_OPTIONS,
|
||||
SETTINGS_STARTUP_WINDOW_MODE_OPTIONS,
|
||||
SETTINGS_THEME_OPTIONS,
|
||||
} from './settings-options';
|
||||
import { SettingsPlaybackSectionComponent } from './settings-playback-section.component';
|
||||
@@ -140,6 +141,7 @@ export class SettingsComponent
|
||||
this.runtime.supportsExternalPlayerPathSettings;
|
||||
readonly supportsVodMultiSource = this.vodSourceDiscovery.isAvailable;
|
||||
readonly supportsRemoteControl = this.runtime.supportsRemoteControl;
|
||||
readonly supportsStartupWindowMode = this.runtime.supportsStartupWindowMode;
|
||||
|
||||
/** Settings form object */
|
||||
readonly settingsForm = this.form.form;
|
||||
@@ -158,6 +160,7 @@ export class SettingsComponent
|
||||
readonly themeOptions = SETTINGS_THEME_OPTIONS;
|
||||
readonly coverSizeOptions = SETTINGS_COVER_SIZE_OPTIONS;
|
||||
readonly startupBehaviorOptions = SETTINGS_STARTUP_BEHAVIOR_OPTIONS;
|
||||
readonly startupWindowModeOptions = SETTINGS_STARTUP_WINDOW_MODE_OPTIONS;
|
||||
readonly epgViewModeOptions = SETTINGS_EPG_VIEW_MODE_OPTIONS;
|
||||
|
||||
readonly sectionNavItems: SettingsSection[] = buildSettingsSectionNavItems({
|
||||
|
||||
@@ -2,6 +2,7 @@ import {
|
||||
CoverSize,
|
||||
EpgViewMode,
|
||||
StartupBehavior,
|
||||
StartupWindowMode,
|
||||
Theme,
|
||||
VideoPlayer,
|
||||
} from '@iptvnator/shared/interfaces';
|
||||
@@ -24,6 +25,11 @@ export interface StartupBehaviorOption {
|
||||
labelKey: string;
|
||||
}
|
||||
|
||||
export interface StartupWindowModeOption {
|
||||
value: StartupWindowMode;
|
||||
labelKey: string;
|
||||
}
|
||||
|
||||
export interface CoverSizeOption {
|
||||
value: CoverSize;
|
||||
icon: string;
|
||||
|
||||
@@ -95,6 +95,7 @@ export const DEFAULT_SETTINGS = {
|
||||
showCaptions: false,
|
||||
showDashboard: true,
|
||||
startupBehavior: StartupBehavior.FirstView,
|
||||
startupWindowMode: 'normal',
|
||||
showExternalPlaybackBar: true,
|
||||
stripCountryPrefix: false,
|
||||
theme: Theme.SystemTheme,
|
||||
@@ -109,6 +110,8 @@ export const DEFAULT_SETTINGS = {
|
||||
epgUrl: [],
|
||||
recordingFolder: '',
|
||||
embeddedMpvFrameCopy: false,
|
||||
embeddedMpvExtraOptions: '',
|
||||
embeddedMpvAutoReconnect: true,
|
||||
coverSize: 'medium',
|
||||
dashboardRails: DEFAULT_DASHBOARD_RAILS,
|
||||
preferUploadedEpgOverXtream: false,
|
||||
|
||||
@@ -368,6 +368,11 @@
|
||||
"STARTUP_BEHAVIOR_DESCRIPTION": "اختر ما إذا كان IPTVnator يفتح أول عرض متاح في مساحة العمل أو يستعيد آخر عرض على مستوى القسم. العرض الأول يعني لوحة التحكم عند تفعيلها، وإلا فالمصادر.",
|
||||
"STARTUP_BEHAVIOR_FIRST_VIEW": "أول عرض متاح",
|
||||
"STARTUP_BEHAVIOR_RESTORE_LAST_VIEW": "استعادة آخر عرض",
|
||||
"STARTUP_WINDOW_MODE": "نافذة التطبيق عند البدء",
|
||||
"STARTUP_WINDOW_MODE_DESCRIPTION": "اختر كيف تُفتح نافذة سطح المكتب: بآخر حجم لها، أو مكبّرة، أو بملء الشاشة. يُطبَّق عند التشغيل التالي. اضغط F11 في أي وقت للدخول إلى وضع ملء الشاشة أو الخروج منه.",
|
||||
"STARTUP_WINDOW_MODE_NORMAL": "آخر حجم للنافذة",
|
||||
"STARTUP_WINDOW_MODE_MAXIMIZED": "مكبّرة",
|
||||
"STARTUP_WINDOW_MODE_FULLSCREEN": "ملء الشاشة",
|
||||
"SHOW_CAPTIONS_DESCRIPTION": "عرض أو إخفاء الترجمات افتراضيًا",
|
||||
"SHOW_EXTERNAL_PLAYBACK_BAR": "عرض شريط التشغيل الحالي",
|
||||
"SHOW_EXTERNAL_PLAYBACK_BAR_DESCRIPTION": "إبقاء شريط حالة المشغل الخارجي مرئيًا في أسفل مساحة العمل على سطح المكتب.",
|
||||
@@ -492,6 +497,13 @@
|
||||
"EMBEDDED_MPV_FRAME_COPY": "MPV المدمج: محرك نسخ الإطارات (تجريبي)",
|
||||
"EMBEDDED_MPV_FRAME_COPY_DESCRIPTION": "يُرسم الفيديو على لوحة التطبيق بحيث تظهر القوائم ومربعات الحوار فوقه.",
|
||||
"EMBEDDED_MPV_FRAME_COPY_RESTART": "يسري المفعول بعد إعادة تشغيل التطبيق.",
|
||||
"EMBEDDED_MPV_EXTRA_OPTIONS": "خيارات إضافية لـ MPV المدمج",
|
||||
"EMBEDDED_MPV_EXTRA_OPTIONS_DESCRIPTION": "خيارات libmpv متقدمة للمشغّل المدمج، زوج key=value واحد في كل سطر (بدون -- في البداية). تُطبَّق في كل محرك بعد الخيارات المضمّنة، وتسري عند بدء جلسة MPV المدمج التالية.",
|
||||
"EMBEDDED_MPV_EXTRA_OPTIONS_HINT": "مثال: hwdec=auto-safe. تُرفض الخيارات التي يعتمد عليها التضمين نفسه (wid، vo، input-ipc-server، …).",
|
||||
"EMBEDDED_MPV_EXTRA_OPTIONS_INVALID_LINES": "ليس سطرًا بصيغة key=value: {{lines}}",
|
||||
"EMBEDDED_MPV_EXTRA_OPTIONS_FORBIDDEN_KEYS": "لا يمكن تغيير هذه الخيارات للمشغّل المدمج: {{keys}}",
|
||||
"EMBEDDED_MPV_AUTO_RECONNECT": "إعادة الاتصال بالبث المنقطع تلقائيًا",
|
||||
"EMBEDDED_MPV_AUTO_RECONNECT_DESCRIPTION": "عند انقطاع بث قيد التشغيل، يعيد المشغّل المدمج تحميله بفواصل زمنية متزايدة، حتى 6 محاولات، قبل أن يتوقف. أما البث الذي لم يبدأ أصلًا فلا يزال يحتاج إلى إعادة المحاولة يدويًا.",
|
||||
"VOD_AUTO_FAILOVER": "التبديل التلقائي للمصدر عند فشل التشغيل",
|
||||
"VOD_AUTO_FAILOVER_DESCRIPTION": "عند فشل تشغيل فيلم وتوفّر الفيلم نفسه في قائمة تشغيل أخرى، يتم التبديل إليها تلقائيًا مع الاحتفاظ بموضع التشغيل. الخيار معطّل افتراضيًا، ويُعلَن عن التبديل دائمًا لأن المصدر الآخر قد يحمل دبلجة مختلفة."
|
||||
},
|
||||
@@ -515,6 +527,8 @@
|
||||
"RETRY": "إعادة المحاولة",
|
||||
"STALLED": "يستغرق بدء هذا البث وقتًا أطول من المتوقع.",
|
||||
"PLAYBACK_FAILED": "فشل التشغيل عبر MPV المضمّن.",
|
||||
"RECONNECTING": "انقطع الاتصال. جارٍ إعادة الاتصال… المحاولة {{attempt}} من {{maxAttempts}}",
|
||||
"STREAM_ENDED": "انتهى البث المباشر.",
|
||||
"CHECKING_SUPPORT": "جارٍ التحقق من دعم MPV المضمّن...",
|
||||
"NOT_AVAILABLE": "MPV المضمّن غير متاح في هذه البيئة.",
|
||||
"LOADING_STREAM": "جارٍ تحميل البث…",
|
||||
@@ -1574,6 +1588,7 @@
|
||||
"OPEN_GLOBAL_SEARCH": "فتح البحث العام",
|
||||
"OPEN_RECENTLY_VIEWED": "فتح العناصر المشاهدة مؤخرًا",
|
||||
"SUBMIT_SHELL_SEARCH": "إرسال بحث مساحة العمل",
|
||||
"TOGGLE_WINDOW_FULLSCREEN": "تبديل ملء الشاشة لنافذة التطبيق",
|
||||
"TOGGLE_SIDEBAR": "تبديل الشريط الجانبي",
|
||||
"M3U_CHANNEL_NUMBER": "اختيار قناة M3U حسب الرقم",
|
||||
"PLAY_PAUSE": "تشغيل أو إيقاف تشغيل Embedded MPV مؤقتًا",
|
||||
|
||||
@@ -368,6 +368,11 @@
|
||||
"STARTUP_BEHAVIOR_DESCRIPTION": "اختار واش IPTVnator يحل أول عرض متوفر في مساحة العمل ولا يسترجع آخر عرض على مستوى القسم. أول عرض كيعني لوحة التحكم إلى كانت مفعلة، إلا فالا فهي Sources.",
|
||||
"STARTUP_BEHAVIOR_FIRST_VIEW": "أول عرض متوفر",
|
||||
"STARTUP_BEHAVIOR_RESTORE_LAST_VIEW": "استرجع آخر عرض",
|
||||
"STARTUP_WINDOW_MODE": "النافذة عند البدء",
|
||||
"STARTUP_WINDOW_MODE_DESCRIPTION": "اختار كيفاش تتحل نافذة سطح المكتب: بآخر حجم ديالها، ولا مكبّرة، ولا بملء الشاشة. كيتطبق فالتشغيل الجاي. ضغط على F11 فأي وقت باش تدخل ولا تخرج من ملء الشاشة.",
|
||||
"STARTUP_WINDOW_MODE_NORMAL": "آخر حجم ديال النافذة",
|
||||
"STARTUP_WINDOW_MODE_MAXIMIZED": "مكبّرة",
|
||||
"STARTUP_WINDOW_MODE_FULLSCREEN": "ملء الشاشة",
|
||||
"SHOW_CAPTIONS_DESCRIPTION": "وري ولا خفي الترجمة افتراضياً",
|
||||
"SHOW_EXTERNAL_PLAYBACK_BAR": "وري شريط التشغيل الحالي",
|
||||
"SHOW_EXTERNAL_PLAYBACK_BAR_DESCRIPTION": "خلي شريط حالة المشغل الخارجي ظاهر في الأسفل ديال مساحة العمل على سطح المكتب.",
|
||||
@@ -492,6 +497,13 @@
|
||||
"EMBEDDED_MPV_FRAME_COPY": "MPV المدمج: محرك نسخ الإطارات (تجريبي)",
|
||||
"EMBEDDED_MPV_FRAME_COPY_DESCRIPTION": "الفيديو كيترسم على لوحة التطبيق باش القوائم والحوارات يبانو فوقو.",
|
||||
"EMBEDDED_MPV_FRAME_COPY_RESTART": "غادي يخدم من بعد ما تعاود تشغل التطبيق.",
|
||||
"EMBEDDED_MPV_EXTRA_OPTIONS": "خيارات زايدة لـ MPV المدمج",
|
||||
"EMBEDDED_MPV_EXTRA_OPTIONS_DESCRIPTION": "خيارات libmpv متقدمة للمشغل المدمج، زوج key=value واحد فكل سطر (بلا -- فالبداية). كيتطبقو فكل محرك من بعد الخيارات المدمجة، وكيبداو يخدمو ملي تبدا جلسة MPV المدمج الجاية.",
|
||||
"EMBEDDED_MPV_EXTRA_OPTIONS_HINT": "مثال: hwdec=auto-safe. الخيارات اللي كيعتمد عليها التضمين بنفسو (wid، vo، input-ipc-server، …) كيتّرفضو.",
|
||||
"EMBEDDED_MPV_EXTRA_OPTIONS_INVALID_LINES": "ماشي سطر بصيغة key=value: {{lines}}",
|
||||
"EMBEDDED_MPV_EXTRA_OPTIONS_FORBIDDEN_KEYS": "هاد الخيارات ما يمكنش تتبدل فالمشغل المدمج: {{keys}}",
|
||||
"EMBEDDED_MPV_AUTO_RECONNECT": "عاود الاتصال أوتوماتيكياً بالبث اللي تقطع",
|
||||
"EMBEDDED_MPV_AUTO_RECONNECT_DESCRIPTION": "ملي شي بث خدام كيتقطع، المشغل المدمج كيعاود يشرجو بفواصل كتزيد شوية بشوية، حتى 6 محاولات، قبل ما يوقف. البث اللي عمرو ما بدا كيبقى محتاج تحاول مرة أخرى يدوياً.",
|
||||
"VOD_AUTO_FAILOVER": "بدل المصدر أوتوماتيكياً عند خطأ فالتشغيل",
|
||||
"VOD_AUTO_FAILOVER_DESCRIPTION": "ملي شي فيلم ما يتشغلش وكان نفس الفيلم كاين ف قائمة تشغيل أخرى، التطبيق كيبدل ليها أوتوماتيكياً وكيحافظ على التوقيت. معطل افتراضياً؛ التبديل ديما كيتعلن حيت مصدر آخر يمكن يكون فيه دوبلاج مختلف."
|
||||
},
|
||||
@@ -515,6 +527,8 @@
|
||||
"RETRY": "حاول مرة أخرى",
|
||||
"STALLED": "هاد البث كياخد وقت كثر من المتوقع باش يبدا.",
|
||||
"PLAYBACK_FAILED": "فشل التشغيل بـ MPV المدمج.",
|
||||
"RECONNECTING": "تقطع الاتصال. كنعاودو الاتصال… المحاولة {{attempt}} من {{maxAttempts}}",
|
||||
"STREAM_ENDED": "البث المباشر سالا.",
|
||||
"CHECKING_SUPPORT": "جاري التحقق من دعم MPV المدمج...",
|
||||
"NOT_AVAILABLE": "MPV المدمج ما متوفرش فهاد البيئة.",
|
||||
"LOADING_STREAM": "جاري تشريج البث…",
|
||||
@@ -1574,6 +1588,7 @@
|
||||
"OPEN_GLOBAL_SEARCH": "حل البحث العام",
|
||||
"OPEN_RECENTLY_VIEWED": "حل المتشافين مؤخراً",
|
||||
"SUBMIT_SHELL_SEARCH": "رسل بحث مساحة العمل",
|
||||
"TOGGLE_WINDOW_FULLSCREEN": "بدل ملء الشاشة ديال نافذة التطبيق",
|
||||
"TOGGLE_SIDEBAR": "بدل عرض الشريط الجانبي",
|
||||
"M3U_CHANNEL_NUMBER": "اختار قناة M3U بالرقم",
|
||||
"PLAY_PAUSE": "شغل ولا وقف مؤقتاً تشغيل MPV المدمج",
|
||||
|
||||
@@ -368,6 +368,11 @@
|
||||
"STARTUP_BEHAVIOR_DESCRIPTION": "Выберыце, ці адкрывае IPTVnator першы даступны выгляд працоўнай прасторы, ці аднаўляе ваш апошні выгляд на ўзроўні раздзела. Першы выгляд азначае «Панэль», калі ўключана, інакш «Крыніцы».",
|
||||
"STARTUP_BEHAVIOR_FIRST_VIEW": "Першы даступны выгляд",
|
||||
"STARTUP_BEHAVIOR_RESTORE_LAST_VIEW": "Аднавіць апошні выгляд",
|
||||
"STARTUP_WINDOW_MODE": "Акно пры запуску",
|
||||
"STARTUP_WINDOW_MODE_DESCRIPTION": "Выберыце, як адкрываецца акно праграмы: у апошнім памеры, разгорнутым ці на ўвесь экран. Ужываецца пры наступным запуску. Націсніце F11 у любы момант, каб увайсці ў поўнаэкранны рэжым або выйсці з яго.",
|
||||
"STARTUP_WINDOW_MODE_NORMAL": "Апошні памер акна",
|
||||
"STARTUP_WINDOW_MODE_MAXIMIZED": "Разгорнутае",
|
||||
"STARTUP_WINDOW_MODE_FULLSCREEN": "На ўвесь экран",
|
||||
"SHOW_CAPTIONS_DESCRIPTION": "Паказаць або схаваць субтытры па змаўчанні.",
|
||||
"SHOW_EXTERNAL_PLAYBACK_BAR": "Паказаць ніжнюю панэль прайгравання",
|
||||
"SHOW_EXTERNAL_PLAYBACK_BAR_DESCRIPTION": "Пакідаць панэль стану знешняга прайгравальніка бачнай унізе працоўнай прасторы на дэсктопе.",
|
||||
@@ -492,6 +497,13 @@
|
||||
"EMBEDDED_MPV_FRAME_COPY": "Embedded MPV: рухавік frame-copy (эксперыментальна)",
|
||||
"EMBEDDED_MPV_FRAME_COPY_DESCRIPTION": "Відэа малюецца на канвасе праграмы, таму меню і дыялогі паказваюцца па-над ім.",
|
||||
"EMBEDDED_MPV_FRAME_COPY_RESTART": "Пачне дзейнічаць пасля перазапуску праграмы.",
|
||||
"EMBEDDED_MPV_EXTRA_OPTIONS": "Дадатковыя параметры ўбудаванага MPV",
|
||||
"EMBEDDED_MPV_EXTRA_OPTIONS_DESCRIPTION": "Пашыраныя параметры libmpv для ўбудаванага прайгравальніка, адна пара key=value на радок (без -- у пачатку). Яны прымяняюцца ў кожным рухавіку пасля ўбудаваных параметраў і пачынаюць дзейнічаць пры запуску наступнага сеанса ўбудаванага MPV.",
|
||||
"EMBEDDED_MPV_EXTRA_OPTIONS_HINT": "Прыклад: hwdec=auto-safe. Параметры, ад якіх залежыць само ўбудаванне (wid, vo, input-ipc-server, …), адхіляюцца.",
|
||||
"EMBEDDED_MPV_EXTRA_OPTIONS_INVALID_LINES": "Не радок выгляду key=value: {{lines}}",
|
||||
"EMBEDDED_MPV_EXTRA_OPTIONS_FORBIDDEN_KEYS": "Гэтыя параметры нельга змяніць для ўбудаванага прайгравальніка: {{keys}}",
|
||||
"EMBEDDED_MPV_AUTO_RECONNECT": "Аўтаматычна перападключацца да абарваных патокаў",
|
||||
"EMBEDDED_MPV_AUTO_RECONNECT_DESCRIPTION": "Калі паток, які прайграецца, абрываецца, убудаваны прайгравальнік перазагружае яго з усё большымі паўзамі, да 6 спроб, перш чым спыніцца. Паток, які так і не запусціўся, па-ранейшаму патрабуе ручнога «Паўтарыць».",
|
||||
"VOD_AUTO_FAILOVER": "Аўтапераключэнне крыніцы пры памылцы прайгравання",
|
||||
"VOD_AUTO_FAILOVER_DESCRIPTION": "Калі фільм не прайграецца, а той жа фільм ёсць у іншым плэйлісце, аўтаматычна пераключацца на яго, захоўваючы пазіцыю прайгравання. Па змаўчанні выключана; пра пераключэнне заўсёды паведамляецца, бо ў іншай крыніцы можа быць іншая агучка."
|
||||
},
|
||||
@@ -515,6 +527,8 @@
|
||||
"RETRY": "Паўтарыць",
|
||||
"STALLED": "Гэты паток запускаецца даўжэй, чым чакалася.",
|
||||
"PLAYBACK_FAILED": "Не ўдалося прайграць праз убудаваны MPV.",
|
||||
"RECONNECTING": "Злучэнне страчана. Перападключэнне… спроба {{attempt}} з {{maxAttempts}}",
|
||||
"STREAM_ENDED": "Прамая трансляцыя скончылася.",
|
||||
"CHECKING_SUPPORT": "Праверка падтрымкі ўбудаванага MPV...",
|
||||
"NOT_AVAILABLE": "Убудаваны MPV недаступны ў гэтым асяроддзі.",
|
||||
"LOADING_STREAM": "Загрузка патоку…",
|
||||
@@ -1574,6 +1588,7 @@
|
||||
"OPEN_GLOBAL_SEARCH": "Адкрыць глабальны пошук",
|
||||
"OPEN_RECENTLY_VIEWED": "Адкрыць нядаўна прагледжанае",
|
||||
"SUBMIT_SHELL_SEARCH": "Выканаць пошук у працоўнай вобласці",
|
||||
"TOGGLE_WINDOW_FULLSCREEN": "Пераключыць поўнаэкранны рэжым акна праграмы",
|
||||
"TOGGLE_SIDEBAR": "Пераключыць бакавую панэль",
|
||||
"M3U_CHANNEL_NUMBER": "Выбраць M3U-канал па нумары",
|
||||
"PLAY_PAUSE": "Прайграць або прыпыніць Embedded MPV",
|
||||
|
||||
@@ -368,6 +368,11 @@
|
||||
"STARTUP_BEHAVIOR_DESCRIPTION": "Legen Sie fest, ob IPTVnator die erste verfügbare Workspace-Ansicht oder Ihre zuletzt geöffnete Abschnittsansicht startet. Erste Ansicht bedeutet Dashboard, wenn aktiviert, sonst Quellen.",
|
||||
"STARTUP_BEHAVIOR_FIRST_VIEW": "Erste verfügbare Ansicht",
|
||||
"STARTUP_BEHAVIOR_RESTORE_LAST_VIEW": "Letzte Ansicht wiederherstellen",
|
||||
"STARTUP_WINDOW_MODE": "Fenster beim Start",
|
||||
"STARTUP_WINDOW_MODE_DESCRIPTION": "Legen Sie fest, wie das Desktop-Fenster geöffnet wird: in der letzten Größe, maximiert oder im Vollbild. Gilt ab dem nächsten Start. Mit F11 können Sie das Vollbild jederzeit aktivieren oder verlassen.",
|
||||
"STARTUP_WINDOW_MODE_NORMAL": "Letzte Fenstergröße",
|
||||
"STARTUP_WINDOW_MODE_MAXIMIZED": "Maximiert",
|
||||
"STARTUP_WINDOW_MODE_FULLSCREEN": "Vollbild",
|
||||
"SHOW_CAPTIONS_DESCRIPTION": "Untertitel standardmäßig ein- oder ausblenden",
|
||||
"SHOW_EXTERNAL_PLAYBACK_BAR": "Now-Playing-Fußleiste anzeigen",
|
||||
"SHOW_EXTERNAL_PLAYBACK_BAR_DESCRIPTION": "Zeigt die Statusleiste für externe Player unten im Arbeitsbereich auf dem Desktop an.",
|
||||
@@ -492,6 +497,13 @@
|
||||
"EMBEDDED_MPV_FRAME_COPY": "Embedded MPV: Frame-Copy-Engine (experimentell)",
|
||||
"EMBEDDED_MPV_FRAME_COPY_DESCRIPTION": "Das Video wird auf die App-Leinwand gerendert, sodass Menüs und Dialoge darüber liegen.",
|
||||
"EMBEDDED_MPV_FRAME_COPY_RESTART": "Wird nach einem Neustart der App wirksam.",
|
||||
"EMBEDDED_MPV_EXTRA_OPTIONS": "Zusätzliche Optionen für eingebettetes MPV",
|
||||
"EMBEDDED_MPV_EXTRA_OPTIONS_DESCRIPTION": "Erweiterte libmpv-Optionen für den eingebetteten Player, ein key=value-Paar pro Zeile (ohne vorangestelltes --). Sie gelten für jede Engine, werden nach den integrierten Optionen angewendet und werden wirksam, sobald die nächste Sitzung mit eingebettetem MPV startet.",
|
||||
"EMBEDDED_MPV_EXTRA_OPTIONS_HINT": "Beispiel: hwdec=auto-safe. Optionen, auf die der eingebettete Player selbst angewiesen ist (wid, vo, input-ipc-server, …), werden abgelehnt.",
|
||||
"EMBEDDED_MPV_EXTRA_OPTIONS_INVALID_LINES": "Keine key=value-Zeile: {{lines}}",
|
||||
"EMBEDDED_MPV_EXTRA_OPTIONS_FORBIDDEN_KEYS": "Diese Optionen können für den eingebetteten Player nicht geändert werden: {{keys}}",
|
||||
"EMBEDDED_MPV_AUTO_RECONNECT": "Unterbrochene Streams automatisch neu verbinden",
|
||||
"EMBEDDED_MPV_AUTO_RECONNECT_DESCRIPTION": "Wenn ein laufender Stream unterbrochen wird, lädt der eingebettete Player ihn in bis zu 6 Versuchen mit immer längeren Wartezeiten neu, bevor er aufgibt. Ein Stream, der gar nicht erst gestartet ist, erfordert weiterhin ein manuelles „Erneut versuchen“.",
|
||||
"VOD_AUTO_FAILOVER": "Quelle bei Wiedergabefehler automatisch wechseln",
|
||||
"VOD_AUTO_FAILOVER_DESCRIPTION": "Wenn sich ein Film nicht abspielen lässt und derselbe Film in einer anderen Playlist vorhanden ist, wird automatisch dorthin gewechselt und die Wiedergabeposition beibehalten. Standardmäßig deaktiviert; der Wechsel wird immer angekündigt, da eine andere Quelle eine andere Synchronfassung enthalten kann."
|
||||
},
|
||||
@@ -515,6 +527,8 @@
|
||||
"RETRY": "Erneut versuchen",
|
||||
"STALLED": "Der Start dieses Streams dauert länger als erwartet.",
|
||||
"PLAYBACK_FAILED": "Die Wiedergabe mit eingebettetem MPV ist fehlgeschlagen.",
|
||||
"RECONNECTING": "Verbindung unterbrochen. Verbindung wird wiederhergestellt… Versuch {{attempt}} von {{maxAttempts}}",
|
||||
"STREAM_ENDED": "Der Live-Stream ist beendet.",
|
||||
"CHECKING_SUPPORT": "Unterstützung für eingebettetes MPV wird geprüft...",
|
||||
"NOT_AVAILABLE": "Eingebettetes MPV ist in dieser Umgebung nicht verfügbar.",
|
||||
"LOADING_STREAM": "Stream wird geladen…",
|
||||
@@ -1574,6 +1588,7 @@
|
||||
"OPEN_GLOBAL_SEARCH": "Globale Suche öffnen",
|
||||
"OPEN_RECENTLY_VIEWED": "Kürzlich angesehene Elemente öffnen",
|
||||
"SUBMIT_SHELL_SEARCH": "Arbeitsbereichssuche absenden",
|
||||
"TOGGLE_WINDOW_FULLSCREEN": "Vollbild für das App-Fenster umschalten",
|
||||
"TOGGLE_SIDEBAR": "Seitenleiste umschalten",
|
||||
"M3U_CHANNEL_NUMBER": "M3U-Kanal per Nummer auswählen",
|
||||
"PLAY_PAUSE": "Eingebettete MPV-Wiedergabe starten oder pausieren",
|
||||
|
||||
@@ -368,6 +368,11 @@
|
||||
"STARTUP_BEHAVIOR_DESCRIPTION": "Επιλέξτε αν το IPTVnator ανοίγει την πρώτη διαθέσιμη προβολή του χώρου εργασίας ή επαναφέρει την τελευταία προβολή σας. Πρώτη προβολή σημαίνει ο πίνακας ελέγχου, όταν είναι ενεργοποιημένος, διαφορετικά οι Πηγές.",
|
||||
"STARTUP_BEHAVIOR_FIRST_VIEW": "Πρώτη διαθέσιμη προβολή",
|
||||
"STARTUP_BEHAVIOR_RESTORE_LAST_VIEW": "Επαναφορά τελευταίας προβολής",
|
||||
"STARTUP_WINDOW_MODE": "Παράθυρο κατά την εκκίνηση",
|
||||
"STARTUP_WINDOW_MODE_DESCRIPTION": "Επιλέξτε πώς ανοίγει το παράθυρο της εφαρμογής: στο τελευταίο του μέγεθος, μεγιστοποιημένο ή σε πλήρη οθόνη. Ισχύει από την επόμενη εκκίνηση. Πατήστε F11 οποιαδήποτε στιγμή για είσοδο ή έξοδο από την πλήρη οθόνη.",
|
||||
"STARTUP_WINDOW_MODE_NORMAL": "Τελευταίο μέγεθος παραθύρου",
|
||||
"STARTUP_WINDOW_MODE_MAXIMIZED": "Μεγιστοποιημένο",
|
||||
"STARTUP_WINDOW_MODE_FULLSCREEN": "Πλήρης οθόνη",
|
||||
"SHOW_CAPTIONS_DESCRIPTION": "Εμφάνιση ή απόκρυψη υποτίτλων από προεπιλογή",
|
||||
"SHOW_EXTERNAL_PLAYBACK_BAR": "Εμφάνιση υποσέλιδου αναπαραγωγής",
|
||||
"SHOW_EXTERNAL_PLAYBACK_BAR_DESCRIPTION": "Διατηρήστε ορατή τη γραμμή κατάστασης του εξωτερικού προγράμματος αναπαραγωγής στο κάτω μέρος του χώρου εργασίας στον υπολογιστή.",
|
||||
@@ -492,6 +497,13 @@
|
||||
"EMBEDDED_MPV_FRAME_COPY": "Embedded MPV: μηχανή frame-copy (πειραματικό)",
|
||||
"EMBEDDED_MPV_FRAME_COPY_DESCRIPTION": "Το βίντεο σχεδιάζεται στον καμβά της εφαρμογής, ώστε τα μενού και τα παράθυρα διαλόγου να εμφανίζονται από πάνω του.",
|
||||
"EMBEDDED_MPV_FRAME_COPY_RESTART": "Ισχύει μετά την επανεκκίνηση της εφαρμογής.",
|
||||
"EMBEDDED_MPV_EXTRA_OPTIONS": "Πρόσθετες επιλογές ενσωματωμένου MPV",
|
||||
"EMBEDDED_MPV_EXTRA_OPTIONS_DESCRIPTION": "Προηγμένες επιλογές libmpv για το ενσωματωμένο πρόγραμμα αναπαραγωγής, ένα ζεύγος key=value ανά γραμμή (χωρίς -- στην αρχή). Εφαρμόζονται σε κάθε μηχανή μετά τις ενσωματωμένες επιλογές και ισχύουν από την επόμενη συνεδρία του ενσωματωμένου MPV.",
|
||||
"EMBEDDED_MPV_EXTRA_OPTIONS_HINT": "Παράδειγμα: hwdec=auto-safe. Οι επιλογές από τις οποίες εξαρτάται η ίδια η ενσωμάτωση (wid, vo, input-ipc-server, …) απορρίπτονται.",
|
||||
"EMBEDDED_MPV_EXTRA_OPTIONS_INVALID_LINES": "Δεν είναι γραμμή key=value: {{lines}}",
|
||||
"EMBEDDED_MPV_EXTRA_OPTIONS_FORBIDDEN_KEYS": "Αυτές οι επιλογές δεν μπορούν να αλλάξουν για το ενσωματωμένο πρόγραμμα αναπαραγωγής: {{keys}}",
|
||||
"EMBEDDED_MPV_AUTO_RECONNECT": "Αυτόματη επανασύνδεση σε ροές που διακόπηκαν",
|
||||
"EMBEDDED_MPV_AUTO_RECONNECT_DESCRIPTION": "Όταν μια ροή που αναπαράγεται διακοπεί, το ενσωματωμένο πρόγραμμα αναπαραγωγής τη φορτώνει ξανά με αυξανόμενες καθυστερήσεις, έως 6 προσπάθειες, πριν τα παρατήσει. Μια ροή που δεν ξεκίνησε ποτέ εξακολουθεί να χρειάζεται χειροκίνητη Επανάληψη.",
|
||||
"VOD_AUTO_FAILOVER": "Αυτόματη αλλαγή πηγής σε σφάλμα αναπαραγωγής",
|
||||
"VOD_AUTO_FAILOVER_DESCRIPTION": "Όταν μια ταινία δεν μπορεί να αναπαραχθεί και η ίδια ταινία υπάρχει σε άλλη λίστα αναπαραγωγής, γίνεται αυτόματη μετάβαση σε αυτήν με διατήρηση της θέσης αναπαραγωγής. Απενεργοποιημένο από προεπιλογή· η αλλαγή ανακοινώνεται πάντα, επειδή μια άλλη πηγή μπορεί να έχει διαφορετική μεταγλώττιση."
|
||||
},
|
||||
@@ -515,6 +527,8 @@
|
||||
"RETRY": "Επανάληψη",
|
||||
"STALLED": "Η έναρξη αυτής της ροής καθυστερεί περισσότερο από το αναμενόμενο.",
|
||||
"PLAYBACK_FAILED": "Η αναπαραγωγή με το ενσωματωμένο MPV απέτυχε.",
|
||||
"RECONNECTING": "Η σύνδεση χάθηκε. Επανασύνδεση… προσπάθεια {{attempt}} από {{maxAttempts}}",
|
||||
"STREAM_ENDED": "Η ζωντανή ροή τερματίστηκε.",
|
||||
"CHECKING_SUPPORT": "Έλεγχος υποστήριξης ενσωματωμένου MPV...",
|
||||
"NOT_AVAILABLE": "Το ενσωματωμένο MPV δεν είναι διαθέσιμο σε αυτό το περιβάλλον.",
|
||||
"LOADING_STREAM": "Φόρτωση ροής…",
|
||||
@@ -1574,6 +1588,7 @@
|
||||
"OPEN_GLOBAL_SEARCH": "Άνοιγμα καθολικής αναζήτησης",
|
||||
"OPEN_RECENTLY_VIEWED": "Άνοιγμα πρόσφατων προβολών",
|
||||
"SUBMIT_SHELL_SEARCH": "Υποβολή αναζήτησης χώρου εργασίας",
|
||||
"TOGGLE_WINDOW_FULLSCREEN": "Εναλλαγή πλήρους οθόνης παραθύρου εφαρμογής",
|
||||
"TOGGLE_SIDEBAR": "Εναλλαγή πλαϊνής μπάρας",
|
||||
"M3U_CHANNEL_NUMBER": "Επιλογή καναλιού M3U με αριθμό",
|
||||
"PLAY_PAUSE": "Αναπαραγωγή ή παύση ενσωματωμένου MPV",
|
||||
|
||||
@@ -368,6 +368,11 @@
|
||||
"STARTUP_BEHAVIOR_DESCRIPTION": "Choose whether IPTVnator opens the first available workspace view or restores your last section-level view. First view means Dashboard when enabled, otherwise Sources.",
|
||||
"STARTUP_BEHAVIOR_FIRST_VIEW": "First available view",
|
||||
"STARTUP_BEHAVIOR_RESTORE_LAST_VIEW": "Restore last view",
|
||||
"STARTUP_WINDOW_MODE": "Window on startup",
|
||||
"STARTUP_WINDOW_MODE_DESCRIPTION": "Choose how the desktop window opens: at its last size, maximized, or fullscreen. Applies on the next launch. Press F11 at any time to enter or leave fullscreen.",
|
||||
"STARTUP_WINDOW_MODE_NORMAL": "Last window size",
|
||||
"STARTUP_WINDOW_MODE_MAXIMIZED": "Maximized",
|
||||
"STARTUP_WINDOW_MODE_FULLSCREEN": "Fullscreen",
|
||||
"SHOW_CAPTIONS_DESCRIPTION": "Show or hide subtitles by default",
|
||||
"SHOW_EXTERNAL_PLAYBACK_BAR": "Show now playing footer",
|
||||
"SHOW_EXTERNAL_PLAYBACK_BAR_DESCRIPTION": "Keep the external-player status bar visible at the bottom of the workspace on desktop.",
|
||||
@@ -492,6 +497,13 @@
|
||||
"EMBEDDED_MPV_FRAME_COPY": "Embedded MPV: frame-copy engine (experimental)",
|
||||
"EMBEDDED_MPV_FRAME_COPY_DESCRIPTION": "Render video onto the app canvas so menus and dialogs stack above it.",
|
||||
"EMBEDDED_MPV_FRAME_COPY_RESTART": "Takes effect after restarting the app.",
|
||||
"EMBEDDED_MPV_EXTRA_OPTIONS": "Extra embedded MPV options",
|
||||
"EMBEDDED_MPV_EXTRA_OPTIONS_DESCRIPTION": "Advanced libmpv options for the embedded player, one key=value pair per line (no leading --). They apply on every engine after the built-in options and take effect when the next embedded MPV session starts.",
|
||||
"EMBEDDED_MPV_EXTRA_OPTIONS_HINT": "Example: hwdec=auto-safe. Options the embed itself depends on (wid, vo, input-ipc-server, …) are refused.",
|
||||
"EMBEDDED_MPV_EXTRA_OPTIONS_INVALID_LINES": "Not a key=value line: {{lines}}",
|
||||
"EMBEDDED_MPV_EXTRA_OPTIONS_FORBIDDEN_KEYS": "These options cannot be changed for the embedded player: {{keys}}",
|
||||
"EMBEDDED_MPV_AUTO_RECONNECT": "Reconnect dropped streams automatically",
|
||||
"EMBEDDED_MPV_AUTO_RECONNECT_DESCRIPTION": "When a playing stream drops, the embedded player reloads it with increasing delays, up to 6 attempts, before giving up. A stream that never started still needs a manual Retry.",
|
||||
"VOD_AUTO_FAILOVER": "Auto-switch source on playback error",
|
||||
"VOD_AUTO_FAILOVER_DESCRIPTION": "When a movie fails to play and the same film exists in another playlist, switch to it automatically and keep the timecode. Off by default; the switch is always announced because another source can carry a different dub."
|
||||
},
|
||||
@@ -515,6 +527,8 @@
|
||||
"RETRY": "Retry",
|
||||
"STALLED": "This stream is taking longer than expected to start.",
|
||||
"PLAYBACK_FAILED": "Embedded MPV playback failed.",
|
||||
"RECONNECTING": "Connection lost. Reconnecting… attempt {{attempt}} of {{maxAttempts}}",
|
||||
"STREAM_ENDED": "The live stream ended.",
|
||||
"CHECKING_SUPPORT": "Checking embedded MPV support...",
|
||||
"NOT_AVAILABLE": "Embedded MPV is not available in this environment.",
|
||||
"LOADING_STREAM": "Loading stream…",
|
||||
@@ -1574,6 +1588,7 @@
|
||||
"OPEN_GLOBAL_SEARCH": "Open global search",
|
||||
"OPEN_RECENTLY_VIEWED": "Open recently viewed",
|
||||
"SUBMIT_SHELL_SEARCH": "Submit workspace search",
|
||||
"TOGGLE_WINDOW_FULLSCREEN": "Toggle app window fullscreen",
|
||||
"TOGGLE_SIDEBAR": "Toggle sidebar",
|
||||
"M3U_CHANNEL_NUMBER": "Select M3U channel by number",
|
||||
"PLAY_PAUSE": "Play or pause embedded MPV playback",
|
||||
|
||||
@@ -368,6 +368,11 @@
|
||||
"STARTUP_BEHAVIOR_DESCRIPTION": "Elige si IPTVnator abre la primera vista disponible del espacio de trabajo o restaura la última vista a nivel de sección. Primera vista significa Panel cuando está activado, de lo contrario Fuentes.",
|
||||
"STARTUP_BEHAVIOR_FIRST_VIEW": "Primera vista disponible",
|
||||
"STARTUP_BEHAVIOR_RESTORE_LAST_VIEW": "Restaurar última vista",
|
||||
"STARTUP_WINDOW_MODE": "Ventana al iniciar",
|
||||
"STARTUP_WINDOW_MODE_DESCRIPTION": "Elige cómo se abre la ventana de escritorio: con su último tamaño, maximizada o a pantalla completa. Se aplica en el próximo inicio. Pulsa F11 en cualquier momento para entrar o salir de la pantalla completa.",
|
||||
"STARTUP_WINDOW_MODE_NORMAL": "Último tamaño de ventana",
|
||||
"STARTUP_WINDOW_MODE_MAXIMIZED": "Maximizada",
|
||||
"STARTUP_WINDOW_MODE_FULLSCREEN": "Pantalla completa",
|
||||
"SHOW_CAPTIONS_DESCRIPTION": "Mostrar u ocultar subtítulos por defecto",
|
||||
"SHOW_EXTERNAL_PLAYBACK_BAR": "Mostrar pie de reproducción actual",
|
||||
"SHOW_EXTERNAL_PLAYBACK_BAR_DESCRIPTION": "Mantén visible la barra de estado del reproductor externo en la parte inferior del espacio de trabajo en escritorio.",
|
||||
@@ -492,6 +497,13 @@
|
||||
"EMBEDDED_MPV_FRAME_COPY": "Embedded MPV: motor frame-copy (experimental)",
|
||||
"EMBEDDED_MPV_FRAME_COPY_DESCRIPTION": "El vídeo se dibuja en el lienzo de la aplicación, de modo que los menús y diálogos quedan encima.",
|
||||
"EMBEDDED_MPV_FRAME_COPY_RESTART": "Se aplica tras reiniciar la aplicación.",
|
||||
"EMBEDDED_MPV_EXTRA_OPTIONS": "Opciones adicionales de MPV integrado",
|
||||
"EMBEDDED_MPV_EXTRA_OPTIONS_DESCRIPTION": "Opciones avanzadas de libmpv para el reproductor integrado, un par key=value por línea (sin -- al inicio). Se aplican en todos los motores después de las opciones integradas y surten efecto al iniciarse la siguiente sesión de MPV integrado.",
|
||||
"EMBEDDED_MPV_EXTRA_OPTIONS_HINT": "Ejemplo: hwdec=auto-safe. Las opciones de las que depende el propio reproductor integrado (wid, vo, input-ipc-server, …) se rechazan.",
|
||||
"EMBEDDED_MPV_EXTRA_OPTIONS_INVALID_LINES": "Sin formato key=value: {{lines}}",
|
||||
"EMBEDDED_MPV_EXTRA_OPTIONS_FORBIDDEN_KEYS": "Estas opciones no se pueden cambiar para el reproductor integrado: {{keys}}",
|
||||
"EMBEDDED_MPV_AUTO_RECONNECT": "Reconectar automáticamente los streams interrumpidos",
|
||||
"EMBEDDED_MPV_AUTO_RECONNECT_DESCRIPTION": "Cuando un stream en reproducción se interrumpe, el reproductor integrado lo vuelve a cargar con intervalos cada vez más largos, hasta 6 intentos, antes de desistir. Un stream que nunca llegó a iniciarse sigue necesitando un Reintentar manual.",
|
||||
"VOD_AUTO_FAILOVER": "Cambiar de fuente automáticamente al fallar la reproducción",
|
||||
"VOD_AUTO_FAILOVER_DESCRIPTION": "Cuando una película no se puede reproducir y la misma película está en otra lista, cambia a ella automáticamente y conserva la posición de reproducción. Desactivado de forma predeterminada; el cambio siempre se avisa, porque otra fuente puede tener un doblaje distinto."
|
||||
},
|
||||
@@ -515,6 +527,8 @@
|
||||
"RETRY": "Reintentar",
|
||||
"STALLED": "Este stream está tardando más de lo esperado en iniciarse.",
|
||||
"PLAYBACK_FAILED": "Falló la reproducción de MPV integrado.",
|
||||
"RECONNECTING": "Conexión perdida. Reconectando… intento {{attempt}} de {{maxAttempts}}",
|
||||
"STREAM_ENDED": "La transmisión en vivo terminó.",
|
||||
"CHECKING_SUPPORT": "Comprobando compatibilidad con MPV integrado...",
|
||||
"NOT_AVAILABLE": "MPV integrado no está disponible en este entorno.",
|
||||
"LOADING_STREAM": "Cargando stream…",
|
||||
@@ -1574,6 +1588,7 @@
|
||||
"OPEN_GLOBAL_SEARCH": "Abrir búsqueda global",
|
||||
"OPEN_RECENTLY_VIEWED": "Abrir vistos recientemente",
|
||||
"SUBMIT_SHELL_SEARCH": "Enviar búsqueda del espacio de trabajo",
|
||||
"TOGGLE_WINDOW_FULLSCREEN": "Alternar pantalla completa de la ventana de la aplicación",
|
||||
"TOGGLE_SIDEBAR": "Mostrar u ocultar barra lateral",
|
||||
"M3U_CHANNEL_NUMBER": "Seleccionar canal M3U por número",
|
||||
"PLAY_PAUSE": "Reproducir o pausar MPV integrado",
|
||||
|
||||
@@ -368,6 +368,11 @@
|
||||
"STARTUP_BEHAVIOR_DESCRIPTION": "Choisissez si IPTVnator ouvre la première vue disponible de l'espace de travail ou restaure votre dernière vue de section. Première vue signifie Tableau de bord lorsqu'il est activé, sinon Sources.",
|
||||
"STARTUP_BEHAVIOR_FIRST_VIEW": "Première vue disponible",
|
||||
"STARTUP_BEHAVIOR_RESTORE_LAST_VIEW": "Restaurer la dernière vue",
|
||||
"STARTUP_WINDOW_MODE": "Fenêtre au démarrage",
|
||||
"STARTUP_WINDOW_MODE_DESCRIPTION": "Choisissez comment la fenêtre de bureau s'ouvre : à sa dernière taille, agrandie ou en plein écran. S'applique au prochain démarrage. Appuyez sur F11 à tout moment pour passer en plein écran ou le quitter.",
|
||||
"STARTUP_WINDOW_MODE_NORMAL": "Dernière taille de fenêtre",
|
||||
"STARTUP_WINDOW_MODE_MAXIMIZED": "Agrandie",
|
||||
"STARTUP_WINDOW_MODE_FULLSCREEN": "Plein écran",
|
||||
"SHOW_CAPTIONS_DESCRIPTION": "Afficher ou masquer les sous-titres par défaut",
|
||||
"SHOW_EXTERNAL_PLAYBACK_BAR": "Afficher le pied de page de lecture en cours",
|
||||
"SHOW_EXTERNAL_PLAYBACK_BAR_DESCRIPTION": "Garde la barre d'état du lecteur externe visible en bas de l'espace de travail sur ordinateur.",
|
||||
@@ -492,6 +497,13 @@
|
||||
"EMBEDDED_MPV_FRAME_COPY": "Embedded MPV : moteur frame-copy (expérimental)",
|
||||
"EMBEDDED_MPV_FRAME_COPY_DESCRIPTION": "La vidéo est rendue sur le canevas de l'application, les menus et boîtes de dialogue s'affichent donc au-dessus.",
|
||||
"EMBEDDED_MPV_FRAME_COPY_RESTART": "Prend effet après le redémarrage de l'application.",
|
||||
"EMBEDDED_MPV_EXTRA_OPTIONS": "Options MPV intégré supplémentaires",
|
||||
"EMBEDDED_MPV_EXTRA_OPTIONS_DESCRIPTION": "Options libmpv avancées pour le lecteur intégré, une paire key=value par ligne (sans -- au début). Elles s'appliquent sur tous les moteurs après les options intégrées et prennent effet au démarrage de la prochaine session MPV intégrée.",
|
||||
"EMBEDDED_MPV_EXTRA_OPTIONS_HINT": "Exemple : hwdec=auto-safe. Les options dont dépend l'intégration (wid, vo, input-ipc-server, …) sont refusées.",
|
||||
"EMBEDDED_MPV_EXTRA_OPTIONS_INVALID_LINES": "Format key=value non respecté : {{lines}}",
|
||||
"EMBEDDED_MPV_EXTRA_OPTIONS_FORBIDDEN_KEYS": "Ces options ne peuvent pas être modifiées pour le lecteur intégré : {{keys}}",
|
||||
"EMBEDDED_MPV_AUTO_RECONNECT": "Reconnecter automatiquement les flux interrompus",
|
||||
"EMBEDDED_MPV_AUTO_RECONNECT_DESCRIPTION": "Lorsqu'un flux en cours de lecture est interrompu, le lecteur intégré le recharge avec des délais croissants, jusqu'à 6 tentatives, avant d'abandonner. Un flux qui n'a jamais démarré doit toujours être relancé manuellement avec « Réessayer ».",
|
||||
"VOD_AUTO_FAILOVER": "Changer de source automatiquement en cas d'erreur de lecture",
|
||||
"VOD_AUTO_FAILOVER_DESCRIPTION": "Lorsqu'un film ne parvient pas à démarrer et que le même film existe dans une autre liste de lecture, bascule automatiquement vers celle-ci en conservant la position de lecture. Désactivé par défaut ; le changement est toujours annoncé, car une autre source peut proposer un doublage différent."
|
||||
},
|
||||
@@ -515,6 +527,8 @@
|
||||
"RETRY": "Réessayer",
|
||||
"STALLED": "Ce flux met plus de temps que prévu à démarrer.",
|
||||
"PLAYBACK_FAILED": "Échec de la lecture avec MPV intégré.",
|
||||
"RECONNECTING": "Connexion perdue. Reconnexion… tentative {{attempt}} sur {{maxAttempts}}",
|
||||
"STREAM_ENDED": "La diffusion en direct est terminée.",
|
||||
"CHECKING_SUPPORT": "Vérification de la prise en charge de MPV intégré...",
|
||||
"NOT_AVAILABLE": "MPV intégré n'est pas disponible dans cet environnement.",
|
||||
"LOADING_STREAM": "Chargement du flux…",
|
||||
@@ -1574,6 +1588,7 @@
|
||||
"OPEN_GLOBAL_SEARCH": "Ouvrir la recherche globale",
|
||||
"OPEN_RECENTLY_VIEWED": "Ouvrir les éléments récemment consultés",
|
||||
"SUBMIT_SHELL_SEARCH": "Lancer la recherche dans l’espace de travail",
|
||||
"TOGGLE_WINDOW_FULLSCREEN": "Activer ou quitter le plein écran de la fenêtre",
|
||||
"TOGGLE_SIDEBAR": "Afficher ou masquer la barre latérale",
|
||||
"M3U_CHANNEL_NUMBER": "Sélectionner une chaîne M3U par numéro",
|
||||
"PLAY_PAUSE": "Lire ou mettre en pause la lecture MPV intégrée",
|
||||
|
||||
@@ -368,6 +368,11 @@
|
||||
"STARTUP_BEHAVIOR_DESCRIPTION": "Válassza ki, hogy induláskor az első elérhető munkaterület-nézet nyíljon meg, vagy az IPTVnator állítsa vissza a legutóbb használt szakasznézetet. Az első nézet bekapcsolt Vezérlőpult esetén a Vezérlőpult, egyébként a Források.",
|
||||
"STARTUP_BEHAVIOR_FIRST_VIEW": "Első elérhető nézet",
|
||||
"STARTUP_BEHAVIOR_RESTORE_LAST_VIEW": "Legutóbbi nézet visszaállítása",
|
||||
"STARTUP_WINDOW_MODE": "Ablak indításkor",
|
||||
"STARTUP_WINDOW_MODE_DESCRIPTION": "Válassza ki, hogyan nyíljon meg az asztali ablak: a legutóbbi méretben, maximalizálva vagy teljes képernyőn. A következő indítástól érvényes. Az F11 billentyűvel bármikor be- vagy kiléphet a teljes képernyős módból.",
|
||||
"STARTUP_WINDOW_MODE_NORMAL": "Legutóbbi ablakméret",
|
||||
"STARTUP_WINDOW_MODE_MAXIMIZED": "Maximalizált",
|
||||
"STARTUP_WINDOW_MODE_FULLSCREEN": "Teljes képernyő",
|
||||
"SHOW_CAPTIONS_DESCRIPTION": "Feliratok alapértelmezett megjelenítése vagy elrejtése",
|
||||
"SHOW_EXTERNAL_PLAYBACK_BAR": "Aktuális lejátszás sávjának megjelenítése",
|
||||
"SHOW_EXTERNAL_PLAYBACK_BAR_DESCRIPTION": "Az asztali alkalmazásban maradjon látható a munkaterület alján a külső lejátszó állapotsávja.",
|
||||
@@ -492,6 +497,13 @@
|
||||
"EMBEDDED_MPV_FRAME_COPY": "Beágyazott MPV: képkocka-másolásos megjelenítés (kísérleti)",
|
||||
"EMBEDDED_MPV_FRAME_COPY_DESCRIPTION": "A videó megjelenítése az alkalmazás rajzfelületén, hogy a menük és párbeszédpanelek fölötte jelenhessenek meg.",
|
||||
"EMBEDDED_MPV_FRAME_COPY_RESTART": "Az alkalmazás újraindítása után lép érvénybe.",
|
||||
"EMBEDDED_MPV_EXTRA_OPTIONS": "További beágyazott MPV-beállítások",
|
||||
"EMBEDDED_MPV_EXTRA_OPTIONS_DESCRIPTION": "Speciális libmpv-beállítások a beágyazott lejátszóhoz, soronként egy key=value pár (kezdő -- nélkül). Minden megjelenítőmotorra érvényesek, a beépített beállítások után kerülnek alkalmazásra, és a következő beágyazott MPV-munkamenet indításakor lépnek érvénybe.",
|
||||
"EMBEDDED_MPV_EXTRA_OPTIONS_HINT": "Példa: hwdec=auto-safe. A beágyazás működéséhez szükséges beállításokat (wid, vo, input-ipc-server, …) az alkalmazás elutasítja.",
|
||||
"EMBEDDED_MPV_EXTRA_OPTIONS_INVALID_LINES": "Nem key=value formátumú sor: {{lines}}",
|
||||
"EMBEDDED_MPV_EXTRA_OPTIONS_FORBIDDEN_KEYS": "Ezek a beállítások nem módosíthatók a beágyazott lejátszónál: {{keys}}",
|
||||
"EMBEDDED_MPV_AUTO_RECONNECT": "Megszakadt adatfolyamok automatikus újracsatlakoztatása",
|
||||
"EMBEDDED_MPV_AUTO_RECONNECT_DESCRIPTION": "Ha egy lejátszás alatt álló adatfolyam megszakad, a beágyazott lejátszó egyre hosszabb várakozással, legfeljebb 6 próbálkozással tölti be újra, mielőtt feladná. Az el sem indult adatfolyamhoz továbbra is kézi újrapróbálkozás szükséges.",
|
||||
"VOD_AUTO_FAILOVER": "Automatikus forrásváltás lejátszási hiba esetén",
|
||||
"VOD_AUTO_FAILOVER_DESCRIPTION": "Ha egy film lejátszása meghiúsul, és ugyanaz a film egy másik lejátszási listában is megtalálható, az alkalmazás automatikusan átvált rá, és megőrzi a lejátszási pozíciót. Alapértelmezés szerint kikapcsolva; a váltásról mindig értesítés jelenik meg, mert egy másik forráson eltérő szinkron lehet."
|
||||
},
|
||||
@@ -515,6 +527,8 @@
|
||||
"RETRY": "Újrapróbálkozás",
|
||||
"STALLED": "Az adatfolyam indítása a vártnál hosszabb ideig tart.",
|
||||
"PLAYBACK_FAILED": "Nem sikerült lejátszani a tartalmat a beágyazott MPV-vel.",
|
||||
"RECONNECTING": "A kapcsolat megszakadt. Újracsatlakozás… próbálkozás: {{attempt}}/{{maxAttempts}}",
|
||||
"STREAM_ENDED": "Az élő adás véget ért.",
|
||||
"CHECKING_SUPPORT": "A beágyazott MPV támogatásának ellenőrzése…",
|
||||
"NOT_AVAILABLE": "A beágyazott MPV ebben a környezetben nem érhető el.",
|
||||
"LOADING_STREAM": "Adatfolyam betöltése…",
|
||||
@@ -1574,6 +1588,7 @@
|
||||
"OPEN_GLOBAL_SEARCH": "Globális keresés megnyitása",
|
||||
"OPEN_RECENTLY_VIEWED": "Legutóbbi megtekintések megnyitása",
|
||||
"SUBMIT_SHELL_SEARCH": "Munkaterületi keresés indítása",
|
||||
"TOGGLE_WINDOW_FULLSCREEN": "Az alkalmazásablak teljes képernyős módjának be- vagy kikapcsolása",
|
||||
"TOGGLE_SIDEBAR": "Oldalsáv megjelenítése vagy elrejtése",
|
||||
"M3U_CHANNEL_NUMBER": "M3U-csatorna kiválasztása a számának beírásával",
|
||||
"PLAY_PAUSE": "A beágyazott MPV lejátszásának indítása vagy szüneteltetése",
|
||||
|
||||
@@ -368,6 +368,11 @@
|
||||
"STARTUP_BEHAVIOR_DESCRIPTION": "Scegli se IPTVnator apre la prima vista dell'area di lavoro disponibile o ripristina l'ultima vista a livello di sezione. La prima vista è la dashboard se attiva, altrimenti Sorgenti.",
|
||||
"STARTUP_BEHAVIOR_FIRST_VIEW": "Prima vista disponibile",
|
||||
"STARTUP_BEHAVIOR_RESTORE_LAST_VIEW": "Ripristina ultima vista",
|
||||
"STARTUP_WINDOW_MODE": "Finestra all'avvio",
|
||||
"STARTUP_WINDOW_MODE_DESCRIPTION": "Scegli come si apre la finestra dell'app: con l'ultima dimensione, ingrandita o a schermo intero. Si applica al prossimo avvio. Premi F11 in qualsiasi momento per entrare o uscire dallo schermo intero.",
|
||||
"STARTUP_WINDOW_MODE_NORMAL": "Ultima dimensione della finestra",
|
||||
"STARTUP_WINDOW_MODE_MAXIMIZED": "Ingrandita",
|
||||
"STARTUP_WINDOW_MODE_FULLSCREEN": "Schermo intero",
|
||||
"SHOW_CAPTIONS_DESCRIPTION": "Visualizza/nascondi i sottotitoli per impostazione predefinita",
|
||||
"SHOW_EXTERNAL_PLAYBACK_BAR": "Mostra footer In riproduzione",
|
||||
"SHOW_EXTERNAL_PLAYBACK_BAR_DESCRIPTION": "Mantieni visibile la barra di stato del lettore esterno in fondo all'area di lavoro su desktop.",
|
||||
@@ -492,6 +497,13 @@
|
||||
"EMBEDDED_MPV_FRAME_COPY": "Embedded MPV: motore frame-copy (sperimentale)",
|
||||
"EMBEDDED_MPV_FRAME_COPY_DESCRIPTION": "Il video viene disegnato sul canvas dell'app, così menu e finestre di dialogo appaiono sopra di esso.",
|
||||
"EMBEDDED_MPV_FRAME_COPY_RESTART": "Ha effetto dopo il riavvio dell'app.",
|
||||
"EMBEDDED_MPV_EXTRA_OPTIONS": "Opzioni aggiuntive per MPV integrato",
|
||||
"EMBEDDED_MPV_EXTRA_OPTIONS_DESCRIPTION": "Opzioni avanzate di libmpv per il lettore integrato, una coppia key=value per riga (senza -- iniziale). Si applicano a tutti i motori dopo le opzioni integrate dell'app e hanno effetto all'avvio della prossima sessione di MPV integrato.",
|
||||
"EMBEDDED_MPV_EXTRA_OPTIONS_HINT": "Esempio: hwdec=auto-safe. Le opzioni da cui dipende l'integrazione stessa (wid, vo, input-ipc-server, …) vengono rifiutate.",
|
||||
"EMBEDDED_MPV_EXTRA_OPTIONS_INVALID_LINES": "Non in formato key=value: {{lines}}",
|
||||
"EMBEDDED_MPV_EXTRA_OPTIONS_FORBIDDEN_KEYS": "Queste opzioni non possono essere modificate per il lettore integrato: {{keys}}",
|
||||
"EMBEDDED_MPV_AUTO_RECONNECT": "Riconnetti automaticamente gli stream interrotti",
|
||||
"EMBEDDED_MPV_AUTO_RECONNECT_DESCRIPTION": "Quando uno stream in riproduzione si interrompe, il lettore integrato lo ricarica a intervalli crescenti, fino a 6 tentativi, prima di rinunciare. Per uno stream che non è mai partito è comunque necessario premere Riprova manualmente.",
|
||||
"VOD_AUTO_FAILOVER": "Cambia sorgente automaticamente in caso di errore di riproduzione",
|
||||
"VOD_AUTO_FAILOVER_DESCRIPTION": "Quando la riproduzione di un film non riesce e lo stesso film è presente in un'altra playlist, l'app passa automaticamente a quella sorgente mantenendo la posizione di riproduzione. Disattivo per impostazione predefinita; il cambio viene sempre segnalato perché un'altra sorgente può avere un doppiaggio diverso."
|
||||
},
|
||||
@@ -515,6 +527,8 @@
|
||||
"RETRY": "Riprova",
|
||||
"STALLED": "L'avvio di questo stream sta richiedendo più tempo del previsto.",
|
||||
"PLAYBACK_FAILED": "Riproduzione con MPV integrato non riuscita.",
|
||||
"RECONNECTING": "Connessione persa. Riconnessione in corso… tentativo {{attempt}} di {{maxAttempts}}",
|
||||
"STREAM_ENDED": "Lo stream in diretta è terminato.",
|
||||
"CHECKING_SUPPORT": "Verifica del supporto per MPV integrato...",
|
||||
"NOT_AVAILABLE": "MPV integrato non è disponibile in questo ambiente.",
|
||||
"LOADING_STREAM": "Caricamento stream…",
|
||||
@@ -1574,6 +1588,7 @@
|
||||
"OPEN_GLOBAL_SEARCH": "Apri ricerca globale",
|
||||
"OPEN_RECENTLY_VIEWED": "Apri visti di recente",
|
||||
"SUBMIT_SHELL_SEARCH": "Invia ricerca nell’area di lavoro",
|
||||
"TOGGLE_WINDOW_FULLSCREEN": "Attiva/disattiva schermo intero della finestra",
|
||||
"TOGGLE_SIDEBAR": "Mostra/nascondi barra laterale",
|
||||
"M3U_CHANNEL_NUMBER": "Seleziona canale M3U per numero",
|
||||
"PLAY_PAUSE": "Riproduci o metti in pausa MPV integrato",
|
||||
|
||||
@@ -368,6 +368,11 @@
|
||||
"STARTUP_BEHAVIOR_DESCRIPTION": "IPTVnatorを起動したときに最初に利用可能なワークスペースビューを開くか、最後に表示していたセクションを復元するかを選択します。「最初のビュー」は、ダッシュボードが有効な場合はダッシュボード、無効な場合はソースを意味します。",
|
||||
"STARTUP_BEHAVIOR_FIRST_VIEW": "最初に利用可能なビュー",
|
||||
"STARTUP_BEHAVIOR_RESTORE_LAST_VIEW": "最後のビューを復元",
|
||||
"STARTUP_WINDOW_MODE": "起動時のウィンドウ",
|
||||
"STARTUP_WINDOW_MODE_DESCRIPTION": "デスクトップウィンドウの開き方を選択します:前回のサイズ、最大化、または全画面。次回の起動時に適用されます。F11キーでいつでも全画面表示を切り替えられます。",
|
||||
"STARTUP_WINDOW_MODE_NORMAL": "前回のウィンドウサイズ",
|
||||
"STARTUP_WINDOW_MODE_MAXIMIZED": "最大化",
|
||||
"STARTUP_WINDOW_MODE_FULLSCREEN": "全画面",
|
||||
"SHOW_CAPTIONS_DESCRIPTION": "デフォルトで字幕を表示/非表示",
|
||||
"SHOW_EXTERNAL_PLAYBACK_BAR": "再生中フッターを表示",
|
||||
"SHOW_EXTERNAL_PLAYBACK_BAR_DESCRIPTION": "デスクトップでワークスペース下部に外部プレーヤーのステータスバーを表示し続けます。",
|
||||
@@ -492,6 +497,13 @@
|
||||
"EMBEDDED_MPV_FRAME_COPY": "埋め込みMPV:フレームコピーエンジン(実験的)",
|
||||
"EMBEDDED_MPV_FRAME_COPY_DESCRIPTION": "映像をアプリのキャンバスに描画するため、メニューやダイアログをその上に表示できます。",
|
||||
"EMBEDDED_MPV_FRAME_COPY_RESTART": "アプリの再起動後に有効になります。",
|
||||
"EMBEDDED_MPV_EXTRA_OPTIONS": "埋め込みMPVの追加オプション",
|
||||
"EMBEDDED_MPV_EXTRA_OPTIONS_DESCRIPTION": "埋め込みプレーヤー向けの高度な libmpv オプションです。1 行に 1 つの key=value ペアを入力します(先頭の -- は不要)。すべてのエンジンで組み込みオプションの後に適用され、次に埋め込みMPVのセッションを開始したときに有効になります。",
|
||||
"EMBEDDED_MPV_EXTRA_OPTIONS_HINT": "例: hwdec=auto-safe。埋め込み自体が依存するオプション(wid、vo、input-ipc-server など)は拒否されます。",
|
||||
"EMBEDDED_MPV_EXTRA_OPTIONS_INVALID_LINES": "key=value 形式の行ではありません: {{lines}}",
|
||||
"EMBEDDED_MPV_EXTRA_OPTIONS_FORBIDDEN_KEYS": "埋め込みプレーヤーでは次のオプションを変更できません: {{keys}}",
|
||||
"EMBEDDED_MPV_AUTO_RECONNECT": "切断されたストリームに自動的に再接続",
|
||||
"EMBEDDED_MPV_AUTO_RECONNECT_DESCRIPTION": "再生中のストリームが切断されると、埋め込みプレーヤーは間隔を延ばしながら最大 6 回まで再読み込みを試み、その後あきらめます。一度も開始しなかったストリームには、引き続き手動の再試行が必要です。",
|
||||
"VOD_AUTO_FAILOVER": "再生エラー時にソースを自動切り替え",
|
||||
"VOD_AUTO_FAILOVER_DESCRIPTION": "映画の再生に失敗したとき、同じ作品が別のプレイリストにもある場合は自動的にそちらへ切り替え、再生位置を引き継ぎます。標準では無効です。別のソースでは吹き替えが異なる場合があるため、切り替え時は必ず通知されます。"
|
||||
},
|
||||
@@ -515,6 +527,8 @@
|
||||
"RETRY": "再試行",
|
||||
"STALLED": "このストリームの開始に予想より時間がかかっています。",
|
||||
"PLAYBACK_FAILED": "組み込みMPVでの再生に失敗しました。",
|
||||
"RECONNECTING": "接続が失われました。再接続中… 試行 {{attempt}} / {{maxAttempts}}",
|
||||
"STREAM_ENDED": "ライブストリームが終了しました。",
|
||||
"CHECKING_SUPPORT": "組み込みMPVのサポートを確認中...",
|
||||
"NOT_AVAILABLE": "この環境では組み込みMPVは利用できません。",
|
||||
"LOADING_STREAM": "ストリームを読み込み中…",
|
||||
@@ -1574,6 +1588,7 @@
|
||||
"OPEN_GLOBAL_SEARCH": "グローバル検索を開く",
|
||||
"OPEN_RECENTLY_VIEWED": "最近視聴した項目を開く",
|
||||
"SUBMIT_SHELL_SEARCH": "ワークスペース検索を実行",
|
||||
"TOGGLE_WINDOW_FULLSCREEN": "アプリウィンドウの全画面表示を切り替え",
|
||||
"TOGGLE_SIDEBAR": "サイドバーを切り替え",
|
||||
"M3U_CHANNEL_NUMBER": "番号で M3U チャンネルを選択",
|
||||
"PLAY_PAUSE": "Embedded MPV の再生/一時停止",
|
||||
|
||||
@@ -368,6 +368,11 @@
|
||||
"STARTUP_BEHAVIOR_DESCRIPTION": "IPTVnator를 처음 시작할 때 첫 번째로 사용 가능한 작업 공간 화면을 열지, 마지막으로 본 섹션 화면을 복원할지 선택합니다. 첫 번째 화면은 활성화된 경우 대시보드, 그렇지 않으면 소스를 의미합니다.",
|
||||
"STARTUP_BEHAVIOR_FIRST_VIEW": "첫 번째로 사용 가능한 화면",
|
||||
"STARTUP_BEHAVIOR_RESTORE_LAST_VIEW": "마지막 화면 복원",
|
||||
"STARTUP_WINDOW_MODE": "시작 시 창",
|
||||
"STARTUP_WINDOW_MODE_DESCRIPTION": "데스크톱 창을 여는 방식을 선택합니다: 마지막 크기, 최대화 또는 전체 화면. 다음 실행부터 적용됩니다. 언제든지 F11 키를 눌러 전체 화면을 켜거나 끌 수 있습니다.",
|
||||
"STARTUP_WINDOW_MODE_NORMAL": "마지막 창 크기",
|
||||
"STARTUP_WINDOW_MODE_MAXIMIZED": "최대화",
|
||||
"STARTUP_WINDOW_MODE_FULLSCREEN": "전체 화면",
|
||||
"SHOW_CAPTIONS_DESCRIPTION": "기본적으로 자막 표시 또는 숨기기",
|
||||
"SHOW_EXTERNAL_PLAYBACK_BAR": "지금 재생 중 표시줄 표시",
|
||||
"SHOW_EXTERNAL_PLAYBACK_BAR_DESCRIPTION": "데스크톱의 작업 공간 하단에 외부 플레이어 상태 표시줄을 계속 표시합니다.",
|
||||
@@ -492,6 +497,13 @@
|
||||
"EMBEDDED_MPV_FRAME_COPY": "내장 MPV: 프레임 복사 엔진(실험적)",
|
||||
"EMBEDDED_MPV_FRAME_COPY_DESCRIPTION": "비디오를 앱 캔버스에 렌더링하여 메뉴와 대화 상자가 그 위에 표시됩니다.",
|
||||
"EMBEDDED_MPV_FRAME_COPY_RESTART": "앱을 다시 시작한 후 적용됩니다.",
|
||||
"EMBEDDED_MPV_EXTRA_OPTIONS": "내장 MPV 추가 옵션",
|
||||
"EMBEDDED_MPV_EXTRA_OPTIONS_DESCRIPTION": "내장 플레이어용 고급 libmpv 옵션입니다. 한 줄에 key=value 쌍 하나씩 입력하세요(앞의 -- 없이). 모든 엔진에서 기본 제공 옵션 다음에 적용되며, 다음 내장 MPV 세션이 시작될 때 반영됩니다.",
|
||||
"EMBEDDED_MPV_EXTRA_OPTIONS_HINT": "예: hwdec=auto-safe. 내장 기능 자체가 의존하는 옵션(wid, vo, input-ipc-server 등)은 거부됩니다.",
|
||||
"EMBEDDED_MPV_EXTRA_OPTIONS_INVALID_LINES": "key=value 형식의 줄이 아닙니다: {{lines}}",
|
||||
"EMBEDDED_MPV_EXTRA_OPTIONS_FORBIDDEN_KEYS": "내장 플레이어에서는 다음 옵션을 변경할 수 없습니다: {{keys}}",
|
||||
"EMBEDDED_MPV_AUTO_RECONNECT": "끊긴 스트림 자동 재연결",
|
||||
"EMBEDDED_MPV_AUTO_RECONNECT_DESCRIPTION": "재생 중인 스트림이 끊기면 내장 플레이어가 점점 길어지는 간격으로 최대 6회까지 다시 불러온 뒤 포기합니다. 한 번도 시작되지 않은 스트림은 여전히 수동으로 다시 시도해야 합니다.",
|
||||
"VOD_AUTO_FAILOVER": "재생 오류 시 소스 자동 전환",
|
||||
"VOD_AUTO_FAILOVER_DESCRIPTION": "영화 재생에 실패했을 때 같은 작품이 다른 재생목록에 있으면 재생 위치를 유지한 채 자동으로 전환합니다. 기본값은 꺼짐이며, 다른 소스는 더빙이 다를 수 있으므로 전환 시 항상 알려 드립니다."
|
||||
},
|
||||
@@ -515,6 +527,8 @@
|
||||
"RETRY": "다시 시도",
|
||||
"STALLED": "이 스트림의 시작이 예상보다 오래 걸리고 있습니다.",
|
||||
"PLAYBACK_FAILED": "내장 MPV 재생에 실패했습니다.",
|
||||
"RECONNECTING": "연결이 끊어졌습니다. 다시 연결하는 중… 시도 {{attempt}}/{{maxAttempts}}",
|
||||
"STREAM_ENDED": "라이브 스트림이 종료되었습니다.",
|
||||
"CHECKING_SUPPORT": "내장 MPV 지원을 확인하는 중...",
|
||||
"NOT_AVAILABLE": "이 환경에서는 내장 MPV를 사용할 수 없습니다.",
|
||||
"LOADING_STREAM": "스트림을 불러오는 중…",
|
||||
@@ -1574,6 +1588,7 @@
|
||||
"OPEN_GLOBAL_SEARCH": "전역 검색 열기",
|
||||
"OPEN_RECENTLY_VIEWED": "최근 시청 항목 열기",
|
||||
"SUBMIT_SHELL_SEARCH": "워크스페이스 검색 실행",
|
||||
"TOGGLE_WINDOW_FULLSCREEN": "앱 창 전체 화면 전환",
|
||||
"TOGGLE_SIDEBAR": "사이드바 전환",
|
||||
"M3U_CHANNEL_NUMBER": "번호로 M3U 채널 선택",
|
||||
"PLAY_PAUSE": "Embedded MPV 재생 또는 일시정지",
|
||||
|
||||
@@ -368,6 +368,11 @@
|
||||
"STARTUP_BEHAVIOR_DESCRIPTION": "Kies of IPTVnator de eerste beschikbare werkruimteweergave opent of je laatste sectieweergave herstelt. Eerste weergave betekent het dashboard wanneer ingeschakeld, anders Bronnen.",
|
||||
"STARTUP_BEHAVIOR_FIRST_VIEW": "Eerste beschikbare weergave",
|
||||
"STARTUP_BEHAVIOR_RESTORE_LAST_VIEW": "Laatste weergave herstellen",
|
||||
"STARTUP_WINDOW_MODE": "Venster bij opstarten",
|
||||
"STARTUP_WINDOW_MODE_DESCRIPTION": "Kies hoe het desktopvenster opent: op de laatste grootte, gemaximaliseerd of op volledig scherm. Geldt vanaf de volgende start. Druk op elk moment op F11 om volledig scherm in of uit te schakelen.",
|
||||
"STARTUP_WINDOW_MODE_NORMAL": "Laatste venstergrootte",
|
||||
"STARTUP_WINDOW_MODE_MAXIMIZED": "Gemaximaliseerd",
|
||||
"STARTUP_WINDOW_MODE_FULLSCREEN": "Volledig scherm",
|
||||
"SHOW_CAPTIONS_DESCRIPTION": "Standaard ondertiteling weergeven of verbergen",
|
||||
"SHOW_EXTERNAL_PLAYBACK_BAR": "Voettekst 'nu spelend' tonen",
|
||||
"SHOW_EXTERNAL_PLAYBACK_BAR_DESCRIPTION": "Houd de statusbalk van de externe speler zichtbaar onderaan de werkruimte op desktop.",
|
||||
@@ -492,6 +497,13 @@
|
||||
"EMBEDDED_MPV_FRAME_COPY": "Embedded MPV: frame-copy-engine (experimenteel)",
|
||||
"EMBEDDED_MPV_FRAME_COPY_DESCRIPTION": "De video wordt op het app-canvas getekend zodat menu's en dialoogvensters erboven verschijnen.",
|
||||
"EMBEDDED_MPV_FRAME_COPY_RESTART": "Wordt van kracht na het herstarten van de app.",
|
||||
"EMBEDDED_MPV_EXTRA_OPTIONS": "Extra opties voor Embedded MPV",
|
||||
"EMBEDDED_MPV_EXTRA_OPTIONS_DESCRIPTION": "Geavanceerde libmpv-opties voor de ingebedde speler, één key=value-paar per regel (zonder -- ervoor). Ze worden bij elke engine toegepast na de ingebouwde opties en worden van kracht wanneer de volgende Embedded MPV-sessie start.",
|
||||
"EMBEDDED_MPV_EXTRA_OPTIONS_HINT": "Voorbeeld: hwdec=auto-safe. Opties waarvan de ingebedde speler zelf afhankelijk is (wid, vo, input-ipc-server, …) worden geweigerd.",
|
||||
"EMBEDDED_MPV_EXTRA_OPTIONS_INVALID_LINES": "Geen key=value-regel: {{lines}}",
|
||||
"EMBEDDED_MPV_EXTRA_OPTIONS_FORBIDDEN_KEYS": "Deze opties kunnen niet worden gewijzigd voor de ingebedde speler: {{keys}}",
|
||||
"EMBEDDED_MPV_AUTO_RECONNECT": "Weggevallen streams automatisch opnieuw verbinden",
|
||||
"EMBEDDED_MPV_AUTO_RECONNECT_DESCRIPTION": "Wanneer een stream tijdens het afspelen wegvalt, laadt de ingebedde speler deze opnieuw met steeds langere tussenpozen, tot maximaal 6 pogingen, voordat hij het opgeeft. Voor een stream die nooit is gestart, blijft handmatig Opnieuw proberen nodig.",
|
||||
"VOD_AUTO_FAILOVER": "Automatisch van bron wisselen bij een afspeelfout",
|
||||
"VOD_AUTO_FAILOVER_DESCRIPTION": "Als een film niet kan worden afgespeeld en dezelfde film in een andere afspeellijst staat, wordt daar automatisch naar overgeschakeld met behoud van de afspeelpositie. Standaard uitgeschakeld; de wissel wordt altijd gemeld, omdat een andere bron een andere nasynchronisatie kan hebben."
|
||||
},
|
||||
@@ -515,6 +527,8 @@
|
||||
"RETRY": "Opnieuw proberen",
|
||||
"STALLED": "Deze stream doet er langer over dan verwacht om te starten.",
|
||||
"PLAYBACK_FAILED": "Afspelen via Embedded MPV is mislukt.",
|
||||
"RECONNECTING": "Verbinding verbroken. Opnieuw verbinden… poging {{attempt}} van {{maxAttempts}}",
|
||||
"STREAM_ENDED": "De live stream is beëindigd.",
|
||||
"CHECKING_SUPPORT": "Ondersteuning voor Embedded MPV controleren...",
|
||||
"NOT_AVAILABLE": "Embedded MPV is niet beschikbaar in deze omgeving.",
|
||||
"LOADING_STREAM": "Stream laden…",
|
||||
@@ -1574,6 +1588,7 @@
|
||||
"OPEN_GLOBAL_SEARCH": "Globaal zoeken openen",
|
||||
"OPEN_RECENTLY_VIEWED": "Recent bekeken openen",
|
||||
"SUBMIT_SHELL_SEARCH": "Werkruimtezoekopdracht uitvoeren",
|
||||
"TOGGLE_WINDOW_FULLSCREEN": "Volledig scherm voor het app-venster in- of uitschakelen",
|
||||
"TOGGLE_SIDEBAR": "Zijbalk in- of uitschakelen",
|
||||
"M3U_CHANNEL_NUMBER": "M3U-kanaal selecteren op nummer",
|
||||
"PLAY_PAUSE": "Ingebedde MPV-weergave afspelen of pauzeren",
|
||||
|
||||
@@ -368,6 +368,11 @@
|
||||
"STARTUP_BEHAVIOR_DESCRIPTION": "Wybierz, czy IPTVnator otwiera pierwszy dostępny widok obszaru roboczego, czy przywraca ostatni widok na poziomie sekcji. Pierwszy widok oznacza pulpit, jeśli jest włączony, w przeciwnym razie Źródła.",
|
||||
"STARTUP_BEHAVIOR_FIRST_VIEW": "Pierwszy dostępny widok",
|
||||
"STARTUP_BEHAVIOR_RESTORE_LAST_VIEW": "Przywróć ostatni widok",
|
||||
"STARTUP_WINDOW_MODE": "Okno przy uruchomieniu",
|
||||
"STARTUP_WINDOW_MODE_DESCRIPTION": "Wybierz, jak otwiera się okno aplikacji: w ostatnim rozmiarze, zmaksymalizowane lub na pełnym ekranie. Obowiązuje od następnego uruchomienia. Naciśnij F11 w dowolnym momencie, aby włączyć lub wyłączyć pełny ekran.",
|
||||
"STARTUP_WINDOW_MODE_NORMAL": "Ostatni rozmiar okna",
|
||||
"STARTUP_WINDOW_MODE_MAXIMIZED": "Zmaksymalizowane",
|
||||
"STARTUP_WINDOW_MODE_FULLSCREEN": "Pełny ekran",
|
||||
"SHOW_CAPTIONS_DESCRIPTION": "Domyślnie pokaż lub ukryj napisy",
|
||||
"SHOW_EXTERNAL_PLAYBACK_BAR": "Pokaż stopkę „teraz odtwarzane”",
|
||||
"SHOW_EXTERNAL_PLAYBACK_BAR_DESCRIPTION": "Pozostaw pasek statusu zewnętrznego odtwarzacza widoczny u dołu obszaru roboczego na komputerze.",
|
||||
@@ -492,6 +497,13 @@
|
||||
"EMBEDDED_MPV_FRAME_COPY": "Embedded MPV: silnik frame-copy (eksperymentalny)",
|
||||
"EMBEDDED_MPV_FRAME_COPY_DESCRIPTION": "Wideo jest rysowane na kanwie aplikacji, dzięki czemu menu i okna dialogowe wyświetlają się nad nim.",
|
||||
"EMBEDDED_MPV_FRAME_COPY_RESTART": "Zacznie działać po ponownym uruchomieniu aplikacji.",
|
||||
"EMBEDDED_MPV_EXTRA_OPTIONS": "Dodatkowe opcje wbudowanego MPV",
|
||||
"EMBEDDED_MPV_EXTRA_OPTIONS_DESCRIPTION": "Zaawansowane opcje libmpv dla wbudowanego odtwarzacza, jedna para key=value w każdym wierszu (bez początkowego --). Są stosowane w każdym silniku po opcjach wbudowanych i zaczynają działać przy uruchomieniu następnej sesji wbudowanego MPV.",
|
||||
"EMBEDDED_MPV_EXTRA_OPTIONS_HINT": "Przykład: hwdec=auto-safe. Opcje, od których zależy samo osadzenie (wid, vo, input-ipc-server, …), są odrzucane.",
|
||||
"EMBEDDED_MPV_EXTRA_OPTIONS_INVALID_LINES": "To nie jest wiersz key=value: {{lines}}",
|
||||
"EMBEDDED_MPV_EXTRA_OPTIONS_FORBIDDEN_KEYS": "Tych opcji nie można zmienić dla wbudowanego odtwarzacza: {{keys}}",
|
||||
"EMBEDDED_MPV_AUTO_RECONNECT": "Automatycznie wznawiaj przerwane strumienie",
|
||||
"EMBEDDED_MPV_AUTO_RECONNECT_DESCRIPTION": "Gdy odtwarzany strumień zostanie przerwany, wbudowany odtwarzacz wczytuje go ponownie z rosnącymi odstępami, maksymalnie 6 prób, zanim się podda. Strumień, który w ogóle się nie uruchomił, nadal wymaga ręcznego użycia „Spróbuj ponownie”.",
|
||||
"VOD_AUTO_FAILOVER": "Automatyczna zmiana źródła przy błędzie odtwarzania",
|
||||
"VOD_AUTO_FAILOVER_DESCRIPTION": "Gdy filmu nie da się odtworzyć, a ten sam tytuł jest dostępny na innej liście odtwarzania, przełącz się na nią automatycznie i zachowaj pozycję odtwarzania. Domyślnie wyłączone; zmiana jest zawsze sygnalizowana, ponieważ inne źródło może mieć inną wersję językową."
|
||||
},
|
||||
@@ -515,6 +527,8 @@
|
||||
"RETRY": "Spróbuj ponownie",
|
||||
"STALLED": "Uruchamianie tego strumienia trwa dłużej niż zwykle.",
|
||||
"PLAYBACK_FAILED": "Odtwarzanie we wbudowanym MPV nie powiodło się.",
|
||||
"RECONNECTING": "Utracono połączenie. Ponowne łączenie… próba {{attempt}} z {{maxAttempts}}",
|
||||
"STREAM_ENDED": "Transmisja na żywo zakończyła się.",
|
||||
"CHECKING_SUPPORT": "Sprawdzanie obsługi wbudowanego MPV...",
|
||||
"NOT_AVAILABLE": "Wbudowane MPV nie jest dostępne w tym środowisku.",
|
||||
"LOADING_STREAM": "Ładowanie strumienia…",
|
||||
@@ -1574,6 +1588,7 @@
|
||||
"OPEN_GLOBAL_SEARCH": "Otwórz wyszukiwanie globalne",
|
||||
"OPEN_RECENTLY_VIEWED": "Otwórz ostatnio oglądane",
|
||||
"SUBMIT_SHELL_SEARCH": "Uruchom wyszukiwanie w obszarze roboczym",
|
||||
"TOGGLE_WINDOW_FULLSCREEN": "Przełącz pełny ekran okna aplikacji",
|
||||
"TOGGLE_SIDEBAR": "Przełącz pasek boczny",
|
||||
"M3U_CHANNEL_NUMBER": "Wybierz kanał M3U według numeru",
|
||||
"PLAY_PAUSE": "Odtwórz lub wstrzymaj osadzony MPV",
|
||||
|
||||
@@ -368,6 +368,11 @@
|
||||
"STARTUP_BEHAVIOR_DESCRIPTION": "Escolha se o IPTVnator abre a primeira visualização disponível do espaço de trabalho ou restaura sua última visualização. Primeira visualização significa Dashboard quando ativada, caso contrário, Fontes.",
|
||||
"STARTUP_BEHAVIOR_FIRST_VIEW": "Primeira visualização disponível",
|
||||
"STARTUP_BEHAVIOR_RESTORE_LAST_VIEW": "Restaurar última visualização",
|
||||
"STARTUP_WINDOW_MODE": "Janela ao iniciar",
|
||||
"STARTUP_WINDOW_MODE_DESCRIPTION": "Escolha como a janela do aplicativo abre: no último tamanho, maximizada ou em tela cheia. Aplica-se na próxima inicialização. Pressione F11 a qualquer momento para entrar ou sair da tela cheia.",
|
||||
"STARTUP_WINDOW_MODE_NORMAL": "Último tamanho da janela",
|
||||
"STARTUP_WINDOW_MODE_MAXIMIZED": "Maximizada",
|
||||
"STARTUP_WINDOW_MODE_FULLSCREEN": "Tela cheia",
|
||||
"SHOW_CAPTIONS_DESCRIPTION": "Mostrar ou ocultar legendas por padrão",
|
||||
"SHOW_EXTERNAL_PLAYBACK_BAR": "Mostrar rodapé de reprodução atual",
|
||||
"SHOW_EXTERNAL_PLAYBACK_BAR_DESCRIPTION": "Mantém a barra de status do reprodutor externo visível na parte inferior do espaço de trabalho no desktop.",
|
||||
@@ -492,6 +497,13 @@
|
||||
"EMBEDDED_MPV_FRAME_COPY": "Embedded MPV: motor frame-copy (experimental)",
|
||||
"EMBEDDED_MPV_FRAME_COPY_DESCRIPTION": "O vídeo é desenhado na tela da aplicação, para que menus e diálogos fiquem por cima.",
|
||||
"EMBEDDED_MPV_FRAME_COPY_RESTART": "Entra em vigor após reiniciar a aplicação.",
|
||||
"EMBEDDED_MPV_EXTRA_OPTIONS": "Opções extras do MPV integrado",
|
||||
"EMBEDDED_MPV_EXTRA_OPTIONS_DESCRIPTION": "Opções avançadas do libmpv para o reprodutor integrado, um par key=value por linha (sem -- no início). São aplicadas em todos os motores após as opções internas e entram em vigor quando a próxima sessão do MPV integrado iniciar.",
|
||||
"EMBEDDED_MPV_EXTRA_OPTIONS_HINT": "Exemplo: hwdec=auto-safe. Opções das quais o próprio reprodutor integrado depende (wid, vo, input-ipc-server, …) são recusadas.",
|
||||
"EMBEDDED_MPV_EXTRA_OPTIONS_INVALID_LINES": "Não está no formato key=value: {{lines}}",
|
||||
"EMBEDDED_MPV_EXTRA_OPTIONS_FORBIDDEN_KEYS": "Estas opções não podem ser alteradas para o reprodutor integrado: {{keys}}",
|
||||
"EMBEDDED_MPV_AUTO_RECONNECT": "Reconectar streams interrompidos automaticamente",
|
||||
"EMBEDDED_MPV_AUTO_RECONNECT_DESCRIPTION": "Quando um stream em reprodução é interrompido, o reprodutor integrado o recarrega com intervalos crescentes, em até 6 tentativas, antes de desistir. Um stream que nunca chegou a iniciar ainda precisa de um \"Tentar novamente\" manual.",
|
||||
"VOD_AUTO_FAILOVER": "Trocar de fonte automaticamente ao falhar a reprodução",
|
||||
"VOD_AUTO_FAILOVER_DESCRIPTION": "Quando um filme não consegue ser reproduzido e o mesmo filme existe em outra playlist, troca para ela automaticamente e mantém a posição de reprodução. Desativado por padrão; a troca é sempre avisada, porque outra fonte pode ter uma dublagem diferente."
|
||||
},
|
||||
@@ -515,6 +527,8 @@
|
||||
"RETRY": "Tentar novamente",
|
||||
"STALLED": "Este stream está demorando mais do que o esperado para iniciar.",
|
||||
"PLAYBACK_FAILED": "Falha na reprodução do MPV integrado.",
|
||||
"RECONNECTING": "Conexão perdida. Reconectando… tentativa {{attempt}} de {{maxAttempts}}",
|
||||
"STREAM_ENDED": "A transmissão ao vivo terminou.",
|
||||
"CHECKING_SUPPORT": "Verificando suporte ao MPV integrado...",
|
||||
"NOT_AVAILABLE": "O MPV integrado não está disponível neste ambiente.",
|
||||
"LOADING_STREAM": "Carregando stream…",
|
||||
@@ -1574,6 +1588,7 @@
|
||||
"OPEN_GLOBAL_SEARCH": "Abrir pesquisa global",
|
||||
"OPEN_RECENTLY_VIEWED": "Abrir assistidos recentemente",
|
||||
"SUBMIT_SHELL_SEARCH": "Enviar pesquisa da área de trabalho",
|
||||
"TOGGLE_WINDOW_FULLSCREEN": "Alternar tela cheia da janela do aplicativo",
|
||||
"TOGGLE_SIDEBAR": "Alternar barra lateral",
|
||||
"M3U_CHANNEL_NUMBER": "Selecionar canal M3U por número",
|
||||
"PLAY_PAUSE": "Reproduzir ou pausar reprodução do Embedded MPV",
|
||||
|
||||
@@ -368,6 +368,11 @@
|
||||
"STARTUP_BEHAVIOR_DESCRIPTION": "Выберите, должен ли IPTVnator открывать первый доступный экран рабочего пространства или восстанавливать последний экран уровня раздела. Первый экран означает Dashboard, если он включён, иначе Источники.",
|
||||
"STARTUP_BEHAVIOR_FIRST_VIEW": "Первый доступный экран",
|
||||
"STARTUP_BEHAVIOR_RESTORE_LAST_VIEW": "Восстановить последний экран",
|
||||
"STARTUP_WINDOW_MODE": "Окно при запуске",
|
||||
"STARTUP_WINDOW_MODE_DESCRIPTION": "Выберите, как открывается окно приложения: в последнем размере, развёрнутым или на весь экран. Применяется при следующем запуске. Нажмите F11 в любой момент, чтобы войти в полноэкранный режим или выйти из него.",
|
||||
"STARTUP_WINDOW_MODE_NORMAL": "Последний размер окна",
|
||||
"STARTUP_WINDOW_MODE_MAXIMIZED": "Развёрнутое",
|
||||
"STARTUP_WINDOW_MODE_FULLSCREEN": "На весь экран",
|
||||
"SHOW_CAPTIONS_DESCRIPTION": "Показать или скрыть субтитры по умолчанию.",
|
||||
"SHOW_EXTERNAL_PLAYBACK_BAR": "Показывать панель «Сейчас воспроизводится»",
|
||||
"SHOW_EXTERNAL_PLAYBACK_BAR_DESCRIPTION": "Оставлять панель состояния внешнего проигрывателя видимой внизу рабочей области в настольном приложении.",
|
||||
@@ -492,6 +497,13 @@
|
||||
"EMBEDDED_MPV_FRAME_COPY": "Embedded MPV: движок frame-copy (экспериментально)",
|
||||
"EMBEDDED_MPV_FRAME_COPY_DESCRIPTION": "Видео рисуется на канвасе приложения, поэтому меню и диалоги отображаются поверх него.",
|
||||
"EMBEDDED_MPV_FRAME_COPY_RESTART": "Вступит в силу после перезапуска приложения.",
|
||||
"EMBEDDED_MPV_EXTRA_OPTIONS": "Дополнительные параметры встроенного MPV",
|
||||
"EMBEDDED_MPV_EXTRA_OPTIONS_DESCRIPTION": "Расширенные параметры libmpv для встроенного плеера, по одной паре key=value на строку (без -- в начале). Они применяются на любом движке после встроенных параметров и вступают в силу при следующем запуске воспроизведения во встроенном MPV.",
|
||||
"EMBEDDED_MPV_EXTRA_OPTIONS_HINT": "Пример: hwdec=auto-safe. Параметры, от которых зависит сам встроенный плеер (wid, vo, input-ipc-server, …), отклоняются.",
|
||||
"EMBEDDED_MPV_EXTRA_OPTIONS_INVALID_LINES": "Не в формате key=value: {{lines}}",
|
||||
"EMBEDDED_MPV_EXTRA_OPTIONS_FORBIDDEN_KEYS": "Эти параметры нельзя изменять для встроенного плеера: {{keys}}",
|
||||
"EMBEDDED_MPV_AUTO_RECONNECT": "Автоматически переподключаться при обрыве потока",
|
||||
"EMBEDDED_MPV_AUTO_RECONNECT_DESCRIPTION": "Если поток обрывается во время воспроизведения, встроенный плеер перезагружает его с возрастающими задержками (до 6 попыток), после чего прекращает переподключение. Для потока, который так и не запустился, по-прежнему нужно нажать «Повторить» вручную.",
|
||||
"VOD_AUTO_FAILOVER": "Авто-переключение источника при ошибке воспроизведения",
|
||||
"VOD_AUTO_FAILOVER_DESCRIPTION": "Если фильм не воспроизводится, а тот же фильм есть в другом плейлисте, автоматически переключаться на него, сохраняя таймкод. По умолчанию выключено; о переключении всегда сообщается, так как в другом источнике может быть другая озвучка."
|
||||
},
|
||||
@@ -515,6 +527,8 @@
|
||||
"RETRY": "Повторить",
|
||||
"STALLED": "Запуск этого потока занимает больше времени, чем ожидалось.",
|
||||
"PLAYBACK_FAILED": "Не удалось воспроизвести через встроенный MPV.",
|
||||
"RECONNECTING": "Подключение потеряно. Переподключение… попытка {{attempt}} из {{maxAttempts}}",
|
||||
"STREAM_ENDED": "Прямой эфир завершился.",
|
||||
"CHECKING_SUPPORT": "Проверка поддержки встроенного MPV...",
|
||||
"NOT_AVAILABLE": "Встроенный MPV недоступен в этой среде.",
|
||||
"LOADING_STREAM": "Загрузка потока…",
|
||||
@@ -1574,6 +1588,7 @@
|
||||
"OPEN_GLOBAL_SEARCH": "Открыть глобальный поиск",
|
||||
"OPEN_RECENTLY_VIEWED": "Открыть недавно просмотренное",
|
||||
"SUBMIT_SHELL_SEARCH": "Отправить поиск в рабочей области",
|
||||
"TOGGLE_WINDOW_FULLSCREEN": "Переключить полноэкранный режим окна приложения",
|
||||
"TOGGLE_SIDEBAR": "Показать или скрыть боковую панель",
|
||||
"M3U_CHANNEL_NUMBER": "Выбрать канал M3U по номеру",
|
||||
"PLAY_PAUSE": "Запустить или приостановить воспроизведение во встроенном MPV",
|
||||
|
||||
@@ -368,6 +368,11 @@
|
||||
"STARTUP_BEHAVIOR_DESCRIPTION": "IPTVnator'un ilk kullanılabilir çalışma alanı görünümünü mü açacağını yoksa bölüm düzeyindeki son görünümünüzü mü geri yükleyeceğini seçin. İlk görünüm, Pano etkinleştirildiğinde Pano, aksi takdirde Kaynaklar anlamına gelir.",
|
||||
"STARTUP_BEHAVIOR_FIRST_VIEW": "İlk kullanılabilir görünüm",
|
||||
"STARTUP_BEHAVIOR_RESTORE_LAST_VIEW": "Son görünümü geri yükle",
|
||||
"STARTUP_WINDOW_MODE": "Başlangıçta pencere",
|
||||
"STARTUP_WINDOW_MODE_DESCRIPTION": "Masaüstü penceresinin nasıl açılacağını seçin: son boyutunda, ekranı kaplamış veya tam ekran. Bir sonraki başlatmada uygulanır. Tam ekrana girmek veya çıkmak için istediğiniz zaman F11 tuşuna basın.",
|
||||
"STARTUP_WINDOW_MODE_NORMAL": "Son pencere boyutu",
|
||||
"STARTUP_WINDOW_MODE_MAXIMIZED": "Ekranı kaplamış",
|
||||
"STARTUP_WINDOW_MODE_FULLSCREEN": "Tam ekran",
|
||||
"SHOW_CAPTIONS_DESCRIPTION": "Varsayılan olarak altyazıları göster veya gizle",
|
||||
"SHOW_EXTERNAL_PLAYBACK_BAR": "Şu an oynatılıyor altbilgisini göster",
|
||||
"SHOW_EXTERNAL_PLAYBACK_BAR_DESCRIPTION": "Masaüstünde harici oynatıcı durum çubuğunu çalışma alanının altında görünür tutun.",
|
||||
@@ -492,6 +497,13 @@
|
||||
"EMBEDDED_MPV_FRAME_COPY": "Embedded MPV: frame-copy motoru (deneysel)",
|
||||
"EMBEDDED_MPV_FRAME_COPY_DESCRIPTION": "Video uygulama tuvaline çizilir, böylece menüler ve iletişim kutuları üzerinde görüntülenir.",
|
||||
"EMBEDDED_MPV_FRAME_COPY_RESTART": "Uygulama yeniden başlatıldıktan sonra geçerli olur.",
|
||||
"EMBEDDED_MPV_EXTRA_OPTIONS": "Gömülü MPV ek seçenekleri",
|
||||
"EMBEDDED_MPV_EXTRA_OPTIONS_DESCRIPTION": "Gömülü oynatıcı için gelişmiş libmpv seçenekleri; her satıra bir key=value çifti girin (başında -- olmadan). Tüm motorlarda yerleşik seçeneklerden sonra uygulanır ve bir sonraki gömülü MPV oturumu başladığında geçerli olur.",
|
||||
"EMBEDDED_MPV_EXTRA_OPTIONS_HINT": "Örnek: hwdec=auto-safe. Gömülü oynatıcının kendisinin bağlı olduğu seçenekler (wid, vo, input-ipc-server, …) reddedilir.",
|
||||
"EMBEDDED_MPV_EXTRA_OPTIONS_INVALID_LINES": "key=value biçiminde olmayan satır: {{lines}}",
|
||||
"EMBEDDED_MPV_EXTRA_OPTIONS_FORBIDDEN_KEYS": "Bu seçenekler gömülü oynatıcı için değiştirilemez: {{keys}}",
|
||||
"EMBEDDED_MPV_AUTO_RECONNECT": "Kesilen yayınlara otomatik yeniden bağlan",
|
||||
"EMBEDDED_MPV_AUTO_RECONNECT_DESCRIPTION": "Oynatılan bir yayın kesildiğinde gömülü oynatıcı, vazgeçmeden önce yayını giderek artan gecikmelerle en fazla 6 kez yeniden yüklemeyi dener. Hiç başlamamış bir yayın için yine de Tekrar dene’ye el ile basmanız gerekir.",
|
||||
"VOD_AUTO_FAILOVER": "Oynatma hatasında kaynağı otomatik değiştir",
|
||||
"VOD_AUTO_FAILOVER_DESCRIPTION": "Bir film oynatılamadığında ve aynı film başka bir oynatma listesinde de varsa, otomatik olarak o listeye geçilir ve oynatma konumu korunur. Varsayılan olarak kapalıdır; başka bir kaynakta dublaj farklı olabileceği için geçiş her zaman bildirilir."
|
||||
},
|
||||
@@ -515,6 +527,8 @@
|
||||
"RETRY": "Tekrar dene",
|
||||
"STALLED": "Bu yayının başlaması beklenenden uzun sürüyor.",
|
||||
"PLAYBACK_FAILED": "Gömülü MPV oynatması başarısız oldu.",
|
||||
"RECONNECTING": "Bağlantı kesildi. Yeniden bağlanılıyor… deneme {{attempt}}/{{maxAttempts}}",
|
||||
"STREAM_ENDED": "Canlı yayın sona erdi.",
|
||||
"CHECKING_SUPPORT": "Gömülü MPV desteği denetleniyor...",
|
||||
"NOT_AVAILABLE": "Gömülü MPV bu ortamda kullanılamıyor.",
|
||||
"LOADING_STREAM": "Yayın yükleniyor…",
|
||||
@@ -1574,6 +1588,7 @@
|
||||
"OPEN_GLOBAL_SEARCH": "Genel aramayı aç",
|
||||
"OPEN_RECENTLY_VIEWED": "Son görüntülenenleri aç",
|
||||
"SUBMIT_SHELL_SEARCH": "Çalışma alanı aramasını gönder",
|
||||
"TOGGLE_WINDOW_FULLSCREEN": "Uygulama penceresi tam ekranını aç/kapat",
|
||||
"TOGGLE_SIDEBAR": "Kenar çubuğunu aç/kapat",
|
||||
"M3U_CHANNEL_NUMBER": "M3U kanalını numarayla seç",
|
||||
"PLAY_PAUSE": "Gömülü MPV oynatmasını oynat veya duraklat",
|
||||
|
||||
@@ -368,6 +368,11 @@
|
||||
"STARTUP_BEHAVIOR_DESCRIPTION": "选择 IPTVnator 启动时是打开第一个可用的工作区视图,还是恢复您上次的章节级视图。第一个视图在启用时为仪表盘,否则为「源」。",
|
||||
"STARTUP_BEHAVIOR_FIRST_VIEW": "首个可用视图",
|
||||
"STARTUP_BEHAVIOR_RESTORE_LAST_VIEW": "恢复上次视图",
|
||||
"STARTUP_WINDOW_MODE": "启动时的窗口",
|
||||
"STARTUP_WINDOW_MODE_DESCRIPTION": "选择桌面窗口的打开方式:上次的大小、最大化或全屏。下次启动时生效。随时按 F11 进入或退出全屏。",
|
||||
"STARTUP_WINDOW_MODE_NORMAL": "上次窗口大小",
|
||||
"STARTUP_WINDOW_MODE_MAXIMIZED": "最大化",
|
||||
"STARTUP_WINDOW_MODE_FULLSCREEN": "全屏",
|
||||
"SHOW_CAPTIONS_DESCRIPTION": "默认显示或隐藏字幕",
|
||||
"SHOW_EXTERNAL_PLAYBACK_BAR": "显示「正在播放」底栏",
|
||||
"SHOW_EXTERNAL_PLAYBACK_BAR_DESCRIPTION": "在桌面端工作区底部保持外部播放器状态栏可见。",
|
||||
@@ -492,6 +497,13 @@
|
||||
"EMBEDDED_MPV_FRAME_COPY": "嵌入式 MPV:帧拷贝引擎(实验性)",
|
||||
"EMBEDDED_MPV_FRAME_COPY_DESCRIPTION": "视频渲染到应用画布上,菜单和对话框可以显示在其上方。",
|
||||
"EMBEDDED_MPV_FRAME_COPY_RESTART": "重启应用后生效。",
|
||||
"EMBEDDED_MPV_EXTRA_OPTIONS": "嵌入式 MPV 附加选项",
|
||||
"EMBEDDED_MPV_EXTRA_OPTIONS_DESCRIPTION": "嵌入式播放器的高级 libmpv 选项,每行一个 key=value 键值对(开头不加 --)。这些选项会在所有引擎中于内置选项之后应用,并在下一次嵌入式 MPV 会话启动时生效。",
|
||||
"EMBEDDED_MPV_EXTRA_OPTIONS_HINT": "示例:hwdec=auto-safe。嵌入本身依赖的选项(wid、vo、input-ipc-server 等)会被拒绝。",
|
||||
"EMBEDDED_MPV_EXTRA_OPTIONS_INVALID_LINES": "不是 key=value 格式的行:{{lines}}",
|
||||
"EMBEDDED_MPV_EXTRA_OPTIONS_FORBIDDEN_KEYS": "嵌入式播放器无法更改这些选项:{{keys}}",
|
||||
"EMBEDDED_MPV_AUTO_RECONNECT": "自动重连中断的流",
|
||||
"EMBEDDED_MPV_AUTO_RECONNECT_DESCRIPTION": "正在播放的流中断时,嵌入式播放器会以逐渐增加的间隔重新加载,最多尝试 6 次后放弃。从未成功启动的流仍需手动重试。",
|
||||
"VOD_AUTO_FAILOVER": "播放出错时自动切换播放源",
|
||||
"VOD_AUTO_FAILOVER_DESCRIPTION": "当某部电影无法播放,而其他播放列表中也有同一部影片时,自动切换过去并保留播放进度。默认关闭;由于其他播放源的配音可能不同,每次切换都会给出提示。"
|
||||
},
|
||||
@@ -515,6 +527,8 @@
|
||||
"RETRY": "重试",
|
||||
"STALLED": "该流的启动时间超出预期。",
|
||||
"PLAYBACK_FAILED": "嵌入式 MPV 播放失败。",
|
||||
"RECONNECTING": "连接已丢失。正在重新连接… 第 {{attempt}} 次尝试,共 {{maxAttempts}} 次",
|
||||
"STREAM_ENDED": "直播已结束。",
|
||||
"CHECKING_SUPPORT": "正在检查嵌入式 MPV 支持…",
|
||||
"NOT_AVAILABLE": "嵌入式 MPV 在当前环境中不可用。",
|
||||
"LOADING_STREAM": "正在加载流…",
|
||||
@@ -1574,6 +1588,7 @@
|
||||
"OPEN_GLOBAL_SEARCH": "打开全局搜索",
|
||||
"OPEN_RECENTLY_VIEWED": "打开最近观看",
|
||||
"SUBMIT_SHELL_SEARCH": "提交工作区搜索",
|
||||
"TOGGLE_WINDOW_FULLSCREEN": "切换应用窗口全屏",
|
||||
"TOGGLE_SIDEBAR": "切换侧边栏",
|
||||
"M3U_CHANNEL_NUMBER": "按数字选择 M3U 频道",
|
||||
"PLAY_PAUSE": "播放或暂停嵌入式 MPV 播放",
|
||||
|
||||
@@ -368,6 +368,11 @@
|
||||
"STARTUP_BEHAVIOR_DESCRIPTION": "選擇 IPTVnator 啟動時要開啟第一個可用的工作區畫面,或還原您上次瀏覽的區段層級畫面。第一個畫面在啟用儀表板時為儀表板,否則為「來源」。",
|
||||
"STARTUP_BEHAVIOR_FIRST_VIEW": "第一個可用畫面",
|
||||
"STARTUP_BEHAVIOR_RESTORE_LAST_VIEW": "還原上次畫面",
|
||||
"STARTUP_WINDOW_MODE": "啟動時的視窗",
|
||||
"STARTUP_WINDOW_MODE_DESCRIPTION": "選擇桌面視窗的開啟方式:上次的大小、最大化或全螢幕。下次啟動時生效。隨時按 F11 進入或離開全螢幕。",
|
||||
"STARTUP_WINDOW_MODE_NORMAL": "上次視窗大小",
|
||||
"STARTUP_WINDOW_MODE_MAXIMIZED": "最大化",
|
||||
"STARTUP_WINDOW_MODE_FULLSCREEN": "全螢幕",
|
||||
"SHOW_CAPTIONS_DESCRIPTION": "預設顯示或隱藏字幕",
|
||||
"SHOW_EXTERNAL_PLAYBACK_BAR": "顯示「正在播放」列",
|
||||
"SHOW_EXTERNAL_PLAYBACK_BAR_DESCRIPTION": "在桌面工作區底部保留外部播放器狀態列可見。",
|
||||
@@ -492,6 +497,13 @@
|
||||
"EMBEDDED_MPV_FRAME_COPY": "內嵌 MPV:幀複製引擎(實驗性)",
|
||||
"EMBEDDED_MPV_FRAME_COPY_DESCRIPTION": "影片會繪製到應用程式畫布上,選單和對話框可顯示在其上方。",
|
||||
"EMBEDDED_MPV_FRAME_COPY_RESTART": "重新啟動應用程式後生效。",
|
||||
"EMBEDDED_MPV_EXTRA_OPTIONS": "內嵌 MPV 額外選項",
|
||||
"EMBEDDED_MPV_EXTRA_OPTIONS_DESCRIPTION": "內嵌播放器的進階 libmpv 選項,每行一組 key=value(開頭不加 --)。這些選項會在所有引擎中於內建選項之後套用,並在下一次內嵌 MPV 工作階段啟動時生效。",
|
||||
"EMBEDDED_MPV_EXTRA_OPTIONS_HINT": "範例:hwdec=auto-safe。內嵌本身所依賴的選項(wid、vo、input-ipc-server 等)會被拒絕。",
|
||||
"EMBEDDED_MPV_EXTRA_OPTIONS_INVALID_LINES": "不是 key=value 格式的行:{{lines}}",
|
||||
"EMBEDDED_MPV_EXTRA_OPTIONS_FORBIDDEN_KEYS": "內嵌播放器無法變更這些選項:{{keys}}",
|
||||
"EMBEDDED_MPV_AUTO_RECONNECT": "自動重新連線中斷的串流",
|
||||
"EMBEDDED_MPV_AUTO_RECONNECT_DESCRIPTION": "播放中的串流中斷時,內嵌播放器會以逐漸拉長的間隔重新載入,最多嘗試 6 次後放棄。從未成功啟動的串流仍需手動重試。",
|
||||
"VOD_AUTO_FAILOVER": "播放發生錯誤時自動切換來源",
|
||||
"VOD_AUTO_FAILOVER_DESCRIPTION": "當電影無法播放,且其他播放清單中也有同一部影片時,會自動切換過去並保留播放進度。預設為關閉;由於其他來源可能使用不同的配音,每次切換都會提示您。"
|
||||
},
|
||||
@@ -515,6 +527,8 @@
|
||||
"RETRY": "重試",
|
||||
"STALLED": "此串流的啟動時間比預期長。",
|
||||
"PLAYBACK_FAILED": "內嵌 MPV 播放失敗。",
|
||||
"RECONNECTING": "連線中斷。正在重新連線… 第 {{attempt}} 次嘗試,共 {{maxAttempts}} 次",
|
||||
"STREAM_ENDED": "現場直播已結束。",
|
||||
"CHECKING_SUPPORT": "正在檢查內嵌 MPV 支援…",
|
||||
"NOT_AVAILABLE": "內嵌 MPV 在此環境中無法使用。",
|
||||
"LOADING_STREAM": "正在載入串流…",
|
||||
@@ -1574,6 +1588,7 @@
|
||||
"OPEN_GLOBAL_SEARCH": "開啟全域搜尋",
|
||||
"OPEN_RECENTLY_VIEWED": "開啟最近觀看",
|
||||
"SUBMIT_SHELL_SEARCH": "提交工作區搜尋",
|
||||
"TOGGLE_WINDOW_FULLSCREEN": "切換應用程式視窗全螢幕",
|
||||
"TOGGLE_SIDEBAR": "切換側邊欄",
|
||||
"M3U_CHANNEL_NUMBER": "按數字選擇 M3U 頻道",
|
||||
"PLAY_PAUSE": "播放或暫停嵌入式 MPV 播放",
|
||||
|
||||
@@ -62,3 +62,23 @@ resolved version.
|
||||
`tools/testing/website-download-pages.test.mjs`, which checks titles,
|
||||
canonicals, direct asset links, JSON-LD, cross-links and sitemap entries
|
||||
without depending on a specific version.
|
||||
|
||||
## Guides
|
||||
|
||||
Evergreen how-to posts live in the blog collection next to release notes
|
||||
(`xtream-codes-setup-guide.mdx` and `stalker-portal-setup-guide.mdx` in
|
||||
`apps/website/src/content/blog/`).
|
||||
Two conventions set them apart:
|
||||
|
||||
- **`faq` frontmatter.** An optional list of `{ q, a }` entries. `BlogPost.astro`
|
||||
renders it as an accordion after the body and emits a `FAQPage` JSON-LD block
|
||||
next to the `BlogPosting` one, so the answers can surface as rich results.
|
||||
- **Screenshots from the capture script.** Guide frames are captured by
|
||||
`pnpm release:screenshots --group guides` into
|
||||
`apps/website/public/blog/guides/screenshots/<slug>-<theme>.png`; the shots are
|
||||
declared in `tools/release/screenshots.manifest.json` with `"group": "guides"`
|
||||
and never appear in a release run.
|
||||
|
||||
`tools/testing/website-guides.test.mjs` (part of `pnpm nx test website`) checks
|
||||
each guide for the FAQPage schema, a link to the download hub and the presence
|
||||
of every referenced screenshot in the build output.
|
||||
@@ -1,8 +1,21 @@
|
||||
// @ts-check
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { defineConfig } from 'astro/config';
|
||||
import sitemap from '@astrojs/sitemap';
|
||||
import mdx from '@astrojs/mdx';
|
||||
|
||||
/**
|
||||
* Repository version for the download pages' offline fallback
|
||||
* (`src/lib/downloads.ts`). Read from the workspace root through the file
|
||||
* system and injected as a build-time constant: importing the root
|
||||
* `package.json` from inside the project trips the Nx module-boundaries rule
|
||||
* ("external resources cannot be imported using a relative path"), and a
|
||||
* value fixed at build time is what the fallback needs anyway.
|
||||
*/
|
||||
const { version } = JSON.parse(
|
||||
readFileSync(new URL('../../package.json', import.meta.url), 'utf8'),
|
||||
);
|
||||
|
||||
// Tailwind (v3) is applied via apps/website/postcss.config.mjs — the
|
||||
// @astrojs/tailwind integration only supports Astro <= 5.
|
||||
// https://astro.build/config
|
||||
@@ -11,4 +24,9 @@ export default defineConfig({
|
||||
base: '/iptvnator',
|
||||
outDir: '../../dist/apps/website',
|
||||
integrations: [sitemap(), mdx()],
|
||||
vite: {
|
||||
define: {
|
||||
__IPTVNATOR_VERSION__: JSON.stringify(version),
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -18,7 +18,7 @@
|
||||
"executor": "nx:run-commands",
|
||||
"dependsOn": ["build"],
|
||||
"options": {
|
||||
"command": "node --test tools/testing/website-giscus-comments.test.mjs tools/testing/website-download-pages.test.mjs"
|
||||
"command": "node --test tools/testing/website-giscus-comments.test.mjs tools/testing/website-download-pages.test.mjs tools/testing/website-guides.test.mjs"
|
||||
}
|
||||
},
|
||||
"serve": {
|
||||
|
||||
|
After Width: | Height: | Size: 471 KiB |
|
After Width: | Height: | Size: 476 KiB |
|
After Width: | Height: | Size: 279 KiB |
|
After Width: | Height: | Size: 276 KiB |
|
After Width: | Height: | Size: 418 KiB |
|
After Width: | Height: | Size: 440 KiB |
|
After Width: | Height: | Size: 433 KiB |
|
After Width: | Height: | Size: 454 KiB |
|
After Width: | Height: | Size: 162 KiB |
|
After Width: | Height: | Size: 160 KiB |
@@ -11,6 +11,8 @@
|
||||
- Download for Windows: https://4gray.github.io/iptvnator/download/windows/
|
||||
- Download for macOS: https://4gray.github.io/iptvnator/download/macos/
|
||||
- Download for Linux: https://4gray.github.io/iptvnator/download/linux/
|
||||
- Guide, Xtream Codes setup: https://4gray.github.io/iptvnator/blog/xtream-codes-setup-guide/
|
||||
- Guide, Stalker/Ministra portal setup: https://4gray.github.io/iptvnator/blog/stalker-portal-setup-guide/
|
||||
- Release Notes (v0.18): https://4gray.github.io/iptvnator/blog/v0-18-release-notes/
|
||||
- Troubleshooting (macOS app damaged fix): https://4gray.github.io/iptvnator/blog/macos-arm-app-damaged-fix/
|
||||
- Source Code: https://github.com/4gray/iptvnator
|
||||
|
||||
@@ -29,8 +29,8 @@ const iconPaths = {
|
||||
{links.map((item) => (
|
||||
<a
|
||||
href={item.href}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
target={item.href.startsWith('/') ? undefined : '_blank'}
|
||||
rel={item.href.startsWith('/') ? undefined : 'noopener noreferrer'}
|
||||
class="group rounded-xl border border-dashed border-surface-700/70 bg-surface-900/50 p-4 transition-all duration-300 hover:border-accent-500/40 hover:bg-accent-500/[0.06]"
|
||||
>
|
||||
<div class="flex items-center gap-2.5">
|
||||
|
||||
@@ -15,7 +15,11 @@ const { href, label, external = true } = Astro.props;
|
||||
class="not-prose group my-6 inline-flex items-center gap-2 rounded-lg border border-dashed border-accent-500/35 bg-accent-500/[0.08] px-4 py-2.5 text-sm font-medium text-accent-200 transition-all duration-200 hover:border-accent-400/50 hover:bg-accent-500/[0.14] hover:text-accent-100"
|
||||
>
|
||||
<span>{label ?? 'Open link'}</span>
|
||||
<svg class="h-4 w-4 transition-transform duration-200 group-hover:translate-x-0.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.8">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M13.5 4.5H19.5m0 0v6m0-6L10.5 13.5M6 6h3.75M6 10.5V18a1.5 1.5 0 001.5 1.5H15" />
|
||||
<svg class="h-4 w-4 transition-transform duration-200 group-hover:translate-x-0.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.8" aria-hidden="true">
|
||||
{external ? (
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M13.5 4.5H19.5m0 0v6m0-6L10.5 13.5M6 6h3.75M6 10.5V18a1.5 1.5 0 001.5 1.5H15" />
|
||||
) : (
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M17 8l4 4m0 0l-4 4m4-4H3" />
|
||||
)}
|
||||
</svg>
|
||||
</a>
|
||||
@@ -13,6 +13,8 @@ const blog = defineCollection({
|
||||
heroImage: z.string().optional(),
|
||||
tags: z.array(z.string()).default([]),
|
||||
draft: z.boolean().default(false),
|
||||
/** Rendered as an accordion after the post and emitted as FAQPage JSON-LD. */
|
||||
faq: z.array(z.object({ q: z.string(), a: z.string() })).optional(),
|
||||
}),
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,191 @@
|
||||
---
|
||||
title: How to Connect a Stalker or Ministra Portal to IPTVnator
|
||||
description: Add a Stalker portal to IPTVnator step by step. Which URL shapes work, how the MAC address is normalized, what device IDs and serial numbers are for, how IPTVnator detects the portal type, and what the usual refusals mean.
|
||||
pubDate: 2026-09-03
|
||||
author: 4gray
|
||||
heroImage: /iptvnator/blog/guides/screenshots/guide-stalker-live-dark.png
|
||||
tags:
|
||||
- guide
|
||||
- stalker-portal
|
||||
- setup
|
||||
- tutorial
|
||||
draft: false
|
||||
faq:
|
||||
- q: What is the difference between a Stalker portal and an Xtream Codes account?
|
||||
a: Both are ways a provider hands you access. Xtream Codes identifies you by username and password against an API. A Stalker or Ministra portal identifies a set-top box by its MAC address against a middleware server, the way MAG boxes and set-top-box emulators work. If your provider gave you a portal URL and a MAC address, you have a Stalker portal.
|
||||
- q: Which portal URL should I enter?
|
||||
a: Whatever your provider gave you, as long as it starts with http or https. A bare host, an address ending in /c, portal.php, or server/load.php all work. IPTVnator probes the usual endpoints behind that address and keeps the one that answers, so you rarely need to guess the exact path.
|
||||
- q: The provider's MAC address is written with dashes or in lowercase. Does it matter?
|
||||
a: No. IPTVnator normalizes hyphens, dots, spaces and lowercase letters into the canonical 00:1A:79:XX:XX:XX form when you leave the field, and again when you click Add. The bytes stay the same, so the portal sees the address it registered.
|
||||
- q: Should I turn on "Generate device IDs from the MAC address"?
|
||||
a: Only for a MAC the portal has never seen, or one you already use in StbEmu with generated IDs. A portal pins the first device ID it receives to the MAC and refuses any other one afterwards, so enabling it for an existing box with a different ID locks the account out. If your provider did not mention device IDs, leave the option off.
|
||||
- q: Why does IPTVnator ask for a username and password on some portals?
|
||||
a: A few Ministra portals require a login on top of the MAC address. IPTVnator reports "This portal requires a login and password" when it sees that; fill in the two optional fields and click Add again. Portals that never ask for it ignore the fields.
|
||||
- q: Does IPTVnator show a program guide and catch-up for portal channels?
|
||||
a: Yes. It loads the portal's seven-day schedule once per session and shows the current program in the channel list and a timeline under the player. Channels the portal marks as archived can be played from the timeline. On the desktop app, XMLTV sources configured in the settings act as a fallback.
|
||||
- q: Where can I see when my portal subscription expires?
|
||||
a: Open Account info from the playlist switcher in the header or from the source card on the dashboard. It shows status, login, tariff plan, expiry date with a days-left counter, and portal details, using the data saved at import when the portal cannot be reached.
|
||||
---
|
||||
|
||||
import Alert from '../../components/blog/Alert.astro';
|
||||
import PostButton from '../../components/blog/PostButton.astro';
|
||||
import StepRail from '../../components/blog/StepRail.astro';
|
||||
import LinkCards from '../../components/blog/LinkCards.astro';
|
||||
|
||||
Stalker portals, also sold as Ministra portals, are the middleware that MAG set-top boxes talk
|
||||
to. Instead of a username and password, the portal knows your device by its **MAC address**,
|
||||
and everything else, from the channel list to the movie catalog and the program guide, comes
|
||||
from that one identity. IPTVnator behaves like a MAG box towards the portal, so a subscription
|
||||
meant for a set-top box or a set-top-box emulator works on your desktop too.
|
||||
|
||||
This guide covers what to enter, what IPTVnator does with it, and what the portal's usual
|
||||
refusals mean. It applies to the desktop app on Windows, macOS and Linux and to the
|
||||
self-hosted browser version.
|
||||
|
||||
<Alert type="info" title="IPTVnator is a player, not a service">
|
||||
It does not include portals, channels or accounts. The portal URL and MAC address in this
|
||||
guide come from the provider you already have a contract with.
|
||||
</Alert>
|
||||
|
||||
## What you need from your provider
|
||||
|
||||
| Field | What it looks like | Notes |
|
||||
| --- | --- | --- |
|
||||
| Portal URL | `http://portal.example-provider.net/c` | A bare host, `/c`, `portal.php` or `server/load.php` all work. Must start with `http://` or `https://`. |
|
||||
| MAC address | `00:1A:79:12:34:56` | Twelve hex digits. Dashes, dots and lowercase are corrected for you. |
|
||||
| Username and password | optional | Only some Ministra portals ask for them. |
|
||||
| Serial number, device IDs, signatures | optional | Only if the provider registered a specific box and told you the values. |
|
||||
|
||||
Most portals only accept MAC addresses from Infomir's `00:1A:79` range, the manufacturer of
|
||||
MAG boxes. IPTVnator shows a hint when an address is outside that range but does not refuse
|
||||
it, because some reseller panels are configured differently.
|
||||
|
||||
## Add the portal
|
||||
|
||||
<StepRail
|
||||
title="In IPTVnator"
|
||||
steps={[
|
||||
'Open the Add playlist dialog from the dashboard, the header playlist switcher, or the command palette entry "Add Stalker portal".',
|
||||
'Choose the "Stalker portal" method.',
|
||||
'Fill in a title of your choice, the portal URL and the MAC address. Leave the optional fields empty unless your provider gave you values for them.',
|
||||
'Click "Add". IPTVnator probes the portal, authenticates with your MAC address, and opens the new source. "Validating portal..." can take a few seconds on slow servers.',
|
||||
]}
|
||||
/>
|
||||
|
||||

|
||||
|
||||
<Alert type="success" title="Paste the provider message instead">
|
||||
The Auto-detect method in the same dialog reads a pasted email or chat message. A MAC
|
||||
address is its strongest signal, so a message with a portal link and a MAC address
|
||||
pre-fills the Stalker form for you. You still click Add yourself.
|
||||
</Alert>
|
||||
|
||||
## What happens when you click Add
|
||||
|
||||
Providers write portal addresses in a dozen shapes, and the same host can run a strict
|
||||
Ministra middleware or a tolerant reseller panel. Guessing from the URL used to break real
|
||||
setups, so IPTVnator no longer guesses:
|
||||
|
||||
1. It tries the address you entered, then the usual endpoints behind it (`portal.php`,
|
||||
`server/load.php`, `stalker_portal/server/load.php`) in order.
|
||||
2. For each one it observes how the portal behaves. A portal that hands out data without a
|
||||
token is a **simple panel**; a portal that demands the handshake token is a **full
|
||||
Ministra portal**, and IPTVnator completes the handshake and reads your profile to
|
||||
confirm it.
|
||||
3. The endpoint and mode that worked are saved with the source. Later requests use exactly
|
||||
that address, and if a provider migrates the portal, IPTVnator re-probes once on the
|
||||
first failure and repairs the stored connection.
|
||||
|
||||
On a full portal the profile also carries your subscription facts, and the dialog confirms
|
||||
the import with the expiry date. A portal that cannot be reached during the import is still
|
||||
added when the address looks like a panel, so a temporarily offline provider does not block
|
||||
you; the next successful request completes the setup.
|
||||
|
||||
## After the import
|
||||
|
||||

|
||||
|
||||
The source gets its own sidebar:
|
||||
|
||||
- **Live TV**: categories on the left, channels with the current program and a progress
|
||||
bar, the player and a timeline of the schedule. IPTVnator loads the complete channel list
|
||||
once per session, so search covers every channel and not only the pages the portal has
|
||||
served so far. Channels the portal marks as archived can be played from the timeline.
|
||||
- **Radio**: portal radio stations in a dedicated audio layout, always through the built-in
|
||||
player.
|
||||
- **Movies** and **Series**: poster grids per category and a detail page with description,
|
||||
cast, trailer and similar titles. Series come in the three shapes portals use, regular
|
||||
series, movies with embedded episodes and Ministra's lazily loaded seasons, and all three
|
||||
render as seasons and episodes with resume positions and watched markers. The desktop app
|
||||
adds a download button for offline viewing.
|
||||
- **Search**, **Favorites** and **Recent** for the source. Global favorites, recent items
|
||||
and search on the dashboard combine all your sources.
|
||||
- **Discover**, when TMDB metadata is enabled in the settings.
|
||||
|
||||
<Alert type="info" title="Program guide">
|
||||
IPTVnator asks the portal for a seven-day schedule once per session and shows it in the
|
||||
channel list and in the timeline under the player. If a channel shows nothing, the portal
|
||||
did not publish a schedule for it. The desktop app additionally uses XMLTV sources
|
||||
configured under Settings › EPG as a fallback, and a right-click on a channel offers
|
||||
"Map EPG channel" to link it to an XMLTV channel by hand.
|
||||
</Alert>
|
||||
|
||||
## Check the subscription
|
||||
|
||||
Open **Account info** from the playlist switcher in the header or from the source card on
|
||||
the dashboard. IPTVnator shows the data saved during the import immediately, marked as saved
|
||||
data, then refreshes it from the portal: status, login, tariff plan, expiry date with a
|
||||
days-left counter, the MAC address and the portal details. The source card on the dashboard
|
||||
carries a small expiry chip that turns amber during the last week of the subscription.
|
||||
|
||||
## Device identity, explained
|
||||
|
||||
A real MAG box reports more than its MAC address: a serial number, two device IDs and two
|
||||
signatures. IPTVnator sends only what you typed. Empty fields stay empty, nothing is invented
|
||||
behind your back, and that matters because of how portals treat device IDs:
|
||||
|
||||
- The portal **pins the first device ID it receives** to the MAC address and refuses every
|
||||
later one. A later empty value locks the MAC out for good.
|
||||
- **Generate device IDs from the MAC address** produces the same pair StbEmu generates, so a
|
||||
MAC you already use there keeps working. It is offered at import only, writes the values
|
||||
into the visible fields, and re-derives them if you correct the MAC before clicking Add.
|
||||
- Once a device ID is stored, the playlist details dialog warns before you change or clear
|
||||
it, because the portal will not accept the new value.
|
||||
|
||||
If your provider registered the box for you and sent a serial number or device IDs, enter
|
||||
exactly those values.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
- **The portal requires a login and password**: fill in the optional username and password
|
||||
fields and click Add again. Some Ministra portals demand a login on top of the MAC address.
|
||||
- **The portal rejected the login and password**: the credentials are wrong or the
|
||||
subscription lapsed; the portal's own message is shown below the headline.
|
||||
- **The portal has a different device ID registered for this MAC address**: another box or
|
||||
emulator authenticated first. Restore the device ID you used there, or ask the provider to
|
||||
reset the device for this MAC.
|
||||
- **The portal refused access for this device**: the account is blocked or expired, or the
|
||||
MAC address is not registered. The portal's own message tells you which; contact the
|
||||
provider.
|
||||
- **Failed to authenticate with the portal. Check the URL and MAC address**: the host did
|
||||
not answer on any of the probed endpoints. Check the address, the network or the VPN, and
|
||||
try again.
|
||||
- **Channels load but nothing plays**: the built-in web players cannot decode every codec.
|
||||
Open the stream in MPV or VLC via Settings › Playback, or enable the experimental embedded
|
||||
MPV engine. IPTVnator resolves a fresh temporary link from the portal for every playback,
|
||||
so a stream that stopped working usually works again after re-selecting the channel.
|
||||
- **The catalog looks stale**: portals paginate their lists. Use the refresh action in the
|
||||
live TV header to reload the complete channel list, and re-open a category to fetch its
|
||||
content again.
|
||||
|
||||
## Related
|
||||
|
||||
<LinkCards
|
||||
links={[
|
||||
{ label: 'Download IPTVnator', href: '/iptvnator/download/', hint: 'Windows, macOS and Linux builds, package managers and the browser version.', icon: 'download' },
|
||||
{ label: 'Add an Xtream Codes account', href: '/iptvnator/blog/xtream-codes-setup-guide/', hint: 'The username-and-password way of connecting a provider.', icon: 'docs' },
|
||||
{ label: 'Why some streams need an external player', href: '/iptvnator/blog/why-external-players-help/', hint: 'Codecs, containers and when MPV or VLC is the right tool.', icon: 'docs' },
|
||||
]}
|
||||
/>
|
||||
|
||||
<PostButton href="/iptvnator/download/" label="Get IPTVnator for your platform" external={false} />
|
||||