mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-16 22:26:16 -08:00
feat(settings): add shared web player controls toggle (#1198)
* docs(playback): design shared controls setting * docs(playback): plan shared controls setting * feat(settings): persist shared web controls preference * test(settings): harden shared controls normalization coverage * feat(settings): expose shared web controls toggle * fix(settings): label shared controls toggle * feat(playback): resolve shared controls from settings * test(playback): cover shared controls setting * docs(playback): document shared controls preference * fix(playback): await settings before host creation * fix(settings): normalize shared controls updates
This commit is contained in:
1 parent
48e3736460
commit
beb62db314
38 files changed
+1863
-300
No files matched your search
@@ -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);
|
||||
|
||||
@@ -64,6 +116,69 @@ describe('SettingsStore dashboard rail settings', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('defaults shared web controls to false when the stored field is missing', async () => {
|
||||
storedSettings = {};
|
||||
const store = injector.get(SettingsStore);
|
||||
|
||||
await store.loadSettings();
|
||||
|
||||
expect(store.getSettings().webPlayerSharedControls).toBe(false);
|
||||
});
|
||||
|
||||
it('restores a persisted true shared web controls preference', async () => {
|
||||
storedSettings = {
|
||||
webPlayerSharedControls: true,
|
||||
};
|
||||
const store = injector.get(SettingsStore);
|
||||
|
||||
await store.loadSettings();
|
||||
|
||||
expect(store.getSettings().webPlayerSharedControls).toBe(true);
|
||||
});
|
||||
|
||||
it('normalizes a persisted string "true" shared web controls preference to false', async () => {
|
||||
storedSettings = {
|
||||
webPlayerSharedControls: 'true' as unknown as boolean,
|
||||
};
|
||||
const store = injector.get(SettingsStore);
|
||||
|
||||
await store.loadSettings();
|
||||
|
||||
expect(store.webPlayerSharedControls?.()).toBe(false);
|
||||
expect(store.getSettings().webPlayerSharedControls).toBe(false);
|
||||
});
|
||||
|
||||
it('persists an updated true shared web controls preference', async () => {
|
||||
const store = injector.get(SettingsStore);
|
||||
|
||||
await store.updateSettings({
|
||||
webPlayerSharedControls: true,
|
||||
});
|
||||
|
||||
expect(storage.set).toHaveBeenCalledWith(
|
||||
STORE_KEY.Settings,
|
||||
expect.objectContaining({
|
||||
webPlayerSharedControls: true,
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('serializes a malformed string "true" shared web controls update as false', async () => {
|
||||
const store = injector.get(SettingsStore);
|
||||
|
||||
await store.updateSettings({
|
||||
webPlayerSharedControls: 'true' as unknown as boolean,
|
||||
});
|
||||
|
||||
expect(store.webPlayerSharedControls?.()).toBe(false);
|
||||
expect(storage.set).toHaveBeenCalledWith(
|
||||
STORE_KEY.Settings,
|
||||
expect.objectContaining({
|
||||
webPlayerSharedControls: false,
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('deep-merges partial stored dashboard rail settings with enabled defaults', async () => {
|
||||
storedSettings = {
|
||||
player: VideoPlayer.VideoJs,
|
||||
|
||||
@@ -26,6 +26,7 @@ import {
|
||||
|
||||
const DEFAULT_SETTINGS: Settings = {
|
||||
player: VideoPlayer.VideoJs,
|
||||
webPlayerSharedControls: false,
|
||||
streamFormat: StreamFormat.AutoStreamFormat,
|
||||
openStreamOnDoubleClick: false,
|
||||
language: Language.ENGLISH,
|
||||
@@ -107,176 +108,206 @@ 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,
|
||||
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(),
|
||||
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.webPlayerSharedControls !== undefined
|
||||
? {
|
||||
webPlayerSharedControls:
|
||||
settings.webPlayerSharedControls === true,
|
||||
}
|
||||
: {}),
|
||||
...(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();
|
||||
|
||||
Reference in new issue
Block a user