feat(command-palette): add recently used section and switch-player commands

Adds a "Recently used" section at the top of the command palette (capped at 5,
persisted via StorageMap) and five "Switch player to ..." commands for one-step
player switching from anywhere. MPV/VLC are gated to Electron; the active player
entry is shown disabled. Switching applies to the next playback session.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Entire-Checkpoint: c2cea9c530e6
This commit is contained in:
4grayandClaude Opus 4.7 committed 2026-04-30 23:54:02 +02:00
1 parent 290843ef3f
commit 2f47ed16cb
13 files changed
+866 -10

No files matched your search

+7 -3
View File
@@ -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",
+12
View File
@@ -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
@@ -1,5 +1,6 @@
/** Labels of properties in local storage */
export enum STORE_KEY {
Settings = 'settings',
Version = 'version'
Version = 'version',
RecentCommands = 'recent-commands',
}
@@ -0,0 +1,5 @@
export {
MAX_RECENT_COMMANDS,
RecentCommandsService,
type RecentCommandEntry,
} from './recent-commands.service';
@@ -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<void> {
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();
});
});
@@ -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 RecentCommandEntry[]>([]);
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<void> {
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<void> {
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);
}
}
@@ -163,3 +163,149 @@ describe('WorkspaceCommandPaletteComponent', () => {
});
});
});
describe('WorkspaceCommandPaletteComponent - recent section', () => {
function setupComponent(options: {
query: string;
recentIds: readonly string[];
commands?: WorkspaceResolvedCommandItem[];
}): {
component: WorkspaceCommandPaletteComponent;
fixture: ComponentFixture<WorkspaceCommandPaletteComponent>;
} {
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);
});
});
@@ -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<WorkspaceCommandGroupSection | null>(
() => {
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<WorkspaceCommandGroupSection[]>(() => {
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';
}
@@ -0,0 +1 @@
export { WorkspacePlayerCommandsContributor } from './workspace-player-commands.contributor';
@@ -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<typeof signal<VideoPlayer>>;
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<typeof of> };
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>(VideoPlayer.VideoJs),
updateSettings: jest.fn().mockResolvedValue(undefined),
};
snackBar = { open: jest.fn() };
translate = {
instant: jest.fn(
(key: string, params?: Record<string, string | number>) =>
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);
});
});
@@ -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',
}
);
}
}
@@ -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<typeof of>;
@@ -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();
});
});
@@ -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);
});
}