mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-08 09:01:03 -08:00
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:
1 parent
290843ef3f
commit
2f47ed16cb
13 files changed
+866
-10
No files matched your search
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
+146
@@ -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);
|
||||
});
|
||||
});
|
||||
+47
-4
@@ -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';
|
||||
+159
@@ -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);
|
||||
});
|
||||
});
|
||||
+132
@@ -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',
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
+63
@@ -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();
|
||||
});
|
||||
});
|
||||
+20
-2
@@ -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);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user