From f7ad17298fabc96183ccb07ddeb6e2dc0b18fac9 Mon Sep 17 00:00:00 2001 From: Lars Emig Date: Sat, 27 Jun 2026 11:51:09 +0200 Subject: [PATCH] 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) --- docs/architecture/workspace-shell.md | 15 ++- ...kspace-player-commands.contributor.spec.ts | 91 +++++++++++++++++-- .../workspace-player-commands.contributor.ts | 69 +++++++++++--- 3 files changed, 152 insertions(+), 23 deletions(-) diff --git a/docs/architecture/workspace-shell.md b/docs/architecture/workspace-shell.md index 6372f3bd3..20d053125 100644 --- a/docs/architecture/workspace-shell.md +++ b/docs/architecture/workspace-shell.md @@ -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: diff --git a/libs/workspace/shell/feature/src/lib/workspace-player-commands/workspace-player-commands.contributor.spec.ts b/libs/workspace/shell/feature/src/lib/workspace-player-commands/workspace-player-commands.contributor.spec.ts index 2d5d4e584..1da95ea1f 100644 --- a/libs/workspace/shell/feature/src/lib/workspace-player-commands/workspace-player-commands.contributor.spec.ts +++ b/libs/workspace/shell/feature/src/lib/workspace-player-commands/workspace-player-commands.contributor.spec.ts @@ -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 }; - 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) => - 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); diff --git a/libs/workspace/shell/feature/src/lib/workspace-player-commands/workspace-player-commands.contributor.ts b/libs/workspace/shell/feature/src/lib/workspace-player-commands/workspace-player-commands.contributor.ts index aa116ae9c..17dbfb7cd 100644 --- a/libs/workspace/shell/feature/src/lib/workspace-player-commands/workspace-player-commands.contributor.ts +++ b/libs/workspace/shell/feature/src/lib/workspace-player-commands/workspace-player-commands.contributor.ts @@ -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 { + 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 });