refactor(runtime): gate settings external players by capability

This commit is contained in:
4gray committed 2026-05-22 13:40:14 +03:00
1 parent b4fd93354e
commit 09e2e08fc1
9 files changed
+104 -27

No files matched your search

@@ -69,7 +69,7 @@
</div>
</div>
@if (isDesktop() && isExternalPlayerSelected()) {
@if (supportsManagedExternalPlayers() && isExternalPlayerSelected()) {
<div
class="setting-item"
data-test-id="external-player-double-click-setting"
@@ -148,7 +148,7 @@
</div>
}
@if (isDesktop() && form().value.player === 'mpv') {
@if (supportsManagedExternalPlayers() && form().value.player === 'mpv') {
<div class="setting-item">
<div class="setting-item__meta">
<h4>{{ 'SETTINGS.MPV_PLAYER_PATH_LABEL' | translate }}</h4>
@@ -220,7 +220,7 @@
</div>
}
@if (isDesktop() && form().value.player === 'vlc') {
@if (supportsManagedExternalPlayers() && form().value.player === 'vlc') {
<div class="setting-item">
<div class="setting-item__meta">
<h4>{{ 'SETTINGS.VLC_PLAYER_PATH_LABEL' | translate }}</h4>
@@ -58,9 +58,10 @@ describe('SettingsPlaybackSectionComponent', () => {
fixture.componentRef.setInput('streamFormatEnum', StreamFormat);
});
it('hides the external-player double-click option outside desktop builds', () => {
it('hides the external-player double-click option when managed external players are unsupported', () => {
fixture.componentRef.setInput('form', createForm(VideoPlayer.MPV));
fixture.componentRef.setInput('isDesktop', false);
fixture.componentRef.setInput('isDesktop', true);
fixture.componentRef.setInput('supportsManagedExternalPlayers', false);
fixture.detectChanges();
expect(
@@ -75,6 +76,7 @@ describe('SettingsPlaybackSectionComponent', () => {
it('hides the external-player double-click option for embedded players', () => {
fixture.componentRef.setInput('isDesktop', true);
fixture.componentRef.setInput('supportsManagedExternalPlayers', true);
fixture.detectChanges();
expect(
@@ -85,10 +87,11 @@ describe('SettingsPlaybackSectionComponent', () => {
});
it.each([VideoPlayer.MPV, VideoPlayer.VLC])(
'labels the double-click option as external-player behavior on desktop for %s',
'labels the double-click option as external-player behavior when managed players are supported for %s',
(player) => {
fixture.componentRef.setInput('form', createForm(player));
fixture.componentRef.setInput('isDesktop', true);
fixture.componentRef.setInput('supportsManagedExternalPlayers', true);
fixture.detectChanges();
expect(
@@ -106,6 +109,7 @@ describe('SettingsPlaybackSectionComponent', () => {
const form = createForm();
fixture.componentRef.setInput('form', form);
fixture.componentRef.setInput('isDesktop', true);
fixture.componentRef.setInput('supportsManagedExternalPlayers', true);
fixture.detectChanges();
expect(
@@ -147,6 +151,7 @@ describe('SettingsPlaybackSectionComponent', () => {
it('shows MPV bundle guidance and the IINA executable tip for desktop MPV playback', () => {
fixture.componentRef.setInput('form', createForm(VideoPlayer.MPV));
fixture.componentRef.setInput('isDesktop', true);
fixture.componentRef.setInput('supportsManagedExternalPlayers', true);
fixture.detectChanges();
expect(fixture.nativeElement.textContent).toContain(
@@ -162,9 +167,10 @@ describe('SettingsPlaybackSectionComponent', () => {
);
});
it('hides MPV path guidance and the IINA executable tip outside desktop builds', () => {
it('hides MPV path guidance and the IINA executable tip when managed external players are unsupported', () => {
fixture.componentRef.setInput('form', createForm(VideoPlayer.MPV));
fixture.componentRef.setInput('isDesktop', false);
fixture.componentRef.setInput('isDesktop', true);
fixture.componentRef.setInput('supportsManagedExternalPlayers', false);
fixture.detectChanges();
expect(fixture.nativeElement.textContent).not.toContain(
@@ -183,6 +189,7 @@ describe('SettingsPlaybackSectionComponent', () => {
it('shows VLC bundle guidance without the IINA tip for desktop VLC playback', () => {
fixture.componentRef.setInput('form', createForm(VideoPlayer.VLC));
fixture.componentRef.setInput('isDesktop', true);
fixture.componentRef.setInput('supportsManagedExternalPlayers', true);
fixture.detectChanges();
expect(fixture.nativeElement.textContent).toContain(
@@ -198,9 +205,10 @@ describe('SettingsPlaybackSectionComponent', () => {
);
});
it('hides VLC path guidance outside desktop builds', () => {
it('hides VLC path guidance when managed external players are unsupported', () => {
fixture.componentRef.setInput('form', createForm(VideoPlayer.VLC));
fixture.componentRef.setInput('isDesktop', false);
fixture.componentRef.setInput('isDesktop', true);
fixture.componentRef.setInput('supportsManagedExternalPlayers', false);
fixture.detectChanges();
expect(fixture.nativeElement.textContent).not.toContain(
@@ -215,6 +223,7 @@ describe('SettingsPlaybackSectionComponent', () => {
it('does not show external-player path guidance for embedded players', () => {
fixture.componentRef.setInput('isDesktop', true);
fixture.componentRef.setInput('supportsManagedExternalPlayers', true);
fixture.detectChanges();
expect(fixture.nativeElement.textContent).not.toContain(
@@ -233,6 +242,7 @@ describe('SettingsPlaybackSectionComponent', () => {
it('shows MPV command-line arguments only when MPV is selected', () => {
fixture.componentRef.setInput('form', createForm(VideoPlayer.MPV));
fixture.componentRef.setInput('isDesktop', true);
fixture.componentRef.setInput('supportsManagedExternalPlayers', true);
fixture.detectChanges();
expect(
@@ -258,6 +268,7 @@ describe('SettingsPlaybackSectionComponent', () => {
it('shows VLC command-line arguments only when VLC is selected', () => {
fixture.componentRef.setInput('form', createForm(VideoPlayer.VLC));
fixture.componentRef.setInput('isDesktop', true);
fixture.componentRef.setInput('supportsManagedExternalPlayers', true);
fixture.detectChanges();
expect(
@@ -45,6 +45,7 @@ export class SettingsPlaybackSectionComponent {
readonly players = input.required<SettingsPlayerOption[]>();
readonly streamFormatEnum = input.required<typeof StreamFormat>();
readonly isDesktop = input(false);
readonly supportsManagedExternalPlayers = input(false);
readonly selectRecordingFolder = output<void>();
isExternalPlayerSelected(): boolean {
@@ -48,6 +48,7 @@
[players]="players()"
[streamFormatEnum]="streamFormatEnum"
[isDesktop]="isDesktop"
[supportsManagedExternalPlayers]="supportsManagedExternalPlayers"
(selectRecordingFolder)="selectRecordingFolder()"
/>
@@ -253,6 +253,8 @@ describe('SettingsComponent', () => {
forceFetchEpg: jest.fn().mockResolvedValue({ success: true }),
getAppVersion: jest.fn().mockResolvedValue('1.0.0'),
getLocalIpAddresses: jest.fn().mockResolvedValue([]),
openInMpv: jest.fn(),
openInVlc: jest.fn(),
platform: 'linux',
saveFileDialog: jest.fn().mockResolvedValue('/tmp/backup.json'),
setMpvPlayerPath: jest.fn().mockResolvedValue(undefined),
@@ -472,8 +474,44 @@ describe('SettingsComponent', () => {
).toBe(true);
});
it('hides external player path settings when the Electron bridge is incomplete', async () => {
fixture.destroy();
window.electron = {
getAppVersion: jest.fn().mockResolvedValue('1.0.0'),
platform: 'linux',
updateSettings: jest.fn().mockResolvedValue(undefined),
} as unknown as typeof window.electron;
const partialBridgeFixture =
TestBed.createComponent(SettingsComponent);
const partialBridgeComponent =
partialBridgeFixture.componentInstance;
partialBridgeComponent.checkAppVersion = jest.fn();
partialBridgeComponent.fetchLocalIpAddresses = jest
.fn()
.mockResolvedValue(undefined);
partialBridgeFixture.detectChanges();
expect(partialBridgeComponent.isDesktop).toBe(true);
expect(
partialBridgeComponent.supportsExternalPlayerPathSettings
).toBe(false);
expect(
partialBridgeComponent
.players()
.some((player) => player.id === VideoPlayer.MPV)
).toBe(false);
expect(
partialBridgeComponent
.players()
.some((player) => player.id === VideoPlayer.VLC)
).toBe(false);
});
it('does not block settings initialization while embedded mpv support is pending', async () => {
let resolveSupport: (value: EmbeddedMpvSupport) => void;
let resolveSupport:
| ((value: EmbeddedMpvSupport) => void)
| undefined;
window.electron = {
...window.electron,
getEmbeddedMpvSupport: jest.fn(
@@ -495,8 +533,11 @@ describe('SettingsComponent', () => {
).toBe(false);
if (!resolveSupport) {
throw new Error('Expected embedded MPV support resolver');
throw new Error(
'Expected embedded MPV support probe to start'
);
}
resolveSupport({
supported: true,
platform: 'darwin',
@@ -137,6 +137,8 @@ export class SettingsComponent implements OnInit, OnDestroy {
/** Flag that indicates whether the app runs in electron environment */
readonly isDesktop = this.runtime.isElectron;
readonly supportsExternalPlayerPathSettings =
this.runtime.supportsExternalPlayerPathSettings;
readonly embeddedMpvSupport = signal<EmbeddedMpvSupport | null>(null);
readonly supportsEmbeddedMpv = computed(
() => this.isDesktop && !!this.embeddedMpvSupport()?.supported
@@ -156,13 +158,15 @@ export class SettingsComponent implements OnInit, OnDestroy {
},
]
: []),
...SETTINGS_OS_PLAYER_OPTIONS,
...(this.supportsExternalPlayerPathSettings
? SETTINGS_OS_PLAYER_OPTIONS
: []),
]);
/** Player options */
readonly players = computed(() => [
...SETTINGS_EMBEDDED_PLAYER_OPTIONS,
...(this.isDesktop ? this.osPlayers() : []),
...this.osPlayers(),
]);
/** Current version of the app */
@@ -481,7 +485,9 @@ export class SettingsComponent implements OnInit, OnDestroy {
if (window.electron) {
window.electron.updateSettings(settings);
}
if (this.supportsExternalPlayerPathSettings && window.electron) {
window.electron.setMpvPlayerPath(settings.mpvPlayerPath);
window.electron.setVlcPlayerPath(settings.vlcPlayerPath);
}
+4 -3
View File
@@ -77,9 +77,10 @@ requires the complete downloads preload API surface used by `DownloadsService`,
bridge, `supportsXtreamSectionNavigation` is available in PWA and in Electron
when either the SQLite Xtream data source or the Xtream API transport is
available, and
`supportsManagedExternalPlayers` requires the MPV and VLC preload launch methods
(`openInMpv` and `openInVlc`); a partial Electron bridge must not expose
desktop-only actions in the PWA/shared UI.
`supportsManagedExternalPlayers` requires the MPV and VLC preload launch and
path-setting methods (`openInMpv`, `openInVlc`, `setMpvPlayerPath`, and
`setVlcPlayerPath`); a partial Electron bridge must not expose desktop-only
actions in the PWA/shared UI.
## Runtime Limitations
@@ -27,6 +27,7 @@ describe('RuntimeCapabilitiesService', () => {
expect(service.supportsPortalActivityStorage).toBe(false);
expect(service.supportsPlaylistRefresh).toBe(false);
expect(service.supportsManagedExternalPlayers).toBe(false);
expect(service.supportsExternalPlayerPathSettings).toBe(false);
expect(service.supportsEmbeddedMpv).toBe(false);
expect(service.supportsDesktopFileSave).toBe(false);
expect(service.supportsRemoteControl).toBe(false);
@@ -97,6 +98,8 @@ describe('RuntimeCapabilitiesService', () => {
onPlaylistRefreshEvent: jest.fn(),
openInMpv: jest.fn(),
openInVlc: jest.fn(),
setMpvPlayerPath: jest.fn(),
setVlcPlayerPath: jest.fn(),
prepareEmbeddedMpv: jest.fn(),
saveFileDialog: jest.fn(),
writeFile: jest.fn(),
@@ -120,6 +123,7 @@ describe('RuntimeCapabilitiesService', () => {
expect(service.supportsPortalActivityStorage).toBe(true);
expect(service.supportsPlaylistRefresh).toBe(true);
expect(service.supportsManagedExternalPlayers).toBe(true);
expect(service.supportsExternalPlayerPathSettings).toBe(true);
expect(service.supportsEmbeddedMpv).toBe(true);
expect(service.supportsDesktopFileSave).toBe(true);
expect(service.supportsRemoteControl).toBe(true);
@@ -143,6 +147,7 @@ describe('RuntimeCapabilitiesService', () => {
expect(service.supportsPortalActivityStorage).toBe(false);
expect(service.supportsPlaylistRefresh).toBe(false);
expect(service.supportsManagedExternalPlayers).toBe(false);
expect(service.supportsExternalPlayerPathSettings).toBe(false);
expect(service.supportsEmbeddedMpv).toBe(false);
expect(service.supportsDesktopFileSave).toBe(false);
expect(service.supportsRemoteControl).toBe(false);
@@ -170,22 +175,27 @@ describe('RuntimeCapabilitiesService', () => {
expect(service.supportsXtreamSqliteDataSource).toBe(false);
});
it('requires both managed external player launch methods', () => {
testWindow.electron = {
openInMpv: jest.fn(),
};
const service = new RuntimeCapabilitiesService();
expect(service.isElectron).toBe(true);
expect(service.supportsManagedExternalPlayers).toBe(false);
it('decouples external player launch support from path-setting support', () => {
testWindow.electron = {
openInMpv: jest.fn(),
openInVlc: jest.fn(),
};
const service = new RuntimeCapabilitiesService();
expect(service.isElectron).toBe(true);
expect(service.supportsManagedExternalPlayers).toBe(true);
expect(service.supportsExternalPlayerPathSettings).toBe(false);
testWindow.electron = {
openInMpv: jest.fn(),
openInVlc: jest.fn(),
setMpvPlayerPath: jest.fn(),
setVlcPlayerPath: jest.fn(),
};
expect(service.supportsManagedExternalPlayers).toBe(true);
expect(service.supportsExternalPlayerPathSettings).toBe(true);
});
it('requires the complete downloads preload surface', () => {
@@ -136,6 +136,12 @@ export class RuntimeCapabilitiesService {
);
}
get supportsExternalPlayerPathSettings(): boolean {
return ['setMpvPlayerPath', 'setVlcPlayerPath'].every((methodName) =>
this.hasElectronMethod(methodName)
);
}
get supportsEmbeddedMpv(): boolean {
return this.hasElectronMethod('prepareEmbeddedMpv');
}