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:
4gray authored and GitHub committed 2026-07-17 13:38:06 +02:00
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,
+192 -161
View File
@@ -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();