mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-14 05:06:16 -08:00
feat(search): add workspace global search with M3U support
Moves global search into a routed workspace view, adds M3U live/radio results, lazy pagination, and DB-backed matching improvements.
This commit is contained in:
1 parent
ffc07146fe
commit
89916af9d8
65 files changed
+3010
-391
No files matched your search
+1
@@ -29,6 +29,7 @@
|
||||
</span>
|
||||
}
|
||||
<input
|
||||
#searchInput
|
||||
type="search"
|
||||
spellcheck="false"
|
||||
[value]="searchQuery()"
|
||||
|
||||
+13
@@ -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);
|
||||
|
||||
+22
@@ -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 ?? '');
|
||||
|
||||
+4
-4
@@ -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),
|
||||
},
|
||||
{
|
||||
|
||||
+4
@@ -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');
|
||||
}
|
||||
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
import { PlaylistMeta } from '@iptvnator/shared/interfaces';
|
||||
|
||||
type SearchablePlaylistMeta = Pick<
|
||||
PlaylistMeta,
|
||||
'count' | 'filePath' | 'macAddress' | 'serverUrl' | 'url'
|
||||
>;
|
||||
|
||||
export function isWorkspaceGlobalSearchablePlaylist(
|
||||
playlist: SearchablePlaylistMeta
|
||||
): boolean {
|
||||
if (playlist.serverUrl) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (playlist.macAddress) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return Boolean(
|
||||
playlist.filePath || playlist.url || Number(playlist.count ?? 0) > 0
|
||||
);
|
||||
}
|
||||
+19
-1
@@ -1,4 +1,10 @@
|
||||
import { computed, DestroyRef, inject, Injectable, signal } from '@angular/core';
|
||||
import {
|
||||
computed,
|
||||
DestroyRef,
|
||||
inject,
|
||||
Injectable,
|
||||
signal,
|
||||
} from '@angular/core';
|
||||
import { takeUntilDestroyed, toSignal } from '@angular/core/rxjs-interop';
|
||||
import { NavigationEnd, Router } from '@angular/router';
|
||||
import { Store } from '@ngrx/store';
|
||||
@@ -84,6 +90,17 @@ export class WorkspaceShellRouteStateService {
|
||||
path: ['/workspace/sources'],
|
||||
});
|
||||
|
||||
if (this.runtime.isElectron) {
|
||||
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 +142,7 @@ export class WorkspaceShellRouteStateService {
|
||||
currentRoute.kind !== 'settings' &&
|
||||
currentRoute.kind !== 'global-favorites' &&
|
||||
currentRoute.kind !== 'global-recent' &&
|
||||
currentRoute.kind !== 'global-search' &&
|
||||
currentRoute.kind !== 'downloads'
|
||||
) {
|
||||
return null;
|
||||
|
||||
+26
@@ -5,6 +5,7 @@ import { TranslateService } from '@ngx-translate/core';
|
||||
import { startWith } from 'rxjs';
|
||||
import { StalkerStore } from '@iptvnator/portal/stalker/data-access';
|
||||
import { XtreamStore } from '@iptvnator/portal/xtream/data-access';
|
||||
import { RuntimeCapabilitiesService } from '@iptvnator/services';
|
||||
import { WorkspaceSearchCapability } from '@iptvnator/workspace/shell/util';
|
||||
import {
|
||||
SEARCH_LOADED_ONLY_STATUS,
|
||||
@@ -14,6 +15,7 @@ import {
|
||||
resolveSearchPlaceholderKey,
|
||||
resolveSearchScopeLabel,
|
||||
} from './helpers/workspace-shell-search-labels';
|
||||
import { isWorkspaceGlobalSearchablePlaylist } from './helpers/workspace-shell-searchable-playlists';
|
||||
import { WorkspaceShellRouteStateService } from './workspace-shell-route-state.service';
|
||||
import { WorkspaceShellSearchSyncService } from './workspace-shell-search-sync.service';
|
||||
|
||||
@@ -23,6 +25,7 @@ export class WorkspaceShellSearchService {
|
||||
private readonly xtreamStore = inject(XtreamStore);
|
||||
private readonly stalkerStore = inject(StalkerStore);
|
||||
private readonly translate = inject(TranslateService);
|
||||
private readonly runtime = inject(RuntimeCapabilitiesService);
|
||||
private readonly routeState = inject(WorkspaceShellRouteStateService);
|
||||
private readonly searchSync = inject(WorkspaceShellSearchSyncService);
|
||||
|
||||
@@ -82,6 +85,29 @@ export class WorkspaceShellSearchService {
|
||||
};
|
||||
}
|
||||
|
||||
if (route.kind === 'global-search') {
|
||||
const hasSearchablePlaylists =
|
||||
this.runtime.isElectron &&
|
||||
this.routeState
|
||||
.playlists()
|
||||
.some(isWorkspaceGlobalSearchablePlaylist);
|
||||
|
||||
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,
|
||||
|
||||
+74
@@ -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',
|
||||
@@ -669,6 +675,17 @@ describe('WorkspaceShellFacade', () => {
|
||||
expect(facade.brandLink()).toBe('/workspace/sources');
|
||||
});
|
||||
|
||||
it('hides the Electron-only global search rail link in the web runtime', () => {
|
||||
runtime.isElectron = false;
|
||||
showDashboardSignal.set(false);
|
||||
|
||||
expect(facade.workspaceLinks().map((link) => link.path)).toEqual([
|
||||
['/workspace/sources'],
|
||||
['/workspace/global-favorites'],
|
||||
['/workspace/global-recent'],
|
||||
]);
|
||||
});
|
||||
|
||||
it('persists the last restorable route from navigation events', () => {
|
||||
expect(
|
||||
startupPreferences.persistLastRestorablePath
|
||||
@@ -706,6 +723,63 @@ 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('does not enable global search for empty invalid playlist metadata', () => {
|
||||
activePlaylistSignal.set(null);
|
||||
playlistsSignal.set([
|
||||
{
|
||||
_id: 'empty-playlist',
|
||||
title: 'Empty playlist',
|
||||
} as PlaylistSignalMeta,
|
||||
]);
|
||||
facade.currentUrl.set('/workspace/search');
|
||||
|
||||
expect(facade.canUseSearch()).toBe(false);
|
||||
expect(
|
||||
facade.commandPaletteCommands().map((command) => command.id)
|
||||
).not.toContain('global-search');
|
||||
});
|
||||
|
||||
it('hides the global search command and header capability in the web runtime', () => {
|
||||
runtime.isElectron = false;
|
||||
playlistsSignal.set([
|
||||
{
|
||||
_id: 'pl-m3u',
|
||||
title: 'Playlist M3U',
|
||||
count: 10,
|
||||
} as PlaylistSignalMeta,
|
||||
]);
|
||||
facade.currentUrl.set('/workspace/search');
|
||||
|
||||
expect(facade.canUseSearch()).toBe(false);
|
||||
expect(
|
||||
facade.commandPaletteCommands().map((command) => command.id)
|
||||
).not.toContain('global-search');
|
||||
});
|
||||
|
||||
it('hides the downloads command when downloads are unsupported', () => {
|
||||
runtime.supportsDownloads = false;
|
||||
activePlaylistSignal.set(null);
|
||||
|
||||
+4
-3
@@ -17,6 +17,7 @@ import {
|
||||
CommandBuilderActions,
|
||||
CommandBuilderContext,
|
||||
} from './helpers/workspace-shell-command-builders';
|
||||
import { isWorkspaceGlobalSearchablePlaylist } from './helpers/workspace-shell-searchable-playlists';
|
||||
import { WorkspaceShellCommandPaletteService } from './workspace-shell-command-palette.service';
|
||||
import { WorkspaceShellHeaderService } from './workspace-shell-header.service';
|
||||
import { WorkspaceShellRouteStateService } from './workspace-shell-route-state.service';
|
||||
@@ -219,9 +220,9 @@ export class WorkspaceShellFacade {
|
||||
context: this.currentContext(),
|
||||
section: this.currentSection(),
|
||||
hasActivePlaylist: !!this.activePlaylist(),
|
||||
hasXtreamPlaylists: this.playlists().some(
|
||||
(playlist) => !!playlist.serverUrl
|
||||
),
|
||||
hasSearchablePlaylists:
|
||||
this.isElectron &&
|
||||
this.playlists().some(isWorkspaceGlobalSearchablePlaylist),
|
||||
canRefreshPlaylist: this.canRefreshPlaylist(),
|
||||
supportsDownloads: this.supportsDownloads,
|
||||
showDashboard: this.showDashboard(),
|
||||
|
||||
@@ -22,6 +22,7 @@
|
||||
/>
|
||||
|
||||
<app-workspace-shell-header
|
||||
#workspaceHeader
|
||||
[playlistTitle]="facade.playlistTitle()"
|
||||
[playlistSubtitle]="facade.playlistSubtitle()"
|
||||
[canOpenPlaylistInfo]="facade.canOpenPlaylistInfo()"
|
||||
|
||||
+59
@@ -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;
|
||||
}
|
||||
Reference in new issue
Block a user