feat(workspace): add embedded MPV to the command palette

Register an "Switch player to Embedded MPV" command in the Cmd+K command
palette so the embedded MPV player can be activated like the other players.
Visibility is gated on an async getEmbeddedMpvSupport() check, mirroring the
Settings dropdown so the command only appears when embedded MPV is usable.

Generalizes the per-command visibility flag from desktopOnly to a `requires`
discriminator ('none' | 'managed-external' | 'embedded-mpv').

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Lars EmigandClaude Opus 4.8 committed 2026-06-27 11:51:09 +02:00
1 parent 6373c798a1
commit f7ad17298f
3 files changed
+152 -23

No files matched your search

+10 -5
View File
@@ -184,11 +184,16 @@ Command palette behavior is shell-owned but view-extensible:
visibility — a navigation command like `Open sources` is invisible while
the user is on `/workspace/sources` but the id stays in storage so it
reappears in the recent section after navigating away.
7. Five "Switch player to X" commands are registered globally by
`WorkspacePlayerCommandsContributor`. The MPV/VLC entries are visible only
in Electron, and the entry matching the current `SettingsStore.player()`
value is disabled. The new player setting applies to the next playback
session; an existing stream is not re-mounted.
7. Six "Switch player to X" commands are registered globally by
`WorkspacePlayerCommandsContributor` (VideoJS, HTML5, ArtPlayer, Embedded
MPV, MPV, VLC). Each command carries a `requires` flag gating its
visibility: the MPV/VLC ("managed-external") entries are visible only when
`RuntimeCapabilitiesService.supportsManagedExternalPlayers` is true, and the
Embedded MPV ("embedded-mpv") entry is visible only after an async
`window.electron.getEmbeddedMpvSupport()` check resolves to `supported`
(mirroring the Settings dropdown gate). The entry matching the current
`SettingsStore.player()` value is disabled. The new player setting applies
to the next playback session; an existing stream is not re-mounted.
Keyboard shortcut help is shell-owned:
@@ -46,10 +46,17 @@ describe('WorkspacePlayerCommandsContributor', () => {
let viewCommands: ViewCommandsMock;
let settingsStore: SettingsStoreMock;
let snackBar: SnackBarMock;
let runtime: { supportsManagedExternalPlayers: boolean };
let runtime: {
supportsManagedExternalPlayers: boolean;
supportsEmbeddedMpv: boolean;
};
let translate: { instant: jest.Mock; onLangChange: ReturnType<typeof of> };
function bootstrap(options: { supportsManagedExternalPlayers: boolean }) {
function bootstrap(options: {
supportsManagedExternalPlayers: boolean;
supportsEmbeddedMpv?: boolean;
embeddedMpvSupportResult?: { supported: boolean } | null;
}) {
viewCommands = {
registerCommand: jest.fn().mockReturnValue(() => undefined),
commands: jest.fn().mockReturnValue([]),
@@ -62,13 +69,23 @@ describe('WorkspacePlayerCommandsContributor', () => {
runtime = {
supportsManagedExternalPlayers:
options.supportsManagedExternalPlayers,
supportsEmbeddedMpv: options.supportsEmbeddedMpv ?? false,
};
const electronStub = runtime.supportsEmbeddedMpv
? {
getEmbeddedMpvSupport: jest.fn().mockResolvedValue(
options.embeddedMpvSupportResult ?? {
supported: true,
}
),
}
: undefined;
(window as unknown as { electron?: unknown }).electron = electronStub;
translate = {
instant: jest.fn(
(key: string, params?: Record<string, string | number>) =>
params?.['name']
? `${key}:${params['name']}`
: key
params?.['name'] ? `${key}:${params['name']}` : key
),
onLangChange: of(null),
};
@@ -92,9 +109,10 @@ describe('WorkspacePlayerCommandsContributor', () => {
afterEach(() => {
TestBed.resetTestingModule();
delete (window as unknown as { electron?: unknown }).electron;
});
it('registers all five player commands when running in Electron', () => {
it('registers all six player commands when running in Electron', () => {
bootstrap({ supportsManagedExternalPlayers: true });
const ids = getRegistered(viewCommands).map((c) => c.id);
@@ -102,6 +120,7 @@ describe('WorkspacePlayerCommandsContributor', () => {
'switch-player-videojs',
'switch-player-html5',
'switch-player-artplayer',
'switch-player-embedded-mpv',
'switch-player-mpv',
'switch-player-vlc',
]);
@@ -122,6 +141,66 @@ describe('WorkspacePlayerCommandsContributor', () => {
expect(visibilityById['switch-player-vlc']).toBe(false);
});
it('hides embedded MPV when it is unsupported', () => {
bootstrap({
supportsManagedExternalPlayers: true,
supportsEmbeddedMpv: false,
});
const embedded = getRegistered(viewCommands).find(
(c) => c.id === 'switch-player-embedded-mpv'
);
expect(resolveBoolean(embedded?.visible)).toBe(false);
});
it('shows embedded MPV once support resolves to supported', async () => {
bootstrap({
supportsManagedExternalPlayers: true,
supportsEmbeddedMpv: true,
embeddedMpvSupportResult: { supported: true },
});
await Promise.resolve();
await Promise.resolve();
const embedded = getRegistered(viewCommands).find(
(c) => c.id === 'switch-player-embedded-mpv'
);
expect(resolveBoolean(embedded?.visible)).toBe(true);
});
it('keeps embedded MPV hidden when support resolves to unsupported', async () => {
bootstrap({
supportsManagedExternalPlayers: true,
supportsEmbeddedMpv: true,
embeddedMpvSupportResult: { supported: false },
});
await Promise.resolve();
await Promise.resolve();
const embedded = getRegistered(viewCommands).find(
(c) => c.id === 'switch-player-embedded-mpv'
);
expect(resolveBoolean(embedded?.visible)).toBe(false);
});
it('switches to embedded MPV on run', () => {
bootstrap({
supportsManagedExternalPlayers: true,
supportsEmbeddedMpv: true,
});
const embedded = getRegistered(viewCommands).find(
(c) => c.id === 'switch-player-embedded-mpv'
);
embedded?.run({ query: '' });
expect(settingsStore.updateSettings).toHaveBeenCalledWith({
player: VideoPlayer.EmbeddedMpv,
});
});
it('marks the active player command as disabled', () => {
bootstrap({ supportsManagedExternalPlayers: true });
settingsStore.player.set(VideoPlayer.MPV);
@@ -1,4 +1,4 @@
import { DestroyRef, Injectable, inject } from '@angular/core';
import { DestroyRef, Injectable, inject, signal } from '@angular/core';
import { MatSnackBar } from '@angular/material/snack-bar';
import { TranslateService } from '@ngx-translate/core';
import {
@@ -8,13 +8,15 @@ import {
import { RuntimeCapabilitiesService, SettingsStore } from '@iptvnator/services';
import { VideoPlayer } from '@iptvnator/shared/interfaces';
type PlayerCommandRequirement = 'none' | 'managed-external' | 'embedded-mpv';
interface PlayerCommandDefinition {
id: string;
player: VideoPlayer;
icon: string;
nameKey: string;
keywords: readonly string[];
desktopOnly: boolean;
requires: PlayerCommandRequirement;
priority: number;
}
@@ -25,7 +27,7 @@ const PLAYER_COMMAND_DEFS: readonly PlayerCommandDefinition[] = [
icon: 'play_circle',
nameKey: 'SETTINGS.PLAYER_VIDEOJS',
keywords: ['player', 'videojs', 'video.js'],
desktopOnly: false,
requires: 'none',
priority: 90,
},
{
@@ -34,7 +36,7 @@ const PLAYER_COMMAND_DEFS: readonly PlayerCommandDefinition[] = [
icon: 'play_circle',
nameKey: 'SETTINGS.PLAYER_HTML5',
keywords: ['player', 'html5'],
desktopOnly: false,
requires: 'none',
priority: 91,
},
{
@@ -43,17 +45,26 @@ const PLAYER_COMMAND_DEFS: readonly PlayerCommandDefinition[] = [
icon: 'play_circle',
nameKey: 'SETTINGS.PLAYER_ARTPLAYER',
keywords: ['player', 'artplayer', 'art'],
desktopOnly: false,
requires: 'none',
priority: 92,
},
{
id: 'switch-player-embedded-mpv',
player: VideoPlayer.EmbeddedMpv,
icon: 'play_circle',
nameKey: 'SETTINGS.PLAYER_EMBEDDED_MPV',
keywords: ['player', 'embedded', 'mpv', 'native'],
requires: 'embedded-mpv',
priority: 93,
},
{
id: 'switch-player-mpv',
player: VideoPlayer.MPV,
icon: 'play_circle_outline',
nameKey: 'SETTINGS.PLAYER_MPV',
keywords: ['player', 'mpv', 'external'],
desktopOnly: true,
priority: 93,
requires: 'managed-external',
priority: 94,
},
{
id: 'switch-player-vlc',
@@ -61,8 +72,8 @@ const PLAYER_COMMAND_DEFS: readonly PlayerCommandDefinition[] = [
icon: 'play_circle_outline',
nameKey: 'SETTINGS.PLAYER_VLC',
keywords: ['player', 'vlc', 'external'],
desktopOnly: true,
priority: 94,
requires: 'managed-external',
priority: 95,
},
];
@@ -74,12 +85,15 @@ export class WorkspacePlayerCommandsContributor {
private readonly translate = inject(TranslateService);
private readonly destroyRef = inject(DestroyRef);
private readonly runtime = inject(RuntimeCapabilitiesService);
private readonly embeddedMpvSupported = signal(false);
constructor() {
const unregisters = PLAYER_COMMAND_DEFS.map((def) =>
this.viewCommands.registerCommand(this.toContribution(def))
);
void this.loadEmbeddedMpvSupport();
this.destroyRef.onDestroy(() => {
for (const unregister of unregisters) {
unregister();
@@ -87,6 +101,26 @@ export class WorkspacePlayerCommandsContributor {
});
}
private async loadEmbeddedMpvSupport(): Promise<void> {
if (
!this.runtime.supportsEmbeddedMpv ||
typeof window === 'undefined' ||
!window.electron?.getEmbeddedMpvSupport
) {
return;
}
try {
const support = await window.electron.getEmbeddedMpvSupport();
this.embeddedMpvSupported.set(!!support?.supported);
} catch (error) {
console.warn(
'Failed to verify embedded MPV support for the command palette.',
error
);
}
}
private toContribution(
def: PlayerCommandDefinition
): WorkspaceCommandContribution {
@@ -96,7 +130,8 @@ export class WorkspacePlayerCommandsContributor {
icon: def.icon,
labelKey: 'WORKSPACE.SHELL.COMMANDS.SWITCH_PLAYER_LABEL',
labelParams: () => ({ name: this.translate.instant(def.nameKey) }),
descriptionKey: 'WORKSPACE.SHELL.COMMANDS.SWITCH_PLAYER_DESCRIPTION',
descriptionKey:
'WORKSPACE.SHELL.COMMANDS.SWITCH_PLAYER_DESCRIPTION',
descriptionParams: () => ({
name: this.translate.instant(def.nameKey),
}),
@@ -105,13 +140,23 @@ export class WorkspacePlayerCommandsContributor {
this.translate.instant(def.nameKey).toLowerCase(),
],
priority: def.priority,
visible: () =>
!def.desktopOnly || this.runtime.supportsManagedExternalPlayers,
visible: () => this.isVisible(def),
enabled: () => this.settingsStore.player() !== def.player,
run: () => this.activate(def),
};
}
private isVisible(def: PlayerCommandDefinition): boolean {
switch (def.requires) {
case 'managed-external':
return this.runtime.supportsManagedExternalPlayers;
case 'embedded-mpv':
return this.embeddedMpvSupported();
default:
return true;
}
}
private activate(def: PlayerCommandDefinition): void {
void this.settingsStore.updateSettings({ player: def.player });