fix(playback): await settings before host creation

This commit is contained in:
4gray committed 2026-07-17 09:23:43 +02:00
1 parent 96a2c2e33f
commit 3edb73fa2b
7 files changed
+311 -176

No files matched your search

+5 -3
View File
@@ -137,9 +137,11 @@ Key files:
- Persisted `Settings.webPlayerSharedControls` is default-off, and its checkbox
appears only when HTML5, Video.js, or ArtPlayer is selected.
`WebPlayerViewComponent` snapshots the preference into
`WEB_PLAYER_SHARED_CONTROLS` for each new player host. Saving applies to the
next host without an application restart; an existing session never changes
controls mode in place.
`WEB_PLAYER_SHARED_CONTROLS` 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 its component-scoped `EmbeddedMpvControlsAdapter`, while
native-view retains the legacy compositor-safe dock and external MPV/VLC
+1 -1
View File
@@ -618,7 +618,7 @@ This project uses modern Angular signal-based APIs and patterns. **ALWAYS** use
- External players: MPV, VLC (via IPC to Electron backend)
- 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. 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 + Windows; enabled via `Settings > Playback > Embedded MPV: frame-copy engine` (restart required) or `IPTVNATOR_ENABLE_EMBEDDED_MPV_FRAME_COPY=1` on top of the embedded MPV experiment flag): a per-session helper renders mpv offscreen at viewport size (headless CGL on macOS, headless EGL on Linux, WGL against a hidden window on Windows) and publishes BGRA frames into a shm ring (POSIX shm; a `Local\` named file mapping on Windows); the preload frame pump uploads them onto a renderer `<canvas data-embedded-mpv-frame>`, so controls/dialogs are ordinary DOM above the video. Frame-copy is the first runtime consumer of shared `app-player-controls`: `PlayerControlsComponent` and its surface/shortcut/fullscreen collaborators own the DOM UI interactions, while the component-scoped `EmbeddedMpvControlsAdapter` maps session state and commands and coordinates correlated recording state; native-view retains the legacy fixed dock. Stored and explicit opt-ins relax the sandbox only while the base embedded-MPV feature is enabled and a platform-supported packaged runtime contains both the regular-file helper (`iptvnator_mpv_helper` / `.exe`) and readable regular frame-reader addon; packaged discovery is restricted to packaged resources. A disabled base experiment keeps embedded MPV unavailable with the sandbox intact, while a missing, mode-stripped, or incomplete frame-copy runtime falls back to the native engine without relaxing the sandbox. On Linux the engine is dev-build-only for now: the helper links system libmpv (build deps: `libmpv-dev`, `libegl-dev`, `libgl-dev`, `libopengl-dev`, `libgbm-dev`) and is stripped from packages until bundled-runtime staging lands. On Windows the helper links vendored libmpv and package validation requires the exact MPV DLL named in the helper's PE import table beside the executable. Backend process adapter: `apps/electron-backend/src/app/services/embedded-mpv-frame-copy.adapter.ts`; shared-controls adapter: `libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-controls.adapter.ts`; helper: `apps/electron-backend/native/helper/`; details in `docs/architecture/embedded-mpv-native.md` ("Frame-Copy Engine").
- 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. Persisted `Settings.webPlayerSharedControls` is default-off, and its checkbox appears only when HTML5, Video.js, or ArtPlayer is selected. `WebPlayerViewComponent` snapshots the preference into the immutable token for each new player host, so 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, and a playback/session transition key prevents engine or session handoff from presenting stale recording feedback while timers and pending commands are cancelled. Same-session IPC replies yield to a broadcast snapshot received while the command was pending, so a successful recording acknowledgement cannot be rolled back by a stale reply. The built-in HTML5/hls.js player is the second guarded consumer: `HtmlVideoPlayerComponent` provides a component-scoped `WebVideoControlsAdapter`, while its neutral `web-video-support` bridge is shared with ArtPlayer and owns HLS/native tracks, MPEG-TS VOD duration correction, caption preference, and source cleanup. `HtmlVideoElementSession` owns native video-event lifecycle, persisted volume, start-time/time/ended propagation, and legacy post-play caption suppression. Video.js is the third guarded consumer: `VjsPlayerComponent` provides a component-scoped `WebVideoControlsAdapter`; its bridge rebinds the current Tech video after `playerreset`, exposes source-stable audio/subtitle IDs, preserves caption preference and explicit subtitle-off state, and reads Video.js duration. Reset-driven raw MPEG-TS changes pause first, coalesce to the latest desired source, preserve actual volume across Video.js's reset, and restart when authoritative live/VOD metadata changes. In shared-controls mode, Video.js native controls, click/double-click/hotkey actions, and spatial navigation are disabled. ArtPlayer is the fourth guarded consumer: `ArtPlayerComponent` provides a component-scoped `WebVideoControlsAdapter`; `ArtPlayerSourceSession` owns HLS/MPEG-TS/native sources, the neutral web-video bridge, exact cleanup, and a destroyed-session guard for delayed `customType` callbacks, while `ArtPlayerVideoSession` owns native media/ArtPlayer events. Shared ArtPlayer mode uses authoritative live/VOD metadata, HLS/native tracks and caption preference, MPEG-TS VOD duration correction, and reapplies app volume directly after ArtPlayer restores its own stored volume. Vendor chrome/hotkeys are disabled, and a transparent capture layer gives shared controls exclusive click and double-click ownership. `WebPlayerViewComponent.resolvedIsLive` supplies authoritative metadata; visible playback diagnostics disable shared pointer/keyboard ownership and exit only the active HTML5, Video.js, or ArtPlayer shell's own fullscreen so retry/fallback actions remain visible. On the preference-off path, all three web players retain their existing controls, source behavior, and legacy series navigation. Contract: `docs/architecture/player-controls-contract.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. Persisted `Settings.webPlayerSharedControls` is default-off, and its checkbox appears only when HTML5, Video.js, or ArtPlayer is selected. `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, and a playback/session transition key prevents engine or session handoff from presenting stale recording feedback while timers and pending commands are cancelled. Same-session IPC replies yield to a broadcast snapshot received while the command was pending, so a successful recording acknowledgement cannot be rolled back by a stale reply. The built-in HTML5/hls.js player is the second guarded consumer: `HtmlVideoPlayerComponent` provides a component-scoped `WebVideoControlsAdapter`, while its neutral `web-video-support` bridge is shared with ArtPlayer and owns HLS/native tracks, MPEG-TS VOD duration correction, caption preference, and source cleanup. `HtmlVideoElementSession` owns native video-event lifecycle, persisted volume, start-time/time/ended propagation, and legacy post-play caption suppression. Video.js is the third guarded consumer: `VjsPlayerComponent` provides a component-scoped `WebVideoControlsAdapter`; its bridge rebinds the current Tech video after `playerreset`, exposes source-stable audio/subtitle IDs, preserves caption preference and explicit subtitle-off state, and reads Video.js duration. Reset-driven raw MPEG-TS changes pause first, coalesce to the latest desired source, preserve actual volume across Video.js's reset, and restart when authoritative live/VOD metadata changes. In shared-controls mode, Video.js native controls, click/double-click/hotkey actions, and spatial navigation are disabled. ArtPlayer is the fourth guarded consumer: `ArtPlayerComponent` provides a component-scoped `WebVideoControlsAdapter`; `ArtPlayerSourceSession` owns HLS/MPEG-TS/native sources, the neutral web-video bridge, exact cleanup, and a destroyed-session guard for delayed `customType` callbacks, while `ArtPlayerVideoSession` owns native media/ArtPlayer events. Shared ArtPlayer mode uses authoritative live/VOD metadata, HLS/native tracks and caption preference, MPEG-TS VOD duration correction, and reapplies app volume directly after ArtPlayer restores its own stored volume. Vendor chrome/hotkeys are disabled, and a transparent capture layer gives shared controls exclusive click and double-click ownership. `WebPlayerViewComponent.resolvedIsLive` supplies authoritative metadata; visible playback diagnostics disable shared pointer/keyboard ownership and exit only the active HTML5, Video.js, or ArtPlayer shell's own fullscreen so retry/fallback actions remain visible. On the preference-off path, all three web players retain their existing controls, source behavior, and legacy series navigation. Contract: `docs/architecture/player-controls-contract.md`.
**VOD/Series Detail Pages (two-state layout)**:
+57 -4
View File
@@ -1,4 +1,8 @@
import { TestBed } from '@angular/core/testing';
import { SettingsStore } from '@iptvnator/services';
describe('app routes', () => {
let workspaceRoute: import('@angular/router').Route | undefined;
let workspaceChildren: Array<{
canActivate?: unknown[];
data?: Record<string, unknown>;
@@ -13,7 +17,6 @@ describe('app routes', () => {
) => unknown;
beforeAll(async () => {
jest.resetModules();
jest.unstable_mockModule(
'@iptvnator/playlist/m3u/feature-player',
() => ({
@@ -48,14 +51,64 @@ describe('app routes', () => {
const appRoutes = await import('./app.routes');
const { routes } = appRoutes;
const workspaceRoute = routes.find(
(route) => route.path === 'workspace'
);
workspaceRoute = routes.find((route) => route.path === 'workspace');
workspaceChildren = workspaceRoute?.children ?? [];
resolveElectronOnlyGlobalSearchRoute =
appRoutes.resolveElectronOnlyGlobalSearchRoute;
});
afterEach(() => {
TestBed.resetTestingModule();
});
it('waits for settings before activating workspace children', async () => {
let releaseSettings!: () => void;
const settingsPending = new Promise<void>((resolve) => {
releaseSettings = resolve;
});
const loadSettings = jest.fn(() => settingsPending);
TestBed.configureTestingModule({
providers: [
{
provide: SettingsStore,
useValue: { loadSettings },
},
],
});
const settingsReadyResolver =
workspaceRoute?.resolve?.['settingsReady'];
expect(typeof settingsReadyResolver).toBe('function');
if (typeof settingsReadyResolver !== 'function') {
return;
}
const resolution = TestBed.runInInjectionContext(() =>
Promise.resolve(
(
settingsReadyResolver as import('@angular/router').ResolveFn<void>
)(
{} as import('@angular/router').ActivatedRouteSnapshot,
{} as import('@angular/router').RouterStateSnapshot
)
)
);
let resolved = false;
void resolution.then(() => {
resolved = true;
});
await Promise.resolve();
expect(loadSettings).toHaveBeenCalledTimes(1);
expect(resolved).toBe(false);
releaseSettings();
await expect(resolution).resolves.toBeUndefined();
expect(resolved).toBe(true);
});
it('lazy-loads M3U workspace routes through the feature boundary', async () => {
const playlistRoute = workspaceChildren.find(
(route) => route.path === 'playlists/:id'
+6 -1
View File
@@ -1,8 +1,10 @@
import { inject } from '@angular/core';
import { Router, Routes } from '@angular/router';
import { RuntimeCapabilitiesService } from '@iptvnator/services';
import { RuntimeCapabilitiesService, SettingsStore } from '@iptvnator/services';
import { WorkspaceStartupPreferencesService } from '@iptvnator/workspace/shell/util';
const settingsReadyResolver = () => inject(SettingsStore).loadSettings();
const workspaceEntryRedirect = async () =>
inject(WorkspaceStartupPreferencesService).resolveInitialWorkspacePath();
@@ -41,6 +43,9 @@ export const routes: Routes = [
data: {
layout: 'workspace',
},
resolve: {
settingsReady: settingsReadyResolver,
},
loadComponent: () =>
import('@iptvnator/workspace/shell/feature').then(
(c) => c.WorkspaceShellComponent
@@ -63,7 +63,10 @@ ArtPlayer skin, source behavior, and legacy series navigation remain unchanged.
`Settings.webPlayerSharedControls` remains default-off. `WebPlayerViewComponent`
snapshots it into `WEB_PLAYER_SHARED_CONTROLS` when a new player host is
created, so HTML5, Video.js, and ArtPlayer switch atomically without an
application restart. Existing sessions never change controls mode in place.
application restart. The parent `/workspace` route awaits the initial
`SettingsStore` load, including for cold-start direct links to workspace
children, before any player host can take this snapshot. Existing sessions
never change controls mode in place.
The shared-controls architecture remains engine-selective: frame-copy can use
normal DOM layering, while the native platform view cannot. The integration
@@ -1,6 +1,6 @@
import { Injector } from '@angular/core';
import { StorageMap } from '@ngx-pwa/local-storage';
import { of } from 'rxjs';
import { of, Subject } from 'rxjs';
import {
DashboardRailsSettings,
Language,
@@ -53,6 +53,58 @@ describe('SettingsStore dashboard rail settings', () => {
});
});
it('shares the pending initial settings load across startup waiters', async () => {
const pendingSettings = new Subject<Partial<Settings> | null>();
storage.get.mockReturnValue(pendingSettings.asObservable());
const store = injector.get(SettingsStore);
const firstWaiter = store.loadSettings();
const secondWaiter = store.loadSettings();
const allWaiters = Promise.all([firstWaiter, secondWaiter]);
let waitersResolved = false;
void allWaiters.then(() => {
waitersResolved = true;
});
await Promise.resolve();
expect(waitersResolved).toBe(false);
expect(store.getSettings().webPlayerSharedControls).toBe(false);
pendingSettings.next({ webPlayerSharedControls: true });
pendingSettings.complete();
await allWaiters;
expect(storage.get).toHaveBeenCalledTimes(1);
expect(firstWaiter).toBe(secondWaiter);
expect(waitersResolved).toBe(true);
expect(store.getSettings().webPlayerSharedControls).toBe(true);
});
it('retries the initial settings load after a storage error', async () => {
const failingSettings = new Subject<Partial<Settings> | null>();
storage.get
.mockReturnValueOnce(failingSettings.asObservable())
.mockReturnValueOnce(of({ webPlayerSharedControls: true }));
const consoleError = jest
.spyOn(console, 'error')
.mockImplementation(() => undefined);
try {
const store = injector.get(SettingsStore);
const initialLoad = store.loadSettings();
failingSettings.error(new Error('storage unavailable'));
await initialLoad;
await store.loadSettings();
expect(storage.get).toHaveBeenCalledTimes(2);
expect(store.getSettings().webPlayerSharedControls).toBe(true);
} finally {
consoleError.mockRestore();
}
});
it('uses enabled dashboard rail defaults when no settings are stored', async () => {
const store = injector.get(SettingsStore);
+185 -165
View File
@@ -108,180 +108,200 @@ export const SettingsStore = signalStore(
() => store.epgViewMode?.() ?? 'timeline'
),
})),
withMethods((store, storage = inject(StorageMap)) => ({
async loadSettings() {
try {
const stored = await firstValueFrom(
storage.get(STORE_KEY.Settings)
);
if (stored) {
const storedSettings = stored as Partial<Settings>;
patchState(store, {
...DEFAULT_SETTINGS,
...storedSettings,
webPlayerSharedControls:
storedSettings.webPlayerSharedControls === true,
dashboardRails: normalizeDashboardRailsSettings(
storedSettings.dashboardRails
),
});
void this.sanitizeEmbeddedMpvSelection().catch((error) => {
console.warn(
'Failed to verify embedded MPV support while loading settings.',
error
withMethods((store, storage = inject(StorageMap)) => {
let settingsLoadPromise: Promise<void> | undefined;
return {
loadSettings() {
if (settingsLoadPromise) {
return settingsLoadPromise;
}
settingsLoadPromise = (async () => {
const stored = await firstValueFrom(
storage.get(STORE_KEY.Settings)
);
if (stored) {
const storedSettings = stored as Partial<Settings>;
patchState(store, {
...DEFAULT_SETTINGS,
...storedSettings,
webPlayerSharedControls:
storedSettings.webPlayerSharedControls === true,
dashboardRails: normalizeDashboardRailsSettings(
storedSettings.dashboardRails
),
});
void this.sanitizeEmbeddedMpvSelection().catch(
(error) => {
console.warn(
'Failed to verify embedded MPV support while loading settings.',
error
);
}
);
});
}
} catch (error) {
console.error('Failed to load settings:', error);
// Keep default settings if loading fails
}
},
async updateSettings(settings: Partial<Settings>) {
patchState(store, {
...settings,
...(settings.dashboardRails !== undefined
? {
dashboardRails: normalizeDashboardRailsSettings(
settings.dashboardRails
),
}
: {}),
});
// Save the complete settings object, not just the partial update
const completeSettings = this.getSettings();
try {
await firstValueFrom(
storage.set(STORE_KEY.Settings, completeSettings)
);
if (completeSettings.player === VideoPlayer.EmbeddedMpv) {
scheduleEmbeddedMpvPrepare();
}
} catch (error) {
console.error('Failed to save settings:', error);
throw error;
}
},
getSettings() {
return {
player: store.player(),
webPlayerSharedControls:
store.webPlayerSharedControls?.() === true,
streamFormat: store.streamFormat(),
openStreamOnDoubleClick: store.openStreamOnDoubleClick(),
language: store.language(),
showCaptions: store.showCaptions(),
showDashboard: store.showDashboard(),
startupBehavior: store.startupBehavior(),
showExternalPlaybackBar:
store.showExternalPlaybackBar?.() ??
DEFAULT_SETTINGS.showExternalPlaybackBar,
theme: store.theme(),
mpvPlayerPath: store.mpvPlayerPath(),
mpvPlayerArguments: store.mpvPlayerArguments(),
mpvReuseInstance: store.mpvReuseInstance(),
vlcPlayerPath: store.vlcPlayerPath(),
vlcPlayerArguments: store.vlcPlayerArguments(),
vlcReuseInstance: store.vlcReuseInstance(),
remoteControl: store.remoteControl(),
remoteControlPort: store.remoteControlPort(),
epgUrl: store.epgUrl(),
downloadFolder:
store.downloadFolder?.() ?? DEFAULT_SETTINGS.downloadFolder,
recordingFolder:
store.recordingFolder?.() ??
DEFAULT_SETTINGS.recordingFolder,
embeddedMpvFrameCopy:
store.embeddedMpvFrameCopy?.() ?? false,
coverSize: store.coverSize?.() ?? DEFAULT_SETTINGS.coverSize,
epgViewMode:
store.epgViewMode?.() ?? DEFAULT_SETTINGS.epgViewMode,
dashboardRails: normalizeDashboardRailsSettings(
store.dashboardRails?.()
),
preferUploadedEpgOverXtream:
store.preferUploadedEpgOverXtream?.() ??
DEFAULT_SETTINGS.preferUploadedEpgOverXtream,
trustedPrivateNetworkEpgUrls:
store.trustedPrivateNetworkEpgUrls?.() ??
DEFAULT_SETTINGS.trustedPrivateNetworkEpgUrls,
trustedInsecureTlsHosts:
store.trustedInsecureTlsHosts?.() ??
DEFAULT_SETTINGS.trustedInsecureTlsHosts,
tmdb: store.tmdb?.() ?? DEFAULT_SETTINGS.tmdb,
};
},
getDownloadFolder() {
return store.downloadFolder?.() ?? DEFAULT_SETTINGS.downloadFolder;
},
getRecordingFolder() {
return (
store.recordingFolder?.() ?? DEFAULT_SETTINGS.recordingFolder
);
},
getTrustOptions(): ElectronBridgeTrustOptions {
const settings = this.getSettings();
return {
trustedPrivateNetworkEpgUrls:
settings.trustedPrivateNetworkEpgUrls ?? [],
trustedInsecureTlsHosts: settings.trustedInsecureTlsHosts ?? [],
};
},
getPlayer() {
return store.player();
},
isEmbeddedPlayer() {
return (
store.player() === VideoPlayer.VideoJs ||
store.player() === VideoPlayer.Html5Player ||
store.player() === VideoPlayer.ArtPlayer ||
store.player() === VideoPlayer.EmbeddedMpv
);
},
async sanitizeEmbeddedMpvSelection() {
if (store.player() !== VideoPlayer.EmbeddedMpv) {
return;
}
if (
typeof window === 'undefined' ||
!window.electron?.getEmbeddedMpvSupport
) {
await this.updateSettings({
player: DEFAULT_SETTINGS.player,
}
})().catch((error) => {
settingsLoadPromise = undefined;
console.error('Failed to load settings:', error);
// Keep default settings if loading fails
});
return;
}
try {
const support = await window.electron.getEmbeddedMpvSupport();
if (!support.supported) {
return settingsLoadPromise;
},
async updateSettings(settings: Partial<Settings>) {
patchState(store, {
...settings,
...(settings.dashboardRails !== undefined
? {
dashboardRails: normalizeDashboardRailsSettings(
settings.dashboardRails
),
}
: {}),
});
// Save the complete settings object, not just the partial update
const completeSettings = this.getSettings();
try {
await firstValueFrom(
storage.set(STORE_KEY.Settings, completeSettings)
);
if (completeSettings.player === VideoPlayer.EmbeddedMpv) {
scheduleEmbeddedMpvPrepare();
}
} catch (error) {
console.error('Failed to save settings:', error);
throw error;
}
},
getSettings() {
return {
player: store.player(),
webPlayerSharedControls:
store.webPlayerSharedControls?.() === true,
streamFormat: store.streamFormat(),
openStreamOnDoubleClick: store.openStreamOnDoubleClick(),
language: store.language(),
showCaptions: store.showCaptions(),
showDashboard: store.showDashboard(),
startupBehavior: store.startupBehavior(),
showExternalPlaybackBar:
store.showExternalPlaybackBar?.() ??
DEFAULT_SETTINGS.showExternalPlaybackBar,
theme: store.theme(),
mpvPlayerPath: store.mpvPlayerPath(),
mpvPlayerArguments: store.mpvPlayerArguments(),
mpvReuseInstance: store.mpvReuseInstance(),
vlcPlayerPath: store.vlcPlayerPath(),
vlcPlayerArguments: store.vlcPlayerArguments(),
vlcReuseInstance: store.vlcReuseInstance(),
remoteControl: store.remoteControl(),
remoteControlPort: store.remoteControlPort(),
epgUrl: store.epgUrl(),
downloadFolder:
store.downloadFolder?.() ??
DEFAULT_SETTINGS.downloadFolder,
recordingFolder:
store.recordingFolder?.() ??
DEFAULT_SETTINGS.recordingFolder,
embeddedMpvFrameCopy:
store.embeddedMpvFrameCopy?.() ?? false,
coverSize:
store.coverSize?.() ?? DEFAULT_SETTINGS.coverSize,
epgViewMode:
store.epgViewMode?.() ?? DEFAULT_SETTINGS.epgViewMode,
dashboardRails: normalizeDashboardRailsSettings(
store.dashboardRails?.()
),
preferUploadedEpgOverXtream:
store.preferUploadedEpgOverXtream?.() ??
DEFAULT_SETTINGS.preferUploadedEpgOverXtream,
trustedPrivateNetworkEpgUrls:
store.trustedPrivateNetworkEpgUrls?.() ??
DEFAULT_SETTINGS.trustedPrivateNetworkEpgUrls,
trustedInsecureTlsHosts:
store.trustedInsecureTlsHosts?.() ??
DEFAULT_SETTINGS.trustedInsecureTlsHosts,
tmdb: store.tmdb?.() ?? DEFAULT_SETTINGS.tmdb,
};
},
getDownloadFolder() {
return (
store.downloadFolder?.() ?? DEFAULT_SETTINGS.downloadFolder
);
},
getRecordingFolder() {
return (
store.recordingFolder?.() ??
DEFAULT_SETTINGS.recordingFolder
);
},
getTrustOptions(): ElectronBridgeTrustOptions {
const settings = this.getSettings();
return {
trustedPrivateNetworkEpgUrls:
settings.trustedPrivateNetworkEpgUrls ?? [],
trustedInsecureTlsHosts:
settings.trustedInsecureTlsHosts ?? [],
};
},
getPlayer() {
return store.player();
},
isEmbeddedPlayer() {
return (
store.player() === VideoPlayer.VideoJs ||
store.player() === VideoPlayer.Html5Player ||
store.player() === VideoPlayer.ArtPlayer ||
store.player() === VideoPlayer.EmbeddedMpv
);
},
async sanitizeEmbeddedMpvSelection() {
if (store.player() !== VideoPlayer.EmbeddedMpv) {
return;
}
if (
typeof window === 'undefined' ||
!window.electron?.getEmbeddedMpvSupport
) {
await this.updateSettings({
player: DEFAULT_SETTINGS.player,
});
return;
}
scheduleEmbeddedMpvPrepare();
} catch (error) {
console.warn(
'Failed to verify embedded MPV support; reverting to the default inline player.',
error
);
await this.updateSettings({
player: DEFAULT_SETTINGS.player,
});
}
},
})),
try {
const support =
await window.electron.getEmbeddedMpvSupport();
if (!support.supported) {
await this.updateSettings({
player: DEFAULT_SETTINGS.player,
});
return;
}
scheduleEmbeddedMpvPrepare();
} catch (error) {
console.warn(
'Failed to verify embedded MPV support; reverting to the default inline player.',
error
);
await this.updateSettings({
player: DEFAULT_SETTINGS.player,
});
}
},
};
}),
withHooks({
onInit(store) {
store.loadSettings();