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 });