feat(search): add workspace global search with M3U support

This commit is contained in:
4gray committed 2026-06-20 20:08:17 +02:00
1 parent ffc07146fe
commit 12d931f4cc
63 files changed
+2359 -374

No files matched your search

@@ -29,6 +29,7 @@
</span>
}
<input
#searchInput
type="search"
spellcheck="false"
[value]="searchQuery()"
@@ -93,6 +93,19 @@ describe('WorkspaceShellHeaderComponent', () => {
expect(emitted).toEqual(['matrix']);
});
it('focuses and selects the search input on request', () => {
const input: HTMLInputElement =
fixture.nativeElement.querySelector('input[type="search"]');
input.value = 'matrix';
input.blur();
component.focusSearchInput({ select: true });
expect(document.activeElement).toBe(input);
expect(input.selectionStart).toBe(0);
expect(input.selectionEnd).toBe('matrix'.length);
});
it('emits add playlist requests when the toolbar add button is clicked', () => {
const requested = jest.fn();
component.addPlaylistRequested.subscribe(requested);
@@ -1,8 +1,10 @@
import {
ChangeDetectionStrategy,
Component,
ElementRef,
input,
output,
viewChild,
} from '@angular/core';
import { MatIconButton } from '@angular/material/button';
import { MatIcon } from '@angular/material/icon';
@@ -26,6 +28,9 @@ import { WorkspaceHeaderBulkAction } from '../../services/helpers/workspace-shel
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class WorkspaceShellHeaderComponent {
private readonly searchInput =
viewChild<ElementRef<HTMLInputElement>>('searchInput');
readonly isMac =
typeof navigator !== 'undefined' &&
/Mac|iPhone|iPad|iPod/i.test(navigator.platform);
@@ -70,6 +75,23 @@ export class WorkspaceShellHeaderComponent {
readonly playlistInfoRequested = output<void>();
readonly accountInfoRequested = output<void>();
focusSearchInput(options: { select?: boolean } = {}): void {
const inputElement = this.searchInput()?.nativeElement;
if (!inputElement || inputElement.disabled) {
return;
}
inputElement.focus();
if (options.select) {
inputElement.select();
}
}
containsSearchInput(target: EventTarget | null): boolean {
const inputElement = this.searchInput()?.nativeElement;
return !!inputElement && target === inputElement;
}
onSearchInput(event: Event): void {
const target = event.target as HTMLInputElement | null;
this.searchChanged.emit(target?.value ?? '');
@@ -27,7 +27,7 @@ export interface CommandBuilderContext {
context: WorkspacePortalContext | null;
section: PortalRailSection | null;
hasActivePlaylist: boolean;
hasXtreamPlaylists: boolean;
hasSearchablePlaylists: boolean;
canRefreshPlaylist: boolean;
supportsDownloads: boolean;
showDashboard: boolean;
@@ -141,7 +141,7 @@ export function getGlobalCommandDefinitions(
): WorkspaceCommandContribution[] {
const {
route,
hasXtreamPlaylists,
hasSearchablePlaylists,
supportsDownloads,
showDashboard,
actions,
@@ -156,8 +156,8 @@ export function getGlobalCommandDefinitions(
descriptionKey:
'WORKSPACE.SHELL.COMMANDS.GLOBAL_SEARCH_DESCRIPTION',
priority: 10,
visible: hasXtreamPlaylists,
keywords: ['xtream'],
visible: hasSearchablePlaylists && route.kind !== 'global-search',
keywords: ['xtream', 'm3u', 'live'],
run: ({ query }) => actions.openGlobalSearch(query),
},
{
@@ -130,6 +130,10 @@ export function resolveSearchScopeLabel(ctx: SearchScopeContext): string {
return translate('PORTALS.RECENTLY_VIEWED');
}
if (kind === 'global-search') {
return translate('WORKSPACE.SHELL.RAIL_GLOBAL_SEARCH');
}
if (kind === 'downloads') {
return translate('WORKSPACE.SHELL.RAIL_DOWNLOADS');
}
@@ -84,6 +84,13 @@ export class WorkspaceShellRouteStateService {
path: ['/workspace/sources'],
});
links.push({
icon: 'search',
tooltip: this.translateText('WORKSPACE.SHELL.RAIL_GLOBAL_SEARCH'),
path: ['/workspace/search'],
exact: true,
});
links.push({
icon: 'favorite',
tooltip: this.translateText('HOME.PLAYLISTS.GLOBAL_FAVORITES'),
@@ -125,6 +132,7 @@ export class WorkspaceShellRouteStateService {
currentRoute.kind !== 'settings' &&
currentRoute.kind !== 'global-favorites' &&
currentRoute.kind !== 'global-recent' &&
currentRoute.kind !== 'global-search' &&
currentRoute.kind !== 'downloads'
) {
return null;
@@ -82,6 +82,36 @@ export class WorkspaceShellSearchService {
};
}
if (route.kind === 'global-search') {
const hasSearchablePlaylists = this.routeState.playlists().some(
(playlist) =>
!!playlist.serverUrl ||
(!playlist.macAddress &&
(!!playlist.filePath ||
!!playlist.url ||
!playlist.serverUrl))
);
return {
enabled: hasSearchablePlaylists,
behavior: hasSearchablePlaylists
? 'remote-search'
: 'disabled',
context: null,
section: null,
searchMode: hasSearchablePlaylists
? 'remote-search'
: 'none',
placeholderKey: 'WORKSPACE.SHELL.SEARCH_GLOBAL_PLACEHOLDER',
scopeLabel: this.translateText(
'WORKSPACE.SHELL.RAIL_GLOBAL_SEARCH'
),
statusLabel: '',
minLength: hasSearchablePlaylists ? 2 : 0,
advancedRouteTarget: null,
};
}
if (route.searchMode === 'none') {
return {
enabled: false,
@@ -653,6 +653,12 @@ describe('WorkspaceShellFacade', () => {
tooltip: 'WORKSPACE.SHELL.RAIL_SOURCES',
path: ['/workspace/sources'],
},
{
icon: 'search',
tooltip: 'WORKSPACE.SHELL.RAIL_GLOBAL_SEARCH',
path: ['/workspace/search'],
exact: true,
},
{
icon: 'favorite',
tooltip: 'HOME.PLAYLISTS.GLOBAL_FAVORITES',
@@ -706,6 +712,30 @@ describe('WorkspaceShellFacade', () => {
expect(commands.every((command) => command.enabled)).toBe(true);
});
it('shows the global search command for M3U-only sources', () => {
activePlaylistSignal.set({
_id: 'pl-m3u',
title: 'Playlist M3U',
count: 10,
importDate: '2026-04-22T10:00:00.000Z',
autoRefresh: false,
});
playlistsSignal.set([
{
_id: 'pl-m3u',
title: 'Playlist M3U',
count: 10,
importDate: '2026-04-22T10:00:00.000Z',
autoRefresh: false,
},
]);
facade.currentUrl.set('/workspace/dashboard');
expect(facade.commandPaletteCommands().map((command) => command.id)).toContain(
'global-search'
);
});
it('hides the downloads command when downloads are unsupported', () => {
runtime.supportsDownloads = false;
activePlaylistSignal.set(null);
@@ -219,8 +219,13 @@ export class WorkspaceShellFacade {
context: this.currentContext(),
section: this.currentSection(),
hasActivePlaylist: !!this.activePlaylist(),
hasXtreamPlaylists: this.playlists().some(
(playlist) => !!playlist.serverUrl
hasSearchablePlaylists: this.playlists().some(
(playlist) =>
!!playlist.serverUrl ||
(!playlist.macAddress &&
(!!playlist.filePath ||
!!playlist.url ||
!playlist.serverUrl))
),
canRefreshPlaylist: this.canRefreshPlaylist(),
supportsDownloads: this.supportsDownloads,
@@ -22,6 +22,7 @@
/>
<app-workspace-shell-header
#workspaceHeader
[playlistTitle]="facade.playlistTitle()"
[playlistSubtitle]="facade.playlistSubtitle()"
[canOpenPlaylistInfo]="facade.canOpenPlaylistInfo()"
@@ -72,6 +72,9 @@ class MockWorkspaceShellHeaderComponent {
readonly headerBulkActionRequested = output<void>();
readonly playlistInfoRequested = output<void>();
readonly accountInfoRequested = output<void>();
focusSearchInput = jest.fn();
containsSearchInput = jest.fn(() => false);
}
@Component({
@@ -187,6 +190,7 @@ class MockWorkspaceShellFacade {
onSearchInput = jest.fn();
onSearchEnter = jest.fn();
openCommandPalette = jest.fn();
openGlobalSearch = jest.fn();
openAddPlaylistDialog = jest.fn();
runHeaderShortcut = jest.fn();
refreshCurrentPlaylist = jest.fn();
@@ -350,4 +354,59 @@ describe('WorkspaceShellComponent', () => {
expect(shortcutsService.openShortcutsDialog).toHaveBeenCalledTimes(1);
});
it('opens the routed global search and focuses header search on Ctrl/Cmd+F', async () => {
jest.useFakeTimers();
const facade = new MockWorkspaceShellFacade();
await TestBed.configureTestingModule({
imports: [WorkspaceShellComponent],
providers: [provideRouter([])],
})
.overrideComponent(WorkspaceShellComponent, {
set: {
imports: [
RouterOutlet,
MockExternalPlaybackDockComponent,
MockPlaylistDropOverlayComponent,
MockPlaylistDropZoneDirective,
MockWorkspaceShellContextSidebarComponent,
MockWorkspaceShellHeaderComponent,
MockWorkspaceShellImportOverlayComponent,
MockWorkspaceShellRailComponent,
],
providers: [
{
provide: WorkspaceShellFacade,
useValue: facade,
},
{
provide: WorkspaceKeyboardShortcutsService,
useClass: MockWorkspaceKeyboardShortcutsService,
},
],
},
})
.compileComponents();
const fixture = TestBed.createComponent(WorkspaceShellComponent);
fixture.detectChanges();
const header = fixture.debugElement.query(
By.directive(MockWorkspaceShellHeaderComponent)
).componentInstance as MockWorkspaceShellHeaderComponent;
const event = new KeyboardEvent('keydown', {
key: 'f',
metaKey: true,
bubbles: true,
cancelable: true,
});
document.dispatchEvent(event);
jest.runOnlyPendingTimers();
expect(event.defaultPrevented).toBe(true);
expect(facade.openGlobalSearch).toHaveBeenCalledWith('');
expect(header.focusSearchInput).toHaveBeenCalledWith({ select: true });
jest.useRealTimers();
});
});
@@ -1,4 +1,4 @@
import { Component, inject } from '@angular/core';
import { Component, HostListener, inject, viewChild } from '@angular/core';
import { RouterOutlet } from '@angular/router';
import { ExternalPlaybackDockComponent } from '@iptvnator/ui/components';
import {
@@ -46,4 +46,52 @@ import { WorkspaceKeyboardShortcutsService } from '../workspace-keyboard-shortcu
export class WorkspaceShellComponent {
readonly facade = inject(WorkspaceShellFacade);
readonly keyboardShortcuts = inject(WorkspaceKeyboardShortcutsService);
private readonly header = viewChild<WorkspaceShellHeaderShortcutTarget>(
'workspaceHeader'
);
@HostListener('document:keydown', ['$event'])
onDocumentKeydown(event: KeyboardEvent): void {
if (
event.defaultPrevented ||
!this.facade.isElectron ||
!isFindShortcut(event)
) {
return;
}
const header = this.header();
const target = event.target;
if (isEditableTarget(target) && !header?.containsSearchInput(target)) {
return;
}
event.preventDefault();
this.facade.openGlobalSearch(this.facade.searchQuery());
setTimeout(() => header?.focusSearchInput({ select: true }));
}
}
function isFindShortcut(event: KeyboardEvent): boolean {
return (event.ctrlKey || event.metaKey) && event.key.toLowerCase() === 'f';
}
function isEditableTarget(target: EventTarget | null): boolean {
if (!(target instanceof HTMLElement)) {
return false;
}
if (target.isContentEditable) {
return true;
}
const tagName = target.tagName.toLowerCase();
return (
tagName === 'input' || tagName === 'textarea' || tagName === 'select'
);
}
interface WorkspaceShellHeaderShortcutTarget {
containsSearchInput(target: EventTarget | null): boolean;
focusSearchInput(options?: { select?: boolean }): void;
}
@@ -42,6 +42,15 @@ describe('workspace-shell-route.utils', () => {
usesQuerySearch: true,
})
);
expect(parseWorkspaceShellRoute('/workspace/search?q=matrix')).toEqual(
expect.objectContaining({
kind: 'global-search',
context: null,
contextPanel: 'none',
searchMode: 'remote-search',
usesQuerySearch: true,
})
);
expect(parseWorkspaceShellRoute('/workspace/downloads')).toEqual(
expect.objectContaining({
kind: 'downloads',
@@ -8,6 +8,7 @@ export type WorkspaceShellPageKind =
| 'downloads'
| 'global-favorites'
| 'global-recent'
| 'global-search'
| 'portal'
| 'settings'
| 'sources'
@@ -194,6 +195,10 @@ function resolveRouteSearchMode(
return 'local-filter';
}
if (kind === 'global-search') {
return 'remote-search';
}
if (kind === 'portal') {
return resolvePortalSearchMode(context, section);
}
@@ -288,9 +293,11 @@ export function parseWorkspaceShellRoute(url: string): WorkspaceShellRoute {
? 'global-favorites'
: page === 'global-recent'
? 'global-recent'
: page === 'downloads'
? 'downloads'
: 'unknown';
: page === 'search'
? 'global-search'
: page === 'downloads'
? 'downloads'
: 'unknown';
const searchMode = resolveRouteSearchMode(kind, null, null);
return {