Files
iptvnator/libs/services/src/lib/settings-store.service.ts
T

418 lines
16 KiB
TypeScript

import { computed, inject } from '@angular/core';
import {
patchState,
signalStore,
withComputed,
withHooks,
withMethods,
withState,
} from '@ngrx/signals';
import { StorageMap } from '@ngx-pwa/local-storage';
import { firstValueFrom } from 'rxjs';
import {
DEFAULT_DASHBOARD_RAILS_SETTINGS,
DEFAULT_TMDB_SETTINGS,
ElectronBridgeTrustOptions,
EpgViewMode,
Language,
Settings,
StartupBehavior,
STORE_KEY,
StreamFormat,
Theme,
VideoPlayer,
normalizeEpgOffsetMinutes,
normalizeDashboardRailsSettings,
normalizeStartupWindowMode,
} from '@iptvnator/shared/interfaces';
const DEFAULT_SETTINGS: Settings = {
player: VideoPlayer.VideoJs,
webPlayerSharedControls: true,
playerAmbientMode: false,
playerUpNextRail: true,
vodAutoFailover: false,
m3uVodDetails: true,
streamFormat: StreamFormat.AutoStreamFormat,
openStreamOnDoubleClick: false,
language: Language.ENGLISH,
showCaptions: false,
showDashboard: true,
startupBehavior: StartupBehavior.FirstView,
startupWindowMode: 'normal',
showExternalPlaybackBar: true,
stripCountryPrefix: false,
theme: Theme.SystemTheme,
mpvPlayerPath: '',
mpvPlayerArguments: '',
mpvReuseInstance: false,
vlcPlayerPath: '',
vlcPlayerArguments: '',
vlcReuseInstance: false,
remoteControl: false,
remoteControlPort: 8765,
epgUrl: [],
downloadFolder: '',
recordingFolder: '',
embeddedMpvFrameCopy: false,
embeddedMpvExtraOptions: '',
embeddedMpvAutoReconnect: true,
portalConnectivityGuard: true,
coverSize: 'medium',
epgViewMode: 'timeline',
epgOffsetMinutes: 0,
dashboardRails: DEFAULT_DASHBOARD_RAILS_SETTINGS,
preferUploadedEpgOverXtream: false,
trustedPrivateNetworkEpgUrls: [],
trustedInsecureTlsHosts: [],
tmdb: DEFAULT_TMDB_SETTINGS,
};
/**
* Which half of the settings persistence round-trip failed, if any.
*
* Settings live in the renderer's IndexedDB, which can be unavailable for
* reasons the app cannot control (a second instance holding the Chromium
* storage lock, a corrupted profile, storage blocked by security software).
* Both failures used to be swallowed: `updateSettings` patches the in-memory
* state before persisting, so a failed write still looked applied until the
* next restart (issue #1156). Recording the failure lets the settings UI say
* so instead of pretending the change stuck.
*/
export type SettingsStorageFailure = 'load' | 'save';
interface SettingsStorageState {
storageFailure: SettingsStorageFailure | null;
}
let embeddedMpvPrepareScheduled = false;
function scheduleEmbeddedMpvPrepare(): void {
if (
embeddedMpvPrepareScheduled ||
typeof window === 'undefined' ||
!window.electron?.prepareEmbeddedMpv
) {
return;
}
embeddedMpvPrepareScheduled = true;
const prepare = () => {
void window.electron
.prepareEmbeddedMpv?.()
.then((support) => {
if (!support?.supported) {
embeddedMpvPrepareScheduled = false;
}
})
.catch((error) => {
embeddedMpvPrepareScheduled = false;
console.warn('Failed to prepare embedded MPV.', error);
});
};
const idleWindow = window as typeof window & {
requestIdleCallback?: (
callback: IdleRequestCallback,
options?: IdleRequestOptions
) => number;
};
if (idleWindow.requestIdleCallback) {
idleWindow.requestIdleCallback(prepare, { timeout: 5000 });
} else {
window.setTimeout(prepare, 2000);
}
}
export const SettingsStore = signalStore(
{ providedIn: 'root' },
withState<Settings>(DEFAULT_SETTINGS),
withState<SettingsStorageState>({ storageFailure: null }),
withComputed((store) => ({
/**
* Live EPG panel layout with the `'timeline'` default applied — the
* single source of truth for the four live hosts, so the fallback is
* not duplicated per call-site.
*/
resolvedEpgViewMode: computed<EpgViewMode>(
() => store.epgViewMode?.() ?? 'timeline'
),
resolvedEpgOffsetMinutes: computed(() =>
normalizeEpgOffsetMinutes(store.epgOffsetMinutes?.())
),
})),
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)
);
patchState(store, { storageFailure: null });
if (stored) {
const storedSettings = stored as Partial<Settings>;
patchState(store, {
...DEFAULT_SETTINGS,
...storedSettings,
epgOffsetMinutes: normalizeEpgOffsetMinutes(
storedSettings.epgOffsetMinutes
),
// Absent in settings stored before the default
// flip means "never chose" — those users get the
// new default; only an explicit false opts out.
webPlayerSharedControls:
storedSettings.webPlayerSharedControls !==
false,
portalConnectivityGuard:
storedSettings.portalConnectivityGuard !==
false,
embeddedMpvAutoReconnect:
storedSettings.embeddedMpvAutoReconnect !==
false,
dashboardRails: normalizeDashboardRailsSettings(
storedSettings.dashboardRails
),
});
void this.sanitizeEmbeddedMpvSelection().catch(
(error) => {
console.warn(
'Failed to verify embedded MPV support while loading settings.',
error
);
}
);
}
})().catch((error) => {
settingsLoadPromise = undefined;
console.error('Failed to load settings:', error);
// Keep default settings if loading fails, but remember
// that they are defaults-by-failure rather than by choice
// so the settings UI can warn about it.
patchState(store, { storageFailure: 'load' });
});
return settingsLoadPromise;
},
async updateSettings(settings: Partial<Settings>) {
patchState(store, {
...settings,
...(settings.webPlayerSharedControls !== undefined
? {
webPlayerSharedControls:
settings.webPlayerSharedControls !== false,
}
: {}),
...(settings.portalConnectivityGuard !== undefined
? {
portalConnectivityGuard:
settings.portalConnectivityGuard !== false,
}
: {}),
...(settings.embeddedMpvAutoReconnect !== undefined
? {
embeddedMpvAutoReconnect:
settings.embeddedMpvAutoReconnect !== false,
}
: {}),
...(settings.dashboardRails !== undefined
? {
dashboardRails: normalizeDashboardRailsSettings(
settings.dashboardRails
),
}
: {}),
...(settings.epgOffsetMinutes !== undefined
? {
epgOffsetMinutes: normalizeEpgOffsetMinutes(
settings.epgOffsetMinutes
),
}
: {}),
});
// Save the complete settings object, not just the partial update
const completeSettings = this.getSettings();
try {
await firstValueFrom(
storage.set(STORE_KEY.Settings, completeSettings)
);
patchState(store, { storageFailure: null });
if (completeSettings.player === VideoPlayer.EmbeddedMpv) {
scheduleEmbeddedMpvPrepare();
}
} catch (error) {
console.error('Failed to save settings:', error);
// The in-memory patch above already applied, so without
// this flag the change looks saved until the next restart.
patchState(store, { storageFailure: 'save' });
throw error;
}
},
getSettings() {
return {
player: store.player(),
webPlayerSharedControls:
store.webPlayerSharedControls?.() !== false,
playerAmbientMode:
store.playerAmbientMode?.() ??
DEFAULT_SETTINGS.playerAmbientMode,
playerUpNextRail:
store.playerUpNextRail?.() ??
DEFAULT_SETTINGS.playerUpNextRail,
vodAutoFailover:
store.vodAutoFailover?.() ??
DEFAULT_SETTINGS.vodAutoFailover,
m3uVodDetails:
store.m3uVodDetails?.() ??
DEFAULT_SETTINGS.m3uVodDetails,
streamFormat: store.streamFormat(),
openStreamOnDoubleClick: store.openStreamOnDoubleClick(),
language: store.language(),
showCaptions: store.showCaptions(),
showDashboard: store.showDashboard(),
startupBehavior: store.startupBehavior(),
startupWindowMode: normalizeStartupWindowMode(
store.startupWindowMode?.()
),
showExternalPlaybackBar:
store.showExternalPlaybackBar?.() ??
DEFAULT_SETTINGS.showExternalPlaybackBar,
stripCountryPrefix:
store.stripCountryPrefix?.() ??
DEFAULT_SETTINGS.stripCountryPrefix,
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,
embeddedMpvExtraOptions:
store.embeddedMpvExtraOptions?.() ?? '',
portalConnectivityGuard:
store.portalConnectivityGuard?.() !== false,
embeddedMpvAutoReconnect:
store.embeddedMpvAutoReconnect?.() !== false,
coverSize:
store.coverSize?.() ?? DEFAULT_SETTINGS.coverSize,
epgViewMode:
store.epgViewMode?.() ?? DEFAULT_SETTINGS.epgViewMode,
epgOffsetMinutes: normalizeEpgOffsetMinutes(
store.epgOffsetMinutes?.()
),
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;
}
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();
},
})
);