Proyecto App Linux

This commit is contained in:
2026-05-03 23:43:09 +02:00
commit 7482733843
1248 changed files with 189750 additions and 0 deletions
@@ -0,0 +1,3 @@
# data-access
This library was generated with [Nx](https://nx.dev).
@@ -0,0 +1,34 @@
import nx from '@nx/eslint-plugin';
import baseConfig from '../../../../eslint.config.mjs';
export default [
...baseConfig,
...nx.configs['flat/angular'],
...nx.configs['flat/angular-template'],
{
files: ['**/*.ts'],
rules: {
'@angular-eslint/directive-selector': [
'error',
{
type: 'attribute',
prefix: 'lib',
style: 'camelCase',
},
],
'@angular-eslint/component-selector': [
'error',
{
type: 'element',
prefix: 'lib',
style: 'kebab-case',
},
],
},
},
{
files: ['**/*.html'],
// Override or add rules here
rules: {},
},
];
@@ -0,0 +1,22 @@
export default {
displayName: 'workspace-dashboard-data-access',
preset: '../../../../jest.preset.js',
setupFilesAfterEnv: ['<rootDir>/src/test-setup.ts'],
coverageDirectory: '../../../../coverage/libs/workspace/dashboard/data-access',
transform: {
'^.+\\.(ts|mjs|js|html)$': [
'jest-preset-angular',
{
tsconfig: '<rootDir>/tsconfig.spec.json',
stringifyContentPathRegex: '\\.(html|svg)$',
},
],
},
transformIgnorePatterns: ['node_modules/(?!.*\\.mjs$)'],
snapshotSerializers: [
'jest-preset-angular/build/serializers/no-ng-attributes',
'jest-preset-angular/build/serializers/ng-snapshot',
'jest-preset-angular/build/serializers/html-comment',
],
moduleFileExtensions: ['ts', 'js', 'html'],
};
@@ -0,0 +1,21 @@
{
"name": "workspace-dashboard-data-access",
"$schema": "../../../../node_modules/nx/schemas/project-schema.json",
"sourceRoot": "libs/workspace/dashboard/data-access/src",
"prefix": "lib",
"projectType": "library",
"tags": ["scope:workspace", "domain:workspace", "type:data-access"],
"targets": {
"test": {
"executor": "@nx/jest:jest",
"outputs": ["{workspaceRoot}/coverage/{projectRoot}"],
"options": {
"jestConfig": "libs/workspace/dashboard/data-access/jest.config.ts",
"tsConfig": "libs/workspace/dashboard/data-access/tsconfig.spec.json"
}
},
"lint": {
"executor": "@nx/eslint:lint"
}
}
}
@@ -0,0 +1 @@
export * from './lib/dashboard-data.service';
@@ -0,0 +1,706 @@
import { signal } from '@angular/core';
import { TestBed } from '@angular/core/testing';
import { Store } from '@ngrx/store';
import { TranslateService } from '@ngx-translate/core';
import { selectAllPlaylistsMeta, selectPlaylistsLoadingFlag } from 'm3u-state';
import { of } from 'rxjs';
import { DatabaseService, PlaylistsService } from 'services';
import { Playlist, PlaylistMeta } from 'shared-interfaces';
import { DashboardDataService } from './dashboard-data.service';
describe('DashboardDataService', () => {
let service: DashboardDataService;
const playlistsLoadedSignal = signal(true);
const createDefaultPlaylists = (): PlaylistMeta[] => [
{
_id: 'm3u-1',
title: 'M3U Playlist',
count: 1,
importDate: '2026-01-01T00:00:00.000Z',
autoRefresh: false,
favorites: ['channel-1', 'https://example.com/stream-2.m3u8'],
recentlyViewed: [
{
source: 'm3u',
id: 'https://example.com/stream-1.m3u8',
url: 'https://example.com/stream-1.m3u8',
title: 'Channel One',
channel_id: 'channel-1',
poster_url: 'https://example.com/logo-1.png',
tvg_id: 'tvg-1',
tvg_name: 'Channel One',
group_title: 'News',
category_id: 'live',
added_at: '2026-02-01T12:00:00.000Z',
},
],
},
{
_id: 'xtream-1',
title: 'Xtream Playlist',
count: 1,
importDate: '2026-01-01T00:00:00.000Z',
autoRefresh: false,
serverUrl: 'https://example.com',
},
];
const playlistsSignal = signal<PlaylistMeta[]>(createDefaultPlaylists());
const storeMock = {
selectSignal: jest.fn((selector: unknown) => {
if (selector === selectAllPlaylistsMeta) {
return playlistsSignal;
}
if (selector === selectPlaylistsLoadingFlag) {
return playlistsLoadedSignal;
}
return signal(null);
}),
dispatch: jest.fn(),
};
const dbServiceMock = {
getGlobalRecentlyAdded: jest.fn().mockResolvedValue([]),
getGlobalRecentlyViewed: jest.fn().mockResolvedValue([]),
getAllGlobalFavorites: jest.fn().mockResolvedValue([]),
removeFromFavorites: jest.fn().mockResolvedValue(undefined),
removeRecentItem: jest.fn().mockResolvedValue(undefined),
};
const playlistMock: Playlist = {
_id: 'm3u-1',
title: 'M3U Playlist',
count: 2,
importDate: '2026-01-01T00:00:00.000Z',
lastUsage: '2026-01-01T00:00:00.000Z',
autoRefresh: false,
favorites: ['channel-1', 'https://example.com/stream-2.m3u8'],
playlist: {
items: [
{
id: 'channel-1',
name: 'Channel One',
url: 'https://example.com/stream-1.m3u8',
tvg: {
logo: 'https://example.com/logo-1.png',
id: 'tvg-1',
name: 'Channel One',
},
},
{
id: 'channel-2',
name: 'Channel Two',
url: 'https://example.com/stream-2.m3u8',
tvg: {
logo: 'https://example.com/logo-2.png',
id: 'tvg-2',
name: 'Channel Two',
},
},
],
},
} as Playlist;
const playlistsServiceMock = {
getPlaylistById: jest.fn().mockReturnValue(of(playlistMock)),
setFavorites: jest.fn().mockReturnValue(of(undefined)),
removeFromM3uRecentlyViewed: jest.fn().mockReturnValue(
of({
...playlistMock,
recentlyViewed: [],
})
),
};
beforeEach(() => {
Object.defineProperty(window, 'electron', {
value: {} as Window['electron'],
configurable: true,
});
playlistsLoadedSignal.set(true);
playlistsSignal.set(createDefaultPlaylists());
playlistsServiceMock.getPlaylistById.mockClear();
playlistsServiceMock.getPlaylistById.mockReturnValue(of(playlistMock));
playlistsServiceMock.setFavorites.mockClear();
playlistsServiceMock.setFavorites.mockReturnValue(of(undefined));
playlistsServiceMock.removeFromM3uRecentlyViewed.mockClear();
playlistsServiceMock.removeFromM3uRecentlyViewed.mockReturnValue(
of({
...playlistMock,
recentlyViewed: [],
})
);
dbServiceMock.getAllGlobalFavorites.mockClear();
dbServiceMock.getAllGlobalFavorites.mockResolvedValue([]);
dbServiceMock.getGlobalRecentlyAdded.mockClear();
dbServiceMock.getGlobalRecentlyAdded.mockResolvedValue([]);
dbServiceMock.getGlobalRecentlyViewed.mockClear();
dbServiceMock.getGlobalRecentlyViewed.mockResolvedValue([]);
dbServiceMock.removeFromFavorites.mockClear();
dbServiceMock.removeRecentItem.mockClear();
storeMock.dispatch.mockClear();
TestBed.configureTestingModule({
providers: [
DashboardDataService,
{ provide: Store, useValue: storeMock },
{ provide: DatabaseService, useValue: dbServiceMock },
{
provide: PlaylistsService,
useValue: playlistsServiceMock,
},
{
provide: TranslateService,
useValue: {
instant: (key: string) => key,
onLangChange: of(null),
currentLang: 'en',
defaultLang: 'en',
},
},
],
});
service = TestBed.inject(DashboardDataService);
});
afterEach(() => {
jest.clearAllMocks();
});
it('does not eagerly load dashboard datasets on construction', () => {
expect(dbServiceMock.getGlobalRecentlyViewed).not.toHaveBeenCalled();
expect(dbServiceMock.getAllGlobalFavorites).not.toHaveBeenCalled();
expect(dbServiceMock.getGlobalRecentlyAdded).not.toHaveBeenCalled();
expect(playlistsServiceMock.getPlaylistById).not.toHaveBeenCalled();
expect(service.dashboardReady()).toBe(false);
});
it('keeps dashboardReady false until xtream recently added finishes its first load', async () => {
expect(service.dashboardReady()).toBe(false);
await service.reloadGlobalRecentItems();
expect(service.dashboardReady()).toBe(false);
await service.reloadGlobalFavorites();
expect(service.dashboardReady()).toBe(false);
await service.reloadXtreamRecentlyAddedItems();
expect(service.dashboardReady()).toBe(true);
});
it('does not wait on xtream recently added when no xtream playlists exist', async () => {
playlistsSignal.set([
{
_id: 'm3u-1',
title: 'M3U Playlist',
count: 1,
importDate: '2026-01-01T00:00:00.000Z',
autoRefresh: false,
favorites: [],
recentlyViewed: [],
},
]);
await service.reloadGlobalRecentItems();
expect(service.dashboardReady()).toBe(false);
await service.reloadGlobalFavorites();
expect(service.dashboardReady()).toBe(true);
});
it('includes M3U favorites in global favorite items', async () => {
await service.reloadGlobalFavorites();
expect(service.globalFavoriteItems()).toEqual(
expect.arrayContaining([
expect.objectContaining({
id: 'channel-1',
title: 'Channel One',
type: 'live',
playlist_id: 'm3u-1',
source: 'm3u',
poster_url: 'https://example.com/logo-1.png',
}),
expect.objectContaining({
id: 'https://example.com/stream-2.m3u8',
title: 'Channel Two',
type: 'live',
playlist_id: 'm3u-1',
source: 'm3u',
poster_url: 'https://example.com/logo-2.png',
}),
])
);
});
it('includes Xtream movies and series in global favorite items', async () => {
dbServiceMock.getAllGlobalFavorites.mockResolvedValue([
{
id: 51,
category_id: 12,
title: 'Action Movie',
rating: '8.0',
added_at: '2026-02-02T10:00:00.000Z',
poster_url: 'https://example.com/movie.png',
xtream_id: 5001,
type: 'movie',
playlist_id: 'xtream-1',
playlist_name: 'Xtream Playlist',
},
{
id: 52,
category_id: 13,
title: 'Sci-Fi Series',
rating: '8.7',
added_at: '2026-02-03T10:00:00.000Z',
poster_url: 'https://example.com/series.png',
xtream_id: 5002,
type: 'series',
playlist_id: 'xtream-1',
playlist_name: 'Xtream Playlist',
},
]);
await service.reloadGlobalFavorites();
expect(service.globalFavoriteItems()).toEqual(
expect.arrayContaining([
expect.objectContaining({
id: 51,
title: 'Action Movie',
type: 'movie',
source: 'xtream',
}),
expect.objectContaining({
id: 52,
title: 'Sci-Fi Series',
type: 'series',
source: 'xtream',
}),
])
);
expect(
service
.globalFavoriteItems()
.filter((item) => item.type === 'movie')
).toHaveLength(1);
expect(
service
.globalFavoriteItems()
.filter((item) => item.type === 'series')
).toHaveLength(1);
});
it('builds the M3U favorites route', async () => {
await service.reloadGlobalFavorites();
const m3uItem = service
.globalFavoriteItems()
.find((item) => item.source === 'm3u');
expect(m3uItem).toBeDefined();
expect(service.getGlobalFavoriteLink(m3uItem!)).toEqual([
'/workspace',
'playlists',
'm3u-1',
'favorites',
]);
expect(service.getGlobalFavoriteNavigationState(m3uItem!)).toEqual({
openLiveCollectionItem: {
contentType: 'live',
sourceType: 'm3u',
playlistId: 'm3u-1',
itemId: 'channel-1',
title: 'Channel One',
imageUrl: 'https://example.com/logo-1.png',
},
});
});
it('routes Xtream non-live favorites through the global favorites page with inline detail state', async () => {
dbServiceMock.getAllGlobalFavorites.mockResolvedValue([
{
id: 51,
category_id: 12,
title: 'Action Movie',
rating: '8.0',
added_at: '2026-02-02T10:00:00.000Z',
poster_url: 'https://example.com/movie.png',
xtream_id: 5001,
type: 'movie',
playlist_id: 'xtream-1',
playlist_name: 'Xtream Playlist',
},
]);
await service.reloadGlobalFavorites();
const movieItem = service
.globalFavoriteItems()
.find((item) => item.type === 'movie');
expect(movieItem).toBeDefined();
expect(service.getGlobalFavoriteLink(movieItem!)).toEqual([
'/workspace',
'global-favorites',
]);
expect(service.getGlobalFavoriteNavigationState(movieItem!)).toEqual({
openCollectionDetailItem: {
item: expect.objectContaining({
uid: 'xtream::xtream-1::movie:5001',
name: 'Action Movie',
contentType: 'movie',
sourceType: 'xtream',
playlistId: 'xtream-1',
}),
},
});
});
it('removes M3U favorites via PlaylistsService', async () => {
await service.reloadGlobalFavorites();
const m3uItem = service
.globalFavoriteItems()
.find((item) => item.id === 'channel-1');
expect(m3uItem).toBeDefined();
await service.removeGlobalFavorite(m3uItem!);
expect(playlistsServiceMock.setFavorites).toHaveBeenCalledWith(
'm3u-1',
['https://example.com/stream-2.m3u8']
);
});
it('includes M3U recently viewed items in global recent items', () => {
expect(service.globalRecentItems()).toEqual(
expect.arrayContaining([
expect.objectContaining({
id: 'https://example.com/stream-1.m3u8',
title: 'Channel One',
type: 'live',
playlist_id: 'm3u-1',
source: 'm3u',
xtream_id: 'https://example.com/stream-1.m3u8',
}),
])
);
});
it('prioritizes recently used Xtream sources over newer imported M3U sources', async () => {
playlistsSignal.set([
{
_id: 'm3u-fresh',
title: 'Fresh Import',
count: 1,
importDate: '2026-04-20T12:00:00.000Z',
autoRefresh: false,
favorites: [],
recentlyViewed: [],
},
{
_id: 'xtream-1',
title: 'Xtream Playlist',
count: 1,
importDate: '2026-01-01T00:00:00.000Z',
autoRefresh: false,
serverUrl: 'https://example.com',
},
]);
dbServiceMock.getGlobalRecentlyViewed.mockResolvedValue([
{
id: 91,
category_id: 18,
title: 'Recent Movie',
rating: '7.8',
viewed_at: '2026-04-21T10:00:00.000Z',
poster_url: 'https://example.com/recent-movie.png',
xtream_id: 7001,
type: 'movie',
playlist_id: 'xtream-1',
playlist_name: 'Xtream Playlist',
},
]);
await service.reloadGlobalRecentItems();
expect(service.recentPlaylists().map((playlist) => playlist._id)).toEqual(
['xtream-1', 'm3u-fresh']
);
});
it('includes M3U, Xtream, and Stalker sources when all have recent activity', async () => {
playlistsSignal.set([
{
_id: 'm3u-1',
title: 'M3U Playlist',
count: 1,
importDate: '2026-01-01T00:00:00.000Z',
autoRefresh: false,
favorites: [],
recentlyViewed: [
{
source: 'm3u',
id: 'https://example.com/stream-1.m3u8',
url: 'https://example.com/stream-1.m3u8',
title: 'Channel One',
category_id: 'live',
added_at: '2026-04-19T10:00:00.000Z',
},
],
},
{
_id: 'stalker-1',
title: 'Stalker Playlist',
count: 1,
importDate: '2026-01-01T00:00:00.000Z',
autoRefresh: false,
macAddress: '00:11:22:33:44:55',
recentlyViewed: [
{
id: 'stalker-item-1',
name: 'Stalker Movie',
category_id: 'vod',
added_at: '2026-04-20T10:00:00.000Z',
},
],
},
{
_id: 'xtream-1',
title: 'Xtream Playlist',
count: 1,
importDate: '2026-01-01T00:00:00.000Z',
autoRefresh: false,
serverUrl: 'https://example.com',
},
]);
dbServiceMock.getGlobalRecentlyViewed.mockResolvedValue([
{
id: 91,
category_id: 18,
title: 'Recent Movie',
rating: '7.8',
viewed_at: '2026-04-21T10:00:00.000Z',
poster_url: 'https://example.com/recent-movie.png',
xtream_id: 7001,
type: 'movie',
playlist_id: 'xtream-1',
playlist_name: 'Xtream Playlist',
},
]);
await service.reloadGlobalRecentItems();
expect(service.recentPlaylists().map((playlist) => playlist._id)).toEqual(
['xtream-1', 'stalker-1', 'm3u-1']
);
});
it('falls back to playlist metadata ordering when sources have no recent activity', async () => {
playlistsSignal.set([
{
_id: 'xtream-1',
title: 'Xtream Playlist',
count: 1,
importDate: '2026-01-01T00:00:00.000Z',
autoRefresh: false,
serverUrl: 'https://example.com',
},
{
_id: 'stalker-1',
title: 'Stalker Playlist',
count: 1,
importDate: '2026-03-01T00:00:00.000Z',
autoRefresh: false,
macAddress: '00:11:22:33:44:55',
recentlyViewed: [],
},
{
_id: 'm3u-1',
title: 'M3U Playlist',
count: 1,
importDate: '2026-02-01T00:00:00.000Z',
updateDate: Date.parse('2026-04-01T00:00:00.000Z'),
autoRefresh: false,
favorites: [],
recentlyViewed: [],
},
]);
dbServiceMock.getGlobalRecentlyViewed.mockResolvedValue([]);
await service.reloadGlobalRecentItems();
expect(service.recentPlaylists().map((playlist) => playlist._id)).toEqual(
['m3u-1', 'stalker-1', 'xtream-1']
);
});
it('maps recently added Xtream rows for the widget', async () => {
dbServiceMock.getGlobalRecentlyAdded.mockResolvedValue([
{
id: 11,
category_id: 77,
title: 'Fresh Movie',
rating: '8.1',
added: '1774298340',
added_at: '1774298340',
poster_url: 'https://example.com/poster.png',
xtream_id: 501,
type: 'movie',
playlist_id: 'xtream-1',
playlist_name: 'Xtream Playlist',
},
]);
await expect(
service.getGlobalRecentlyAddedItems('all', 25)
).resolves.toEqual([
expect.objectContaining({
id: 11,
title: 'Fresh Movie',
type: 'movie',
playlist_id: 'xtream-1',
source: 'xtream',
added_at: expect.any(String),
}),
]);
expect(dbServiceMock.getGlobalRecentlyAdded).toHaveBeenCalledWith(
'all',
25
);
});
it('builds the Xtream recently added route', () => {
const item = {
id: 11,
title: 'Fresh Series',
type: 'series',
playlist_id: 'xtream-1',
playlist_name: 'Xtream Playlist',
added_at: '2026-03-27T14:39:00.000Z',
category_id: 77,
xtream_id: 501,
poster_url: 'https://example.com/poster.png',
source: 'xtream',
} as const;
expect(service.getRecentlyAddedLink(item)).toEqual([
'/workspace',
'xtreams',
'xtream-1',
'series',
'77',
'501',
]);
expect(service.getRecentlyAddedNavigationState(item)).toBeUndefined();
});
it('builds the M3U recent route with collection auto-open state', () => {
const m3uItem = service
.globalRecentItems()
.find((item) => item.source === 'm3u');
expect(m3uItem).toBeDefined();
expect(service.getRecentItemLink(m3uItem!)).toEqual([
'/workspace',
'playlists',
'm3u-1',
'recent',
]);
expect(service.getRecentItemNavigationState(m3uItem!)).toEqual({
openLiveCollectionItem: {
contentType: 'live',
sourceType: 'm3u',
playlistId: 'm3u-1',
itemId: 'https://example.com/stream-1.m3u8',
title: 'Channel One',
imageUrl: 'https://example.com/logo-1.png',
},
});
});
it('routes Xtream non-live recents through the global recent page with inline detail state', async () => {
dbServiceMock.getGlobalRecentlyViewed.mockResolvedValue([
{
id: 91,
category_id: 18,
title: 'Recent Movie',
rating: '7.8',
viewed_at: '2026-02-04T10:00:00.000Z',
poster_url: 'https://example.com/recent-movie.png',
backdrop_url: 'https://example.com/recent-movie-backdrop.png',
xtream_id: 7001,
type: 'movie',
playlist_id: 'xtream-1',
playlist_name: 'Xtream Playlist',
},
]);
await service.reloadGlobalRecentItems();
const movieItem = service
.globalRecentItems()
.find((item) => item.source === 'xtream');
expect(movieItem).toEqual(
expect.objectContaining({
backdrop_url: 'https://example.com/recent-movie-backdrop.png',
})
);
expect(service.getRecentItemLink(movieItem!)).toEqual([
'/workspace',
'global-recent',
]);
expect(service.getRecentItemNavigationState(movieItem!)).toEqual({
openCollectionDetailItem: {
item: expect.objectContaining({
uid: 'xtream::xtream-1::movie:7001',
name: 'Recent Movie',
contentType: 'movie',
sourceType: 'xtream',
playlistId: 'xtream-1',
}),
},
});
});
it('normalizes SQLite-style Xtream recent timestamps before sorting and rendering', async () => {
dbServiceMock.getGlobalRecentlyViewed.mockResolvedValue([
{
id: 91,
category_id: 18,
title: 'Recent Movie',
rating: '7.8',
viewed_at: '2026-02-04 10:00:00',
poster_url: 'https://example.com/recent-movie.png',
xtream_id: 7001,
type: 'movie',
playlist_id: 'xtream-1',
playlist_name: 'Xtream Playlist',
},
]);
await service.reloadGlobalRecentItems();
const movieItem = service
.globalRecentItems()
.find((item) => item.source === 'xtream');
expect(movieItem).toEqual(
expect.objectContaining({
viewed_at: '2026-02-04T10:00:00.000Z',
})
);
});
it('removes M3U recently viewed via PlaylistsService', async () => {
const m3uItem = service
.globalRecentItems()
.find((item) => item.source === 'm3u');
expect(m3uItem).toBeDefined();
await service.removeGlobalRecentItem(m3uItem!);
expect(
playlistsServiceMock.removeFromM3uRecentlyViewed
).toHaveBeenCalledWith('m3u-1', 'https://example.com/stream-1.m3u8');
});
});
@@ -0,0 +1,839 @@
import {
Injectable,
NgZone,
computed,
effect,
inject,
signal,
} from '@angular/core';
import { toSignal } from '@angular/core/rxjs-interop';
import { Store } from '@ngrx/store';
import { TranslateService } from '@ngx-translate/core';
import {
PlaylistActions,
selectAllPlaylistsMeta,
selectPlaylistsLoadingFlag,
} from 'm3u-state';
import { firstValueFrom, startWith } from 'rxjs';
import {
DatabaseService,
GlobalRecentlyAddedKind,
PlaylistsService,
} from 'services';
import {
buildPlaylistRecentItems,
Channel,
Playlist,
PortalAddedItem,
PortalActivityItem,
PlaylistMeta,
PortalActivityType,
PortalFavoriteItem,
PortalRecentItem,
stalkerItemMatchesId,
} from 'shared-interfaces';
import {
buildStalkerFavoriteItems,
getActivityTypeLabelKey,
mapDbFavoriteToItem,
mapDbRecentlyAddedToItem,
mapDbRecentToItem,
toDateTimestamp,
toTimestamp,
} from './dashboard-mappers';
import {
buildStalkerDetailNavigationTarget,
buildStalkerStateItem,
buildXtreamNavigationTarget,
getGlobalFavoriteNavigation,
getRecentItemNavigation,
WorkspaceNavigationTarget,
} from '@iptvnator/portal/shared/util';
export type DashboardContentKind = 'all' | 'channels' | 'vod' | 'series';
/** @deprecated Use {@link PortalRecentItem} from `shared-interfaces` instead. */
export type GlobalRecentItem = PortalRecentItem;
/** @deprecated Use {@link PortalFavoriteItem} from `shared-interfaces` instead. */
export type DashboardFavoriteItem = PortalFavoriteItem;
/** @deprecated Use {@link PortalAddedItem} from `shared-interfaces` instead. */
export type DashboardRecentlyAddedItem = PortalAddedItem;
export type DashboardRecentlyAddedFilterKind = GlobalRecentlyAddedKind;
@Injectable({ providedIn: 'root' })
export class DashboardDataService {
private readonly store = inject(Store);
private readonly dbService = inject(DatabaseService);
private readonly playlistsService = inject(PlaylistsService);
private readonly ngZone = inject(NgZone);
private readonly translate = inject(TranslateService);
private favoritesAutoRefreshEnabled = false;
private readonly languageTick = toSignal(
this.translate.onLangChange.pipe(startWith(null)),
{ initialValue: null }
);
private readonly xtreamGlobalRecentItems = signal<GlobalRecentItem[]>([]);
private readonly xtreamRecentlyAddedItemsState = signal<
DashboardRecentlyAddedItem[]
>([]);
private readonly xtreamGlobalFavorites = signal<DashboardFavoriteItem[]>(
[]
);
private readonly m3uGlobalFavorites = signal<DashboardFavoriteItem[]>([]);
private readonly globalRecentLoadingState = signal(true);
private readonly globalRecentLoadedState = signal(false);
private readonly globalRecentDbLoadedState = signal(!window.electron);
private readonly globalFavoritesLoadingState = signal(true);
private readonly globalFavoritesLoadedState = signal(false);
private readonly xtreamRecentlyAddedLoadingState = signal(true);
private readonly xtreamRecentlyAddedLoadedState = signal(false);
readonly playlists = this.store.selectSignal(selectAllPlaylistsMeta);
readonly playlistsLoaded = this.store.selectSignal(
selectPlaylistsLoadingFlag
);
readonly xtreamPlaylistCount = computed(
() => this.playlists().filter((playlist) => !!playlist.serverUrl).length
);
readonly hasXtreamPlaylists = computed(() => this.xtreamPlaylistCount() > 0);
readonly globalRecentLoading = this.globalRecentLoadingState.asReadonly();
readonly globalRecentLoaded = this.globalRecentLoadedState.asReadonly();
readonly globalFavoritesLoading =
this.globalFavoritesLoadingState.asReadonly();
readonly globalFavoritesLoaded =
this.globalFavoritesLoadedState.asReadonly();
readonly xtreamRecentlyAddedLoading =
this.xtreamRecentlyAddedLoadingState.asReadonly();
readonly xtreamRecentlyAddedLoaded =
this.xtreamRecentlyAddedLoadedState.asReadonly();
readonly xtreamRecentlyAddedItems =
this.xtreamRecentlyAddedItemsState.asReadonly();
readonly dashboardReady = computed(
() =>
this.playlistsLoaded() &&
this.globalRecentLoaded() &&
this.globalFavoritesLoaded() &&
(!this.hasXtreamPlaylists() || this.xtreamRecentlyAddedLoaded())
);
private readonly playlistFavoritesReloadKey = computed(() => {
if (!this.playlistsLoaded()) {
return null;
}
return this.playlists()
.map((playlist) =>
[
playlist._id,
playlist.serverUrl
? 'xtream'
: playlist.macAddress
? 'stalker'
: 'm3u',
JSON.stringify(playlist.favorites ?? []),
].join('::')
)
.join('|');
});
readonly playlistBackedGlobalRecentItems = computed<GlobalRecentItem[]>(
() => {
this.languageTick();
return buildPlaylistRecentItems(this.playlists(), {
stalker: this.translateText(
'WORKSPACE.DASHBOARD.STALKER_PORTAL'
),
m3u: this.translateText('WORKSPACE.DASHBOARD.M3U'),
});
}
);
readonly globalRecentItems = computed<GlobalRecentItem[]>(() =>
[
...this.xtreamGlobalRecentItems(),
...this.playlistBackedGlobalRecentItems(),
]
.sort(
(a, b) =>
toDateTimestamp(b.viewed_at) - toDateTimestamp(a.viewed_at)
)
.slice(0, 200)
);
readonly stalkerGlobalFavorites = computed<DashboardFavoriteItem[]>(() => {
this.languageTick();
return buildStalkerFavoriteItems(
this.playlists(),
this.translateText('WORKSPACE.DASHBOARD.STALKER_PORTAL')
);
});
readonly globalFavoriteItems = computed(() =>
[
...this.xtreamGlobalFavorites(),
...this.m3uGlobalFavorites(),
...this.stalkerGlobalFavorites(),
]
.sort((a, b) => toTimestamp(b.added_at) - toTimestamp(a.added_at))
.slice(0, 200)
);
private readonly recentPlaylistActivityTimestamps = computed(() => {
const timestamps = new Map<string, number>();
for (const item of this.globalRecentItems()) {
const viewedAt = toDateTimestamp(item.viewed_at);
const current = timestamps.get(item.playlist_id) ?? 0;
if (viewedAt > current) {
timestamps.set(item.playlist_id, viewedAt);
}
}
return timestamps;
});
constructor() {
effect(() => {
this.playlistFavoritesReloadKey();
if (!this.playlistsLoaded() || !this.favoritesAutoRefreshEnabled) {
return;
}
void this.refreshPlaylistBackedGlobalFavorites();
});
effect(() => {
this.playlistsLoaded();
this.finishInitialGlobalRecentLoadIfReady();
this.finishInitialGlobalFavoritesLoadIfReady();
});
effect(() => {
if (this.hasXtreamPlaylists()) {
return;
}
this.ngZone.run(() => {
this.xtreamRecentlyAddedItemsState.set([]);
this.xtreamRecentlyAddedLoadingState.set(false);
this.xtreamRecentlyAddedLoadedState.set(true);
});
});
}
readonly stats = computed(() => {
const items = this.playlists();
return {
total: items.length,
xtream: items.filter((item) => !!item.serverUrl).length,
stalker: items.filter((item) => !!item.macAddress).length,
m3u: items.filter((item) => !item.serverUrl && !item.macAddress)
.length,
};
});
readonly recentPlaylists = computed(() =>
[...this.playlists()]
.sort((a, b) => {
const activityDelta =
this.getPlaylistActivityTimestamp(b) -
this.getPlaylistActivityTimestamp(a);
if (activityDelta !== 0) {
return activityDelta;
}
const metadataDelta =
this.getRecentTimestamp(b) - this.getRecentTimestamp(a);
if (metadataDelta !== 0) {
return metadataDelta;
}
return a._id.localeCompare(b._id);
})
.slice(0, 12)
);
readonly quickRecent = computed(() => this.recentPlaylists().slice(0, 4));
async reloadGlobalRecentItems(): Promise<void> {
if (!this.globalRecentLoaded()) {
this.globalRecentLoadingState.set(true);
}
if (!window.electron) {
this.xtreamGlobalRecentItems.set([]);
this.globalRecentDbLoadedState.set(true);
this.finishInitialGlobalRecentLoadIfReady();
return;
}
try {
const recentItems = await this.dbService.getGlobalRecentlyViewed();
const normalized = recentItems.map((item) =>
mapDbRecentToItem(item)
);
this.ngZone.run(() => this.xtreamGlobalRecentItems.set(normalized));
} catch (err) {
console.warn(
'[DashboardData] Failed to reload global recent items',
err
);
this.ngZone.run(() => this.xtreamGlobalRecentItems.set([]));
} finally {
this.globalRecentDbLoadedState.set(true);
this.finishInitialGlobalRecentLoadIfReady();
}
}
async reloadGlobalFavorites(): Promise<void> {
if (!this.globalFavoritesLoaded()) {
this.globalFavoritesLoadingState.set(true);
}
const xtreamReload = this.reloadXtreamGlobalFavorites();
const m3uReload = this.refreshPlaylistBackedGlobalFavorites();
await Promise.all([xtreamReload, m3uReload]);
this.favoritesAutoRefreshEnabled = true;
}
async getGlobalRecentlyAddedItems(
kind: DashboardRecentlyAddedFilterKind,
limit = 200
): Promise<DashboardRecentlyAddedItem[]> {
if (!window.electron) {
return [];
}
const items = await this.dbService.getGlobalRecentlyAdded(kind, limit);
return items
.map((item) => mapDbRecentlyAddedToItem(item))
.sort((a, b) => toTimestamp(b.added_at) - toTimestamp(a.added_at));
}
async getXtreamRecentlyAddedItems(
limit = 20
): Promise<DashboardRecentlyAddedItem[]> {
if (!window.electron) {
return [];
}
const items = await this.dbService.getGlobalRecentlyAdded(
'all',
limit,
'xtream'
);
return items
.map((item) => mapDbRecentlyAddedToItem(item))
.sort((a, b) => toTimestamp(b.added_at) - toTimestamp(a.added_at));
}
async reloadXtreamRecentlyAddedItems(limit = 20): Promise<void> {
if (!this.xtreamRecentlyAddedLoaded()) {
this.xtreamRecentlyAddedLoadingState.set(true);
}
if (!window.electron) {
this.ngZone.run(() => {
this.xtreamRecentlyAddedItemsState.set([]);
this.xtreamRecentlyAddedLoadingState.set(false);
this.xtreamRecentlyAddedLoadedState.set(true);
});
return;
}
try {
const items = await this.getXtreamRecentlyAddedItems(limit);
this.ngZone.run(() =>
this.xtreamRecentlyAddedItemsState.set(items)
);
} catch (err) {
console.warn(
'[DashboardData] Failed to reload Xtream recently added items',
err
);
this.ngZone.run(() => this.xtreamRecentlyAddedItemsState.set([]));
} finally {
this.ngZone.run(() => {
this.xtreamRecentlyAddedLoadingState.set(false);
this.xtreamRecentlyAddedLoadedState.set(true);
});
}
}
private async reloadXtreamGlobalFavorites(): Promise<void> {
if (!window.electron) {
this.xtreamGlobalFavorites.set([]);
this.finishInitialGlobalFavoritesLoadIfReady();
return;
}
try {
const favorites = await this.dbService.getAllGlobalFavorites();
const normalized = favorites.map((item) =>
mapDbFavoriteToItem(item)
);
this.ngZone.run(() => this.xtreamGlobalFavorites.set(normalized));
} catch (err) {
console.warn(
'[DashboardData] Failed to reload global favorites',
err
);
this.ngZone.run(() => this.xtreamGlobalFavorites.set([]));
} finally {
this.finishInitialGlobalFavoritesLoadIfReady();
}
}
private async refreshPlaylistBackedGlobalFavorites(): Promise<void> {
await this.reloadM3uGlobalFavorites();
this.finishInitialGlobalFavoritesLoadIfReady();
}
private async reloadM3uGlobalFavorites(): Promise<void> {
const m3uPlaylists = this.playlists().filter(
(playlist) =>
!playlist.serverUrl &&
!playlist.macAddress &&
Array.isArray(playlist.favorites) &&
playlist.favorites.some(
(favorite): favorite is string =>
typeof favorite === 'string' &&
favorite.trim().length > 0
)
);
if (m3uPlaylists.length === 0) {
this.ngZone.run(() => this.m3uGlobalFavorites.set([]));
return;
}
const favoriteItems = await Promise.all(
m3uPlaylists.map((playlist) =>
this.loadM3uPlaylistFavorites(playlist).catch(
(): DashboardFavoriteItem[] => []
)
)
);
const flattenedFavorites = favoriteItems.reduce(
(acc, items) => [...acc, ...items],
[] as DashboardFavoriteItem[]
);
this.ngZone.run(() => this.m3uGlobalFavorites.set(flattenedFavorites));
}
private finishInitialGlobalFavoritesLoadIfReady(): void {
if (!this.playlistsLoaded()) {
return;
}
this.ngZone.run(() => {
this.globalFavoritesLoadedState.set(true);
this.globalFavoritesLoadingState.set(false);
});
}
private finishInitialGlobalRecentLoadIfReady(): void {
if (!this.playlistsLoaded() || !this.globalRecentDbLoadedState()) {
return;
}
this.ngZone.run(() => {
this.globalRecentLoadedState.set(true);
this.globalRecentLoadingState.set(false);
});
}
isTypeInKind(
type: PortalActivityType,
kind: DashboardContentKind
): boolean {
if (kind === 'all') {
return true;
}
if (kind === 'channels') {
return type === 'live';
}
if (kind === 'vod') {
return type === 'movie';
}
return type === 'series';
}
getPlaylistLink(playlist: PlaylistMeta): string[] {
if (playlist.serverUrl) {
return ['/workspace', 'xtreams', playlist._id, 'vod'];
}
if (playlist.macAddress) {
return ['/workspace', 'stalker', playlist._id, 'vod'];
}
return ['/workspace', 'playlists', playlist._id];
}
getPlaylistProvider(playlist: PlaylistMeta): string {
this.languageTick();
if (playlist.serverUrl) {
return this.translateText('WORKSPACE.DASHBOARD.XTREAM');
}
if (playlist.macAddress) {
return this.translateText('WORKSPACE.DASHBOARD.STALKER');
}
return this.translateText('WORKSPACE.DASHBOARD.M3U');
}
getRecentItemProviderLabel(item: GlobalRecentItem): string {
this.languageTick();
if (item.source === 'stalker') {
return this.translateText('WORKSPACE.DASHBOARD.STALKER');
}
if (item.source === 'xtream') {
return this.translateText('WORKSPACE.DASHBOARD.XTREAM');
}
if (item.source === 'm3u') {
return this.translateText('WORKSPACE.DASHBOARD.M3U');
}
return this.translateText('WORKSPACE.DASHBOARD.PROVIDER');
}
getRecentItemTypeLabel(item: GlobalRecentItem): string {
this.languageTick();
return this.translateText(getActivityTypeLabelKey(item.type));
}
getRecentItemLink(item: GlobalRecentItem): string[] {
return getRecentItemNavigation(item).link;
}
getRecentItemNavigationState(
item: GlobalRecentItem
): WorkspaceNavigationTarget['state'] {
return getRecentItemNavigation(item).state;
}
async removeGlobalRecentItem(item: GlobalRecentItem): Promise<void> {
if (item.source === 'xtream') {
await this.dbService.removeRecentItem(
item.id as number,
item.playlist_id
);
await this.reloadGlobalRecentItems();
return;
}
if (item.source === 'stalker') {
const updatedPlaylist = await firstValueFrom(
this.playlistsService.removeFromPortalRecentlyViewed(
item.playlist_id,
item.id
)
);
this.store.dispatch(
PlaylistActions.updatePlaylistMeta({
playlist: {
_id: item.playlist_id,
recentlyViewed: updatedPlaylist?.recentlyViewed ?? [],
} as unknown as PlaylistMeta,
}) as any
);
return;
}
if (item.source === 'm3u') {
const updatedPlaylist = await firstValueFrom(
this.playlistsService.removeFromM3uRecentlyViewed(
item.playlist_id,
String(item.xtream_id ?? item.id)
)
);
this.store.dispatch(
PlaylistActions.updatePlaylistMeta({
playlist: {
_id: item.playlist_id,
recentlyViewed: updatedPlaylist?.recentlyViewed ?? [],
} as unknown as PlaylistMeta,
}) as any
);
}
}
getFavoriteItemProviderLabel(item: DashboardFavoriteItem): string {
return this.getActivityItemProviderLabel(item);
}
getRecentlyAddedItemProviderLabel(
item: DashboardRecentlyAddedItem
): string {
return this.getActivityItemProviderLabel(item);
}
private getActivityItemProviderLabel(
item: Pick<PortalActivityItem, 'source'>
): string {
this.languageTick();
if (item.source === 'stalker') {
return this.translateText('WORKSPACE.DASHBOARD.STALKER');
}
if (item.source === 'xtream') {
return this.translateText('WORKSPACE.DASHBOARD.XTREAM');
}
if (item.source === 'm3u') {
return this.translateText('WORKSPACE.DASHBOARD.M3U');
}
return this.translateText('WORKSPACE.DASHBOARD.PROVIDER');
}
getFavoriteItemTypeLabel(item: DashboardFavoriteItem): string {
return this.getActivityItemTypeLabel(item);
}
getRecentlyAddedItemTypeLabel(item: DashboardRecentlyAddedItem): string {
return this.getActivityItemTypeLabel(item);
}
private getActivityItemTypeLabel(
item: Pick<PortalActivityItem, 'type'>
): string {
this.languageTick();
return this.translateText(getActivityTypeLabelKey(item.type));
}
getGlobalFavoriteLink(item: DashboardFavoriteItem): string[] {
return getGlobalFavoriteNavigation(item).link;
}
getGlobalFavoriteNavigationState(
item: DashboardFavoriteItem
): WorkspaceNavigationTarget['state'] {
return getGlobalFavoriteNavigation(item).state;
}
getRecentlyAddedLink(item: DashboardRecentlyAddedItem): string[] {
if (item.source === 'stalker' && item.type !== 'live') {
return buildStalkerDetailNavigationTarget({
playlistId: item.playlist_id,
type: item.type,
categoryId: item.category_id,
item: buildStalkerStateItem(item.stalker_item, {
id: item.id,
title: item.title,
type: item.type,
category_id: item.category_id,
poster_url: item.poster_url,
}),
}).link;
}
return buildXtreamNavigationTarget({
playlistId: item.playlist_id,
type: item.type,
categoryId: item.category_id,
itemId: item.xtream_id,
title: item.title,
imageUrl: item.poster_url,
}).link;
}
getRecentlyAddedNavigationState(
item: DashboardRecentlyAddedItem
): WorkspaceNavigationTarget['state'] {
if (item.source === 'stalker' && item.type !== 'live') {
return buildStalkerDetailNavigationTarget({
playlistId: item.playlist_id,
type: item.type,
categoryId: item.category_id,
item: buildStalkerStateItem(item.stalker_item, {
id: item.id,
title: item.title,
type: item.type,
category_id: item.category_id,
poster_url: item.poster_url,
}),
}).state;
}
return buildXtreamNavigationTarget({
playlistId: item.playlist_id,
type: item.type,
categoryId: item.category_id,
itemId: item.xtream_id,
title: item.title,
imageUrl: item.poster_url,
}).state;
}
async removeGlobalFavorite(item: DashboardFavoriteItem): Promise<void> {
if (item.source === 'xtream') {
await this.dbService.removeFromFavorites(
item.id as number,
item.playlist_id
);
await this.reloadGlobalFavorites();
return;
}
if (item.source === 'stalker') {
const playlist = this.playlists().find(
(p) => p._id === item.playlist_id
);
if (!playlist) return;
const currentFavorites = Array.isArray(playlist.favorites)
? [...playlist.favorites]
: [];
const itemMatchStr = String(item.id);
const filteredFavorites = currentFavorites.filter(
(raw, index) =>
!stalkerItemMatchesId(
raw,
itemMatchStr,
playlist._id,
index
)
);
this.store.dispatch(
PlaylistActions.updatePlaylistMeta({
playlist: {
_id: item.playlist_id,
favorites: filteredFavorites,
} as unknown as PlaylistMeta,
}) as any
);
}
if (item.source === 'm3u') {
const playlist = await firstValueFrom(
this.playlistsService.getPlaylistById(item.playlist_id)
);
const currentFavorites = Array.isArray(playlist?.favorites)
? playlist.favorites.filter(
(favorite): favorite is string =>
typeof favorite === 'string'
)
: [];
const filteredFavorites = currentFavorites.filter(
(favorite) => favorite !== String(item.id)
);
await firstValueFrom(
this.playlistsService.setFavorites(
item.playlist_id,
filteredFavorites
)
);
await this.reloadGlobalFavorites();
}
}
formatTimestamp(value?: string | number): string {
this.languageTick();
const timestamp = toTimestamp(value);
if (!timestamp) {
return this.translateText('WORKSPACE.DASHBOARD.NOT_YET_SYNCED');
}
return new Date(timestamp).toLocaleString(this.getLocale());
}
private async loadM3uPlaylistFavorites(
playlistMeta: PlaylistMeta
): Promise<DashboardFavoriteItem[]> {
const playlist = (await firstValueFrom(
this.playlistsService.getPlaylistById(playlistMeta._id)
)) as Playlist & {
playlist?: {
items?: Channel[];
};
};
const favorites = Array.isArray(playlist?.favorites)
? playlist.favorites.filter(
(favorite): favorite is string =>
typeof favorite === 'string' && favorite.trim().length > 0
)
: [];
if (favorites.length === 0) {
return [];
}
const items: Channel[] = Array.isArray(playlist?.playlist?.items)
? (playlist.playlist.items as Channel[])
: [];
const fallbackTimestamp =
this.getM3uFavoriteTimestamp(playlistMeta) ??
new Date(0).toISOString();
return items.reduce<DashboardFavoriteItem[]>((acc, channel) => {
const channelId = String(channel.id ?? '').trim();
const channelUrl = String(channel.url ?? '').trim();
const matchedFavoriteId = favorites.find(
(favorite) => favorite === channelId || favorite === channelUrl
);
if (!matchedFavoriteId) {
return acc;
}
acc.push({
id: matchedFavoriteId,
title: channel.name?.trim() || channel.tvg?.name || channelId,
type: 'live',
playlist_id: playlistMeta._id,
playlist_name:
playlistMeta.title || playlistMeta.filename || 'M3U',
added_at: fallbackTimestamp,
category_id: 'live',
xtream_id: matchedFavoriteId,
poster_url: channel.tvg?.logo || undefined,
source: 'm3u',
});
return acc;
}, []);
}
private getM3uFavoriteTimestamp(playlist: PlaylistMeta): string | null {
if (playlist.updateDate) {
return new Date(playlist.updateDate).toISOString();
}
return typeof playlist.importDate === 'string'
? playlist.importDate
: null;
}
private getPlaylistActivityTimestamp(item: PlaylistMeta): number {
return this.recentPlaylistActivityTimestamps().get(item._id) ?? 0;
}
private getRecentTimestamp(item: PlaylistMeta): number {
return toTimestamp(item.updateDate) || toTimestamp(item.importDate);
}
private getLocale(): string | undefined {
return (
this.translate.currentLang ||
this.translate.defaultLang ||
undefined
);
}
private translateText(
key: string,
params?: Record<string, string | number>
): string {
return this.translate.instant(key, params);
}
}
@@ -0,0 +1,190 @@
import {
GlobalFavoriteItem as DbGlobalFavoriteItem,
GlobalRecentlyAddedItem as DbGlobalRecentlyAddedItem,
GlobalRecentItem as DbGlobalRecentItem,
} from 'services';
import {
PortalAddedItem,
PlaylistMeta,
PortalActivityType,
PortalFavoriteItem,
PortalRecentItem,
extractStalkerItemId,
extractStalkerItemPoster,
extractStalkerItemTitle,
extractStalkerItemType,
normalizeStalkerDate,
} from 'shared-interfaces';
// ────── Type / label helpers ──────
export function normalizeActivityType(value: string): PortalActivityType {
return value === 'live' || value === 'series' ? value : 'movie';
}
export function getActivityTypeLabelKey(type: PortalActivityType): string {
if (type === 'live') return 'WORKSPACE.DASHBOARD.TYPE_LIVE';
if (type === 'series') return 'WORKSPACE.DASHBOARD.TYPE_SERIES';
return 'WORKSPACE.DASHBOARD.TYPE_MOVIE';
}
// ────── Xtream DB → ViewModel ──────
export function mapDbFavoriteToItem(
item: DbGlobalFavoriteItem
): PortalFavoriteItem {
return {
id: item.id,
title: item.title,
type: normalizeActivityType(item.type),
playlist_id: item.playlist_id,
playlist_name: item.playlist_name,
added_at: normalizeStalkerDate(item.added_at),
category_id: item.category_id,
xtream_id: item.xtream_id,
poster_url: item.poster_url,
backdrop_url: item.backdrop_url ?? undefined,
source: 'xtream',
};
}
export function mapDbRecentToItem(item: DbGlobalRecentItem): PortalRecentItem {
return {
id: item.id,
title: item.title,
type: normalizeActivityType(item.type),
playlist_id: item.playlist_id,
playlist_name: item.playlist_name,
viewed_at: normalizeStalkerDate(item.viewed_at),
category_id: item.category_id,
xtream_id: item.xtream_id,
poster_url: item.poster_url,
backdrop_url: item.backdrop_url ?? undefined,
source: 'xtream',
};
}
export function mapDbRecentlyAddedToItem(
item: DbGlobalRecentlyAddedItem
): PortalAddedItem {
return {
id: item.id,
title: item.title,
type: normalizeActivityType(item.type),
playlist_id: item.playlist_id,
playlist_name: item.playlist_name,
added_at: normalizeStalkerDate(item.added_at),
category_id: item.category_id,
xtream_id: item.xtream_id,
poster_url: item.poster_url,
source: 'xtream',
};
}
// ────── Stalker playlist → ViewModel ──────
export function buildStalkerRecentItems(
playlists: PlaylistMeta[],
defaultPlaylistName: string
): PortalRecentItem[] {
return playlists
.filter((playlist) => Boolean(playlist.macAddress))
.reduce<PortalRecentItem[]>((acc, playlist) => {
const recentItems = Array.isArray(
(playlist as { recentlyViewed?: unknown[] }).recentlyViewed
)
? ((playlist as { recentlyViewed?: unknown[] })
.recentlyViewed ?? [])
: [];
const mapped = recentItems.map((rawItem, index) => {
const item = (rawItem ?? {}) as Record<string, unknown>;
const id = extractStalkerItemId(item, playlist._id, index);
return {
id,
title: extractStalkerItemTitle(item),
type: extractStalkerItemType(item),
playlist_id: playlist._id,
playlist_name: playlist.title || defaultPlaylistName,
viewed_at: normalizeStalkerDate(item['added_at']),
category_id: String(item['category_id'] ?? ''),
xtream_id: id,
poster_url: extractStalkerItemPoster(item),
source: 'stalker' as const,
stalker_item: rawItem,
} as PortalRecentItem;
});
acc.push(...mapped);
return acc;
}, []);
}
export function buildStalkerFavoriteItems(
playlists: PlaylistMeta[],
defaultPlaylistName: string
): PortalFavoriteItem[] {
return playlists
.filter((playlist) => Boolean(playlist.macAddress))
.reduce<PortalFavoriteItem[]>((acc, playlist) => {
const favorites = Array.isArray(playlist.favorites)
? playlist.favorites
: [];
const mapped = favorites.map((item, index) => {
const raw = (item ?? {}) as Record<string, unknown>;
const id = extractStalkerItemId(raw, playlist._id, index);
return {
id,
title: extractStalkerItemTitle(raw),
type: extractStalkerItemType(raw),
playlist_id: playlist._id,
playlist_name: playlist.title || defaultPlaylistName,
added_at: normalizeStalkerDate(raw['added_at']),
category_id: String(raw['category_id'] ?? ''),
xtream_id: id,
poster_url: extractStalkerItemPoster(raw),
source: 'stalker' as const,
stalker_item: item,
} as PortalFavoriteItem;
});
acc.push(...mapped);
return acc;
}, []);
}
// ────── Timestamp helpers ──────
export function toDateTimestamp(value: unknown): number {
if (typeof value === 'number') {
if (!Number.isFinite(value) || value <= 0) return 0;
return value > 10_000_000_000 ? value : value * 1000;
}
if (typeof value === 'string') {
const trimmed = value.trim();
if (/^\d+$/.test(trimmed)) {
const numeric = Number(trimmed);
if (Number.isFinite(numeric) && numeric > 0) {
return numeric > 10_000_000_000 ? numeric : numeric * 1000;
}
return 0;
}
const parsed = Date.parse(normalizeStalkerDate(trimmed) || trimmed);
return Number.isNaN(parsed) ? 0 : parsed;
}
return 0;
}
export function toTimestamp(value?: string | number): number {
if (typeof value === 'number') return value;
if (typeof value === 'string') {
const parsed = Date.parse(normalizeStalkerDate(value) || value);
return Number.isNaN(parsed) ? 0 : parsed;
}
return 0;
}
@@ -0,0 +1,6 @@
import { setupZoneTestEnv } from 'jest-preset-angular/setup-env/zone';
setupZoneTestEnv({
errorOnUnknownElements: true,
errorOnUnknownProperties: true,
});
@@ -0,0 +1,31 @@
{
"extends": "../../../../tsconfig.base.json",
"compilerOptions": {
"isolatedModules": true,
"target": "es2022",
"moduleResolution": "bundler",
"strict": true,
"noImplicitOverride": true,
"noPropertyAccessFromIndexSignature": true,
"noImplicitReturns": true,
"noFallthroughCasesInSwitch": true,
"emitDecoratorMetadata": false,
"module": "preserve"
},
"angularCompilerOptions": {
"enableI18nLegacyMessageIdFormat": false,
"strictInjectionParameters": true,
"strictInputAccessModifiers": true,
"strictTemplates": true
},
"files": [],
"include": [],
"references": [
{
"path": "./tsconfig.lib.json"
},
{
"path": "./tsconfig.spec.json"
}
]
}
@@ -0,0 +1,12 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"outDir": "../../../../dist/out-tsc",
"declaration": true,
"declarationMap": true,
"inlineSources": true,
"types": []
},
"include": ["src/**/*.ts"],
"exclude": ["src/**/*.spec.ts", "src/**/*.test.ts"]
}
@@ -0,0 +1,16 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"outDir": "../../../../dist/out-tsc",
"module": "commonjs",
"moduleResolution": "node10",
"types": ["jest", "node"]
},
"include": [
"jest.config.ts",
"src/test-setup.ts",
"src/**/*.test.ts",
"src/**/*.spec.ts",
"src/**/*.d.ts"
]
}
@@ -0,0 +1,3 @@
# feature
This library was generated with [Nx](https://nx.dev).
@@ -0,0 +1,34 @@
import nx from '@nx/eslint-plugin';
import baseConfig from '../../../../eslint.config.mjs';
export default [
...baseConfig,
...nx.configs['flat/angular'],
...nx.configs['flat/angular-template'],
{
files: ['**/*.ts'],
rules: {
'@angular-eslint/directive-selector': [
'error',
{
type: 'attribute',
prefix: 'lib',
style: 'camelCase',
},
],
'@angular-eslint/component-selector': [
'error',
{
type: 'element',
prefix: 'lib',
style: 'kebab-case',
},
],
},
},
{
files: ['**/*.html'],
// Override or add rules here
rules: {},
},
];
@@ -0,0 +1,22 @@
export default {
displayName: 'workspace-dashboard-feature',
preset: '../../../../jest.preset.js',
setupFilesAfterEnv: ['<rootDir>/src/test-setup.ts'],
coverageDirectory: '../../../../coverage/libs/workspace/dashboard/feature',
transform: {
'^.+\\.(ts|mjs|js|html)$': [
'jest-preset-angular',
{
tsconfig: '<rootDir>/tsconfig.spec.json',
stringifyContentPathRegex: '\\.(html|svg)$',
},
],
},
transformIgnorePatterns: ['node_modules/(?!.*\\.mjs$)'],
snapshotSerializers: [
'jest-preset-angular/build/serializers/no-ng-attributes',
'jest-preset-angular/build/serializers/ng-snapshot',
'jest-preset-angular/build/serializers/html-comment',
],
moduleFileExtensions: ['ts', 'js', 'html'],
};
@@ -0,0 +1,21 @@
{
"name": "workspace-dashboard-feature",
"$schema": "../../../../node_modules/nx/schemas/project-schema.json",
"sourceRoot": "libs/workspace/dashboard/feature/src",
"prefix": "lib",
"projectType": "library",
"tags": ["scope:workspace", "domain:workspace", "type:feature"],
"targets": {
"test": {
"executor": "@nx/jest:jest",
"outputs": ["{workspaceRoot}/coverage/{projectRoot}"],
"options": {
"jestConfig": "libs/workspace/dashboard/feature/jest.config.ts",
"tsConfig": "libs/workspace/dashboard/feature/tsconfig.spec.json"
}
},
"lint": {
"executor": "@nx/eslint:lint"
}
}
}
@@ -0,0 +1,2 @@
export * from './lib/rails/workspace-dashboard-rails.component';
export * from './lib/rails/dashboard-rail.component';
@@ -0,0 +1,136 @@
<section class="rail" [attr.data-test-id]="testId()">
<header class="rail__header">
<h2 class="rail__title">
<span class="rail__label">{{ label() }}</span>
@if ((totalCount() ?? items().length) > 0) {
<span class="rail__count" aria-hidden="true">{{
totalCount() ?? items().length
}}</span>
}
</h2>
@if (seeAllLink(); as link) {
<a
class="rail__see-all"
[routerLink]="link"
[attr.data-test-id]="testId() ? testId() + '-manage-all' : null"
>
<span class="rail__see-all-label">{{
'WORKSPACE.DASHBOARD.MANAGE_ALL' | translate
}}</span>
<mat-icon>chevron_right</mat-icon>
</a>
}
</header>
<div class="rail__viewport">
<button
type="button"
class="rail__chev rail__chev--left"
[class.rail__chev--visible]="canScrollLeft()"
(click)="scrollBy(-1)"
aria-label="Scroll left"
tabindex="-1"
>
<mat-icon>chevron_left</mat-icon>
</button>
<div #track class="rail__track" (scroll)="onScroll()" role="list">
@for (card of items(); track card.id) {
<div
class="rail__card"
role="listitem"
[attr.title]="card.title"
[attr.data-test-id]="testId() ? testId() + '-card' : null"
>
<a
class="rail__card-link"
[routerLink]="card.link"
[state]="card.state"
>
<div
class="rail__art"
[style.aspect-ratio]="aspectRatio()"
>
@if (card.imageUrl && !failedImages()[card.id]) {
<img
[src]="card.imageUrl"
[alt]="card.title"
loading="lazy"
decoding="async"
(error)="markFailed(card.id)"
/>
} @else {
<div class="rail__art-fallback">
<mat-icon>{{ card.icon }}</mat-icon>
</div>
}
</div>
<div class="rail__meta">
<span class="rail__card-title">{{
card.title
}}</span>
@if (card.subtitle) {
<span class="rail__card-subtitle">{{
card.subtitle
}}</span>
}
</div>
</a>
@if (card.actions && card.actions.length > 0) {
<button
type="button"
mat-icon-button
class="rail__action-trigger"
[matMenuTriggerFor]="cardActionMenu"
[matMenuTriggerData]="{ card: card }"
[attr.aria-label]="'SHOW_MORE' | translate"
[attr.data-test-id]="
testId() ? testId() + '-card-actions' : null
"
(click)="stopActionEvent($event)"
>
<mat-icon>more_vert</mat-icon>
</button>
}
</div>
}
</div>
<mat-menu
#cardActionMenu="matMenu"
panelClass="dashboard-rail-action-menu"
xPosition="before"
>
<ng-template matMenuContent let-card="card">
@for (action of card.actions ?? []; track action.id) {
<button
mat-menu-item
type="button"
[disabled]="action.disabled"
[class.rail__menu-action--danger]="action.destructive"
[class.rail__menu-action--separated]="
action.separatorBefore
"
(click)="selectAction(card, action, $event)"
>
<mat-icon>{{ action.icon }}</mat-icon>
<span>{{ action.labelKey | translate }}</span>
</button>
}
</ng-template>
</mat-menu>
<button
type="button"
class="rail__chev rail__chev--right"
[class.rail__chev--visible]="canScrollRight()"
(click)="scrollBy(1)"
aria-label="Scroll right"
tabindex="-1"
>
<mat-icon>chevron_right</mat-icon>
</button>
</div>
</section>
@@ -0,0 +1,421 @@
:host {
display: block;
--rail-card-width: 172px;
--rail-gap: 14px;
--rail-radius: 12px;
}
.rail {
display: flex;
flex-direction: column;
gap: 12px;
}
.rail__header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 0 2px;
// Tighter height cap — keeps rail chapter markers compact
min-height: 24px;
}
.rail__title {
margin: 0;
display: inline-flex;
align-items: baseline;
gap: 8px;
font-size: 1.125rem; // 18px — matches Netflix rail-title scale
font-weight: 600;
line-height: 1.2;
letter-spacing: -0.012em;
color: var(--app-heading-color, #1a1d23);
}
// Muted count badge, tabular numerals for alignment, sits just after label.
.rail__count {
font-size: 0.78rem;
font-weight: 500;
line-height: 1;
color: var(--app-muted-color, #8891a4);
font-variant-numeric: tabular-nums;
// Tiny capsule to read as a "count chip", not a number-in-text
padding: 2px 8px;
border-radius: 999px;
background: color-mix(
in srgb,
var(--app-muted-color, #8891a4) 18%,
transparent
);
letter-spacing: 0;
}
.rail__see-all {
display: inline-flex;
align-items: center;
gap: 2px;
padding: 4px 8px 4px 10px;
border-radius: 999px;
font-size: 0.78rem;
font-weight: 500;
line-height: 1;
letter-spacing: 0.005em;
color: var(--app-muted-color, #8891a4);
text-decoration: none;
transition:
color 0.15s ease,
background-color 0.15s ease;
mat-icon {
font-size: 16px;
width: 16px;
height: 16px;
transition: transform 0.15s ease;
}
&:hover {
color: var(--app-selection-color, #2f7bff);
background: color-mix(
in srgb,
var(--app-selection-color, #2f7bff) 12%,
transparent
);
mat-icon {
transform: translateX(2px);
}
}
&:focus-visible {
outline: 2px solid var(--app-selection-color, #2f7bff);
outline-offset: 2px;
}
}
.rail__viewport {
position: relative;
min-width: 0;
// Soft edge gradients hint at horizontal scroll without any interaction.
// Anchored to the viewport so they appear/disappear in sync with the
// chevrons' `.rail__chev--visible` state.
&::before,
&::after {
content: '';
position: absolute;
top: 0;
bottom: 0;
width: 32px;
pointer-events: none;
z-index: 1;
opacity: 0;
transition: opacity 0.2s ease;
}
&::before {
left: 0;
background: linear-gradient(
to right,
var(--app-content-bg, #161a22) 0%,
transparent 100%
);
}
&::after {
right: 0;
background: linear-gradient(
to left,
var(--app-content-bg, #161a22) 0%,
transparent 100%
);
}
// Show the correct edge gradient when scroll is possible in that direction
&:has(.rail__chev--left.rail__chev--visible)::before {
opacity: 0.9;
}
&:has(.rail__chev--right.rail__chev--visible)::after {
opacity: 0.9;
}
&:hover .rail__chev--visible {
opacity: 1;
transform: translateY(-50%) scale(1);
}
}
.rail__track {
display: flex;
align-items: flex-start;
gap: var(--rail-gap);
overflow-x: auto;
overflow-y: hidden;
scroll-snap-type: x mandatory;
scroll-padding-inline-start: 4px;
scrollbar-width: none;
padding: 4px;
margin: -4px;
&::-webkit-scrollbar {
display: none;
}
}
.rail__card {
// Pin the card size so long titles never expand the flex item.
// `min-width: 0` is needed because flex items default to `min-width: auto`
// (their intrinsic content size), which would otherwise override the
// flex-basis when the title wraps.
flex: 0 0 var(--rail-card-width);
width: var(--rail-card-width);
max-width: var(--rail-card-width);
min-width: 0;
scroll-snap-align: start;
position: relative;
display: flex;
flex-direction: column;
color: inherit;
border-radius: var(--rail-radius);
transition:
transform 0.2s ease,
filter 0.2s ease;
&:hover {
transform: translateY(-2px);
.rail__art {
box-shadow:
0 12px 22px -12px rgba(0, 0, 0, 0.45),
0 0 0 1px var(--app-selection-border, rgba(47, 123, 255, 0.4));
}
.rail__card-title {
color: var(--app-selection-color, #2f7bff);
}
}
}
.rail__card-link {
display: flex;
flex-direction: column;
gap: 8px;
min-width: 0;
color: inherit;
text-decoration: none;
border-radius: inherit;
&:focus-visible {
outline: 2px solid var(--app-selection-color, #2f7bff);
outline-offset: 3px;
}
}
.rail__art {
position: relative;
overflow: hidden;
border-radius: var(--rail-radius);
background: var(--app-widget-bg, #1e2330);
border: 1px solid var(--app-widget-header-border, rgba(255, 255, 255, 0.05));
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.15);
transition: box-shadow 0.2s ease;
img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
}
.rail__art-fallback {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
background:
radial-gradient(
circle at 30% 20%,
var(--app-selection-surface, rgba(120, 173, 255, 0.18)) 0%,
transparent 55%
),
var(--app-widget-bg, #1e2330);
mat-icon {
font-size: 44px;
width: 44px;
height: 44px;
color: var(--app-selection-color, #78adff);
opacity: 0.55;
}
}
.rail__action-trigger {
position: absolute;
top: 8px;
right: 8px;
z-index: 3;
width: 30px;
height: 30px;
padding: 3px;
border-radius: 50%;
color: #fff;
background: transparent;
border: 0;
box-shadow: none;
opacity: 0.92;
transition:
opacity 0.15s ease,
transform 0.15s ease;
--mdc-icon-button-state-layer-size: 30px;
mat-icon {
font-size: 18px;
width: 18px;
height: 18px;
line-height: 18px;
}
&:hover,
&:focus-visible {
opacity: 1;
transform: translateY(-1px);
}
}
.rail__menu-action--separated {
border-top: 1px solid var(--app-separator);
margin-top: 4px;
}
.rail__menu-action--danger {
color: var(--mat-sys-error);
mat-icon {
color: var(--mat-sys-error);
}
}
.rail__meta {
display: flex;
flex-direction: column;
gap: 3px;
padding: 0 2px;
min-width: 0;
}
.rail__card-title {
font-size: 0.85rem;
font-weight: 600;
line-height: 1.3;
color: var(--app-heading-color, #1a1d23);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
letter-spacing: -0.005em;
transition: color 0.15s ease;
}
.rail__card-subtitle {
font-size: 0.72rem;
font-weight: 500;
line-height: 1.4;
color: var(--app-muted-color, #8891a4);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
letter-spacing: 0.005em;
}
// Circular chevron — streaming-app pattern (Netflix/Apple TV+). High-contrast
// dark glass works against any card color, including the muted fallback tiles
// which previously camouflaged the buttons.
.rail__chev {
position: absolute;
top: 50%;
transform: translateY(-50%) scale(0.96);
display: flex;
align-items: center;
justify-content: center;
width: 44px;
height: 44px;
padding: 0;
border: 1px solid rgba(255, 255, 255, 0.14);
border-radius: 50%;
background: rgba(0, 0, 0, 0.72);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
color: #fff;
cursor: pointer;
opacity: 0;
pointer-events: none;
transition:
opacity 0.18s ease,
background-color 0.18s ease,
transform 0.18s ease,
box-shadow 0.18s ease;
z-index: 2;
box-shadow:
0 4px 14px -4px rgba(0, 0, 0, 0.55),
0 0 0 0 rgba(255, 255, 255, 0); // slot for hover ring
mat-icon {
font-size: 24px;
width: 24px;
height: 24px;
line-height: 24px;
// Keep icon crisp over bright content
filter: drop-shadow(0 1px 2px rgba(0, 0, 0, 0.5));
}
// Peek at rest when scroll is possible — hints the affordance exists
// without requiring the user to hover first.
&--visible {
opacity: 0.5;
pointer-events: auto;
}
&:hover {
background: rgba(0, 0, 0, 0.88);
transform: translateY(-50%) scale(1.08);
box-shadow:
0 8px 22px -6px rgba(0, 0, 0, 0.65),
0 0 0 4px rgba(255, 255, 255, 0.06);
}
&:active {
transform: translateY(-50%) scale(0.96);
}
&:focus-visible {
outline: 2px solid var(--app-selection-color, #78adff);
outline-offset: 3px;
}
&--left {
left: 6px;
}
&--right {
right: 6px;
}
}
@media (hover: none) {
.rail__chev {
display: none;
}
.rail__viewport::before,
.rail__viewport::after {
display: none;
}
}
@media (max-width: 720px) {
:host {
--rail-card-width: 140px;
}
}
@@ -0,0 +1,166 @@
import {
AfterViewInit,
ChangeDetectionStrategy,
Component,
ElementRef,
OnDestroy,
effect,
input,
output,
signal,
viewChild,
} from '@angular/core';
import { MatButtonModule } from '@angular/material/button';
import { MatIcon } from '@angular/material/icon';
import { MatMenuModule } from '@angular/material/menu';
import { RouterLink } from '@angular/router';
import { TranslatePipe } from '@ngx-translate/core';
export interface DashboardRailAction {
id: string;
labelKey: string;
icon: string;
destructive?: boolean;
disabled?: boolean;
separatorBefore?: boolean;
}
export interface DashboardRailCard {
id: string;
title: string;
subtitle?: string;
imageUrl?: string;
icon: string;
link: string[];
state?: Record<string, unknown>;
actions?: DashboardRailAction[];
}
export interface DashboardRailActionSelection {
action: DashboardRailAction;
card: DashboardRailCard;
}
@Component({
selector: 'lib-dashboard-rail',
imports: [
MatButtonModule,
MatIcon,
MatMenuModule,
RouterLink,
TranslatePipe,
],
templateUrl: './dashboard-rail.component.html',
styleUrl: './dashboard-rail.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class DashboardRailComponent implements AfterViewInit, OnDestroy {
readonly label = input.required<string>();
readonly items = input.required<DashboardRailCard[]>();
readonly seeAllLink = input<string[] | null>(null);
readonly aspectRatio = input<string>('2 / 3');
readonly testId = input<string | null>(null);
readonly actionSelected = output<DashboardRailActionSelection>();
/**
* True total in the underlying dataset. Shown as a count badge next to
* the rail label. Falls back to `items().length` when not supplied.
*/
readonly totalCount = input<number | null>(null);
private readonly track =
viewChild.required<ElementRef<HTMLDivElement>>('track');
readonly canScrollLeft = signal(false);
readonly canScrollRight = signal(false);
readonly failedImages = signal<Record<string, true>>({});
private readonly viewReady = signal(false);
private resizeObserver?: ResizeObserver;
private resetFrameId: number | null = null;
private settleFrameId: number | null = null;
constructor() {
effect(() => {
this.items();
if (!this.viewReady()) return;
this.scheduleResetToStart();
});
}
ngAfterViewInit(): void {
this.viewReady.set(true);
this.updateScrollState();
this.resizeObserver = new ResizeObserver(() =>
this.updateScrollState()
);
this.resizeObserver.observe(this.track().nativeElement);
}
ngOnDestroy(): void {
this.resizeObserver?.disconnect();
this.cancelPendingReset();
}
onScroll(): void {
this.updateScrollState();
}
scrollBy(direction: 1 | -1): void {
const el = this.track().nativeElement;
el.scrollBy({
left: direction * el.clientWidth * 0.85,
behavior: 'smooth',
});
}
markFailed(id: string): void {
this.failedImages.update((state) =>
state[id] ? state : { ...state, [id]: true }
);
}
stopActionEvent(event: Event): void {
event.stopPropagation();
}
selectAction(
card: DashboardRailCard,
action: DashboardRailAction,
event: Event
): void {
event.stopPropagation();
this.actionSelected.emit({ card, action });
}
private updateScrollState(): void {
const el = this.track().nativeElement;
this.canScrollLeft.set(el.scrollLeft > 4);
this.canScrollRight.set(
el.scrollLeft + el.clientWidth < el.scrollWidth - 4
);
}
private scheduleResetToStart(): void {
this.cancelPendingReset();
this.resetFrameId = requestAnimationFrame(() => {
this.resetFrameId = null;
this.settleFrameId = requestAnimationFrame(() => {
this.settleFrameId = null;
const el = this.track().nativeElement;
el.scrollTo({ left: 0, behavior: 'auto' });
this.updateScrollState();
});
});
}
private cancelPendingReset(): void {
if (this.resetFrameId !== null) {
cancelAnimationFrame(this.resetFrameId);
this.resetFrameId = null;
}
if (this.settleFrameId !== null) {
cancelAnimationFrame(this.settleFrameId);
this.settleFrameId = null;
}
}
}
@@ -0,0 +1,160 @@
<section
class="rails-page"
[class.rails-page--empty]="ready() && !hasPlaylists()"
data-test-id="dashboard-page"
>
@if (!ready()) {
<!-- Precise ghosts that mirror the real hero + rails so the layout
doesn't reflow when data arrives. -->
<section
class="rails-page__skeleton-hero"
aria-hidden="true"
aria-busy="true"
>
<div class="rails-page__skeleton-hero-poster"></div>
<div class="rails-page__skeleton-hero-body">
<div class="rails-page__skeleton-hero-title"></div>
<div
class="rails-page__skeleton-hero-title rails-page__skeleton-hero-title--short"
></div>
<div class="rails-page__skeleton-hero-subtitle"></div>
<div class="rails-page__skeleton-hero-cta"></div>
</div>
</section>
@for (_ of skeletonRails; track $index) {
<section
class="rails-page__skeleton-rail"
aria-hidden="true"
aria-busy="true"
>
<div class="rails-page__skeleton-header"></div>
<div class="rails-page__skeleton-track">
@for (slot of skeletonSlots; track slot) {
<div class="rails-page__skeleton-card"></div>
}
</div>
</section>
}
} @else if (!hasPlaylists()) {
<app-empty-state
[type]="'welcome-dashboard'"
[showElectronOnlyValueProps]="isElectron"
(addPlaylistClicked)="onAddPlaylist($event)"
/>
} @else {
@if (hero(); as item) {
<a
class="hero"
data-test-id="dashboard-hero"
[class.hero--with-image]="
!!item.posterUrl || !!item.backdropUrl
"
[class.hero--has-backdrop]="item.hasBackdrop"
[class.hero--has-default-backdrop]="
item.backdropSource === 'fallback'
"
[routerLink]="item.link"
[state]="item.state ?? {}"
>
<span
class="hero__backdrop"
aria-hidden="true"
[style.backgroundImage]="
item.backdropUrl
? 'url(' + item.backdropUrl + ')'
: item.fallbackBackdropBackground
"
></span>
@if (item.backdropUrl; as backdropUrl) {
<img
class="hero__image-probe"
[src]="backdropUrl"
alt=""
aria-hidden="true"
loading="eager"
decoding="async"
(error)="markHeroImageFailed(backdropUrl)"
/>
}
<span class="hero__vignette" aria-hidden="true"></span>
<span class="hero__poster">
@if (item.posterUrl; as posterUrl) {
<img
[src]="posterUrl"
[alt]="item.title"
loading="eager"
decoding="async"
(error)="markHeroImageFailed(posterUrl)"
/>
} @else {
<span
class="hero__poster-fallback"
[style.backgroundImage]="
item.fallbackPosterBackground
"
>
<mat-icon>{{ item.icon }}</mat-icon>
</span>
}
</span>
<span class="hero__body">
<h1 class="hero__title">{{ item.title }}</h1>
<p class="hero__subtitle">{{ item.subtitle }}</p>
<span class="hero__cta">
<mat-icon>play_arrow</mat-icon>
<span>{{
'WORKSPACE.DASHBOARD.CONTINUE_WATCHING' | translate
}}</span>
</span>
</span>
</a>
}
@if (recentlyWatchedCards().length > 0) {
<lib-dashboard-rail
[label]="'WORKSPACE.DASHBOARD.RECENTLY_WATCHED' | translate"
[items]="recentlyWatchedCards()"
[totalCount]="data.globalRecentItems().length"
[seeAllLink]="['/workspace/global-recent']"
[testId]="'dashboard-recently-watched-rail'"
/>
}
@if (favoriteCards().length > 0) {
<lib-dashboard-rail
[label]="'WORKSPACE.DASHBOARD.GLOBAL_FAVORITES' | translate"
[items]="favoriteCards()"
[totalCount]="data.globalFavoriteItems().length"
[seeAllLink]="['/workspace/global-favorites']"
[testId]="'dashboard-global-favorites-rail'"
/>
}
@if (sourceCards().length > 0) {
<lib-dashboard-rail
[label]="'WORKSPACE.DASHBOARD.RECENT_SOURCES' | translate"
[items]="sourceCards()"
[totalCount]="data.playlists().length"
[seeAllLink]="['/workspace/sources']"
aspectRatio="16 / 9"
[testId]="'dashboard-recent-sources-rail'"
(actionSelected)="onSourceActionSelected($event)"
/>
}
@if (xtreamRecentlyAddedCards().length > 0) {
<lib-dashboard-rail
[label]="
'WORKSPACE.DASHBOARD.XTREAM_RECENTLY_ADDED' | translate
"
[items]="xtreamRecentlyAddedCards()"
[totalCount]="data.xtreamRecentlyAddedItems().length"
[testId]="'dashboard-xtream-recently-added-rail'"
/>
}
}
</section>
@@ -0,0 +1,463 @@
:host {
display: block;
padding: 24px 28px 40px;
box-sizing: border-box;
min-height: 100%;
}
:host(.rails-page-host--empty) {
padding: 0;
height: 100%;
min-height: 100%;
display: flex;
overflow: hidden;
}
.rails-page {
max-width: 1440px;
margin: 0 auto;
display: flex;
flex-direction: column;
gap: 32px;
min-width: 0;
}
.rails-page--empty {
flex: 1;
max-width: none;
margin: 0;
gap: 0;
min-height: 0;
height: 100%;
overflow: hidden;
}
// ── Hero ────────────────────────────────────────────────────────────────────
// Netflix/Apple TV-style hero: blurred poster backdrop + vignette + sharp 2:3
// poster card + text block. Mirrors content-hero fallback (no dedicated
// backdrop asset available, so we reuse the poster blurred + scaled).
.hero {
position: relative;
display: grid;
grid-template-columns: minmax(0, var(--hero-poster-width)) minmax(0, 1fr);
gap: 24px;
padding: 20px;
text-decoration: none;
color: inherit;
border-radius: 18px;
overflow: hidden;
isolation: isolate;
background: var(--app-widget-header-bg, #232840);
border: 1px solid var(--app-widget-header-border, rgba(255, 255, 255, 0.08));
box-shadow:
0 1px 2px rgba(0, 0, 0, 0.2),
0 12px 32px -18px var(--app-selection-glow, rgba(120, 173, 255, 0.4));
transition:
transform 0.2s ease,
box-shadow 0.2s ease;
--hero-poster-width: 160px;
&:hover {
transform: translateY(-2px);
box-shadow:
0 1px 2px rgba(0, 0, 0, 0.2),
0 20px 40px -20px
var(--app-selection-glow, rgba(120, 173, 255, 0.55));
}
// No-image fallback keeps the accent gradient so the hero is never flat.
&:not(.hero--with-image) {
background:
var(
--app-accent-gradient,
linear-gradient(
135deg,
rgba(99, 102, 241, 0.12) 0%,
rgba(168, 85, 247, 0.07) 100%
)
),
var(--app-widget-header-bg, #232840);
}
}
// Layer 0: backdrop. Two modes:
// (1) Real backdrop (`.hero--has-backdrop`): sharp wide image with no blur.
// (2) Blurred-poster fallback: heavy blur + scale so the poster's colours
// wash across the full panel without the portrait edges showing.
.hero__backdrop {
position: absolute;
inset: 0;
background-size: cover;
background-position: center 25%;
background-repeat: no-repeat;
z-index: 0;
pointer-events: none;
// Fallback: blurred poster.
filter: blur(40px) saturate(1.5);
transform: scale(1.5);
opacity: 0.9;
}
.hero--has-backdrop .hero__backdrop {
filter: none;
transform: none;
opacity: 1;
}
.hero--has-default-backdrop .hero__backdrop {
filter: none;
transform: none;
opacity: 0.95;
}
.hero__image-probe {
position: absolute;
width: 1px;
height: 1px;
clip-path: inset(50%);
opacity: 0;
pointer-events: none;
}
// Layer 1: vignette. Scrim behind the poster card (contrast) and on the
// right edge (text legibility). Middle stays mostly transparent so the
// backdrop colours show through. Darkens further when a sharp backdrop is
// present, since real photography has more focal variation than a blur wash.
.hero__vignette {
position: absolute;
inset: 0;
z-index: 1;
pointer-events: none;
background:
linear-gradient(
90deg,
color-mix(
in srgb,
var(--app-widget-header-bg, #232840) 55%,
transparent
)
0%,
color-mix(
in srgb,
var(--app-widget-header-bg, #232840) 10%,
transparent
)
45%,
color-mix(
in srgb,
var(--app-widget-header-bg, #232840) 65%,
transparent
)
100%
),
linear-gradient(
to bottom,
transparent 55%,
color-mix(
in srgb,
var(--app-widget-header-bg, #232840) 35%,
transparent
)
100%
);
}
.hero--has-backdrop .hero__vignette {
background:
linear-gradient(
90deg,
rgba(0, 0, 0, 0.55) 0%,
rgba(0, 0, 0, 0.2) 35%,
rgba(0, 0, 0, 0.78) 100%
),
linear-gradient(
to bottom,
rgba(0, 0, 0, 0) 40%,
rgba(0, 0, 0, 0.45) 100%
);
}
// Layer 2: sharp poster in native 2:3 aspect (no more 16:9 crop).
.hero__poster {
position: relative;
z-index: 2;
width: var(--hero-poster-width);
aspect-ratio: 2 / 3;
border-radius: 12px;
overflow: hidden;
background: var(--app-widget-bg, #1e2330);
border: 1px solid var(--app-widget-header-border, rgba(255, 255, 255, 0.1));
box-shadow: 0 8px 24px -10px rgba(0, 0, 0, 0.55);
align-self: center;
img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
}
.hero__poster-fallback {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
background: radial-gradient(
circle at 50% 40%,
var(--app-selection-surface, rgba(120, 173, 255, 0.2)) 0%,
transparent 60%
);
background-size: cover;
background-position: center;
mat-icon {
font-size: 56px;
width: 56px;
height: 56px;
color: var(--app-selection-color, #78adff);
opacity: 0.75;
}
}
.hero__body {
position: relative;
z-index: 2;
display: flex;
flex-direction: column;
justify-content: center;
gap: 6px;
min-width: 0;
}
.hero__title {
margin: 6px 0 4px;
font-size: 1.75rem;
font-weight: 600;
line-height: 1.15;
// Display-weight tracking: looser than -0.02em which crushes glyphs at
// this size (~28px). Matches Apple TV+ "Up Next" card scale.
letter-spacing: -0.015em;
color: var(--app-heading-color, #d8dce8);
overflow: hidden;
text-overflow: ellipsis;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
.hero--with-image & {
color: #fff;
text-shadow: 0 2px 12px rgba(0, 0, 0, 0.55);
}
}
.hero__subtitle {
margin: 0;
font-size: 0.88rem;
font-weight: 500;
line-height: 1.45;
letter-spacing: 0.002em;
color: var(--app-body-color, #8b93a8);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
.hero--with-image & {
color: rgba(255, 255, 255, 0.82);
text-shadow: 0 1px 6px rgba(0, 0, 0, 0.45);
}
}
.hero__cta {
margin-top: 16px;
align-self: flex-start;
display: inline-flex;
align-items: center;
gap: 6px;
padding: 10px 20px 10px 14px;
border-radius: 999px;
background: var(--app-selection-color, #78adff);
color: var(--app-selection-on-color, #071225);
font-size: 0.875rem;
font-weight: 600;
line-height: 1;
letter-spacing: 0.005em;
// Explicit min-height anchors the text baseline regardless of font-smoothing
min-height: 36px;
box-sizing: border-box;
box-shadow: 0 4px 12px -4px
var(--app-selection-glow, rgba(120, 173, 255, 0.5));
mat-icon {
font-size: 20px;
width: 20px;
height: 20px;
line-height: 20px;
}
}
// ── Loading skeleton ────────────────────────────────────────────────────────
// Geometry mirrors the real hero + rails so the layout doesn't shift when
// data arrives. All shimmer targets share the `%skeleton-shimmer` placeholder
// defined below.
%skeleton-shimmer {
background: linear-gradient(
90deg,
var(--app-widget-bg, #1e2330) 0%,
var(--app-widget-header-bg, #232840) 50%,
var(--app-widget-bg, #1e2330) 100%
);
background-size: 200% 100%;
animation: dashboard-skeleton-shimmer 1.6s linear infinite;
border: 1px solid var(--app-widget-header-border, rgba(255, 255, 255, 0.05));
}
@keyframes dashboard-skeleton-shimmer {
from {
background-position: 200% 0;
}
to {
background-position: -200% 0;
}
}
// ── Hero ghost ──────────────────────────────────────────────────────────────
.rails-page__skeleton-hero {
position: relative;
display: grid;
grid-template-columns: minmax(0, 160px) minmax(0, 1fr);
gap: 24px;
padding: 20px;
border-radius: 18px;
background: var(--app-widget-header-bg, #232840);
border: 1px solid var(--app-widget-header-border, rgba(255, 255, 255, 0.08));
}
.rails-page__skeleton-hero-poster {
@extend %skeleton-shimmer;
width: 160px;
aspect-ratio: 2 / 3;
border-radius: 12px;
align-self: center;
}
.rails-page__skeleton-hero-body {
display: flex;
flex-direction: column;
justify-content: center;
gap: 10px;
min-width: 0;
}
.rails-page__skeleton-hero-title {
@extend %skeleton-shimmer;
width: 60%;
height: 28px;
border-radius: 6px;
}
.rails-page__skeleton-hero-title--short {
width: 35%;
}
.rails-page__skeleton-hero-subtitle {
@extend %skeleton-shimmer;
width: 45%;
height: 14px;
border-radius: 4px;
margin-top: 2px;
}
.rails-page__skeleton-hero-cta {
@extend %skeleton-shimmer;
width: 160px;
height: 36px;
border-radius: 999px;
margin-top: 14px;
}
// ── Rail ghost ──────────────────────────────────────────────────────────────
.rails-page__skeleton-rail {
display: flex;
flex-direction: column;
gap: 12px;
padding: 12px 0 0;
}
.rails-page__skeleton-header {
@extend %skeleton-shimmer;
width: 180px;
height: 22px;
border-radius: 6px;
}
.rails-page__skeleton-track {
display: flex;
gap: 14px;
padding: 4px 0 16px;
overflow: hidden;
}
.rails-page__skeleton-card {
@extend %skeleton-shimmer;
// Matches the real .rail__card width (--rail-card-width: 172px) and
// .rail__art aspect-ratio 2/3, so when cards arrive the geometry is
// identical and there's no layout shift.
flex: 0 0 172px;
width: 172px;
aspect-ratio: 2 / 3;
border-radius: 12px;
}
@media (prefers-reduced-motion: reduce) {
.rails-page__skeleton-hero-poster,
.rails-page__skeleton-hero-title,
.rails-page__skeleton-hero-subtitle,
.rails-page__skeleton-hero-cta,
.rails-page__skeleton-header,
.rails-page__skeleton-card {
animation: none;
background-position: 0 0;
}
}
// ── Responsive ──────────────────────────────────────────────────────────────
@media (max-width: 720px) {
:host {
padding: 16px 16px 32px;
}
.rails-page {
gap: 24px;
}
.hero {
--hero-poster-width: 120px;
grid-template-columns: var(--hero-poster-width) minmax(0, 1fr);
gap: 14px;
padding: 14px;
}
// Skeleton hero mirrors real hero at this breakpoint.
.rails-page__skeleton-hero {
grid-template-columns: 120px minmax(0, 1fr);
gap: 14px;
padding: 14px;
}
.rails-page__skeleton-hero-poster {
width: 120px;
}
.rails-page__skeleton-card {
flex: 0 0 140px;
width: 140px;
}
.hero__title {
font-size: 1.3rem;
}
}
@@ -0,0 +1,104 @@
import type { PlaylistMeta } from 'shared-interfaces';
import {
buildDashboardSourceActions,
resolveDashboardHeroArtwork,
} from './workspace-dashboard-rails.component';
describe('buildDashboardSourceActions', () => {
const basePlaylist = {
_id: 'playlist-1',
title: 'Playlist',
importDate: '2026-04-24T08:00:00.000Z',
autoRefresh: false,
} as PlaylistMeta;
const actionIds = (playlist: PlaylistMeta, canRefresh: boolean) =>
buildDashboardSourceActions(playlist, canRefresh).map(
(action) => action.id
);
it('exposes refresh, info, and remove for refreshable M3U sources', () => {
const playlist = {
...basePlaylist,
url: 'https://example.com/playlist.m3u',
} as PlaylistMeta;
expect(actionIds(playlist, true)).toEqual([
'refresh',
'playlist-info',
'remove',
]);
});
it('exposes refresh, info, account, and remove for refreshable Xtream sources', () => {
const playlist = {
...basePlaylist,
serverUrl: 'https://provider.example.test',
username: 'demo',
password: 'secret',
} as PlaylistMeta;
expect(actionIds(playlist, true)).toEqual([
'refresh',
'playlist-info',
'account-info',
'remove',
]);
});
it('exposes info and remove for Stalker sources', () => {
const playlist = {
...basePlaylist,
macAddress: '00:1A:79:00:00:01',
portalUrl: 'https://stalker.example.test',
} as PlaylistMeta;
expect(actionIds(playlist, false)).toEqual(['playlist-info', 'remove']);
});
});
describe('resolveDashboardHeroArtwork', () => {
const item = {
title: 'Broken Hero',
posterUrl: 'https://images.example.test/poster.jpg',
backdropUrl: 'https://images.example.test/backdrop.jpg',
};
it('uses the explicit backdrop when both hero images are available', () => {
expect(resolveDashboardHeroArtwork(item, {})).toMatchObject({
backdropUrl: item.backdropUrl,
backdropSource: 'backdrop',
hasBackdrop: true,
posterUrl: item.posterUrl,
});
});
it('falls back from a failed backdrop to the poster cover', () => {
expect(
resolveDashboardHeroArtwork(item, {
[item.backdropUrl]: true,
})
).toMatchObject({
backdropUrl: item.posterUrl,
backdropSource: 'poster',
hasBackdrop: false,
posterUrl: item.posterUrl,
});
});
it('uses generated fallback artwork when backdrop and poster both fail', () => {
const artwork = resolveDashboardHeroArtwork(item, {
[item.backdropUrl]: true,
[item.posterUrl]: true,
});
expect(artwork).toMatchObject({
backdropSource: 'fallback',
hasBackdrop: false,
posterUrl: undefined,
backdropUrl: undefined,
});
expect(artwork.fallbackBackdropBackground).toContain('linear-gradient');
expect(artwork.fallbackPosterBackground).toContain('linear-gradient');
});
});
@@ -0,0 +1,484 @@
import {
ChangeDetectionStrategy,
Component,
computed,
effect,
inject,
signal,
} from '@angular/core';
import { MatButtonModule } from '@angular/material/button';
import { MatDialog } from '@angular/material/dialog';
import { MatIcon } from '@angular/material/icon';
import { MatSnackBar } from '@angular/material/snack-bar';
import { RouterLink } from '@angular/router';
import { Store } from '@ngrx/store';
import { TranslatePipe, TranslateService } from '@ngx-translate/core';
import {
EmptyStateComponent,
PlaylistInfoComponent,
} from '@iptvnator/playlist/shared/ui';
import { PlaylistRefreshActionService } from '@iptvnator/playlist/shared/util';
import {
WORKSPACE_SHELL_ACTIONS,
WorkspacePlaylistType,
} from '@iptvnator/workspace/shell/util';
import { DialogService } from 'components';
import { PlaylistActions } from 'm3u-state';
import { DatabaseService } from 'services';
import {
DashboardDataService,
DashboardFavoriteItem,
DashboardRecentlyAddedItem,
GlobalRecentItem,
} from 'workspace-dashboard-data-access';
import { DashboardRailComponent } from './dashboard-rail.component';
import type {
DashboardRailAction,
DashboardRailCard,
DashboardRailActionSelection,
} from './dashboard-rail.component';
import type { PlaylistMeta } from 'shared-interfaces';
// Cap dashboard rails at 20 items. Users get ~3× what's visible at once,
// the DOM stays cheap, and the "Manage all" link is one click away for the
// full list. Matches the single-rail density of Netflix / Apple TV+.
const RAIL_ITEM_LIMIT = 20;
// Six placeholder slots per skeleton rail — fills a typical viewport without
// taking the whole page. Mirrors the recently-added skeleton density.
const SKELETON_CARDS_PER_RAIL = [1, 2, 3, 4, 5, 6] as const;
const SKELETON_RAILS = [1, 2, 3] as const;
interface DashboardHeroModel {
readonly backdropUrl?: string;
readonly backdropSource: DashboardHeroBackdropSource;
readonly fallbackBackdropBackground: string;
readonly fallbackPosterBackground: string;
readonly hasBackdrop: boolean;
readonly icon: string;
readonly link: string[];
readonly posterUrl?: string;
readonly state?: Record<string, unknown>;
readonly subtitle: string;
readonly title: string;
}
export type DashboardHeroBackdropSource = 'backdrop' | 'poster' | 'fallback';
export interface DashboardHeroArtworkInput {
readonly backdropUrl?: string | null;
readonly posterUrl?: string | null;
readonly title: string;
}
export interface DashboardHeroArtwork {
readonly backdropUrl?: string;
readonly backdropSource: DashboardHeroBackdropSource;
readonly fallbackBackdropBackground: string;
readonly fallbackPosterBackground: string;
readonly hasBackdrop: boolean;
readonly posterUrl?: string;
}
export function resolveDashboardHeroArtwork(
item: DashboardHeroArtworkInput,
failedImages: Record<string, true>
): DashboardHeroArtwork {
const posterUrl =
item.posterUrl && !failedImages[item.posterUrl]
? item.posterUrl
: undefined;
const explicitBackdropUrl =
item.backdropUrl && !failedImages[item.backdropUrl]
? item.backdropUrl
: undefined;
const backdropUrl = explicitBackdropUrl ?? posterUrl;
const backdropSource: DashboardHeroBackdropSource = explicitBackdropUrl
? 'backdrop'
: posterUrl
? 'poster'
: 'fallback';
return {
backdropUrl,
backdropSource,
fallbackBackdropBackground: buildFallbackBackground(
item.title,
50,
15,
80,
5,
60
),
fallbackPosterBackground: buildFallbackBackground(
item.title,
40,
25,
50,
15,
40
),
hasBackdrop: backdropSource === 'backdrop',
posterUrl,
};
}
function buildFallbackBackground(
title: string,
saturationA: number,
lightnessA: number,
saturationB: number,
lightnessB: number,
hueOffset: number
): string {
const hue = calculateHue(title || 'placeholder');
const h2 = (hue + hueOffset) % 360;
return `linear-gradient(135deg, hsl(${hue}, ${saturationA}%, ${lightnessA}%) 0%, hsl(${h2}, ${saturationB}%, ${lightnessB}%) 100%)`;
}
function calculateHue(text: string): number {
let hash = 0;
for (let i = 0; i < text.length; i++) {
hash = text.charCodeAt(i) + ((hash << 5) - hash);
hash = hash & hash;
}
return Math.abs(hash) % 360;
}
export type DashboardSourceActionId =
| 'refresh'
| 'playlist-info'
| 'account-info'
| 'remove';
export function buildDashboardSourceActions(
playlist: PlaylistMeta,
canRefresh: boolean
): DashboardRailAction[] {
const actions: DashboardRailAction[] = [];
if (canRefresh) {
actions.push({
id: 'refresh',
icon: 'sync',
labelKey: playlist.serverUrl
? 'HOME.PLAYLISTS.REFRESH_XTREAM'
: 'HOME.PLAYLISTS.REFRESH',
});
}
actions.push({
id: 'playlist-info',
icon: 'edit',
labelKey: 'HOME.PLAYLISTS.SHOW_DETAILS',
});
if (isXtreamAccountPlaylist(playlist)) {
actions.push({
id: 'account-info',
icon: 'person',
labelKey: 'WORKSPACE.SHELL.ACCOUNT_INFO',
});
}
actions.push({
id: 'remove',
icon: 'delete',
labelKey: 'HOME.PLAYLISTS.REMOVE_DIALOG.TITLE',
destructive: true,
separatorBefore: true,
});
return actions;
}
function isXtreamAccountPlaylist(
playlist: PlaylistMeta
): playlist is PlaylistMeta & {
serverUrl: string;
username: string;
password: string;
} {
return Boolean(
playlist.serverUrl && playlist.username && playlist.password
);
}
@Component({
selector: 'lib-workspace-dashboard-rails',
imports: [
DashboardRailComponent,
EmptyStateComponent,
MatButtonModule,
MatIcon,
RouterLink,
TranslatePipe,
],
templateUrl: './workspace-dashboard-rails.component.html',
styleUrl: './workspace-dashboard-rails.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush,
host: {
'[class.rails-page-host--empty]': 'ready() && !hasPlaylists()',
},
})
export class WorkspaceDashboardRailsComponent {
readonly data = inject(DashboardDataService);
private readonly databaseService = inject(DatabaseService);
private readonly dialog = inject(MatDialog);
private readonly dialogService = inject(DialogService);
private readonly playlistRefreshAction = inject(
PlaylistRefreshActionService
);
private readonly snackBar = inject(MatSnackBar);
private readonly store = inject(Store);
private readonly translate = inject(TranslateService);
private readonly shellActions = inject(WORKSPACE_SHELL_ACTIONS);
readonly hasPlaylists = computed(() => this.data.playlists().length > 0);
readonly ready = this.data.dashboardReady;
readonly xtreamPlaylistCount = this.data.xtreamPlaylistCount;
readonly isElectron = !!window.electron;
readonly skeletonSlots = SKELETON_CARDS_PER_RAIL;
readonly skeletonRails = SKELETON_RAILS;
readonly failedHeroImages = signal<Record<string, true>>({});
readonly hero = computed<DashboardHeroModel | null>(() => {
const item = this.data.globalRecentItems()[0] ?? null;
if (!item) {
return null;
}
const artwork = resolveDashboardHeroArtwork(
{
backdropUrl: item.backdrop_url,
posterUrl: item.poster_url,
title: item.title,
},
this.failedHeroImages()
);
return {
...artwork,
icon: this.typeIcon(item.type),
link: this.data.getRecentItemLink(item),
state: this.data.getRecentItemNavigationState(item),
subtitle: this.buildHeroSubtitle(item),
title: item.title,
};
});
readonly recentlyWatchedCards = computed<DashboardRailCard[]>(() =>
this.data
.globalRecentItems()
.slice(0, RAIL_ITEM_LIMIT)
.map((item) => this.toRecentCard(item))
);
readonly favoriteCards = computed<DashboardRailCard[]>(() =>
this.data
.globalFavoriteItems()
.slice(0, RAIL_ITEM_LIMIT)
.map((item) => this.toFavoriteCard(item))
);
readonly xtreamRecentlyAddedCards = computed<DashboardRailCard[]>(() =>
this.data
.xtreamRecentlyAddedItems()
.slice(0, RAIL_ITEM_LIMIT)
.map((item) => this.toRecentlyAddedCard(item))
);
readonly sourceCards = computed<DashboardRailCard[]>(() =>
this.data.recentPlaylists().map((playlist) => ({
id: playlist._id,
title:
playlist.title ||
playlist.filename ||
this.t('WORKSPACE.DASHBOARD.UNTITLED_SOURCE'),
subtitle: this.data.getPlaylistProvider(playlist),
icon: playlist.serverUrl
? 'cloud'
: playlist.macAddress
? 'cast'
: 'folder_open',
link: this.data.getPlaylistLink(playlist),
actions: buildDashboardSourceActions(
playlist,
this.playlistRefreshAction.canRefresh(playlist)
),
}))
);
constructor() {
// Re-entering the dashboard should pick up any DB-backed recent/favorite
// changes made while viewing details, including newly backfilled
// backdrops that do not change recency ordering.
void this.data.reloadGlobalRecentItems();
void this.data.reloadGlobalFavorites();
// Refresh when Xtream playlist count changes so a newly added provider
// populates the rail without a manual dashboard reload.
effect(() => {
if (this.xtreamPlaylistCount() === 0) {
return;
}
void this.data.reloadXtreamRecentlyAddedItems(RAIL_ITEM_LIMIT);
});
}
onAddPlaylist(type?: WorkspacePlaylistType): void {
this.shellActions.openAddPlaylistDialog(type);
}
markHeroImageFailed(url: string): void {
this.failedHeroImages.update((state) =>
state[url] ? state : { ...state, [url]: true }
);
}
onSourceActionSelected(selection: DashboardRailActionSelection): void {
const playlist = this.data
.playlists()
.find((item) => item._id === selection.card.id);
if (!playlist) {
return;
}
switch (selection.action.id as DashboardSourceActionId) {
case 'refresh':
this.playlistRefreshAction.refresh(playlist);
break;
case 'playlist-info':
this.dialog.open(PlaylistInfoComponent, { data: playlist });
break;
case 'account-info':
this.openXtreamAccountInfo(playlist);
break;
case 'remove':
this.confirmRemovePlaylist(playlist);
break;
}
}
private buildHeroSubtitle(item: GlobalRecentItem): string {
const parts = [
item.playlist_name,
this.data.getRecentItemProviderLabel(item),
this.data.getRecentItemTypeLabel(item),
].filter((value): value is string => Boolean(value));
return parts.join(' · ');
}
private toRecentCard(item: GlobalRecentItem): DashboardRailCard {
return {
id: `recent-${item.id}-${item.playlist_id}-${item.viewed_at}`,
title: item.title,
subtitle: `${this.data.getRecentItemProviderLabel(item)} · ${this.data.getRecentItemTypeLabel(item)}`,
imageUrl: item.poster_url,
icon: this.typeIcon(item.type),
link: this.data.getRecentItemLink(item),
state: this.data.getRecentItemNavigationState(item),
};
}
private toFavoriteCard(item: DashboardFavoriteItem): DashboardRailCard {
return {
id: `fav-${item.id}-${item.playlist_id}-${item.added_at}`,
title: item.title,
subtitle: `${this.data.getFavoriteItemProviderLabel(item)} · ${this.data.getFavoriteItemTypeLabel(item)}`,
imageUrl: item.poster_url,
icon: this.typeIcon(item.type),
link: this.data.getGlobalFavoriteLink(item),
state: this.data.getGlobalFavoriteNavigationState(item),
};
}
private toRecentlyAddedCard(
item: DashboardRecentlyAddedItem
): DashboardRailCard {
const typeLabel = this.data.getRecentlyAddedItemTypeLabel(item);
const subtitleParts = [item.playlist_name, typeLabel].filter(
(value): value is string => Boolean(value)
);
return {
id: `added-${item.id}-${item.playlist_id}-${item.added_at}`,
title: item.title,
subtitle: subtitleParts.join(' · '),
imageUrl: item.poster_url,
icon: this.typeIcon(item.type),
link: this.data.getRecentlyAddedLink(item),
state: this.data.getRecentlyAddedNavigationState(item),
};
}
private typeIcon(type: 'live' | 'movie' | 'series'): string {
if (type === 'live') return 'live_tv';
if (type === 'movie') return 'movie';
return 'video_library';
}
private openXtreamAccountInfo(playlist: PlaylistMeta): void {
if (!isXtreamAccountPlaylist(playlist)) {
return;
}
const title =
playlist.title ||
playlist.filename ||
this.t('WORKSPACE.DASHBOARD.UNTITLED_SOURCE');
this.shellActions.openAccountInfo({
playlist: {
id: playlist._id,
name: title,
title,
serverUrl: playlist.serverUrl,
username: playlist.username,
password: playlist.password,
},
});
}
private confirmRemovePlaylist(playlist: PlaylistMeta): void {
this.dialogService.openConfirmDialog({
title: this.translate.instant('HOME.PLAYLISTS.REMOVE_DIALOG.TITLE'),
message: this.translate.instant(
'HOME.PLAYLISTS.REMOVE_DIALOG.MESSAGE'
),
onConfirm: () => {
void this.removePlaylist(playlist);
},
});
}
private async removePlaylist(playlist: PlaylistMeta): Promise<void> {
const operationId = playlist.serverUrl
? this.databaseService.createOperationId('playlist-delete')
: undefined;
const deleted = await this.databaseService.deletePlaylist(
playlist._id,
operationId ? { operationId } : undefined
);
if (!deleted) {
return;
}
this.store.dispatch(
PlaylistActions.removePlaylist({ playlistId: playlist._id })
);
this.snackBar.open(
this.translate.instant('HOME.PLAYLISTS.REMOVE_DIALOG.SUCCESS'),
undefined,
{ duration: 2000 }
);
}
private t(key: string): string {
return this.translate.instant(key);
}
}
@@ -0,0 +1,6 @@
import { setupZoneTestEnv } from 'jest-preset-angular/setup-env/zone';
setupZoneTestEnv({
errorOnUnknownElements: true,
errorOnUnknownProperties: true,
});
@@ -0,0 +1,28 @@
{
"extends": "../../../../tsconfig.base.json",
"compilerOptions": {
"isolatedModules": true,
"target": "es2022",
"moduleResolution": "bundler",
"strict": true,
"noImplicitOverride": true,
"noPropertyAccessFromIndexSignature": true,
"noImplicitReturns": true,
"noFallthroughCasesInSwitch": true,
"emitDecoratorMetadata": false,
"module": "preserve"
},
"angularCompilerOptions": {
"enableI18nLegacyMessageIdFormat": false,
"strictInjectionParameters": true,
"strictInputAccessModifiers": true,
"strictTemplates": true
},
"files": [],
"include": [],
"references": [
{
"path": "./tsconfig.lib.json"
}
]
}
@@ -0,0 +1,12 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"outDir": "../../../../dist/out-tsc",
"declaration": true,
"declarationMap": true,
"inlineSources": true,
"types": []
},
"include": ["src/**/*.ts"],
"exclude": ["src/**/*.spec.ts", "src/**/*.test.ts"]
}
@@ -0,0 +1,16 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"outDir": "../../../../dist/out-tsc",
"module": "commonjs",
"moduleResolution": "node10",
"types": ["jest", "node"]
},
"include": [
"jest.config.ts",
"src/test-setup.ts",
"src/**/*.test.ts",
"src/**/*.spec.ts",
"src/**/*.d.ts"
]
}
@@ -0,0 +1,22 @@
export default {
displayName: 'workspace-shell-feature',
preset: '../../../../jest.preset.js',
setupFilesAfterEnv: ['<rootDir>/src/test-setup.ts'],
coverageDirectory: '../../../../coverage/libs/workspace/shell/feature',
transform: {
'^.+\\.(ts|mjs|js|html)$': [
'jest-preset-angular',
{
tsconfig: '<rootDir>/tsconfig.spec.json',
stringifyContentPathRegex: '\\.(html|svg)$',
},
],
},
transformIgnorePatterns: ['node_modules/(?!.*\\.mjs$)'],
snapshotSerializers: [
'jest-preset-angular/build/serializers/no-ng-attributes',
'jest-preset-angular/build/serializers/ng-snapshot',
'jest-preset-angular/build/serializers/html-comment',
],
moduleFileExtensions: ['ts', 'js', 'html'],
};
+13
View File
@@ -0,0 +1,13 @@
{
"name": "workspace-shell-feature",
"$schema": "../../../../node_modules/nx/schemas/project-schema.json",
"sourceRoot": "libs/workspace/shell/feature/src",
"prefix": "lib",
"projectType": "library",
"tags": ["scope:workspace", "domain:workspace", "type:feature"],
"targets": {
"lint": {
"executor": "@nx/eslint:lint"
}
}
}
@@ -0,0 +1,4 @@
export * from './lib/global-favorites/global-favorites-list.component';
export * from './lib/global-favorites/unified-favorite-channel.interface';
export * from './lib/workspace-shell/workspace-shell.component';
export * from './lib/workspace-sources/workspace-sources.component';
@@ -0,0 +1,59 @@
<div class="search-panel">
<mat-form-field class="search-bar">
<mat-icon matPrefix>search</mat-icon>
<input
matInput
type="search"
[placeholder]="
'WORKSPACE.GLOBAL_FAVORITES.SEARCH_PLACEHOLDER' | translate
"
[value]="searchTerm()"
(input)="searchTerm.set($any($event.target).value)"
/>
</mat-form-field>
</div>
<div class="nav-list" cdkDropList (cdkDropListDropped)="onDrop($event)">
@if (enrichedChannels().length === 0) {
<div class="empty-state">
<mat-icon class="empty-icon">star_border</mat-icon>
@if (searchTerm()) {
<p class="empty-title">
{{ 'WORKSPACE.GLOBAL_FAVORITES.NO_RESULTS' | translate }}
</p>
<p class="empty-hint">
{{
'WORKSPACE.GLOBAL_FAVORITES.NO_RESULTS_HINT'
| translate
}}
</p>
} @else {
<p class="empty-title">
{{
'WORKSPACE.GLOBAL_FAVORITES.EMPTY_TITLE' | translate
}}
</p>
<p class="empty-hint">
{{ 'WORKSPACE.GLOBAL_FAVORITES.EMPTY_HINT' | translate }}
</p>
}
</div>
}
@for (ch of enrichedChannels(); track trackByUid($index, ch)) {
<app-channel-list-item
[isDraggable]="!searchTerm()"
[logo]="ch.logo"
[name]="ch.name"
[selected]="activeUid() === ch.uid"
[showEpg]="true"
[epgProgram]="ch.currentEpgProgram"
[progressPercentage]="ch.progressPercentage"
[showFavoriteButton]="true"
[isFavorite]="true"
[showProgramInfoButton]="true"
(clicked)="onChannelClick(ch)"
(favoriteToggled)="onFavoriteToggled(ch)"
/>
}
</div>
@@ -0,0 +1,79 @@
@use '@angular/material' as mat;
:host {
display: flex;
flex-direction: column;
height: 100%;
min-height: 0;
overflow: hidden;
}
// ─── Search ────────────────────────────────────────────────────────────────────
.search-panel {
margin-top: 10px;
display: flex;
justify-content: center;
margin-bottom: -10px;
flex-shrink: 0;
}
.search-bar {
width: 90%;
@include mat.form-field-density(-5);
}
:host ::ng-deep .search-bar div {
border-radius: 20px;
}
:host ::ng-deep .search-bar div[matformfieldlineripple],
:host ::ng-deep .search-bar .mat-mdc-form-field-subscript-wrapper {
display: none;
}
// ─── List ──────────────────────────────────────────────────────────────────────
.nav-list {
flex: 1;
overflow-y: auto;
overflow-x: hidden;
padding: 8px 8px 12px;
display: flex;
flex-direction: column;
gap: 2px;
}
// ─── Empty state ───────────────────────────────────────────────────────────────
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 48px 24px;
text-align: center;
color: var(--mat-sys-on-surface-variant);
.empty-icon {
font-size: 48px;
height: 48px;
width: 48px;
margin-bottom: 16px;
opacity: 0.5;
}
.empty-title {
font-size: 1.05rem;
font-weight: 520;
letter-spacing: -0.01em;
margin: 0 0 8px;
}
.empty-hint {
font-size: 0.85rem;
opacity: 0.65;
margin: 0;
max-width: 260px;
}
}
@@ -0,0 +1,4 @@
export {
EnrichedUnifiedFavorite,
GlobalFavoritesListComponent,
} from '@iptvnator/portal/shared/ui';
@@ -0,0 +1,367 @@
/**
* GlobalFavoritesService
*
* Aggregates live TV favorites from all three playlist source types:
* - M3U – channels whose URL/id is listed in playlist.favorites[]
* - Xtream – rows in the `favorites` DB table where content.type = 'live'
* - Stalker – items in playlist.favorites[] that have a `cmd` (live channels)
* and do NOT have movie_id / series_id
*
* Also handles reordering: persists position for Xtream rows via IPC.
* For M3U and Stalker the list order is managed in the app_state table
* as a JSON array of UIDs (keyed by GLOBAL_FAVORITES_ORDER_KEY).
*/
import { inject, Injectable } from '@angular/core';
import { Store } from '@ngrx/store';
import { TranslateService } from '@ngx-translate/core';
import { selectAllPlaylistsMeta } from 'm3u-state';
import { firstValueFrom, map } from 'rxjs';
import { DatabaseService, PlaylistsService } from 'services';
import {
Channel,
Playlist,
PlaylistMeta,
StalkerPortalItem,
} from 'shared-interfaces';
import {
buildFavoriteUid,
UnifiedFavoriteChannel,
} from '@iptvnator/portal/shared/util';
const GLOBAL_FAVORITES_ORDER_KEY = 'global-favorites-channel-order-v1';
interface XtreamGlobalFavoriteRow {
readonly added_at?: string | null;
readonly id: number;
readonly playlist_id: string;
readonly playlist_name: string;
readonly position?: number | null;
readonly poster_url?: string | null;
readonly title: string;
readonly xtream_id: number;
}
type PlaylistWithChannels = Playlist & {
readonly playlist?: {
readonly items?: Channel[];
};
};
function isStalkerFavoriteItem(
value: string | StalkerPortalItem
): value is StalkerPortalItem {
return typeof value !== 'string';
}
@Injectable({ providedIn: 'root' })
export class GlobalFavoritesService {
private readonly store = inject(Store);
private readonly dbService = inject(DatabaseService);
private readonly playlistsService = inject(PlaylistsService);
private readonly translate = inject(TranslateService);
/**
* Fetch and merge live TV favorites from all source types.
* The returned list is sorted by persisted position first,
* then by addedAt descending for items without a position.
*/
async getUnifiedLiveFavorites(): Promise<UnifiedFavoriteChannel[]> {
const [m3uItems, xtreamItems, stalkerItems, savedOrder] =
await Promise.all([
this.getM3uFavorites(),
this.getXtreamLiveFavorites(),
this.getStalkerLiveFavorites(),
this.getSavedOrder(),
]);
const all = [...m3uItems, ...xtreamItems, ...stalkerItems];
return this.applyOrder(all, savedOrder);
}
/**
* Remove a channel from its source playlist's favorites.
* Works for all three source types (M3U, Xtream, Stalker).
*/
async removeFavorite(channel: UnifiedFavoriteChannel): Promise<void> {
switch (channel.sourceType) {
case 'm3u': {
const playlist = await firstValueFrom(
this.playlistsService.getPlaylistById(channel.playlistId)
);
const currentFavs = (playlist.favorites as string[]) ?? [];
const filtered = currentFavs.filter(
(f) => f !== channel.streamUrl
);
await firstValueFrom(
this.playlistsService.setFavorites(
channel.playlistId,
filtered
)
);
break;
}
case 'xtream': {
if (window.electron && channel.contentId != null) {
await window.electron.dbRemoveFavorite(
channel.contentId,
channel.playlistId
);
}
break;
}
case 'stalker': {
const sourceItemId = channel.uid.split('::')[2];
await firstValueFrom(
this.playlistsService.removeFromPortalFavorites(
channel.playlistId,
sourceItemId
)
);
break;
}
}
}
/**
* Persist the new display order.
* For Xtream items update the DB position column.
* For all items, persist the UID order in app_state.
*/
async reorder(channels: UnifiedFavoriteChannel[]): Promise<void> {
const uidOrder = channels.map((ch) => ch.uid);
// Persist xtream positions to DB
const xtreamUpdates = channels
.filter(
(
ch
): ch is UnifiedFavoriteChannel & { contentId: number } =>
ch.sourceType === 'xtream' && ch.contentId != null
)
.map((ch, index) => ({
content_id: ch.contentId,
position: index,
}));
await Promise.all([
xtreamUpdates.length > 0 && window.electron
? window.electron.dbReorderGlobalFavorites(xtreamUpdates)
: Promise.resolve(),
this.saveOrder(uidOrder),
]);
}
// ─── Private helpers ───────────────────────────────────────────
private async getM3uFavorites(): Promise<UnifiedFavoriteChannel[]> {
const allMeta = await firstValueFrom(
this.store
.select(selectAllPlaylistsMeta)
.pipe(map((metas) => metas as PlaylistMeta[]))
);
const m3uPlaylists = allMeta.filter(
(p) => p._id && !p.serverUrl && !p.macAddress
);
const results: UnifiedFavoriteChannel[] = [];
for (const meta of m3uPlaylists) {
if (!meta.favorites?.length) continue;
const favoriteIds = new Set(
(meta.favorites as string[]).map(String)
);
// Load full playlist to access channel items
let playlist: PlaylistWithChannels | undefined;
try {
playlist = (await firstValueFrom(
this.playlistsService.getPlaylistById(meta._id)
)) as PlaylistWithChannels | undefined;
} catch {
continue;
}
const items = playlist?.playlist?.items ?? [];
for (const ch of items) {
if (favoriteIds.has(ch.id) || favoriteIds.has(ch.url)) {
const sourceItemId = ch.url || ch.id;
results.push({
uid: buildFavoriteUid('m3u', meta._id, sourceItemId),
name: ch.name,
logo: ch.tvg?.logo ?? null,
sourceType: 'm3u',
playlistId: meta._id,
playlistName: meta.title || meta.filename || 'M3U',
streamUrl: ch.url,
tvgId: ch.tvg?.id || ch.tvg?.name || ch.name,
addedAt: new Date(0).toISOString(),
position: 0,
});
}
}
}
return results;
}
private async getXtreamLiveFavorites(): Promise<UnifiedFavoriteChannel[]> {
if (!window.electron) return [];
let items: XtreamGlobalFavoriteRow[] = [];
try {
items =
(await this.dbService.getGlobalFavorites()) as XtreamGlobalFavoriteRow[];
} catch {
return [];
}
// getGlobalFavorites now only returns type='live'
return items.map((item) => ({
uid: buildFavoriteUid('xtream', item.playlist_id, item.xtream_id),
name: item.title,
logo: item.poster_url ?? null,
sourceType: 'xtream' as const,
playlistId: item.playlist_id,
playlistName: item.playlist_name,
xtreamId: item.xtream_id,
tvgId: String(item.xtream_id),
addedAt: item.added_at ?? new Date(0).toISOString(),
position: item.position ?? 0,
contentId: item.id,
}));
}
private async getStalkerLiveFavorites(): Promise<UnifiedFavoriteChannel[]> {
const allMeta = await firstValueFrom(
this.store
.select(selectAllPlaylistsMeta)
.pipe(map((metas) => metas as PlaylistMeta[]))
);
const stalkerPlaylists = allMeta.filter((p) => p._id && p.macAddress);
const results: UnifiedFavoriteChannel[] = [];
for (const meta of stalkerPlaylists) {
if (!meta.favorites?.length) continue;
// Fetch full playlist to get credentials
let playlist: Playlist | undefined;
try {
playlist = (await firstValueFrom(
this.playlistsService.getPlaylistById(meta._id)
)) as Playlist | undefined;
} catch {
continue;
}
const favorites = Array.isArray(playlist?.favorites)
? playlist.favorites.filter(isStalkerFavoriteItem)
: [];
for (const fav of favorites) {
// Live channels: have a cmd, no movie_id / series_id
const isLive =
fav.cmd &&
!fav.movie_id &&
!fav.series_id &&
(fav.stream_type === 'live' ||
fav.category_id === 'itv' ||
(!fav.stream_type && !fav.series_id && !fav.movie_id));
if (!isLive) continue;
const streamId = fav.stream_id ?? fav.id;
results.push({
uid: buildFavoriteUid('stalker', meta._id, streamId),
name:
fav.o_name ||
fav.name ||
this.translate.instant(
'WORKSPACE.GLOBAL_FAVORITES.UNKNOWN_CHANNEL'
),
logo: fav.logo ?? fav.cover ?? null,
sourceType: 'stalker' as const,
playlistId: meta._id,
playlistName:
meta.title ||
meta.filename ||
this.translate.instant(
'WORKSPACE.DASHBOARD.STALKER_PORTAL'
),
stalkerCmd: fav.cmd,
stalkerPortalUrl: playlist.portalUrl ?? playlist.url,
stalkerMacAddress: playlist.macAddress,
addedAt: fav.added_at
? new Date(fav.added_at).toISOString()
: new Date(0).toISOString(),
position: 0,
});
}
}
return results;
}
private async getSavedOrder(): Promise<string[]> {
if (!window.electron?.dbGetAppState) return [];
try {
const raw = await window.electron.dbGetAppState(
GLOBAL_FAVORITES_ORDER_KEY
);
if (!raw) return [];
return JSON.parse(raw) as string[];
} catch {
return [];
}
}
private async saveOrder(uidOrder: string[]): Promise<void> {
if (!window.electron?.dbSetAppState) return;
try {
await window.electron.dbSetAppState(
GLOBAL_FAVORITES_ORDER_KEY,
JSON.stringify(uidOrder)
);
} catch {
// ignore
}
}
private applyOrder(
channels: UnifiedFavoriteChannel[],
savedOrder: string[]
): UnifiedFavoriteChannel[] {
if (!savedOrder.length) {
// Default: sort by addedAt descending, xtream position asc
return channels.slice().sort((a, b) => {
if (a.position !== b.position) return a.position - b.position;
return (
new Date(b.addedAt).getTime() -
new Date(a.addedAt).getTime()
);
});
}
const orderMap = new Map(savedOrder.map((uid, i) => [uid, i]));
const ordered: UnifiedFavoriteChannel[] = [];
const unordered: UnifiedFavoriteChannel[] = [];
for (const ch of channels) {
const position = orderMap.get(ch.uid);
if (position != null) {
ordered.push({ ...ch, position });
} else {
unordered.push(ch);
}
}
ordered.sort((a, b) => a.position - b.position);
unordered.sort(
(a, b) =>
new Date(b.addedAt).getTime() - new Date(a.addedAt).getTime()
);
return [...ordered, ...unordered];
}
}
@@ -0,0 +1,5 @@
export {
buildFavoriteUid,
FavoriteSourceType,
UnifiedFavoriteChannel,
} from '@iptvnator/portal/shared/util';
@@ -0,0 +1,2 @@
export * from './playlist-drop-overlay.component';
export * from './playlist-drop-zone.directive';
@@ -0,0 +1,31 @@
@if (isVisible()) {
<div class="drop-overlay__backdrop" aria-hidden="true"></div>
<div
class="drop-overlay__card"
[class.is-rejected]="isRejected()"
role="status"
aria-live="polite"
>
<div class="drop-overlay__icon-wrap">
<mat-icon class="drop-overlay__icon">
{{ isRejected() ? 'error_outline' : 'file_download' }}
</mat-icon>
</div>
<h2 class="drop-overlay__title">
{{
(isRejected()
? rejectionKey()
: 'WORKSPACE.SHELL.DROP_OVERLAY_TITLE'
) | translate
}}
</h2>
<p class="drop-overlay__body">
{{
(isRejected()
? 'WORKSPACE.SHELL.DROP_OVERLAY_REJECTED_BODY'
: 'WORKSPACE.SHELL.DROP_OVERLAY_BODY'
) | translate
}}
</p>
</div>
}
@@ -0,0 +1,149 @@
:host {
position: fixed;
inset: 0;
z-index: 9999;
display: none;
pointer-events: none;
align-items: center;
justify-content: center;
padding: 24px;
}
:host.is-visible {
display: flex;
animation: drop-overlay-fade 0.15s ease-out;
}
.drop-overlay__backdrop {
position: absolute;
inset: 0;
background: rgba(2, 6, 23, 0.6);
backdrop-filter: blur(8px);
-webkit-backdrop-filter: blur(8px);
}
.drop-overlay__card {
position: relative;
display: flex;
flex-direction: column;
align-items: center;
text-align: center;
gap: 12px;
padding: clamp(28px, 5vh, 48px) clamp(28px, 5vw, 56px);
min-width: 320px;
max-width: min(520px, 88vw);
background: linear-gradient(
145deg,
rgba(96, 165, 250, 0.12),
rgba(37, 99, 235, 0.08)
);
border: 2px dashed rgba(96, 165, 250, 0.55);
border-radius: 18px;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.45);
color: #f1f5f9;
animation: drop-overlay-scale 0.18s ease-out;
&.is-rejected {
border-color: rgba(248, 113, 113, 0.55);
background: linear-gradient(
145deg,
rgba(248, 113, 113, 0.12),
rgba(220, 38, 38, 0.08)
);
.drop-overlay__icon-wrap {
background: linear-gradient(135deg, #f87171, #dc2626);
box-shadow: 0 8px 22px rgba(220, 38, 38, 0.4);
}
}
}
.drop-overlay__icon-wrap {
display: flex;
align-items: center;
justify-content: center;
width: 64px;
height: 64px;
border-radius: 18px;
background: linear-gradient(135deg, #3b82f6, #2563eb);
box-shadow: 0 8px 22px rgba(59, 130, 246, 0.45);
margin-bottom: 4px;
}
.drop-overlay__icon {
font-size: 36px;
width: 36px;
height: 36px;
color: white;
}
.drop-overlay__title {
font-family: Roboto, 'Helvetica Neue', sans-serif;
font-weight: 600;
font-size: 1.25rem;
margin: 0;
letter-spacing: -0.01em;
}
.drop-overlay__body {
font-family: Roboto, 'Helvetica Neue', sans-serif;
font-size: 0.9rem;
margin: 0;
opacity: 0.78;
line-height: 1.45;
}
@keyframes drop-overlay-fade {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@keyframes drop-overlay-scale {
from {
opacity: 0;
transform: translateY(6px) scale(0.97);
}
to {
opacity: 1;
transform: translateY(0) scale(1);
}
}
@media (prefers-reduced-motion: reduce) {
:host.is-visible {
animation: drop-overlay-fade 0.1s ease-out;
}
.drop-overlay__card {
animation: drop-overlay-fade 0.1s ease-out;
}
}
@media (prefers-color-scheme: light) {
.drop-overlay__backdrop {
background: rgba(15, 23, 42, 0.45);
}
.drop-overlay__card {
background: linear-gradient(
145deg,
rgba(255, 255, 255, 0.96),
rgba(241, 245, 249, 0.96)
);
color: #0f172a;
border-color: rgba(37, 99, 235, 0.55);
&.is-rejected {
background: linear-gradient(
145deg,
rgba(254, 226, 226, 0.96),
rgba(252, 165, 165, 0.92)
);
border-color: rgba(220, 38, 38, 0.6);
}
}
}
@@ -0,0 +1,40 @@
import { ChangeDetectionStrategy, Component, input } from '@angular/core';
import { MatIcon } from '@angular/material/icon';
import { TranslatePipe } from '@ngx-translate/core';
export type PlaylistDropOverlayState =
| { kind: 'idle' }
| { kind: 'dragging' }
| { kind: 'rejected'; reason: 'unsupported' | 'empty' | 'read-error' };
@Component({
selector: 'app-playlist-drop-overlay',
templateUrl: './playlist-drop-overlay.component.html',
styleUrl: './playlist-drop-overlay.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [MatIcon, TranslatePipe],
host: {
'[class.is-visible]': 'isVisible()',
'[attr.aria-hidden]': '!isVisible()',
},
})
export class PlaylistDropOverlayComponent {
readonly state = input<PlaylistDropOverlayState>({ kind: 'idle' });
readonly isVisible = (): boolean => this.state().kind !== 'idle';
readonly isRejected = (): boolean => this.state().kind === 'rejected';
readonly rejectionKey = (): string => {
const current = this.state();
if (current.kind !== 'rejected') return '';
switch (current.reason) {
case 'unsupported':
return 'WORKSPACE.SHELL.DROP_OVERLAY_REJECTED_UNSUPPORTED';
case 'empty':
return 'WORKSPACE.SHELL.DROP_OVERLAY_REJECTED_EMPTY';
case 'read-error':
return 'WORKSPACE.SHELL.DROP_OVERLAY_REJECTED_READ_ERROR';
}
};
}
@@ -0,0 +1,134 @@
import {
DestroyRef,
Directive,
ElementRef,
HostListener,
OnInit,
inject,
signal,
} from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { MatSnackBar } from '@angular/material/snack-bar';
import { TranslateService } from '@ngx-translate/core';
import { PlaylistFileImportService } from '@iptvnator/playlist/shared/util';
import { fromEvent, merge } from 'rxjs';
import type { PlaylistDropOverlayState } from './playlist-drop-overlay.component';
const REJECTED_DISMISS_MS = 1800;
@Directive({
selector: '[appPlaylistDropZone]',
exportAs: 'playlistDropZone',
})
export class PlaylistDropZoneDirective implements OnInit {
private readonly host = inject(ElementRef<HTMLElement>);
private readonly destroyRef = inject(DestroyRef);
private readonly importService = inject(PlaylistFileImportService);
private readonly snackBar = inject(MatSnackBar);
private readonly translate = inject(TranslateService);
private dragDepth = 0;
private rejectionTimer: ReturnType<typeof setTimeout> | null = null;
readonly overlayState = signal<PlaylistDropOverlayState>({ kind: 'idle' });
ngOnInit(): void {
merge(
fromEvent<KeyboardEvent>(window, 'keydown'),
fromEvent(window, 'blur')
)
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe((event) => {
if (event instanceof KeyboardEvent && event.key !== 'Escape') {
return;
}
this.resetState();
});
}
@HostListener('dragenter', ['$event'])
onDragEnter(event: DragEvent): void {
if (!this.isFileDrag(event)) return;
event.preventDefault();
this.dragDepth += 1;
if (this.overlayState().kind === 'rejected') return;
this.overlayState.set({ kind: 'dragging' });
}
@HostListener('dragover', ['$event'])
onDragOver(event: DragEvent): void {
if (!this.isFileDrag(event)) return;
event.preventDefault();
if (event.dataTransfer) {
event.dataTransfer.dropEffect = 'copy';
}
}
@HostListener('dragleave', ['$event'])
onDragLeave(event: DragEvent): void {
if (!this.isFileDrag(event)) return;
this.dragDepth = Math.max(0, this.dragDepth - 1);
if (this.dragDepth === 0 && this.overlayState().kind === 'dragging') {
this.overlayState.set({ kind: 'idle' });
}
}
@HostListener('drop', ['$event'])
async onDrop(event: DragEvent): Promise<void> {
if (!this.isFileDrag(event)) return;
event.preventDefault();
this.dragDepth = 0;
const file = event.dataTransfer?.files?.[0];
if (!file) {
this.overlayState.set({ kind: 'idle' });
return;
}
const result = await this.importService.importFile(file);
if (result.ok === true) {
this.overlayState.set({ kind: 'idle' });
this.snackBar.open(
this.translate.instant(
'WORKSPACE.SHELL.DROP_IMPORT_SUCCESS',
{ title: result.title }
),
undefined,
{ duration: 3000 }
);
return;
}
this.flashRejection(result.reason);
}
private isFileDrag(event: DragEvent): boolean {
const types = event.dataTransfer?.types;
if (!types) return false;
return Array.from(types).includes('Files');
}
private flashRejection(
reason: 'unsupported' | 'empty' | 'read-error'
): void {
this.overlayState.set({ kind: 'rejected', reason });
this.clearRejectionTimer();
this.rejectionTimer = setTimeout(() => {
this.overlayState.set({ kind: 'idle' });
this.rejectionTimer = null;
}, REJECTED_DISMISS_MS);
}
private resetState(): void {
this.dragDepth = 0;
this.clearRejectionTimer();
this.overlayState.set({ kind: 'idle' });
}
private clearRejectionTimer(): void {
if (this.rejectionTimer !== null) {
clearTimeout(this.rejectionTimer);
this.rejectionTimer = null;
}
}
}
@@ -0,0 +1,61 @@
<div class="palette-shell">
<label class="palette-search">
<mat-icon class="palette-search__icon">search</mat-icon>
<input
#queryInput
type="search"
spellcheck="false"
[value]="query()"
(input)="onQueryInput($event)"
(keydown)="onInputKeydown($event)"
[placeholder]="
'WORKSPACE.COMMAND_PALETTE.SEARCH_PLACEHOLDER' | translate
"
[attr.aria-label]="
'WORKSPACE.COMMAND_PALETTE.SEARCH_LABEL' | translate
"
/>
<kbd class="palette-search__kbd">&#8984;K</kbd>
</label>
@if (commandGroups().length > 0) {
<div class="palette-divider"></div>
<div class="palette-results">
@for (group of commandGroups(); track group.group) {
<section class="palette-group">
<h3 class="palette-group__title">
{{ getGroupTitleKey(group.group) | translate }}
</h3>
@for (command of group.items; track command.id) {
<button
type="button"
class="palette-command"
[class.is-selected]="isCommandSelected(command)"
[disabled]="!command.enabled"
(mouseenter)="onCommandHover(command)"
(click)="onCommandClick(command)"
>
<span class="palette-command__icon">
<mat-icon>{{ command.icon }}</mat-icon>
</span>
<span class="palette-command__label">{{
command.label
}}</span>
@if (command.description) {
<span class="palette-command__desc">{{
command.description
}}</span>
}
</button>
}
</section>
}
</div>
} @else {
<div class="palette-divider"></div>
<div class="palette-empty">
{{ 'WORKSPACE.COMMAND_PALETTE.EMPTY' | translate }}
</div>
}
</div>
@@ -0,0 +1,219 @@
:host {
display: block;
width: 100%;
max-height: 80vh;
app-region: no-drag;
}
// ─── Shell ───────────────────────────────────────────────────────────────────
.palette-shell {
background: var(--app-widget-bg);
color: var(--mat-sys-on-surface);
border: 1px solid var(--app-separator);
border-radius: 14px;
box-shadow:
0 24px 48px -28px rgba(0, 0, 0, 0.5),
0 10px 24px -18px rgba(0, 0, 0, 0.35);
overflow: hidden;
}
// ─── Search ──────────────────────────────────────────────────────────────────
.palette-search {
display: flex;
align-items: center;
gap: 10px;
padding: 0 14px;
min-height: 44px;
cursor: text;
}
.palette-search__icon {
font-size: 20px;
width: 20px;
height: 20px;
color: var(--mat-sys-on-surface-variant);
flex-shrink: 0;
opacity: 0.7;
}
.palette-search input {
flex: 1;
border: none;
outline: none;
background: transparent;
color: var(--mat-sys-on-surface);
font: inherit;
font-size: 0.94rem;
font-weight: 450;
width: 100%;
min-width: 0;
&::placeholder {
color: var(--mat-sys-on-surface-variant);
opacity: 0.55;
}
&::-webkit-search-cancel-button {
display: none;
}
}
.palette-search__kbd {
font-family: inherit;
font-size: 0.68rem;
font-weight: 500;
color: var(--mat-sys-on-surface-variant);
border: 1px solid
color-mix(
in srgb,
var(--mat-sys-outline-variant) 80%,
transparent
);
border-radius: 5px;
padding: 2px 5px;
line-height: 1;
flex-shrink: 0;
opacity: 0.7;
}
// ─── Divider ─────────────────────────────────────────────────────────────────
.palette-divider {
height: 1px;
background: var(--app-separator);
}
// ─── Results ─────────────────────────────────────────────────────────────────
.palette-results {
max-height: min(56vh, 480px);
overflow-y: auto;
overflow-x: hidden;
padding: 6px;
scrollbar-width: thin;
scrollbar-color: var(--mat-sys-outline-variant) transparent;
}
// ─── Group ───────────────────────────────────────────────────────────────────
.palette-group {
&:not(:first-child) {
margin-top: 4px;
}
}
.palette-group__title {
margin: 0;
padding: 6px 8px 4px;
font-size: 0.68rem;
font-weight: 600;
color: var(--mat-sys-on-surface-variant);
text-transform: uppercase;
letter-spacing: 0.06em;
opacity: 0.7;
}
// ─── Command Row ─────────────────────────────────────────────────────────────
.palette-command {
display: flex;
align-items: center;
gap: 10px;
width: 100%;
padding: 7px 8px;
border: 1px solid transparent;
border-radius: 9px;
background: transparent;
color: inherit;
text-align: left;
cursor: pointer;
transition:
background 0.12s ease,
border-color 0.12s ease;
&:hover:not(:disabled),
&.is-selected:not(:disabled) {
background: linear-gradient(
135deg,
var(--app-selection-surface-strong),
var(--app-selection-surface)
);
border-color: var(--app-selection-border);
}
&.is-selected:not(:disabled) {
box-shadow: 0 1px 4px -2px var(--app-selection-glow);
.palette-command__icon {
background: color-mix(
in srgb,
var(--app-selection-color) 22%,
transparent
);
color: var(--app-selection-color);
}
.palette-command__label {
color: var(--app-selection-color);
}
}
&:disabled {
cursor: not-allowed;
opacity: 0.4;
}
}
.palette-command__icon {
width: 28px;
height: 28px;
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: 7px;
background: color-mix(
in srgb,
var(--mat-sys-on-surface-variant) 10%,
transparent
);
color: var(--mat-sys-on-surface-variant);
flex-shrink: 0;
transition:
background 0.12s ease,
color 0.12s ease;
mat-icon {
font-size: 17px;
width: 17px;
height: 17px;
}
}
.palette-command__label {
font-size: 0.84rem;
font-weight: 530;
letter-spacing: -0.006em;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
min-width: 0;
transition: color 0.12s ease;
}
.palette-command__desc {
margin-left: auto;
font-size: 0.74rem;
color: var(--mat-sys-on-surface-variant);
opacity: 0.65;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
flex-shrink: 1;
min-width: 0;
padding-left: 12px;
}
// ─── Empty State ─────────────────────────────────────────────────────────────
.palette-empty {
padding: 20px 14px;
text-align: center;
color: var(--mat-sys-on-surface-variant);
font-size: 0.82rem;
opacity: 0.7;
}
@@ -0,0 +1,165 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog';
import { TranslateService } from '@ngx-translate/core';
import { of } from 'rxjs';
import { WorkspaceResolvedCommandItem } from '@iptvnator/portal/shared/util';
import { WorkspaceCommandPaletteComponent } from './workspace-command-palette.component';
describe('WorkspaceCommandPaletteComponent', () => {
let fixture: ComponentFixture<WorkspaceCommandPaletteComponent>;
let component: WorkspaceCommandPaletteComponent;
let dialogRef: { close: jest.Mock };
let commands: WorkspaceResolvedCommandItem[];
beforeEach(async () => {
dialogRef = {
close: jest.fn(),
};
commands = [
{
id: 'global-search',
label: 'Search all Xtream playlists',
description: 'Open global search overlay',
group: 'global',
icon: 'search',
keywords: ['xtream', 'global'],
priority: 100,
visible: true,
enabled: false,
run: () => undefined,
},
{
id: 'playlist-search',
label: 'Search this playlist',
description: 'Open playlist search route',
group: 'playlist',
icon: 'playlist_play',
keywords: ['playlist'],
priority: 10,
visible: true,
enabled: true,
run: () => undefined,
},
{
id: 'multi-epg',
label: 'Open Multi-EPG',
description: '',
group: 'view',
icon: 'view_list',
keywords: ['epg', 'guide'],
priority: 0,
visible: true,
enabled: true,
run: () => undefined,
},
{
id: 'hidden-command',
label: 'Hidden command',
description: 'Should not appear',
group: 'global',
icon: 'visibility_off',
keywords: [],
priority: 200,
visible: false,
enabled: true,
run: () => undefined,
},
];
await TestBed.configureTestingModule({
imports: [WorkspaceCommandPaletteComponent],
providers: [
{
provide: MatDialogRef,
useValue: dialogRef,
},
{
provide: MAT_DIALOG_DATA,
useValue: {
query: 'search',
commands,
},
},
{
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',
},
},
],
}).compileComponents();
fixture = TestBed.createComponent(WorkspaceCommandPaletteComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('creates and applies initial query', () => {
expect(component).toBeTruthy();
expect(component.query()).toBe('search');
});
it('selects the first enabled command by default', () => {
expect(component.selectedIndex()).toBe(0);
expect(component.flatCommands()[0].id).toBe('playlist-search');
});
it('hides non-visible commands and omits empty groups', () => {
expect(component.flatCommands().map((command) => command.id)).toEqual([
'playlist-search',
'global-search',
]);
expect(component.commandGroups().map((group) => group.group)).toEqual([
'playlist',
'global',
]);
});
it('filters commands by keywords', () => {
component.query.set('guide');
expect(component.flatCommands().map((command) => command.id)).toEqual([
'multi-epg',
]);
});
it('skips disabled commands during keyboard navigation', () => {
component.query.set('');
fixture.detectChanges();
expect(component.flatCommands().map((command) => command.id)).toEqual([
'multi-epg',
'playlist-search',
'global-search',
]);
expect(component.selectedIndex()).toBe(0);
component.onInputKeydown(
new KeyboardEvent('keydown', { key: 'ArrowDown' })
);
expect(component.selectedIndex()).toBe(1);
component.onInputKeydown(
new KeyboardEvent('keydown', { key: 'ArrowDown' })
);
expect(component.selectedIndex()).toBe(0);
});
it('closes with selected command and query on click', () => {
component.query.set('');
const command = component.flatCommands()[0];
component.onCommandClick(command);
expect(dialogRef.close).toHaveBeenCalledWith({
commandId: 'multi-epg',
query: '',
});
});
});
@@ -0,0 +1,252 @@
import {
AfterViewInit,
Component,
ElementRef,
computed,
effect,
inject,
signal,
viewChild,
} from '@angular/core';
import {
MAT_DIALOG_DATA,
MatDialogModule,
MatDialogRef,
} from '@angular/material/dialog';
import { MatIconModule } from '@angular/material/icon';
import { TranslatePipe } from '@ngx-translate/core';
import {
WorkspaceCommandGroup,
WorkspaceCommandSelection,
WorkspaceResolvedCommandItem,
} from '@iptvnator/portal/shared/util';
interface WorkspaceCommandPaletteData {
commands: WorkspaceResolvedCommandItem[];
query?: string;
}
interface WorkspaceCommandGroupSection {
group: WorkspaceCommandGroup;
items: WorkspaceResolvedCommandItem[];
}
@Component({
selector: 'app-workspace-command-palette',
imports: [MatDialogModule, MatIconModule, TranslatePipe],
templateUrl: './workspace-command-palette.component.html',
styleUrl: './workspace-command-palette.component.scss',
})
export class WorkspaceCommandPaletteComponent implements AfterViewInit {
private readonly dialogRef = inject(
MatDialogRef<
WorkspaceCommandPaletteComponent,
WorkspaceCommandSelection | undefined
>
);
private readonly data =
inject<WorkspaceCommandPaletteData>(MAT_DIALOG_DATA);
private readonly queryInputRef =
viewChild<ElementRef<HTMLInputElement>>('queryInput');
readonly query = signal(this.data?.query ?? '');
readonly selectedIndex = signal(0);
readonly visibleCommands = computed(() =>
this.data.commands.filter((command) => command.visible)
);
readonly filteredCommands = computed(() => {
const term = this.query().trim().toLowerCase();
const commands = this.visibleCommands();
if (!term) {
return commands;
}
return commands.filter((command) => {
const haystack = [
command.label,
command.description,
...(command.keywords ?? []),
]
.join(' ')
.toLowerCase();
return haystack.includes(term);
});
});
readonly commandGroups = computed<WorkspaceCommandGroupSection[]>(() => {
const commands = this.filteredCommands();
const sections: WorkspaceCommandGroupSection[] = [];
const buildSection = (
group: WorkspaceCommandGroup
): WorkspaceCommandGroupSection | null => {
const items = commands.filter((command) => command.group === group);
if (items.length === 0) {
return null;
}
return { group, items };
};
const groups = [
buildSection('view'),
buildSection('playlist'),
buildSection('global'),
].filter(
(group): group is WorkspaceCommandGroupSection => group !== null
);
sections.push(...groups);
return sections;
});
readonly flatCommands = computed(() =>
this.commandGroups().reduce<WorkspaceResolvedCommandItem[]>(
(items, group) => items.concat(group.items),
[]
)
);
readonly selectableIndices = computed(() =>
this.flatCommands()
.map((command, index) => (command.enabled ? index : -1))
.filter((index) => index >= 0)
);
constructor() {
effect(() => {
const items = this.flatCommands();
if (items.length === 0) {
this.selectedIndex.set(-1);
return;
}
const selectableIndices = this.selectableIndices();
if (selectableIndices.length === 0) {
this.selectedIndex.set(-1);
return;
}
const currentIndex = this.selectedIndex();
if (!selectableIndices.includes(currentIndex)) {
this.selectedIndex.set(selectableIndices[0]);
}
});
}
ngAfterViewInit(): void {
queueMicrotask(() => {
this.queryInputRef()?.nativeElement.focus();
this.queryInputRef()?.nativeElement.select();
});
}
onQueryInput(event: Event): void {
const target = event.target as HTMLInputElement | null;
this.query.set(target?.value ?? '');
}
onInputKeydown(event: KeyboardEvent): void {
if (event.key === 'ArrowDown') {
event.preventDefault();
this.moveSelection(1);
return;
}
if (event.key === 'ArrowUp') {
event.preventDefault();
this.moveSelection(-1);
return;
}
if (event.key === 'Enter') {
event.preventDefault();
this.selectCurrent();
return;
}
if (event.key === 'Escape') {
event.preventDefault();
this.dialogRef.close();
}
}
onCommandHover(command: WorkspaceResolvedCommandItem): void {
if (!command.enabled) {
return;
}
const index = this.flatCommands().findIndex(
(item) => item.id === command.id
);
if (index >= 0) {
this.selectedIndex.set(index);
}
}
onCommandClick(command: WorkspaceResolvedCommandItem): void {
if (!command.enabled) {
return;
}
this.dialogRef.close({
commandId: command.id,
query: this.query().trim(),
});
}
isCommandSelected(command: WorkspaceResolvedCommandItem): boolean {
const index = this.flatCommands().findIndex(
(item) => item.id === command.id
);
return index >= 0 && this.selectedIndex() === index;
}
getGroupTitleKey(group: WorkspaceCommandGroup): string {
if (group === 'view') {
return 'WORKSPACE.COMMAND_PALETTE.GROUP_VIEW';
}
if (group === 'playlist') {
return 'WORKSPACE.COMMAND_PALETTE.GROUP_PLAYLIST';
}
return 'WORKSPACE.COMMAND_PALETTE.GROUP_GLOBAL';
}
private moveSelection(direction: 1 | -1): void {
const selectableIndices = this.selectableIndices();
if (selectableIndices.length === 0) {
return;
}
const currentPosition = selectableIndices.indexOf(this.selectedIndex());
const nextPosition =
currentPosition === -1
? direction === 1
? 0
: selectableIndices.length - 1
: (currentPosition + direction + selectableIndices.length) %
selectableIndices.length;
this.selectedIndex.set(selectableIndices[nextPosition]);
}
private selectCurrent(): void {
const items = this.flatCommands();
if (items.length === 0) {
return;
}
const index = this.selectedIndex();
if (index < 0 || index >= items.length) {
return;
}
this.onCommandClick(items[index]);
}
}
@@ -0,0 +1,67 @@
@if (items()?.length > 0) {
<div class="nav-list">
@if (statusText()) {
<div class="context-inline-status">
{{ statusText() }}
</div>
}
@for (item of items(); track $index) {
<button
type="button"
class="nav-item category-item"
[attr.data-category-id]="
$any(item).category_id ?? $any(item).xtream_id ?? item.id
"
[class.selected]="isSelected(item)"
[class.category-item--selected]="isSelected(item)"
[class.category-item--disabled]="!interactionEnabled()"
[attr.aria-current]="isSelected(item) ? 'true' : null"
[attr.aria-disabled]="!interactionEnabled()"
[disabled]="!interactionEnabled()"
(click)="onCategoryClick(item)"
>
<span class="nav-item-label">
{{
item.category_name ||
$any(item).name ||
'No category name'
}}
</span>
@if (showCounts()) {
@if (countDisplayMode() === 'loading') {
<div
class="nav-item-meta item-count item-count--loading"
aria-hidden="true"
></div>
} @else {
<div class="nav-item-meta item-count">
{{ getItemCount(item) }}
</div>
}
} @else if ($any(item).count !== undefined) {
<div class="nav-item-meta item-count">
{{ $any(item).count }}
</div>
}
</button>
} @empty {
<app-workspace-context-error-view
[title]="'PORTALS.EMPTY_LIST_VIEW.TITLE' | translate"
[description]="
'PORTALS.EMPTY_LIST_VIEW.NO_SEARCH_RESULTS' | translate
"
[showActionButtons]="false"
[viewType]="'NO_SEARCH_RESULTS'"
/>
}
</div>
} @else {
<div class="category-empty-state">
<app-workspace-context-error-view
[title]="'No categories available'"
[showIllustration]="false"
[showActionButtons]="false"
[viewType]="'EMPTY_CATEGORY'"
/>
</div>
}
@@ -0,0 +1,135 @@
@use '../../../../../../../../apps/web/src/nav-list.scss';
:host {
display: block;
height: 100%;
min-height: 0;
overflow-y: auto;
flex-wrap: wrap;
gap: 10px;
justify-content: center;
}
.context-inline-status {
margin: 0 4px 8px;
padding: 8px 12px;
border-radius: 12px;
background: color-mix(
in srgb,
var(--app-selection-surface, var(--mat-sys-surface-container)) 72%,
transparent
);
border: 1px solid
color-mix(
in srgb,
var(--app-selection-border, var(--mat-sys-outline-variant)) 70%,
transparent
);
color: var(--mat-sys-on-surface-variant);
font-size: 0.78rem;
line-height: 1.35;
}
.category-item {
appearance: none;
font: inherit;
cursor: pointer;
width: auto;
.item-count {
opacity: 0.6;
font-size: 0.85em;
margin-left: 4px;
}
}
.category-item--disabled,
.category-item--disabled:hover {
cursor: progress;
transform: none;
box-shadow: none;
}
.item-count--loading {
position: relative;
overflow: hidden;
width: 22px;
height: 10px;
border-radius: 999px;
background: color-mix(
in srgb,
var(--app-widget-header-bg, var(--mat-sys-surface-container-high)) 52%,
var(--app-body-color, var(--mat-sys-on-surface-variant)) 48%
);
&::after {
content: '';
position: absolute;
inset: 0;
background: linear-gradient(
100deg,
transparent 8%,
color-mix(
in srgb,
var(--app-heading-color, var(--mat-sys-on-surface)) 18%,
transparent
)
44%,
transparent 74%
);
transform: translateX(-120%);
animation: context-item-count-shimmer 1.15s ease-in-out infinite;
}
}
.category-item--selected .item-count {
color: var(--app-selection-color) !important;
opacity: 0.95;
}
mat-card-content {
overflow-wrap: break-word;
padding: 12px;
}
.category-empty-state {
height: 100%;
min-height: 0;
display: flex;
align-items: center;
justify-content: center;
padding: clamp(14px, 3vh, 28px) 12px clamp(20px, 4vh, 34px);
box-sizing: border-box;
app-workspace-context-error-view {
--error-empty-illustration-size: clamp(132px, 15vw, 180px);
--error-empty-max-width: min(270px, 100%);
--error-empty-title-size: clamp(1.15rem, 1.35vw, 1.4rem);
--error-empty-title-weight: 610;
--error-empty-title-spacing: -0.006em;
--error-empty-copy-gap: 4px;
--error-empty-description-opacity: 0.82;
}
}
@keyframes context-item-count-shimmer {
100% {
transform: translateX(120%);
}
}
@media (max-width: 800px) {
.category-item {
width: 100%;
margin: 4px;
}
.category-empty-state {
app-workspace-context-error-view {
--error-empty-illustration-size: 142px;
--error-empty-max-width: 230px;
--error-empty-title-size: 1.05rem;
--error-empty-title-weight: 600;
}
}
}
@@ -0,0 +1,103 @@
import {
ChangeDetectionStrategy,
Component,
effect,
ElementRef,
inject,
input,
output,
} from '@angular/core';
import { MatListModule } from '@angular/material/list';
import { TranslatePipe } from '@ngx-translate/core';
import { WorkspaceContextErrorViewComponent } from './workspace-context-error-view.component';
interface WorkspaceCategoryViewItem {
readonly category_id?: string | number;
readonly category_name?: string;
readonly count?: number;
readonly id?: string | number;
readonly name?: string;
}
@Component({
selector: 'app-workspace-context-category-view',
imports: [MatListModule, TranslatePipe, WorkspaceContextErrorViewComponent],
changeDetection: ChangeDetectionStrategy.OnPush,
templateUrl: './workspace-context-category-view.component.html',
styleUrl: './workspace-context-category-view.component.scss',
})
export class WorkspaceContextCategoryViewComponent {
readonly items = input<WorkspaceCategoryViewItem[]>([]);
readonly selectedCategoryId = input<string | number | null | undefined>();
readonly itemCounts = input<Map<number, number>>(new Map());
readonly showCounts = input(false);
readonly countDisplayMode = input<'loading' | 'ready'>('ready');
readonly interactionEnabled = input(true);
readonly statusText = input('');
private readonly hostEl = inject(ElementRef<HTMLElement>);
readonly categoryClicked = output<WorkspaceCategoryViewItem>();
constructor() {
effect(() => {
const selectedCategory = this.selectedCategoryId();
if (selectedCategory == null) {
return;
}
queueMicrotask(() => {
const container = this.hostEl.nativeElement;
const candidates = Array.from(
container.querySelectorAll('[data-category-id]')
) as HTMLElement[];
const selected = candidates.find(
(el) =>
el.dataset['categoryId'] === String(selectedCategory)
);
if (!selected) {
return;
}
const containerRect = container.getBoundingClientRect();
const selectedRect = selected.getBoundingClientRect();
const targetTop =
container.scrollTop +
(selectedRect.top - containerRect.top) -
container.clientHeight / 2 +
selectedRect.height / 2;
const maxScrollTop = Math.max(
0,
container.scrollHeight - container.clientHeight
);
container.scrollTo({
behavior: 'smooth',
top: Math.min(maxScrollTop, Math.max(0, targetTop)),
});
});
});
}
isSelected(item: WorkspaceCategoryViewItem): boolean {
const selectedCategory = this.selectedCategoryId();
const itemId = item.category_id ?? item.id;
return (
selectedCategory != null &&
String(selectedCategory) === String(itemId)
);
}
getItemCount(item: WorkspaceCategoryViewItem): number {
const itemId = Number(item.id ?? item.category_id);
return this.itemCounts().get(itemId) ?? 0;
}
onCategoryClick(item: WorkspaceCategoryViewItem): void {
if (!this.interactionEnabled()) {
return;
}
this.categoryClicked.emit(item);
}
}
@@ -0,0 +1,29 @@
<div
class="error-view"
[class.error-view--empty-category]="viewType() === 'EMPTY_CATEGORY'"
[class.error-view--text-only]="!showIllustration()"
>
@if (showIllustration()) {
@if (viewType() === 'ERROR') {
<img
alt="Something went wrong"
src="./assets/images/broken-tv.svg"
/>
} @else if (viewType() === 'EMPTY_CATEGORY') {
<img
alt="empty category"
class="error-view__illustration"
src="./assets/images/empty-category.svg"
/>
} @else if (viewType() === 'NO_SEARCH_RESULTS') {
<mat-icon class="icon">search</mat-icon>
}
}
<div class="error-view__copy">
<h3 class="error-view__title">{{ title() }}</h3>
@if (description()) {
<p class="error-view__description">{{ description() }}</p>
}
</div>
</div>
@@ -0,0 +1,103 @@
:host {
display: block;
width: 100%;
min-height: 0;
}
.error-view {
--error-view-illustration-size: var(
--error-empty-illustration-size,
clamp(170px, 28vw, 260px)
);
--error-view-copy-width: var(--error-empty-max-width, 420px);
--error-view-copy-gap: var(--error-empty-copy-gap, 8px);
--error-view-title-size: var(--error-empty-title-size, 1.55rem);
--error-view-title-weight: var(--error-empty-title-weight, 640);
--error-view-title-spacing: var(--error-empty-title-spacing, -0.012em);
--error-view-description-size: var(--error-empty-description-size, 0.98rem);
--error-view-description-line-height: var(
--error-empty-description-line-height,
1.4
);
--error-view-description-opacity: var(
--error-empty-description-opacity,
0.88
);
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
width: 100%;
min-height: 100%;
padding: clamp(12px, 2.2vw, 26px);
gap: 14px;
text-align: center;
box-sizing: border-box;
color: var(--app-body-color, var(--mat-sys-on-surface-variant));
img {
width: min(100%, var(--error-view-illustration-size));
height: auto;
filter: invert(80%);
}
}
.icon {
font-size: 64px;
height: 64px;
width: 64px;
}
.error-view--empty-category .error-view__illustration {
margin-top: clamp(0px, 1vh, 8px);
}
.error-view--text-only {
gap: 6px;
}
.error-view__copy {
max-width: var(--error-view-copy-width);
display: grid;
gap: var(--error-view-copy-gap);
}
.error-view__title {
margin: 0;
font-size: var(--error-view-title-size);
font-weight: var(--error-view-title-weight);
line-height: 1.12;
color: var(--app-heading-color, var(--mat-sys-on-surface));
letter-spacing: var(--error-view-title-spacing);
text-wrap: balance;
}
.error-view__description {
margin: 0;
font-size: var(--error-view-description-size);
line-height: var(--error-view-description-line-height);
color: inherit;
opacity: var(--error-view-description-opacity);
text-wrap: balance;
}
@media (max-width: 1024px) {
.error-view {
--error-view-title-size: var(--error-empty-title-size, 1.4rem);
--error-view-description-size: var(
--error-empty-description-size,
0.92rem
);
}
}
::ng-deep {
.dark-theme {
.error-view {
img {
filter: invert(10%) !important;
}
}
}
}
@@ -0,0 +1,19 @@
import { ChangeDetectionStrategy, Component, input } from '@angular/core';
import { MatIcon } from '@angular/material/icon';
@Component({
selector: 'app-workspace-context-error-view',
imports: [MatIcon],
templateUrl: './workspace-context-error-view.component.html',
styleUrl: './workspace-context-error-view.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class WorkspaceContextErrorViewComponent {
readonly description = input<string | undefined>(undefined);
readonly showIllustration = input(true);
readonly showActionButtons = input(false);
readonly title = input<string | undefined>(undefined);
readonly viewType = input<'ERROR' | 'EMPTY_CATEGORY' | 'NO_SEARCH_RESULTS'>(
'ERROR'
);
}
@@ -0,0 +1,74 @@
import { Component, computed, inject } from '@angular/core';
import { TranslatePipe } from '@ngx-translate/core';
import { PortalCollectionContextService } from '@iptvnator/portal/shared/util';
import { WorkspaceContextCategoryViewComponent } from './components/workspace-context-category-view.component';
/**
* Context panel rendered in the workspace-shell aside for
* collection-backed workspace pages such as downloads.
*
* Reads categories from PortalCollectionContextService (populated by the
* active routed collection page) and emits
* category selections back through the same service.
*/
@Component({
selector: 'app-workspace-collection-context-panel',
imports: [TranslatePipe, WorkspaceContextCategoryViewComponent],
template: `
<div class="context-column">
<header class="context-header">
<div class="context-header__top">
<h2>
{{
selectedCategory()?.category_name ||
('WORKSPACE.CONTEXT.FILTER_BY_TYPE' | translate)
}}
</h2>
</div>
@if (selectedCount() !== null) {
<span class="context-header__badge">
@if (selectedCount() === 1) {
{{ 'WORKSPACE.CONTEXT.ITEM_COUNT_ONE' | translate }}
} @else {
{{
'WORKSPACE.CONTEXT.ITEM_COUNT_OTHER'
| translate: { count: selectedCount() }
}}
}
</span>
}
</header>
<div class="context-divider"></div>
<app-workspace-context-category-view
[items]="ctx.categories()"
[selectedCategoryId]="ctx.selectedCategoryId()"
(categoryClicked)="onCategoryClicked($event)"
/>
</div>
`,
styleUrl: './workspace-context-panel.component.scss',
})
export class WorkspaceCollectionContextPanelComponent {
readonly ctx = inject(PortalCollectionContextService);
readonly selectedCategory = computed(() => {
const categories = this.ctx.categories();
return categories.find(
(c) => c.category_id === this.ctx.selectedCategoryId()
);
});
readonly selectedCount = computed(
() => this.selectedCategory()?.count ?? null
);
onCategoryClicked(item: {
category_id?: string | number;
id?: string | number;
}): void {
const id = String(item.category_id ?? item.id ?? 'all');
this.ctx.setCategoryId(id);
}
}
@@ -0,0 +1,132 @@
<div class="context-column">
<header class="context-header">
<div class="context-header__top">
<h2>{{ title() | translate }}</h2>
<div class="context-header__actions">
@if (canSearchCategories()) {
<button
type="button"
mat-icon-button
class="context-header__action"
[class.context-header__action--active]="isSearchOpen()"
(click)="toggleCategorySearch()"
[matTooltip]="'WORKSPACE.CONTEXT.SEARCH' | translate"
[attr.aria-label]="
'WORKSPACE.CONTEXT.SEARCH' | translate
"
>
<mat-icon>search</mat-icon>
</button>
}
@if (isXtreamCategories()) {
<button
type="button"
mat-icon-button
class="context-header__action"
(click)="openManageCategories()"
[disabled]="!canManageXtreamCategories()"
[matTooltip]="
'WORKSPACE.CONTEXT.MANAGE_CATEGORIES' | translate
"
[attr.aria-label]="
'WORKSPACE.CONTEXT.MANAGE_CATEGORIES' | translate
"
>
<mat-icon>tune</mat-icon>
</button>
}
</div>
</div>
<!-- @if (subtitle(); as text) {
<span class="context-header__badge">{{ text }}</span>
} -->
</header>
@if (isSearchOpen()) {
<label class="context-search">
<mat-icon class="context-search__icon">search</mat-icon>
<input
#searchInput
type="search"
spellcheck="false"
[value]="categorySearchTerm()"
(input)="onSearchInput($event)"
[placeholder]="
'WORKSPACE.CONTEXT.SEARCH_CATEGORIES' | translate
"
[attr.aria-label]="
'WORKSPACE.CONTEXT.SEARCH_CATEGORIES' | translate
"
/>
@if (categorySearchTerm()) {
<button
type="button"
mat-icon-button
class="context-search__clear"
(click)="clearCategorySearch()"
>
<mat-icon>close</mat-icon>
</button>
}
</label>
} @else {
<div class="context-divider"></div>
}
@if (isXtreamCategories()) {
@if (isXtreamCategoryLoading()) {
<div class="context-skeleton" aria-hidden="true">
@for (row of skeletonRows; track row) {
<div class="context-skeleton__row">
<span class="context-skeleton__dot"></span>
<span
class="context-skeleton__label"
[style.width.%]="skeletonLabelWidths[row]"
></span>
<span class="context-skeleton__count"></span>
</div>
}
</div>
} @else {
<app-workspace-context-category-view
[items]="filteredXtreamCategories()"
[selectedCategoryId]="xtreamSelectedCategoryId()"
[itemCounts]="xtreamCategoryItemCounts()"
[showCounts]="true"
[countDisplayMode]="xtreamCountDisplayMode()"
[interactionEnabled]="isXtreamCategoryInteractionEnabled()"
[statusText]="xtreamStatusText()"
(categoryClicked)="onXtreamCategoryClicked($event)"
/>
}
} @else if (isStalkerCategories()) {
@if (isStalkerCategoryLoading()) {
<div class="context-skeleton" aria-hidden="true">
@for (row of skeletonRows; track row) {
<div class="context-skeleton__row">
<span class="context-skeleton__dot"></span>
<span
class="context-skeleton__label"
[style.width.%]="skeletonLabelWidths[row]"
></span>
<span class="context-skeleton__count"></span>
</div>
}
</div>
} @else if (isStalkerCategoryFailed()) {
<app-workspace-context-error-view
[title]="'WORKSPACE.CONTEXT.LOAD_CATEGORIES_ERROR' | translate"
[description]="
'WORKSPACE.CONTEXT.LOAD_CATEGORIES_ERROR_HINT' | translate
"
[showActionButtons]="false"
/>
} @else {
<app-workspace-context-category-view
[items]="filteredStalkerCategories()"
[selectedCategoryId]="$any(stalkerSelectedCategoryId())"
(categoryClicked)="onStalkerCategoryClicked($any($event))"
/>
}
}
</div>
@@ -0,0 +1,260 @@
:host {
display: flex;
flex-direction: column;
min-height: 0;
height: 100%;
overflow: hidden;
}
.context-column {
display: flex;
flex-direction: column;
min-height: 0;
height: 100%;
overflow: hidden;
}
// ─── Header ───────────────────────────────────────────────────────────────────
.context-header {
padding: 4px 6px 10px;
flex-shrink: 0;
}
.context-header__top {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
h2 {
margin: 0;
font-size: 0.94rem;
font-weight: 600;
letter-spacing: -0.01em;
color: var(--mat-sys-on-surface);
}
}
.context-header__actions {
display: flex;
align-items: center;
gap: 2px;
flex-shrink: 0;
}
.context-header__action {
width: 28px;
height: 28px;
line-height: 28px;
flex-shrink: 0;
padding: 0;
&[disabled] {
opacity: 0.48;
}
&--active mat-icon {
color: var(--mat-sys-primary);
}
mat-icon {
font-size: 18px;
width: 18px;
height: 18px;
color: var(--mat-sys-on-surface-variant);
}
}
.context-header__badge {
display: inline-flex;
align-items: center;
gap: 4px;
margin-top: 5px;
padding: 2px 0;
border-radius: 999px;
background: var(--mat-sys-surface-container);
color: var(--mat-sys-on-surface-variant);
font-size: 0.72rem;
font-weight: 500;
letter-spacing: 0.04em;
text-transform: uppercase;
}
.context-header p {
margin: 4px 0 0;
font-size: 0.78rem;
color: var(--mat-sys-on-surface-variant);
}
// ─── Search Bar ──────────────────────────────────────────────────────────────
.context-search {
display: flex;
align-items: center;
gap: 8px;
margin: 0 6px 6px;
padding: 0 10px;
min-height: 36px;
border-radius: 10px;
border: 1px solid var(--app-search-border, var(--mat-sys-outline-variant));
background: var(--app-search-bg, var(--mat-sys-surface-container));
flex-shrink: 0;
cursor: text;
transition:
border-color 0.15s ease,
box-shadow 0.15s ease;
&:focus-within {
border-color: var(--mat-sys-primary);
box-shadow: 0 0 0 3px
color-mix(in srgb, var(--mat-sys-primary) 12%, transparent);
}
}
.context-search__icon {
font-size: 18px;
width: 18px;
height: 18px;
color: var(--mat-sys-on-surface-variant);
flex-shrink: 0;
}
.context-search input {
flex: 1;
border: none;
outline: none;
background: transparent;
color: var(--mat-sys-on-surface);
font: inherit;
font-size: 0.84rem;
width: 100%;
min-width: 0;
&::placeholder {
color: var(--mat-sys-on-surface-variant);
opacity: 0.6;
}
&::-webkit-search-cancel-button {
display: none;
}
}
.context-search__clear {
width: 24px;
height: 24px;
min-width: 24px;
line-height: 24px;
flex-shrink: 0;
padding: 0;
mat-icon {
font-size: 16px;
width: 16px;
height: 16px;
}
}
// ─── Divider ──────────────────────────────────────────────────────────────────
.context-divider {
height: 1px;
background: var(--mat-sys-outline-variant);
margin: 0 0 6px;
opacity: 0.6;
flex-shrink: 0;
}
// ─── Category List ────────────────────────────────────────────────────────────
app-workspace-context-category-view {
display: block;
min-height: 0;
height: 100%;
flex: 1;
overflow-y: auto;
overflow-x: hidden;
overscroll-behavior: contain;
scrollbar-width: thin;
scrollbar-color: var(--mat-sys-outline-variant) transparent;
}
.context-skeleton {
display: grid;
gap: 8px;
padding: 4px 6px 2px;
overflow: hidden;
}
.context-skeleton__row {
display: grid;
grid-template-columns: 12px minmax(0, 1fr) 28px;
align-items: center;
gap: 10px;
min-height: 36px;
padding: 0 10px;
border-radius: 12px;
background: color-mix(
in srgb,
var(--app-widget-header-bg, var(--mat-sys-surface-container-high)) 88%,
var(--app-widget-bg, var(--mat-sys-surface-container)) 12%
);
border: 1px solid
color-mix(
in srgb,
var(--app-widget-header-border, var(--mat-sys-outline-variant)) 96%,
transparent
);
}
.context-skeleton__dot,
.context-skeleton__label,
.context-skeleton__count {
position: relative;
overflow: hidden;
background: color-mix(
in srgb,
var(--app-widget-header-bg, var(--mat-sys-surface-container-high)) 52%,
var(--app-body-color, var(--mat-sys-on-surface-variant)) 48%
);
&::after {
content: '';
position: absolute;
inset: 0;
background: linear-gradient(
100deg,
transparent 8%,
color-mix(
in srgb,
var(--app-heading-color, var(--mat-sys-on-surface)) 18%,
transparent
)
44%,
transparent 74%
);
transform: translateX(-120%);
animation: context-skeleton-shimmer 1.15s ease-in-out infinite;
}
}
.context-skeleton__dot {
width: 12px;
height: 12px;
border-radius: 999px;
}
.context-skeleton__label {
height: 9px;
border-radius: 999px;
}
.context-skeleton__count {
justify-self: end;
width: 22px;
height: 9px;
border-radius: 999px;
}
@keyframes context-skeleton-shimmer {
100% {
transform: translateX(120%);
}
}
@@ -0,0 +1,197 @@
import { computed, signal } from '@angular/core';
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { NoopAnimationsModule } from '@angular/platform-browser/animations';
import { Router } from '@angular/router';
import { TranslateService } from '@ngx-translate/core';
import { of } from 'rxjs';
import { MatDialog } from '@angular/material/dialog';
import { StalkerStore } from '@iptvnator/portal/stalker/data-access';
import {
XtreamContentLoadState,
XtreamStore,
} from '@iptvnator/portal/xtream/data-access';
import { WorkspaceContextPanelComponent } from './workspace-context-panel.component';
const translations: Record<string, string> = {
'WORKSPACE.CONTEXT.MANAGE_CATEGORIES': 'Manage categories',
'WORKSPACE.CONTEXT.XTREAM_SYNCING_LIVE': 'Syncing live categories...',
'WORKSPACE.CONTEXT.XTREAM_SYNCING_MOVIES': 'Syncing movie categories...',
'WORKSPACE.SHELL.XTREAM_IMPORT_LOADING':
'Fetching playlist data from source...',
};
describe('WorkspaceContextPanelComponent', () => {
let fixture: ComponentFixture<WorkspaceContextPanelComponent>;
const xtreamCategories = signal([
{ id: 1, name: 'News' },
{ id: 2, name: 'Sports' },
]);
const xtreamCategoryItemCounts = signal(new Map<number, number>());
const xtreamSelectedCategoryId = signal<number | null>(null);
const xtreamSelectedTypeContentState =
signal<XtreamContentLoadState>('loading');
const xtreamImportPhase = signal<string | null>('loading-live');
const xtreamIsLoadingCategories = signal(false);
const xtreamStore = {
getCategoriesBySelectedType: xtreamCategories,
getCategoryItemCounts: xtreamCategoryItemCounts,
selectedCategoryId: xtreamSelectedCategoryId,
selectedTypeContentState: xtreamSelectedTypeContentState,
selectedTypeContentReady: computed(
() => xtreamSelectedTypeContentState() === 'ready'
),
selectedTypeCountsReady: computed(
() => xtreamSelectedTypeContentState() === 'ready'
),
currentImportPhase: xtreamImportPhase,
isLoadingCategories: xtreamIsLoadingCategories,
setSelectedItem: jest.fn(),
setSelectedCategory: jest.fn(),
reloadCategories: jest.fn(),
};
const stalkerStore = {
getCategoryResource: signal([]),
selectedCategoryId: signal<string | null>(null),
isCategoryResourceLoading: signal(false),
isCategoryResourceFailed: signal(false),
setSelectedCategory: jest.fn(),
setPage: jest.fn(),
clearSelectedItem: jest.fn(),
};
const router = {
navigate: jest.fn(),
};
const dialog = {
open: jest.fn(),
};
beforeEach(async () => {
xtreamCategories.set([
{ id: 1, name: 'News' },
{ id: 2, name: 'Sports' },
]);
xtreamCategoryItemCounts.set(new Map());
xtreamSelectedCategoryId.set(null);
xtreamSelectedTypeContentState.set('loading');
xtreamImportPhase.set('loading-live');
xtreamIsLoadingCategories.set(false);
xtreamStore.setSelectedItem.mockClear();
xtreamStore.setSelectedCategory.mockClear();
xtreamStore.reloadCategories.mockClear();
router.navigate.mockClear();
dialog.open.mockClear();
await TestBed.configureTestingModule({
imports: [WorkspaceContextPanelComponent, NoopAnimationsModule],
providers: [
{
provide: TranslateService,
useValue: {
instant: (key: string) => translations[key] ?? key,
get: (key: string) => of(translations[key] ?? key),
stream: (key: string) => of(translations[key] ?? key),
onLangChange: of(null),
onTranslationChange: of(null),
onDefaultLangChange: of(null),
currentLang: 'en',
defaultLang: 'en',
},
},
{
provide: XtreamStore,
useValue: xtreamStore,
},
{
provide: StalkerStore,
useValue: stalkerStore,
},
{
provide: Router,
useValue: router,
},
{
provide: MatDialog,
useValue: dialog,
},
],
}).compileComponents();
fixture = TestBed.createComponent(WorkspaceContextPanelComponent);
fixture.componentRef.setInput('context', {
provider: 'xtreams',
playlistId: 'playlist-1',
});
});
it('renders loading meta and blocks xtream category clicks until counts are ready', () => {
fixture.componentRef.setInput('section', 'live');
fixture.detectChanges();
const countPlaceholders = fixture.nativeElement.querySelectorAll(
'.item-count--loading'
);
const categoryButtons = Array.from(
fixture.nativeElement.querySelectorAll('.category-item')
) as HTMLButtonElement[];
const status = fixture.nativeElement.querySelector(
'.context-inline-status'
) as HTMLElement | null;
const manageButton = fixture.nativeElement.querySelector(
'.context-header__action'
) as HTMLButtonElement | null;
expect(countPlaceholders).toHaveLength(2);
expect(categoryButtons.every((button) => button.disabled)).toBe(true);
expect(status?.textContent).toContain('Syncing live categories...');
expect(status?.textContent).toContain(
'Fetching playlist data from source...'
);
expect(manageButton?.disabled).toBe(true);
categoryButtons[0]?.click();
expect(xtreamStore.setSelectedItem).not.toHaveBeenCalled();
expect(xtreamStore.setSelectedCategory).not.toHaveBeenCalled();
expect(router.navigate).not.toHaveBeenCalled();
});
it('shows real counts and enables navigation once the selected xtream type is ready', () => {
fixture.componentRef.setInput('section', 'vod');
xtreamSelectedTypeContentState.set('ready');
xtreamImportPhase.set(null);
xtreamCategoryItemCounts.set(
new Map([
[1, 3],
[2, 0],
])
);
fixture.detectChanges();
const countTexts = Array.from(
fixture.nativeElement.querySelectorAll('.item-count')
).map((element: Element) => element.textContent?.trim());
const categoryButtons = Array.from(
fixture.nativeElement.querySelectorAll('.category-item')
) as HTMLButtonElement[];
const manageButton = fixture.nativeElement.querySelector(
'.context-header__action'
) as HTMLButtonElement | null;
expect(countTexts).toEqual(['3', '0']);
expect(categoryButtons.every((button) => !button.disabled)).toBe(true);
expect(manageButton?.disabled).toBe(false);
categoryButtons[1]?.click();
expect(xtreamStore.setSelectedItem).toHaveBeenCalledWith(null);
expect(xtreamStore.setSelectedCategory).toHaveBeenCalledWith(2);
expect(router.navigate).toHaveBeenCalledWith([
'/workspace',
'xtreams',
'playlist-1',
'vod',
2,
]);
});
});
@@ -0,0 +1,375 @@
import {
Component,
computed,
DestroyRef,
effect,
inject,
input,
signal,
viewChild,
ElementRef,
} from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { MatIconButton } from '@angular/material/button';
import { MatDialog } from '@angular/material/dialog';
import { MatIcon } from '@angular/material/icon';
import { MatTooltip } from '@angular/material/tooltip';
import { Router } from '@angular/router';
import { TranslatePipe, TranslateService } from '@ngx-translate/core';
import { StalkerStore } from '@iptvnator/portal/stalker/data-access';
import { XtreamStore } from '@iptvnator/portal/xtream/data-access';
import { WorkspaceContextCategoryViewComponent } from './components/workspace-context-category-view.component';
import { WorkspaceContextErrorViewComponent } from './components/workspace-context-error-view.component';
type WorkspaceProvider = 'xtreams' | 'stalker' | 'playlists';
interface WorkspaceContextRoute {
provider: WorkspaceProvider;
playlistId: string;
}
interface XtreamCategoryLike {
id?: number | string;
category_id?: number | string;
}
@Component({
selector: 'app-workspace-context-panel',
imports: [
MatIconButton,
MatIcon,
MatTooltip,
TranslatePipe,
WorkspaceContextCategoryViewComponent,
WorkspaceContextErrorViewComponent,
],
templateUrl: './workspace-context-panel.component.html',
styleUrl: './workspace-context-panel.component.scss',
})
export class WorkspaceContextPanelComponent {
private readonly router = inject(Router);
private readonly xtreamStore = inject(XtreamStore);
private readonly stalkerStore = inject(StalkerStore);
private readonly dialog = inject(MatDialog);
private readonly destroyRef = inject(DestroyRef);
private readonly translate = inject(TranslateService);
readonly context = input.required<WorkspaceContextRoute>();
readonly section = input.required<string>();
readonly isXtreamCategories = computed(
() =>
this.context().provider === 'xtreams' &&
(this.section() === 'vod' ||
this.section() === 'series' ||
this.section() === 'live')
);
readonly isStalkerCategories = computed(
() =>
this.context().provider === 'stalker' &&
(this.section() === 'vod' ||
this.section() === 'series' ||
this.section() === 'itv')
);
readonly xtreamCategories = this.xtreamStore.getCategoriesBySelectedType;
readonly xtreamCategoryItemCounts = this.xtreamStore.getCategoryItemCounts;
readonly xtreamSelectedCategoryId = this.xtreamStore.selectedCategoryId;
readonly xtreamSelectedTypeContentState =
this.xtreamStore.selectedTypeContentState;
readonly xtreamSelectedTypeContentReady =
this.xtreamStore.selectedTypeContentReady;
readonly xtreamSelectedTypeCountsReady =
this.xtreamStore.selectedTypeCountsReady;
readonly xtreamImportPhase = this.xtreamStore.currentImportPhase;
readonly isXtreamCategoryLoading = computed(
() =>
this.isXtreamCategories() &&
this.xtreamStore.isLoadingCategories() &&
this.xtreamCategories().length === 0
);
readonly isXtreamCategoryInteractionEnabled = computed(
() =>
!this.isXtreamCategoryLoading() &&
this.xtreamSelectedTypeContentReady()
);
readonly xtreamCountDisplayMode = computed<'loading' | 'ready'>(() =>
this.isXtreamCategoryInteractionEnabled() ? 'ready' : 'loading'
);
readonly canManageXtreamCategories = computed(
() =>
this.isXtreamCategories() &&
this.xtreamCategories().length > 0 &&
this.xtreamSelectedTypeCountsReady()
);
readonly xtreamStatusText = computed(() => {
if (
!this.isXtreamCategories() ||
this.isXtreamCategoryLoading() ||
this.xtreamSelectedTypeContentState() !== 'loading'
) {
return '';
}
const syncLabel = this.translate.instant(
this.getXtreamSyncLabelKey(this.section())
);
const phaseKey = this.getXtreamImportPhaseLabelKey(
this.xtreamImportPhase()
);
const phaseLabel = phaseKey ? this.translate.instant(phaseKey) : '';
return phaseLabel ? `${syncLabel} ${phaseLabel}` : syncLabel;
});
readonly stalkerCategories = this.stalkerStore.getCategoryResource;
readonly stalkerSelectedCategoryId = this.stalkerStore.selectedCategoryId;
readonly isStalkerCategoryLoading =
this.stalkerStore.isCategoryResourceLoading;
readonly isStalkerCategoryFailed =
this.stalkerStore.isCategoryResourceFailed;
readonly skeletonRows = Array.from({ length: 14 }, (_, index) => index);
readonly skeletonLabelWidths = [
78, 66, 74, 59, 83, 69, 76, 62, 81, 64, 72, 67, 79, 61,
];
readonly searchInput =
viewChild<ElementRef<HTMLInputElement>>('searchInput');
readonly isSearchOpen = signal(false);
readonly categorySearchTerm = signal('');
readonly canSearchCategories = computed(
() =>
(this.isXtreamCategories() &&
this.xtreamCategories().length > 0) ||
(this.isStalkerCategories() &&
this.stalkerCategories().length > 0)
);
readonly filteredXtreamCategories = computed(() => {
const cats = this.xtreamCategories();
const term = this.categorySearchTerm().trim().toLowerCase();
if (!term) return cats;
return cats.filter((c) => {
const label =
(c as { category_name?: string }).category_name ??
(c as { name?: string }).name ??
'';
return label.toLowerCase().includes(term);
});
});
readonly filteredStalkerCategories = computed(() => {
const cats = this.stalkerCategories();
const term = this.categorySearchTerm().trim().toLowerCase();
if (!term) return cats;
return cats.filter((c) => {
const label =
(c as { name?: string }).name ??
(c as { category_name?: string }).category_name ??
'';
return label.toLowerCase().includes(term);
});
});
readonly title = computed(() => {
if (this.isXtreamCategories()) {
if (this.section() === 'vod') {
return 'WORKSPACE.CONTEXT.MOVIE_CATEGORIES';
}
if (this.section() === 'series') {
return 'WORKSPACE.CONTEXT.SERIES_CATEGORIES';
}
return 'WORKSPACE.CONTEXT.LIVE_CATEGORIES';
}
if (this.isStalkerCategories()) {
if (this.section() === 'vod') {
return 'WORKSPACE.CONTEXT.MOVIE_CATEGORIES';
}
if (this.section() === 'series') {
return 'WORKSPACE.CONTEXT.SERIES_CATEGORIES';
}
return 'WORKSPACE.CONTEXT.LIVE_CATEGORIES';
}
return 'WORKSPACE.CONTEXT.CATEGORIES';
});
readonly subtitle = computed(() => {
if (this.isXtreamCategories()) {
return 'WORKSPACE.CONTEXT.XTREAM_SOURCE';
}
if (this.isStalkerCategories()) {
return 'WORKSPACE.CONTEXT.STALKER_PORTAL';
}
return '';
});
constructor() {
effect(() => {
if (this.isSearchOpen()) {
queueMicrotask(() => {
this.searchInput()?.nativeElement.focus();
});
}
});
}
toggleCategorySearch(): void {
const opening = !this.isSearchOpen();
this.isSearchOpen.set(opening);
if (!opening) {
this.categorySearchTerm.set('');
}
}
onSearchInput(event: Event): void {
const value = (event.target as HTMLInputElement).value;
this.categorySearchTerm.set(value);
}
clearCategorySearch(): void {
this.categorySearchTerm.set('');
this.searchInput()?.nativeElement.focus();
}
openManageCategories(): void {
if (!this.canManageXtreamCategories()) {
return;
}
const context = this.context();
const section = this.section();
const contentType =
section === 'series'
? 'series'
: section === 'live'
? 'live'
: 'vod';
void import('@iptvnator/portal/xtream/feature').then(
({ CategoryManagementDialogComponent }) => {
const dialogRef = this.dialog.open(
CategoryManagementDialogComponent,
{
data: {
playlistId: context.playlistId,
contentType,
itemCounts: this.xtreamStore.getCategoryItemCounts(),
},
width: '500px',
maxHeight: '90vh',
}
);
dialogRef
.afterClosed()
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe((result) => {
if (result) {
this.xtreamStore.reloadCategories();
}
});
}
);
}
onXtreamCategoryClicked(category: XtreamCategoryLike): void {
if (!this.isXtreamCategoryInteractionEnabled()) {
return;
}
const context = this.context();
const section = this.section();
const rawCategoryId = category.category_id ?? category.id;
if (rawCategoryId === undefined || rawCategoryId === null) {
return;
}
const numericCategoryId = Number(rawCategoryId);
if (Number.isNaN(numericCategoryId)) {
return;
}
const categoryId = numericCategoryId;
this.xtreamStore.setSelectedItem(null);
this.xtreamStore.setSelectedCategory(categoryId);
if (section === 'live') {
return;
}
this.router.navigate([
'/workspace',
'xtreams',
context.playlistId,
section,
categoryId,
]);
}
onStalkerCategoryClicked(item: { category_id?: string | number }): void {
const context = this.context();
const section = this.section();
const categoryId = String(item.category_id ?? '*');
this.stalkerStore.setSelectedCategory(categoryId);
this.stalkerStore.setPage(0);
this.stalkerStore.clearSelectedItem();
if (section === 'itv') {
return;
}
if (categoryId === '*') {
this.router.navigate([
'/workspace',
'stalker',
context.playlistId,
section,
]);
return;
}
this.router.navigate([
'/workspace',
'stalker',
context.playlistId,
section,
categoryId,
]);
}
private getXtreamSyncLabelKey(section: string): string {
switch (section) {
case 'live':
return 'WORKSPACE.CONTEXT.XTREAM_SYNCING_LIVE';
case 'series':
return 'WORKSPACE.CONTEXT.XTREAM_SYNCING_SERIES';
case 'vod':
default:
return 'WORKSPACE.CONTEXT.XTREAM_SYNCING_MOVIES';
}
}
private getXtreamImportPhaseLabelKey(phase: string | null): string {
switch (phase) {
case 'preparing-content':
return 'WORKSPACE.SHELL.XTREAM_IMPORT_PREPARING';
case 'loading-categories':
case 'loading-live':
case 'loading-movies':
case 'loading-series':
return 'WORKSPACE.SHELL.XTREAM_IMPORT_LOADING';
case 'saving-categories':
case 'saving-content':
return 'WORKSPACE.SHELL.XTREAM_IMPORT_SAVING';
case 'restoring-favorites':
return 'WORKSPACE.SHELL.XTREAM_IMPORT_RESTORING_FAVORITES';
case 'restoring-recently-viewed':
return 'WORKSPACE.SHELL.XTREAM_IMPORT_RESTORING_RECENT';
default:
return '';
}
}
}
@@ -0,0 +1,37 @@
@use '../../nav-list.scss';
:host {
display: flex;
flex-direction: column;
height: 100%;
overflow: hidden;
}
.panel-title {
margin: 0;
padding: 16px 16px 0;
font-size: 20px;
font-weight: 500;
color: var(--mat-sys-on-surface);
}
.settings-panel-body {
flex: 1;
min-height: 0;
padding-top: 16px;
}
.settings-sections-list {
height: 100%;
}
.settings-panel-footer {
flex-shrink: 0;
padding: 12px 8px calc(20px + env(safe-area-inset-bottom, 0px));
border-top: 1px solid var(--app-separator);
}
.settings-section-item:hover:not(.active):not(.selected),
.settings-back-button:hover:not(.active):not(.selected) {
background: var(--app-selection-surface);
}
@@ -0,0 +1,47 @@
import { Location } from '@angular/common';
import { Component, inject } from '@angular/core';
import { MatIconModule } from '@angular/material/icon';
import { TranslateModule } from '@ngx-translate/core';
import { SettingsContextService } from '@iptvnator/workspace/shell/util';
@Component({
selector: 'app-workspace-settings-context-panel',
imports: [MatIconModule, TranslateModule],
styleUrls: ['./workspace-settings-context-panel.component.scss'],
template: `
<h2 class="panel-title">{{ 'SETTINGS.TITLE' | translate }}</h2>
<div class="settings-panel-body">
<div class="nav-list settings-sections-list">
@for (section of ctx.sections(); track section.id) {
<button
type="button"
class="nav-item settings-section-item"
[class.active]="ctx.activeSection() === section.id"
(click)="ctx.navigateToSection(section.id)"
>
<mat-icon>{{ section.icon }}</mat-icon>
<span>{{ section.label | translate }}</span>
</button>
}
</div>
</div>
<div class="settings-panel-footer">
<button
type="button"
class="nav-item settings-back-button"
(click)="onBack()"
>
<mat-icon>arrow_back</mat-icon>
<span>{{ 'SETTINGS.BACK_TO_HOME' | translate }}</span>
</button>
</div>
`,
})
export class WorkspaceSettingsContextPanelComponent {
readonly ctx = inject(SettingsContextService);
private readonly location = inject(Location);
onBack() {
this.location.back();
}
}
@@ -0,0 +1,52 @@
@if (variant() === 'sources' && hasPlaylists()) {
<aside
class="context-panel context-panel--sources"
appResizable
[minWidth]="220"
[maxWidth]="440"
[defaultWidth]="290"
storageKey="sidebar-width"
>
<app-workspace-sources-filters-panel />
</aside>
} @else if (variant() === 'category') {
@if (context(); as context) {
@if (section(); as section) {
<aside
class="context-panel context-panel--route"
appResizable
[minWidth]="240"
[maxWidth]="560"
[defaultWidth]="300"
storageKey="sidebar-width"
>
<app-workspace-context-panel
[context]="context"
[section]="section"
/>
</aside>
}
}
} @else if (variant() === 'settings') {
<aside
class="context-panel context-panel--settings"
appResizable
[minWidth]="200"
[maxWidth]="400"
[defaultWidth]="260"
storageKey="sidebar-width"
>
<app-workspace-settings-context-panel />
</aside>
} @else if (variant() === 'collection') {
<aside
class="context-panel context-panel--route"
appResizable
[minWidth]="200"
[maxWidth]="400"
[defaultWidth]="260"
storageKey="sidebar-width"
>
<app-workspace-collection-context-panel />
</aside>
}
@@ -0,0 +1,43 @@
:host {
display: block;
min-width: 0;
min-height: 0;
}
.context-panel {
border-right: 1px solid var(--mat-sys-outline-variant);
background: var(--app-rail-bg, var(--mat-sys-surface-container-low));
display: flex;
flex-direction: column;
gap: 6px;
height: 100%;
padding: 12px;
width: 300px;
min-width: 0;
min-height: 0;
overflow: hidden;
}
.context-panel--route {
padding: 12px 10px 10px 12px;
overflow: visible;
}
.context-panel--sources {
padding: 12px 10px 10px 12px;
overflow: visible;
}
.context-panel--route app-workspace-context-panel {
display: flex;
flex: 1;
min-height: 0;
overflow: hidden;
}
.context-panel--sources app-workspace-sources-filters-panel {
display: flex;
flex: 1;
min-height: 0;
overflow: hidden;
}
@@ -0,0 +1,109 @@
import {
Component,
Directive,
input,
} from '@angular/core';
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { WorkspaceShellContextSidebarComponent } from './workspace-shell-context-sidebar.component';
@Directive({
selector: '[appResizable]',
standalone: true,
})
class MockResizableDirective {
readonly minWidth = input<number | null>(null);
readonly maxWidth = input<number | null>(null);
readonly defaultWidth = input<number | null>(null);
readonly storageKey = input<string | null>(null);
}
@Component({
selector: 'app-workspace-context-panel',
template: '',
standalone: true,
})
class MockWorkspaceContextPanelComponent {
readonly context = input.required<unknown>();
readonly section = input.required<string>();
}
@Component({
selector: 'app-workspace-collection-context-panel',
template: '',
standalone: true,
})
class MockWorkspaceCollectionContextPanelComponent {}
@Component({
selector: 'app-workspace-settings-context-panel',
template: '',
standalone: true,
})
class MockWorkspaceSettingsContextPanelComponent {}
@Component({
selector: 'app-workspace-sources-filters-panel',
template: '',
standalone: true,
})
class MockWorkspaceSourcesFiltersPanelComponent {}
describe('WorkspaceShellContextSidebarComponent', () => {
let fixture: ComponentFixture<WorkspaceShellContextSidebarComponent>;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [WorkspaceShellContextSidebarComponent],
})
.overrideComponent(WorkspaceShellContextSidebarComponent, {
set: {
imports: [
MockResizableDirective,
MockWorkspaceContextPanelComponent,
MockWorkspaceCollectionContextPanelComponent,
MockWorkspaceSettingsContextPanelComponent,
MockWorkspaceSourcesFiltersPanelComponent,
],
},
})
.compileComponents();
fixture = TestBed.createComponent(WorkspaceShellContextSidebarComponent);
});
it('renders the settings panel for the settings variant', () => {
fixture.componentRef.setInput('variant', 'settings');
fixture.detectChanges();
expect(
fixture.nativeElement.querySelector(
'app-workspace-settings-context-panel'
)
).not.toBeNull();
});
it('renders the route context panel for category routes', () => {
fixture.componentRef.setInput('variant', 'category');
fixture.componentRef.setInput('context', {
provider: 'xtreams',
playlistId: 'pl-1',
});
fixture.componentRef.setInput('section', 'vod');
fixture.detectChanges();
expect(
fixture.nativeElement.querySelector('app-workspace-context-panel')
).not.toBeNull();
});
it('renders the shared collection panel for collection routes', () => {
fixture.componentRef.setInput('variant', 'collection');
fixture.detectChanges();
expect(
fixture.nativeElement.querySelector(
'app-workspace-collection-context-panel'
)
).not.toBeNull();
});
});
@@ -0,0 +1,35 @@
import {
ChangeDetectionStrategy,
Component,
input,
} from '@angular/core';
import { ResizableDirective } from 'components';
import { PortalRailSection } from '@iptvnator/portal/shared/util';
import {
WorkspacePortalContext,
WorkspaceShellContextPanel,
} from '@iptvnator/workspace/shell/util';
import { WorkspaceCollectionContextPanelComponent } from '../../../workspace-context-panel/workspace-collection-context-panel.component';
import { WorkspaceContextPanelComponent } from '../../../workspace-context-panel/workspace-context-panel.component';
import { WorkspaceSettingsContextPanelComponent } from '../../../workspace-context-panel/workspace-settings-context-panel.component';
import { WorkspaceSourcesFiltersPanelComponent } from '../../../workspace-sources-filters-panel/workspace-sources-filters-panel.component';
@Component({
selector: 'app-workspace-shell-context-sidebar',
imports: [
ResizableDirective,
WorkspaceCollectionContextPanelComponent,
WorkspaceContextPanelComponent,
WorkspaceSettingsContextPanelComponent,
WorkspaceSourcesFiltersPanelComponent,
],
templateUrl: './workspace-shell-context-sidebar.component.html',
styleUrl: './workspace-shell-context-sidebar.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class WorkspaceShellContextSidebarComponent {
readonly variant = input.required<WorkspaceShellContextPanel>();
readonly context = input<WorkspacePortalContext | null>(null);
readonly section = input<PortalRailSection | null>(null);
readonly hasPlaylists = input(true);
}
@@ -0,0 +1,141 @@
<header class="workspace-header">
<app-playlist-switcher
class="playlist-switcher"
[currentTitle]="playlistTitle()"
[subtitle]="playlistSubtitle()"
[showPlaylistInfo]="canOpenPlaylistInfo()"
[showAccountInfo]="canOpenAccountInfo()"
[showAddPlaylist]="true"
[canRefreshActivePlaylist]="canRefreshPlaylist()"
[isRefreshingActivePlaylist]="isRefreshingPlaylist()"
(playlistInfoRequested)="onPlaylistInfoRequested()"
(accountInfoRequested)="onAccountInfoRequested()"
(addPlaylistRequested)="onAddPlaylistRequested()"
(refreshPlaylistRequested)="onRefreshPlaylistRequested()"
/>
@if (!hasNoPlaylists() || showQuickPlayerSelect()) {
<div class="search-toolbar">
@if (showQuickPlayerSelect()) {
<mat-form-field
class="quick-player-field"
appearance="outline"
subscriptSizing="dynamic"
>
<mat-label>{{
'WORKSPACE.SHELL.QUICK_PLAYER_LABEL' | translate
}}</mat-label>
<mat-select
[value]="quickHeaderPlayerValue()"
(selectionChange)="
onQuickPlayerChange($event.value)
"
[attr.aria-label]="
'WORKSPACE.SHELL.QUICK_PLAYER_LABEL' | translate
"
>
<mat-option [value]="VideoPlayer.ArtPlayer">{{
'WORKSPACE.SHELL.QUICK_PLAYER_ARTPLAYER' | translate
}}</mat-option>
<mat-option [value]="VideoPlayer.VLC">{{
'WORKSPACE.SHELL.QUICK_PLAYER_VLC' | translate
}}</mat-option>
</mat-select>
</mat-form-field>
}
@if (!hasNoPlaylists()) {
<label class="search-field">
<mat-icon>search</mat-icon>
@if (searchScopeLabel()) {
<span class="search-chip search-chip--scope">
{{ searchScopeLabel() }}
</span>
}
<input
type="search"
spellcheck="false"
[value]="searchQuery()"
(input)="onSearchInput($event)"
(keydown.enter)="onSearchEnter($event)"
[disabled]="!canUseSearch()"
[placeholder]="searchPlaceholder() | translate"
[attr.aria-label]="
'WORKSPACE.SHELL.SEARCH_LABEL' | translate
"
/>
@if (searchStatusLabel()) {
<span class="search-chip search-chip--status">
{{ searchStatusLabel() }}
</span>
}
<button
type="button"
class="command-trigger"
(click)="onCommandPaletteRequested()"
[matTooltip]="
'WORKSPACE.SHELL.COMMAND_PALETTE_SHORTCUT'
| translate
"
[attr.aria-label]="
'WORKSPACE.SHELL.OPEN_COMMAND_PALETTE' | translate
"
>
{{ commandShortcutLabel }}
</button>
</label>
}
</div>
}
<div class="header-actions header-actions--leading">
<button
type="button"
mat-icon-button
(click)="onAddPlaylistRequested()"
[matTooltip]="'WORKSPACE.SHELL.ADD_PLAYLIST_ALL' | translate"
[attr.aria-label]="'WORKSPACE.SHELL.ADD_PLAYLIST' | translate"
>
<mat-icon>add</mat-icon>
</button>
@if (headerShortcut(); as action) {
<button
type="button"
class="header-shortcut"
mat-icon-button
(click)="onHeaderShortcutRequested()"
[matTooltip]="action.tooltipKey | translate"
[attr.aria-label]="action.ariaLabelKey | translate"
>
<mat-icon>{{ action.icon }}</mat-icon>
</button>
}
</div>
@if (isElectron()) {
<div class="header-actions header-actions--trailing">
<span class="download-btn-wrap">
@if (hasActiveDownloads()) {
<span class="download-activity-bar" aria-hidden="true">
<span class="download-activity-bar__fill"></span>
</span>
}
<button
type="button"
class="header-shortcut"
mat-icon-button
[class.is-active]="isDownloadsView()"
[class.has-activity]="hasActiveDownloads()"
(click)="onDownloadsRequested()"
[matTooltip]="
'WORKSPACE.SHELL.RAIL_DOWNLOADS' | translate
"
[attr.aria-label]="
'WORKSPACE.SHELL.OPEN_DOWNLOADS' | translate
"
>
<mat-icon>download</mat-icon>
</button>
</span>
</div>
}
</header>
@@ -0,0 +1,337 @@
:host {
display: block;
min-width: 0;
}
.workspace-header {
display: flex;
align-items: center;
gap: 12px;
height: 56px;
padding: 0 24px;
border-bottom: 1px solid var(--mat-sys-outline-variant);
background: var(--app-header-bg, var(--mat-sys-surface-container-low));
app-region: drag;
}
.playlist-switcher {
width: min(260px, 32vw);
flex-shrink: 0;
app-region: no-drag;
}
.search-toolbar {
flex: 1;
min-width: 0;
max-width: 720px;
display: flex;
align-items: center;
gap: 10px;
app-region: no-drag;
}
.quick-player-field {
flex: 0 0 auto;
width: min(168px, 28vw);
--mat-form-field-container-height: 40px;
--mat-form-field-container-vertical-padding: 0px;
::ng-deep .mat-mdc-form-field-subscript-wrapper {
display: none;
}
::ng-deep .mat-mdc-text-field-wrapper {
padding-bottom: 0;
}
::ng-deep .mat-mdc-form-field-infix {
padding-top: 6px;
padding-bottom: 6px;
min-height: 36px;
}
}
.search-field {
flex: 1;
min-width: 0;
max-width: none;
height: 36px;
border-radius: 10px;
border: 1px solid var(--app-search-border, var(--mat-sys-outline-variant));
background: var(--app-search-bg, var(--mat-sys-surface-container));
display: flex;
align-items: center;
gap: 8px;
padding: 0 12px;
transition:
border-color 0.15s ease,
background-color 0.15s ease,
box-shadow 0.15s ease;
app-region: no-drag;
&:focus-within {
border-color: var(--mat-sys-primary);
box-shadow: 0 0 0 3px
color-mix(in srgb, var(--mat-sys-primary) 12%, transparent);
}
mat-icon {
font-size: 18px;
width: 18px;
height: 18px;
color: var(--mat-sys-on-surface-variant);
flex-shrink: 0;
}
.search-chip {
max-width: 200px;
padding: 0 10px;
height: 24px;
border-radius: 999px;
display: inline-flex;
align-items: center;
justify-content: center;
color: var(--mat-sys-on-surface-variant);
background: color-mix(
in srgb,
var(--mat-sys-surface-container-high) 76%,
transparent
);
border: 1px solid
color-mix(in srgb, var(--mat-sys-outline-variant) 82%, transparent);
font-size: 0.75rem;
font-weight: 600;
line-height: 1;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
flex-shrink: 1;
min-width: 48px;
}
.search-chip--scope {
color: var(--mat-sys-on-surface);
}
.search-chip--status {
color: var(--app-selection-color, var(--mat-sys-primary));
background: color-mix(
in srgb,
var(--app-selection-surface, var(--mat-sys-primary-container)) 70%,
transparent
);
border-color: color-mix(
in srgb,
var(--app-selection-border, var(--mat-sys-primary)) 52%,
transparent
);
}
input {
flex: 1;
border: none;
outline: none;
background: transparent;
color: var(--mat-sys-on-surface);
font: inherit;
font-size: 0.875rem;
&:disabled {
cursor: not-allowed;
opacity: 0.5;
}
&::placeholder {
color: var(--mat-sys-on-surface-variant);
opacity: 0.7;
}
}
.command-trigger {
display: inline-flex;
align-items: center;
justify-content: center;
height: 22px;
min-width: 36px;
padding: 0 6px;
margin-right: -2px;
color: var(--mat-sys-on-surface-variant);
background: color-mix(
in srgb,
var(--mat-sys-surface-container-high) 76%,
transparent
);
border: 1px solid
color-mix(in srgb, var(--mat-sys-outline-variant) 70%, transparent);
border-radius: 5px;
font-family: inherit;
font-size: 0.7rem;
font-weight: 600;
letter-spacing: 0.02em;
line-height: 1;
cursor: pointer;
flex-shrink: 0;
transition:
color 0.15s ease,
background 0.15s ease,
border-color 0.15s ease;
&:hover {
background: var(--mat-sys-surface-container-highest);
color: var(--mat-sys-on-surface);
border-color: color-mix(
in srgb,
var(--mat-sys-outline-variant) 100%,
transparent
);
}
&:focus-visible {
outline: 2px solid var(--mat-sys-primary);
outline-offset: 1px;
}
}
}
.header-actions {
display: flex;
align-items: center;
gap: 4px;
app-region: no-drag;
button,
a {
color: var(--mat-sys-on-surface-variant);
border-radius: 8px;
transition:
color 0.15s ease,
background 0.15s ease;
&:hover {
color: var(--mat-sys-on-surface);
background: var(--mat-sys-surface-container);
}
&.is-active {
color: var(--app-selection-color);
background: linear-gradient(
145deg,
var(--app-selection-surface-strong),
var(--app-selection-surface)
);
box-shadow:
inset 0 0 0 1px var(--app-selection-border),
0 10px 20px -16px var(--app-selection-glow);
}
}
}
.header-actions--leading {
margin-left: -4px;
}
.header-actions--trailing {
margin-left: auto;
padding-left: 8px;
border-left: 1px solid var(--mat-sys-outline-variant);
app-region: no-drag;
}
// ─── Download Activity Indicator ─────────────────────────────────────────────
.download-btn-wrap {
position: relative;
display: inline-flex;
align-items: center;
justify-content: center;
}
.download-activity-bar {
position: absolute;
bottom: 2px;
left: 50%;
transform: translateX(-50%);
width: 18px;
height: 2px;
border-radius: 1px;
background: rgba(255, 255, 255, 0.08);
overflow: hidden;
pointer-events: none;
z-index: 10;
}
.download-activity-bar__fill {
display: block;
position: absolute;
top: 0;
left: -50%;
width: 50%;
height: 100%;
border-radius: inherit;
background: var(--app-selection-color, #78adff);
animation: download-bar-slide 1.4s ease-in-out infinite;
}
@keyframes download-bar-slide {
0% {
left: -50%;
}
50% {
left: 100%;
}
100% {
left: -50%;
}
}
@media (max-width: 960px) {
.search-field {
min-width: 120px;
flex: 1 1 150px;
.search-chip {
max-width: 150px;
}
}
}
@media (max-width: 780px) {
.workspace-header {
padding-inline: 10px;
gap: 10px;
}
.playlist-switcher {
width: 190px;
}
.header-actions {
gap: 0;
}
.search-field {
gap: 6px;
padding-inline: 10px;
.search-chip {
max-width: 116px;
padding-inline: 8px;
}
}
}
@media (max-width: 640px) {
.playlist-switcher {
width: auto;
min-width: 140px;
flex: 1;
}
.search-field {
.search-chip--status {
display: none;
}
}
}
@@ -0,0 +1,147 @@
import {
Component,
input,
output,
} from '@angular/core';
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { NoopAnimationsModule } from '@angular/platform-browser/animations';
import { MatIconButton } from '@angular/material/button';
import { MatIcon } from '@angular/material/icon';
import { MatTooltip } from '@angular/material/tooltip';
import { TranslateService } from '@ngx-translate/core';
import { of } from 'rxjs';
import { TranslatePipe } from '@ngx-translate/core';
import { WorkspaceShellHeaderComponent } from './workspace-shell-header.component';
@Component({
selector: 'app-playlist-switcher',
template: '',
standalone: true,
})
class MockPlaylistSwitcherComponent {
readonly currentTitle = input.required<string>();
readonly subtitle = input('');
readonly showPlaylistInfo = input(false);
readonly showAccountInfo = input(false);
readonly showAddPlaylist = input(false);
readonly playlistInfoRequested = output<void>();
readonly accountInfoRequested = output<void>();
readonly addPlaylistRequested = output<void>();
}
describe('WorkspaceShellHeaderComponent', () => {
let fixture: ComponentFixture<WorkspaceShellHeaderComponent>;
let component: WorkspaceShellHeaderComponent;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [WorkspaceShellHeaderComponent, NoopAnimationsModule],
providers: [
{
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',
},
},
],
})
.overrideComponent(WorkspaceShellHeaderComponent, {
set: {
imports: [
MatIcon,
MatIconButton,
MatTooltip,
MockPlaylistSwitcherComponent,
TranslatePipe,
],
},
})
.compileComponents();
fixture = TestBed.createComponent(WorkspaceShellHeaderComponent);
component = fixture.componentInstance;
fixture.componentRef.setInput('playlistTitle', 'Playlist A');
fixture.componentRef.setInput('playlistSubtitle', 'Subtitle');
fixture.componentRef.setInput('searchQuery', 'neo');
fixture.componentRef.setInput('canUseSearch', true);
fixture.componentRef.setInput(
'searchPlaceholder',
'WORKSPACE.SHELL.SEARCH_PLAYLIST_PLACEHOLDER'
);
fixture.detectChanges();
});
it('emits search input changes as user types', () => {
const emitted: string[] = [];
component.searchChanged.subscribe((value) => emitted.push(value));
const input: HTMLInputElement =
fixture.nativeElement.querySelector('input[type="search"]');
input.value = 'matrix';
input.dispatchEvent(new Event('input'));
expect(emitted).toEqual(['matrix']);
});
it('emits add playlist requests when the toolbar add button is clicked', () => {
const requested = jest.fn();
component.addPlaylistRequested.subscribe(requested);
const button: HTMLButtonElement = fixture.nativeElement.querySelector(
'button[aria-label="WORKSPACE.SHELL.ADD_PLAYLIST"]'
);
button.click();
expect(requested).toHaveBeenCalledTimes(1);
});
it('does not render the removed global favorites shortcut', () => {
const button: HTMLButtonElement | null =
fixture.nativeElement.querySelector(
'button[aria-label="WORKSPACE.SHELL.OPEN_GLOBAL_FAVORITES"]'
);
expect(button).toBeNull();
});
it('emits contextual header shortcut requests when configured', () => {
const requested = jest.fn();
component.headerShortcutRequested.subscribe(requested);
fixture.componentRef.setInput('headerShortcut', {
icon: 'tune',
tooltipKey: 'shortcut.tooltip',
ariaLabelKey: 'shortcut.aria',
run: () => undefined,
});
fixture.detectChanges();
const button: HTMLButtonElement = fixture.nativeElement.querySelector(
'button[aria-label="shortcut.aria"]'
);
button.click();
expect(requested).toHaveBeenCalledTimes(1);
});
it('renders scope and status chips when search metadata is provided', () => {
fixture.componentRef.setInput('searchScopeLabel', 'Movies / All Items');
fixture.componentRef.setInput(
'searchStatusLabel',
'Loaded channels only'
);
fixture.detectChanges();
const chips = Array.from(
fixture.nativeElement.querySelectorAll('.search-chip')
).map((element: Element) => element.textContent?.trim());
expect(chips).toEqual(['Movies / All Items', 'Loaded channels only']);
});
});
@@ -0,0 +1,111 @@
import {
ChangeDetectionStrategy,
Component,
input,
output,
} from '@angular/core';
import { MatIconButton } from '@angular/material/button';
import { MatFormFieldModule } from '@angular/material/form-field';
import { MatIcon } from '@angular/material/icon';
import { MatSelectModule } from '@angular/material/select';
import { MatTooltip } from '@angular/material/tooltip';
import { TranslatePipe } from '@ngx-translate/core';
import { PlaylistSwitcherComponent } from '@iptvnator/playlist/shared/ui';
import { WorkspaceHeaderAction } from '@iptvnator/portal/shared/util';
import { VideoPlayer } from 'shared-interfaces';
@Component({
selector: 'app-workspace-shell-header',
imports: [
MatFormFieldModule,
MatIcon,
MatIconButton,
MatSelectModule,
MatTooltip,
PlaylistSwitcherComponent,
TranslatePipe,
],
templateUrl: './workspace-shell-header.component.html',
styleUrl: './workspace-shell-header.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class WorkspaceShellHeaderComponent {
readonly VideoPlayer = VideoPlayer;
readonly isMac =
typeof navigator !== 'undefined' &&
/Mac|iPhone|iPad|iPod/i.test(navigator.platform);
readonly commandShortcutLabel = this.isMac ? '⌘K' : 'Ctrl+K';
readonly playlistTitle = input('');
readonly playlistSubtitle = input('');
readonly canOpenPlaylistInfo = input(false);
readonly canOpenAccountInfo = input(false);
readonly searchQuery = input('');
readonly canUseSearch = input(false);
readonly searchPlaceholder = input('');
readonly searchScopeLabel = input('');
readonly searchStatusLabel = input('');
readonly headerShortcut = input<WorkspaceHeaderAction | null>(null);
readonly canRefreshPlaylist = input(false);
readonly isRefreshingPlaylist = input(false);
readonly isElectron = input(false);
readonly hasNoPlaylists = input(false);
readonly isDownloadsView = input(false);
readonly hasActiveDownloads = input(false);
readonly showQuickPlayerSelect = input(false);
readonly quickHeaderPlayerValue = input(VideoPlayer.ArtPlayer);
readonly searchChanged = output<string>();
readonly searchSubmitted = output<string>();
readonly commandPaletteRequested = output<void>();
readonly addPlaylistRequested = output<void>();
readonly headerShortcutRequested = output<void>();
readonly refreshPlaylistRequested = output<void>();
readonly downloadsRequested = output<void>();
readonly playlistInfoRequested = output<void>();
readonly accountInfoRequested = output<void>();
readonly quickHeaderPlayerChange = output<VideoPlayer>();
onSearchInput(event: Event): void {
const target = event.target as HTMLInputElement | null;
this.searchChanged.emit(target?.value ?? '');
}
onSearchEnter(event: Event): void {
const target = event.target as HTMLInputElement | null;
this.searchSubmitted.emit(target?.value ?? this.searchQuery());
}
onPlaylistInfoRequested(): void {
this.playlistInfoRequested.emit();
}
onAccountInfoRequested(): void {
this.accountInfoRequested.emit();
}
onAddPlaylistRequested(): void {
this.addPlaylistRequested.emit();
}
onCommandPaletteRequested(): void {
this.commandPaletteRequested.emit();
}
onHeaderShortcutRequested(): void {
this.headerShortcutRequested.emit();
}
onRefreshPlaylistRequested(): void {
this.refreshPlaylistRequested.emit();
}
onDownloadsRequested(): void {
this.downloadsRequested.emit();
}
onQuickPlayerChange(value: VideoPlayer): void {
this.quickHeaderPlayerChange.emit(value);
}
}
@@ -0,0 +1,20 @@
<nav class="rail-links">
@for (link of links(); track link.path.join('/')) {
<a
class="portal-rail-link nav-item"
[routerLink]="link.path"
[routerLinkActive]="activeClass()"
[routerLinkActiveOptions]="{ exact: link.exact ?? false }"
[matTooltip]="link.tooltip"
[matTooltipPosition]="'right'"
[attr.aria-label]="link.tooltip"
[attr.aria-current]="isActive(link) ? 'page' : null"
[class.active]="activeClass() === 'active' && isActive(link)"
[class.is-active]="
activeClass() === 'is-active' && isActive(link)
"
>
<mat-icon>{{ resolveIcon(link) }}</mat-icon>
</a>
}
</nav>
@@ -0,0 +1,99 @@
:host {
display: block;
width: 100%;
--rail-link-color: var(--mat-sys-on-surface-variant);
}
.rail-links {
display: flex;
flex-direction: column;
align-items: center;
gap: 2px;
width: 100%;
}
.portal-rail-link {
width: 44px;
height: 44px;
padding: 0;
gap: 0;
border-radius: 14px;
position: relative;
display: inline-flex;
align-items: center;
justify-content: center;
color: var(--rail-link-color);
text-decoration: none;
app-region: no-drag;
animation: rail-link-enter 0.2s ease-out both;
transition:
background-color 0.2s ease,
color 0.2s ease,
box-shadow 0.2s ease,
transform 0.2s ease;
&:hover {
color: var(--rail-link-hover-color, var(--mat-sys-on-surface));
background: var(
--rail-link-hover-bg,
var(--mat-sys-surface-container)
);
}
mat-icon {
font-size: 20px;
width: 20px;
height: 20px;
}
}
@for $i from 1 through 8 {
.portal-rail-link:nth-child(#{$i}) {
animation-delay: #{($i - 1) * 30}ms;
}
}
@keyframes rail-link-enter {
from {
opacity: 0;
transform: translateY(-6px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.portal-rail-link.is-active,
.portal-rail-link.active {
color: var(--rail-link-active-color, var(--app-selection-color));
background: linear-gradient(
145deg,
var(--rail-link-active-bg-strong, var(--app-selection-surface-strong)),
var(--rail-link-active-bg, var(--app-selection-surface))
);
box-shadow:
inset 0 0 0 1px
var(--rail-link-active-border, var(--app-selection-border)),
0 10px 20px -16px
var(--rail-link-active-glow, var(--app-selection-glow));
transform: translateY(-1px);
mat-icon {
color: var(--rail-link-active-icon, var(--app-selection-color));
}
}
.portal-rail-link.is-active::after,
.portal-rail-link.active::after {
content: '';
position: absolute;
right: -4px;
top: 50%;
transform: translateY(-50%);
width: 4px;
height: 20px;
border-radius: 999px;
background: var(--rail-link-active-icon, var(--app-selection-color));
box-shadow: 0 0 10px var(--rail-link-active-glow, var(--app-selection-glow));
}
@@ -0,0 +1,56 @@
import { ChangeDetectionStrategy, Component, inject, input } from '@angular/core';
import { MatIcon } from '@angular/material/icon';
import { MatTooltip } from '@angular/material/tooltip';
import {
IsActiveMatchOptions,
Router,
RouterLink,
RouterLinkActive,
} from '@angular/router';
import {
PortalRailLink,
PortalRailSection,
} from '@iptvnator/portal/shared/util';
@Component({
selector: 'app-workspace-shell-rail-links',
imports: [MatIcon, MatTooltip, RouterLink, RouterLinkActive],
templateUrl: './workspace-shell-rail-links.component.html',
styleUrl: './workspace-shell-rail-links.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class WorkspaceShellRailLinksComponent {
private readonly router = inject(Router);
readonly links = input<PortalRailLink[]>([]);
readonly selectedSection = input<
PortalRailSection | string | null | undefined
>(null);
readonly activeClass = input<'active' | 'is-active'>('is-active');
resolveIcon(link: PortalRailLink): string {
const normalizedPath = link.path.map((segment) => String(segment)).join('/');
if (normalizedPath.includes('/workspace/sources')) {
return 'playlist_play';
}
return link.icon;
}
isActive(link: PortalRailLink): boolean {
const selectedSection = this.selectedSection();
if (selectedSection && link.section) {
return selectedSection === link.section;
}
const commands = link.path.map((segment) => String(segment));
const tree = this.router.createUrlTree(commands);
const matchOptions: IsActiveMatchOptions = {
paths: link.exact ? 'exact' : 'subset',
queryParams: 'ignored',
fragment: 'ignored',
matrixParams: 'ignored',
};
return this.router.isActive(tree, matchOptions);
}
}
@@ -0,0 +1,51 @@
<aside class="app-rail" [class.is-macos]="isMacOS()">
<a
class="brand"
[routerLink]="brandLink()"
[matTooltip]="brandTooltipKey() | translate"
[matTooltipPosition]="'right'"
[attr.aria-label]="brandAriaLabelKey() | translate"
>
<img
src="./assets/icons/icon-tv-256.png"
[attr.alt]="'WORKSPACE.SHELL.BRAND_ALT' | translate"
/>
</a>
<app-workspace-shell-rail-links [links]="workspaceLinks()" />
@if (primaryContextLinks().length > 0) {
<div
class="rail-context-region"
[class]="railProviderClass()"
>
<app-workspace-shell-rail-links
activeClass="is-active"
[selectedSection]="selectedSection()"
[links]="primaryContextLinks()"
/>
@if (secondaryContextLinks().length > 0) {
<div class="rail-divider"></div>
<app-workspace-shell-rail-links
activeClass="is-active"
[selectedSection]="selectedSection()"
[links]="secondaryContextLinks()"
/>
}
</div>
}
<div class="rail-footer">
<a
class="rail-shortcut nav-item"
routerLink="/workspace/settings"
[class.is-active]="isSettingsRoute()"
[matTooltip]="'WORKSPACE.SHELL.RAIL_SETTINGS' | translate"
[matTooltipPosition]="'right'"
[attr.aria-label]="'WORKSPACE.SHELL.OPEN_SETTINGS' | translate"
>
<mat-icon>settings</mat-icon>
</a>
</div>
</aside>
@@ -0,0 +1,267 @@
:host {
display: block;
min-width: 0;
min-height: 0;
}
.app-rail {
display: flex;
flex-direction: column;
align-items: center;
gap: 2px;
height: 100%;
box-sizing: border-box;
padding: 10px 8px;
border-right: 1px solid
var(--app-rail-border, var(--mat-sys-outline-variant));
background: var(--app-rail-bg, var(--mat-sys-surface-container-low));
app-region: drag;
overflow-y: auto;
overflow-x: hidden;
scrollbar-width: none;
--rail-link-color: color-mix(in srgb, var(--app-on-surface) 65%, transparent);
--rail-link-hover-color: var(--app-on-surface);
--rail-link-hover-bg: var(--app-hover-overlay);
--rail-link-active-bg: var(--app-selection-surface);
--rail-link-active-bg-strong: var(--app-selection-surface-strong);
--rail-link-active-color: var(--app-selection-color);
--rail-link-active-icon: var(--app-selection-color);
--rail-link-active-border: var(--app-selection-border);
--rail-link-active-glow: var(--app-selection-glow);
&.is-macos {
padding-top: 36px;
}
&::-webkit-scrollbar {
display: none;
}
}
.app-rail a {
width: 44px;
height: 44px;
border-radius: 14px;
position: relative;
display: inline-flex;
align-items: center;
justify-content: center;
color: var(--rail-link-color);
text-decoration: none;
transition:
background-color 0.2s ease,
color 0.2s ease,
box-shadow 0.2s ease,
transform 0.2s ease;
app-region: no-drag;
mat-icon {
font-size: 20px;
width: 20px;
height: 20px;
}
}
.app-rail a:hover {
color: var(--rail-link-hover-color);
background: var(--rail-link-hover-bg);
}
.app-rail a.is-active,
.app-rail a.active {
color: var(--rail-link-active-color);
background: linear-gradient(
145deg,
var(--rail-link-active-bg-strong),
var(--rail-link-active-bg)
);
box-shadow:
inset 0 0 0 1px var(--rail-link-active-border),
0 10px 20px -16px var(--rail-link-active-glow);
transform: translateY(-1px);
mat-icon {
color: var(--rail-link-active-icon);
}
&::after {
content: '';
position: absolute;
right: -4px;
top: 50%;
transform: translateY(-50%);
width: 3px;
height: 18px;
border-radius: 999px;
background: var(--rail-link-active-icon);
box-shadow: 0 0 10px var(--rail-link-active-glow);
}
}
.brand {
margin-bottom: 10px;
background: var(--mat-sys-primary-container);
color: var(--mat-sys-on-primary-container);
width: 40px;
height: 40px;
border-radius: 12px;
display: inline-flex;
align-items: center;
justify-content: center;
}
.brand img {
width: 24px;
height: 24px;
object-fit: contain;
}
.rail-context-region {
--region-accent: var(--mat-sys-outline-variant);
display: flex;
flex-direction: column;
align-items: center;
gap: 2px;
width: 100%;
max-width: 48px;
padding: 5px 2px;
margin: 4px 0;
border-radius: 14px;
background: color-mix(in srgb, var(--app-on-surface) 3%, transparent);
border: 1px solid color-mix(in srgb, var(--app-on-surface) 4%, transparent);
position: relative;
animation: region-enter 0.25s ease-out both;
&::before {
content: '';
position: absolute;
top: -1px;
left: 10px;
right: 10px;
height: 2px;
border-radius: 0 0 2px 2px;
background: var(--region-accent);
opacity: 0.5;
}
.rail-divider {
width: 24px;
margin: 3px 0;
opacity: 0.25;
}
}
.rail-context-region--xtreams {
--region-accent: #7c8cf5;
}
.rail-context-region--stalker {
--region-accent: #4ecdc4;
}
.rail-context-region--playlists {
--region-accent: #f5a623;
}
@keyframes region-enter {
from {
opacity: 0;
transform: scale(0.95);
}
to {
opacity: 1;
transform: scale(1);
}
}
.rail-divider {
width: 44px;
height: 1px;
background: var(--mat-sys-outline-variant);
margin: 6px 0;
opacity: 0.5;
}
.rail-footer {
margin-top: auto;
.rail-shortcut {
width: 44px;
height: 44px;
padding: 0;
gap: 0;
border-radius: 14px;
position: relative;
display: inline-flex;
align-items: center;
justify-content: center;
color: color-mix(in srgb, var(--app-on-surface) 65%, transparent);
app-region: no-drag;
mat-icon {
font-size: 20px;
width: 20px;
height: 20px;
}
&:hover:not(.is-active) {
background: var(--app-hover-overlay);
color: var(--app-on-surface);
}
&.is-active::after {
content: '';
position: absolute;
right: -4px;
top: 50%;
transform: translateY(-50%);
width: 4px;
height: 20px;
border-radius: 999px;
background: var(--app-selection-color);
box-shadow: 0 0 10px var(--app-selection-glow);
}
}
}
@media (max-width: 640px) {
.app-rail {
flex-direction: row;
justify-content: space-between;
padding: 6px 10px;
border-right: none;
border-bottom: 1px solid var(--mat-sys-outline-variant);
.brand {
margin-bottom: 0;
}
}
.rail-divider {
width: 1px;
height: 24px;
margin: 0 4px;
}
.rail-context-region {
width: auto;
flex-direction: row;
margin: 0;
padding: 2px 4px;
border-radius: 10px;
&::before {
display: none;
}
.rail-divider {
width: 1px;
height: 24px;
margin: 0 2px;
}
}
.rail-footer {
margin-top: 0;
}
}
@@ -0,0 +1,100 @@
import { Component, input } from '@angular/core';
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { MatIcon } from '@angular/material/icon';
import { MatTooltip } from '@angular/material/tooltip';
import { provideRouter } from '@angular/router';
import { TranslateService } from '@ngx-translate/core';
import { of } from 'rxjs';
import { RouterLink } from '@angular/router';
import { TranslatePipe } from '@ngx-translate/core';
import { WorkspaceShellRailComponent } from './workspace-shell-rail.component';
@Component({
selector: 'app-workspace-shell-rail-links',
template: '',
standalone: true,
})
class MockWorkspaceShellRailLinksComponent {
readonly links = input<unknown[]>([]);
readonly selectedSection = input<string | null>(null);
readonly activeClass = input('active');
}
describe('WorkspaceShellRailComponent', () => {
let fixture: ComponentFixture<WorkspaceShellRailComponent>;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [WorkspaceShellRailComponent],
providers: [
provideRouter([]),
{
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',
},
},
],
})
.overrideComponent(WorkspaceShellRailComponent, {
set: {
imports: [
MatIcon,
MatTooltip,
MockWorkspaceShellRailLinksComponent,
RouterLink,
TranslatePipe,
],
},
})
.compileComponents();
fixture = TestBed.createComponent(WorkspaceShellRailComponent);
});
it('renders provider context region and active settings shortcut state', () => {
fixture.componentRef.setInput('brandLink', '/workspace/sources');
fixture.componentRef.setInput(
'brandAriaLabelKey',
'WORKSPACE.SHELL.OPEN_SOURCES'
);
fixture.componentRef.setInput('primaryContextLinks', [
{
icon: 'movie',
tooltip: 'Movies',
path: ['/workspace', 'xtreams', 'pl-1', 'vod'],
section: 'vod',
},
]);
fixture.componentRef.setInput(
'railProviderClass',
'rail-context-region rail-context-region--xtreams'
);
fixture.componentRef.setInput('isSettingsRoute', true);
fixture.detectChanges();
expect(
fixture.nativeElement.querySelector('.rail-context-region--xtreams')
).not.toBeNull();
expect(
fixture.nativeElement.querySelector('.rail-shortcut.is-active')
).not.toBeNull();
expect(
fixture.nativeElement
.querySelector('.brand')
?.getAttribute('href')
).toContain('/workspace/sources');
expect(
fixture.nativeElement
.querySelector('.brand')
?.getAttribute('aria-label')
).toBe('WORKSPACE.SHELL.OPEN_SOURCES');
});
});
@@ -0,0 +1,42 @@
import {
ChangeDetectionStrategy,
Component,
input,
} from '@angular/core';
import { MatIcon } from '@angular/material/icon';
import { MatTooltip } from '@angular/material/tooltip';
import { RouterLink } from '@angular/router';
import { TranslatePipe } from '@ngx-translate/core';
import {
PortalRailLink,
PortalRailSection,
} from '@iptvnator/portal/shared/util';
import { WorkspaceShellRailLinksComponent } from '../workspace-shell-rail-links/workspace-shell-rail-links.component';
@Component({
selector: 'app-workspace-shell-rail',
imports: [
MatIcon,
MatTooltip,
RouterLink,
TranslatePipe,
WorkspaceShellRailLinksComponent,
],
templateUrl: './workspace-shell-rail.component.html',
styleUrl: './workspace-shell-rail.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class WorkspaceShellRailComponent {
readonly isMacOS = input(false);
readonly brandLink = input('/workspace/dashboard');
readonly brandTooltipKey = input('WORKSPACE.SHELL.RAIL_DASHBOARD');
readonly brandAriaLabelKey = input('WORKSPACE.SHELL.OPEN_DASHBOARD');
readonly workspaceLinks = input<PortalRailLink[]>([]);
readonly primaryContextLinks = input<PortalRailLink[]>([]);
readonly secondaryContextLinks = input<PortalRailLink[]>([]);
readonly selectedSection = input<
PortalRailSection | string | null | undefined
>(null);
readonly railProviderClass = input('rail-context-region');
readonly isSettingsRoute = input(false);
}
@@ -0,0 +1,578 @@
import { signal } from '@angular/core';
import { TestBed } from '@angular/core/testing';
import { MatDialog } from '@angular/material/dialog';
import { NavigationEnd, Router } from '@angular/router';
import { Store } from '@ngrx/store';
import { TranslateService } from '@ngx-translate/core';
import { of } from 'rxjs';
import { PlaylistContextFacade } from '@iptvnator/playlist/shared/util';
import {
PORTAL_EXTERNAL_PLAYBACK,
WorkspaceHeaderContextService,
WorkspaceViewCommandService,
} from '@iptvnator/portal/shared/util';
import { StalkerStore } from '@iptvnator/portal/stalker/data-access';
import { XtreamStore } from '@iptvnator/portal/xtream/data-access';
import {
PlaylistsService,
SettingsStore,
WORKSPACE_PRODUCT_BRANDING,
} from 'services';
import { PlaylistMeta } from 'shared-interfaces';
import {
WorkspaceStartupPreferencesService,
WORKSPACE_SHELL_ACTIONS,
} from '@iptvnator/workspace/shell/util';
import { WorkspaceShellFacade } from './workspace-shell.facade';
class MockXtreamStore {
readonly recentItems = signal<unknown[]>([]);
readonly searchTerm = signal('');
readonly categorySearchTerm = signal('');
readonly isImporting = signal(false);
readonly isCancellingImport = signal(false);
readonly activeImportSessionId = signal<string | null>(null);
readonly currentImportPhase = signal<string | null>(null);
readonly activeImportContentType = signal<'live' | 'vod' | 'series' | null>(
null
);
readonly activeImportCurrentCount = signal(0);
readonly activeImportTotalCount = signal(0);
readonly getImportCount = signal(0);
readonly itemsToImport = signal(0);
readonly getSelectedCategory = signal<{
category_name?: string;
name?: string;
} | null>(null);
readonly vodStreams = signal<unknown[]>([{ id: 1 }]);
readonly liveStreams = signal<unknown[]>([{ id: 2 }]);
readonly serialStreams = signal<unknown[]>([{ id: 3 }]);
setSearchTerm = jest.fn((term: string) => this.searchTerm.set(term));
setCategorySearchTerm = jest.fn((term: string) =>
this.categorySearchTerm.set(term)
);
clearRecentItems = jest.fn();
}
class MockStalkerStore {
readonly searchPhrase = signal('');
readonly getSelectedCategoryName = signal('All Items');
setSearchPhrase = jest.fn((term: string) => this.searchPhrase.set(term));
}
function createParseUrl(url: string): { queryParams: Record<string, unknown> } {
const parsed = new URL(url, 'http://localhost');
const queryParams: Record<string, unknown> = {};
parsed.searchParams.forEach((value, key) => {
const currentValue = queryParams[key];
if (currentValue === undefined) {
queryParams[key] = value;
return;
}
if (Array.isArray(currentValue)) {
currentValue.push(value);
return;
}
queryParams[key] = [currentValue, value];
});
return { queryParams };
}
describe('WorkspaceShellFacade', () => {
type PlaylistSignalMeta = PlaylistMeta & {
serverUrl?: string;
macAddress?: string;
};
let facade: WorkspaceShellFacade;
let router: {
url: string;
events: ReturnType<typeof of>;
navigate: jest.Mock;
navigateByUrl: jest.Mock;
parseUrl: jest.Mock;
createUrlTree: jest.Mock;
isActive: jest.Mock;
};
let playlistsService: {
clearPortalRecentlyViewed: jest.Mock;
clearM3uRecentlyViewed: jest.Mock;
};
let workspaceActions: {
openAddPlaylistDialog: jest.Mock;
openGlobalSearch: jest.Mock;
openGlobalRecent: jest.Mock;
openAccountInfo: jest.Mock;
};
let storeDispatch: jest.Mock;
let activePlaylistSignal: ReturnType<
typeof signal<PlaylistSignalMeta | null>
>;
let playlistsSignal: ReturnType<typeof signal<PlaylistSignalMeta[]>>;
let stalkerStore: MockStalkerStore;
let showDashboardSignal: ReturnType<typeof signal<boolean>>;
let startupPreferences: {
getFirstAvailableWorkspacePath: jest.Mock;
persistLastRestorablePath: jest.Mock;
showDashboard: jest.Mock;
};
beforeEach(() => {
window.electron = { platform: 'darwin' } as typeof window.electron;
showDashboardSignal = signal(true);
activePlaylistSignal = signal({
_id: 'pl-1',
serverUrl: 'http://example.com',
title: 'Playlist A',
recentlyViewed: ['recent-1'],
});
playlistsSignal = signal([
{ _id: 'pl-1', serverUrl: 'http://example.com' },
{ _id: 'pl-2', macAddress: '00:11:22:33' },
]);
router = {
url: '/workspace/xtreams/pl-1/vod',
events: of(
new NavigationEnd(
1,
'/workspace/xtreams/pl-1/vod',
'/workspace/xtreams/pl-1/vod'
)
),
navigate: jest.fn().mockResolvedValue(true),
navigateByUrl: jest.fn().mockResolvedValue(true),
parseUrl: jest.fn((url: string) => createParseUrl(url)),
createUrlTree: jest.fn(),
isActive: jest.fn(),
};
playlistsService = {
clearPortalRecentlyViewed: jest
.fn()
.mockReturnValue(of({ recentlyViewed: [] })),
clearM3uRecentlyViewed: jest
.fn()
.mockReturnValue(of({ recentlyViewed: [] })),
};
workspaceActions = {
openAddPlaylistDialog: jest.fn(),
openGlobalSearch: jest.fn(),
openGlobalRecent: jest.fn(),
openAccountInfo: jest.fn(),
};
startupPreferences = {
getFirstAvailableWorkspacePath: jest.fn((showDashboard: boolean) =>
showDashboard ? '/workspace/dashboard' : '/workspace/sources'
),
persistLastRestorablePath: jest.fn(),
showDashboard: jest.fn(() => showDashboardSignal()),
};
storeDispatch = jest.fn();
stalkerStore = new MockStalkerStore();
const selectSignal = jest.fn().mockReturnValue(playlistsSignal);
TestBed.configureTestingModule({
providers: [
WorkspaceShellFacade,
{
provide: Router,
useValue: router,
},
{
provide: Store,
useValue: {
selectSignal,
dispatch: storeDispatch,
},
},
{
provide: XtreamStore,
useClass: MockXtreamStore,
},
{
provide: StalkerStore,
useValue: stalkerStore,
},
{
provide: PORTAL_EXTERNAL_PLAYBACK,
useValue: {
activeSession: signal(null),
visibleSession: signal(null),
closeSession: jest.fn(),
},
},
{
provide: PlaylistContextFacade,
useValue: {
activePlaylist: activePlaylistSignal,
},
},
{
provide: SettingsStore,
useValue: {
showExternalPlaybackBar: signal(true),
},
},
{
provide: PlaylistsService,
useValue: playlistsService,
},
{
provide: MatDialog,
useValue: {
open: jest.fn().mockReturnValue({
afterClosed: () => of(undefined),
}),
},
},
{
provide: WORKSPACE_SHELL_ACTIONS,
useValue: workspaceActions,
},
{
provide: WorkspaceStartupPreferencesService,
useValue: startupPreferences,
},
{
provide: WORKSPACE_PRODUCT_BRANDING,
useValue: null,
},
{
provide: TranslateService,
useValue: {
instant: (
key: string,
params?: Record<string, string | number>
) => {
if (
key === 'WORKSPACE.SHELL.XTREAM_IMPORT_PROGRESS' &&
params
) {
return `${params.type} imported: ${params.current} / ${params.total}`;
}
return key;
},
get: (key: string) => of(key),
stream: (key: string) => of(key),
onLangChange: of(null),
onTranslationChange: of(null),
onDefaultLangChange: of(null),
currentLang: 'en',
defaultLang: 'en',
},
},
],
});
facade = TestBed.inject(WorkspaceShellFacade);
});
it('routes dashboard search Enter into the active Xtream playlist search', () => {
const xtreamStore = TestBed.inject(XtreamStore) as unknown as MockXtreamStore;
facade.currentUrl.set('/workspace/dashboard');
router.navigate.mockClear();
facade.onSearchEnter('Matrix');
expect(xtreamStore.setSearchTerm).toHaveBeenCalledWith('Matrix');
expect(router.navigate).toHaveBeenCalledWith(
['/workspace', 'xtreams', 'pl-1', 'search'],
{
queryParams: { q: 'Matrix' },
}
);
});
it('uses a loading label for remote Xtream fetch phases', () => {
const xtreamStore = TestBed.inject(XtreamStore) as unknown as MockXtreamStore;
xtreamStore.currentImportPhase.set('loading-categories');
expect(facade.xtreamImportPhaseLabel()).toBe(
'WORKSPACE.SHELL.XTREAM_IMPORT_LOADING'
);
xtreamStore.currentImportPhase.set('loading-live');
expect(facade.xtreamImportPhaseLabel()).toBe(
'WORKSPACE.SHELL.XTREAM_IMPORT_LOADING'
);
expect(facade.xtreamImportSourceLabel()).toBe(
'WORKSPACE.SHELL.XTREAM_IMPORT_REMOTE_BADGE'
);
expect(facade.xtreamImportDetailLabel()).toBe(
'WORKSPACE.SHELL.XTREAM_IMPORT_DETAIL_REMOTE'
);
xtreamStore.currentImportPhase.set('saving-categories');
expect(facade.xtreamImportPhaseLabel()).toBe(
'WORKSPACE.SHELL.XTREAM_IMPORT_SAVING'
);
xtreamStore.currentImportPhase.set('saving-content');
expect(facade.xtreamImportPhaseLabel()).toBe(
'WORKSPACE.SHELL.XTREAM_IMPORT_SAVING'
);
expect(facade.xtreamImportSourceLabel()).toBe(
'WORKSPACE.SHELL.XTREAM_IMPORT_LOCAL_BADGE'
);
expect(facade.xtreamImportDetailLabel()).toBe(
'WORKSPACE.SHELL.XTREAM_IMPORT_DETAIL_LOCAL'
);
});
it('builds a type-aware xtream import progress label', () => {
const xtreamStore = TestBed.inject(XtreamStore) as unknown as MockXtreamStore;
xtreamStore.activeImportContentType.set('vod');
xtreamStore.activeImportCurrentCount.set(20);
xtreamStore.activeImportTotalCount.set(12323);
expect(facade.xtreamImportTypeLabel()).toBe(
'WORKSPACE.SHELL.RAIL_MOVIES'
);
expect(facade.xtreamImportProgressLabel()).toBe(
'WORKSPACE.SHELL.RAIL_MOVIES imported: 20 / 12,323'
);
});
it('keeps stalker local-filter routes on the same page and syncs q', () => {
router.navigate.mockClear();
router.navigateByUrl.mockClear();
facade.currentUrl.set('/workspace/stalker/pl-1/downloads');
facade.onSearchEnter('Neo');
TestBed.flushEffects();
expect(router.navigate).not.toHaveBeenCalled();
expect(router.navigateByUrl).toHaveBeenCalledWith(
'/workspace/stalker/pl-1/downloads?q=Neo',
{
replaceUrl: true,
}
);
});
it('removes the q param when clearing a sources search', async () => {
facade.appliedSearchQuery.set('matrix');
facade.currentUrl.set('/workspace/sources?q=matrix');
router.navigateByUrl.mockClear();
facade.onSearchEnter('');
TestBed.flushEffects();
expect(router.navigateByUrl).toHaveBeenCalledWith('/workspace/sources', {
replaceUrl: true,
});
});
it('navigates to the global favorites route', () => {
router.navigate.mockClear();
facade.currentUrl.set('/workspace/xtreams/pl-1/vod');
facade.navigateToGlobalFavorites();
expect(router.navigate).toHaveBeenCalledWith([
'/workspace/global-favorites',
]);
});
it('clears stalker recent items and refreshes the route', async () => {
facade.currentUrl.set('/workspace/stalker/pl-1/recent');
router.navigateByUrl.mockClear();
await facade.runHeaderBulkAction();
expect(playlistsService.clearPortalRecentlyViewed).toHaveBeenCalledWith(
'pl-1'
);
expect(storeDispatch).toHaveBeenCalled();
expect(router.navigateByUrl).toHaveBeenCalledWith(
expect.stringMatching(/^\/workspace\/stalker\/pl-1\/recent\?refresh=/),
{
replaceUrl: true,
}
);
});
it('exposes loaded-only status for stalker itv searches', () => {
facade.currentUrl.set('/workspace/stalker/pl-1/itv?q=cnn');
(facade as { syncSearchFromRoute: () => void }).syncSearchFromRoute();
TestBed.flushEffects();
expect(stalkerStore.setSearchPhrase).toHaveBeenCalledWith('cnn');
expect(facade.canUseSearch()).toBe(true);
expect(facade.searchStatusLabel()).toBe(
'WORKSPACE.SHELL.SEARCH_STATUS_LOADED_ONLY'
);
});
it('applies q to Xtream category search on vod routes', () => {
const xtreamStore = TestBed.inject(XtreamStore) as unknown as MockXtreamStore;
facade.currentUrl.set('/workspace/xtreams/pl-1/vod?q=neo');
(facade as { syncSearchFromRoute: () => void }).syncSearchFromRoute();
TestBed.flushEffects();
expect(xtreamStore.setCategorySearchTerm).toHaveBeenCalledWith('neo');
expect(xtreamStore.setSearchTerm).not.toHaveBeenCalled();
});
it('enables local-filter search on playlist favorites routes', () => {
facade.currentUrl.set('/workspace/playlists/pl-1/favorites?q=news');
(facade as { syncSearchFromRoute: () => void }).syncSearchFromRoute();
expect(facade.canUseSearch()).toBe(true);
expect(facade.searchQuery()).toBe('news');
});
it('uses the translated global favorites scope label on the global favorites route', () => {
facade.currentUrl.set('/workspace/global-favorites?q=news');
(facade as { syncSearchFromRoute: () => void }).syncSearchFromRoute();
expect(facade.searchScopeLabel()).toBe(
'HOME.PLAYLISTS.GLOBAL_FAVORITES'
);
});
it('removes the dashboard rail link when dashboard is hidden', () => {
showDashboardSignal.set(false);
expect(facade.workspaceLinks()).toEqual([
{
icon: 'library_books',
tooltip: 'WORKSPACE.SHELL.RAIL_SOURCES',
path: ['/workspace/sources'],
},
{
icon: 'favorite',
tooltip: 'WORKSPACE.SHELL.RAIL_GLOBAL_FAVORITES',
path: ['/workspace/global-favorites'],
exact: true,
},
{
icon: 'history',
tooltip: 'WORKSPACE.SHELL.RAIL_GLOBAL_RECENT',
path: ['/workspace/global-recent'],
exact: true,
},
]);
expect(facade.brandLink()).toBe('/workspace/sources');
});
it('persists the last restorable route from navigation events', () => {
expect(startupPreferences.persistLastRestorablePath).toHaveBeenCalledWith(
'/workspace/xtreams/pl-1/vod'
);
});
it('uses the translated recent scope label on the global recent route', () => {
facade.currentUrl.set('/workspace/global-recent?q=news');
(facade as { syncSearchFromRoute: () => void }).syncSearchFromRoute();
expect(facade.searchScopeLabel()).toBe('PORTALS.RECENTLY_VIEWED');
});
it('shows only actionable global commands on an empty dashboard', () => {
activePlaylistSignal.set(null);
playlistsSignal.set([]);
facade.currentUrl.set('/workspace/dashboard');
const commands = facade.commandPaletteCommands();
expect(commands.map((command) => command.id)).toEqual([
'open-global-favorites',
'open-global-recent',
'open-downloads',
'open-settings',
'open-sources',
'add-playlist',
]);
expect(commands.every((command) => command.group === 'global')).toBe(
true
);
expect(commands.every((command) => command.enabled)).toBe(true);
});
it('includes M3U navigation, playlist actions, and Multi-EPG on playlist routes', () => {
const headerContext = TestBed.inject(WorkspaceHeaderContextService);
activePlaylistSignal.set({
_id: 'pl-m3u',
title: 'Playlist M3U',
count: 10,
importDate: '2026-04-22T10:00:00.000Z',
autoRefresh: false,
});
facade.currentUrl.set('/workspace/playlists/pl-m3u/groups');
headerContext.setAction({
id: 'm3u-multi-epg',
icon: 'view_list',
tooltipKey: 'TOP_MENU.OPEN_MULTI_EPG',
ariaLabelKey: 'TOP_MENU.OPEN_MULTI_EPG',
palette: {
labelKey: 'TOP_MENU.OPEN_MULTI_EPG',
descriptionKey:
'WORKSPACE.SHELL.COMMANDS.OPEN_MULTI_EPG_DESCRIPTION',
keywords: ['epg', 'guide', 'schedule'],
priority: 10,
},
run: jest.fn(),
});
const commands = facade.commandPaletteCommands();
expect(commands.map((command) => command.id)).toEqual(
expect.arrayContaining([
'm3u-multi-epg',
'go-to-all',
'go-to-favorites',
'go-to-recent',
'playlist-info',
])
);
expect(
commands.find((command) => command.id === 'm3u-multi-epg')?.group
).toBe('view');
expect(commands.some((command) => command.id === 'account-info')).toBe(
false
);
});
it('places registered current-view commands ahead of globals on global favorites routes', () => {
const viewCommands = TestBed.inject(WorkspaceViewCommandService);
const clearCurrent = jest.fn();
const unregister = viewCommands.registerCommand({
id: 'clear-current-favorites',
group: 'view',
icon: 'delete_sweep',
labelKey: 'WORKSPACE.SHELL.CLEAR_FAVORITES_TYPE',
labelParams: () => ({ type: 'Live TV' }),
descriptionKey:
'WORKSPACE.SHELL.COMMANDS.CLEAR_CURRENT_VIEW_DESCRIPTION',
descriptionParams: () => ({ type: 'Live TV' }),
priority: 10,
run: clearCurrent,
});
facade.currentUrl.set('/workspace/global-favorites');
const commands = facade.commandPaletteCommands();
expect(commands[0]?.id).toBe('clear-current-favorites');
expect(commands[0]?.group).toBe('view');
expect(commands.some((command) => command.id === 'playlist-search')).toBe(
false
);
unregister();
});
});
@@ -0,0 +1,148 @@
<div
class="workspace-shell"
appPlaylistDropZone
#dropZone="playlistDropZone"
>
<app-playlist-drop-overlay [state]="dropZone.overlayState()" />
@if (facade.showXtreamImportOverlay()) {
<div class="workspace-loading-overlay">
<div class="workspace-loading-overlay__card">
<div class="workspace-loading-overlay__header">
@if (facade.xtreamImportSourceLabel()) {
<span
class="workspace-loading-overlay__badge"
[class.workspace-loading-overlay__badge--remote]="
facade.xtreamImportPhaseTone() === 'remote'
"
>
{{ facade.xtreamImportSourceLabel() }}
</span>
}
<h3>{{ facade.xtreamImportTitleLabel() }}</h3>
</div>
@if (facade.xtreamImportPhaseLabel()) {
<p
class="workspace-loading-overlay__phase"
aria-live="polite"
>
{{ facade.xtreamImportPhaseLabel() }}
</p>
}
@if (facade.xtreamImportDetailLabel()) {
<p class="workspace-loading-overlay__detail">
{{ facade.xtreamImportDetailLabel() }}
</p>
}
@if (
facade.xtreamActiveImportCount() !== 0 &&
facade.xtreamActiveItemsToImport() !== 0
) {
<mat-progress-bar
mode="determinate"
[value]="
(facade.xtreamActiveImportCount() /
facade.xtreamActiveItemsToImport()) *
100
"
/>
} @else {
<mat-progress-bar mode="indeterminate" />
}
@if (facade.xtreamImportProgressLabel()) {
<p class="workspace-loading-overlay__progress-copy">
{{ facade.xtreamImportProgressLabel() }}
</p>
}
@if (facade.canCancelXtreamImport()) {
<button
mat-stroked-button
class="workspace-loading-overlay__action"
type="button"
[disabled]="facade.isCancellingXtreamImport()"
(click)="facade.cancelXtreamImport()"
>
{{
(facade.isCancellingXtreamImport()
? 'WORKSPACE.SHELL.XTREAM_IMPORT_CANCELLING'
: 'WORKSPACE.SHELL.XTREAM_IMPORT_CANCEL'
) | translate
}}
</button>
}
</div>
</div>
}
<app-workspace-shell-rail
[isMacOS]="facade.isMacOS"
[brandLink]="facade.brandLink()"
[brandTooltipKey]="facade.brandTooltipKey()"
[brandAriaLabelKey]="facade.brandAriaLabelKey()"
[workspaceLinks]="facade.workspaceLinks()"
[primaryContextLinks]="facade.primaryContextLinks()"
[secondaryContextLinks]="facade.secondaryContextLinks()"
[selectedSection]="facade.currentSection()"
[railProviderClass]="facade.railProviderClass()"
[isSettingsRoute]="facade.isSettingsRoute()"
/>
<app-workspace-shell-header
[playlistTitle]="facade.playlistTitle()"
[playlistSubtitle]="facade.playlistSubtitle()"
[canOpenPlaylistInfo]="facade.canOpenPlaylistInfo()"
[canOpenAccountInfo]="facade.canOpenAccountInfo()"
[searchQuery]="facade.searchQuery()"
[canUseSearch]="facade.canUseSearch()"
[searchPlaceholder]="facade.searchPlaceholder()"
[searchScopeLabel]="facade.searchScopeLabel()"
[searchStatusLabel]="facade.searchStatusLabel()"
[headerShortcut]="facade.headerShortcut()"
[canRefreshPlaylist]="facade.canRefreshPlaylist()"
[isRefreshingPlaylist]="facade.isRefreshingPlaylist()"
[isElectron]="facade.isElectron"
[hasNoPlaylists]="facade.hasNoPlaylists()"
[isDownloadsView]="facade.isDownloadsView()"
[hasActiveDownloads]="facade.hasActiveDownloads()"
[showQuickPlayerSelect]="facade.isElectron"
[quickHeaderPlayerValue]="facade.quickHeaderPlayerValue()"
(quickHeaderPlayerChange)="facade.setQuickHeaderPlayer($event)"
(refreshPlaylistRequested)="facade.refreshCurrentPlaylist()"
(searchChanged)="facade.onSearchInput($event)"
(searchSubmitted)="facade.onSearchEnter($event)"
(commandPaletteRequested)="facade.openCommandPalette()"
(addPlaylistRequested)="facade.openAddPlaylistDialog()"
(headerShortcutRequested)="facade.runHeaderShortcut()"
(downloadsRequested)="facade.openDownloadsShortcut()"
(playlistInfoRequested)="facade.openPlaylistInfo()"
(accountInfoRequested)="facade.openAccountInfo()"
/>
<div class="workspace-body" [class.single-pane]="!facade.showContextPanel()">
@if (facade.showContextPanel()) {
<app-workspace-shell-context-sidebar
[variant]="facade.contextPanel()"
[context]="facade.currentContext()"
[section]="facade.currentSection()"
[hasPlaylists]="!facade.hasNoPlaylists()"
/>
}
<main class="workspace-content">
<router-outlet />
</main>
</div>
@if (facade.showExternalPlaybackBar()) {
@if (facade.externalPlaybackSession(); as session) {
<footer class="workspace-now-playing">
<app-external-playback-dock
[session]="session"
(closeClicked)="facade.closeActiveExternalSession()"
(artworkClicked)="
facade.openActiveExternalSessionTarget()
"
/>
</footer>
}
}
</div>
@@ -0,0 +1,225 @@
:host {
display: block;
width: 100%;
height: 100%;
min-height: 0;
}
.workspace-shell {
display: grid;
grid-template-columns: 60px 1fr;
grid-template-rows: 56px minmax(0, 1fr) auto;
width: 100%;
height: 100%;
min-height: 0;
overflow: hidden;
background: var(--app-shell-bg, var(--mat-sys-surface));
color: var(--mat-sys-on-surface);
}
app-workspace-shell-rail {
grid-column: 1;
grid-row: 1 / span 3;
min-height: 0;
}
app-workspace-shell-header {
grid-column: 2;
grid-row: 1;
min-width: 0;
}
.workspace-body {
grid-column: 2;
grid-row: 2;
display: grid;
grid-template-columns: auto minmax(0, 1fr);
min-height: 0;
min-width: 0;
overflow: hidden;
background: var(--app-rail-bg, var(--mat-sys-surface-container-low));
&.single-pane {
grid-template-columns: 1fr;
}
}
app-workspace-shell-context-sidebar {
min-width: 0;
min-height: 0;
}
.workspace-now-playing {
grid-column: 2;
grid-row: 3;
padding: 0;
background: var(--app-content-bg, var(--mat-sys-surface));
}
.workspace-content {
min-width: 0;
min-height: 0;
overflow: auto;
background: var(--app-content-bg, var(--mat-sys-surface));
border-radius: 16px 0 0 0;
box-shadow: -4px 8px 20px var(--app-content-shadow, rgba(0, 0, 0, 0.18));
}
.workspace-loading-overlay {
position: fixed;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
padding: 24px;
background:
radial-gradient(
circle at top,
rgba(99, 120, 255, 0.12),
transparent 34%
),
rgba(8, 10, 16, 0.68);
backdrop-filter: blur(14px);
z-index: 1000;
}
.workspace-loading-overlay__card {
width: min(440px, 100%);
display: grid;
gap: 14px;
padding: 24px 24px 20px;
border-radius: 24px;
border: 1px solid var(--app-separator, rgba(255, 255, 255, 0.12));
background:
linear-gradient(
180deg,
rgba(255, 255, 255, 0.06),
rgba(255, 255, 255, 0)
),
var(--mat-sys-surface-container-high);
box-shadow:
0 24px 64px rgba(0, 0, 0, 0.32),
0 1px 0 rgba(255, 255, 255, 0.05) inset;
text-align: center;
animation: workspace-loading-overlay-enter 180ms ease-out;
h3 {
margin: 0;
font-size: 1.55rem;
font-weight: 650;
letter-spacing: -0.03em;
color: var(--app-heading-color, var(--mat-sys-on-surface));
}
}
.workspace-loading-overlay__header {
display: grid;
gap: 10px;
justify-items: center;
}
.workspace-loading-overlay__badge {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 6px 12px;
border-radius: 999px;
border: 1px solid rgba(120, 220, 255, 0.24);
background: rgba(120, 220, 255, 0.08);
color: rgba(164, 233, 255, 1);
font-size: 0.72rem;
font-weight: 700;
letter-spacing: 0.12em;
text-transform: uppercase;
}
.workspace-loading-overlay__badge--remote {
border-color: rgba(105, 195, 255, 0.34);
background: rgba(83, 158, 255, 0.12);
color: rgba(202, 230, 255, 1);
}
.workspace-loading-overlay__phase,
.workspace-loading-overlay__detail,
.workspace-loading-overlay__progress-copy {
margin: 0;
}
.workspace-loading-overlay__phase {
font-size: 1rem;
font-weight: 560;
color: var(--app-body-color, var(--mat-sys-on-surface));
}
.workspace-loading-overlay__detail {
max-width: 32ch;
justify-self: center;
color: var(--app-muted-color, var(--mat-sys-on-surface-variant));
line-height: 1.45;
}
.workspace-loading-overlay__progress-copy {
max-width: 30ch;
justify-self: center;
line-height: 1.45;
font-variant-numeric: tabular-nums;
color: var(--app-muted-color, var(--mat-sys-on-surface-variant));
text-wrap: balance;
}
.workspace-loading-overlay__action {
justify-self: center;
min-width: 180px;
border-radius: 999px;
}
:host ::ng-deep .workspace-loading-overlay__card .mat-mdc-progress-bar {
overflow: hidden;
border-radius: 999px;
}
@keyframes workspace-loading-overlay-enter {
from {
opacity: 0;
transform: translateY(10px) scale(0.985);
}
to {
opacity: 1;
transform: translateY(0) scale(1);
}
}
@media (max-width: 780px) {
.workspace-shell {
grid-template-columns: 56px 1fr;
grid-template-rows: 56px 1fr auto;
}
}
@media (max-width: 640px) {
.workspace-shell {
grid-template-columns: 1fr;
grid-template-rows: 52px 56px minmax(0, 1fr) auto;
}
app-workspace-shell-rail {
grid-column: 1;
grid-row: 1;
}
app-workspace-shell-header {
grid-column: 1;
grid-row: 2;
}
.workspace-body {
grid-column: 1;
grid-row: 3;
}
.workspace-now-playing {
grid-column: 1;
grid-row: 4;
}
}
@@ -0,0 +1,259 @@
import {
Component,
input,
output,
signal,
} from '@angular/core';
import { TestBed } from '@angular/core/testing';
import { MatProgressBarModule } from '@angular/material/progress-bar';
import { RouterOutlet, provideRouter } from '@angular/router';
import {
WorkspacePortalContext,
WorkspaceShellContextPanel,
} from '@iptvnator/workspace/shell/util';
import { WorkspaceShellComponent } from './workspace-shell.component';
import {
WorkspaceHeaderBulkAction,
WorkspaceShellFacade,
} from './services/workspace-shell.facade';
import { VideoPlayer } from 'shared-interfaces';
@Component({
selector: 'app-workspace-shell-rail',
template: '',
standalone: true,
})
class MockWorkspaceShellRailComponent {
readonly isMacOS = input(false);
readonly brandLink = input('/workspace/dashboard');
readonly brandTooltipKey = input('WORKSPACE.SHELL.RAIL_DASHBOARD');
readonly brandAriaLabelKey = input('WORKSPACE.SHELL.OPEN_DASHBOARD');
readonly workspaceLinks = input<unknown[]>([]);
readonly primaryContextLinks = input<unknown[]>([]);
readonly secondaryContextLinks = input<unknown[]>([]);
readonly selectedSection = input<string | null>(null);
readonly railProviderClass = input('');
readonly isSettingsRoute = input(false);
}
@Component({
selector: 'app-workspace-shell-header',
template: '',
standalone: true,
})
class MockWorkspaceShellHeaderComponent {
readonly playlistTitle = input('');
readonly playlistSubtitle = input('');
readonly canOpenPlaylistInfo = input(false);
readonly canOpenAccountInfo = input(false);
readonly searchQuery = input('');
readonly canUseSearch = input(false);
readonly searchPlaceholder = input('');
readonly searchScopeLabel = input('');
readonly searchStatusLabel = input('');
readonly headerShortcut = input<unknown>(null);
readonly canRefreshPlaylist = input(false);
readonly isRefreshingPlaylist = input(false);
readonly isElectron = input(false);
readonly isDownloadsView = input(false);
readonly hasActiveDownloads = input(false);
readonly hasNoPlaylists = input(false);
readonly showQuickPlayerSelect = input(false);
readonly quickHeaderPlayerValue = input(VideoPlayer.ArtPlayer);
readonly headerBulkAction = input<WorkspaceHeaderBulkAction | null>(null);
readonly searchChanged = output<string>();
readonly searchSubmitted = output<string>();
readonly commandPaletteRequested = output<void>();
readonly addPlaylistRequested = output<void>();
readonly headerShortcutRequested = output<void>();
readonly refreshPlaylistRequested = output<void>();
readonly downloadsRequested = output<void>();
readonly headerBulkActionRequested = output<void>();
readonly playlistInfoRequested = output<void>();
readonly accountInfoRequested = output<void>();
readonly quickHeaderPlayerChange = output<VideoPlayer>();
}
@Component({
selector: 'app-workspace-shell-context-sidebar',
template: '',
standalone: true,
})
class MockWorkspaceShellContextSidebarComponent {
readonly variant = input<WorkspaceShellContextPanel>('none');
readonly context = input<WorkspacePortalContext | null>(null);
readonly section = input<string | null>(null);
}
@Component({
selector: 'app-external-playback-dock',
template: '',
standalone: true,
})
class MockExternalPlaybackDockComponent {
readonly session = input<unknown>(null);
readonly closeClicked = output<void>();
}
class MockWorkspaceShellFacade {
readonly brandLink = signal('/workspace/dashboard');
readonly brandTooltipKey = signal('WORKSPACE.SHELL.RAIL_DASHBOARD');
readonly brandAriaLabelKey = signal('WORKSPACE.SHELL.OPEN_DASHBOARD');
readonly workspaceLinks = signal([]);
readonly primaryContextLinks = signal([]);
readonly secondaryContextLinks = signal([]);
readonly currentSection = signal<string | null>(null);
readonly railProviderClass = signal('rail-context-region');
readonly isSettingsRoute = signal(false);
readonly playlistTitle = signal('Playlist A');
readonly playlistSubtitle = signal('Subtitle');
readonly canOpenPlaylistInfo = signal(true);
readonly canOpenAccountInfo = signal(true);
readonly searchQuery = signal('');
readonly canUseSearch = signal(true);
readonly searchPlaceholder = signal(
'WORKSPACE.SHELL.SEARCH_PLAYLIST_PLACEHOLDER'
);
readonly searchScopeLabel = signal('Movies / All Items');
readonly searchStatusLabel = signal('');
readonly headerShortcut = signal(null);
readonly canRefreshPlaylist = signal(false);
readonly isRefreshingPlaylist = signal(false);
readonly isDownloadsView = signal(false);
readonly hasActiveDownloads = signal(false);
readonly headerBulkAction = signal<WorkspaceHeaderBulkAction | null>(null);
readonly showContextPanel = signal(true);
readonly contextPanel = signal<WorkspaceShellContextPanel>('settings');
readonly currentContext = signal<WorkspacePortalContext | null>(null);
readonly showExternalPlaybackBar = signal(true);
readonly externalPlaybackSession = signal({ id: 'session-1' });
readonly showXtreamImportOverlay = signal(false);
readonly xtreamImportCount = signal(0);
readonly xtreamItemsToImport = signal(0);
readonly xtreamActiveImportCount = signal(0);
readonly xtreamActiveItemsToImport = signal(0);
readonly xtreamImportTitleLabel = signal(
'WORKSPACE.SHELL.XTREAM_IMPORT_TITLE'
);
readonly xtreamImportSourceLabel = signal(
'WORKSPACE.SHELL.XTREAM_IMPORT_REMOTE_BADGE'
);
readonly xtreamImportPhaseLabel = signal(
'WORKSPACE.SHELL.XTREAM_IMPORT_LOADING'
);
readonly xtreamImportDetailLabel = signal(
'WORKSPACE.SHELL.XTREAM_IMPORT_DETAIL_REMOTE'
);
readonly xtreamImportProgressLabel = signal('');
readonly xtreamImportPhaseTone = signal<'remote' | 'local' | null>(
'remote'
);
readonly canCancelXtreamImport = signal(false);
readonly isCancellingXtreamImport = signal(false);
readonly isMacOS = true;
readonly isElectron = true;
onSearchInput = jest.fn();
onSearchEnter = jest.fn();
openCommandPalette = jest.fn();
openAddPlaylistDialog = jest.fn();
runHeaderShortcut = jest.fn();
refreshCurrentPlaylist = jest.fn();
openDownloadsShortcut = jest.fn();
runHeaderBulkAction = jest.fn();
openPlaylistInfo = jest.fn();
openAccountInfo = jest.fn();
closeActiveExternalSession = jest.fn();
cancelXtreamImport = jest.fn();
}
describe('WorkspaceShellComponent', () => {
it('creates and renders the shell composition with mocked children', async () => {
const facade = new MockWorkspaceShellFacade();
await TestBed.configureTestingModule({
imports: [WorkspaceShellComponent],
providers: [provideRouter([])],
})
.overrideComponent(WorkspaceShellComponent, {
set: {
imports: [
MatProgressBarModule,
RouterOutlet,
MockExternalPlaybackDockComponent,
MockWorkspaceShellContextSidebarComponent,
MockWorkspaceShellHeaderComponent,
MockWorkspaceShellRailComponent,
],
providers: [
{
provide: WorkspaceShellFacade,
useValue: facade,
},
],
},
})
.compileComponents();
const fixture = TestBed.createComponent(WorkspaceShellComponent);
fixture.detectChanges();
expect(fixture.componentInstance).toBeTruthy();
expect(
fixture.nativeElement.querySelector('app-workspace-shell-rail')
).not.toBeNull();
expect(
fixture.nativeElement.querySelector('app-workspace-shell-header')
).not.toBeNull();
expect(
fixture.nativeElement.querySelector(
'app-workspace-shell-context-sidebar'
)
).not.toBeNull();
expect(
fixture.nativeElement.querySelector('app-external-playback-dock')
).not.toBeNull();
});
it('renders type-aware xtream import progress copy in the overlay', async () => {
const facade = new MockWorkspaceShellFacade();
await TestBed.configureTestingModule({
imports: [WorkspaceShellComponent],
providers: [provideRouter([])],
})
.overrideComponent(WorkspaceShellComponent, {
set: {
imports: [
MatProgressBarModule,
RouterOutlet,
MockExternalPlaybackDockComponent,
MockWorkspaceShellContextSidebarComponent,
MockWorkspaceShellHeaderComponent,
MockWorkspaceShellRailComponent,
],
providers: [
{
provide: WorkspaceShellFacade,
useValue: facade,
},
],
},
})
.compileComponents();
facade.showXtreamImportOverlay.set(true);
facade.xtreamActiveImportCount.set(20);
facade.xtreamActiveItemsToImport.set(12323);
facade.xtreamImportProgressLabel.set('Movies imported: 20 / 12,323');
const fixture = TestBed.createComponent(WorkspaceShellComponent);
fixture.detectChanges();
expect(
fixture.nativeElement.querySelector(
'.workspace-loading-overlay__progress-copy'
)?.textContent
).toContain('Movies imported: 20 / 12,323');
});
});
@@ -0,0 +1,39 @@
import {
Component,
inject,
} from '@angular/core';
import { MatButtonModule } from '@angular/material/button';
import { MatProgressBarModule } from '@angular/material/progress-bar';
import { RouterOutlet } from '@angular/router';
import { TranslatePipe } from '@ngx-translate/core';
import { ExternalPlaybackDockComponent } from 'components';
import {
PlaylistDropOverlayComponent,
PlaylistDropZoneDirective,
} from '../playlist-drop-overlay';
import { WorkspaceShellContextSidebarComponent } from './components/workspace-shell-context-sidebar/workspace-shell-context-sidebar.component';
import { WorkspaceShellHeaderComponent } from './components/workspace-shell-header/workspace-shell-header.component';
import { WorkspaceShellRailComponent } from './components/workspace-shell-rail/workspace-shell-rail.component';
import { WorkspaceShellFacade } from './services/workspace-shell.facade';
@Component({
selector: 'app-workspace-shell',
imports: [
ExternalPlaybackDockComponent,
MatButtonModule,
MatProgressBarModule,
PlaylistDropOverlayComponent,
PlaylistDropZoneDirective,
RouterOutlet,
TranslatePipe,
WorkspaceShellContextSidebarComponent,
WorkspaceShellHeaderComponent,
WorkspaceShellRailComponent,
],
templateUrl: './workspace-shell.component.html',
styleUrl: './workspace-shell.component.scss',
providers: [WorkspaceShellFacade],
})
export class WorkspaceShellComponent {
readonly facade = inject(WorkspaceShellFacade);
}
@@ -0,0 +1,38 @@
<div class="context-column">
<header class="context-header">
<div class="context-header__top">
<h2>{{ 'WORKSPACE.SOURCES.TITLE' | translate }}</h2>
</div>
<p>{{ 'WORKSPACE.SOURCES.SUBTITLE' | translate }}</p>
</header>
<div class="context-divider"></div>
<section class="panel-section">
<div class="nav-list option-list">
<h3 class="panel-section-title">
{{ 'WORKSPACE.SOURCES.TYPE' | translate }}
</h3>
@for (option of typeOptions; track option.id) {
<button
type="button"
class="nav-item category-item option-row"
[class.selected]="isTypeActive(option.id)"
(click)="selectType(option.id)"
>
<mat-icon>{{ option.icon }}</mat-icon>
<span class="nav-item-label option-label">
{{
option.translationKey
? (option.translationKey | translate)
: option.label
}}
</span>
<div class="nav-item-meta option-meta">
{{ getTypeCount(option.id) }}
</div>
</button>
}
</div>
</section>
</div>
@@ -0,0 +1,102 @@
@use '../../nav-list.scss';
:host {
display: flex;
flex-direction: column;
min-height: 0;
height: 100%;
}
.context-column {
display: flex;
flex-direction: column;
min-height: 0;
height: 100%;
overflow: hidden;
}
// ─── Header ───────────────────────────────────────────────────────────────────
.context-header {
padding: 4px 6px 10px;
flex-shrink: 0;
}
.context-header__top {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
h2 {
margin: 0;
font-size: 0.94rem;
font-weight: 600;
letter-spacing: -0.01em;
color: var(--mat-sys-on-surface);
}
}
.context-header p {
margin: 4px 0 0;
font-size: 0.78rem;
color: var(--mat-sys-on-surface-variant);
}
// ─── Divider ──────────────────────────────────────────────────────────────────
.context-divider {
height: 1px;
background: var(--mat-sys-outline-variant);
margin: 0 0 6px;
opacity: 0.6;
flex-shrink: 0;
}
.panel-section {
display: flex;
flex-direction: column;
min-height: 0;
}
.option-list {
overflow-y: auto;
min-height: 0;
}
.option-row {
border-radius: 12px;
transition:
box-shadow 0.18s ease,
transform 0.18s ease;
.option-label,
.option-meta,
mat-icon {
transition: color 0.18s ease, opacity 0.18s ease;
}
&.selected {
box-shadow: 0 10px 20px -18px var(--app-selection-glow);
transform: translateY(-1px);
.option-label,
.option-meta,
mat-icon {
color: var(--app-selection-color) !important;
}
.option-meta {
opacity: 0.95;
}
}
}
.panel-section-title {
margin: 0;
padding: 10px 10px 6px;
font-size: 0.72rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.08em;
color: var(--mat-sys-on-surface-variant);
opacity: 0.85;
}
@@ -0,0 +1,100 @@
import { computed, Component, inject } from '@angular/core';
import { MatIcon } from '@angular/material/icon';
import { MatListModule } from '@angular/material/list';
import { Store } from '@ngrx/store';
import {
FilterActions,
selectActiveTypeFilters,
selectAllPlaylistsMeta,
} from 'm3u-state';
import { TranslatePipe } from '@ngx-translate/core';
type PlaylistFilterId = 'all' | 'm3u' | 'xtream' | 'stalker';
interface PlaylistFilterOption {
id: PlaylistFilterId;
icon: string;
label?: string;
translationKey?: string;
}
const ALL_FILTERS = ['m3u', 'xtream', 'stalker'];
@Component({
selector: 'app-workspace-sources-filters-panel',
imports: [MatIcon, MatListModule, TranslatePipe],
templateUrl: './workspace-sources-filters-panel.component.html',
styleUrl: './workspace-sources-filters-panel.component.scss',
})
export class WorkspaceSourcesFiltersPanelComponent {
private readonly store = inject(Store);
private readonly activeTypeFilters = this.store.selectSignal(
selectActiveTypeFilters
);
private readonly playlists = this.store.selectSignal(selectAllPlaylistsMeta);
readonly typeOptions: PlaylistFilterOption[] = [
{
id: 'all',
icon: 'layers',
translationKey: 'WORKSPACE.SOURCES.ALL',
},
{
id: 'm3u',
icon: 'playlist_play',
translationKey: 'HOME.PLAYLIST_TYPES.M3U',
},
{
id: 'xtream',
icon: 'cloud',
translationKey: 'HOME.PLAYLIST_TYPES.XTREAM',
},
{
id: 'stalker',
icon: 'router',
translationKey: 'HOME.PLAYLIST_TYPES.STALKER',
},
];
readonly typeCounts = computed(() => {
const items = this.playlists();
return {
all: items.length,
m3u: items.filter((item) => !item.serverUrl && !item.macAddress)
.length,
xtream: items.filter((item) => !!item.serverUrl).length,
stalker: items.filter((item) => !!item.macAddress).length,
};
});
isTypeActive(filterId: PlaylistFilterId): boolean {
const selected = this.activeTypeFilters();
if (filterId === 'all') {
return (
selected.length === ALL_FILTERS.length &&
ALL_FILTERS.every((id) => selected.includes(id))
);
}
return selected.length === 1 && selected[0] === filterId;
}
selectType(filterId: PlaylistFilterId): void {
const selectedFilters =
filterId === 'all' ? ALL_FILTERS : [filterId];
this.store.dispatch(
FilterActions.setSelectedFilters({
selectedFilters,
})
);
}
getTypeCount(filterId: PlaylistFilterId): number {
const counts = this.typeCounts();
if (filterId === 'all') {
return counts.all;
}
return counts[filterId];
}
}
@@ -0,0 +1,45 @@
<section
class="sources-page"
[class.sources-page--empty]="visibleSourcesCount() === 0"
>
@if (visibleSourcesCount() > 0) {
<header class="sources-header">
<div class="sources-header__text">
<h2 class="sources-title">{{ title() }}</h2>
<p class="sources-subtitle">{{ subtitle() }}</p>
</div>
<button
type="button"
mat-stroked-button
class="sort-trigger"
[matMenuTriggerFor]="sortMenu"
>
<mat-icon>sort</mat-icon>
<span class="sort-trigger__label">{{ activeSortLabel() }}</span>
<mat-icon class="sort-trigger__caret">arrow_drop_down</mat-icon>
</button>
<mat-menu #sortMenu="matMenu" xPosition="before">
@for (option of sortOptions; track option.translationKey) {
<button
mat-menu-item
type="button"
[class.active-option]="isSortActive(option)"
(click)="setSortOption(option)"
>
<mat-icon>{{ option.icon }}</mat-icon>
<span>{{ option.translationKey | translate }}</span>
@if (isSortActive(option)) {
<mat-icon class="sort-menu__check">check</mat-icon>
}
</button>
}
</mat-menu>
</header>
}
<div class="sources-content">
<app-recent-playlists
[searchQueryInput]="searchQuery()"
(addPlaylistClicked)="onAddPlaylist($event)"
/>
</div>
</section>
@@ -0,0 +1,89 @@
:host {
display: block;
height: 100%;
min-height: 100%;
}
.sources-page {
display: flex;
flex-direction: column;
height: 100%;
min-height: 100%;
min-width: 0;
overflow: hidden;
}
.sources-header {
position: sticky;
top: 0;
z-index: 2;
padding: 20px 20px 8px;
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 16px;
}
.sources-header__text {
min-width: 0;
flex: 1;
}
.sources-title {
margin: 0;
font-size: 1.2rem;
font-weight: 650;
letter-spacing: -0.02em;
}
.sources-subtitle {
margin: 4px 0 0;
font-size: 0.875rem;
color: var(--mat-sys-on-surface-variant, rgba(255, 255, 255, 0.6));
}
.sort-trigger {
flex-shrink: 0;
display: inline-flex;
align-items: center;
gap: 4px;
.sort-trigger__label {
font-size: 0.85rem;
font-weight: 500;
}
.sort-trigger__caret {
font-size: 18px;
width: 18px;
height: 18px;
margin-left: -2px;
}
}
.sources-content {
flex: 1;
min-height: 0;
overflow-y: auto;
padding: 0 20px 20px;
}
.sources-content app-recent-playlists {
display: block;
height: 100%;
}
.sources-page--empty {
.sources-content {
padding: 0;
overflow: hidden;
display: flex;
}
.sources-content app-recent-playlists {
flex: 1;
height: 100%;
min-height: 0;
display: flex;
}
}
@@ -0,0 +1,193 @@
import { Component, computed, inject } from '@angular/core';
import { toSignal } from '@angular/core/rxjs-interop';
import { MatButtonModule } from '@angular/material/button';
import { MatIconModule } from '@angular/material/icon';
import { MatMenuModule } from '@angular/material/menu';
import { ActivatedRoute } from '@angular/router';
import { Store } from '@ngrx/store';
import { RecentPlaylistsComponent } from '@iptvnator/playlist/shared/ui';
import { TranslatePipe, TranslateService } from '@ngx-translate/core';
import { selectActiveTypeFilters, selectAllPlaylistsMeta } from 'm3u-state';
import { map, startWith } from 'rxjs';
import { SortBy, SortOrder, SortService } from 'services';
import {
WORKSPACE_SHELL_ACTIONS,
WorkspacePlaylistType,
} from '@iptvnator/workspace/shell/util';
interface SortOption {
by: SortBy;
order: SortOrder;
icon: string;
translationKey: string;
}
@Component({
selector: 'app-workspace-sources',
imports: [
MatButtonModule,
MatIconModule,
MatMenuModule,
RecentPlaylistsComponent,
TranslatePipe,
],
templateUrl: './workspace-sources.component.html',
styleUrl: './workspace-sources.component.scss',
})
export class WorkspaceSourcesComponent {
private readonly route = inject(ActivatedRoute);
private readonly store = inject(Store);
private readonly workspaceActions = inject(WORKSPACE_SHELL_ACTIONS);
private readonly translate = inject(TranslateService);
private readonly sortService = inject(SortService);
private readonly activeTypeFilters = this.store.selectSignal(
selectActiveTypeFilters
);
private readonly playlists = this.store.selectSignal(
selectAllPlaylistsMeta
);
private readonly languageTick = toSignal(
this.translate.onLangChange.pipe(startWith(null)),
{ initialValue: null }
);
readonly sortOptions: SortOption[] = [
{
by: SortBy.DATE_ADDED,
order: SortOrder.DESC,
icon: 'schedule',
translationKey: 'HOME.SORT_OPTIONS.NEWEST',
},
{
by: SortBy.DATE_ADDED,
order: SortOrder.ASC,
icon: 'history',
translationKey: 'HOME.SORT_OPTIONS.OLDEST',
},
{
by: SortBy.NAME,
order: SortOrder.ASC,
icon: 'sort_by_alpha',
translationKey: 'HOME.SORT_OPTIONS.NAME_ASC',
},
{
by: SortBy.NAME,
order: SortOrder.DESC,
icon: 'sort_by_alpha',
translationKey: 'HOME.SORT_OPTIONS.NAME_DESC',
},
{
by: SortBy.CUSTOM,
order: SortOrder.ASC,
icon: 'drag_indicator',
translationKey: 'HOME.SORT_OPTIONS.CUSTOM_ORDER',
},
];
private readonly currentSortOptions = toSignal(
this.sortService.getSortOptions(),
{ requireSync: true }
);
readonly searchQuery = toSignal(
this.route.queryParamMap.pipe(map((params) => params.get('q') ?? '')),
{ initialValue: '' }
);
readonly title = computed(() => {
this.languageTick();
const filters = this.activeTypeFilters();
if (filters.length === 1) {
if (filters[0] === 'm3u') {
return this.translateText('WORKSPACE.SOURCES.M3U_PLAYLISTS');
}
if (filters[0] === 'xtream') {
return this.translateText('WORKSPACE.SOURCES.XTREAM_PLAYLISTS');
}
if (filters[0] === 'stalker') {
return this.translateText(
'WORKSPACE.SOURCES.STALKER_PLAYLISTS'
);
}
}
return this.translateText('WORKSPACE.SOURCES.ALL_PLAYLISTS');
});
readonly visibleSourcesCount = computed(() => {
const query = this.searchQuery().trim().toLowerCase();
const filters = this.activeTypeFilters();
const allPlaylists = this.playlists();
return allPlaylists
.filter((item) => {
const isStalkerFilter =
!!item.macAddress && filters.includes('stalker');
const isXtreamFilter =
!!item.username &&
!!item.password &&
!!item.serverUrl &&
filters.includes('xtream');
const isM3uFilter =
!item.username &&
!item.password &&
!item.serverUrl &&
!item.macAddress &&
filters.includes('m3u');
return isStalkerFilter || isXtreamFilter || isM3uFilter;
})
.filter((item) => (item.title || '').toLowerCase().includes(query))
.length;
});
readonly subtitle = computed(() => {
this.languageTick();
const count = this.visibleSourcesCount();
if (count === 1) {
return this.translateText('WORKSPACE.SOURCES.PLAYLIST_COUNT_ONE');
}
return this.translateText('WORKSPACE.SOURCES.PLAYLIST_COUNT_OTHER', {
count,
});
});
readonly activeSortLabel = computed(() => {
this.languageTick();
const current = this.currentSortOptions();
const match = this.sortOptions.find(
(option) =>
option.by === current.by && option.order === current.order
);
return this.translateText(
match?.translationKey ?? 'HOME.SORT_OPTIONS.NEWEST'
);
});
onAddPlaylist(type?: WorkspacePlaylistType): void {
this.workspaceActions.openAddPlaylistDialog(type);
}
isSortActive(option: SortOption): boolean {
const current = this.currentSortOptions();
return current.by === option.by && current.order === option.order;
}
setSortOption(option: SortOption): void {
this.sortService.setSortOptions({
by: option.by,
order: option.order,
});
}
private translateText(
key: string,
params?: Record<string, string | number>
): string {
return this.translate.instant(key, params);
}
}
@@ -0,0 +1 @@
@use '../../../../../apps/web/src/nav-list.scss';
@@ -0,0 +1,6 @@
import { setupZoneTestEnv } from 'jest-preset-angular/setup-env/zone';
setupZoneTestEnv({
errorOnUnknownElements: true,
errorOnUnknownProperties: true,
});
@@ -0,0 +1,31 @@
{
"extends": "../../../../tsconfig.base.json",
"compilerOptions": {
"isolatedModules": true,
"target": "es2022",
"moduleResolution": "bundler",
"strict": true,
"noImplicitOverride": true,
"noPropertyAccessFromIndexSignature": true,
"noImplicitReturns": true,
"noFallthroughCasesInSwitch": true,
"emitDecoratorMetadata": false,
"module": "preserve"
},
"angularCompilerOptions": {
"enableI18nLegacyMessageIdFormat": false,
"strictInjectionParameters": true,
"strictInputAccessModifiers": true,
"strictTemplates": true
},
"files": [],
"include": [],
"references": [
{
"path": "./tsconfig.lib.json"
},
{
"path": "./tsconfig.spec.json"
}
]
}
@@ -0,0 +1,12 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"outDir": "../../../../dist/out-tsc",
"declaration": true,
"declarationMap": true,
"inlineSources": true,
"types": []
},
"include": ["src/**/*.ts"],
"exclude": ["src/**/*.spec.ts", "src/**/*.test.ts"]
}
@@ -0,0 +1,17 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"outDir": "../../../../dist/out-tsc",
"module": "commonjs",
"target": "es2016",
"types": ["jest", "node"],
"moduleResolution": "node10"
},
"files": ["src/test-setup.ts"],
"include": [
"jest.config.ts",
"src/**/*.test.ts",
"src/**/*.spec.ts",
"src/**/*.d.ts"
]
}
+22
View File
@@ -0,0 +1,22 @@
export default {
displayName: 'workspace-shell-util',
preset: '../../../../jest.preset.js',
setupFilesAfterEnv: ['<rootDir>/src/test-setup.ts'],
coverageDirectory: '../../../../coverage/libs/workspace/shell/util',
transform: {
'^.+\\.(ts|mjs|js|html)$': [
'jest-preset-angular',
{
tsconfig: '<rootDir>/tsconfig.spec.json',
stringifyContentPathRegex: '\\.(html|svg)$',
},
],
},
transformIgnorePatterns: ['node_modules/(?!.*\\.mjs$)'],
snapshotSerializers: [
'jest-preset-angular/build/serializers/no-ng-attributes',
'jest-preset-angular/build/serializers/ng-snapshot',
'jest-preset-angular/build/serializers/html-comment',
],
moduleFileExtensions: ['ts', 'js', 'html'],
};
+13
View File
@@ -0,0 +1,13 @@
{
"name": "workspace-shell-util",
"$schema": "../../../../node_modules/nx/schemas/project-schema.json",
"sourceRoot": "libs/workspace/shell/util/src",
"prefix": "lib",
"projectType": "library",
"tags": ["scope:workspace", "domain:workspace", "type:util"],
"targets": {
"lint": {
"executor": "@nx/eslint:lint"
}
}
}
+5
View File
@@ -0,0 +1,5 @@
export * from './lib/settings-context.service';
export * from './lib/navigation/workspace-shell-route.utils';
export * from './lib/navigation/workspace-search-capability';
export * from './lib/workspace-shell-actions';
export * from './lib/workspace-startup-preferences.service';
@@ -0,0 +1,25 @@
import { PortalRailSection } from '@iptvnator/portal/shared/util';
import {
WorkspacePortalContext,
WorkspaceShellSearchMode,
} from './workspace-shell-route.utils';
export type WorkspaceSearchBehavior =
| 'disabled'
| 'local-filter'
| 'remote-search'
| 'advanced-only'
| 'degraded-loaded-only';
export interface WorkspaceSearchCapability {
enabled: boolean;
behavior: WorkspaceSearchBehavior;
context: WorkspacePortalContext | null;
section: PortalRailSection | null;
searchMode: WorkspaceShellSearchMode;
placeholderKey: string;
scopeLabel: string;
statusLabel: string;
minLength: number;
advancedRouteTarget: readonly string[] | null;
}
@@ -0,0 +1,181 @@
import {
parseWorkspaceShellRoute,
usesWorkspaceRouteQuerySearch,
} from './workspace-shell-route.utils';
describe('workspace-shell-route.utils', () => {
it('parses top-level workspace pages', () => {
expect(parseWorkspaceShellRoute('/workspace')).toEqual(
expect.objectContaining({
kind: 'dashboard',
context: null,
contextPanel: 'none',
searchMode: 'advanced-only',
usesQuerySearch: false,
})
);
expect(parseWorkspaceShellRoute('/workspace/sources?q=matrix')).toEqual(
expect.objectContaining({
kind: 'sources',
context: null,
contextPanel: 'sources',
searchMode: 'local-filter',
usesQuerySearch: true,
})
);
expect(parseWorkspaceShellRoute('/workspace/settings')).toEqual(
expect.objectContaining({
kind: 'settings',
context: null,
contextPanel: 'settings',
})
);
expect(parseWorkspaceShellRoute('/workspace/global-favorites')).toEqual(
expect.objectContaining({
kind: 'global-favorites',
})
);
expect(parseWorkspaceShellRoute('/workspace/global-recent')).toEqual(
expect.objectContaining({
kind: 'global-recent',
searchMode: 'local-filter',
usesQuerySearch: true,
})
);
expect(parseWorkspaceShellRoute('/workspace/downloads')).toEqual(
expect.objectContaining({
kind: 'downloads',
})
);
});
it('parses Xtream portal routes and detects context-panel/query-search state', () => {
expect(
parseWorkspaceShellRoute('/workspace/xtreams/pl-1/vod/42')
).toEqual(
expect.objectContaining({
kind: 'portal',
context: {
provider: 'xtreams',
playlistId: 'pl-1',
},
section: 'vod',
contextPanel: 'category',
searchMode: 'local-filter',
usesQuerySearch: true,
isPortalFavoritesAllScope: false,
})
);
expect(
parseWorkspaceShellRoute(
'/workspace/xtreams/pl-1/favorites?scope=all&q=neo'
)
).toEqual(
expect.objectContaining({
section: 'favorites',
contextPanel: 'none',
searchMode: 'local-filter',
usesQuerySearch: true,
isPortalFavoritesAllScope: true,
})
);
expect(
parseWorkspaceShellRoute('/workspace/xtreams/pl-1/downloads')
).toEqual(
expect.objectContaining({
section: 'downloads',
contextPanel: 'collection',
searchMode: 'local-filter',
usesQuerySearch: true,
})
);
});
it('parses Stalker portal routes and detects context-panel/query-search state', () => {
expect(
parseWorkspaceShellRoute('/workspace/stalker/pl-2/itv')
).toEqual(
expect.objectContaining({
kind: 'portal',
context: {
provider: 'stalker',
playlistId: 'pl-2',
},
section: 'itv',
contextPanel: 'category',
searchMode: 'remote-search',
usesQuerySearch: true,
})
);
expect(
parseWorkspaceShellRoute('/workspace/stalker/pl-2/recent?q=test')
).toEqual(
expect.objectContaining({
section: 'recent',
contextPanel: 'none',
searchMode: 'local-filter',
usesQuerySearch: true,
})
);
});
it('parses M3U portal routes without provider-specific workspace state', () => {
expect(
parseWorkspaceShellRoute('/workspace/playlists/pl-3/favorites')
).toEqual(
expect.objectContaining({
kind: 'portal',
context: {
provider: 'playlists',
playlistId: 'pl-3',
},
section: 'favorites',
contextPanel: 'none',
searchMode: 'local-filter',
usesQuerySearch: true,
isPortalFavoritesAllScope: false,
})
);
});
it('returns unknown for non-workspace URLs', () => {
expect(parseWorkspaceShellRoute('/xtreams/pl-1/search')).toEqual(
expect.objectContaining({
kind: 'unknown',
context: null,
section: null,
searchMode: 'none',
})
);
});
it('exposes query-search eligibility as a pure helper', () => {
expect(
usesWorkspaceRouteQuerySearch(
{ provider: 'xtreams', playlistId: '1' },
'favorites'
)
).toBe(true);
expect(
usesWorkspaceRouteQuerySearch(
{ provider: 'xtreams', playlistId: '1' },
'vod'
)
).toBe(true);
expect(
usesWorkspaceRouteQuerySearch(
{ provider: 'stalker', playlistId: '1' },
'recent'
)
).toBe(true);
expect(
usesWorkspaceRouteQuerySearch(
{ provider: 'playlists', playlistId: '1' },
'favorites'
)
).toBe(true);
});
});
@@ -0,0 +1,303 @@
import {
PortalProvider,
PortalRailSection,
} from '@iptvnator/portal/shared/util';
export type WorkspaceShellPageKind =
| 'dashboard'
| 'downloads'
| 'global-favorites'
| 'global-recent'
| 'portal'
| 'settings'
| 'sources'
| 'unknown';
export type WorkspaceShellContextPanel =
| 'none'
| 'sources'
| 'settings'
| 'category'
| 'collection';
export interface WorkspacePortalContext {
provider: PortalProvider;
playlistId: string;
}
export type WorkspaceShellSearchMode =
| 'none'
| 'local-filter'
| 'remote-search'
| 'advanced-only';
export interface WorkspaceShellRoute {
kind: WorkspaceShellPageKind;
context: WorkspacePortalContext | null;
section: PortalRailSection | null;
isPortalFavoritesAllScope: boolean;
searchMode: WorkspaceShellSearchMode;
usesQuerySearch: boolean;
contextPanel: WorkspaceShellContextPanel;
}
const PORTAL_PROVIDERS: readonly PortalProvider[] = [
'xtreams',
'stalker',
'playlists',
];
const PORTAL_SECTIONS: readonly PortalRailSection[] = [
'all',
'downloads',
'favorites',
'groups',
'itv',
'library',
'live',
'recent',
'recently-added',
'search',
'series',
'vod',
];
const XTREAM_ROUTE_QUERY_SEARCH_SECTIONS = new Set<PortalRailSection>([
'favorites',
'downloads',
'recent',
'recently-added',
]);
const STALKER_ROUTE_QUERY_SEARCH_SECTIONS = new Set<PortalRailSection>([
'favorites',
'recent',
]);
const XTREAM_CATEGORY_CONTEXT_SECTIONS = new Set<PortalRailSection>([
'vod',
'series',
'live',
]);
const STALKER_CATEGORY_CONTEXT_SECTIONS = new Set<PortalRailSection>([
'vod',
'series',
'itv',
]);
function normalizePath(url: string): {
segments: string[];
queryParams: URLSearchParams;
} {
const [path, query = ''] = url.split('?');
return {
segments: path.split('/').filter(Boolean),
queryParams: new URLSearchParams(query),
};
}
function asPortalProvider(value?: string): PortalProvider | null {
if (!value) {
return null;
}
return PORTAL_PROVIDERS.includes(value as PortalProvider)
? (value as PortalProvider)
: null;
}
function asPortalRailSection(value?: string): PortalRailSection | null {
if (!value) {
return null;
}
return PORTAL_SECTIONS.includes(value as PortalRailSection)
? (value as PortalRailSection)
: null;
}
export function usesWorkspaceRouteQuerySearch(
context: WorkspacePortalContext | null,
section: PortalRailSection | null
): boolean {
return resolvePortalSearchMode(context, section) !== 'none';
}
function resolvePortalSearchMode(
context: WorkspacePortalContext | null,
section: PortalRailSection | null
): Exclude<WorkspaceShellSearchMode, 'advanced-only'> {
if (!context || !section) {
return 'none';
}
if (context.provider === 'playlists') {
return section === 'all' ||
section === 'groups' ||
section === 'favorites' ||
section === 'recent'
? 'local-filter'
: 'none';
}
if (context.provider === 'xtreams') {
if (section === 'search') {
return 'remote-search';
}
return section === 'vod' ||
section === 'series' ||
section === 'live' ||
XTREAM_ROUTE_QUERY_SEARCH_SECTIONS.has(section)
? 'local-filter'
: 'none';
}
if (context.provider === 'stalker') {
if (
section === 'vod' ||
section === 'series' ||
section === 'itv' ||
section === 'search'
) {
return 'remote-search';
}
return STALKER_ROUTE_QUERY_SEARCH_SECTIONS.has(section) ||
section === 'downloads'
? 'local-filter'
: 'none';
}
return 'none';
}
function resolveRouteSearchMode(
kind: WorkspaceShellPageKind,
context: WorkspacePortalContext | null,
section: PortalRailSection | null
): WorkspaceShellSearchMode {
if (kind === 'dashboard') {
return 'advanced-only';
}
if (
kind === 'sources' ||
kind === 'downloads' ||
kind === 'global-favorites' ||
kind === 'global-recent'
) {
return 'local-filter';
}
if (kind === 'portal') {
return resolvePortalSearchMode(context, section);
}
return 'none';
}
function resolveContextPanel(
kind: WorkspaceShellPageKind,
context: WorkspacePortalContext | null,
section: PortalRailSection | null
): WorkspaceShellContextPanel {
if (kind === 'sources') {
return 'sources';
}
if (kind === 'settings') {
return 'settings';
}
if (!context || !section) {
return 'none';
}
if (
(context.provider === 'xtreams' &&
XTREAM_CATEGORY_CONTEXT_SECTIONS.has(section)) ||
(context.provider === 'stalker' &&
STALKER_CATEGORY_CONTEXT_SECTIONS.has(section))
) {
return 'category';
}
if (
(context.provider === 'xtreams' || context.provider === 'stalker') &&
section === 'downloads'
) {
return 'collection';
}
return 'none';
}
export function parseWorkspaceShellRoute(url: string): WorkspaceShellRoute {
const { segments, queryParams } = normalizePath(url);
if (segments[0] !== 'workspace') {
return {
kind: 'unknown',
context: null,
section: null,
isPortalFavoritesAllScope: false,
searchMode: 'none',
usesQuerySearch: false,
contextPanel: 'none',
};
}
const sectionSegment = segments[3];
const provider = asPortalProvider(segments[1]);
if (provider && segments[2]) {
const context: WorkspacePortalContext = {
provider,
playlistId: segments[2],
};
const section = asPortalRailSection(sectionSegment);
return {
kind: 'portal',
context,
section,
isPortalFavoritesAllScope:
section === 'favorites' &&
(provider === 'xtreams' || provider === 'stalker') &&
queryParams.get('scope') === 'all',
searchMode: resolveRouteSearchMode('portal', context, section),
usesQuerySearch: usesWorkspaceRouteQuerySearch(context, section),
contextPanel: resolveContextPanel('portal', context, section),
};
}
const page = segments[1];
const kind: WorkspaceShellPageKind =
!page || page === 'dashboard'
? 'dashboard'
: page === 'sources'
? 'sources'
: page === 'settings'
? 'settings'
: page === 'global-favorites'
? 'global-favorites'
: page === 'global-recent'
? 'global-recent'
: page === 'downloads'
? 'downloads'
: 'unknown';
const searchMode = resolveRouteSearchMode(kind, null, null);
return {
kind,
context: null,
section: null,
isPortalFavoritesAllScope: false,
searchMode,
usesQuerySearch:
searchMode === 'local-filter' || searchMode === 'remote-search',
contextPanel: resolveContextPanel(kind, null, null),
};
}
@@ -0,0 +1,37 @@
import { Injectable, signal } from '@angular/core';
export interface SettingsNavItem {
id: string;
label: string;
icon: string;
}
@Injectable({ providedIn: 'root' })
export class SettingsContextService {
readonly sections = signal<SettingsNavItem[]>([]);
readonly activeSection = signal<string>('general');
readonly pendingScrollTarget = signal<string | null>(null);
setSections(items: SettingsNavItem[]): void {
this.sections.set(items);
}
setActiveSection(id: string): void {
this.activeSection.set(id);
}
navigateToSection(id: string): void {
this.activeSection.set(id);
this.pendingScrollTarget.set(id);
}
clearPendingScrollTarget(): void {
this.pendingScrollTarget.set(null);
}
reset(): void {
this.sections.set([]);
this.activeSection.set('general');
this.pendingScrollTarget.set(null);
}
}
@@ -0,0 +1,23 @@
import { InjectionToken } from '@angular/core';
import type { XtreamAccountInfoDialogData } from 'shared-interfaces';
export type WorkspacePlaylistType =
| 'xtream'
| 'url'
| 'text'
| 'file'
| 'stalker';
export type PlaylistCategory = 'm3u' | 'xtream' | 'stalker';
export type M3uSubType = 'url' | 'file' | 'text';
export type WorkspaceAccountInfoData = XtreamAccountInfoDialogData;
export interface WorkspaceShellActions {
openAddPlaylistDialog(type?: WorkspacePlaylistType): void;
openGlobalSearch(initialQuery?: string): void;
openGlobalRecent(): void;
openAccountInfo(data: WorkspaceAccountInfoData): void;
}
export const WORKSPACE_SHELL_ACTIONS =
new InjectionToken<WorkspaceShellActions>('WORKSPACE_SHELL_ACTIONS');
@@ -0,0 +1,104 @@
import { signal } from '@angular/core';
import { TestBed } from '@angular/core/testing';
import { of } from 'rxjs';
import { PlaylistsService, SettingsStore } from 'services';
import { StartupBehavior } from 'shared-interfaces';
import { WorkspaceStartupPreferencesService } from './workspace-startup-preferences.service';
describe('WorkspaceStartupPreferencesService', () => {
let service: WorkspaceStartupPreferencesService;
let playlistsService: { getAllPlaylists: jest.Mock };
let settingsStore: {
loadSettings: jest.Mock;
showDashboard: ReturnType<typeof signal<boolean>>;
startupBehavior: ReturnType<typeof signal<StartupBehavior>>;
};
beforeEach(() => {
localStorage.clear();
playlistsService = {
getAllPlaylists: jest.fn().mockReturnValue(
of([{ _id: 'playlist-1' }])
),
};
settingsStore = {
loadSettings: jest.fn().mockResolvedValue(undefined),
showDashboard: signal(true),
startupBehavior: signal(StartupBehavior.FirstView),
};
TestBed.configureTestingModule({
providers: [
WorkspaceStartupPreferencesService,
{
provide: PlaylistsService,
useValue: playlistsService,
},
{
provide: SettingsStore,
useValue: settingsStore,
},
],
});
service = TestBed.inject(WorkspaceStartupPreferencesService);
});
it('resolves the first view to dashboard when dashboard is enabled', async () => {
await expect(service.resolveInitialWorkspacePath()).resolves.toBe(
'/workspace/dashboard'
);
});
it('resolves the first view to sources when dashboard is hidden', async () => {
settingsStore.showDashboard.set(false);
await expect(service.resolveInitialWorkspacePath()).resolves.toBe(
'/workspace/sources'
);
});
it('restores the last route when restore-last-view is enabled', async () => {
settingsStore.startupBehavior.set(StartupBehavior.RestoreLastView);
service.persistLastRestorablePath('/workspace/global-recent?q=matrix');
await expect(service.resolveInitialWorkspacePath()).resolves.toBe(
'/workspace/global-recent'
);
});
it('falls back to sources when the stored dashboard route is hidden', async () => {
settingsStore.showDashboard.set(false);
settingsStore.startupBehavior.set(StartupBehavior.RestoreLastView);
service.persistLastRestorablePath('/workspace/dashboard');
await expect(service.resolveInitialWorkspacePath()).resolves.toBe(
'/workspace/sources'
);
});
it('canonicalizes detail routes to their section root', () => {
expect(
service.getRestorablePath(
'/workspace/xtreams/playlist-1/vod/123/456?q=matrix'
)
).toBe('/workspace/xtreams/playlist-1/vod');
});
it('ignores non-restorable routes', () => {
expect(service.getRestorablePath('/workspace/settings')).toBeNull();
expect(service.getRestorablePath('/workspace')).toBeNull();
expect(service.getRestorablePath('/unknown')).toBeNull();
});
it('falls back to the first available view when the stored playlist no longer exists', async () => {
settingsStore.startupBehavior.set(StartupBehavior.RestoreLastView);
playlistsService.getAllPlaylists.mockReturnValue(of([]));
service.persistLastRestorablePath('/workspace/xtreams/missing/live');
await expect(service.resolveInitialWorkspacePath()).resolves.toBe(
'/workspace/dashboard'
);
});
});
@@ -0,0 +1,153 @@
import { Injectable, inject } from '@angular/core';
import { firstValueFrom } from 'rxjs';
import { PlaylistsService, SettingsStore } from 'services';
import {
StartupBehavior,
} from 'shared-interfaces';
import { parseWorkspaceShellRoute } from './navigation/workspace-shell-route.utils';
const LAST_RESTORABLE_ROUTE_STORAGE_KEY = 'workspace-last-restorable-route-v1';
@Injectable({ providedIn: 'root' })
export class WorkspaceStartupPreferencesService {
private readonly settingsStore = inject(SettingsStore);
private readonly playlistsService = inject(PlaylistsService);
async resolveInitialWorkspacePath(): Promise<string> {
await this.settingsStore.loadSettings();
const showDashboard = this.showDashboard();
const firstViewPath = this.getFirstAvailableWorkspacePath(showDashboard);
if (this.startupBehavior() !== StartupBehavior.RestoreLastView) {
return firstViewPath;
}
return (
(await this.getValidatedLastRestorablePath(showDashboard)) ??
firstViewPath
);
}
async resolveDashboardPath(): Promise<string> {
await this.settingsStore.loadSettings();
return this.showDashboard()
? '/workspace/dashboard'
: '/workspace/sources';
}
getFirstAvailableWorkspacePath(showDashboard = this.showDashboard()): string {
return showDashboard ? '/workspace/dashboard' : '/workspace/sources';
}
showDashboard(): boolean {
return this.settingsStore.showDashboard?.() ?? true;
}
startupBehavior(): StartupBehavior {
return (
this.settingsStore.startupBehavior?.() ??
StartupBehavior.FirstView
);
}
persistLastRestorablePath(url: string): void {
const canonicalPath = this.getRestorablePath(url);
if (!canonicalPath) {
return;
}
try {
localStorage.setItem(
LAST_RESTORABLE_ROUTE_STORAGE_KEY,
canonicalPath
);
} catch {
// Ignore storage write failures.
}
}
getRestorablePath(url: string): string | null {
const [path] = url.split('?');
if (path === '/workspace' || path === '/workspace/') {
return null;
}
const route = parseWorkspaceShellRoute(url);
switch (route.kind) {
case 'dashboard':
return '/workspace/dashboard';
case 'downloads':
return '/workspace/downloads';
case 'global-favorites':
return '/workspace/global-favorites';
case 'global-recent':
return '/workspace/global-recent';
case 'sources':
return '/workspace/sources';
case 'portal':
if (!route.context || !route.section) {
return null;
}
return [
'/workspace',
route.context.provider,
route.context.playlistId,
route.section,
].join('/');
default:
return null;
}
}
async getValidatedLastRestorablePath(
showDashboard = this.showDashboard()
): Promise<string | null> {
const storedPath = this.readLastRestorablePath();
if (!storedPath) {
return null;
}
const canonicalPath = this.getRestorablePath(storedPath);
if (!canonicalPath) {
return null;
}
if (!showDashboard && canonicalPath === '/workspace/dashboard') {
return this.getFirstAvailableWorkspacePath(false);
}
const route = parseWorkspaceShellRoute(canonicalPath);
if (route.kind !== 'portal' || !route.context) {
return canonicalPath;
}
try {
const playlists = await firstValueFrom(
this.playlistsService.getAllPlaylists()
);
return playlists.some(
(playlist) => playlist._id === route.context?.playlistId
)
? canonicalPath
: this.getFirstAvailableWorkspacePath(showDashboard);
} catch {
return this.getFirstAvailableWorkspacePath(showDashboard);
}
}
private readLastRestorablePath(): string | null {
try {
const value = localStorage.getItem(
LAST_RESTORABLE_ROUTE_STORAGE_KEY
);
return value && value.trim().length > 0 ? value : null;
} catch {
return null;
}
}
}

Some files were not shown because too many files have changed in this diff Show More