Merge pull request #1093 from larsemig/feat/embedded-mpv-command-palette

feat(workspace): add embedded MPV to the command palette
This commit is contained in:
4gray authored and GitHub committed 2026-06-28 07:26:53 +02:00
commit 148affdfca
6 files changed
+243 -28

No files matched your search

+1 -1
View File
@@ -79,7 +79,7 @@ The renderer never gets direct native-module access. It can only call the preloa
- dispose session
- subscribe to session updates
Settings uses the preload support API only as a lightweight availability check. That check verifies platform, experiment gating, addon presence, and bundled platform runtime presence without `require()`-loading `embedded_mpv.node`. This avoids blocking Settings navigation on synchronous native-addon loading and code-signing or dynamic-linker work.
Settings uses the preload support API as an availability and capability check. Unsupported paths return before loading the addon when platform, experiment gating, addon presence, bundled runtime presence, or the Linux `mpv` executable check fails. Supported paths load `embedded_mpv.node` so the renderer can receive capability flags from the actual addon binary. Avoid calling this support API from global workspace startup paths; use an explicit user action or idle preparation path when a renderer surface only needs to reveal optional Embedded MPV UI.
When `embedded-mpv` is the saved player, the settings store schedules an idle `prepareEmbeddedMpv()` call. This intentionally moves the first native addon load away from the click-to-play path. It can still block the Electron main process briefly because Node native addon loading is synchronous, but doing it during idle is less visible than doing it when the user clicks a video. Actual MPV session creation still happens on playback because it needs the current Electron window handle and viewport bounds.
+13 -5
View File
@@ -184,11 +184,19 @@ 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 the command
palette lazily preloads an async `window.electron.getEmbeddedMpvSupport()`
check and it resolves to `supported` (mirroring the Settings dropdown gate).
Do not run this Embedded MPV support check from workspace shell bootstrap:
supported desktop builds may load the native addon while resolving
capabilities. 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,25 @@ describe('WorkspacePlayerCommandsContributor', () => {
let viewCommands: ViewCommandsMock;
let settingsStore: SettingsStoreMock;
let snackBar: SnackBarMock;
let runtime: { supportsManagedExternalPlayers: boolean };
let runtime: {
supportsManagedExternalPlayers: boolean;
supportsEmbeddedMpv: boolean;
};
let electronStub:
| {
getEmbeddedMpvSupport: jest.Mock<
Promise<{ supported: boolean }>,
[]
>;
}
| undefined;
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 +77,23 @@ describe('WorkspacePlayerCommandsContributor', () => {
runtime = {
supportsManagedExternalPlayers:
options.supportsManagedExternalPlayers,
supportsEmbeddedMpv: options.supportsEmbeddedMpv ?? false,
};
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 +117,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 +128,7 @@ describe('WorkspacePlayerCommandsContributor', () => {
'switch-player-videojs',
'switch-player-html5',
'switch-player-artplayer',
'switch-player-embedded-mpv',
'switch-player-mpv',
'switch-player-vlc',
]);
@@ -122,6 +149,73 @@ 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('does not verify embedded MPV support during contributor bootstrap', () => {
bootstrap({
supportsManagedExternalPlayers: true,
supportsEmbeddedMpv: true,
});
expect(electronStub?.getEmbeddedMpvSupport).not.toHaveBeenCalled();
});
it('shows embedded MPV once support resolves to supported', async () => {
const contributor = bootstrap({
supportsManagedExternalPlayers: true,
supportsEmbeddedMpv: true,
embeddedMpvSupportResult: { supported: true },
});
await contributor.ensureEmbeddedMpvSupportLoaded();
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 () => {
const contributor = bootstrap({
supportsManagedExternalPlayers: true,
supportsEmbeddedMpv: true,
embeddedMpvSupportResult: { supported: false },
});
await contributor.ensureEmbeddedMpvSupportLoaded();
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,6 +85,9 @@ export class WorkspacePlayerCommandsContributor {
private readonly translate = inject(TranslateService);
private readonly destroyRef = inject(DestroyRef);
private readonly runtime = inject(RuntimeCapabilitiesService);
private readonly embeddedMpvSupported = signal(false);
private embeddedMpvSupportChecked = false;
private embeddedMpvSupportLoad: Promise<void> | null = null;
constructor() {
const unregisters = PLAYER_COMMAND_DEFS.map((def) =>
@@ -87,6 +101,39 @@ export class WorkspacePlayerCommandsContributor {
});
}
ensureEmbeddedMpvSupportLoaded(): Promise<void> | undefined {
if (this.embeddedMpvSupportChecked) {
return undefined;
}
if (
!this.runtime.supportsEmbeddedMpv ||
typeof window === 'undefined' ||
!window.electron?.getEmbeddedMpvSupport
) {
this.embeddedMpvSupportChecked = true;
return undefined;
}
this.embeddedMpvSupportLoad ??= this.loadEmbeddedMpvSupport();
return this.embeddedMpvSupportLoad;
}
private async loadEmbeddedMpvSupport(): Promise<void> {
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
);
} finally {
this.embeddedMpvSupportChecked = true;
this.embeddedMpvSupportLoad = null;
}
}
private toContribution(
def: PlayerCommandDefinition
): WorkspaceCommandContribution {
@@ -96,7 +143,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 +153,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 });
@@ -20,8 +20,7 @@ export class WorkspaceShellCommandPaletteService {
private readonly viewCommands = inject(WorkspaceViewCommandService);
private readonly recentCommands = inject(RecentCommandsService);
private readonly destroyRef = inject(DestroyRef);
// Eager construction registers the player-switch commands via WorkspaceViewCommandService.
private readonly _playerCommandsBootstrap = inject(
private readonly playerCommands = inject(
WorkspacePlayerCommandsContributor
);
@@ -29,6 +28,7 @@ export class WorkspaceShellCommandPaletteService {
WorkspaceCommandPaletteComponent,
WorkspaceCommandSelection | undefined
> | null = null;
private commandPaletteOpening = false;
buildPaletteCommands(
ctx: CommandBuilderContext
@@ -42,8 +42,36 @@ export class WorkspaceShellCommandPaletteService {
return;
}
if (this.commandPaletteOpening) {
return;
}
const embeddedMpvSupportLoad =
this.playerCommands.ensureEmbeddedMpvSupportLoaded();
if (embeddedMpvSupportLoad) {
this.commandPaletteOpening = true;
void embeddedMpvSupportLoad.finally(() => {
this.commandPaletteOpening = false;
this.openResolvedCommandPalette(ctx, initialQuery);
});
return;
}
this.openResolvedCommandPalette(ctx, initialQuery);
}
private openResolvedCommandPalette(
ctx: CommandBuilderContext,
initialQuery: string
): void {
if (this.commandPaletteRef) {
return;
}
const commands = this.buildPaletteCommands(ctx);
const recentIds = this.recentCommands.entries().map((entry) => entry.id);
const recentIds = this.recentCommands
.entries()
.map((entry) => entry.id);
const dialogRef = this.dialog.open<
WorkspaceCommandPaletteComponent,
{
@@ -111,6 +111,9 @@ describe('WorkspaceShellFacade', () => {
record: jest.Mock;
prune: jest.Mock;
};
let playerCommands: {
ensureEmbeddedMpvSupportLoaded: jest.Mock;
};
let router: {
url: string;
events: ReturnType<typeof of>;
@@ -218,6 +221,9 @@ describe('WorkspaceShellFacade', () => {
record: jest.fn(),
prune: jest.fn(),
};
playerCommands = {
ensureEmbeddedMpvSupportLoaded: jest.fn(),
};
const selectSignal = jest.fn().mockReturnValue(playlistsSignal);
@@ -349,7 +355,7 @@ describe('WorkspaceShellFacade', () => {
},
{
provide: WorkspacePlayerCommandsContributor,
useValue: {},
useValue: playerCommands,
},
],
});
@@ -880,6 +886,27 @@ describe('WorkspaceShellFacade', () => {
expect(recentCommands.record).toHaveBeenCalledWith('open-settings');
});
it('waits for embedded MPV support preload before opening the palette', async () => {
const dialog = TestBed.inject(MatDialog) as unknown as {
open: jest.Mock;
};
let resolveSupport!: () => void;
playerCommands.ensureEmbeddedMpvSupportLoaded.mockReturnValueOnce(
new Promise<void>((resolve) => {
resolveSupport = resolve;
})
);
facade.openCommandPalette();
expect(dialog.open).not.toHaveBeenCalled();
resolveSupport();
await Promise.resolve();
expect(dialog.open).toHaveBeenCalledTimes(1);
});
it('does not record when the palette closes without a selection', () => {
const dialog = TestBed.inject(MatDialog) as unknown as {
open: jest.Mock;