diff --git a/apps/web/src/assets/i18n/en.json b/apps/web/src/assets/i18n/en.json index b3db88055..b1b1b0385 100644 --- a/apps/web/src/assets/i18n/en.json +++ b/apps/web/src/assets/i18n/en.json @@ -765,7 +765,7 @@ "OPEN_DASHBOARD": "Open dashboard", "RAIL_SOURCES": "Sources", "OPEN_SOURCES": "Open sources", - "RAIL_GLOBAL_FAVORITES": "Global favorites", + "RAIL_GLOBAL_FAVORITES": "Favorites", "RAIL_GLOBAL_RECENT": "Recently viewed", "RAIL_SETTINGS": "Settings", "OPEN_SETTINGS": "Open settings", @@ -879,7 +879,10 @@ "GO_TO_LIVE_LABEL": "Live TV", "GO_TO_LIVE_DESCRIPTION": "Jump to live TV section", "GO_TO_SERIES_LABEL": "Series", - "GO_TO_SERIES_DESCRIPTION": "Jump to series section" + "GO_TO_SERIES_DESCRIPTION": "Jump to series section", + "SWITCH_PLAYER_LABEL": "Switch player to {{name}}", + "SWITCH_PLAYER_DESCRIPTION": "Set {{name}} as the active video player", + "SWITCH_PLAYER_FEEDBACK": "Player set to {{name}}" } }, "SOURCES": { @@ -922,7 +925,8 @@ "GROUP_GLOBAL": "Global actions", "GROUP_PLAYLIST": "This playlist", "GROUP_VIEW": "Current view", - "GROUP_SECTION": "This section" + "GROUP_SECTION": "This section", + "GROUP_RECENT": "Recently used" }, "GLOBAL_FAVORITES": { "TITLE": "Global Favorites", diff --git a/docs/architecture/workspace-shell.md b/docs/architecture/workspace-shell.md index 5bdbb15b8..c2b38e352 100644 --- a/docs/architecture/workspace-shell.md +++ b/docs/architecture/workspace-shell.md @@ -131,6 +131,18 @@ Command palette behavior is shell-owned but view-extensible: metadata through `WorkspaceHeaderContextService`. 5. Filtering matches command labels, descriptions, and keywords, and keyboard selection always lands on the first enabled command. +6. A "Recently used" section is rendered above the standard groups when the + query is empty and at least one stored id resolves to a visible+enabled + command; ids are persisted via `RecentCommandsService` (capped at 5, + stored at `STORE_KEY.RecentCommands`). Storage is **not** pruned by route + 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. ## Maintenance Guidance diff --git a/libs/shared/interfaces/src/lib/store-keys.enum.ts b/libs/shared/interfaces/src/lib/store-keys.enum.ts index ce9927749..fcf529142 100644 --- a/libs/shared/interfaces/src/lib/store-keys.enum.ts +++ b/libs/shared/interfaces/src/lib/store-keys.enum.ts @@ -1,5 +1,6 @@ /** Labels of properties in local storage */ export enum STORE_KEY { Settings = 'settings', - Version = 'version' + Version = 'version', + RecentCommands = 'recent-commands', } \ No newline at end of file diff --git a/libs/workspace/shell/feature/src/lib/recent-commands/index.ts b/libs/workspace/shell/feature/src/lib/recent-commands/index.ts new file mode 100644 index 000000000..b8a26623b --- /dev/null +++ b/libs/workspace/shell/feature/src/lib/recent-commands/index.ts @@ -0,0 +1,5 @@ +export { + MAX_RECENT_COMMANDS, + RecentCommandsService, + type RecentCommandEntry, +} from './recent-commands.service'; diff --git a/libs/workspace/shell/feature/src/lib/recent-commands/recent-commands.service.spec.ts b/libs/workspace/shell/feature/src/lib/recent-commands/recent-commands.service.spec.ts new file mode 100644 index 000000000..eb96069a5 --- /dev/null +++ b/libs/workspace/shell/feature/src/lib/recent-commands/recent-commands.service.spec.ts @@ -0,0 +1,185 @@ +import { + EnvironmentInjector, + Injector, + createEnvironmentInjector, + runInInjectionContext, +} from '@angular/core'; +import { StorageMap } from '@ngx-pwa/local-storage'; +import { of, throwError } from 'rxjs'; +import { + MAX_RECENT_COMMANDS, + RecentCommandsService, + type RecentCommandEntry, +} from './recent-commands.service'; + +interface StorageMapMock { + get: jest.Mock; + set: jest.Mock; +} + +function createService(storage: StorageMapMock): RecentCommandsService { + const injector = createEnvironmentInjector( + [{ provide: StorageMap, useValue: storage }], + Injector.NULL as unknown as EnvironmentInjector + ); + return runInInjectionContext(injector, () => new RecentCommandsService()); +} + +async function flushMicrotasks(): Promise { + await Promise.resolve(); + await Promise.resolve(); +} + +describe('RecentCommandsService', () => { + let storage: StorageMapMock; + + beforeEach(() => { + storage = { + get: jest.fn().mockReturnValue(of(undefined)), + set: jest.fn().mockReturnValue(of(undefined)), + }; + }); + + it('starts with an empty list when storage has no entries', async () => { + const service = createService(storage); + await flushMicrotasks(); + + expect(service.entries()).toEqual([]); + expect(storage.get).toHaveBeenCalledWith('recent-commands'); + }); + + it('hydrates from storage on construction', async () => { + const stored: RecentCommandEntry[] = [ + { id: 'open-settings', usedAt: 100 }, + { id: 'switch-player-mpv', usedAt: 200 }, + ]; + storage.get.mockReturnValue(of(stored)); + + const service = createService(storage); + await flushMicrotasks(); + + expect(service.entries()).toEqual(stored); + }); + + it('ignores corrupt storage payloads and keeps an empty list', async () => { + storage.get.mockReturnValue(of({ not: 'an array' })); + + const service = createService(storage); + await flushMicrotasks(); + + expect(service.entries()).toEqual([]); + }); + + it('filters out malformed entries when hydrating', async () => { + storage.get.mockReturnValue( + of([ + { id: 'good', usedAt: 1 }, + { id: 42, usedAt: 2 }, + null, + { id: 'no-timestamp' }, + ]) + ); + + const service = createService(storage); + await flushMicrotasks(); + + expect(service.entries()).toEqual([{ id: 'good', usedAt: 1 }]); + }); + + it('survives a storage load failure without crashing', async () => { + const consoleErrorSpy = jest + .spyOn(console, 'error') + .mockImplementation(() => undefined); + storage.get.mockReturnValue(throwError(() => new Error('boom'))); + + const service = createService(storage); + await flushMicrotasks(); + + expect(service.entries()).toEqual([]); + expect(consoleErrorSpy).toHaveBeenCalled(); + consoleErrorSpy.mockRestore(); + }); + + it('records a new entry at the head of the list', async () => { + const service = createService(storage); + await flushMicrotasks(); + + service.record('open-settings'); + + expect(service.entries()[0]?.id).toBe('open-settings'); + expect(storage.set).toHaveBeenCalledWith( + 'recent-commands', + service.entries() + ); + }); + + it('deduplicates by id when recording an existing command', async () => { + const service = createService(storage); + await flushMicrotasks(); + + service.record('a'); + service.record('b'); + service.record('a'); + + expect(service.entries().map((e) => e.id)).toEqual(['a', 'b']); + }); + + it('caps the list at MAX_RECENT_COMMANDS', async () => { + const service = createService(storage); + await flushMicrotasks(); + + for (let index = 0; index < MAX_RECENT_COMMANDS + 3; index++) { + service.record(`cmd-${index}`); + } + + expect(service.entries()).toHaveLength(MAX_RECENT_COMMANDS); + expect(service.entries()[0]?.id).toBe( + `cmd-${MAX_RECENT_COMMANDS + 2}` + ); + }); + + it('ignores empty ids', async () => { + const service = createService(storage); + await flushMicrotasks(); + + service.record(''); + + expect(service.entries()).toEqual([]); + expect(storage.set).not.toHaveBeenCalled(); + }); + + it('prune writes back when the list shrinks', async () => { + storage.get.mockReturnValue( + of([ + { id: 'a', usedAt: 1 }, + { id: 'b', usedAt: 2 }, + ]) + ); + const service = createService(storage); + await flushMicrotasks(); + storage.set.mockClear(); + + service.prune((id) => id === 'a'); + + expect(service.entries().map((e) => e.id)).toEqual(['a']); + expect(storage.set).toHaveBeenCalledWith('recent-commands', [ + { id: 'a', usedAt: 1 }, + ]); + }); + + it('prune is a no-op when nothing is removed', async () => { + storage.get.mockReturnValue( + of([ + { id: 'a', usedAt: 1 }, + { id: 'b', usedAt: 2 }, + ]) + ); + const service = createService(storage); + await flushMicrotasks(); + storage.set.mockClear(); + + service.prune(() => true); + + expect(storage.set).not.toHaveBeenCalled(); + }); +}); diff --git a/libs/workspace/shell/feature/src/lib/recent-commands/recent-commands.service.ts b/libs/workspace/shell/feature/src/lib/recent-commands/recent-commands.service.ts new file mode 100644 index 000000000..8c2e8e0f1 --- /dev/null +++ b/libs/workspace/shell/feature/src/lib/recent-commands/recent-commands.service.ts @@ -0,0 +1,87 @@ +import { Injectable, computed, inject, signal } from '@angular/core'; +import { StorageMap } from '@ngx-pwa/local-storage'; +import { firstValueFrom } from 'rxjs'; +import { STORE_KEY } from 'shared-interfaces'; + +export interface RecentCommandEntry { + id: string; + usedAt: number; +} + +export const MAX_RECENT_COMMANDS = 5; + +@Injectable({ providedIn: 'root' }) +export class RecentCommandsService { + private readonly storage = inject(StorageMap); + private readonly _entries = signal([]); + + readonly entries = computed(() => this._entries()); + + constructor() { + void this.loadFromStorage(); + } + + record(id: string): void { + if (!id) return; + + const next = [ + { id, usedAt: Date.now() }, + ...this._entries().filter((entry) => entry.id !== id), + ].slice(0, MAX_RECENT_COMMANDS); + + this._entries.set(next); + void this.persist(next); + } + + prune(predicate: (id: string) => boolean): void { + const current = this._entries(); + const filtered = current.filter((entry) => predicate(entry.id)); + + if (filtered.length === current.length) { + return; + } + + this._entries.set(filtered); + void this.persist(filtered); + } + + private async loadFromStorage(): Promise { + try { + const raw = await firstValueFrom( + this.storage.get(STORE_KEY.RecentCommands) + ); + const parsed = this.parseStored(raw); + if (parsed.length > 0) { + this._entries.set(parsed); + } + } catch (error) { + console.error('Failed to load recent commands:', error); + } + } + + private async persist( + entries: readonly RecentCommandEntry[] + ): Promise { + try { + await firstValueFrom( + this.storage.set(STORE_KEY.RecentCommands, entries) + ); + } catch (error) { + console.error('Failed to persist recent commands:', error); + } + } + + private parseStored(raw: unknown): RecentCommandEntry[] { + if (!Array.isArray(raw)) return []; + + return raw + .filter( + (entry): entry is RecentCommandEntry => + !!entry && + typeof entry === 'object' && + typeof (entry as RecentCommandEntry).id === 'string' && + typeof (entry as RecentCommandEntry).usedAt === 'number' + ) + .slice(0, MAX_RECENT_COMMANDS); + } +} diff --git a/libs/workspace/shell/feature/src/lib/workspace-command-palette/workspace-command-palette.component.spec.ts b/libs/workspace/shell/feature/src/lib/workspace-command-palette/workspace-command-palette.component.spec.ts index 3cca4b946..27a81e5d8 100644 --- a/libs/workspace/shell/feature/src/lib/workspace-command-palette/workspace-command-palette.component.spec.ts +++ b/libs/workspace/shell/feature/src/lib/workspace-command-palette/workspace-command-palette.component.spec.ts @@ -163,3 +163,149 @@ describe('WorkspaceCommandPaletteComponent', () => { }); }); }); + +describe('WorkspaceCommandPaletteComponent - recent section', () => { + function setupComponent(options: { + query: string; + recentIds: readonly string[]; + commands?: WorkspaceResolvedCommandItem[]; + }): { + component: WorkspaceCommandPaletteComponent; + fixture: ComponentFixture; + } { + const baseCommands: WorkspaceResolvedCommandItem[] = + options.commands ?? [ + { + id: 'open-settings', + label: 'Open settings', + description: '', + group: 'global', + icon: 'settings', + keywords: ['settings'], + priority: 50, + visible: true, + enabled: true, + run: () => undefined, + }, + { + id: 'switch-player-mpv', + label: 'Switch player to MPV', + description: '', + group: 'global', + icon: 'play_circle', + keywords: ['mpv', 'player'], + priority: 93, + visible: true, + enabled: true, + run: () => undefined, + }, + { + id: 'switch-player-vlc', + label: 'Switch player to VLC', + description: '', + group: 'global', + icon: 'play_circle', + keywords: ['vlc', 'player'], + priority: 94, + visible: true, + enabled: false, + run: () => undefined, + }, + ]; + + TestBed.resetTestingModule(); + TestBed.configureTestingModule({ + imports: [WorkspaceCommandPaletteComponent], + providers: [ + { provide: MatDialogRef, useValue: { close: jest.fn() } }, + { + provide: MAT_DIALOG_DATA, + useValue: { + query: options.query, + commands: baseCommands, + recentIds: options.recentIds, + }, + }, + { + provide: TranslateService, + useValue: { + instant: (key: string) => key, + get: (key: string) => of(key), + stream: (key: string) => of(key), + onLangChange: of(null), + onTranslationChange: of(null), + onDefaultLangChange: of(null), + currentLang: 'en', + defaultLang: 'en', + }, + }, + ], + }); + + const fixture = TestBed.createComponent( + WorkspaceCommandPaletteComponent + ); + fixture.detectChanges(); + + return { component: fixture.componentInstance, fixture }; + } + + it('renders the recent section first when query is empty and ids resolve', () => { + const { component } = setupComponent({ + query: '', + recentIds: ['switch-player-mpv'], + }); + + const groups = component.commandGroups(); + expect(groups[0]?.group).toBe('recent'); + expect(groups[0]?.items.map((item) => item.id)).toEqual([ + 'switch-player-mpv', + ]); + }); + + it('omits recent ids from their native group to avoid duplicates', () => { + const { component } = setupComponent({ + query: '', + recentIds: ['switch-player-mpv'], + }); + + const flatIds = component.flatCommands().map((command) => command.id); + const occurrences = flatIds.filter((id) => id === 'switch-player-mpv'); + expect(occurrences).toHaveLength(1); + }); + + it('hides the recent section once the user types', () => { + const { component } = setupComponent({ + query: '', + recentIds: ['switch-player-mpv'], + }); + + component.query.set('settings'); + + expect( + component.commandGroups().some((group) => group.group === 'recent') + ).toBe(false); + }); + + it('drops recent ids that resolve to disabled or invisible commands', () => { + const { component } = setupComponent({ + query: '', + recentIds: ['switch-player-vlc', 'unknown-id'], + }); + + expect( + component.commandGroups().some((group) => group.group === 'recent') + ).toBe(false); + }); + + it('renders no recent section when recentIds is empty', () => { + const { component } = setupComponent({ + query: '', + recentIds: [], + }); + + expect( + component.commandGroups().some((group) => group.group === 'recent') + ).toBe(false); + }); +}); diff --git a/libs/workspace/shell/feature/src/lib/workspace-command-palette/workspace-command-palette.component.ts b/libs/workspace/shell/feature/src/lib/workspace-command-palette/workspace-command-palette.component.ts index 8fa6433cc..5a66f6756 100644 --- a/libs/workspace/shell/feature/src/lib/workspace-command-palette/workspace-command-palette.component.ts +++ b/libs/workspace/shell/feature/src/lib/workspace-command-palette/workspace-command-palette.component.ts @@ -24,10 +24,13 @@ import { interface WorkspaceCommandPaletteData { commands: WorkspaceResolvedCommandItem[]; query?: string; + recentIds?: readonly string[]; } +type PaletteSectionGroup = WorkspaceCommandGroup | 'recent'; + interface WorkspaceCommandGroupSection { - group: WorkspaceCommandGroup; + group: PaletteSectionGroup; items: WorkspaceResolvedCommandItem[]; } @@ -78,14 +81,45 @@ export class WorkspaceCommandPaletteComponent implements AfterViewInit { }); }); + readonly recentSection = computed( + () => { + if (this.query().trim() !== '') { + return null; + } + + const recentIds = this.data?.recentIds ?? []; + if (recentIds.length === 0) { + return null; + } + + const byId = new Map( + this.visibleCommands().map((command) => [command.id, command]) + ); + const items = recentIds + .map((id) => byId.get(id)) + .filter( + (command): command is WorkspaceResolvedCommandItem => + !!command && command.enabled + ); + + return items.length === 0 ? null : { group: 'recent', items }; + } + ); + readonly commandGroups = computed(() => { const commands = this.filteredCommands(); - const sections: WorkspaceCommandGroupSection[] = []; + const recent = this.recentSection(); + const excludedIds = new Set( + recent ? recent.items.map((item) => item.id) : [] + ); const buildSection = ( group: WorkspaceCommandGroup ): WorkspaceCommandGroupSection | null => { - const items = commands.filter((command) => command.group === group); + const items = commands.filter( + (command) => + command.group === group && !excludedIds.has(command.id) + ); if (items.length === 0) { return null; @@ -94,6 +128,12 @@ export class WorkspaceCommandPaletteComponent implements AfterViewInit { return { group, items }; }; + const sections: WorkspaceCommandGroupSection[] = []; + + if (recent) { + sections.push(recent); + } + const groups = [ buildSection('view'), buildSection('playlist'), @@ -208,7 +248,10 @@ export class WorkspaceCommandPaletteComponent implements AfterViewInit { return index >= 0 && this.selectedIndex() === index; } - getGroupTitleKey(group: WorkspaceCommandGroup): string { + getGroupTitleKey(group: PaletteSectionGroup): string { + if (group === 'recent') { + return 'WORKSPACE.COMMAND_PALETTE.GROUP_RECENT'; + } if (group === 'view') { return 'WORKSPACE.COMMAND_PALETTE.GROUP_VIEW'; } diff --git a/libs/workspace/shell/feature/src/lib/workspace-player-commands/index.ts b/libs/workspace/shell/feature/src/lib/workspace-player-commands/index.ts new file mode 100644 index 000000000..a92f5facd --- /dev/null +++ b/libs/workspace/shell/feature/src/lib/workspace-player-commands/index.ts @@ -0,0 +1 @@ +export { WorkspacePlayerCommandsContributor } from './workspace-player-commands.contributor'; 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 new file mode 100644 index 000000000..fc8d19236 --- /dev/null +++ b/libs/workspace/shell/feature/src/lib/workspace-player-commands/workspace-player-commands.contributor.spec.ts @@ -0,0 +1,159 @@ +import { signal } from '@angular/core'; +import { TestBed } from '@angular/core/testing'; +import { MatSnackBar } from '@angular/material/snack-bar'; +import { TranslateService } from '@ngx-translate/core'; +import { of } from 'rxjs'; +import { + WorkspaceCommandContribution, + WorkspaceViewCommandService, +} from '@iptvnator/portal/shared/util'; +import { SettingsStore } from 'services'; +import { VideoPlayer } from 'shared-interfaces'; +import { WorkspacePlayerCommandsContributor } from './workspace-player-commands.contributor'; + +interface ViewCommandsMock { + registerCommand: jest.Mock; + commands: jest.Mock; +} + +interface SettingsStoreMock { + player: ReturnType>; + updateSettings: jest.Mock; +} + +interface SnackBarMock { + open: jest.Mock; +} + +function getRegistered( + viewCommands: ViewCommandsMock +): WorkspaceCommandContribution[] { + return viewCommands.registerCommand.mock.calls.map( + ([command]: [WorkspaceCommandContribution]) => command + ); +} + +function resolveBoolean( + value: boolean | (() => boolean | undefined) | undefined +): boolean { + if (typeof value === 'function') { + return value() ?? true; + } + return value ?? true; +} + +describe('WorkspacePlayerCommandsContributor', () => { + let viewCommands: ViewCommandsMock; + let settingsStore: SettingsStoreMock; + let snackBar: SnackBarMock; + let translate: { instant: jest.Mock; onLangChange: ReturnType }; + + function bootstrap(options: { isDesktop: boolean }) { + if (options.isDesktop) { + window.electron = { platform: 'darwin' } as typeof window.electron; + } else { + // @ts-expect-error - simulating PWA environment + window.electron = undefined; + } + + viewCommands = { + registerCommand: jest.fn().mockReturnValue(() => undefined), + commands: jest.fn().mockReturnValue([]), + }; + settingsStore = { + player: signal(VideoPlayer.VideoJs), + updateSettings: jest.fn().mockResolvedValue(undefined), + }; + snackBar = { open: jest.fn() }; + translate = { + instant: jest.fn( + (key: string, params?: Record) => + params?.['name'] + ? `${key}:${params['name']}` + : key + ), + onLangChange: of(null), + }; + + TestBed.configureTestingModule({ + providers: [ + WorkspacePlayerCommandsContributor, + { + provide: WorkspaceViewCommandService, + useValue: viewCommands, + }, + { provide: SettingsStore, useValue: settingsStore }, + { provide: MatSnackBar, useValue: snackBar }, + { provide: TranslateService, useValue: translate }, + ], + }); + + return TestBed.inject(WorkspacePlayerCommandsContributor); + } + + afterEach(() => { + TestBed.resetTestingModule(); + }); + + it('registers all five player commands when running in Electron', () => { + bootstrap({ isDesktop: true }); + + const ids = getRegistered(viewCommands).map((c) => c.id); + expect(ids).toEqual([ + 'switch-player-videojs', + 'switch-player-html5', + 'switch-player-artplayer', + 'switch-player-mpv', + 'switch-player-vlc', + ]); + }); + + it('hides MPV and VLC when window.electron is unavailable', () => { + bootstrap({ isDesktop: false }); + + const registered = getRegistered(viewCommands); + const visibilityById = Object.fromEntries( + registered.map((c) => [c.id, resolveBoolean(c.visible)]) + ); + + expect(visibilityById['switch-player-videojs']).toBe(true); + expect(visibilityById['switch-player-html5']).toBe(true); + expect(visibilityById['switch-player-artplayer']).toBe(true); + expect(visibilityById['switch-player-mpv']).toBe(false); + expect(visibilityById['switch-player-vlc']).toBe(false); + }); + + it('marks the active player command as disabled', () => { + bootstrap({ isDesktop: true }); + settingsStore.player.set(VideoPlayer.MPV); + + const registered = getRegistered(viewCommands); + const enabledById = Object.fromEntries( + registered.map((c) => [c.id, resolveBoolean(c.enabled)]) + ); + + expect(enabledById['switch-player-mpv']).toBe(false); + expect(enabledById['switch-player-videojs']).toBe(true); + expect(enabledById['switch-player-vlc']).toBe(true); + }); + + it('updates settings and shows feedback on run', () => { + bootstrap({ isDesktop: true }); + + const mpvCommand = getRegistered(viewCommands).find( + (c) => c.id === 'switch-player-mpv' + ); + mpvCommand?.run({ query: '' }); + + expect(settingsStore.updateSettings).toHaveBeenCalledWith({ + player: VideoPlayer.MPV, + }); + expect(snackBar.open).toHaveBeenCalledTimes(1); + const [message, action, config] = snackBar.open.mock.calls[0]; + expect(message).toContain( + 'WORKSPACE.SHELL.COMMANDS.SWITCH_PLAYER_FEEDBACK' + ); + expect(action).toBeUndefined(); + expect(config?.duration).toBe(2500); + }); +}); 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 new file mode 100644 index 000000000..9702c7389 --- /dev/null +++ b/libs/workspace/shell/feature/src/lib/workspace-player-commands/workspace-player-commands.contributor.ts @@ -0,0 +1,132 @@ +import { DestroyRef, Injectable, inject } from '@angular/core'; +import { MatSnackBar } from '@angular/material/snack-bar'; +import { TranslateService } from '@ngx-translate/core'; +import { + WorkspaceCommandContribution, + WorkspaceViewCommandService, +} from '@iptvnator/portal/shared/util'; +import { SettingsStore } from 'services'; +import { VideoPlayer } from 'shared-interfaces'; + +interface PlayerCommandDefinition { + id: string; + player: VideoPlayer; + icon: string; + nameKey: string; + keywords: readonly string[]; + desktopOnly: boolean; + priority: number; +} + +const PLAYER_COMMAND_DEFS: readonly PlayerCommandDefinition[] = [ + { + id: 'switch-player-videojs', + player: VideoPlayer.VideoJs, + icon: 'play_circle', + nameKey: 'SETTINGS.PLAYER_VIDEOJS', + keywords: ['player', 'videojs', 'video.js'], + desktopOnly: false, + priority: 90, + }, + { + id: 'switch-player-html5', + player: VideoPlayer.Html5Player, + icon: 'play_circle', + nameKey: 'SETTINGS.PLAYER_HTML5', + keywords: ['player', 'html5'], + desktopOnly: false, + priority: 91, + }, + { + id: 'switch-player-artplayer', + player: VideoPlayer.ArtPlayer, + icon: 'play_circle', + nameKey: 'SETTINGS.PLAYER_ARTPLAYER', + keywords: ['player', 'artplayer', 'art'], + desktopOnly: false, + priority: 92, + }, + { + id: 'switch-player-mpv', + player: VideoPlayer.MPV, + icon: 'play_circle_outline', + nameKey: 'SETTINGS.PLAYER_MPV', + keywords: ['player', 'mpv', 'external'], + desktopOnly: true, + priority: 93, + }, + { + id: 'switch-player-vlc', + player: VideoPlayer.VLC, + icon: 'play_circle_outline', + nameKey: 'SETTINGS.PLAYER_VLC', + keywords: ['player', 'vlc', 'external'], + desktopOnly: true, + priority: 94, + }, +]; + +@Injectable({ providedIn: 'root' }) +export class WorkspacePlayerCommandsContributor { + private readonly viewCommands = inject(WorkspaceViewCommandService); + private readonly settingsStore = inject(SettingsStore); + private readonly snackBar = inject(MatSnackBar); + private readonly translate = inject(TranslateService); + private readonly destroyRef = inject(DestroyRef); + + private readonly isDesktop = !!window.electron; + + constructor() { + const unregisters = PLAYER_COMMAND_DEFS.map((def) => + this.viewCommands.registerCommand(this.toContribution(def)) + ); + + this.destroyRef.onDestroy(() => { + for (const unregister of unregisters) { + unregister(); + } + }); + } + + private toContribution( + def: PlayerCommandDefinition + ): WorkspaceCommandContribution { + return { + id: def.id, + group: 'global', + icon: def.icon, + labelKey: 'WORKSPACE.SHELL.COMMANDS.SWITCH_PLAYER_LABEL', + labelParams: () => ({ name: this.translate.instant(def.nameKey) }), + descriptionKey: 'WORKSPACE.SHELL.COMMANDS.SWITCH_PLAYER_DESCRIPTION', + descriptionParams: () => ({ + name: this.translate.instant(def.nameKey), + }), + keywords: () => [ + ...def.keywords, + this.translate.instant(def.nameKey).toLowerCase(), + ], + priority: def.priority, + visible: () => !def.desktopOnly || this.isDesktop, + enabled: () => this.settingsStore.player() !== def.player, + run: () => this.activate(def), + }; + } + + private activate(def: PlayerCommandDefinition): void { + void this.settingsStore.updateSettings({ player: def.player }); + + const name = this.translate.instant(def.nameKey); + this.snackBar.open( + this.translate.instant( + 'WORKSPACE.SHELL.COMMANDS.SWITCH_PLAYER_FEEDBACK', + { name } + ), + undefined, + { + duration: 2500, + horizontalPosition: 'center', + verticalPosition: 'bottom', + } + ); + } +} diff --git a/libs/workspace/shell/feature/src/lib/workspace-shell/services/workspace-shell.facade.spec.ts b/libs/workspace/shell/feature/src/lib/workspace-shell/services/workspace-shell.facade.spec.ts index baf8475c9..a343b633b 100644 --- a/libs/workspace/shell/feature/src/lib/workspace-shell/services/workspace-shell.facade.spec.ts +++ b/libs/workspace/shell/feature/src/lib/workspace-shell/services/workspace-shell.facade.spec.ts @@ -22,6 +22,8 @@ import { WorkspaceStartupPreferencesService, WORKSPACE_SHELL_ACTIONS, } from '@iptvnator/workspace/shell/util'; +import { RecentCommandsService } from '../../recent-commands'; +import { WorkspacePlayerCommandsContributor } from '../../workspace-player-commands'; import { WorkspaceShellFacade } from './workspace-shell.facade'; class MockXtreamStore { @@ -90,6 +92,11 @@ describe('WorkspaceShellFacade', () => { }; let facade: WorkspaceShellFacade; + let recentCommands: { + entries: jest.Mock; + record: jest.Mock; + prune: jest.Mock; + }; let router: { url: string; events: ReturnType; @@ -175,6 +182,11 @@ describe('WorkspaceShellFacade', () => { }; storeDispatch = jest.fn(); stalkerStore = new MockStalkerStore(); + recentCommands = { + entries: jest.fn().mockReturnValue([]), + record: jest.fn(), + prune: jest.fn(), + }; const selectSignal = jest.fn().mockReturnValue(playlistsSignal); @@ -265,6 +277,14 @@ describe('WorkspaceShellFacade', () => { defaultLang: 'en', }, }, + { + provide: RecentCommandsService, + useValue: recentCommands, + }, + { + provide: WorkspacePlayerCommandsContributor, + useValue: {}, + }, ], }); @@ -489,6 +509,9 @@ describe('WorkspaceShellFacade', () => { 'open-downloads', 'open-settings', 'open-sources', + 'add-playlist-stalker', + 'add-playlist-xtream', + 'add-playlist-m3u', 'add-playlist', ]); expect(commands.every((command) => command.group === 'global')).toBe( @@ -570,4 +593,44 @@ describe('WorkspaceShellFacade', () => { unregister(); }); + + it('records the executed command id after the palette closes with a selection', () => { + const dialog = TestBed.inject(MatDialog) as unknown as { + open: jest.Mock; + }; + dialog.open.mockReturnValueOnce({ + afterClosed: () => + of({ commandId: 'open-settings', query: '' }), + }); + + facade.openCommandPalette(); + + expect(recentCommands.record).toHaveBeenCalledWith('open-settings'); + }); + + it('does not record when the palette closes without a selection', () => { + const dialog = TestBed.inject(MatDialog) as unknown as { + open: jest.Mock; + }; + dialog.open.mockReturnValueOnce({ + afterClosed: () => of(undefined), + }); + + facade.openCommandPalette(); + + expect(recentCommands.record).not.toHaveBeenCalled(); + }); + + it('does not prune recent ids whose commands are temporarily invisible (e.g. on the same route)', () => { + const dialog = TestBed.inject(MatDialog) as unknown as { + open: jest.Mock; + }; + dialog.open.mockReturnValueOnce({ + afterClosed: () => of(undefined), + }); + + facade.openCommandPalette(); + + expect(recentCommands.prune).not.toHaveBeenCalled(); + }); }); diff --git a/libs/workspace/shell/feature/src/lib/workspace-shell/services/workspace-shell.facade.ts b/libs/workspace/shell/feature/src/lib/workspace-shell/services/workspace-shell.facade.ts index 1e1c94d04..c35e2f2f4 100644 --- a/libs/workspace/shell/feature/src/lib/workspace-shell/services/workspace-shell.facade.ts +++ b/libs/workspace/shell/feature/src/lib/workspace-shell/services/workspace-shell.facade.ts @@ -46,6 +46,8 @@ import { WORKSPACE_SHELL_ACTIONS, } from '@iptvnator/workspace/shell/util'; import { WorkspaceCommandPaletteComponent } from '../../workspace-command-palette/workspace-command-palette.component'; +import { RecentCommandsService } from '../../recent-commands'; +import { WorkspacePlayerCommandsContributor } from '../../workspace-player-commands'; export interface WorkspaceHeaderBulkAction { icon: string; @@ -102,6 +104,11 @@ export class WorkspaceShellFacade { ); readonly headerContext = inject(WorkspaceHeaderContextService); private readonly viewCommands = inject(WorkspaceViewCommandService); + private readonly recentCommands = inject(RecentCommandsService); + // Eager construction registers the player-switch commands via WorkspaceViewCommandService. + private readonly _playerCommandsBootstrap = inject( + WorkspacePlayerCommandsContributor + ); private readonly playlistRefreshAction = inject( PlaylistRefreshActionService ); @@ -780,9 +787,14 @@ export class WorkspaceShellFacade { } const commands = this.commandPaletteCommands(); + const recentIds = this.recentCommands.entries().map((entry) => entry.id); const dialogRef = this.dialog.open< WorkspaceCommandPaletteComponent, - { commands: WorkspaceResolvedCommandItem[]; query: string }, + { + commands: WorkspaceResolvedCommandItem[]; + query: string; + recentIds: readonly string[]; + }, WorkspaceCommandSelection | undefined >(WorkspaceCommandPaletteComponent, { width: 'min(760px, 92vw)', @@ -792,6 +804,7 @@ export class WorkspaceShellFacade { data: { commands, query: this.searchQuery(), + recentIds, }, }); this.commandPaletteRef = dialogRef; @@ -811,7 +824,12 @@ export class WorkspaceShellFacade { item.visible && item.enabled ); - command?.run({ query: selection.query.trim() }); + if (!command) { + return; + } + + command.run({ query: selection.query.trim() }); + this.recentCommands.record(command.id); }); }