Proyecto App Windows

This commit is contained in:
2026-05-03 23:36:48 +02:00
commit 96793b2e1e
1248 changed files with 189750 additions and 0 deletions
+13
View File
@@ -0,0 +1,13 @@
{
"name": "epg-data-access",
"$schema": "../../../../node_modules/nx/schemas/project-schema.json",
"sourceRoot": "libs/epg/data-access/src",
"prefix": "lib",
"projectType": "library",
"tags": ["scope:shared", "domain:playback", "type:data-access"],
"targets": {
"lint": {
"executor": "@nx/eslint:lint"
}
}
}
+2
View File
@@ -0,0 +1,2 @@
export * from './lib/epg-progress.service';
export * from './lib/epg.service';
@@ -0,0 +1,37 @@
import { EpgProgram } from 'shared-interfaces';
import { normalizeEpgPrograms } from './epg-program-normalization.util';
function buildProgram(overrides: Partial<EpgProgram> = {}): EpgProgram {
return {
start: '2026-04-15T20:00:00Z',
stop: '2026-04-15T21:00:00Z',
channel: 'channel-1',
title: 'Sample',
desc: null,
category: null,
iconUrl: null,
rating: null,
episodeNum: null,
...overrides,
};
}
describe('normalizeEpgPrograms', () => {
it('keeps valid rows and normalizes dates', () => {
expect(normalizeEpgPrograms([buildProgram()])).toEqual([
expect.objectContaining({
start: '2026-04-15T20:00:00.000Z',
stop: '2026-04-15T21:00:00.000Z',
}),
]);
});
it('drops rows with invalid stop dates', () => {
expect(
normalizeEpgPrograms([
buildProgram({ title: 'ok' }),
buildProgram({ title: 'bad', stop: '' }),
])
).toEqual([expect.objectContaining({ title: 'ok' })]);
});
});
@@ -0,0 +1,38 @@
import { EpgProgram } from 'shared-interfaces';
function toIsoDate(value: string | undefined): string | null {
if (!value) {
return null;
}
const date = new Date(value);
if (Number.isNaN(date.getTime())) {
return null;
}
return date.toISOString();
}
/**
* Drop malformed EPG rows instead of failing the whole channel request.
*/
export function normalizeEpgPrograms(programs: EpgProgram[]): EpgProgram[] {
const normalizedPrograms: EpgProgram[] = [];
for (const program of programs) {
const start = toIsoDate(program.start);
const stop = toIsoDate(program.stop);
if (!start || !stop) {
continue;
}
normalizedPrograms.push({
...program,
start,
stop,
});
}
return normalizedPrograms;
}
@@ -0,0 +1,86 @@
import { Injectable, computed, signal } from '@angular/core';
export interface EpgImportProgress {
url: string;
status: 'queued' | 'loading' | 'complete' | 'error';
stats?: { totalChannels: number; totalPrograms: number };
error?: string;
queuePosition?: number;
}
@Injectable({ providedIn: 'root' })
export class EpgProgressService {
private readonly importsMap = signal<Map<string, EpgImportProgress>>(
new Map()
);
private initialized = false;
readonly imports = computed(() => Array.from(this.importsMap().values()));
readonly hasActiveImports = computed(() =>
this.imports().some((item) => item.status === 'loading')
);
readonly activeCount = computed(
() => this.imports().filter((item) => item.status === 'loading').length
);
readonly queuedImports = computed(() =>
this.imports()
.filter((item) => item.status === 'queued')
.sort(
(a, b) => (a.queuePosition ?? 0) - (b.queuePosition ?? 0)
)
);
readonly queuedCount = computed(() => this.queuedImports().length);
readonly isVisible = computed(() => this.imports().length > 0);
constructor() {
this.initializeListener();
}
dismiss(url: string): void {
this.removeImport(url);
}
dismissAll(): void {
this.importsMap.set(new Map());
}
retry(url: string): void {
// Clear the errored row so the backend's subsequent 'queued' event
// reappears cleanly rather than updating an existing error row.
this.removeImport(url);
void window.electron?.forceFetchEpg?.(url);
}
private initializeListener(): void {
if (this.initialized) {
return;
}
this.initialized = true;
if (window.electron?.onEpgProgress) {
window.electron.onEpgProgress((data) => {
this.updateProgress(data);
});
}
}
private updateProgress(progress: EpgImportProgress): void {
this.importsMap.update((current) => {
const updated = new Map(current);
updated.set(progress.url, progress);
return updated;
});
if (progress.status === 'complete' || progress.status === 'error') {
setTimeout(() => this.removeImport(progress.url), 5000);
}
}
private removeImport(url: string): void {
this.importsMap.update((current) => {
const updated = new Map(current);
updated.delete(url);
return updated;
});
}
}
@@ -0,0 +1,119 @@
/* import { TestBed } from '@angular/core/testing';
import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar';
import { MockStore, provideMockStore } from '@ngrx/store/testing';
import { TranslateService } from '@ngx-translate/core';
import { invoke } from '@tauri-apps/api/core';
import { MockModule, MockProviders } from 'ng-mocks';
import { DataService } from './data.service';
import { EpgService } from './epg.service';
jest.mock('@tauri-apps/api/core', () => ({
invoke: jest.fn(),
isTauri: () => true,
}));
describe('EpgService', () => {
let service: EpgService;
let snackBar: MatSnackBar;
let store: MockStore;
let translateService: TranslateService;
let dispatchSpy: jest.SpyInstance;
beforeEach(() => {
TestBed.configureTestingModule({
providers: [
EpgService,
MockProviders(DataService, TranslateService, MatSnackBar),
provideMockStore(),
],
imports: [MockModule(MatSnackBarModule)],
});
service = TestBed.inject(EpgService);
snackBar = TestBed.inject(MatSnackBar);
store = TestBed.inject(MockStore);
translateService = TestBed.inject(TranslateService);
jest.spyOn(translateService, 'instant').mockImplementation(
(key) => key
);
jest.spyOn(snackBar, 'open');
dispatchSpy = jest.spyOn(store, 'dispatch');
// Suppress console.error during tests
jest.spyOn(console, 'error').mockImplementation(() => {});
jest.spyOn(console, 'log').mockImplementation(() => {});
});
afterEach(() => {
jest.clearAllMocks();
});
it('should create a service instance', () => {
expect(service).toBeTruthy();
});
describe('fetchEpg', () => {
it('should show success notification on successful fetch', async () => {
(invoke as jest.Mock).mockResolvedValueOnce({});
service.fetchEpg(['http://example.com/epg.xml']);
await new Promise((resolve) => setTimeout(resolve, 0));
expect(snackBar.open).toHaveBeenCalledWith(
'EPG.FETCH_SUCCESS',
null,
expect.any(Object)
);
});
it('should show error notification on fetch failure', async () => {
(invoke as jest.Mock).mockRejectedValueOnce(new Error('Failed'));
service.fetchEpg(['http://example.com/epg.xml']);
await new Promise((resolve) => setTimeout(resolve, 0));
expect(snackBar.open).toHaveBeenCalledWith(
'EPG.ERROR',
'CLOSE',
expect.any(Object)
);
});
});
describe('getChannelPrograms', () => {
it('should update programs and set EPG flag to true when programs exist', (done) => {
const mockPrograms = [
{
start: new Date().toISOString(),
stop: new Date().toISOString(),
title: 'Test',
},
];
(invoke as jest.Mock).mockResolvedValueOnce(mockPrograms);
service.getChannelPrograms('test-channel');
service.currentEpgPrograms$.subscribe((programs) => {
expect(programs).toHaveLength(1);
expect(dispatchSpy).toHaveBeenCalledWith(
expect.objectContaining({ value: true })
);
done();
});
});
it('should set EPG flag to false when no programs found', (done) => {
(invoke as jest.Mock).mockResolvedValueOnce([]);
service.getChannelPrograms('test-channel');
service.currentEpgPrograms$.subscribe((programs) => {
expect(programs).toHaveLength(0);
expect(dispatchSpy).toHaveBeenCalledWith(
expect.objectContaining({ value: false })
);
done();
});
});
});
});
*/
+277
View File
@@ -0,0 +1,277 @@
import { inject, Injectable } from '@angular/core';
import { MatSnackBar } from '@angular/material/snack-bar';
import { TranslateService } from '@ngx-translate/core';
import { BehaviorSubject, forkJoin, from, Observable, of } from 'rxjs';
import { catchError, map, tap, timeout } from 'rxjs/operators';
import { EpgChannelMetadata, EpgProgram } from 'shared-interfaces';
import { normalizeEpgPrograms } from './epg-program-normalization.util';
interface CachedProgram {
program: EpgProgram | null;
timestamp: number;
}
type EpgChannelMetadataApi = {
getEpgChannelMetadata?: (
channelIds: string[]
) => Promise<Record<string, EpgChannelMetadata | null>>;
};
@Injectable({
providedIn: 'root',
})
export class EpgService {
private snackBar = inject(MatSnackBar);
private translate = inject(TranslateService);
private epgAvailable = new BehaviorSubject<boolean>(false);
private currentEpgPrograms = new BehaviorSubject<EpgProgram[]>([]);
// Cache for channel programs with 60-second TTL
private programCache = new Map<string, CachedProgram>();
private readonly CACHE_TTL = 60000; // 60 seconds
private readonly isDesktop = !!window.electron;
readonly epgAvailable$ = this.epgAvailable.asObservable();
readonly currentEpgPrograms$ = this.currentEpgPrograms.asObservable();
/**
* Fetches EPG from the given URLs
*/
fetchEpg(urls: string[]): void {
if (!this.isDesktop) return;
// Filter out empty URLs and send all URLs at once
const validUrls = urls.filter((url) => url?.trim());
if (validUrls.length === 0) return;
from(window.electron.fetchEpg(validUrls))
.pipe(
tap((result) => {
if (result.success) {
this.epgAvailable.next(true);
} else {
this.epgAvailable.next(false);
this.showErrorSnackbar(result.message);
}
}),
catchError((err) => {
console.error('EPG fetch error:', err);
this.epgAvailable.next(false);
this.showErrorSnackbar();
return of(null);
})
)
.subscribe();
}
/**
* Gets EPG programs for a specific channel
*/
getChannelPrograms(channelId: string): void {
if (!this.isDesktop) return;
console.log('Fetching EPG for channel ID:', channelId);
from(window.electron.getChannelPrograms(channelId))
.pipe(
timeout(3000),
map((programs: EpgProgram[]) => normalizeEpgPrograms(programs)),
catchError((err) => {
console.error('EPG get programs error:', err);
this.showErrorSnackbar();
this.currentEpgPrograms.next([]);
this.epgAvailable.next(false);
return of([]);
})
)
.subscribe((programs) => {
this.epgAvailable.next(programs.length > 0);
this.currentEpgPrograms.next(programs);
});
}
/**
* Shows error snackbar
*/
private showErrorSnackbar(message?: string): void {
const errorMessage = message || this.translate.instant('EPG.ERROR');
this.snackBar.open(errorMessage, this.translate.instant('CLOSE'), {
duration: 3000,
horizontalPosition: 'start',
});
}
/**
* Gets the current EPG program for a specific channel (with caching)
* @param channelId Channel ID (tvg-id or channel name)
* @returns Observable of current program or null
*/
getCurrentProgramForChannel(
channelId: string
): Observable<EpgProgram | null> {
if (!this.isDesktop || !channelId) {
return of(null);
}
// Check cache first
const cached = this.programCache.get(channelId);
const now = Date.now();
if (cached && now - cached.timestamp < this.CACHE_TTL) {
return of(cached.program);
}
// Fetch from backend
return from(window.electron.getChannelPrograms(channelId)).pipe(
map((programs: EpgProgram[]) => normalizeEpgPrograms(programs)),
map((programs: EpgProgram[]) => {
if (!programs.length) {
this.programCache.set(channelId, {
program: null,
timestamp: now,
});
return null;
}
const currentProgram = this.findCurrentProgram(programs);
// Cache the result
this.programCache.set(channelId, {
program: currentProgram,
timestamp: now,
});
return currentProgram;
}),
catchError((err) => {
console.error('EPG get current program error:', err);
this.programCache.set(channelId, {
program: null,
timestamp: now,
});
return of(null);
})
);
}
/**
* Finds the current program from a list of programs
*/
private findCurrentProgram(programs: EpgProgram[]): EpgProgram | null {
const now = new Date();
return (
programs.find((program) => {
const start = new Date(program.start);
const stop = new Date(program.stop);
return start <= now && now <= stop;
}) || null
);
}
/**
* Gets current programs for multiple channels (batch operation)
* @param channelIds Array of channel IDs
* @returns Observable of Map with channelId -> current program
*/
getCurrentProgramsForChannels(
channelIds: string[]
): Observable<Map<string, EpgProgram | null>> {
if (!this.isDesktop) {
return of(new Map());
}
if (!channelIds || channelIds.length === 0) {
return of(new Map());
}
const resultMap = new Map<string, EpgProgram | null>();
const channelsToFetch: string[] = [];
const now = Date.now();
// Check cache for each channel
channelIds.forEach((channelId) => {
const cached = this.programCache.get(channelId);
if (cached && now - cached.timestamp < this.CACHE_TTL) {
resultMap.set(channelId, cached.program);
} else {
channelsToFetch.push(channelId);
}
});
// If all channels were cached, return immediately
if (channelsToFetch.length === 0) {
return of(resultMap);
}
// Fetch uncached channels with timeout and error handling per request
const fetchObservables = channelsToFetch.map((channelId) =>
this.getCurrentProgramForChannel(channelId).pipe(
timeout(5000), // 5 second timeout per request
map((program) => ({ channelId, program })),
catchError(() => of({ channelId, program: null }))
)
);
// Combine all fetches using forkJoin
return forkJoin(fetchObservables).pipe(
map((results) => {
results.forEach((result) => {
resultMap.set(result.channelId, result.program);
});
return resultMap;
})
);
}
getChannelMetadataForChannels(
channelIds: string[]
): Observable<Map<string, EpgChannelMetadata | null>> {
if (!this.isDesktop) {
return of(new Map());
}
const normalizedChannelIds = Array.from(
new Set(
channelIds
.map((channelId) => channelId.trim())
.filter((channelId) => channelId.length > 0)
)
);
if (normalizedChannelIds.length === 0) {
return of(new Map());
}
const getEpgChannelMetadata = (
window.electron as EpgChannelMetadataApi | undefined
)?.getEpgChannelMetadata;
if (typeof getEpgChannelMetadata !== 'function') {
return of(new Map());
}
return from(getEpgChannelMetadata(normalizedChannelIds)).pipe(
map((metadataByChannelId) => {
return new Map<string, EpgChannelMetadata | null>(
normalizedChannelIds.map((channelId) => [
channelId,
metadataByChannelId[channelId] ?? null,
])
);
}),
catchError((err) => {
console.error('EPG get channel metadata error:', err);
return of(new Map<string, EpgChannelMetadata | null>());
})
);
}
/**
* Clears the program cache (useful when EPG is refreshed)
*/
clearCache(): void {
this.programCache.clear();
}
}
+28
View File
@@ -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"
}
]
}
+12
View File
@@ -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"]
}
+7
View File
@@ -0,0 +1,7 @@
# m3u-state
This library was generated with [Nx](https://nx.dev).
## Running unit tests
Run `nx test m3u-state` to execute the unit tests.
+34
View File
@@ -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: {},
},
];
+21
View File
@@ -0,0 +1,21 @@
export default {
displayName: 'm3u-state',
preset: '../../jest.preset.js',
setupFilesAfterEnv: ['<rootDir>/src/test-setup.ts'],
coverageDirectory: '../../coverage/libs/m3u-state',
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',
],
};
+21
View File
@@ -0,0 +1,21 @@
{
"name": "m3u-state",
"$schema": "../../node_modules/nx/schemas/project-schema.json",
"sourceRoot": "libs/m3u-state/src",
"prefix": "lib",
"projectType": "library",
"tags": ["scope:playlist", "domain:m3u", "type:data-access"],
"targets": {
"test": {
"executor": "@nx/jest:jest",
"outputs": ["{workspaceRoot}/coverage/{projectRoot}"],
"options": {
"jestConfig": "libs/m3u-state/jest.config.ts",
"tsConfig": "libs/m3u-state/tsconfig.spec.json"
}
},
"lint": {
"executor": "@nx/eslint:lint"
}
}
}
+7
View File
@@ -0,0 +1,7 @@
export * from './lib/actions';
export * from './lib/channel-epg-lookup.util';
export * from './lib/effects';
export * from './lib/external-player-payload.util';
export * from './lib/reducers';
export * from './lib/router.selectors';
export * from './lib/selectors';
+72
View File
@@ -0,0 +1,72 @@
import { createActionGroup, emptyProps, props } from '@ngrx/store';
import { Channel, EpgProgram, Playlist, PlaylistMeta } from 'shared-interfaces';
export const PlaylistActions = createActionGroup({
source: 'Playlists',
events: {
'Load Playlists': emptyProps(),
'Load Playlists Success': props<{ playlists: PlaylistMeta[] }>(),
'Add Playlist': props<{ playlist: Playlist }>(),
'Add Many Playlists': props<{ playlists: Playlist[] }>(),
'Remove Playlist': props<{ playlistId: string }>(),
'Update Playlist Meta': props<{ playlist: PlaylistMeta }>(),
'Update Playlist': props<{ playlist: Playlist; playlistId: string }>(),
'Update Many Playlists': props<{ playlists: Playlist[] }>(),
'Parse Playlist': props<{
uploadType: 'FILE' | 'URL' | 'TEXT';
playlist: string;
title: string;
path?: string;
}>(),
'Set Active Playlist': props<{ playlistId: string }>(),
'Update Playlist Positions': props<{
positionUpdates: { id: string; changes: { position: number } }[];
}>(),
'Remove All Playlists': emptyProps(),
'Handle Adding Playlist By Url': props<{
isTemporary: boolean;
playlist: Playlist;
}>(),
},
});
export const ChannelActions = createActionGroup({
source: 'Channels',
events: {
'Set Channels Loading': props<{ loading: boolean }>(),
'Set Channels': props<{ channels: Channel[] }>(),
'Set Active Channel': props<{ channel: Channel }>(),
'Set Active Channel Success': props<{ channel: Channel }>(),
'Reset Active Channel': emptyProps(),
'Set Adjacent Channel As Active': props<{
direction: 'next' | 'previous';
}>(),
},
});
export const EpgActions = createActionGroup({
source: 'EPG',
events: {
'Set Active Epg Program': props<{ program: EpgProgram }>(),
'Set Active Playback Url': props<{ playbackUrl: string }>(),
'Set Current Epg Program': props<{ program: EpgProgram }>(),
'Reset Active Epg Program': emptyProps(),
'Return To Live Playback': emptyProps(),
'Set Epg Available Flag': props<{ value: boolean }>(),
},
});
export const FavoritesActions = createActionGroup({
source: 'Favorites',
events: {
'Update Favorites': props<{ channel: Channel }>(),
'Set Favorites': props<{ channelIds: string[] }>(),
},
});
export const FilterActions = createActionGroup({
source: 'Filters',
events: {
'Set Selected Filters': props<{ selectedFilters: string[] }>(),
},
});
@@ -0,0 +1,65 @@
import { Channel } from 'shared-interfaces';
import { resolveChannelEpgLookupKey } from './channel-epg-lookup.util';
function buildChannel(overrides: Partial<Channel> = {}): Channel {
return {
id: 'channel-1',
url: 'https://example.com/live.m3u8',
name: 'Fallback Name',
group: { title: 'News' },
tvg: {
id: 'epg-id',
name: 'Guide Name',
url: '',
logo: '',
rec: '',
},
http: {
referrer: '',
'user-agent': '',
origin: '',
},
radio: 'false',
...overrides,
};
}
describe('resolveChannelEpgLookupKey', () => {
it('prefers tvg id when present', () => {
expect(resolveChannelEpgLookupKey(buildChannel())).toBe('epg-id');
});
it('falls back to tvg name before channel name', () => {
expect(
resolveChannelEpgLookupKey(
buildChannel({
tvg: {
id: ' ',
name: 'Guide Name',
url: '',
logo: '',
rec: '',
},
name: 'Channel Name',
})
)
).toBe('Guide Name');
});
it('falls back to channel name when tvg fields are empty', () => {
expect(
resolveChannelEpgLookupKey(
buildChannel({
tvg: {
id: ' ',
name: ' ',
url: '',
logo: '',
rec: '',
},
name: 'Channel Name',
})
)
).toBe('Channel Name');
});
});
@@ -0,0 +1,12 @@
import { Channel } from 'shared-interfaces';
export function resolveChannelEpgLookupKey(
channel: Channel | null | undefined
): string {
return (
channel?.tvg?.id?.trim() ||
channel?.tvg?.name?.trim() ||
channel?.name?.trim() ||
''
);
}
+45
View File
@@ -0,0 +1,45 @@
import { Channel } from 'shared-interfaces';
import { buildExternalPlayerPayload } from './external-player-payload.util';
describe('buildExternalPlayerPayload', () => {
const activeChannel: Channel = {
id: 'channel-1',
name: 'Sample TV',
url: 'https://streams.example.com/live.m3u8',
group: { title: 'News' },
tvg: {
id: 'sample-tv',
name: 'Sample TV',
url: '',
logo: '',
rec: '3',
},
catchup: {
type: 'shift',
days: '3',
},
timeshift: '3',
http: {
referrer: 'https://referrer.example.com',
'user-agent': 'Codex Test Agent',
origin: 'https://origin.example.com',
},
radio: 'false',
epgParams: '',
};
it('uses the resolved archive url while preserving headers and title', () => {
expect(
buildExternalPlayerPayload(
activeChannel,
'https://archive.example.com/replay.m3u8?utc=123&lutc=456'
)
).toEqual({
url: 'https://archive.example.com/replay.m3u8?utc=123&lutc=456',
title: 'Sample TV',
'user-agent': 'Codex Test Agent',
referer: 'https://referrer.example.com',
origin: 'https://origin.example.com',
});
});
});
+412
View File
@@ -0,0 +1,412 @@
import { inject, Injectable } from '@angular/core';
import { MatSnackBar } from '@angular/material/snack-bar';
import { Router } from '@angular/router';
import { EpgService } from '@iptvnator/epg/data-access';
import { resolveM3uCatchupUrl } from 'm3u-utils';
import { Actions, createEffect, ofType } from '@ngrx/effects';
import { Store } from '@ngrx/store';
import { StorageMap } from '@ngx-pwa/local-storage';
import { TranslateService } from '@ngx-translate/core';
import {
EMPTY,
filter,
firstValueFrom,
map,
switchMap,
tap,
withLatestFrom,
} from 'rxjs';
import { DataService, PlaylistsService } from 'services';
import {
OPEN_MPV_PLAYER,
OPEN_VLC_PLAYER,
Channel,
Playlist,
STORE_KEY,
VideoPlayer,
} from 'shared-interfaces';
import {
ChannelActions,
EpgActions,
FavoritesActions,
PlaylistActions,
} from './actions';
import {
selectActive,
selectActivePlaylistId,
selectChannels,
selectFavorites,
} from './selectors';
import { resolveChannelEpgLookupKey } from './channel-epg-lookup.util';
import { buildExternalPlayerPayload } from './external-player-payload.util';
@Injectable({ providedIn: 'any' })
export class PlaylistEffects {
private actions$ = inject(Actions);
private playlistsService = inject(PlaylistsService);
private dataService = inject(DataService);
private epgService = inject(EpgService);
private router = inject(Router);
private snackBar = inject(MatSnackBar);
private storage = inject(StorageMap);
private store = inject(Store);
private translate = inject(TranslateService);
updateFavorites$ = createEffect(
() => {
return this.actions$.pipe(
ofType(FavoritesActions.updateFavorites),
withLatestFrom(
this.store.select(selectFavorites),
this.store.select(selectActivePlaylistId)
),
filter(([, , playlistId]) => !!playlistId),
switchMap(([, favorites, playlistId]) =>
this.playlistsService.updateFavorites(playlistId, favorites)
),
tap(() => {
this.snackBar.open(
this.translate.instant('CHANNELS.FAVORITES_UPDATED'),
undefined,
{ duration: 2000 }
);
})
);
},
{ dispatch: false }
);
setFavorites$ = createEffect(
() => {
return this.actions$.pipe(
ofType(FavoritesActions.setFavorites),
withLatestFrom(this.store.select(selectActivePlaylistId)),
filter(([, playlistId]) => !!playlistId),
switchMap(([action, playlistId]) =>
this.playlistsService.setFavorites(
playlistId,
action.channelIds
)
)
);
},
{
dispatch: false,
}
);
resolveActiveEpgProgram$ = createEffect(() => {
return this.actions$.pipe(
ofType(EpgActions.setActiveEpgProgram),
withLatestFrom(this.store.select(selectActive)),
map(([action, activeChannel]) => {
const playbackUrl = activeChannel
? resolveM3uCatchupUrl(activeChannel, action.program)
: null;
return playbackUrl
? EpgActions.setActivePlaybackUrl({ playbackUrl })
: EpgActions.resetActiveEpgProgram();
})
);
});
openArchivedPlayback$ = createEffect(
() => {
return this.actions$.pipe(
ofType(EpgActions.setActivePlaybackUrl),
withLatestFrom(this.store.select(selectActive)),
tap(([action, activeChannel]) => {
void this.openWithConfiguredExternalPlayer(
action.playbackUrl,
activeChannel
);
})
);
},
{ dispatch: false }
);
returnToLivePlayback$ = createEffect(
() => {
return this.actions$.pipe(
ofType(EpgActions.returnToLivePlayback),
withLatestFrom(this.store.select(selectActive)),
filter(([, activeChannel]) => Boolean(activeChannel?.url)),
tap(([, activeChannel]) => {
void this.openWithConfiguredExternalPlayer(
activeChannel?.url ?? '',
activeChannel
);
})
);
},
{ dispatch: false }
);
setActiveChannel$ = createEffect(() => {
return this.actions$.pipe(
ofType(ChannelActions.setActiveChannel),
// Skip the effect entirely when channel is falsy
filter((action) => !!action.channel),
map((action) => {
const { channel } = action;
// Use modern EPG service to get channel programs
const channelId = resolveChannelEpgLookupKey(channel);
if (channelId) {
this.epgService.getChannelPrograms(channelId);
}
// Set user agent if specified on channel
if (channel.http['user-agent']) {
window.electron?.setUserAgent(
channel.http['user-agent'],
channel.http.referrer
);
}
firstValueFrom(this.storage.get(STORE_KEY.Settings)).then(
(settings: any) => {
if (
settings &&
Object.keys(settings).length > 0 &&
settings.player === VideoPlayer.MPV &&
channel.radio !== 'true'
) {
this.dataService.sendIpcEvent(OPEN_MPV_PLAYER, {
url: channel.url,
title: channel.name ?? '',
'user-agent': channel.http['user-agent'],
referer: channel.http.referrer,
origin: channel.http.origin,
});
} else if (
settings &&
Object.keys(settings).length > 0 &&
settings.player === VideoPlayer.VLC &&
channel.radio !== 'true'
)
this.dataService.sendIpcEvent(OPEN_VLC_PLAYER, {
url: channel.url,
title: channel.name ?? '',
'user-agent': channel.http['user-agent'],
referer: channel.http.referrer,
origin: channel.http.origin,
});
}
);
return ChannelActions.setActiveChannelSuccess({
channel: action.channel,
});
})
);
});
loadPlaylists$ = createEffect(() => {
return this.actions$.pipe(
ofType(PlaylistActions.loadPlaylists),
switchMap(() =>
this.playlistsService.getAllPlaylists().pipe(
map((playlists) =>
PlaylistActions.loadPlaylistsSuccess({
playlists,
})
)
)
)
);
});
private async openWithConfiguredExternalPlayer(
playbackUrl: string,
activeChannel: Channel | undefined | null
): Promise<void> {
const payload = buildExternalPlayerPayload(activeChannel, playbackUrl);
if (!payload) {
return;
}
const settings: any = await firstValueFrom(
this.storage.get(STORE_KEY.Settings)
);
if (!settings || Object.keys(settings).length === 0) {
return;
}
if (settings.player === VideoPlayer.MPV) {
this.dataService.sendIpcEvent(OPEN_MPV_PLAYER, payload);
return;
}
if (settings.player === VideoPlayer.VLC) {
this.dataService.sendIpcEvent(OPEN_VLC_PLAYER, payload);
}
}
removePlaylist$ = createEffect(
() => {
return this.actions$.pipe(
ofType(PlaylistActions.removePlaylist),
switchMap(async (action) => {
await firstValueFrom(
this.playlistsService.deletePlaylist(action.playlistId)
);
})
);
},
{ dispatch: false }
);
updatePlaylist$ = createEffect(
() => {
return this.actions$.pipe(
ofType(PlaylistActions.updatePlaylist),
switchMap((action) =>
this.playlistsService.updatePlaylist(action.playlistId, {
...action.playlist,
_id: action.playlistId,
})
)
);
},
{ dispatch: false }
);
parsePlaylist$ = createEffect(() => {
return this.actions$.pipe(
ofType(PlaylistActions.parsePlaylist),
map((action) =>
this.playlistsService.handlePlaylistParsing(
action.uploadType,
action.playlist,
action.title,
action.path
)
),
map((playlist) =>
PlaylistActions.addPlaylist({
playlist,
})
)
);
});
addPlaylist$ = createEffect(
() => {
return this.actions$.pipe(
ofType(
PlaylistActions.addPlaylist,
PlaylistActions.handleAddingPlaylistByUrl
),
tap((action) => {
if ('isTemporary' in action && action.isTemporary) {
return;
}
this.navigateToPlaylist(action.playlist);
}),
switchMap((action) => {
if ('isTemporary' in action && action.isTemporary) {
return EMPTY;
}
return this.playlistsService.addPlaylist(action.playlist);
})
);
},
{ dispatch: false }
);
updatePlaylistMeta$ = createEffect(
() => {
return this.actions$.pipe(
ofType(PlaylistActions.updatePlaylistMeta),
switchMap((action) =>
this.playlistsService.updatePlaylistMeta(action.playlist)
)
);
},
{ dispatch: false }
);
updatePlaylistPositions$ = createEffect(
() => {
return this.actions$.pipe(
ofType(PlaylistActions.updatePlaylistPositions),
switchMap((action) =>
this.playlistsService.updatePlaylistPositions(
action.positionUpdates
)
)
);
},
{ dispatch: false }
);
addManyPlaylists$ = createEffect(
() => {
return this.actions$.pipe(
ofType(PlaylistActions.addManyPlaylists),
switchMap((action) =>
this.playlistsService.addManyPlaylists(action.playlists)
)
);
},
{ dispatch: false }
);
updateManyPlaylists$ = createEffect(
() => {
return this.actions$.pipe(
ofType(PlaylistActions.updateManyPlaylists),
switchMap((action) =>
this.playlistsService.updateManyPlaylists(action.playlists)
)
);
},
{ dispatch: false }
);
setAdjacentChannelAsActive$ = createEffect(() => {
return this.actions$.pipe(
ofType(ChannelActions.setAdjacentChannelAsActive),
withLatestFrom(
this.store.select(selectChannels),
this.store.select(selectActive)
),
map(([action, channels, activeChannel]) => {
let adjacentChannel;
const index = channels.findIndex(
(channel) => channel.id === activeChannel?.id
);
if (action.direction === 'next') {
if (index === channels.length - 1)
adjacentChannel = activeChannel;
adjacentChannel = channels[index + 1];
} else if (action.direction === 'previous') {
if (index === -1 || index === 0)
adjacentChannel = activeChannel;
adjacentChannel = channels[index - 1];
}
return ChannelActions.setActiveChannelSuccess({
channel: adjacentChannel!,
});
})
);
});
private navigateToPlaylist(playlist: Playlist): void {
if (playlist.serverUrl) {
void this.router.navigate(['/workspace', 'xtreams', playlist._id]);
return;
}
if (playlist.macAddress) {
void this.router.navigate(['/workspace', 'stalker', playlist._id]);
return;
}
void this.router.navigate(['/workspace', 'playlists', playlist._id]);
}
}
@@ -0,0 +1,26 @@
import { Channel } from 'shared-interfaces';
export function buildExternalPlayerPayload(
activeChannel: Channel | undefined | null,
playbackUrl: string
):
| {
url: string;
title: string;
'user-agent': string | undefined;
referer: string | undefined;
origin: string | undefined;
}
| null {
if (!playbackUrl || !activeChannel) {
return null;
}
return {
url: playbackUrl,
title: activeChannel.name ?? '',
'user-agent': activeChannel.http?.['user-agent'],
referer: activeChannel.http?.referrer,
origin: activeChannel.http?.origin,
};
}
+27
View File
@@ -0,0 +1,27 @@
import { createEntityAdapter, EntityAdapter, EntityState } from '@ngrx/entity';
import { PlaylistMeta } from 'shared-interfaces';
export const playlistsAdapter: EntityAdapter<PlaylistMeta> =
createEntityAdapter<PlaylistMeta>({
selectId: (item: PlaylistMeta) => item._id,
});
export interface PlaylistMetaState extends EntityState<PlaylistMeta> {
selectedId: string;
allPlaylistsLoaded: boolean;
selectedFilters: string[];
}
export const initialPlaylistMetaState: PlaylistMetaState =
playlistsAdapter.getInitialState({
selectedId: '',
allPlaylistsLoaded: false,
selectedFilters: ['m3u', 'xtream', 'stalker'],
});
export const {
selectIds,
selectEntities: getPlaylistMetaEntities,
selectAll: getAllPlaylistsMeta,
selectTotal,
} = playlistsAdapter.getSelectors();
+2
View File
@@ -0,0 +1,2 @@
// Re-export from the new modular reducers structure
export * from './reducers/index';
@@ -0,0 +1,53 @@
import { createReducer } from '@ngrx/store';
import { Channel } from 'shared-interfaces';
import { ChannelActions } from '../actions';
import { initialState } from '../state';
import { channelReducers } from './channel.reducers';
const reducer = createReducer(initialState, ...channelReducers);
describe('channelReducers', () => {
const sampleChannel = {
epgParams: '',
http: {
origin: '',
referrer: '',
'user-agent': '',
},
id: 'channel-1',
name: 'Sample TV',
radio: 'false',
tvg: {
id: 'sample-tvg-id',
logo: '',
name: 'Sample TV',
rec: '',
url: '',
},
url: 'https://example.com/live.m3u8',
} as Channel;
it('tracks explicit channel loading state', () => {
const nextState = reducer(
initialState,
ChannelActions.setChannelsLoading({ loading: true })
);
expect(nextState.channelsLoading).toBe(true);
});
it('stores channels and clears the loading flag when channel data arrives', () => {
const loadingState = reducer(
initialState,
ChannelActions.setChannelsLoading({ loading: true })
);
const nextState = reducer(
loadingState,
ChannelActions.setChannels({ channels: [sampleChannel] })
);
expect(nextState.channels).toEqual([sampleChannel]);
expect(nextState.channelsLoading).toBe(false);
});
});
@@ -0,0 +1,38 @@
import { on } from '@ngrx/store';
import { Channel } from 'shared-interfaces';
import { ChannelActions } from '../actions';
import { PlaylistState } from '../state';
export const channelReducers = [
on(ChannelActions.setChannelsLoading, (state, action): PlaylistState => {
return {
...state,
channelsLoading: action.loading,
};
}),
on(
ChannelActions.setActiveChannelSuccess,
(state, action): PlaylistState => {
const { channel } = action;
return {
...state,
active: { ...channel, epgParams: '' } as Channel,
activePlaybackUrl: null,
};
}
),
on(ChannelActions.resetActiveChannel, (state): PlaylistState => {
return {
...state,
active: undefined,
activePlaybackUrl: null,
};
}),
on(ChannelActions.setChannels, (state, action): PlaylistState => {
return {
...state,
channelsLoading: false,
channels: action.channels,
};
}),
];
@@ -0,0 +1,111 @@
import { createReducer } from '@ngrx/store';
import { EpgActions } from '../actions';
import { initialState } from '../state';
import { epgReducers } from './epg.reducers';
const reducer = createReducer(initialState, ...epgReducers);
describe('epgReducers', () => {
it('does not recreate the active channel when epg params are already empty', () => {
const state = {
...initialState,
active: {
id: 'channel-1',
name: 'ARD-alpha',
url: 'https://example.com/live.m3u8',
tvg: {
id: '',
name: 'ARD-alpha',
url: '',
logo: '',
rec: '',
},
group: { title: '' },
http: {
referrer: '',
'user-agent': '',
origin: '',
},
radio: 'false',
epgParams: '',
},
activePlaybackUrl: null,
};
const nextState = reducer(state, EpgActions.resetActiveEpgProgram());
expect(nextState).toBe(state);
});
it('clears epg params when they are present', () => {
const state = {
...initialState,
active: {
id: 'channel-1',
name: 'ARD-alpha',
url: 'https://example.com/live.m3u8',
tvg: {
id: '',
name: 'ARD-alpha',
url: '',
logo: '',
rec: '',
},
group: { title: '' },
http: {
referrer: '',
'user-agent': '',
origin: '',
},
radio: 'false',
epgParams: '?utc=123&lutc=456',
},
activePlaybackUrl: 'https://archive.example.com/catchup.m3u8',
};
const nextState = reducer(state, EpgActions.resetActiveEpgProgram());
expect(nextState).not.toBe(state);
expect(nextState.active?.epgParams).toBe('');
expect(nextState.activePlaybackUrl).toBeNull();
});
it('stores the resolved playback url without mutating the active channel', () => {
const state = {
...initialState,
active: {
id: 'channel-1',
name: 'ARD-alpha',
url: 'https://example.com/live.m3u8',
tvg: {
id: '',
name: 'ARD-alpha',
url: '',
logo: '',
rec: '',
},
group: { title: '' },
http: {
referrer: '',
'user-agent': '',
origin: '',
},
radio: 'false',
epgParams: '',
},
activePlaybackUrl: null,
};
const nextState = reducer(
state,
EpgActions.setActivePlaybackUrl({
playbackUrl: 'https://archive.example.com/catchup.m3u8',
})
);
expect(nextState.activePlaybackUrl).toBe(
'https://archive.example.com/catchup.m3u8'
);
expect(nextState.active?.url).toBe('https://example.com/live.m3u8');
});
});
@@ -0,0 +1,46 @@
import { on } from '@ngrx/store';
import { Channel } from 'shared-interfaces';
import { EpgActions } from '../actions';
import { PlaylistState } from '../state';
export const epgReducers = [
on(EpgActions.setActivePlaybackUrl, (state, action): PlaylistState => ({
...state,
activePlaybackUrl: action.playbackUrl,
})),
on(
EpgActions.resetActiveEpgProgram,
EpgActions.returnToLivePlayback,
(state): PlaylistState => {
if (!state.active && !state.activePlaybackUrl) {
return state;
}
if (!state.activePlaybackUrl && !state.active?.epgParams) {
return state;
}
return {
...state,
activePlaybackUrl: null,
active: state.active
? ({ ...state.active, epgParams: '' } as Channel)
: undefined,
};
}
),
on(
EpgActions.setCurrentEpgProgram,
(state, action): PlaylistState => ({
...state,
currentEpgProgram: action.program,
})
),
on(
EpgActions.setEpgAvailableFlag,
(state, action): PlaylistState => ({
...state,
epgAvailable: action.value,
})
),
];
@@ -0,0 +1,57 @@
import { on } from '@ngrx/store';
import { FavoritesActions } from '../actions';
import { PlaylistState } from '../state';
export const favoritesReducers = [
on(FavoritesActions.updateFavorites, (state, action): PlaylistState => {
const selectedId = state.playlists.selectedId;
const playlist = state.playlists.entities[selectedId];
if (!selectedId || !playlist) {
return state;
}
const { channel } = action;
const playlistFavorites = (playlist.favorites ?? []).filter(
(favorite): favorite is string => typeof favorite === 'string'
);
const favorites = playlistFavorites.includes(channel.url)
? playlistFavorites.filter((url) => url !== channel.url)
: [...playlistFavorites, channel.url];
return {
...state,
playlists: {
...state.playlists,
entities: {
...state.playlists.entities,
[selectedId]: {
...playlist,
favorites,
},
},
},
};
}),
on(FavoritesActions.setFavorites, (state, action): PlaylistState => {
const selectedId = state.playlists.selectedId;
const playlist = state.playlists.entities[selectedId];
if (!selectedId || !playlist) {
return state;
}
const { channelIds } = action;
return {
...state,
playlists: {
...state.playlists,
entities: {
...state.playlists.entities,
[selectedId]: {
...playlist,
favorites: channelIds,
},
},
},
};
}),
];
@@ -0,0 +1,18 @@
import { on } from '@ngrx/store';
import { FilterActions } from '../actions';
import { PlaylistState } from '../state';
export const filterReducers = [
on(
FilterActions.setSelectedFilters,
(state, { selectedFilters }): PlaylistState => {
return {
...state,
playlists: {
...state.playlists,
selectedFilters,
},
};
}
),
];
+34
View File
@@ -0,0 +1,34 @@
import { createReducer } from '@ngrx/store';
import { initialState, PlaylistState } from '../state';
import { channelReducers } from './channel.reducers';
import { epgReducers } from './epg.reducers';
import { favoritesReducers } from './favorites.reducers';
import { filterReducers } from './filter.reducers';
import { playlistReducers } from './playlist.reducers';
export const playlistReducer = createReducer(
initialState,
...epgReducers,
...channelReducers,
...playlistReducers,
...favoritesReducers,
...filterReducers
);
// Selectors
export const selectPlaylists = (state: PlaylistState) => state.playlists;
export const selectPlaylistId = (state: PlaylistState) =>
state.playlists?.selectedId;
export const selectIsEpgAvailableReducer = (state: PlaylistState) =>
state.epgAvailable;
export const selectActiveReducer = (state: PlaylistState) => state.active;
export const selectActivePlaybackUrlReducer = (state: PlaylistState) =>
state.activePlaybackUrl;
export const selectCurrentEpgProgramReducer = (state: PlaylistState) =>
state.currentEpgProgram;
export const selectChannelsLoadingReducer = (state: PlaylistState) =>
state.channelsLoading;
export const selectChannelsReducer = (state: PlaylistState) => state.channels;
export const selectPlaylistsLoadingFlagReducer = (state: PlaylistState) =>
state.playlists?.allPlaylistsLoaded;
@@ -0,0 +1,133 @@
import { createReducer } from '@ngrx/store';
import { PlaylistActions } from '../actions';
import { playlistsAdapter } from '../playlists.state';
import { initialState } from '../state';
import { playlistReducers } from './playlist.reducers';
import { Channel, Playlist, PlaylistMeta } from 'shared-interfaces';
const reducer = createReducer(initialState, ...playlistReducers);
describe('playlistReducers', () => {
it('persists updateDate and hiddenGroupTitles when playlist meta is updated', () => {
const existingPlaylist: PlaylistMeta = {
_id: 'playlist-1',
title: 'Xtream Playlist',
count: 0,
importDate: '2026-03-28T00:00:00.000Z',
autoRefresh: false,
serverUrl: 'http://localhost:8080',
username: 'demo',
password: 'secret',
};
const state = {
...initialState,
playlists: playlistsAdapter.addOne(
existingPlaylist,
initialState.playlists
),
};
const nextState = reducer(
state,
PlaylistActions.updatePlaylistMeta({
playlist: {
...existingPlaylist,
hiddenGroupTitles: ['Movies', 'News'],
updateDate: 1712145600000,
},
})
);
expect(nextState.playlists.entities['playlist-1']?.updateDate).toBe(
1712145600000
);
expect(
nextState.playlists.entities['playlist-1']?.hiddenGroupTitles
).toEqual(['Movies', 'News']);
});
it('updates the active playlist channel cache and clears loading on playlist refresh', () => {
const refreshedChannel = {
epgParams: '',
http: {
origin: '',
referrer: '',
'user-agent': '',
},
id: 'channel-1',
name: 'Refreshed TV',
radio: 'false',
tvg: {
id: 'channel-1',
logo: '',
name: 'Refreshed TV',
rec: '',
url: '',
},
url: 'https://example.com/refreshed.m3u8',
} as Channel;
const existingPlaylist: PlaylistMeta = {
_id: 'playlist-1',
count: 1,
importDate: '2026-03-28T00:00:00.000Z',
title: 'Playlist One',
} as PlaylistMeta;
const refreshedPlaylist = {
playlist: {
items: [refreshedChannel],
},
} as Playlist;
const state = {
...initialState,
channelsLoading: true,
playlists: playlistsAdapter.addOne(existingPlaylist, {
...initialState.playlists,
selectedId: 'playlist-1',
}),
};
const nextState = reducer(
state,
PlaylistActions.updatePlaylist({
playlist: refreshedPlaylist,
playlistId: 'playlist-1',
})
);
expect(nextState.channels).toEqual([refreshedChannel]);
expect(nextState.channelsLoading).toBe(false);
});
it('keeps hiddenGroupTitles on playlist refresh when the refreshed payload omits them', () => {
const existingPlaylist: PlaylistMeta = {
_id: 'playlist-1',
count: 1,
hiddenGroupTitles: ['Radio-de'],
importDate: '2026-03-28T00:00:00.000Z',
title: 'Playlist One',
} as PlaylistMeta;
const state = {
...initialState,
playlists: playlistsAdapter.addOne(existingPlaylist, {
...initialState.playlists,
selectedId: 'playlist-1',
}),
};
const nextState = reducer(
state,
PlaylistActions.updatePlaylist({
playlist: {
playlist: {
items: [],
},
} as Playlist,
playlistId: 'playlist-1',
})
);
expect(
nextState.playlists.entities['playlist-1']?.hiddenGroupTitles
).toEqual(['Radio-de']);
});
});
@@ -0,0 +1,197 @@
import { on } from '@ngrx/store';
import { Channel } from 'shared-interfaces';
import { PlaylistActions } from '../actions';
import { playlistsAdapter } from '../playlists.state';
import { PlaylistState } from '../state';
export const playlistReducers = [
on(PlaylistActions.loadPlaylistsSuccess, (state, action): PlaylistState => {
return {
...state,
playlists: playlistsAdapter.addMany(action.playlists, {
...state.playlists,
allPlaylistsLoaded: true,
}),
};
}),
on(PlaylistActions.removePlaylist, (state, action): PlaylistState => {
const playlists = playlistsAdapter.removeOne(
action.playlistId,
state.playlists
);
return {
...state,
playlists: {
...playlists,
selectedId:
state.playlists.selectedId === action.playlistId
? ''
: playlists.selectedId,
},
};
}),
on(PlaylistActions.updatePlaylist, (state, action): PlaylistState => {
const isActivePlaylist =
state.playlists.selectedId === action.playlistId;
return {
...state,
channels: isActivePlaylist
? (action.playlist.playlist.items as Channel[])
: state.channels,
channelsLoading: isActivePlaylist ? false : state.channelsLoading,
playlists: playlistsAdapter.updateOne(
{
id: action.playlistId,
changes: {
...action.playlist,
_id: action.playlistId,
updateDate: Date.now(),
count: action.playlist.playlist.items.length,
userAgent: action.playlist.userAgent,
favorites:
state.playlists.entities[action.playlistId]
?.favorites ?? [],
},
},
state.playlists
),
};
}),
on(PlaylistActions.addPlaylist, (state, action): PlaylistState => {
return {
...state,
playlists: playlistsAdapter.addOne(
action.playlist,
state.playlists
),
};
}),
on(PlaylistActions.addManyPlaylists, (state, action): PlaylistState => {
return {
...state,
playlists: playlistsAdapter.addMany(
action.playlists,
state.playlists
),
};
}),
on(PlaylistActions.setActivePlaylist, (state, action): PlaylistState => {
return {
...state,
playlists: {
...state.playlists,
selectedId: action.playlistId,
},
};
}),
on(
PlaylistActions.updatePlaylistPositions,
(state, action): PlaylistState => {
return {
...state,
playlists: playlistsAdapter.updateMany(
action.positionUpdates,
state.playlists
),
};
}
),
on(PlaylistActions.updatePlaylistMeta, (state, action): PlaylistState => {
const p = action.playlist;
return {
...state,
playlists: playlistsAdapter.updateOne(
{
id: p._id,
changes: {
...(p.title != null ? { title: p.title } : {}),
...(p.autoRefresh != null
? { autoRefresh: p.autoRefresh }
: {}),
...(p.userAgent != null
? { userAgent: p.userAgent }
: {}),
...(p.referrer !== undefined
? { referrer: p.referrer }
: {}),
...(p.origin !== undefined ? { origin: p.origin } : {}),
...(p.serverUrl != null
? { serverUrl: p.serverUrl }
: {}),
...(p.username != null ? { username: p.username } : {}),
...(p.password != null ? { password: p.password } : {}),
...(p.macAddress != null
? { macAddress: p.macAddress }
: {}),
...(p.portalUrl != null
? { portalUrl: p.portalUrl }
: {}),
...(p.isFullStalkerPortal !== undefined
? {
isFullStalkerPortal: p.isFullStalkerPortal,
}
: {}),
...(p.favorites != null
? { favorites: p.favorites }
: {}),
...(p.recentlyViewed != null
? { recentlyViewed: p.recentlyViewed }
: {}),
...(p.hiddenGroupTitles != null
? { hiddenGroupTitles: p.hiddenGroupTitles }
: {}),
...(p.updateDate !== undefined
? { updateDate: p.updateDate }
: {}),
},
},
state.playlists
),
};
}),
on(PlaylistActions.updateManyPlaylists, (state, action): PlaylistState => {
return {
...state,
playlists: playlistsAdapter.updateMany(
action.playlists.map((updatedPlaylist) => ({
id: updatedPlaylist._id,
changes: {
...updatedPlaylist,
updateDate: Date.now(),
},
})),
state.playlists
),
};
}),
on(PlaylistActions.removeAllPlaylists, (state): PlaylistState => {
const playlists = playlistsAdapter.removeAll(state.playlists);
return {
...state,
playlists: {
...playlists,
selectedId: '',
},
};
}),
on(
PlaylistActions.handleAddingPlaylistByUrl,
(state, action): PlaylistState => {
if (action.isTemporary) {
return {
...state,
channelsLoading: false,
channels: action.playlist.playlist.items as Channel[],
};
} else {
return {
...state,
playlists: playlistsAdapter.addOne(
action.playlist,
state.playlists
),
};
}
}
),
];
@@ -0,0 +1,14 @@
import { getRouterSelectors } from '@ngrx/router-store';
export const {
selectCurrentRoute,
selectFragment,
selectQueryParams,
selectQueryParam,
selectRouteParams,
selectRouteParam,
selectRouteData,
selectRouteDataParam,
selectUrl,
selectTitle,
} = getRouterSelectors();
+45
View File
@@ -0,0 +1,45 @@
import { PlaylistMeta } from 'shared-interfaces';
import { PlaylistState } from './state';
import { selectActivePlaylist, selectChannelsLoading } from './selectors';
describe('selectors', () => {
const playlistOne = {
_id: 'playlist-1',
title: 'Playlist One',
} as PlaylistMeta;
const playlistTwo = {
_id: 'playlist-2',
title: 'Playlist Two',
} as PlaylistMeta;
const entities = {
'playlist-1': playlistOne,
'playlist-2': playlistTwo,
};
describe('selectActivePlaylist', () => {
it('resolves the active playlist from entity state and selected id', () => {
expect(selectActivePlaylist.projector(entities, 'playlist-2')).toBe(
playlistTwo
);
});
it('returns null when there is no active playlist id', () => {
expect(selectActivePlaylist.projector(entities, '')).toBeNull();
});
});
describe('selectChannelsLoading', () => {
it('returns the current M3U channel loading flag', () => {
expect(
selectChannelsLoading.projector({
channelsLoading: true,
} as PlaylistState)
).toBe(true);
expect(
selectChannelsLoading.projector({
channelsLoading: false,
} as PlaylistState)
).toBe(false);
});
});
});
+105
View File
@@ -0,0 +1,105 @@
import { createFeatureSelector, createSelector } from '@ngrx/store';
import { Playlist } from 'shared-interfaces';
import * as fromPlaylistMetaState from './playlists.state';
import * as fromPlaylistState from './reducers';
import { PlaylistState } from './state';
export const selectPlaylistState =
createFeatureSelector<PlaylistState>('playlistState');
export const selectIsEpgAvailable = createSelector(
selectPlaylistState,
fromPlaylistState.selectIsEpgAvailableReducer
);
export const selectActive = createSelector(
selectPlaylistState,
fromPlaylistState.selectActiveReducer
);
export const selectActivePlaybackUrl = createSelector(
selectPlaylistState,
fromPlaylistState.selectActivePlaybackUrlReducer
);
export const selectCurrentEpgProgram = createSelector(
selectPlaylistState,
fromPlaylistState.selectCurrentEpgProgramReducer
);
export const selectChannelsLoading = createSelector(
selectPlaylistState,
fromPlaylistState.selectChannelsLoadingReducer
);
export const selectChannels = createSelector(
selectPlaylistState,
fromPlaylistState.selectChannelsReducer
);
export const selectPlaylistsLoadingFlag = createSelector(
selectPlaylistState,
fromPlaylistState.selectPlaylistsLoadingFlagReducer
);
/** Playlist entity store selectors */
export const selectPlaylistsMetaState = createSelector(
selectPlaylistState,
fromPlaylistState.selectPlaylists
);
export const selectAllPlaylistsMeta = createSelector(
selectPlaylistsMetaState,
fromPlaylistMetaState.getAllPlaylistsMeta
);
export const selectActiveTypeFilters = createSelector(
selectPlaylistsMetaState,
(state) => state.selectedFilters
);
export const selectPlaylistEntity = (id: string) =>
createSelector(selectPlaylistEntities, (entities) => entities[id]);
export const selectActivePlaylistId = createSelector(
selectPlaylistsMetaState,
(state) => state.selectedId
);
export const selectPlaylistEntities = createSelector(
selectPlaylistsMetaState,
fromPlaylistMetaState.getPlaylistMetaEntities
);
export const selectActivePlaylist = createSelector(
selectPlaylistEntities,
selectActivePlaylistId,
(entities, activePlaylistId) => {
if (!entities || !activePlaylistId) {
return null;
}
return entities[activePlaylistId] ?? null;
}
);
export const selectPlaylistTitle = createSelector(
selectActivePlaylist,
(playlist) => playlist?.title || playlist?.filename || 'Untitled playlist'
);
export const selectPlaylistById = (id: string) =>
createSelector(selectPlaylistEntities, (entities) => {
if (entities) {
return entities[id] as Playlist;
}
return null;
});
export const selectFavorites = createSelector(
selectActivePlaylist,
(playlist) =>
(playlist?.favorites || []).filter(
(favorite): favorite is string => typeof favorite === 'string'
)
);
+24
View File
@@ -0,0 +1,24 @@
import { Channel } from 'shared-interfaces';
import { initialPlaylistMetaState, PlaylistMetaState } from './playlists.state';
import { EpgProgram } from 'shared-interfaces';
export interface PlaylistState {
active: Channel | undefined;
activePlaybackUrl: string | null;
currentEpgProgram: EpgProgram | undefined;
epgAvailable: boolean;
channelsLoading: boolean;
channels: Channel[]; // TODO: use entity store
playlists: PlaylistMetaState;
}
export const initialState: PlaylistState = {
active: undefined,
activePlaybackUrl: null,
currentEpgProgram: undefined,
epgAvailable: false,
channelsLoading: false,
channels: [],
playlists: initialPlaylistMetaState,
};
+6
View File
@@ -0,0 +1,6 @@
import { setupZoneTestEnv } from 'jest-preset-angular/setup-env/zone';
setupZoneTestEnv({
errorOnUnknownElements: true,
errorOnUnknownProperties: true,
});
+30
View File
@@ -0,0 +1,30 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"target": "es2022",
"moduleResolution": "bundler",
"strict": true,
"noImplicitOverride": true,
"noPropertyAccessFromIndexSignature": true,
"noImplicitReturns": true,
"noFallthroughCasesInSwitch": true,
"module": "preserve"
},
"angularCompilerOptions": {
"enableI18nLegacyMessageIdFormat": false,
"strictInjectionParameters": true,
"strictInputAccessModifiers": true,
"typeCheckHostBindings": true,
"strictTemplates": true
},
"files": [],
"include": [],
"references": [
{
"path": "./tsconfig.lib.json"
},
{
"path": "./tsconfig.spec.json"
}
]
}
+18
View File
@@ -0,0 +1,18 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"outDir": "../../dist/out-tsc",
"declaration": true,
"declarationMap": true,
"inlineSources": true,
"types": []
},
"files": ["../../global.d.ts"],
"exclude": [
"src/**/*.spec.ts",
"src/test-setup.ts",
"jest.config.ts",
"src/**/*.test.ts"
],
"include": ["src/**/*.ts"]
}
+17
View File
@@ -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"
]
}
+13
View File
@@ -0,0 +1,13 @@
{
"name": "playlist-import-feature",
"$schema": "../../../../node_modules/nx/schemas/project-schema.json",
"sourceRoot": "libs/playlist/import/feature/src",
"prefix": "lib",
"projectType": "library",
"tags": ["scope:playlist", "domain:playlist-import", "type:feature"],
"targets": {
"lint": {
"executor": "@nx/eslint:lint"
}
}
}
@@ -0,0 +1 @@
export * from './lib/add-playlist-dialog/add-playlist-dialog.component';
@@ -0,0 +1,155 @@
<div class="apd">
<h2 mat-dialog-title>
@if (productBranding?.xtreamOnlyUi) {
{{ 'HOME.KIRATV.ADD_PLAYLIST_TITLE' | translate }}
} @else {
{{ 'HOME.URL_UPLOAD.ADD_PLAYLIST' | translate }}
}
</h2>
<mat-dialog-content>
@if (!productBranding?.xtreamOnlyUi) {
<div class="apd-segmented" role="tablist" aria-label="Playlist category">
@for (opt of categoryOptions; track opt.value) {
<button
type="button"
role="tab"
[attr.aria-selected]="category() === opt.value"
class="apd-segment"
[class.active]="category() === opt.value"
(click)="category.set(opt.value)"
>
{{ opt.label }}
</button>
}
</div>
@if (category() === 'm3u') {
<div
class="apd-segmented apd-segmented--sub"
role="tablist"
aria-label="M3U source"
>
@for (opt of subtypeOptions; track opt.value) {
<button
type="button"
role="tab"
[attr.aria-selected]="m3uSubType() === opt.value"
class="apd-segment"
[class.active]="m3uSubType() === opt.value"
(click)="m3uSubType.set(opt.value)"
>
{{ opt.labelKey | translate }}
</button>
}
</div>
}
}
<div class="apd-form-area">
@switch (playlistType()) {
@case ('file') {
<app-file-upload
(fileRejected)="rejectFile($event)"
(imported)="onFileImported()"
(closeDialog)="closeDialog()"
/>
}
@case ('url') {
<app-url-upload />
}
@case ('text') {
<app-text-import (textAdded)="uploadAsText($event)" />
}
@case ('stalker') {
<app-stalker-portal-import (addClicked)="closeDialog()" />
}
@case ('xtream') {
<app-xtream-code-import (addClicked)="closeDialog()" />
}
}
</div>
</mat-dialog-content>
<mat-dialog-actions align="end">
<button mat-button type="button" (click)="closeDialog()">
{{ 'CANCEL' | translate }}
</button>
@switch (playlistType()) {
@case ('url') {
<button
mat-flat-button
color="accent"
type="button"
[disabled]="!urlUpload()?.form?.valid"
(click)="submitUrlPlaylist()"
>
{{ 'HOME.URL_UPLOAD.ADD_PLAYLIST' | translate }}
</button>
}
@case ('file') {
<button
mat-flat-button
color="accent"
type="button"
[disabled]="!fileUpload()?.selectedFile()"
(click)="fileUpload()?.confirm()"
>
{{ 'HOME.URL_UPLOAD.ADD_PLAYLIST' | translate }}
</button>
}
@case ('text') {
<button
mat-flat-button
color="accent"
type="button"
[disabled]="!textImport()?.textForm?.valid"
(click)="textImport()?.textAdded?.emit(textImport()?.textForm?.value?.text)"
>
{{ 'HOME.TEXT_IMPORT.BUTTON_LABEL' | translate }}
</button>
}
@case ('xtream') {
<button
mat-stroked-button
type="button"
[disabled]="!xtreamImport()?.form?.valid || xtreamImport()?.isTestingConnection"
(click)="xtreamImport()?.testConnection()"
>
{{
xtreamImport()?.isTestingConnection
? ('HOME.XTREAM_PLAYLIST.TESTING' | translate)
: ('HOME.XTREAM_PLAYLIST.TEST_CONNECTION' | translate)
}}
</button>
<button
mat-flat-button
color="accent"
type="button"
[disabled]="!xtreamImport()?.form?.valid"
(click)="xtreamImport()?.addPlaylist()"
>
{{ 'HOME.XTREAM_PLAYLIST.ADD' | translate }}
</button>
}
@case ('stalker') {
@if (stalkerImport()?.isLoading()) {
<button mat-flat-button color="accent" type="button" disabled>
{{ 'HOME.STALKER_PORTAL.VALIDATING' | translate }}...
</button>
} @else {
<button
mat-flat-button
color="accent"
type="button"
[disabled]="!stalkerImport()?.form?.valid"
(click)="stalkerImport()?.addPlaylist()"
>
{{ 'HOME.STALKER_PORTAL.ADD' | translate }}
</button>
}
}
}
</mat-dialog-actions>
</div>
@@ -0,0 +1,118 @@
.apd {
--apd-border: var(--app-widget-header-border);
--apd-heading: var(--app-heading-color);
--apd-body: var(--app-body-color);
--apd-muted: var(--app-muted-color);
--apd-surface-raised: var(--app-widget-header-bg);
--apd-accent: var(--app-selection-color);
--apd-segment-track: color-mix(in srgb, var(--app-shell-bg) 70%, transparent);
--apd-segment-active: color-mix(
in srgb,
var(--app-widget-header-bg) 70%,
white 14%
);
}
// ─── Segmented control ──────────────────────────────────────────────────────
.apd-segmented {
display: grid;
grid-auto-flow: column;
grid-auto-columns: 1fr;
background: var(--apd-segment-track);
border-radius: 12px;
padding: 4px;
gap: 2px;
margin-bottom: 14px;
&--sub {
background: color-mix(in srgb, var(--apd-segment-track) 85%, transparent);
}
}
.apd-segment {
appearance: none;
border: 0;
background: transparent;
color: var(--apd-body);
padding: 8px 14px;
border-radius: 9px;
font: inherit;
font-size: 13px;
font-weight: 500;
cursor: pointer;
line-height: 1.3;
transition:
background-color 160ms ease,
color 160ms ease,
box-shadow 160ms ease;
&:hover:not(.active) {
color: var(--apd-heading);
}
&.active {
background: var(--apd-segment-active);
color: var(--apd-heading);
box-shadow:
0 1px 2px rgba(0, 0, 0, 0.25),
inset 0 0 0 1px color-mix(in srgb, white 6%, transparent);
}
&:focus-visible {
outline: 2px solid var(--apd-accent);
outline-offset: 1px;
}
}
// ─── Form area ──────────────────────────────────────────────────────────────
.apd-form-area {
min-height: 0;
form {
display: flex;
flex-direction: column;
gap: 4px;
}
.w-full,
.mat-mdc-form-field {
width: 100%;
}
// Textarea overrides the compact density so multi-line input has room.
textarea.mat-mdc-input-element {
min-height: 140px;
padding-top: 8px;
}
app-text-import .mat-mdc-form-field-infix {
min-height: auto;
}
app-text-import .mat-mdc-form-field {
min-height: 180px;
}
.cors-note {
margin: 6px 0 0;
padding: 10px 12px;
border-radius: 8px;
background: color-mix(in srgb, var(--apd-surface-raised) 50%, transparent);
color: var(--apd-muted);
font-size: 11.5px;
line-height: 1.45;
}
}
// ─── Light-theme segmented control tweak ────────────────────────────────────
// `.dark-theme` is on <body>, so target light mode via `body:not(...)`.
body:not(.dark-theme) .apd {
--apd-segment-track: color-mix(in srgb, var(--app-content-bg) 70%, transparent);
--apd-segment-active: #ffffff;
.apd-segment.active {
box-shadow:
0 1px 2px rgba(0, 0, 0, 0.06),
0 0 0 1px rgba(0, 0, 0, 0.06);
}
}
@@ -0,0 +1,132 @@
import { TestBed } from '@angular/core/testing';
import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog';
import { MatSnackBar } from '@angular/material/snack-bar';
import { Store } from '@ngrx/store';
import { TranslateService } from '@ngx-translate/core';
import { PlaylistActions } from 'm3u-state';
import { DataService } from 'services';
import { PLAYLIST_PARSE_BY_URL } from 'shared-interfaces';
import { AddPlaylistDialogComponent } from './add-playlist-dialog.component';
describe('AddPlaylistDialogComponent', () => {
let component: AddPlaylistDialogComponent;
let dataService: { sendIpcEvent: jest.Mock };
let dialogRef: { close: jest.Mock };
let store: { dispatch: jest.Mock };
beforeEach(() => {
dataService = {
sendIpcEvent: jest.fn(),
};
dialogRef = {
close: jest.fn(),
};
store = {
dispatch: jest.fn(),
};
TestBed.configureTestingModule({
providers: [
{
provide: DataService,
useValue: dataService,
},
{
provide: MatDialogRef,
useValue: dialogRef,
},
{
provide: Store,
useValue: store,
},
{
provide: MatSnackBar,
useValue: {
open: jest.fn(),
},
},
{
provide: TranslateService,
useValue: {
instant: jest.fn((value: string) => value),
},
},
{
provide: MAT_DIALOG_DATA,
useValue: null,
},
],
});
component = TestBed.runInInjectionContext(
() => new AddPlaylistDialogComponent()
);
});
it('sends a trimmed custom title for URL playlists', () => {
(component as { urlUpload: jest.Mock }).urlUpload = jest.fn(() => ({
form: {
getRawValue: () => ({
playlistName: ' My Playlist ',
playlistUrl: ' https://example.com/list.m3u ',
}),
},
}));
component.submitUrlPlaylist();
expect(dataService.sendIpcEvent).toHaveBeenCalledWith(
PLAYLIST_PARSE_BY_URL,
{
title: 'My Playlist',
url: 'https://example.com/list.m3u',
}
);
expect(dialogRef.close).toHaveBeenCalled();
});
it('omits the title when the optional name is blank', () => {
(component as { urlUpload: jest.Mock }).urlUpload = jest.fn(() => ({
form: {
getRawValue: () => ({
playlistName: ' ',
playlistUrl: 'https://example.com/list.m3u',
}),
},
}));
component.submitUrlPlaylist();
expect(dataService.sendIpcEvent).toHaveBeenCalledWith(
PLAYLIST_PARSE_BY_URL,
{
url: 'https://example.com/list.m3u',
}
);
expect(dialogRef.close).toHaveBeenCalled();
});
it('strips playlist extensions from uploaded file titles', () => {
component.handlePlaylist({
uploadEvent: {
target: {
result: '#EXTM3U',
},
} as unknown as Event,
file: {
name: 'Local Source.m3u',
path: '/tmp/Local Source.m3u',
} as File,
});
expect(store.dispatch).toHaveBeenCalledWith(
PlaylistActions.parsePlaylist({
uploadType: 'FILE',
playlist: '#EXTM3U',
title: 'Local Source',
path: '/tmp/Local Source.m3u',
})
);
expect(dialogRef.close).toHaveBeenCalled();
});
});
@@ -0,0 +1,180 @@
import {
Component,
computed,
inject,
signal,
ViewEncapsulation,
viewChild,
} from '@angular/core';
import { MatButtonModule } from '@angular/material/button';
import {
MAT_DIALOG_DATA,
MatDialogModule,
MatDialogRef,
} from '@angular/material/dialog';
import { MatSnackBar } from '@angular/material/snack-bar';
import { Store } from '@ngrx/store';
import { TranslateModule, TranslateService } from '@ngx-translate/core';
import { PlaylistType } from '@iptvnator/playlist/shared/ui';
import {
M3uSubType,
PlaylistCategory,
} from '@iptvnator/workspace/shell/util';
import { PlaylistActions } from 'm3u-state';
import { DataService, WORKSPACE_PRODUCT_BRANDING } from 'services';
import { PLAYLIST_PARSE_BY_URL } from 'shared-interfaces';
import { FileUploadComponent } from '../file-upload/file-upload.component';
import { StalkerPortalImportComponent } from '../stalker-portal-import/stalker-portal-import.component';
import { TextImportComponent } from '../text-import/text-import.component';
import { UrlUploadComponent } from '../url-upload/url-upload.component';
import { XtreamCodeImportComponent } from '../xtream-code-import/xtream-code-import.component';
interface CategoryOption {
value: PlaylistCategory;
label: string;
}
interface SubtypeOption {
value: M3uSubType;
labelKey: string;
}
@Component({
imports: [
FileUploadComponent,
MatButtonModule,
MatDialogModule,
StalkerPortalImportComponent,
TextImportComponent,
TranslateModule,
UrlUploadComponent,
XtreamCodeImportComponent,
],
selector: 'app-add-playlist',
templateUrl: './add-playlist-dialog.component.html',
styleUrl: './add-playlist-dialog.component.scss',
encapsulation: ViewEncapsulation.None,
})
export class AddPlaylistDialogComponent {
private dataService = inject(DataService);
private dialogRef = inject(MatDialogRef<AddPlaylistDialogComponent>);
private store = inject(Store);
private snackBar = inject(MatSnackBar);
private translateService = inject(TranslateService);
private data = inject<{ type?: PlaylistType } | null>(MAT_DIALOG_DATA, {
optional: true,
});
readonly productBranding = inject(WORKSPACE_PRODUCT_BRANDING, {
optional: true,
});
readonly urlUpload = viewChild(UrlUploadComponent);
readonly fileUpload = viewChild(FileUploadComponent);
readonly textImport = viewChild(TextImportComponent);
readonly xtreamImport = viewChild(XtreamCodeImportComponent);
readonly stalkerImport = viewChild(StalkerPortalImportComponent);
readonly category = signal<PlaylistCategory>('m3u');
readonly m3uSubType = signal<M3uSubType>('url');
readonly categoryOptions: CategoryOption[] = [
{ value: 'm3u', label: 'M3U' },
{ value: 'xtream', label: 'Xtream' },
{ value: 'stalker', label: 'Stalker' },
];
readonly subtypeOptions: SubtypeOption[] = [
{ value: 'url', labelKey: 'HOME.TABS.URL_UPLOAD' },
{ value: 'file', labelKey: 'HOME.TABS.FILE_UPLOAD' },
{ value: 'text', labelKey: 'HOME.TABS.TEXT_IMPORT' },
];
readonly playlistType = computed<PlaylistType>(() => {
const cat = this.category();
if (cat === 'xtream') return 'xtream';
if (cat === 'stalker') return 'stalker';
return this.m3uSubType();
});
constructor() {
if (this.data?.type) {
this.initFromType(this.data.type);
} else if (this.productBranding?.xtreamOnlyUi) {
this.category.set('xtream');
}
}
private initFromType(type: PlaylistType): void {
if (type === 'xtream') {
this.category.set('xtream');
} else if (type === 'stalker') {
this.category.set('stalker');
} else {
this.category.set('m3u');
this.m3uSubType.set(type as M3uSubType);
}
}
/**
* Closes the dialog after a successful file import. The actual parse and
* dispatch happens inside `PlaylistFileImportService` (called from the
* `FileUploadComponent`).
*/
onFileImported(): void {
this.closeDialog();
}
rejectFile(filename: string): void {
this.snackBar.open(
this.translateService.instant('HOME.FILE_UPLOAD.REJECTED', {
filename,
})
);
}
/**
* Sends url of the playlist to the renderer process and preserves the
* existing fallback title behavior when the optional name is blank.
*/
submitUrlPlaylist(): void {
const formValue = this.urlUpload()?.form?.getRawValue();
const playlistUrl = formValue?.playlistUrl?.trim();
if (!playlistUrl) {
return;
}
const playlistName = this.normalizeOptionalValue(formValue?.playlistName);
this.dataService.sendIpcEvent(PLAYLIST_PARSE_BY_URL, {
url: playlistUrl,
...(playlistName ? { title: playlistName } : {}),
});
this.closeDialog();
}
/**
* Sends IPC event to the renderer process to parse playlist
* @param text playlist as string
*/
uploadAsText(playlist: string): void {
this.store.dispatch(
PlaylistActions.parsePlaylist({
uploadType: 'TEXT',
playlist,
title: this.translateService.instant('HOME.IMPORTED_AS_TEXT'),
})
);
this.closeDialog();
}
closeDialog(): void {
this.dialogRef.close();
}
private normalizeOptionalValue(value?: string | null): string | undefined {
const normalizedValue = value?.trim();
return normalizedValue ? normalizedValue : undefined;
}
}
@@ -0,0 +1,46 @@
import {
Directive,
EventEmitter,
HostListener,
Output,
} from '@angular/core';
@Directive({
standalone: true,
selector: '[appDragDropFileUpload]',
})
export class DragDropFileUploadDirective {
@Output() fileDropped = new EventEmitter<FileList>();
@Output() dragStateChanged = new EventEmitter<boolean>();
@HostListener('dragenter', ['$event']) dragEnter(event: DragEvent): void {
event.preventDefault();
event.stopPropagation();
this.dragStateChanged.emit(true);
}
@HostListener('dragover', ['$event']) dragOver(event: DragEvent): void {
event.preventDefault();
event.stopPropagation();
if (event.dataTransfer) {
event.dataTransfer.dropEffect = 'copy';
}
this.dragStateChanged.emit(true);
}
@HostListener('dragleave', ['$event']) dragLeave(event: DragEvent): void {
event.preventDefault();
event.stopPropagation();
this.dragStateChanged.emit(false);
}
@HostListener('drop', ['$event']) drop(event: DragEvent): void {
event.preventDefault();
event.stopPropagation();
this.dragStateChanged.emit(false);
const files = event.dataTransfer?.files;
if (files && files.length > 0) {
this.fileDropped.emit(files);
}
}
}
@@ -0,0 +1,53 @@
@if (selectedFile(); as file) {
<div class="file-selected">
<div class="file-selected__icon">
<mat-icon>description</mat-icon>
</div>
<div class="file-selected__meta">
<div class="file-selected__name" [title]="file.name">
{{ file.name }}
</div>
<div class="file-selected__size">{{ formatSize(file.size) }}</div>
</div>
<button
type="button"
class="file-selected__remove"
(click)="clearSelection()"
[attr.aria-label]="'CLEAR' | translate"
>
<mat-icon>close</mat-icon>
</button>
</div>
} @else {
<button
type="button"
class="file-drop"
[class.file-drop--dragging]="isDragging()"
appDragDropFileUpload
(fileDropped)="onFilesDropped($event)"
(dragStateChanged)="onDragStateChange($event)"
(click)="openPicker(fileField)"
>
<span class="file-drop__icon">
<mat-icon>file_download</mat-icon>
</span>
<span class="file-drop__title">
{{ 'HOME.FILE_UPLOAD.DRAG_DROP' | translate }}
</span>
<span class="file-drop__subtitle">
{{ 'HOME.FILE_UPLOAD.OR' | translate }}
{{ 'HOME.FILE_UPLOAD.CHOOSE_PLAYLIST' | translate }}
</span>
<span class="file-drop__browse">
{{ 'HOME.FILE_UPLOAD.BROWSE' | translate }}
</span>
</button>
}
<input
type="file"
name="playlist"
#fileField
(change)="onPicked(fileField)"
accept=".m3u,.m3u8"
hidden
/>
@@ -0,0 +1,194 @@
:host {
display: block;
margin: 4px 0 12px;
}
// ─── Dashed drop zone ────────────────────────────────────────────────────────
.file-drop {
width: 100%;
appearance: none;
font: inherit;
cursor: pointer;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 8px;
padding: 36px 24px 28px;
border-radius: 16px;
border: 1.5px dashed
color-mix(in srgb, var(--app-body-color) 30%, transparent);
background: color-mix(in srgb, var(--app-shell-bg) 55%, transparent);
color: var(--app-heading-color);
transition:
border-color 160ms ease,
background-color 160ms ease,
transform 160ms ease;
&:hover {
border-color: color-mix(
in srgb,
var(--app-selection-color) 55%,
transparent
);
background: color-mix(
in srgb,
var(--app-selection-color) 4%,
var(--app-shell-bg) 70%
);
}
&--dragging {
border-color: var(--app-selection-color);
background: color-mix(
in srgb,
var(--app-selection-color) 10%,
var(--app-shell-bg) 70%
);
transform: scale(1.005);
}
&:focus-visible {
outline: 2px solid var(--app-selection-color);
outline-offset: 2px;
}
&__icon {
width: 44px;
height: 44px;
display: inline-flex;
align-items: center;
justify-content: center;
color: var(--app-body-color);
margin-bottom: 6px;
mat-icon {
font-size: 30px;
width: 30px;
height: 30px;
line-height: 30px;
}
}
&__title {
font-size: 15px;
font-weight: 600;
color: var(--app-heading-color);
letter-spacing: -0.005em;
}
&__subtitle {
font-size: 12.5px;
color: var(--app-muted-color);
margin-bottom: 6px;
}
&__browse {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 8px 18px;
border-radius: 9px;
font-size: 13px;
font-weight: 600;
color: var(--app-heading-color);
background: transparent;
box-shadow: inset 0 0 0 1px
color-mix(in srgb, var(--app-body-color) 35%, transparent);
transition: background-color 160ms ease, box-shadow 160ms ease;
}
&:hover &__browse {
background: color-mix(in srgb, var(--app-hover-overlay) 100%, transparent);
box-shadow: inset 0 0 0 1px
color-mix(in srgb, var(--app-body-color) 55%, transparent);
}
}
// ─── Selected-file card ─────────────────────────────────────────────────────
.file-selected {
display: flex;
align-items: center;
gap: 12px;
padding: 14px 14px 14px 16px;
border-radius: 12px;
background: color-mix(in srgb, var(--app-shell-bg) 85%, transparent);
box-shadow: inset 0 0 0 1px var(--app-widget-header-border);
&__icon {
width: 40px;
height: 40px;
flex: 0 0 40px;
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: 10px;
background: color-mix(
in srgb,
var(--app-selection-color) 16%,
transparent
);
color: var(--app-selection-color);
mat-icon {
font-size: 22px;
width: 22px;
height: 22px;
line-height: 22px;
}
}
&__meta {
flex: 1 1 auto;
min-width: 0;
display: flex;
flex-direction: column;
gap: 2px;
}
&__name {
font-size: 14px;
font-weight: 600;
color: var(--app-heading-color);
white-space: nowrap;
text-overflow: ellipsis;
overflow: hidden;
}
&__size {
font-size: 12px;
color: var(--app-muted-color);
}
&__remove {
appearance: none;
border: 0;
background: transparent;
width: 32px;
height: 32px;
border-radius: 8px;
display: inline-flex;
align-items: center;
justify-content: center;
color: var(--app-muted-color);
cursor: pointer;
transition: background-color 120ms ease, color 120ms ease;
mat-icon {
font-size: 18px;
width: 18px;
height: 18px;
line-height: 18px;
}
&:hover {
background: var(--app-hover-overlay);
color: var(--app-heading-color);
}
&:focus-visible {
outline: 2px solid var(--app-selection-color);
outline-offset: 2px;
}
}
}
@@ -0,0 +1,94 @@
import { Component, EventEmitter, Output, inject, signal } from '@angular/core';
import { MatIconModule } from '@angular/material/icon';
import { TranslatePipe } from '@ngx-translate/core';
import { PlaylistFileImportService } from '@iptvnator/playlist/shared/util';
import { DragDropFileUploadDirective } from './drag-drop-file-upload.directive';
const MB = 1024 * 1024;
const KB = 1024;
@Component({
imports: [DragDropFileUploadDirective, MatIconModule, TranslatePipe],
selector: 'app-file-upload',
templateUrl: './file-upload.component.html',
styleUrls: ['./file-upload.component.scss'],
})
export class FileUploadComponent {
private readonly importService = inject(PlaylistFileImportService);
@Output() imported = new EventEmitter<{ title: string }>();
@Output() fileRejected = new EventEmitter<string>();
@Output() closeDialog = new EventEmitter<void>();
readonly selectedFile = signal<File | null>(null);
readonly isDragging = signal(false);
readonly isImporting = signal(false);
openPicker(input: HTMLInputElement): void {
input.value = '';
input.click();
}
onDragStateChange(isDragging: boolean): void {
this.isDragging.set(isDragging);
}
onFilesDropped(files: FileList): void {
this.setFile(files[0]);
}
onPicked(input: HTMLInputElement): void {
const file = input.files?.[0] as (File & { path?: string }) | undefined;
const pathOverride = input.dataset['filePathOverride'];
if (file && !file.path && pathOverride) {
Object.defineProperty(file, 'path', {
configurable: true,
value: pathOverride,
});
}
if (file) {
this.setFile(file);
}
delete input.dataset['filePathOverride'];
input.value = '';
}
clearSelection(): void {
this.selectedFile.set(null);
}
async confirm(): Promise<void> {
const file = this.selectedFile();
if (!file || this.isImporting()) return;
this.isImporting.set(true);
const result = await this.importService.importFile(file);
this.isImporting.set(false);
if (result.ok === true) {
this.imported.emit({ title: result.title });
return;
}
this.selectedFile.set(null);
this.fileRejected.emit(file.name);
}
formatSize(bytes: number): string {
if (bytes < KB) return `${bytes} B`;
if (bytes < MB) return `${(bytes / KB).toFixed(1)} KB`;
return `${(bytes / MB).toFixed(1)} MB`;
}
private setFile(file: File | undefined): void {
if (!file) return;
if (!this.importService.isSupportedFile(file)) {
this.fileRejected.emit(file.name);
return;
}
this.selectedFile.set(file);
}
}
@@ -0,0 +1,116 @@
<form [formGroup]="form">
<mat-form-field class="w-full">
<mat-label for="title">{{
'HOME.STALKER_PORTAL.TITLE' | translate
}}</mat-label>
<input matInput type="text" id="title" formControlName="title" />
</mat-form-field>
<mat-form-field class="w-full" subscriptSizing="dynamic">
<mat-label for="portalUrl">{{
'HOME.STALKER_PORTAL.SERVER_URL' | translate
}}</mat-label>
<input
matInput
type="text"
id="portalUrl"
formControlName="portalUrl"
/>
<mat-hint>{{
'HOME.STALKER_PORTAL.URL_VALIDATION_ERROR' | translate
}}</mat-hint>
<mat-error>{{ 'SETTINGS.EPG_URL_ERROR' | translate }}</mat-error>
</mat-form-field>
<mat-form-field class="w-full">
<mat-label for="macAddress">{{
'HOME.STALKER_PORTAL.MAC_ADDRESS' | translate
}}</mat-label>
<input
matInput
type="text"
id="macAddress"
formControlName="macAddress"
/>
</mat-form-field>
<mat-form-field class="w-full">
<mat-label for="serialNumber">{{
'HOME.STALKER_PORTAL.SERIAL_NUMBER' | translate
}}</mat-label>
<input
matInput
type="text"
id="serialNumber"
formControlName="serialNumber"
/>
<mat-hint>{{
'HOME.STALKER_PORTAL.SERIAL_NUMBER_HINT' | translate
}}</mat-hint>
</mat-form-field>
<mat-form-field class="w-full">
<mat-label for="deviceId1">{{
'HOME.STALKER_PORTAL.DEVICE_ID_1' | translate
}}</mat-label>
<input
matInput
type="text"
id="deviceId1"
formControlName="deviceId1"
/>
<mat-hint>{{
'HOME.STALKER_PORTAL.DEVICE_ID_HINT' | translate
}}</mat-hint>
</mat-form-field>
<mat-form-field class="w-full">
<mat-label for="deviceId2">{{
'HOME.STALKER_PORTAL.DEVICE_ID_2' | translate
}}</mat-label>
<input
matInput
type="text"
id="deviceId2"
formControlName="deviceId2"
/>
<mat-hint>{{
'HOME.STALKER_PORTAL.DEVICE_ID_HINT' | translate
}}</mat-hint>
</mat-form-field>
<mat-form-field class="w-full">
<mat-label for="signature1">{{
'HOME.STALKER_PORTAL.SIGNATURE_1' | translate
}}</mat-label>
<input
matInput
type="text"
id="signature1"
formControlName="signature1"
/>
<mat-hint>{{
'HOME.STALKER_PORTAL.SIGNATURE_HINT' | translate
}}</mat-hint>
</mat-form-field>
<mat-form-field class="w-full">
<mat-label for="signature2">{{
'HOME.STALKER_PORTAL.SIGNATURE_2' | translate
}}</mat-label>
<input
matInput
type="text"
id="signature2"
formControlName="signature2"
/>
<mat-hint>{{
'HOME.STALKER_PORTAL.SIGNATURE_HINT' | translate
}}</mat-hint>
</mat-form-field>
<!-- <mat-form-field class="w-full">
<mat-label for="username">{{
'HOME.XTREAM_PLAYLIST.USERNAME' | translate
}}</mat-label>
<input matInput type="text" id="username" formControlName="username" />
</mat-form-field>
<mat-form-field class="w-full">
<mat-label for="password">{{
'HOME.XTREAM_PLAYLIST.PASSWORD' | translate
}}</mat-label>
<input matInput type="text" id="password" formControlName="password" />
</mat-form-field> -->
</form>
@@ -0,0 +1,240 @@
import { Component, inject, output, signal } from '@angular/core';
import {
FormControl,
FormGroup,
FormsModule,
ReactiveFormsModule,
Validators,
} from '@angular/forms';
import { MatFormFieldModule } from '@angular/material/form-field';
import { MatInputModule } from '@angular/material/input';
import { MatSnackBar } from '@angular/material/snack-bar';
import { Store } from '@ngrx/store';
import { TranslatePipe, TranslateService } from '@ngx-translate/core';
import { PlaylistActions } from 'm3u-state';
import {
STALKER_SERIAL_NUMBER,
StalkerSessionService,
} from '@iptvnator/portal/stalker/data-access';
import { Playlist } from 'shared-interfaces';
import { v4 as uuid } from 'uuid';
@Component({
imports: [
FormsModule,
MatFormFieldModule,
MatInputModule,
ReactiveFormsModule,
TranslatePipe,
],
selector: 'app-stalker-portal-import',
templateUrl: './stalker-portal-import.component.html',
styles: [
`
:host {
display: flex;
margin: 10px;
justify-content: center;
}
form {
width: 100%;
}
.loading-container {
display: flex;
align-items: center;
gap: 8px;
}
`,
],
})
export class StalkerPortalImportComponent {
readonly addClicked = output<void>();
readonly URL_REGEX = /^(http|https|file):\/\/[^ "]+$/;
readonly form = new FormGroup({
_id: new FormControl(uuid()),
title: new FormControl('', [Validators.required]),
macAddress: new FormControl('', [Validators.required]),
serialNumber: new FormControl(''),
deviceId1: new FormControl(''),
deviceId2: new FormControl(''),
signature1: new FormControl(''),
signature2: new FormControl(''),
password: new FormControl(''),
username: new FormControl(''),
portalUrl: new FormControl('', [
Validators.required,
Validators.pattern(this.URL_REGEX),
]),
importDate: new FormControl(new Date().toISOString()),
userAgent: new FormControl(''),
});
private readonly stalkerSessionService = inject(StalkerSessionService);
private readonly store = inject(Store);
private readonly snackBar = inject(MatSnackBar);
readonly translate = inject(TranslateService);
readonly isLoading = signal(false);
async addPlaylist() {
if (!this.form.valid || this.isLoading()) {
return;
}
this.isLoading.set(true);
try {
const originalUrl = this.form.value.portalUrl;
const transformedUrl = this.transformPortalUrl(originalUrl);
const isFullStalkerPortal =
this.isFullStalkerPortalUrl(originalUrl);
let stalkerToken: string | undefined;
let stalkerAccountInfo: Playlist['stalkerAccountInfo'] | undefined;
let stalkerSerialNumber: string | undefined;
let stalkerDeviceId1: string | undefined;
let stalkerDeviceId2: string | undefined;
let stalkerSignature1: string | undefined;
let stalkerSignature2: string | undefined;
// For full stalker portal URLs, perform handshake and get profile
if (isFullStalkerPortal) {
// Use provided serial number or generate a new one
stalkerSerialNumber =
this.form.value.serialNumber?.trim() ||
STALKER_SERIAL_NUMBER;
// Use provided device IDs if available (64 hex chars each)
stalkerDeviceId1 =
this.form.value.deviceId1?.trim() || undefined;
stalkerDeviceId2 =
this.form.value.deviceId2?.trim() || undefined;
stalkerSignature1 =
this.form.value.signature1?.trim() || undefined;
stalkerSignature2 =
this.form.value.signature2?.trim() || undefined;
try {
const authResult =
await this.stalkerSessionService.authenticate(
transformedUrl,
this.form.value.macAddress,
stalkerSerialNumber,
stalkerDeviceId1,
stalkerDeviceId2
);
stalkerToken = authResult.token;
if (authResult.accountInfo) {
stalkerAccountInfo = {
login: authResult.accountInfo.login,
expireDate: authResult.accountInfo.expire_date,
tariffPlanName:
authResult.accountInfo.tariff_plan_name,
status: authResult.accountInfo.status,
};
}
// Show success notification with account info if available
if (stalkerAccountInfo?.expireDate) {
const expireDate = new Date(
stalkerAccountInfo.expireDate * 1000
);
this.snackBar.open(
`Portal validated. Expires: ${expireDate.toLocaleDateString()}`,
null,
{ duration: 3000 }
);
}
} catch (error) {
console.error(
'[StalkerImport] Authentication failed:',
error
);
this.snackBar.open(
'Failed to authenticate with portal. Please check URL and MAC address.',
null,
{ duration: 5000 }
);
this.isLoading.set(false);
return;
}
}
const playlist: Playlist = {
...this.form.value,
portalUrl: transformedUrl,
isFullStalkerPortal,
stalkerToken,
stalkerAccountInfo,
stalkerSerialNumber,
stalkerDeviceId1,
stalkerDeviceId2,
stalkerSignature1,
stalkerSignature2,
} as Playlist;
this.store.dispatch(PlaylistActions.addPlaylist({ playlist }));
this.addClicked.emit();
} finally {
this.isLoading.set(false);
}
}
/**
* Checks if the URL is a full stalker portal URL that requires handshake authentication
* Pattern: example.com/stalker_portal/c or example.com/stalker_portal/...
*/
isFullStalkerPortalUrl(url: string): boolean {
return url.includes('/stalker_portal');
}
/**
* Transforms the portal URL to the correct API endpoint
* - Simple URL (example.com/c) -> example.com/portal.php
* - Full stalker portal (example.com/stalker_portal/c) -> example.com/stalker_portal/server/load.php
*/
transformPortalUrl(url: string): string {
// Remove trailing slashes
url = url.replace(/\/+$/, '');
// Case 1: Simple URL ending with /c -> convert to /portal.php
if (url.endsWith('/c')) {
// Check if it's a full stalker portal URL
if (url.includes('/stalker_portal')) {
// example.com/stalker_portal/c -> example.com/stalker_portal/server/load.php
return url.replace(
/\/stalker_portal\/c$/,
'/stalker_portal/server/load.php'
);
}
// Simple URL: example.com/c -> example.com/portal.php
return url.replace(/\/c$/, '/portal.php');
}
// Case 2: Full stalker portal URL without /c at the end
if (
url.includes('/stalker_portal') &&
!url.includes('/server/load.php')
) {
// example.com/stalker_portal -> example.com/stalker_portal/server/load.php
if (url.endsWith('/stalker_portal')) {
return url + '/server/load.php';
}
// If it has other path segments after /stalker_portal, append server/load.php
if (!url.endsWith('/load.php')) {
return url.replace(
/\/stalker_portal(\/.*)?$/,
'/stalker_portal/server/load.php'
);
}
}
// Otherwise keep the provided url
return url;
}
}
@@ -0,0 +1,6 @@
<form [formGroup]="textForm">
<mat-form-field class="w-full">
<mat-label>{{ 'HOME.TEXT_IMPORT.LABEL' | translate }}</mat-label>
<textarea formControlName="text" matInput></textarea>
</mat-form-field>
</form>
@@ -0,0 +1,7 @@
button {
margin: 0 15px;
}
textarea {
height: 300px !important;
}
@@ -0,0 +1,34 @@
import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing';
import { FormsModule, ReactiveFormsModule } from '@angular/forms';
import { MatInputModule } from '@angular/material/input';
import { TranslateModule } from '@ngx-translate/core';
import { MockModule } from 'ng-mocks';
import { TextImportComponent } from './text-import.component';
describe('TextImportComponent', () => {
let component: TextImportComponent;
let fixture: ComponentFixture<TextImportComponent>;
beforeEach(waitForAsync(() => {
TestBed.configureTestingModule({
imports: [
TextImportComponent,
MockModule(MatInputModule),
MockModule(FormsModule),
MockModule(ReactiveFormsModule),
TranslateModule.forRoot(),
],
}).compileComponents();
}));
beforeEach(() => {
fixture = TestBed.createComponent(TextImportComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});
@@ -0,0 +1,23 @@
import { Component, EventEmitter, Output } from '@angular/core';
import {
FormControl,
FormGroup,
ReactiveFormsModule,
Validators,
} from '@angular/forms';
import { MatInputModule } from '@angular/material/input';
import { TranslatePipe } from '@ngx-translate/core';
@Component({
selector: 'app-text-import',
templateUrl: './text-import.component.html',
styleUrls: ['./text-import.component.scss'],
imports: [MatInputModule, ReactiveFormsModule, TranslatePipe],
})
export class TextImportComponent {
@Output() textAdded = new EventEmitter<string>();
textForm = new FormGroup({
text: new FormControl('', Validators.required),
});
}
@@ -0,0 +1,15 @@
<form [formGroup]="form">
<mat-form-field class="w-full">
<mat-label>{{ 'HOME.URL_UPLOAD.PLAYLIST_URL' | translate }}</mat-label>
<input type="url" matInput formControlName="playlistUrl" />
</mat-form-field>
<mat-form-field class="w-full">
<mat-label>{{ 'HOME.XTREAM_PLAYLIST.TITLE' | translate }}</mat-label>
<input type="text" matInput formControlName="playlistName" />
</mat-form-field>
@if (!isDesktop) {
<p class="cors-note">
{{ 'HOME.URL_UPLOAD.CORS_NOTE' | translate }}
</p>
}
</form>
@@ -0,0 +1,60 @@
import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing';
import { FormsModule, ReactiveFormsModule } from '@angular/forms';
import { MatFormFieldModule } from '@angular/material/form-field';
import { NoopAnimationsModule } from '@angular/platform-browser/animations';
import { TranslateModule } from '@ngx-translate/core';
import { MockModule } from 'ng-mocks';
import { UrlUploadComponent } from './url-upload.component';
describe('UrlUploadComponent', () => {
let component: UrlUploadComponent;
let fixture: ComponentFixture<UrlUploadComponent>;
beforeEach(waitForAsync(() => {
TestBed.configureTestingModule({
imports: [
UrlUploadComponent,
MockModule(FormsModule),
MockModule(MatFormFieldModule),
MockModule(ReactiveFormsModule),
TranslateModule.forRoot(),
NoopAnimationsModule,
],
}).compileComponents();
}));
beforeEach(() => {
fixture = TestBed.createComponent(UrlUploadComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
it('accepts an optional playlist name without affecting url validation', () => {
const testUrl = 'http://example.org/playlist.m3u';
component.form.setValue({
playlistName: ' Custom Playlist ',
playlistUrl: 'wrong url here',
});
fixture.detectChanges();
expect(component.form.valid).toBeFalsy();
component.form.setValue({
playlistName: '',
playlistUrl: testUrl,
});
fixture.detectChanges();
expect(component.form.valid).toBeTruthy();
component.form.setValue({
playlistName: ' ',
playlistUrl: testUrl,
});
fixture.detectChanges();
expect(component.form.valid).toBeTruthy();
});
});
@@ -0,0 +1,42 @@
import { Component, inject, OnInit } from '@angular/core';
import {
FormBuilder,
FormControl,
FormGroup,
ReactiveFormsModule,
Validators,
} from '@angular/forms';
import { MatFormFieldModule } from '@angular/material/form-field';
import { MatInputModule } from '@angular/material/input';
import { TranslatePipe } from '@ngx-translate/core';
@Component({
selector: 'app-url-upload',
templateUrl: './url-upload.component.html',
imports: [
MatFormFieldModule,
MatInputModule,
ReactiveFormsModule,
TranslatePipe,
],
})
export class UrlUploadComponent implements OnInit {
private readonly fb = inject(FormBuilder);
form!: FormGroup<{
playlistName: FormControl<string>;
playlistUrl: FormControl<string>;
}>;
readonly isDesktop = !!window.electron;
ngOnInit(): void {
const urlRegex = '(https?://.*?)';
this.form = this.fb.nonNullable.group({
playlistUrl: [
'',
[Validators.required, Validators.pattern(urlRegex)],
],
playlistName: [''],
});
}
}
@@ -0,0 +1,56 @@
<form [formGroup]="form">
@if (!productBranding?.xtreamDefaultServerUrl) {
<mat-form-field class="w-full">
<mat-label for="title">{{
'HOME.XTREAM_PLAYLIST.TITLE' | translate
}}</mat-label>
<input matInput type="text" id="title" formControlName="title" />
</mat-form-field>
<mat-form-field class="w-full" subscriptSizing="dynamic">
<mat-label for="serverUrl">{{
'HOME.XTREAM_PLAYLIST.SERVER_URL' | translate
}}</mat-label>
<input
matInput
type="text"
id="serverUrl"
formControlName="serverUrl"
(input)="extractParams(form.value.serverUrl)"
/>
<mat-hint>{{
'HOME.XTREAM_PLAYLIST.URL_VALIDATION_ERROR' | translate
}}</mat-hint>
<mat-error>{{ 'SETTINGS.EPG_URL_ERROR' | translate }}</mat-error>
</mat-form-field>
} @else {
<p class="kiratv-fixed-server" id="kiratv-fixed-server">
{{ 'HOME.KIRATV.FIXED_SERVER' | translate }}
<strong>{{ productBranding.xtreamDefaultServerUrl }}</strong>
</p>
<p class="kiratv-fixed-title" id="kiratv-fixed-title">
{{ 'HOME.KIRATV.FIXED_PLAYLIST_TITLE' | translate }}
<strong>{{ productBranding.defaultPlaylistTitle }}</strong>
</p>
}
<mat-form-field class="w-full">
<mat-label for="username">{{
'HOME.XTREAM_PLAYLIST.USERNAME' | translate
}}</mat-label>
<input matInput type="text" id="username" formControlName="username" />
</mat-form-field>
<mat-form-field class="w-full">
<mat-label for="password">{{
'HOME.XTREAM_PLAYLIST.PASSWORD' | translate
}}</mat-label>
<input matInput type="text" id="password" formControlName="password" />
</mat-form-field>
@if (connectionStatus) {
<div class="connection-status">
<mat-icon [class]="getStatusClass()">
{{ getStatusIcon() }}
</mat-icon>
<span [class]="getStatusClass()">{{ getStatusMessage() }}</span>
</div>
}
</form>
@@ -0,0 +1,214 @@
import {
Component,
EventEmitter,
OnInit,
Output,
inject,
} from '@angular/core';
import {
FormControl,
FormGroup,
FormsModule,
ReactiveFormsModule,
Validators,
} from '@angular/forms';
import { MatFormFieldModule } from '@angular/material/form-field';
import { MatIcon } from '@angular/material/icon';
import { MatInputModule } from '@angular/material/input';
import { Store } from '@ngrx/store';
import { TranslatePipe } from '@ngx-translate/core';
import { PlaylistActions } from 'm3u-state';
import {
PortalStatus,
PortalStatusService,
WORKSPACE_PRODUCT_BRANDING,
} from 'services';
import { Playlist } from 'shared-interfaces';
import { v4 as uuid } from 'uuid';
@Component({
imports: [
FormsModule,
MatFormFieldModule,
MatIcon,
MatInputModule,
ReactiveFormsModule,
TranslatePipe,
],
selector: 'app-xtream-code-import',
templateUrl: './xtream-code-import.component.html',
styles: [
`
:host {
display: flex;
margin: 10px;
justify-content: center;
}
form {
width: 100%;
}
.status-active {
color: #4caf50;
}
.status-inactive {
color: #f44336;
}
.status-expired {
color: #ff9800;
}
.status-unavailable {
color: #9e9e9e;
}
.connection-status {
margin: 10px 0;
display: flex;
align-items: center;
gap: 8px;
}
.kiratv-fixed-server,
.kiratv-fixed-title {
margin: 0 0 12px;
font-size: 14px;
line-height: 1.4;
}
`,
],
})
export class XtreamCodeImportComponent implements OnInit {
@Output() addClicked = new EventEmitter<void>();
URL_REGEX = /^(http|https|file):\/\/[^ "]+$/;
readonly productBranding = inject(WORKSPACE_PRODUCT_BRANDING, {
optional: true,
});
form = new FormGroup({
_id: new FormControl(uuid()),
title: new FormControl('', [Validators.required]),
password: new FormControl('', [Validators.required]),
username: new FormControl('', [Validators.required]),
serverUrl: new FormControl('', [
Validators.required,
Validators.pattern(this.URL_REGEX),
]),
importDate: new FormControl(new Date().toISOString()),
});
readonly store = inject(Store);
readonly portalStatusService = inject(PortalStatusService);
connectionStatus: PortalStatus | null = null;
isTestingConnection = false;
ngOnInit(): void {
this.applyProductBrandingDefaults();
}
private applyProductBrandingDefaults(): void {
const b = this.productBranding;
if (!b?.xtreamDefaultServerUrl) {
return;
}
this.form.patchValue({
serverUrl: b.xtreamDefaultServerUrl,
title: b.defaultPlaylistTitle,
});
this.form.get('serverUrl')?.disable({ emitEvent: false });
this.form.get('title')?.disable({ emitEvent: false });
}
async testConnection(): Promise<void> {
if (!this.form.valid) return;
this.isTestingConnection = true;
const serverUrlAsString = this.form.getRawValue().serverUrl as string;
const url = new URL(serverUrlAsString);
const serverUrl = `${url.protocol}//${url.hostname}${
url.port ? ':' + url.port : ''
}`;
try {
this.connectionStatus =
await this.portalStatusService.checkPortalStatus(
serverUrl,
this.form.getRawValue().username as string,
this.form.getRawValue().password as string
);
} finally {
this.isTestingConnection = false;
}
}
getStatusMessage(): string {
return this.portalStatusService.getStatusMessage(this.connectionStatus);
}
getStatusClass(): string {
return this.portalStatusService.getStatusClass(this.connectionStatus);
}
getStatusIcon(): string {
return this.portalStatusService.getStatusIcon(this.connectionStatus);
}
clearForm() {
const b = this.productBranding;
this.form.reset({
_id: uuid(),
title: b?.defaultPlaylistTitle ?? '',
password: '',
username: '',
serverUrl: b?.xtreamDefaultServerUrl ?? '',
importDate: new Date().toISOString(),
});
this.applyProductBrandingDefaults();
this.connectionStatus = null;
}
addPlaylist() {
const raw = this.form.getRawValue();
const serverUrlAsString = raw.serverUrl as string;
const url = new URL(serverUrlAsString);
const serverUrl = `${url.protocol}//${url.hostname}${
url.port ? ':' + url.port : ''
}`;
this.store.dispatch(
PlaylistActions.addPlaylist({
playlist: {
...raw,
serverUrl,
} as Playlist,
})
);
this.addClicked.emit();
}
extractParams(urlAsString: string): void {
if (
this.form.get('username').value !== '' ||
this.form.get('password').value !== ''
)
return;
try {
// Create a new URL object from the complete link
const url = new URL(urlAsString);
// Extract username and password from query parameters
const username = url.searchParams.get('username') || '';
const password = url.searchParams.get('password') || '';
this.form.get('username')?.setValue(username);
this.form.get('password')?.setValue(password);
} catch (error) {
console.error('Invalid URL', error);
}
}
}
@@ -0,0 +1,13 @@
{
"name": "playlist-m3u-feature-player",
"$schema": "../../../../node_modules/nx/schemas/project-schema.json",
"sourceRoot": "libs/playlist/m3u/feature-player/src",
"prefix": "lib",
"projectType": "library",
"tags": ["scope:playlist", "domain:m3u", "type:feature"],
"targets": {
"lint": {
"executor": "@nx/eslint:lint"
}
}
}
@@ -0,0 +1,3 @@
export * from './lib/m3u-collection-route/m3u-collection-route.component';
export * from './lib/m3u-workspace-route-session.service';
export * from './lib/video-player/video-player.component';
@@ -0,0 +1,38 @@
import {
inject,
ChangeDetectionStrategy,
Component,
input,
} from '@angular/core';
import { ActivatedRoute } from '@angular/router';
import { UnifiedCollectionPageComponent } from '@iptvnator/portal/shared/ui';
import {
CollectionScope,
routeParamSignal,
} from '@iptvnator/portal/shared/util';
@Component({
selector: 'app-m3u-collection-route',
imports: [UnifiedCollectionPageComponent],
template: `
<app-unified-collection-page
[mode]="mode()"
[portalType]="portalType()"
[playlistId]="playlistId()"
[defaultScope]="defaultScope()"
/>
`,
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class M3uCollectionRouteComponent {
private readonly route = inject(ActivatedRoute);
readonly mode = input<'favorites' | 'recent'>('favorites');
readonly portalType = input('m3u');
readonly defaultScope = input<CollectionScope | undefined>(undefined);
readonly playlistId = routeParamSignal<string | undefined>(
this.route,
'id',
(value) => value ?? undefined
);
}
@@ -0,0 +1,181 @@
import { TestBed } from '@angular/core/testing';
import { NavigationEnd, Router } from '@angular/router';
import { Subject } from 'rxjs';
import { ChannelActions, FavoritesActions } from 'm3u-state';
import { PlaylistContextFacade } from '@iptvnator/playlist/shared/util';
import { PlaylistsService } from 'services';
import { Channel, Playlist } from 'shared-interfaces';
import { Store } from '@ngrx/store';
import { M3uWorkspaceRouteSession } from './m3u-workspace-route-session.service';
const PLAYLIST_ID = 'playlist-1';
const NEXT_PLAYLIST_ID = 'playlist-2';
const PRIMARY_CHANNEL = {
epgParams: '',
http: {
origin: '',
referrer: '',
'user-agent': '',
},
id: 'channel-1',
name: 'Primary channel',
radio: 'false',
tvg: {
id: 'primary-channel',
logo: '',
name: 'Primary channel',
rec: '',
url: '',
},
url: 'https://example.com/primary.m3u8',
} as Channel;
const NEXT_CHANNEL = {
...PRIMARY_CHANNEL,
id: 'channel-2',
name: 'Next channel',
tvg: {
...PRIMARY_CHANNEL.tvg,
id: 'next-channel',
name: 'Next channel',
},
url: 'https://example.com/next.m3u8',
} as Channel;
async function flushEffects(): Promise<void> {
await Promise.resolve();
await Promise.resolve();
}
function getM3uRouteContext(url: string): {
inWorkspace: boolean;
playlistId: string | null;
provider: 'playlists' | null;
section: 'all' | 'favorites' | 'groups' | 'recent' | null;
} {
const match = url.match(
/^\/workspace\/playlists\/([^/]+)\/([^/?]+)(?:\/|$)/
);
return {
inWorkspace: true,
playlistId: match?.[1] ?? null,
provider: match ? 'playlists' : null,
section:
(match?.[2] as 'all' | 'favorites' | 'groups' | 'recent' | null) ??
null,
};
}
describe('M3uWorkspaceRouteSession', () => {
const routerEvents = new Subject<NavigationEnd>();
const playlistContext = {
syncFromUrl: jest.fn(),
};
const playlistsService = {
getPlaylist: jest.fn(),
};
const store = {
dispatch: jest.fn(),
};
const router = {
url: `/workspace/playlists/${PLAYLIST_ID}/all`,
events: routerEvents.asObservable(),
};
beforeEach(async () => {
router.url = `/workspace/playlists/${PLAYLIST_ID}/all`;
playlistContext.syncFromUrl.mockImplementation((url: string) =>
getM3uRouteContext(url)
);
playlistsService.getPlaylist.mockReset();
store.dispatch.mockClear();
await TestBed.configureTestingModule({
providers: [
M3uWorkspaceRouteSession,
{
provide: PlaylistContextFacade,
useValue: playlistContext,
},
{
provide: PlaylistsService,
useValue: playlistsService,
},
{
provide: Router,
useValue: router,
},
{
provide: Store,
useValue: store,
},
],
});
});
it('does not start channel loading for collection routes like favorites', async () => {
router.url = `/workspace/playlists/${PLAYLIST_ID}/favorites`;
TestBed.inject(M3uWorkspaceRouteSession);
await flushEffects();
expect(store.dispatch).toHaveBeenCalledWith(
ChannelActions.resetActiveChannel()
);
expect(store.dispatch).toHaveBeenCalledWith(
ChannelActions.setChannelsLoading({ loading: false })
);
expect(playlistsService.getPlaylist).not.toHaveBeenCalled();
});
it('ignores stale playlist responses after a newer route request wins', async () => {
const firstResponse = new Subject<Playlist>();
const secondResponse = new Subject<Playlist>();
playlistsService.getPlaylist.mockImplementation((playlistId: string) => {
return playlistId === PLAYLIST_ID
? firstResponse.asObservable()
: secondResponse.asObservable();
});
TestBed.inject(M3uWorkspaceRouteSession);
await flushEffects();
router.url = `/workspace/playlists/${NEXT_PLAYLIST_ID}/all`;
routerEvents.next(new NavigationEnd(1, router.url, router.url));
await flushEffects();
secondResponse.next({
favorites: [NEXT_CHANNEL.url],
playlist: {
items: [NEXT_CHANNEL],
},
} as Playlist);
secondResponse.complete();
await flushEffects();
firstResponse.next({
favorites: [PRIMARY_CHANNEL.url],
playlist: {
items: [PRIMARY_CHANNEL],
},
} as Playlist);
firstResponse.complete();
await flushEffects();
const setChannelsCalls = store.dispatch.mock.calls.filter(
([action]) => action.type === ChannelActions.setChannels.type
);
expect(setChannelsCalls).toEqual([
[ChannelActions.setChannels({ channels: [NEXT_CHANNEL] })],
]);
expect(store.dispatch).toHaveBeenCalledWith(
FavoritesActions.setFavorites({
channelIds: [NEXT_CHANNEL.url],
})
);
});
});
@@ -0,0 +1,157 @@
import {
DestroyRef,
ENVIRONMENT_INITIALIZER,
inject,
Injectable,
Provider,
} from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { NavigationEnd, Router } from '@angular/router';
import { Store } from '@ngrx/store';
import { ChannelActions, FavoritesActions } from 'm3u-state';
import { filter, firstValueFrom } from 'rxjs';
import { PlaylistContextFacade } from '@iptvnator/playlist/shared/util';
import { PlaylistsService } from 'services';
type M3uLoadedSection = 'all' | 'groups';
@Injectable()
export class M3uWorkspaceRouteSession {
private readonly destroyRef = inject(DestroyRef);
private readonly playlistContext = inject(PlaylistContextFacade);
private readonly playlistsService = inject(PlaylistsService);
private readonly router = inject(Router);
private readonly store = inject(Store);
private currentPlaylistId: string | null = null;
private currentSection: string | null = null;
private loadRequestId = 0;
constructor() {
this.router.events
.pipe(
filter(
(event): event is NavigationEnd =>
event instanceof NavigationEnd
),
takeUntilDestroyed(this.destroyRef)
)
.subscribe(() => {
void this.syncRouteContext();
});
void this.syncRouteContext();
}
private async syncRouteContext(): Promise<void> {
const routeContext = this.playlistContext.syncFromUrl(this.router.url);
const playlistId =
routeContext.provider === 'playlists'
? routeContext.playlistId
: null;
const section =
routeContext.provider === 'playlists' ? routeContext.section : null;
const previousSection = this.currentSection;
this.currentSection = section;
const playlistChanged = playlistId !== this.currentPlaylistId;
const shouldLoadPlaylist = this.isLoadedSection(section);
const enteringLoadedSection =
shouldLoadPlaylist && !this.isLoadedSection(previousSection);
if (!playlistId) {
this.currentPlaylistId = null;
this.loadRequestId += 1;
this.store.dispatch(
ChannelActions.setChannelsLoading({ loading: false })
);
return;
}
if (playlistChanged) {
this.currentPlaylistId = playlistId;
this.store.dispatch(ChannelActions.resetActiveChannel());
}
if (!shouldLoadPlaylist) {
this.loadRequestId += 1;
this.store.dispatch(
ChannelActions.setChannelsLoading({ loading: false })
);
return;
}
if (!playlistChanged && !enteringLoadedSection) {
return;
}
const requestId = ++this.loadRequestId;
this.store.dispatch(ChannelActions.setChannelsLoading({ loading: true }));
try {
const playlist = await firstValueFrom(
this.playlistsService.getPlaylist(playlistId)
);
if (!this.isCurrentLoadRequest(requestId, playlistId, section)) {
return;
}
if (playlist.userAgent) {
window.electron?.setUserAgent(playlist.userAgent, 'localhost');
}
this.store.dispatch(
ChannelActions.setChannels({
channels: playlist.playlist?.items ?? [],
})
);
const favorites = (playlist.favorites ?? []).filter(
(favorite): favorite is string => typeof favorite === 'string'
);
this.store.dispatch(
FavoritesActions.setFavorites({
channelIds: favorites,
})
);
} catch {
if (!this.isCurrentLoadRequest(requestId, playlistId, section)) {
return;
}
this.store.dispatch(ChannelActions.setChannels({ channels: [] }));
this.store.dispatch(
FavoritesActions.setFavorites({ channelIds: [] })
);
}
}
private isLoadedSection(section: string | null): section is M3uLoadedSection {
return section === 'all' || section === 'groups';
}
private isCurrentLoadRequest(
requestId: number,
playlistId: string,
section: M3uLoadedSection
): boolean {
return (
requestId === this.loadRequestId &&
this.currentPlaylistId === playlistId &&
this.currentSection === section
);
}
}
export function provideM3uWorkspaceRouteSession(): Provider[] {
return [
M3uWorkspaceRouteSession,
{
provide: ENVIRONMENT_INITIALIZER,
multi: true,
useValue: () => {
inject(M3uWorkspaceRouteSession);
},
},
];
}
@@ -0,0 +1,98 @@
<div
class="sidebar"
appResizable
[minWidth]="sidebarMinWidth"
[maxWidth]="sidebarMaxWidth"
[defaultWidth]="sidebarWidth()"
[style.width.px]="sidebarWidth()"
[storageKey]="sidebarStorageKey()"
(widthChange)="onSidebarWidthChange($event)"
(resizeEnd)="onSidebarResizeEnd($event)"
>
@defer {
<app-sidebar
[channels]="(channels$ | async)!"
[channelsLoading]="channelsLoading()"
[showPlaylistHeader]="!isWorkspaceLayout"
[activeView]="activeView()"
[sidebarWidth]="sidebarWidth()"
(sidebarWidthRequested)="onGroupedSidebarWidthRequested($event)"
(sidebarWidthRequestEnded)="onGroupedSidebarWidthRequestEnded($event)"
/>
} @loading {
<app-channel-list-loading-state [view]="activeView()" />
} @placeholder {
<app-channel-list-loading-state [view]="activeView()" />
}
</div>
<div class="content-container">
@if (activeChannel(); as activeChannel) {
@let playbackTarget = playbackChannel();
@if (activeChannel.radio === 'true' || shouldShowInlinePlayer(activeChannel)) {
<div class="video-player">
@if (activeChannel.radio === 'true') {
<app-audio-player
[url]="activeChannel.url"
[icon]="activeChannel?.tvg?.logo!"
[channelName]="activeChannel.name || activeChannel?.tvg?.name || ''"
/>
} @else {
@if (playerSettings.player === 'videojs') {
<app-vjs-player
[options]="{
sources: [
{
src:
playbackTarget?.url +
playbackTarget?.epgParams,
type: 'application/x-mpegURL',
},
],
}"
[volume]="volume"
[class.hide-captions]="!playerSettings.showCaptions"
/>
}
@if (playerSettings.player === 'html5') {
<app-html-video-player
[channel]="playbackTarget!"
[volume]="volume"
[showCaptions]="playerSettings.showCaptions!"
/>
}
@if (playerSettings.player === 'artplayer') {
<app-art-player
[channel]="playbackTarget!"
[volume]="volume"
[showCaptions]="playerSettings.showCaptions!"
/>
}
}
</div>
}
@if (activeChannel.radio !== 'true') {
<div class="epg">
<div class="epg-content">
<app-epg-list
[archivePlaybackAvailable]="archivePlaybackAvailable()"
/>
</div>
</div>
}
@if (showChannelNumberOverlay) {
<div class="channel-number-overlay">
<div class="channel-number-display">
{{ channelNumberInput }}
</div>
</div>
}
} @else {
<app-portal-empty-state
icon="live_tv"
[message]="'CHANNELS.SELECT_CHANNEL_PLAYBACK' | translate"
/>
}
</div>
@@ -0,0 +1,112 @@
@use '../../../../../../ui/styles/portal-layout' as portal;
@include portal.live-layout;
:host {
min-height: 0;
background: var(--mat-sys-surface);
}
.sidebar {
> app-channel-list-loading-state {
flex: 1;
min-height: 0;
overflow: hidden;
}
app-sidebar {
flex: 1;
min-height: 0;
overflow: hidden;
}
}
.content-container {
min-width: 0;
background: var(--mat-sys-surface);
}
.video-player {
background: #000;
&:has(> app-audio-player) {
background: var(--mat-sys-surface);
}
> app-audio-player,
> app-vjs-player,
> app-html-video-player,
> app-art-player {
flex: 1;
min-height: 0;
display: block;
}
}
.epg {
border-top: 1px solid var(--mat-sys-outline-variant);
background: var(--mat-sys-surface);
}
.epg-content {
> app-epg-list {
flex: 1;
min-height: 0;
}
}
.channel-number-overlay {
position: fixed;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
z-index: 1000;
pointer-events: none;
}
.channel-number-display {
min-width: 120px;
padding: 2rem 3rem;
border: 2px solid rgba(255, 255, 255, 0.1);
border-radius: 16px;
background: rgba(0, 0, 0, 0.85);
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.5);
color: #fff;
font-family: 'DM Sans', sans-serif;
font-size: 6rem;
font-weight: 700;
letter-spacing: 0.05em;
text-align: center;
animation: scaleIn 0.15s ease-out;
}
@keyframes scaleIn {
from {
opacity: 0;
transform: scale(0.8);
}
to {
opacity: 1;
transform: scale(1);
}
}
@media only screen and (max-width: 599px) {
:host {
flex-direction: column-reverse;
}
.sidebar {
width: 100%;
min-width: 100%;
max-width: 100%;
height: 50vh;
border-right: none;
border-top: 1px solid var(--mat-sys-outline-variant);
}
.content-container {
height: 50vh;
}
}
@@ -0,0 +1,413 @@
import { AsyncPipe } from '@angular/common';
import { NO_ERRORS_SCHEMA, signal } from '@angular/core';
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { ActivatedRoute, Router } from '@angular/router';
import { Store } from '@ngrx/store';
import { StorageMap } from '@ngx-pwa/local-storage';
import { TranslatePipe } from '@ngx-translate/core';
import { MockPipe } from 'ng-mocks';
import { BehaviorSubject, of } from 'rxjs';
import {
ChannelActions,
selectActive,
selectActivePlaybackUrl,
selectChannels,
selectChannelsLoading,
selectCurrentEpgProgram,
} from 'm3u-state';
import { PlaylistContextFacade } from '@iptvnator/playlist/shared/util';
import {
PORTAL_EXTERNAL_PLAYBACK,
WorkspaceHeaderContextService,
} from '@iptvnator/portal/shared/util';
import { DataService, PlaylistsService, SettingsStore } from 'services';
import {
Channel,
Settings,
VideoPlayer,
} from 'shared-interfaces';
import { Overlay } from '@angular/cdk/overlay';
import { VideoPlayerComponent } from './video-player.component';
describe('VideoPlayerComponent', () => {
let fixture: ComponentFixture<VideoPlayerComponent>;
let component: VideoPlayerComponent;
let headerContext: WorkspaceHeaderContextService;
const playlistId = signal('playlist-1');
const activeChannel = signal<Channel | null>(null);
const activePlaybackUrl = signal<string | null>(null);
const channels = signal<Channel[]>([]);
const channelsLoading = signal(false);
const currentEpgProgram = signal(null);
const channels$ = new BehaviorSubject<Channel[]>([]);
const activeChannel$ = new BehaviorSubject<Channel | null>(null);
const currentEpgProgram$ = new BehaviorSubject(null);
const player = signal<VideoPlayer>(VideoPlayer.VideoJs);
const showCaptions = signal(false);
const overlayRef = {
attach: jest.fn().mockReturnValue({ instance: {} }),
backdropClick: jest.fn().mockReturnValue(of(undefined)),
dispose: jest.fn(),
};
const positionStrategy = {
centerHorizontally: jest.fn().mockReturnThis(),
centerVertically: jest.fn().mockReturnThis(),
};
const overlayMock = {
position: jest.fn().mockReturnValue({
global: jest.fn().mockReturnValue(positionStrategy),
}),
create: jest.fn().mockReturnValue(overlayRef),
};
const storeMock = {
dispatch: jest.fn(),
selectSignal: jest.fn((selector: unknown) => {
switch (selector) {
case selectActive:
return activeChannel;
case selectActivePlaybackUrl:
return activePlaybackUrl;
case selectChannels:
return channels;
case selectChannelsLoading:
return channelsLoading;
case selectCurrentEpgProgram:
return currentEpgProgram;
default:
return signal(null);
}
}),
select: jest.fn((selector: unknown) => {
switch (selector) {
case selectChannels:
return channels$.asObservable();
case selectActive:
return activeChannel$.asObservable();
case selectCurrentEpgProgram:
return currentEpgProgram$.asObservable();
default:
return of(null);
}
}),
};
const routerMock = {
url: '/workspace/playlists/playlist-1/all',
navigate: jest.fn(),
currentNavigation: jest.fn().mockReturnValue(null),
};
const playlistsServiceMock = {
getPlaylist: jest.fn(() =>
of({
playlist: {
items: channels(),
},
favorites: [],
})
),
getPlaylistWithGlobalFavorites: jest.fn(() =>
of({
playlist: {
items: [],
},
favorites: [],
})
),
addM3uRecentlyViewed: jest.fn(() =>
of({
recentlyViewed: [],
})
),
};
const sampleChannel: Channel = {
id: 'channel-1',
url: 'http://localhost/live.m3u8',
name: 'Sample TV',
epgParams: '',
radio: 'false',
tvg: {
id: 'sample-tvg-id',
logo: 'http://localhost/logo.png',
name: 'Sample TV',
},
} as Channel;
function syncStoreState(channel: Channel | null): void {
activeChannel.set(channel);
activeChannel$.next(channel);
channels.set(channel ? [channel] : []);
channels$.next(channel ? [channel] : []);
}
beforeEach(async () => {
syncStoreState(null);
localStorage.removeItem('m3u-sidebar-width');
player.set(VideoPlayer.VideoJs);
showCaptions.set(false);
activePlaybackUrl.set(null);
channelsLoading.set(false);
currentEpgProgram.set(null);
currentEpgProgram$.next(null);
overlayMock.create.mockClear();
overlayRef.attach.mockClear();
overlayRef.dispose.mockClear();
storeMock.dispatch.mockClear();
await TestBed.configureTestingModule({
imports: [VideoPlayerComponent],
schemas: [NO_ERRORS_SCHEMA],
providers: [
{
provide: ActivatedRoute,
useValue: {
params: of({ id: playlistId(), view: 'all' }),
queryParams: of({}),
snapshot: {
data: { layout: 'workspace' },
queryParams: {},
},
},
},
{
provide: Router,
useValue: routerMock,
},
{
provide: Store,
useValue: storeMock,
},
{
provide: Overlay,
useValue: overlayMock,
},
{
provide: DataService,
useValue: {
sendIpcEvent: jest.fn(),
},
},
{
provide: PlaylistsService,
useValue: playlistsServiceMock,
},
{
provide: PlaylistContextFacade,
useValue: {
resolvedPlaylistId: playlistId,
},
},
{
provide: SettingsStore,
useValue: {
player,
showCaptions,
},
},
{
provide: StorageMap,
useValue: {
get: jest.fn(() =>
of({
player: player(),
showCaptions: showCaptions(),
} as Partial<Settings>)
),
},
},
{
provide: PORTAL_EXTERNAL_PLAYBACK,
useValue: {
activeSession: signal(null),
},
},
],
})
.overrideComponent(VideoPlayerComponent, {
set: {
imports: [
AsyncPipe,
MockPipe(
TranslatePipe,
(value: string | null | undefined) => value ?? ''
),
],
},
})
.compileComponents();
fixture = TestBed.createComponent(VideoPlayerComponent);
component = fixture.componentInstance;
headerContext = TestBed.inject(WorkspaceHeaderContextService);
});
afterEach(() => {
fixture.destroy();
});
it('registers and clears the workspace multi-EPG header shortcut', () => {
fixture.detectChanges();
expect(headerContext.action()).toEqual(
expect.objectContaining({
id: 'm3u-multi-epg',
icon: 'view_list',
})
);
fixture.destroy();
expect(headerContext.action()).toBeNull();
});
it('renders the inline player with the embedded EPG panel', () => {
syncStoreState(sampleChannel);
player.set(VideoPlayer.VideoJs);
fixture.detectChanges();
expect(
fixture.nativeElement.querySelector('.video-player')
).not.toBeNull();
expect(
fixture.nativeElement.querySelector('app-vjs-player')
).not.toBeNull();
expect(
fixture.nativeElement.querySelector('app-epg-list')
).not.toBeNull();
});
it('renders only the EPG panel when an external player is configured', () => {
syncStoreState(sampleChannel);
player.set(VideoPlayer.MPV);
fixture.detectChanges();
expect(
fixture.nativeElement.querySelector('.video-player')
).toBeNull();
expect(
fixture.nativeElement.querySelector('app-epg-list')
).not.toBeNull();
});
it('uses the active playback override url when archive playback is active', () => {
syncStoreState(sampleChannel);
player.set(VideoPlayer.VideoJs);
activePlaybackUrl.set(
'http://localhost/archive.m3u8?utc=123&lutc=456'
);
fixture.detectChanges();
expect(component.playbackChannel()?.url).toBe(
'http://localhost/archive.m3u8?utc=123&lutc=456'
);
activePlaybackUrl.set(null);
fixture.detectChanges();
expect(component.playbackChannel()?.url).toBe(sampleChannel.url);
});
it('updates the outer sidebar width while grouped view requests a larger total width', () => {
fixture.detectChanges();
component.onGroupedSidebarWidthRequested(540);
fixture.detectChanges();
const sidebar = fixture.nativeElement.querySelector(
'.sidebar'
) as HTMLElement | null;
expect(sidebar?.style.width).toBe('540px');
});
it('uses the single-pane sidebar key for all-channel view instead of the groups total key', () => {
fixture.destroy();
localStorage.setItem('m3u-sidebar-width', '320');
localStorage.setItem('m3u-groups-sidebar-width', '560');
fixture = TestBed.createComponent(VideoPlayerComponent);
component = fixture.componentInstance;
headerContext = TestBed.inject(WorkspaceHeaderContextService);
fixture.detectChanges();
const sidebar = fixture.nativeElement.querySelector(
'.sidebar'
) as HTMLElement | null;
expect(sidebar?.style.width).toBe('320px');
});
it('tracks manual sidebar resize and persists the committed width', () => {
fixture.detectChanges();
component.onSidebarWidthChange(420);
fixture.detectChanges();
const sidebar = fixture.nativeElement.querySelector(
'.sidebar'
) as HTMLElement | null;
expect(sidebar?.style.width).toBe('420px');
component.onSidebarResizeEnd(420);
expect(localStorage.getItem('m3u-sidebar-width')).toBe('420');
});
it('clamps and persists grouped-view sidebar width requests on resize end', () => {
fixture.detectChanges();
component.onGroupedSidebarWidthRequestEnded(640);
fixture.detectChanges();
const sidebar = fixture.nativeElement.querySelector(
'.sidebar'
) as HTMLElement | null;
expect(sidebar?.style.width).toBe('600px');
expect(localStorage.getItem('m3u-sidebar-width')).toBe('600');
});
it('renders the shared empty state when no channel is active', () => {
fixture.detectChanges();
const emptyState = fixture.nativeElement.querySelector(
'app-portal-empty-state'
) as HTMLElement | null;
expect(emptyState).not.toBeNull();
expect(emptyState?.textContent).toContain(
'CHANNELS.SELECT_CHANNEL_PLAYBACK'
);
});
it('reuses the registered header shortcut callback to open multi EPG', () => {
fixture.detectChanges();
headerContext.action()?.run();
expect(overlayMock.create).toHaveBeenCalledTimes(1);
expect(overlayRef.attach).toHaveBeenCalledTimes(1);
});
it('switches channels by number through the store action', () => {
syncStoreState(sampleChannel);
fixture.detectChanges();
component.switchToChannelByNumber(1);
expect(storeMock.dispatch).toHaveBeenCalledWith(
ChannelActions.setActiveChannel({ channel: sampleChannel })
);
});
});
@@ -0,0 +1,771 @@
import { Overlay, OverlayRef } from '@angular/cdk/overlay';
import { ComponentPortal } from '@angular/cdk/portal';
import { AsyncPipe, CommonModule } from '@angular/common';
import {
Component,
HostListener,
Injector,
OnDestroy,
OnInit,
computed,
effect,
inject,
signal,
} from '@angular/core';
import { toSignal } from '@angular/core/rxjs-interop';
import { ActivatedRoute, Router } from '@angular/router';
import { Store } from '@ngrx/store';
import { StorageMap } from '@ngx-pwa/local-storage';
import { TranslatePipe } from '@ngx-translate/core';
import { ResizableDirective } from 'components';
import { isM3uCatchupPlaybackSupported } from 'm3u-utils';
import { PlaylistContextFacade } from '@iptvnator/playlist/shared/util';
import {
COMPONENT_OVERLAY_REF,
EpgListComponent,
MultiEpgContainerComponent,
} from '@iptvnator/ui/epg';
import {
ChannelActions,
PlaylistActions,
selectActive,
selectActivePlaybackUrl,
selectChannels,
selectChannelsLoading,
selectCurrentEpgProgram,
} from 'm3u-state';
import {
firstValueFrom,
Observable,
Subscription,
combineLatest,
combineLatestWith,
distinctUntilChanged,
filter,
map,
startWith,
switchMap,
take,
} from 'rxjs';
import {
getAdjacentChannelItem,
getChannelItemByNumber,
isWorkspaceLayoutRoute,
PORTAL_EXTERNAL_PLAYBACK,
WorkspaceHeaderContextService,
} from '@iptvnator/portal/shared/util';
import { PortalEmptyStateComponent } from '@iptvnator/portal/shared/ui';
import {
ArtPlayerComponent,
AudioPlayerComponent,
HtmlVideoPlayerComponent,
SidebarComponent,
VjsPlayerComponent,
} from '@iptvnator/ui/playback';
import { ChannelListLoadingStateComponent } from 'components';
import { DataService, PlaylistsService, SettingsStore } from 'services';
import {
Channel,
EpgProgram,
ExternalPlayerSession,
PLAYLIST_PARSE_BY_URL,
M3uRecentlyViewedItem,
PlaylistMeta,
STORE_KEY,
Settings,
VideoPlayer,
} from 'shared-interfaces';
const M3U_MULTI_EPG_HEADER_ACTION_ID = 'm3u-multi-epg';
const M3U_SIDEBAR_STORAGE_KEY = 'm3u-sidebar-width';
const M3U_GROUPS_SIDEBAR_STORAGE_KEY = 'm3u-groups-sidebar-width';
const M3U_SIDEBAR_MIN_WIDTH = 200;
const M3U_SIDEBAR_MAX_WIDTH = 600;
const M3U_SIDEBAR_DEFAULT_WIDTH = 460;
@Component({
selector: 'app-video-player',
imports: [
ArtPlayerComponent,
AsyncPipe,
AudioPlayerComponent,
ChannelListLoadingStateComponent,
CommonModule,
EpgListComponent,
HtmlVideoPlayerComponent,
PortalEmptyStateComponent,
ResizableDirective,
SidebarComponent,
TranslatePipe,
VjsPlayerComponent,
],
templateUrl: './video-player.component.html',
styleUrl: './video-player.component.scss',
})
export class VideoPlayerComponent implements OnInit, OnDestroy {
private readonly activatedRoute = inject(ActivatedRoute);
private readonly dataService = inject(DataService);
private readonly overlay = inject(Overlay);
private readonly playlistsService = inject(PlaylistsService);
private readonly playlistContext = inject(PlaylistContextFacade);
private readonly router = inject(Router);
private readonly settingsStore = inject(SettingsStore);
private readonly storage = inject(StorageMap);
private readonly store = inject(Store);
private readonly externalPlayback = inject(PORTAL_EXTERNAL_PLAYBACK);
private readonly workspaceHeaderContext = inject(
WorkspaceHeaderContextService
);
/** Active selected channel */
readonly activeChannel = this.store.selectSignal(selectActive);
readonly activePlaybackUrl = this.store.selectSignal(
selectActivePlaybackUrl
);
readonly activePlaylistId = this.playlistContext.resolvedPlaylistId;
readonly channels = this.store.selectSignal(selectChannels);
readonly channelsLoading = this.store.selectSignal(selectChannelsLoading);
readonly archivePlaybackAvailable = computed(() =>
isM3uCatchupPlaybackSupported(this.activeChannel())
);
readonly playbackChannel = computed<Channel | null>(() => {
const activeChannel = this.activeChannel();
if (!activeChannel) {
return null;
}
const playbackUrl = this.activePlaybackUrl();
if (!playbackUrl) {
return activeChannel;
}
return {
...activeChannel,
url: playbackUrl,
epgParams: '',
} as Channel;
});
readonly sidebarStorageKey = computed(() =>
this.activeView() === 'groups'
? M3U_GROUPS_SIDEBAR_STORAGE_KEY
: M3U_SIDEBAR_STORAGE_KEY
);
readonly sidebarWidth = signal(M3U_SIDEBAR_DEFAULT_WIDTH);
readonly sidebarMinWidth = M3U_SIDEBAR_MIN_WIDTH;
readonly sidebarMaxWidth = M3U_SIDEBAR_MAX_WIDTH;
/** Channels list */
readonly channels$: Observable<Channel[]> = this.store.select(
selectChannels
) as Observable<Channel[]>;
/** Current epg program */
readonly epgProgram = this.store.selectSignal(selectCurrentEpgProgram);
/** Active M3U view (all, groups, favorites, recent) */
readonly activeView = toSignal(
this.activatedRoute.params.pipe(
map((params) => params['view'] || 'all')
),
{ initialValue: 'all' }
);
/** Selected video player options */
playerSettings: Partial<Settings> = {
player: VideoPlayer.VideoJs,
showCaptions: false,
};
readonly isDesktop = !!window['electron'];
readonly isWorkspaceLayout = isWorkspaceLayoutRoute(this.activatedRoute);
/** EPG overlay reference */
private overlayRef!: OverlayRef;
private unsubscribeRemoteChannelChange?: () => void;
private unsubscribeRemoteCommand?: () => void;
private statusSubscription?: Subscription;
private lastKnownVolume = 1;
private lastRecordedRecentKey = '';
private lastExternalSessionStateKey = this.getExternalSessionStateKey(
this.externalPlayback.activeSession()
);
/** Channel number input state */
channelNumberInput = '';
showChannelNumberOverlay = false;
private channelNumberTimeout?: number;
volume = 1;
constructor() {
// Initialize volume from localStorage in constructor
const savedVolume = localStorage.getItem('volume');
if (savedVolume !== null) {
this.volume = Number(savedVolume);
}
// React to settings changes
effect(() => {
this.playerSettings = {
player: this.settingsStore.player(),
showCaptions: this.settingsStore.showCaptions(),
};
});
effect(() => {
this.sidebarWidth.set(
this.loadSidebarWidth(this.sidebarStorageKey())
);
});
effect(() => {
const playlistId = this.activePlaylistId();
const activeChannel = this.activeChannel();
if (!playlistId || !activeChannel?.url) {
return;
}
const nextKey = `${playlistId}::${activeChannel.url}`;
if (this.lastRecordedRecentKey === nextKey) {
return;
}
this.lastRecordedRecentKey = nextKey;
void this.persistRecentlyViewedChannel(playlistId, activeChannel);
});
effect(() => {
const currentView = this.activeView();
const channels = this.channels();
const activeChannel = this.activeChannel();
const state =
this.router.currentNavigation()?.extras?.state ??
window.history.state;
const targetUrl =
typeof state?.openRecentChannelUrl === 'string'
? state.openRecentChannelUrl.trim()
: '';
if (
currentView !== 'recent' ||
!targetUrl ||
channels.length === 0
) {
return;
}
if (activeChannel?.url === targetUrl) {
this.clearConsumedRecentChannelState();
return;
}
const matchedChannel = channels.find(
(channel) => channel.url === targetUrl
);
if (!matchedChannel) {
return;
}
this.store.dispatch(
ChannelActions.setActiveChannel({ channel: matchedChannel })
);
this.clearConsumedRecentChannelState();
});
effect(() => {
const player = this.settingsStore.player();
const session = this.externalPlayback.activeSession();
const activeChannel = this.activeChannel();
const sessionStateKey = this.getExternalSessionStateKey(session);
if (sessionStateKey === this.lastExternalSessionStateKey) {
return;
}
this.lastExternalSessionStateKey = sessionStateKey;
if (
!activeChannel ||
!this.isExternalPlayer(player) ||
!this.isTerminalExternalSession(session)
) {
return;
}
this.store.dispatch(ChannelActions.resetActiveChannel());
});
}
/**
* Sets video player and subscribes to channel list from the store
*/
ngOnInit(): void {
this.applySettings();
this.getPlaylistUrlAsParam();
this.registerHeaderShortcut();
// Setup remote control channel change listener (Electron only)
if (this.isDesktop && window.electron?.onChannelChange) {
const unsubscribe = window.electron.onChannelChange(
(data: { direction: 'up' | 'down' }) => {
this.handleRemoteChannelChange(data.direction);
}
);
if (typeof unsubscribe === 'function') {
this.unsubscribeRemoteChannelChange = unsubscribe;
}
}
if (this.isDesktop && window.electron?.onRemoteControlCommand) {
const unsubscribe = window.electron.onRemoteControlCommand(
(command) => {
this.handleRemoteControlCommand(command);
}
);
if (typeof unsubscribe === 'function') {
this.unsubscribeRemoteCommand = unsubscribe;
}
}
this.statusSubscription = combineLatest([
this.channels$,
this.store.select(selectActive),
this.store.select(selectCurrentEpgProgram).pipe(startWith(null)),
]).subscribe(([channels, activeChannel, epgProgram]) => {
if (!window.electron?.updateRemoteControlStatus || !activeChannel) {
return;
}
const currentEpgProgram = epgProgram as
| EpgProgram
| null
| undefined;
const currentIndex = channels.findIndex(
(channel) => channel.url === activeChannel.url
);
window.electron.updateRemoteControlStatus({
portal: 'm3u',
isLiveView: true,
channelName: activeChannel.name ?? activeChannel.tvg?.name,
channelNumber: currentIndex >= 0 ? currentIndex + 1 : undefined,
epgTitle: currentEpgProgram?.title,
epgStart: currentEpgProgram?.start,
epgEnd: currentEpgProgram?.stop,
supportsVolume: true,
volume: this.volume,
muted: this.volume === 0,
});
});
}
/**
* Handle remote control channel change
*/
handleRemoteChannelChange(direction: 'up' | 'down'): void {
console.log(`Remote control: changing channel ${direction}`);
// Use combineLatest to get both values and take only the first emission
combineLatest([this.channels$, this.store.select(selectActive)])
.pipe(
filter(([channels, activeChannel]) => {
return channels.length > 0 && !!activeChannel;
}),
take(1),
map(([channels, activeChannel]) => {
return {
channels,
activeChannel: activeChannel as Channel,
};
})
)
.subscribe({
next: ({ channels, activeChannel }) => {
const nextChannel = getAdjacentChannelItem(
channels,
activeChannel.url,
direction,
(channel) => channel.url
);
if (!nextChannel) {
return;
}
this.store.dispatch(
ChannelActions.setActiveChannel({
channel: nextChannel,
})
);
},
error: (err) => {
console.error('Error changing channel:', err);
},
});
}
ngOnDestroy(): void {
this.workspaceHeaderContext.clearAction(M3U_MULTI_EPG_HEADER_ACTION_ID);
this.unsubscribeRemoteChannelChange?.();
this.unsubscribeRemoteCommand?.();
this.statusSubscription?.unsubscribe();
}
onSidebarWidthChange(width: number): void {
this.sidebarWidth.set(this.clampSidebarWidth(width));
}
onSidebarResizeEnd(width: number): void {
this.persistSidebarWidth(this.sidebarStorageKey(), width);
}
onGroupedSidebarWidthRequested(width: number): void {
this.sidebarWidth.set(this.clampSidebarWidth(width));
}
onGroupedSidebarWidthRequestEnded(width: number): void {
this.persistSidebarWidth(this.sidebarStorageKey(), width);
}
/**
* Opens a playlist provided as a url param
* e.g. iptvnat.or?url=http://...
* @pwaOnly
*/
getPlaylistUrlAsParam() {
const URL_REGEX = /^(http|https|file):\/\/[^ "]+$/;
const playlistUrl = this.activatedRoute.snapshot.queryParams['url'];
if (playlistUrl && playlistUrl.match(URL_REGEX)) {
this.dataService.sendIpcEvent(PLAYLIST_PARSE_BY_URL, {
url: playlistUrl,
isTemporary: true,
});
}
}
/**
* Reads the app configuration from the browsers storage and applies the settings in the current component
*/
applySettings(): void {
this.storage.get(STORE_KEY.Settings).subscribe((settings: unknown) => {
if (settings && Object.keys(settings as Settings).length > 0) {
this.playerSettings = {
player:
(settings as Settings).player || VideoPlayer.VideoJs,
showCaptions: (settings as Settings).showCaptions || false,
};
}
});
}
private loadSidebarWidth(storageKey: string): number {
const fallbackKey =
storageKey === M3U_GROUPS_SIDEBAR_STORAGE_KEY
? M3U_SIDEBAR_STORAGE_KEY
: '';
const storedWidth = Number.parseInt(
localStorage.getItem(storageKey) ??
(fallbackKey ? localStorage.getItem(fallbackKey) : '') ??
'',
10
);
return this.clampSidebarWidth(
Number.isNaN(storedWidth)
? M3U_SIDEBAR_DEFAULT_WIDTH
: storedWidth
);
}
private persistSidebarWidth(storageKey: string, width: number): void {
const clampedWidth = this.clampSidebarWidth(width);
this.sidebarWidth.set(clampedWidth);
localStorage.setItem(storageKey, clampedWidth.toString());
}
private clampSidebarWidth(width: number): number {
return Math.max(
M3U_SIDEBAR_MIN_WIDTH,
Math.min(M3U_SIDEBAR_MAX_WIDTH, width)
);
}
private async persistRecentlyViewedChannel(
playlistId: string,
channel: Channel
): Promise<void> {
const recentlyViewedItem: M3uRecentlyViewedItem = {
source: 'm3u',
id: channel.url,
url: channel.url,
title: channel.name?.trim() || channel.tvg?.name || channel.url,
channel_id: channel.id,
poster_url: channel.tvg?.logo || undefined,
tvg_id: channel.tvg?.id || undefined,
tvg_name: channel.tvg?.name || undefined,
group_title: channel.group?.title || undefined,
category_id: 'live',
added_at: new Date().toISOString(),
};
const updatedPlaylist = await firstValueFrom(
this.playlistsService.addM3uRecentlyViewed(
playlistId,
recentlyViewedItem
)
);
this.store.dispatch(
PlaylistActions.updatePlaylistMeta({
playlist: {
_id: playlistId,
recentlyViewed: updatedPlaylist?.recentlyViewed ?? [],
} as PlaylistMeta,
}) as any
);
}
private clearConsumedRecentChannelState(): void {
const historyState = (window.history.state ?? {}) as Record<
string,
unknown
>;
if (!historyState['openRecentChannelUrl']) {
return;
}
try {
const nextState = { ...historyState };
delete nextState['openRecentChannelUrl'];
window.history.replaceState(nextState, document.title);
} catch {
// no-op
}
}
/**
* Opens the overlay with multi EPG view
*/
openMultiEpgView(): void {
const positionStrategy = this.overlay
.position()
.global()
.centerHorizontally()
.centerVertically();
this.overlayRef = this.overlay.create({
hasBackdrop: true,
positionStrategy,
width: '100%',
height: '100%',
});
const injector = Injector.create({
providers: [
{
provide: COMPONENT_OVERLAY_REF,
useValue: this.overlayRef,
},
],
});
const portal = new ComponentPortal(
MultiEpgContainerComponent,
null,
injector
);
const componentRef = this.overlayRef.attach(portal);
componentRef.instance.playlistChannels = this.store.select(
selectChannels
) as Observable<Channel[]>;
// Pass the active channel's tvg.id for highlighting
const currentChannel = this.activeChannel();
if (currentChannel) {
componentRef.instance.activeChannelId =
currentChannel.tvg?.id || null;
}
this.overlayRef.backdropClick().subscribe(() => {
this.overlayRef.dispose();
});
}
@HostListener('document:keydown', ['$event'])
handleKeyPress(event: KeyboardEvent): void {
// Only handle digit keys (0-9)
if (event.key >= '0' && event.key <= '9') {
// Don't trigger hotkeys when user is typing in input fields
if (this.isTypingInInput(event)) {
return;
}
event.preventDefault();
this.handleChannelNumberInput(event.key);
}
}
/**
* Handle channel number input from keyboard
* Debounces input to allow multi-digit channel numbers
*/
handleChannelNumberInput(digit: string): void {
// Clear existing timeout
if (this.channelNumberTimeout) {
clearTimeout(this.channelNumberTimeout);
}
// Add digit to current input
this.channelNumberInput += digit;
this.showChannelNumberOverlay = true;
// Set timeout to switch channel after 2 seconds of no input
this.channelNumberTimeout = window.setTimeout(() => {
this.switchToChannelByNumber(parseInt(this.channelNumberInput, 10));
this.clearChannelNumberInput();
}, 2000);
}
/**
* Switch to channel by number (1-based index)
*/
switchToChannelByNumber(channelNumber: number): void {
this.channels$
.pipe(
take(1),
map((channels) =>
getChannelItemByNumber(channels, channelNumber)
)
)
.subscribe((channel) => {
if (channel) {
this.store.dispatch(
ChannelActions.setActiveChannel({ channel })
);
}
});
}
/**
* Clear channel number input and hide overlay
*/
clearChannelNumberInput(): void {
this.channelNumberInput = '';
this.showChannelNumberOverlay = false;
if (this.channelNumberTimeout) {
clearTimeout(this.channelNumberTimeout);
this.channelNumberTimeout = undefined;
}
}
/**
* Check if the user is currently typing in an input or textarea field
* @param event Keyboard event
* @returns true if the event target is an input or textarea element
*/
private isTypingInInput(event: Event): boolean {
const target = event.target;
return (
target instanceof HTMLInputElement ||
target instanceof HTMLTextAreaElement
);
}
private handleRemoteControlCommand(command: {
type:
| 'channel-select-number'
| 'volume-up'
| 'volume-down'
| 'volume-toggle-mute';
number?: number;
}): void {
if (command.type === 'channel-select-number' && command.number) {
this.switchToChannelByNumber(command.number);
return;
}
if (command.type === 'volume-up') {
this.setVolume(this.volume + 0.1);
} else if (command.type === 'volume-down') {
this.setVolume(this.volume - 0.1);
} else if (command.type === 'volume-toggle-mute') {
if (this.volume === 0) {
this.setVolume(this.lastKnownVolume || 1);
} else {
this.lastKnownVolume = this.volume;
this.setVolume(0);
}
}
}
private setVolume(next: number): void {
const clamped = Math.max(0, Math.min(1, Number(next.toFixed(2))));
this.volume = clamped;
if (clamped > 0) {
this.lastKnownVolume = clamped;
}
localStorage.setItem('volume', String(clamped));
if (window.electron?.updateRemoteControlStatus) {
window.electron.updateRemoteControlStatus({
portal: 'm3u',
isLiveView: true,
supportsVolume: true,
volume: this.volume,
muted: this.volume === 0,
});
}
}
shouldShowInlinePlayer(channel: Channel | null | undefined): boolean {
if (!channel) {
return false;
}
return !this.isExternalPlayer(this.playerSettings.player);
}
private getExternalSessionStateKey(
session: ExternalPlayerSession | null | undefined
): string | null {
if (!session) {
return null;
}
return `${session.id}:${session.status}`;
}
private isExternalPlayer(
player: VideoPlayer | null | undefined
): player is VideoPlayer.MPV | VideoPlayer.VLC {
return player === VideoPlayer.MPV || player === VideoPlayer.VLC;
}
private isTerminalExternalSession(
session: ExternalPlayerSession | null | undefined
): boolean {
return session?.status === 'closed' || session?.status === 'error';
}
private registerHeaderShortcut(): void {
if (!this.isWorkspaceLayout) {
return;
}
this.workspaceHeaderContext.setAction({
id: M3U_MULTI_EPG_HEADER_ACTION_ID,
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: () => this.openMultiEpgView(),
});
}
}
+21
View File
@@ -0,0 +1,21 @@
export default {
displayName: 'playlist-shared-ui',
preset: '../../../../jest.preset.js',
setupFilesAfterEnv: ['<rootDir>/src/test-setup.ts'],
coverageDirectory: '../../../../coverage/libs/playlist/shared/ui',
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',
],
};
+21
View File
@@ -0,0 +1,21 @@
{
"name": "playlist-shared-ui",
"$schema": "../../../../node_modules/nx/schemas/project-schema.json",
"sourceRoot": "libs/playlist/shared/ui/src",
"prefix": "lib",
"projectType": "library",
"tags": ["scope:playlist", "domain:m3u", "type:ui"],
"targets": {
"test": {
"executor": "@nx/jest:jest",
"outputs": ["{workspaceRoot}/coverage/{projectRoot}"],
"options": {
"jestConfig": "libs/playlist/shared/ui/jest.config.ts",
"tsConfig": "libs/playlist/shared/ui/tsconfig.spec.json"
}
},
"lint": {
"executor": "@nx/eslint:lint"
}
}
}
+6
View File
@@ -0,0 +1,6 @@
export * from './lib/add-playlist-menu/playlist-type';
export * from './lib/filter-sort-menu/filter-sort-menu.component';
export * from './lib/playlist-switcher/playlist-switcher.component';
export * from './lib/recent-playlists/playlist-info/playlist-info.component';
export * from './lib/recent-playlists/recent-playlists.component';
export * from './lib/recent-playlists/empty-state/empty-state.component';
@@ -0,0 +1 @@
export type PlaylistType = 'xtream' | 'url' | 'text' | 'file' | 'stalker';
@@ -0,0 +1,140 @@
<mat-menu #filterSortMenu="matMenu" class="filter-sort-menu" xPosition="before">
<section class="filter-section">
<div class="menu-section-header">
<mat-icon class="section-icon">filter_list</mat-icon>
<span>{{ 'HOME.FILTER_BY_TYPE' | translate }}</span>
</div>
@for (type of playlistTypes; track type.id) {
<button
mat-menu-item
(click)="togglePlaylistType(type); $event.stopPropagation()"
[class.active-option]="type.checked"
>
<mat-icon>{{ type.icon }}</mat-icon>
<span class="menu-item-text">{{
type.translationKey | translate
}}</span>
<mat-icon
class="check-icon"
[class.check-visible]="type.checked"
>check</mat-icon
>
</button>
}
</section>
<section class="sort-section">
<div class="menu-section-header">
<mat-icon class="section-icon">sort</mat-icon>
<span>{{ 'HOME.SORT_BY' | translate }}</span>
</div>
<button
mat-menu-item
(click)="
setSortOptions(SortBy.NAME, SortOrder.ASC);
$event.stopPropagation()
"
[class.active-option]="isSortActive(SortBy.NAME, SortOrder.ASC)"
>
<mat-icon>sort_by_alpha</mat-icon>
<span class="menu-item-text">{{
'HOME.SORT_OPTIONS.NAME_ASC' | translate
}}</span>
<mat-icon
class="check-icon"
[class.check-visible]="isSortActive(SortBy.NAME, SortOrder.ASC)"
>check</mat-icon
>
</button>
<button
mat-menu-item
(click)="
setSortOptions(SortBy.NAME, SortOrder.DESC);
$event.stopPropagation()
"
[class.active-option]="isSortActive(SortBy.NAME, SortOrder.DESC)"
>
<mat-icon>sort_by_alpha</mat-icon>
<span class="menu-item-text">{{
'HOME.SORT_OPTIONS.NAME_DESC' | translate
}}</span>
<mat-icon
class="check-icon"
[class.check-visible]="
isSortActive(SortBy.NAME, SortOrder.DESC)
"
>check</mat-icon
>
</button>
<button
mat-menu-item
(click)="
setSortOptions(SortBy.DATE_ADDED, SortOrder.DESC);
$event.stopPropagation()
"
[class.active-option]="
isSortActive(SortBy.DATE_ADDED, SortOrder.DESC)
"
>
<mat-icon>date_range</mat-icon>
<span class="menu-item-text">{{
'HOME.SORT_OPTIONS.NEWEST' | translate
}}</span>
<mat-icon
class="check-icon"
[class.check-visible]="
isSortActive(SortBy.DATE_ADDED, SortOrder.DESC)
"
>check</mat-icon
>
</button>
<button
mat-menu-item
(click)="
setSortOptions(SortBy.DATE_ADDED, SortOrder.ASC);
$event.stopPropagation()
"
[class.active-option]="
isSortActive(SortBy.DATE_ADDED, SortOrder.ASC)
"
>
<mat-icon>date_range</mat-icon>
<span class="menu-item-text">{{
'HOME.SORT_OPTIONS.OLDEST' | translate
}}</span>
<mat-icon
class="check-icon"
[class.check-visible]="
isSortActive(SortBy.DATE_ADDED, SortOrder.ASC)
"
>check</mat-icon
>
</button>
<button
mat-menu-item
(click)="
setSortOptions(SortBy.CUSTOM, SortOrder.ASC);
$event.stopPropagation()
"
[class.active-option]="isSortActive(SortBy.CUSTOM, SortOrder.ASC)"
>
<mat-icon>drag_indicator</mat-icon>
<span class="menu-item-text">{{
'HOME.SORT_OPTIONS.CUSTOM_ORDER' | translate
}}</span>
<mat-icon
class="check-icon"
[class.check-visible]="
isSortActive(SortBy.CUSTOM, SortOrder.ASC)
"
>check</mat-icon
>
</button>
</section>
<div class="menu-footer">
<button mat-button (click)="closeMenu()">
{{ 'CLOSE' | translate }}
</button>
</div>
</mat-menu>
@@ -0,0 +1,87 @@
.filter-section,
.sort-section {
padding: 4px 0;
}
.menu-section-header {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 16px 4px;
font-size: 11px;
font-weight: 600;
letter-spacing: 0.05em;
text-transform: uppercase;
pointer-events: none;
.section-icon {
font-size: 16px;
width: 16px;
height: 16px;
color: rgba(139, 92, 246, 0.6);
}
}
::ng-deep .filter-sort-menu {
.mat-mdc-menu-item {
display: flex !important;
align-items: center !important;
gap: 12px !important;
min-height: 40px !important;
padding: 0 16px !important;
.mat-icon:first-child {
margin-right: 0 !important;
flex-shrink: 0;
}
.menu-item-text {
flex: 1;
text-align: left;
}
.check-icon {
margin-left: auto !important;
margin-right: 0 !important;
flex-shrink: 0;
opacity: 0;
transform: scale(0.8);
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
&.check-visible {
opacity: 1;
transform: scale(1);
}
}
}
}
.active-option {
background-color: rgba(139, 92, 246, 0.12) !important;
position: relative;
&::before {
content: '';
position: absolute;
left: 0;
top: 50%;
transform: translateY(-50%);
width: 3px;
height: 60%;
background: rgba(139, 92, 246, 0.8);
border-radius: 0 2px 2px 0;
}
}
.check-icon {
font-size: 18px !important;
width: 18px !important;
height: 18px !important;
color: rgba(139, 92, 246, 1) !important;
}
.menu-footer {
padding: 4px 8px 4px;
display: flex;
justify-content: center;
}
@@ -0,0 +1,118 @@
import {
Component,
effect,
inject,
input,
output,
viewChild,
} from '@angular/core';
import { toSignal } from '@angular/core/rxjs-interop';
import { MatButtonModule } from '@angular/material/button';
import { MatIconModule } from '@angular/material/icon';
import { MatMenu, MatMenuModule, MatMenuTrigger } from '@angular/material/menu';
import { Store } from '@ngrx/store';
import { TranslateModule } from '@ngx-translate/core';
import { FilterActions, selectActiveTypeFilters } from 'm3u-state';
import { SortBy, SortOrder, SortService } from 'services';
@Component({
selector: 'app-filter-sort-menu',
templateUrl: './filter-sort-menu.component.html',
styleUrls: ['./filter-sort-menu.component.scss'],
imports: [MatButtonModule, MatIconModule, MatMenuModule, TranslateModule],
})
export class FilterSortMenuComponent {
private readonly store = inject(Store);
private readonly sortService = inject(SortService);
readonly menu = viewChild.required<MatMenu>('filterSortMenu');
readonly menuTrigger = input<MatMenuTrigger>();
readonly filterChanged = output<void>();
playlistTypes = [
{
id: 'm3u',
icon: 'playlist_play',
translationKey: 'HOME.PLAYLIST_TYPES.M3U',
checked: true,
},
{
id: 'xtream',
icon: 'cloud',
translationKey: 'HOME.PLAYLIST_TYPES.XTREAM',
checked: true,
},
{
id: 'stalker',
icon: 'router',
translationKey: 'HOME.PLAYLIST_TYPES.STALKER',
checked: true,
},
];
private readonly selectedTypeFilters = this.store.selectSignal(
selectActiveTypeFilters
);
readonly SortBy = SortBy;
readonly SortOrder = SortOrder;
private readonly currentSortOptions = toSignal(
this.sortService.getSortOptions(),
{
requireSync: true,
}
);
constructor() {
effect(() => {
if (this.selectedTypeFilters) {
this.playlistTypes = this.playlistTypes.map((type) => {
type.checked = this.selectedTypeFilters().includes(type.id);
return type;
});
}
});
}
togglePlaylistType(type: {
id: string;
icon: string;
translationKey: string;
checked: boolean;
}) {
const currentlySelectedCount = this.playlistTypes.filter(
(item) => item.checked
).length;
if (type.checked && currentlySelectedCount === 1) {
return;
}
type.checked = !type.checked;
this.store.dispatch(
FilterActions.setSelectedFilters({
selectedFilters: this.playlistTypes
.filter((item) => item.checked)
.map((item) => item.id),
})
);
this.filterChanged.emit();
}
setSortOptions(by: SortBy, order: SortOrder): void {
this.sortService.setSortOptions({ by, order });
}
isSortActive(by: SortBy, order: SortOrder): boolean {
const options = this.currentSortOptions();
return options?.by === by && options?.order === order;
}
get activeFiltersCount(): number {
return this.playlistTypes.filter((type) => !type.checked).length;
}
closeMenu(): void {
this.menuTrigger()?.closeMenu();
}
}
@@ -0,0 +1,257 @@
<div
class="playlist-switcher-trigger"
[class.is-open]="isMenuOpen()"
matRipple
[matRippleDisabled]="false"
[matMenuTriggerFor]="playlistMenu"
#menuTrigger="matMenuTrigger"
#triggerElement
(menuOpened)="onMenuOpened()"
(menuClosed)="onMenuClosed()"
>
<div class="trigger-icon-container">
<mat-icon class="trigger-playlist-icon">playlist_play</mat-icon>
</div>
<div class="playlist-info">
<div class="name">{{ displayTitle() }}</div>
<div class="channels-count">{{ subtitle() }}</div>
</div>
@if (canRefreshActivePlaylist()) {
<button
type="button"
mat-icon-button
class="trigger-refresh-button"
[disabled]="isRefreshingActivePlaylist()"
(click)="onRefreshClick($event)"
(mousedown)="$event.stopPropagation()"
[matTooltip]="'WORKSPACE.SHELL.REFRESH_PLAYLIST' | translate"
[attr.aria-label]="
'WORKSPACE.SHELL.REFRESH_PLAYLIST' | translate
"
>
<mat-icon
class="trigger-refresh-icon"
[class.is-spinning]="isRefreshingActivePlaylist()"
>sync</mat-icon
>
</button>
}
<mat-icon class="dropdown-icon" [class.rotated]="isMenuOpen()">
expand_more
</mat-icon>
</div>
<mat-menu
#playlistMenu="matMenu"
panelClass="playlist-switcher-menu"
hasBackdrop
backdropClass="playlist-switcher-menu-backdrop"
overlapTrigger="false"
xPosition="after"
yPosition="below"
>
<div class="menu-header">
<mat-icon class="menu-header-icon">playlist_play</mat-icon>
<span class="menu-header-title">{{
'HOME.PLAYLISTS.MY_PLAYLISTS' | translate
}}</span>
@if (hasSearchToggle()) {
<button
mat-icon-button
type="button"
class="search-toggle-btn"
[class.is-active]="searchExpanded()"
(click)="toggleSearchField($event)"
[attr.aria-label]="
'HOME.PLAYLISTS.SEARCH_PLAYLISTS' | translate
"
>
<mat-icon>search</mat-icon>
</button>
}
</div>
@if (showSearchField()) {
<div class="search-container" (click)="$event.stopPropagation()">
<mat-icon class="search-icon">search</mat-icon>
<input
#searchInput
type="text"
class="search-input"
[placeholder]="'HOME.PLAYLISTS.SEARCH_PLAYLISTS' | translate"
[ngModel]="searchQuery()"
(ngModelChange)="setSearchQuery($event)"
/>
@if (searchQuery()) {
<button
mat-icon-button
class="clear-btn"
(click)="setSearchQuery('')"
>
<mat-icon>close</mat-icon>
</button>
}
</div>
}
@if (showTypeFilters()) {
<div class="type-filters" (click)="$event.stopPropagation()">
@if (hasType('m3u')) {
<button
type="button"
class="type-filter-chip"
[class.selected]="isTypeFilterSelected('m3u')"
(click)="togglePlaylistTypeFilter('m3u', $event)"
>
M3U
</button>
}
@if (hasType('stalker')) {
<button
type="button"
class="type-filter-chip"
[class.selected]="isTypeFilterSelected('stalker')"
(click)="togglePlaylistTypeFilter('stalker', $event)"
>
Stalker
</button>
}
@if (hasType('xtream')) {
<button
type="button"
class="type-filter-chip"
[class.selected]="isTypeFilterSelected('xtream')"
(click)="togglePlaylistTypeFilter('xtream', $event)"
>
Xtream
</button>
}
</div>
}
<div class="menu-content">
@for (playlist of filteredPlaylists(); track playlist._id) {
<div
class="playlist-item"
[class.selected]="isSelected(playlist)"
(click)="selectPlaylist(playlist)"
>
<!-- @if (isSelected(playlist)) {
<mat-icon class="selected-indicator">check</mat-icon>
} @else {
<div class="selected-indicator-placeholder"></div>
} -->
<div class="playlist-icon-container">
<mat-icon class="playlist-type-icon">
{{ getPlaylistIcon(playlist) }}
</mat-icon>
@if (playlist.serverUrl) {
<span
class="status-dot"
[class]="getStatusClass(playlist._id)"
></span>
}
@if (
playlist.autoRefresh &&
!playlist.serverUrl &&
!playlist.macAddress
) {
<mat-icon class="auto-refresh-indicator"
>autorenew</mat-icon
>
}
</div>
<div class="playlist-details">
<span class="playlist-title">
{{ playlist.title || playlist.filename }}
</span>
<span class="playlist-meta">
{{ getPlaylistMetaLabel(playlist) }}
@if (playlist.updateDate) {
<span class="separator">·</span>
{{
playlist.updateDate
| date
: 'MMM d, HH:mm'
: undefined
: currentLocale()
}}
}
</span>
</div>
<button
type="button"
class="item-action-trigger"
mat-icon-button
[matMenuTriggerFor]="itemMenu"
[matMenuTriggerData]="{ playlist: playlist }"
(click)="$event.stopPropagation()"
aria-label="Playlist actions"
>
<mat-icon>more_vert</mat-icon>
</button>
</div>
} @empty {
<div class="empty-state">
<mat-icon>playlist_add</mat-icon>
<span>{{ 'HOME.PLAYLISTS.NO_PLAYLISTS' | translate }}</span>
</div>
}
</div>
@if (showAddPlaylist() || showAccountInfo()) {
<div class="context-actions-section">
@if (showAddPlaylist()) {
<button
class="context-action-item"
(click)="requestAddPlaylist()"
>
<mat-icon>add</mat-icon>
<span>{{
'WORKSPACE.SHELL.ADD_PLAYLIST' | translate
}}</span>
</button>
}
@if (showAccountInfo()) {
<button
class="context-action-item"
(click)="requestAccountInfo()"
>
<mat-icon>person</mat-icon>
<span>{{
'WORKSPACE.SHELL.ACCOUNT_INFO' | translate
}}</span>
</button>
}
</div>
}
</mat-menu>
<mat-menu #itemMenu="matMenu" class="playlist-item-action-menu">
<ng-template matMenuContent let-playlist="playlist">
<button mat-menu-item (click)="openPlaylistInfoFor(playlist, $event)">
<mat-icon>info</mat-icon>
<span>{{ 'WORKSPACE.SHELL.PLAYLIST_INFO' | translate }}</span>
</button>
@if (canRefreshPlaylist(playlist)) {
<button mat-menu-item (click)="refreshPlaylistFor(playlist, $event)">
<mat-icon>sync</mat-icon>
<span>{{
'WORKSPACE.SHELL.REFRESH_PLAYLIST' | translate
}}</span>
</button>
}
<mat-divider />
<button
mat-menu-item
class="danger"
(click)="removePlaylistFor(playlist, $event)"
>
<mat-icon>delete</mat-icon>
<span>{{ 'HOME.PLAYLISTS.REMOVE_DIALOG.TITLE' | translate }}</span>
</button>
</ng-template>
</mat-menu>
@@ -0,0 +1,608 @@
:host {
display: block;
width: 100%;
}
.playlist-switcher-trigger {
display: flex;
align-items: center;
gap: 8px;
width: 100%;
box-sizing: border-box;
padding: 4px 8px 4px 4px;
border-radius: 10px;
cursor: pointer;
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
background: color-mix(in srgb, var(--app-on-surface) 5%, transparent);
border: 1px solid
color-mix(in srgb, var(--mat-sys-outline-variant) 36%, transparent);
app-region: no-drag;
&:hover {
background: color-mix(
in srgb,
var(--app-on-surface) 12%,
transparent
);
border-color: color-mix(
in srgb,
var(--mat-sys-outline-variant) 64%,
transparent
);
}
&.is-open {
background: color-mix(
in srgb,
var(--app-on-surface) 12%,
transparent
);
border-color: color-mix(
in srgb,
var(--mat-sys-outline-variant) 72%,
transparent
);
}
&:active {
transform: scale(0.98);
}
}
.trigger-icon-container {
width: 30px;
height: 30px;
border-radius: 8px;
display: inline-flex;
align-items: center;
justify-content: center;
background: color-mix(
in srgb,
var(--app-on-surface) 8%,
transparent
);
flex-shrink: 0;
}
.trigger-playlist-icon {
font-size: 18px;
width: 18px;
height: 18px;
color: var(--mat-sys-on-surface-variant);
}
.playlist-info {
flex: 1;
min-width: 0;
.name {
font-size: 0.95em;
font-weight: 600;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
line-height: 1.25;
letter-spacing: 0.01em;
}
.channels-count {
font-size: 0.74em;
line-height: 1.3;
letter-spacing: 0.02em;
color: var(--mat-sys-on-surface-variant);
}
}
.trigger-refresh-button {
margin-left: auto;
width: 32px;
height: 32px;
min-width: 32px;
min-height: 32px;
padding: 0;
line-height: 32px;
flex-shrink: 0;
display: inline-flex;
align-items: center;
justify-content: center;
color: var(--mat-sys-on-surface-variant);
transition:
color 0.15s ease,
background 0.15s ease;
&:hover:not(:disabled) {
color: var(--mat-sys-on-surface);
background: var(--mat-sys-surface-container);
}
::ng-deep .mat-mdc-button-touch-target {
width: 32px;
height: 32px;
}
::ng-deep .mat-mdc-button-persistent-ripple,
::ng-deep .mat-mdc-focus-indicator {
border-radius: 50%;
}
}
.trigger-refresh-icon {
font-size: 18px;
width: 18px;
height: 18px;
line-height: 18px;
margin: 0;
padding: 0;
display: inline-flex;
align-items: center;
justify-content: center;
&.is-spinning {
animation: switcher-refresh-spin 1s linear infinite;
}
}
@keyframes switcher-refresh-spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
.dropdown-icon {
font-size: 20px;
width: 20px;
height: 20px;
color: var(--mat-sys-on-surface-variant);
transition: transform 0.25s cubic-bezier(0.4, 0, 0.2, 1);
margin-left: 4px;
&.rotated {
transform: rotate(180deg);
}
}
::ng-deep .playlist-switcher-menu.mat-mdc-menu-panel {
--mat-menu-container-color: var(--mat-sys-surface-container-high);
width: min(
var(--playlist-switcher-overlay-width, 400px),
calc(100vw - 24px)
) !important;
min-width: min(
var(--playlist-switcher-overlay-width, 400px),
calc(100vw - 24px)
) !important;
max-width: min(
var(--playlist-switcher-overlay-width, 400px),
calc(100vw - 24px)
) !important;
margin-top: -1px !important;
border-radius: 0 0 12px 12px !important;
border: 1px solid var(--mat-sys-outline) !important;
border-top: 0 !important;
overflow: hidden !important;
background: var(--mat-menu-container-color) !important;
.mat-mdc-menu-content {
padding: 0 !important;
background: var(--mat-menu-container-color) !important;
}
}
.menu-header {
display: flex;
align-items: center;
gap: 10px;
padding: 8px 12px 6px;
font-size: 0.85em;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.5px;
background: var(--mat-menu-container-color);
.menu-header-title {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
opacity: 0.7;
}
.menu-header-icon {
opacity: 0.7;
}
mat-icon {
font-size: 18px;
width: 18px;
height: 18px;
}
.search-toggle-btn {
width: 30px;
height: 30px;
line-height: 30px;
color: var(--mat-sys-on-surface-variant);
opacity: 0.8;
padding: 0;
&:hover,
&.is-active {
opacity: 1;
color: var(--mat-sys-on-surface);
}
mat-icon {
font-size: 18px;
width: 18px;
height: 18px;
}
}
}
.search-container {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 12px;
margin: 0 8px 4px;
background: color-mix(
in srgb,
var(--app-on-surface) 6%,
transparent
);
border-radius: 8px;
border: 1px solid transparent;
transition: all 0.2s ease;
}
.search-container:focus-within {
border-color: var(--mat-sys-primary);
background: var(--mat-sys-surface-container-highest);
}
.search-container .search-icon {
font-size: 18px;
width: 18px;
height: 18px;
color: var(--mat-sys-on-surface-variant);
}
.search-container .search-input {
flex: 1;
border: none;
background: transparent;
outline: none;
font-size: 0.86em;
font-weight: 500;
line-height: 1.4;
color: inherit;
min-width: 0;
}
.search-container .search-input::placeholder {
color: var(--mat-sys-on-surface-variant);
}
.search-container .clear-btn {
width: 24px;
height: 24px;
line-height: 24px;
padding: 0;
}
.search-container .clear-btn mat-icon {
font-size: 16px;
width: 16px;
height: 16px;
}
.type-filters {
display: flex;
flex-wrap: wrap;
gap: 6px;
padding: 0 8px 8px;
margin-top: 6px;
}
.type-filter-chip {
height: 24px;
padding: 0 10px;
border-radius: 999px;
border: 1px solid
color-mix(in srgb, var(--mat-sys-outline-variant) 72%, transparent);
background: color-mix(
in srgb,
var(--mat-sys-surface-container-high) 78%,
transparent
);
color: var(--mat-sys-on-surface-variant);
font-size: 0.72rem;
font-weight: 600;
letter-spacing: 0.02em;
line-height: 1;
cursor: pointer;
transition: all 0.16s ease;
&:hover {
background: var(--mat-sys-surface-container);
color: var(--mat-sys-on-surface);
}
&.selected {
border-color: var(--app-selection-border, var(--app-selection-color));
background: var(
--app-selection-surface-strong,
var(--app-selection-surface)
);
color: var(--app-selection-color, var(--mat-sys-primary));
/* box-shadow:
inset 0 0 0 1px
color-mix(in srgb, var(--app-selection-color) 18%, transparent),
0 0 0 1px
color-mix(in srgb, var(--app-selection-color) 12%, transparent),
0 0 10px var(--app-selection-glow, transparent); */
}
&:focus-visible {
outline: 2px solid var(--app-selection-border, var(--mat-sys-primary));
outline-offset: 1px;
}
}
.menu-content {
max-height: 360px;
overflow-y: auto;
padding: 4px 0;
background: color-mix(in srgb, #000 22%, transparent);
border-top: 1px solid
color-mix(in srgb, var(--app-on-surface) 14%, transparent);
border-bottom: 1px solid
color-mix(in srgb, var(--app-on-surface) 14%, transparent);
}
.playlist-item {
display: flex;
flex-direction: row;
align-items: center;
gap: 10px;
padding: 4px 12px;
min-height: 44px;
transition: background-color 0.15s ease;
position: relative;
cursor: pointer;
&:not(:last-child) {
border-bottom: 1px solid
color-mix(in srgb, var(--app-on-surface) 8%, transparent);
}
}
.playlist-item.selected {
background: var(--app-selection-surface, rgba(139, 92, 246, 0.1));
&::before {
content: '';
position: absolute;
left: 0;
top: 6px;
bottom: 6px;
width: 3px;
border-radius: 0 3px 3px 0;
background: var(--app-selection-color, #a78bfa);
}
.playlist-title,
.playlist-meta {
color: var(--app-selection-color, #a78bfa);
}
}
.item-action-trigger {
opacity: 0;
pointer-events: none;
transition: opacity 0.15s ease;
width: 32px;
height: 32px;
line-height: 32px;
flex-shrink: 0;
color: var(--mat-sys-on-surface-variant);padding: 0;
mat-icon {
font-size: 18px;
width: 18px;
height: 18px;
}
}
.playlist-item:hover .item-action-trigger,
.playlist-item.selected .item-action-trigger,
.item-action-trigger:focus-visible,
.item-action-trigger[aria-expanded='true'] {
opacity: 1;
pointer-events: auto;
}
::ng-deep .playlist-item-action-menu {
.danger,
.danger mat-icon {
color: var(--mat-sys-error, #f44336);
}
}
.playlist-item:hover:not(.selected) {
background: color-mix(
in srgb,
var(--app-on-surface) 10%,
transparent
);
}
.playlist-icon-container {
position: relative;
display: flex;
align-items: center;
justify-content: center;
width: 36px;
height: 36px;
border-radius: 8px;
background: color-mix(
in srgb,
var(--app-on-surface) 6%,
transparent
);
flex-shrink: 0;
}
.playlist-type-icon {
font-size: 20px;
width: 20px;
height: 20px;
color: var(--mat-sys-on-surface-variant);
}
.status-dot {
position: absolute;
width: 10px;
height: 10px;
border-radius: 50%;
right: -4px;
bottom: -4px;
border: 2px solid var(--mat-menu-container-color, #2a2a2e);
}
.status-active {
background-color: #4caf50;
box-shadow: 0 0 6px rgba(76, 175, 80, 0.4);
}
.status-inactive {
background-color: #f44336;
box-shadow: 0 0 6px rgba(244, 67, 54, 0.4);
}
.status-expired {
background-color: #ff9800;
box-shadow: 0 0 6px rgba(255, 152, 0, 0.4);
}
.status-unavailable {
background-color: #666666;
}
.auto-refresh-indicator {
position: absolute;
right: -2px;
bottom: -2px;
font-size: 12px !important;
width: 14px !important;
height: 14px !important;
color: var(--mat-sys-primary);
background-color: var(--mat-menu-container-color, var(--mat-sys-surface));
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
}
.playlist-details {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 3px;
}
.playlist-title {
font-size: 0.92em;
font-weight: 600;
line-height: 1.2;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.playlist-meta {
font-size: 0.73em;
line-height: 1.25;
letter-spacing: 0.01em;
color: var(--mat-sys-on-surface-variant);
display: flex;
align-items: center;
gap: 4px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
.separator {
color: var(--mat-sys-on-surface-variant);
}
}
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 32px 16px;
gap: 12px;
color: var(--mat-sys-on-surface-variant);
mat-icon {
font-size: 36px;
width: 36px;
height: 36px;
}
span {
font-size: 0.85em;
}
}
.context-actions-section {
padding: 6px 8px 8px;
background: var(--mat-menu-container-color);
.context-action-item {
display: flex;
align-items: center;
gap: 10px;
width: 100%;
padding: 8px 10px;
border: none;
border-radius: 8px;
background: transparent;
color: var(--mat-sys-on-surface-variant);
font: inherit;
font-size: 0.86em;
font-weight: 500;
cursor: pointer;
transition:
background 0.15s ease,
color 0.15s ease;
&:hover {
background: var(--mat-sys-surface-container);
color: var(--mat-sys-on-surface);
}
mat-icon {
font-size: 18px;
width: 18px;
height: 18px;
}
}
}
@media only screen and (max-width: 480px) {
.playlist-info .name {
font-size: 0.9em;
}
.playlist-icon-container {
display: none;
}
}
@@ -0,0 +1,344 @@
import { signal } from '@angular/core';
import {
ComponentFixture,
TestBed,
fakeAsync,
flushMicrotasks,
tick,
} from '@angular/core/testing';
import { MatDialog } from '@angular/material/dialog';
import { MatSnackBar } from '@angular/material/snack-bar';
import { Store } from '@ngrx/store';
import { NoopAnimationsModule } from '@angular/platform-browser/animations';
import { TranslateModule } from '@ngx-translate/core';
import {
PlaylistContextFacade,
PlaylistRefreshActionService,
} from '@iptvnator/playlist/shared/util';
import { DialogService } from 'components';
import { DatabaseService, PortalStatusService } from 'services';
import { PlaylistMeta } from 'shared-interfaces';
import { PlaylistSwitcherComponent } from './playlist-switcher.component';
const SEARCH_QUERY_STORAGE_KEY = 'playlist-switcher:search-query';
const PLAYLIST_TYPE_FILTER_STORAGE_KEY = 'playlist-switcher:type-filters';
function createPlaylist(
overrides: Partial<PlaylistMeta> & { _id: string }
): PlaylistMeta {
return {
_id: overrides._id,
title: overrides.title ?? overrides.filename ?? overrides._id,
filename: overrides.filename,
count: overrides.count ?? 0,
importDate:
overrides.importDate ?? new Date('2026-04-05T10:00:00.000Z').toISOString(),
autoRefresh: overrides.autoRefresh ?? false,
...overrides,
} as PlaylistMeta;
}
describe('PlaylistSwitcherComponent', () => {
let fixture: ComponentFixture<PlaylistSwitcherComponent>;
let component: PlaylistSwitcherComponent;
let playlistsSignal: ReturnType<typeof signal<PlaylistMeta[]>>;
let resolvedPlaylistIdSignal: ReturnType<typeof signal<string | null>>;
let activePlaylistSignal: ReturnType<typeof signal<PlaylistMeta | null>>;
let playlistContext: {
playlists: typeof playlistsSignal;
allPlaylistsLoaded: ReturnType<typeof signal<boolean>>;
resolvedPlaylistId: typeof resolvedPlaylistIdSignal;
activePlaylist: typeof activePlaylistSignal;
selectPlaylist: jest.Mock;
};
let portalStatusService: {
checkPortalStatus: jest.Mock;
getStatusClass: jest.Mock;
};
let refreshActionService: {
canRefresh: jest.Mock;
refresh: jest.Mock;
};
let dialog: {
open: jest.Mock;
};
let dialogService: {
openConfirmDialog: jest.Mock;
};
let databaseService: {
createOperationId: jest.Mock;
deletePlaylist: jest.Mock;
};
let snackBar: {
open: jest.Mock;
};
let store: {
dispatch: jest.Mock;
};
const m3uPlaylist = createPlaylist({
_id: 'm3u-1',
count: 24,
importDate: '2026-04-05T09:00:00.000Z',
title: 'Morning M3U',
url: 'http://example.test/m3u.m3u',
});
const stalkerPlaylist = createPlaylist({
_id: 'stalker-1',
importDate: '2026-04-05T11:00:00.000Z',
macAddress: '00:1A:79:00:00:01',
portalUrl: 'http://127.0.0.1:3210/portal.php',
title: 'Living Room Stalker',
});
const xtreamPlaylist = createPlaylist({
_id: 'xtream-1',
importDate: '2026-04-05T12:00:00.000Z',
password: 'pass1',
serverUrl: 'http://127.0.0.1:3211',
title: 'Cinema Xtream',
updateDate: new Date('2026-04-05T12:30:00.000Z').getTime(),
username: 'user1',
});
async function createComponent(): Promise<void> {
await TestBed.configureTestingModule({
imports: [
PlaylistSwitcherComponent,
NoopAnimationsModule,
TranslateModule.forRoot(),
],
providers: [
{
provide: PlaylistContextFacade,
useValue: playlistContext,
},
{
provide: PortalStatusService,
useValue: portalStatusService,
},
{
provide: PlaylistRefreshActionService,
useValue: refreshActionService,
},
{
provide: MatDialog,
useValue: dialog,
},
{
provide: DialogService,
useValue: dialogService,
},
{
provide: DatabaseService,
useValue: databaseService,
},
{
provide: MatSnackBar,
useValue: snackBar,
},
{
provide: Store,
useValue: store,
},
],
}).compileComponents();
fixture = TestBed.createComponent(PlaylistSwitcherComponent);
component = fixture.componentInstance;
fixture.componentRef.setInput('currentTitle', '');
fixture.componentRef.setInput('subtitle', '3 playlists');
fixture.detectChanges();
}
beforeEach(() => {
localStorage.clear();
playlistsSignal = signal([m3uPlaylist, stalkerPlaylist, xtreamPlaylist]);
resolvedPlaylistIdSignal = signal<string | null>(xtreamPlaylist._id);
activePlaylistSignal = signal<PlaylistMeta | null>(xtreamPlaylist);
playlistContext = {
playlists: playlistsSignal,
allPlaylistsLoaded: signal(true),
resolvedPlaylistId: resolvedPlaylistIdSignal,
activePlaylist: activePlaylistSignal,
selectPlaylist: jest.fn((playlist: PlaylistMeta) => {
resolvedPlaylistIdSignal.set(playlist._id);
activePlaylistSignal.set(playlist);
}),
};
portalStatusService = {
checkPortalStatus: jest
.fn()
.mockResolvedValueOnce('active')
.mockResolvedValueOnce('inactive'),
getStatusClass: jest.fn((status: string) => `status-${status}`),
};
refreshActionService = {
canRefresh: jest.fn(
(playlist: PlaylistMeta) =>
Boolean(
playlist.serverUrl || playlist.url || playlist.filePath
)
),
refresh: jest.fn(),
};
dialog = {
open: jest.fn(),
};
dialogService = {
openConfirmDialog: jest.fn(),
};
databaseService = {
createOperationId: jest.fn(),
deletePlaylist: jest.fn(),
};
snackBar = {
open: jest.fn(),
};
store = {
dispatch: jest.fn(),
};
});
afterEach(() => {
localStorage.clear();
jest.restoreAllMocks();
});
it('reads persisted search and type filters on startup and filters playlists accordingly', async () => {
localStorage.setItem(SEARCH_QUERY_STORAGE_KEY, 'room');
localStorage.setItem(
PLAYLIST_TYPE_FILTER_STORAGE_KEY,
JSON.stringify({
m3u: false,
stalker: true,
xtream: true,
})
);
await createComponent();
expect(component.searchQuery()).toBe('room');
expect(component.isTypeFilterSelected('m3u')).toBe(false);
expect(component.filteredPlaylists()).toEqual([stalkerPlaylist]);
});
it('sorts playlists by import date descending and prevents clearing the last enabled type filter', async () => {
await createComponent();
expect(component.filteredPlaylists().map((playlist) => playlist._id)).toEqual([
xtreamPlaylist._id,
stalkerPlaylist._id,
m3uPlaylist._id,
]);
component.togglePlaylistTypeFilter('m3u');
component.togglePlaylistTypeFilter('stalker');
expect(component.playlistTypeFilters()).toEqual({
m3u: false,
stalker: false,
xtream: true,
});
component.togglePlaylistTypeFilter('xtream');
expect(component.playlistTypeFilters()).toEqual({
m3u: false,
stalker: false,
xtream: true,
});
});
it('selects a playlist, closes the menu, and emits the selected id', async () => {
await createComponent();
const closeMenuSpy = jest.spyOn(component.menuTrigger(), 'closeMenu');
const selectedIds: string[] = [];
component.playlistSelected.subscribe((playlistId) => {
selectedIds.push(playlistId);
});
component.selectPlaylist(stalkerPlaylist);
fixture.detectChanges();
expect(closeMenuSpy).toHaveBeenCalledTimes(1);
expect(playlistContext.selectPlaylist).toHaveBeenCalledWith(
stalkerPlaylist
);
expect(selectedIds).toEqual([stalkerPlaylist._id]);
expect(component.isSelected(stalkerPlaylist)).toBe(true);
});
it('emits playlist and account info requests through the context actions', async () => {
await createComponent();
const closeMenuSpy = jest.spyOn(component.menuTrigger(), 'closeMenu');
const playlistInfoSpy = jest.fn();
const accountInfoSpy = jest.fn();
component.playlistInfoRequested.subscribe(playlistInfoSpy);
component.accountInfoRequested.subscribe(accountInfoSpy);
component.requestPlaylistInfo();
component.requestAccountInfo();
expect(closeMenuSpy).toHaveBeenCalledTimes(2);
expect(playlistInfoSpy).toHaveBeenCalledTimes(1);
expect(accountInfoSpy).toHaveBeenCalledTimes(1);
});
it('opens the menu, syncs overlay width, and checks portal statuses for Xtream playlists', fakeAsync(async () => {
await createComponent();
const triggerElement = component.triggerElement().nativeElement;
jest.spyOn(triggerElement, 'getBoundingClientRect').mockReturnValue({
bottom: 0,
height: 0,
left: 0,
right: 320,
toJSON: () => ({}),
top: 0,
width: 320,
x: 0,
y: 0,
});
component.onMenuOpened();
flushMicrotasks();
tick();
expect(component.isMenuOpen()).toBe(true);
expect(
document.documentElement.style.getPropertyValue(
'--playlist-switcher-overlay-width'
)
).toBe('400px');
expect(portalStatusService.checkPortalStatus).toHaveBeenCalledTimes(1);
expect(portalStatusService.checkPortalStatus).toHaveBeenCalledWith(
xtreamPlaylist.serverUrl,
xtreamPlaylist.username,
xtreamPlaylist.password
);
expect(component.portalStatuses().get(xtreamPlaylist._id)).toBe('active');
expect(component.getStatusClass(xtreamPlaylist._id)).toBe('status-active');
component.onMenuClosed();
expect(component.isMenuOpen()).toBe(false);
expect(
document.documentElement.style.getPropertyValue(
'--playlist-switcher-overlay-width'
)
).toBe('');
}));
it('falls back to the active playlist title when no explicit current title is provided', async () => {
await createComponent();
expect(component.displayTitle()).toBe('Cinema Xtream');
resolvedPlaylistIdSignal.set(null);
activePlaylistSignal.set(null);
fixture.detectChanges();
expect(component.displayTitle()).toBe('Select playlist');
});
});
@@ -0,0 +1,509 @@
import { DatePipe, DOCUMENT } from '@angular/common';
import {
Component,
computed,
DestroyRef,
ElementRef,
inject,
input,
output,
signal,
viewChild,
} from '@angular/core';
import { toSignal } from '@angular/core/rxjs-interop';
import { FormsModule } from '@angular/forms';
import { MatIconButton } from '@angular/material/button';
import { MatRippleModule } from '@angular/material/core';
import { MatDialog } from '@angular/material/dialog';
import { MatDivider } from '@angular/material/divider';
import { MatIcon } from '@angular/material/icon';
import { MatInputModule } from '@angular/material/input';
import { MatMenu, MatMenuModule, MatMenuTrigger } from '@angular/material/menu';
import { MatSnackBar } from '@angular/material/snack-bar';
import { MatTooltip } from '@angular/material/tooltip';
import { Store } from '@ngrx/store';
import { normalizeDateLocale } from '@iptvnator/pipes';
import { TranslatePipe, TranslateService } from '@ngx-translate/core';
import { DialogService } from 'components';
import { PlaylistActions } from 'm3u-state';
import {
PlaylistContextFacade,
PlaylistRefreshActionService,
} from '@iptvnator/playlist/shared/util';
import {
DatabaseService,
PortalStatus,
PortalStatusService,
} from 'services';
import { PlaylistMeta } from 'shared-interfaces';
import { startWith } from 'rxjs';
import { PlaylistInfoComponent } from '../recent-playlists/playlist-info/playlist-info.component';
type PlaylistFilterType = 'm3u' | 'stalker' | 'xtream';
const SEARCH_QUERY_STORAGE_KEY = 'playlist-switcher:search-query';
const PLAYLIST_TYPE_FILTER_STORAGE_KEY = 'playlist-switcher:type-filters';
const DEFAULT_PLAYLIST_TYPE_FILTERS: Record<PlaylistFilterType, boolean> = {
m3u: true,
stalker: true,
xtream: true,
};
@Component({
selector: 'app-playlist-switcher',
templateUrl: './playlist-switcher.component.html',
styleUrls: ['./playlist-switcher.component.scss'],
imports: [
DatePipe,
FormsModule,
MatDivider,
MatIcon,
MatIconButton,
MatInputModule,
MatMenuModule,
MatRippleModule,
MatTooltip,
TranslatePipe,
],
})
export class PlaylistSwitcherComponent {
private readonly document = inject(DOCUMENT);
private readonly destroyRef = inject(DestroyRef);
private readonly playlistContext = inject(PlaylistContextFacade);
private readonly portalStatusService = inject(PortalStatusService);
private readonly translate = inject(TranslateService);
private readonly refreshAction = inject(PlaylistRefreshActionService);
private readonly dialog = inject(MatDialog);
private readonly dialogService = inject(DialogService);
private readonly databaseService = inject(DatabaseService);
private readonly snackBar = inject(MatSnackBar);
private readonly store = inject(Store);
private focusSearchTimeoutId: ReturnType<typeof setTimeout> | null = null;
private readonly languageTick = toSignal(
this.translate.onLangChange.pipe(startWith(null)),
{ initialValue: null }
);
readonly currentTitle = input.required<string>();
readonly subtitle = input<string>('');
readonly showPlaylistInfo = input(false);
readonly showAccountInfo = input(false);
readonly showAddPlaylist = input(false);
readonly canRefreshActivePlaylist = input(false);
readonly isRefreshingActivePlaylist = input(false);
readonly playlistSelected = output<string>();
readonly playlistInfoRequested = output<void>();
readonly accountInfoRequested = output<void>();
readonly addPlaylistRequested = output<void>();
readonly refreshPlaylistRequested = output<void>();
onRefreshClick(event: Event): void {
event.stopPropagation();
event.preventDefault();
this.refreshPlaylistRequested.emit();
}
readonly menuTrigger = viewChild.required<MatMenuTrigger>('menuTrigger');
readonly playlistMenu = viewChild.required<MatMenu>('playlistMenu');
readonly triggerElement =
viewChild.required<ElementRef<HTMLElement>>('triggerElement');
readonly searchInput =
viewChild<ElementRef<HTMLInputElement>>('searchInput');
readonly isMenuOpen = signal(false);
readonly searchQuery = signal(this.readPersistedSearchQuery());
readonly playlistTypeFilters = signal(this.readPersistedTypeFilters());
readonly searchExpanded = signal(false);
private static readonly SEARCH_AUTO_VISIBLE_THRESHOLD = 5;
readonly playlists = this.playlistContext.playlists;
readonly allPlaylistsLoaded = this.playlistContext.allPlaylistsLoaded;
readonly activePlaylistId = this.playlistContext.resolvedPlaylistId;
readonly activePlaylist = this.playlistContext.activePlaylist;
readonly filteredPlaylists = computed(() => {
const query = this.searchQuery().toLowerCase().trim();
const filters = this.playlistTypeFilters();
const allPlaylists = this.playlists();
const filteredByType = allPlaylists.filter((playlist) => {
const playlistType = this.getPlaylistFilterType(playlist);
return filters[playlistType];
});
const filtered = query
? filteredByType.filter(
(playlist) =>
playlist.title?.toLowerCase().includes(query) ||
playlist.filename?.toLowerCase().includes(query)
)
: filteredByType;
return [...filtered].sort(
(a, b) =>
new Date(b.importDate ?? 0).getTime() -
new Date(a.importDate ?? 0).getTime()
);
});
readonly displayTitle = computed(() => {
if (!this.activePlaylistId()) {
return 'Select playlist';
}
return (
this.currentTitle() ||
this.activePlaylist()?.title ||
this.activePlaylist()?.filename ||
this.activePlaylist()?.url ||
this.activePlaylist()?.portalUrl ||
'Untitled playlist'
);
});
readonly availablePlaylistTypes = computed(() => {
const types = new Set<PlaylistFilterType>();
for (const playlist of this.playlists()) {
types.add(this.getPlaylistFilterType(playlist));
}
return types;
});
readonly showTypeFilters = computed(
() => this.availablePlaylistTypes().size > 1
);
readonly hasSearchToggle = computed(
() =>
this.playlists().length <=
PlaylistSwitcherComponent.SEARCH_AUTO_VISIBLE_THRESHOLD
);
readonly showSearchField = computed(
() => !this.hasSearchToggle() || this.searchExpanded()
);
readonly portalStatuses = signal<Map<string, PortalStatus>>(new Map());
readonly currentLocale = computed(() => {
this.languageTick();
return normalizeDateLocale(
this.translate.currentLang || this.translate.defaultLang
);
});
constructor() {
this.destroyRef.onDestroy(() => {
this.clearMenuOverlayWidth();
this.clearSearchFocusTimeout();
});
}
onMenuOpened(): void {
this.isMenuOpen.set(true);
this.syncMenuOverlayWidthToTrigger();
if (this.showSearchField()) {
this.focusSearchField();
}
void this.checkPortalStatuses(this.playlists());
}
onMenuClosed(): void {
this.isMenuOpen.set(false);
this.clearMenuOverlayWidth();
this.clearSearchFocusTimeout();
if (this.hasSearchToggle()) {
this.searchExpanded.set(false);
}
}
toggleSearchField(event?: Event): void {
event?.stopPropagation();
const next = !this.searchExpanded();
this.searchExpanded.set(next);
if (next) {
this.focusSearchField();
}
}
setSearchQuery(value: string): void {
this.searchQuery.set(value);
this.writePersistedSearchQuery(value);
}
togglePlaylistTypeFilter(type: PlaylistFilterType, event?: Event): void {
event?.stopPropagation();
const current = this.playlistTypeFilters();
const next = {
...current,
[type]: !current[type],
};
if (!next.m3u && !next.stalker && !next.xtream) {
return;
}
this.playlistTypeFilters.set(next);
this.writePersistedTypeFilters(next);
}
isTypeFilterSelected(type: PlaylistFilterType): boolean {
return this.playlistTypeFilters()[type];
}
selectPlaylist(playlist: PlaylistMeta): void {
this.menuTrigger().closeMenu();
this.playlistContext.selectPlaylist(playlist);
this.playlistSelected.emit(playlist._id);
}
requestPlaylistInfo(): void {
this.menuTrigger().closeMenu();
this.playlistInfoRequested.emit();
}
requestAccountInfo(): void {
this.menuTrigger().closeMenu();
this.accountInfoRequested.emit();
}
requestAddPlaylist(): void {
this.menuTrigger().closeMenu();
this.addPlaylistRequested.emit();
}
hasType(type: PlaylistFilterType): boolean {
return this.availablePlaylistTypes().has(type);
}
canRefreshPlaylist(playlist: PlaylistMeta): boolean {
return this.refreshAction.canRefresh(playlist);
}
openPlaylistInfoFor(playlist: PlaylistMeta, event?: Event): void {
event?.stopPropagation();
this.menuTrigger().closeMenu();
this.dialog.open(PlaylistInfoComponent, { data: playlist });
}
refreshPlaylistFor(playlist: PlaylistMeta, event?: Event): void {
event?.stopPropagation();
this.menuTrigger().closeMenu();
this.refreshAction.refresh(playlist);
}
removePlaylistFor(playlist: PlaylistMeta, event?: Event): void {
event?.stopPropagation();
this.menuTrigger().closeMenu();
this.dialogService.openConfirmDialog({
title: this.translate.instant('HOME.PLAYLISTS.REMOVE_DIALOG.TITLE'),
message: this.translate.instant(
'HOME.PLAYLISTS.REMOVE_DIALOG.MESSAGE'
),
onConfirm: () => this.removePlaylistConfirmed(playlist),
});
}
private async removePlaylistConfirmed(
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 }
);
}
getPlaylistIcon(playlist: PlaylistMeta): string {
if (playlist.macAddress) {
return 'dashboard';
}
if (playlist.serverUrl) {
return 'public';
}
if (playlist.url) {
return 'cloud';
}
return 'folder';
}
getPlaylistTypeLabel(playlist: PlaylistMeta): string {
if (playlist.macAddress) {
return 'Stalker Portal';
}
if (playlist.serverUrl) {
return 'Xtream Code';
}
return `${playlist.count} channels`;
}
getPlaylistMetaLabel(playlist: PlaylistMeta): string {
const count = playlist.count ?? 0;
const channelsLabel = this.translate.instant(
'HOME.PLAYLISTS.CHANNELS_COUNT',
{ count }
);
const fallback = `${count} channels`;
const countLabel =
channelsLabel && channelsLabel !== 'HOME.PLAYLISTS.CHANNELS_COUNT'
? channelsLabel
: fallback;
if (playlist.macAddress) {
return 'Stalker Portal';
}
if (playlist.serverUrl) {
return 'Xtream Code';
}
return countLabel;
}
getStatusClass(playlistId: string): string {
const status = this.portalStatuses().get(playlistId);
return this.portalStatusService.getStatusClass(status || 'unavailable');
}
isSelected(playlist: PlaylistMeta): boolean {
return this.activePlaylistId() === playlist._id;
}
private getPlaylistFilterType(playlist: PlaylistMeta): PlaylistFilterType {
if (playlist.macAddress) {
return 'stalker';
}
if (playlist.serverUrl) {
return 'xtream';
}
return 'm3u';
}
private async checkPortalStatuses(playlists: PlaylistMeta[]) {
const statusPromises = playlists
.filter(
(playlist) =>
playlist.serverUrl && playlist.username && playlist.password
)
.map(async (playlist) => {
try {
const status =
await this.portalStatusService.checkPortalStatus(
playlist.serverUrl,
playlist.username,
playlist.password
);
return { id: playlist._id, status };
} catch {
return {
id: playlist._id,
status: 'unavailable' as PortalStatus,
};
}
});
const results = await Promise.all(statusPromises);
const statusMap = new Map(
results.map((result) => [result.id, result.status])
);
this.portalStatuses.set(statusMap);
}
private syncMenuOverlayWidthToTrigger(): void {
const triggerWidth = Math.round(
this.triggerElement().nativeElement.getBoundingClientRect().width
);
const overlayWidth = Math.max(triggerWidth, 400);
this.document.documentElement.style.setProperty(
'--playlist-switcher-overlay-width',
`${overlayWidth}px`
);
}
private clearMenuOverlayWidth(): void {
this.document.documentElement.style.removeProperty(
'--playlist-switcher-overlay-width'
);
}
private focusSearchField(): void {
this.clearSearchFocusTimeout();
this.focusSearchTimeoutId = setTimeout(() => {
const input = this.searchInput()?.nativeElement;
if (!input) {
return;
}
input.focus();
input.select();
this.focusSearchTimeoutId = null;
}, 0);
}
private clearSearchFocusTimeout(): void {
if (this.focusSearchTimeoutId !== null) {
clearTimeout(this.focusSearchTimeoutId);
this.focusSearchTimeoutId = null;
}
}
private readPersistedSearchQuery(): string {
try {
return localStorage.getItem(SEARCH_QUERY_STORAGE_KEY) ?? '';
} catch {
return '';
}
}
private writePersistedSearchQuery(value: string): void {
try {
localStorage.setItem(SEARCH_QUERY_STORAGE_KEY, value);
} catch {
// Ignore storage write failures.
}
}
private readPersistedTypeFilters(): Record<PlaylistFilterType, boolean> {
try {
const raw = localStorage.getItem(PLAYLIST_TYPE_FILTER_STORAGE_KEY);
if (!raw) {
return { ...DEFAULT_PLAYLIST_TYPE_FILTERS };
}
const parsed = JSON.parse(raw) as Partial<
Record<PlaylistFilterType, boolean>
>;
const next = {
m3u: parsed.m3u !== false,
stalker: parsed.stalker !== false,
xtream: parsed.xtream !== false,
};
if (!next.m3u && !next.stalker && !next.xtream) {
return { ...DEFAULT_PLAYLIST_TYPE_FILTERS };
}
return next;
} catch {
return { ...DEFAULT_PLAYLIST_TYPE_FILTERS };
}
}
private writePersistedTypeFilters(
filters: Record<PlaylistFilterType, boolean>
): void {
try {
localStorage.setItem(
PLAYLIST_TYPE_FILTER_STORAGE_KEY,
JSON.stringify(filters)
);
} catch {
// Ignore storage write failures.
}
}
}
@@ -0,0 +1,179 @@
@switch (type()) {
@case ('welcome-dashboard') {
<div class="empty-state welcome-dashboard">
<div class="welcome-dashboard__hero">
<div class="welcome-dashboard__illustration-container">
<svg class="welcome-dashboard__illustration" viewBox="0 0 512 512" xmlns="http://www.w3.org/2000/svg">
<rect x="40" y="40" width="432" height="432" rx="80" class="icon-background"/>
<rect x="134" y="387" width="244" height="18" rx="4" class="monitor-base"/>
<rect x="238" y="347" width="36" height="40" rx="4" class="monitor-stand"/>
<rect x="88" y="102" width="336" height="245" rx="12" stroke="currentColor" stroke-width="12" fill="none" class="screen-border"/>
<circle cx="256" cy="280" r="16" class="wifi-dot"/>
<path d="M 220 250 Q 256 220, 292 250" stroke="currentColor" stroke-width="14" fill="none" stroke-linecap="round" class="wifi-arc arc-1"/>
<path d="M 200 230 Q 256 185, 312 230" stroke="currentColor" stroke-width="14" fill="none" stroke-linecap="round" class="wifi-arc arc-2"/>
<path d="M 180 210 Q 256 150, 332 210" stroke="currentColor" stroke-width="14" fill="none" stroke-linecap="round" class="wifi-arc arc-3"/>
</svg>
</div>
@if (productBranding?.xtreamOnlyUi) {
<h2 class="welcome-dashboard__title">
{{ 'HOME.KIRATV.WELCOME_TITLE' | translate }}
</h2>
<p class="welcome-dashboard__tagline">
{{ 'HOME.KIRATV.WELCOME_TAGLINE' | translate }}
</p>
<p class="welcome-dashboard__description">
{{ 'HOME.KIRATV.WELCOME_DESCRIPTION' | translate }}
</p>
} @else {
<h2 class="welcome-dashboard__title">
{{ 'HOME.PLAYLISTS.WELCOME_TITLE' | translate }}
</h2>
<p class="welcome-dashboard__tagline">
{{ 'HOME.PLAYLISTS.DASHBOARD_WELCOME_TAGLINE' | translate }}
</p>
<p class="welcome-dashboard__description">
{{ 'HOME.PLAYLISTS.DASHBOARD_WELCOME_DESCRIPTION' | translate }}
</p>
}
<button
mat-raised-button
type="button"
class="welcome-dashboard__cta"
(click)="onAddPlaylist()"
>
<mat-icon>add_circle</mat-icon>
@if (productBranding?.xtreamOnlyUi) {
{{ 'HOME.KIRATV.ADD_FIRST_CTA' | translate }}
} @else {
{{ 'HOME.PLAYLISTS.ADD_YOUR_FIRST' | translate }}
}
</button>
</div>
<ul class="welcome-dashboard__cards" role="list">
@for (card of featureCards; track card.titleKey) {
@if (isFeatureCardVisible(card)) {
<li class="feature-card">
<div class="feature-card__icon-wrap">
<mat-icon class="feature-card__icon">{{ card.icon }}</mat-icon>
</div>
<h3 class="feature-card__title">{{ card.titleKey | translate }}</h3>
<p class="feature-card__desc">{{ card.descKey | translate }}</p>
</li>
}
}
</ul>
</div>
}
@case ('welcome-sources') {
<div class="empty-state welcome-sources">
<header class="welcome-sources__header">
<div class="welcome-sources__icon-wrap">
<mat-icon class="welcome-sources__icon">playlist_add</mat-icon>
</div>
@if (productBranding?.xtreamOnlyUi) {
<h2 class="welcome-sources__title">
{{ 'HOME.KIRATV.WELCOME_TITLE' | translate }}
</h2>
<p class="welcome-sources__body">
{{ 'HOME.KIRATV.WELCOME_DESCRIPTION' | translate }}
</p>
} @else {
<h2 class="welcome-sources__title">
{{ 'HOME.PLAYLISTS.SOURCES_WELCOME_TITLE' | translate }}
</h2>
<p class="welcome-sources__body">
{{ 'HOME.PLAYLISTS.SOURCES_WELCOME_BODY' | translate }}
</p>
}
</header>
<div class="welcome-sources__grid">
@for (card of visibleSourceCards(); track card.type) {
<article class="source-card">
<div class="source-card__head">
<mat-icon class="source-card__icon">{{ card.icon }}</mat-icon>
<h3 class="source-card__name">{{ card.nameKey | translate }}</h3>
</div>
<p class="source-card__needs">{{ card.needsKey | translate }}</p>
<ul class="source-card__chips" role="list">
@for (chip of card.contentKeys; track chip) {
<li class="source-card__chip">{{ chip | translate }}</li>
}
</ul>
<button
mat-raised-button
type="button"
class="source-card__cta"
(click)="onAddPlaylist(card.type)"
>
<mat-icon>add</mat-icon>
{{ card.addLabelKey | translate }}
</button>
</article>
}
</div>
</div>
}
@case ('no-results') {
<div class="empty-state no-results-state">
<div class="illustration-container small">
<svg class="illustration" viewBox="0 0 160 160" fill="none" xmlns="http://www.w3.org/2000/svg">
<circle cx="65" cy="65" r="35" class="search-circle" stroke-width="4"/>
<line x1="90" y1="90" x2="115" y2="115" class="search-handle" stroke-width="4" stroke-linecap="round"/>
<rect x="45" y="50" width="40" height="30" rx="3" class="empty-box" stroke-width="2" opacity="0.4"/>
<line x1="55" y1="65" x2="75" y2="65" class="empty-line" stroke-width="2" opacity="0.3"/>
</svg>
</div>
<div class="content">
<h3 class="title small">{{ 'HOME.PLAYLISTS.NO_RESULTS_TITLE' | translate }}</h3>
<p class="description">{{ 'HOME.PLAYLISTS.NO_RESULTS_DESCRIPTION' | translate }}</p>
</div>
</div>
}
@case ('no-data') {
<div class="empty-state no-data-state">
<div class="empty-icon-container">
<mat-icon class="empty-icon">{{ icon() }}</mat-icon>
</div>
<div class="content">
@if (titleKey()) {
<h3 class="title small">{{ titleKey() | translate }}</h3>
}
@if (descriptionKey()) {
<p class="description">{{ descriptionKey() | translate }}</p>
}
@if (primaryActionLabelKey() || secondaryActionLabelKey()) {
<div class="actions">
@if (primaryActionLabelKey(); as primaryLabel) {
<button
mat-raised-button
type="button"
class="primary-button"
(click)="onPrimaryAction()"
>
<mat-icon>add_circle</mat-icon>
{{ primaryLabel | translate }}
</button>
}
@if (secondaryActionLabelKey(); as secondaryLabel) {
<button
mat-button
type="button"
class="secondary-button"
(click)="onSecondaryAction()"
>
{{ secondaryLabel | translate }}
</button>
}
</div>
}
</div>
</div>
}
}
@@ -0,0 +1,176 @@
:host {
display: block;
width: 100%;
height: 100%;
}
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
text-align: center;
animation: fadeIn 0.6s ease-out;
.content {
max-width: 480px;
animation: slideUp 0.8s ease-out;
}
.title {
font-family: Roboto, 'Helvetica Neue', sans-serif;
font-weight: 500;
margin: 0 0 12px 0;
background: linear-gradient(135deg, #60a5fa 0%, #3b82f6 50%, #2563eb 100%);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
&.small {
font-size: 1.5rem;
}
}
.description {
font-family: Roboto, 'Helvetica Neue', sans-serif;
font-size: 0.95rem;
line-height: 1.6;
margin: 0 0 32px 0;
opacity: 0.7;
}
}
.no-results-state {
padding: 40px 24px;
.illustration-container {
margin-bottom: 24px;
opacity: 0.5;
&.small {
animation: gentleFloat 1s ease-out;
}
}
.illustration {
width: 120px;
height: 120px;
.search-circle,
.search-handle {
stroke: currentColor;
opacity: 0.4;
}
.empty-box,
.empty-line {
stroke: currentColor;
}
}
.description {
margin-bottom: 0;
font-size: 0.875rem;
}
}
.no-data-state {
padding: 56px 24px;
.empty-icon-container {
margin-bottom: 20px;
animation: gentleFloat 0.6s ease-out;
}
.empty-icon {
font-size: 72px;
width: 72px;
height: 72px;
color: #60a5fa;
opacity: 0.85;
}
.content {
max-width: 460px;
}
.title {
font-size: 1.5rem;
margin-bottom: 8px;
}
.description {
margin-bottom: 24px;
}
.actions {
display: flex;
gap: 12px;
justify-content: center;
flex-wrap: wrap;
.primary-button {
background: linear-gradient(135deg, #3b82f6 0%, #2563eb 100%);
color: white;
font-weight: 500;
padding: 10px 24px;
border-radius: 10px;
text-transform: none;
letter-spacing: 0.01em;
box-shadow: 0 4px 14px rgba(59, 130, 246, 0.25);
transition: all 0.25s ease;
mat-icon {
margin-right: 6px;
}
&:hover {
transform: translateY(-1px);
box-shadow: 0 6px 18px rgba(59, 130, 246, 0.35);
}
}
.secondary-button {
color: #60a5fa;
font-weight: 500;
padding: 10px 18px;
text-transform: none;
}
}
}
@keyframes fadeIn {
from { opacity: 0; }
to { opacity: 1; }
}
@keyframes slideUp {
from {
opacity: 0;
transform: translateY(20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes gentleFloat {
from {
opacity: 0;
transform: scale(0.9);
}
to {
opacity: 0.85;
transform: scale(1);
}
}
@keyframes gridMove {
0% {
transform: translate(0, 0) rotate(0deg);
}
100% {
transform: translate(40px, 40px) rotate(0.5deg);
}
}
@@ -0,0 +1,152 @@
import { Component, inject, input, output } from '@angular/core';
import { MatButtonModule } from '@angular/material/button';
import { MatIcon } from '@angular/material/icon';
import { TranslatePipe } from '@ngx-translate/core';
import type { WorkspacePlaylistType } from '@iptvnator/workspace/shell/util';
import { WORKSPACE_PRODUCT_BRANDING } from 'services';
export type EmptyStateType =
| 'welcome-dashboard'
| 'welcome-sources'
| 'no-results'
| 'no-data';
interface FeatureCard {
icon: string;
titleKey: string;
descKey: string;
electronOnly?: boolean;
}
interface SourceCard {
type: WorkspacePlaylistType;
icon: string;
nameKey: string;
needsKey: string;
addLabelKey: string;
contentKeys: readonly string[];
}
const FEATURE_CARDS: readonly FeatureCard[] = [
{
icon: 'live_tv',
titleKey: 'HOME.PLAYLISTS.FEATURE_LIVE_TITLE',
descKey: 'HOME.PLAYLISTS.FEATURE_LIVE_DESC',
},
{
icon: 'movie',
titleKey: 'HOME.PLAYLISTS.FEATURE_VOD_TITLE',
descKey: 'HOME.PLAYLISTS.FEATURE_VOD_DESC',
},
{
icon: 'event_note',
titleKey: 'HOME.PLAYLISTS.FEATURE_EPG_TITLE',
descKey: 'HOME.PLAYLISTS.FEATURE_EPG_DESC',
},
{
icon: 'download_for_offline',
titleKey: 'HOME.PLAYLISTS.FEATURE_OFFLINE_TITLE',
descKey: 'HOME.PLAYLISTS.FEATURE_OFFLINE_DESC',
electronOnly: true,
},
];
const SOURCE_CARDS: readonly SourceCard[] = [
{
type: 'url',
icon: 'folder_open',
nameKey: 'HOME.PLAYLISTS.FEATURE_M3U',
needsKey: 'HOME.PLAYLISTS.SOURCE_M3U_NEEDS',
addLabelKey: 'HOME.PLAYLISTS.SOURCE_M3U_ADD',
contentKeys: [
'HOME.PLAYLISTS.CONTENT_LIVE',
'HOME.PLAYLISTS.CONTENT_EPG',
],
},
{
type: 'xtream',
icon: 'cloud',
nameKey: 'HOME.PLAYLISTS.FEATURE_XTREAM',
needsKey: 'HOME.PLAYLISTS.SOURCE_XTREAM_NEEDS',
addLabelKey: 'HOME.PLAYLISTS.SOURCE_XTREAM_ADD',
contentKeys: [
'HOME.PLAYLISTS.CONTENT_LIVE',
'HOME.PLAYLISTS.CONTENT_VOD',
'HOME.PLAYLISTS.CONTENT_SERIES',
'HOME.PLAYLISTS.CONTENT_EPG',
],
},
{
type: 'stalker',
icon: 'cast',
nameKey: 'HOME.PLAYLISTS.FEATURE_STALKER',
needsKey: 'HOME.PLAYLISTS.SOURCE_STALKER_NEEDS',
addLabelKey: 'HOME.PLAYLISTS.SOURCE_STALKER_ADD',
contentKeys: [
'HOME.PLAYLISTS.CONTENT_LIVE',
'HOME.PLAYLISTS.CONTENT_VOD',
'HOME.PLAYLISTS.CONTENT_SERIES',
'HOME.PLAYLISTS.CONTENT_EPG',
],
},
];
@Component({
selector: 'app-empty-state',
templateUrl: './empty-state.component.html',
styleUrls: [
'./empty-state.component.scss',
'./empty-state.welcome-dashboard.scss',
'./empty-state.welcome-sources.scss',
'./empty-state.responsive.scss',
'./empty-state.themes.scss',
],
imports: [MatButtonModule, MatIcon, TranslatePipe],
})
export class EmptyStateComponent {
readonly productBranding = inject(WORKSPACE_PRODUCT_BRANDING, {
optional: true,
});
readonly type = input.required<EmptyStateType>();
readonly icon = input<string>('inbox');
readonly titleKey = input<string>('');
readonly descriptionKey = input<string>('');
readonly primaryActionLabelKey = input<string | null>(null);
readonly secondaryActionLabelKey = input<string | null>(null);
readonly showElectronOnlyValueProps = input<boolean>(false);
readonly addPlaylistClicked = output<WorkspacePlaylistType | undefined>();
readonly primaryActionClicked = output<void>();
readonly secondaryActionClicked = output<void>();
readonly featureCards = FEATURE_CARDS;
visibleSourceCards(): readonly SourceCard[] {
if (this.productBranding?.xtreamOnlyUi) {
return SOURCE_CARDS.filter((c) => c.type === 'xtream');
}
return SOURCE_CARDS;
}
onAddPlaylist(type?: WorkspacePlaylistType): void {
const resolved =
type ??
(this.productBranding?.xtreamOnlyUi ? 'xtream' : undefined);
this.addPlaylistClicked.emit(resolved);
}
onPrimaryAction(): void {
this.primaryActionClicked.emit();
}
onSecondaryAction(): void {
this.secondaryActionClicked.emit();
}
isFeatureCardVisible(card: FeatureCard): boolean {
return !card.electronOnly || this.showElectronOnlyValueProps();
}
}
@@ -0,0 +1,41 @@
@media (max-width: 480px) {
.no-results-state {
padding: 30px 20px;
.illustration {
width: 100px;
height: 100px;
}
}
.no-data-state {
padding: 40px 20px;
.actions {
flex-direction: column;
gap: 8px;
}
}
}
@media (max-height: 700px) {
.no-results-state {
padding: 24px 20px;
.illustration-container { margin-bottom: 16px; }
}
.no-data-state {
padding: 32px 24px;
.empty-icon { font-size: 56px; width: 56px; height: 56px; }
}
}
@media (max-height: 550px) {
.no-results-state {
padding: 16px 20px;
.illustration-container { margin-bottom: 12px; }
.illustration { width: 80px; height: 80px; }
.title { font-size: 1.125rem; }
.description { font-size: 0.8125rem; }
}
}
@@ -0,0 +1,38 @@
@media (prefers-color-scheme: light) {
.empty-state {
.title {
background: linear-gradient(135deg, #2563eb 0%, #1d4ed8 50%, #1e40af 100%);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
.description {
opacity: 0.8;
color: #64748b;
}
}
.no-results-state {
.illustration {
opacity: 0.3;
.search-circle,
.search-handle,
.empty-box,
.empty-line {
stroke: #64748b;
}
}
}
.no-data-state {
.empty-icon {
color: #2563eb;
}
.secondary-button {
color: #2563eb;
}
}
}
@@ -0,0 +1,422 @@
.welcome-dashboard {
position: relative;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 100%;
width: 100%;
box-sizing: border-box;
padding: clamp(16px, 3vh, 36px) clamp(12px, 3vw, 32px);
overflow: hidden;
text-align: center;
gap: clamp(16px, 3vh, 32px);
animation: fadeIn 0.5s ease-out;
min-width: 0;
min-height: 0;
&::before {
content: '';
position: absolute;
inset: -50%;
background-image:
repeating-linear-gradient(
0deg,
transparent,
transparent 39px,
rgba(59, 130, 246, 0.03) 39px,
rgba(59, 130, 246, 0.03) 41px
),
repeating-linear-gradient(
90deg,
transparent,
transparent 39px,
rgba(59, 130, 246, 0.03) 39px,
rgba(59, 130, 246, 0.03) 41px
),
repeating-linear-gradient(
45deg,
transparent,
transparent 79px,
rgba(59, 130, 246, 0.015) 79px,
rgba(59, 130, 246, 0.015) 81px
);
mask-image: linear-gradient(
to bottom,
rgba(0, 0, 0, 1) 0%,
rgba(0, 0, 0, 0.7) 40%,
rgba(0, 0, 0, 0.3) 70%,
transparent 100%
);
-webkit-mask-image: linear-gradient(
to bottom,
rgba(0, 0, 0, 1) 0%,
rgba(0, 0, 0, 0.7) 40%,
rgba(0, 0, 0, 0.3) 70%,
transparent 100%
);
animation: gridMove 60s linear infinite;
pointer-events: none;
z-index: 0;
}
> * {
position: relative;
z-index: 1;
}
.welcome-dashboard__hero {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 10px;
width: 100%;
max-width: 640px;
min-width: 0;
animation: slideUp 0.6s ease-out;
}
.welcome-dashboard__illustration-container {
position: relative;
margin-bottom: 4px;
}
.welcome-dashboard__illustration {
width: clamp(96px, 16vh, 160px);
height: clamp(96px, 16vh, 160px);
filter: drop-shadow(0 10px 30px rgba(59, 130, 246, 0.2));
color: #60a5fa;
display: block;
.icon-background {
fill: #3b82f6;
opacity: 0.15;
}
.monitor-base,
.monitor-stand {
fill: #3b82f6;
}
.screen-border {
stroke: #3b82f6;
}
.wifi-dot {
fill: #60a5fa;
}
.wifi-arc {
stroke: #60a5fa;
opacity: 0.7;
&.arc-1 { opacity: 0.9; }
&.arc-2 { opacity: 0.7; }
&.arc-3 { opacity: 0.5; }
}
}
.welcome-dashboard__title {
font-family: Roboto, 'Helvetica Neue', sans-serif;
font-weight: 600;
font-size: clamp(1.4rem, 3.4vh, 2.1rem);
margin: 0;
background: linear-gradient(135deg, #60a5fa 0%, #3b82f6 50%, #2563eb 100%);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
letter-spacing: -0.02em;
}
.welcome-dashboard__tagline {
font-family: Roboto, 'Helvetica Neue', sans-serif;
font-size: clamp(0.95rem, 1.9vh, 1.05rem);
font-weight: 500;
opacity: 0.85;
margin: 0;
line-height: 1.45;
}
.welcome-dashboard__description {
font-family: Roboto, 'Helvetica Neue', sans-serif;
font-size: clamp(0.8125rem, 1.7vh, 0.9375rem);
opacity: 0.65;
margin: 0;
line-height: 1.5;
max-width: 480px;
}
.welcome-dashboard__cta {
margin-top: 8px;
background: linear-gradient(135deg, #3b82f6 0%, #2563eb 100%);
color: white;
font-weight: 500;
font-size: clamp(0.875rem, 1.8vh, 1rem);
padding: 10px 28px;
border-radius: 12px;
box-shadow: 0 4px 16px rgba(59, 130, 246, 0.3);
transition:
transform 0.2s ease,
box-shadow 0.2s ease;
text-transform: none;
letter-spacing: 0.01em;
mat-icon {
margin-right: 8px;
}
&:hover {
transform: translateY(-2px);
box-shadow: 0 6px 24px rgba(59, 130, 246, 0.4);
}
&:active {
transform: translateY(0);
}
}
.welcome-dashboard__cards {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(min(140px, 100%), 1fr));
gap: clamp(10px, 1.6vw, 18px);
width: 100%;
max-width: 820px;
margin: 0 auto;
padding: 8px 4px 4px;
list-style: none;
justify-items: stretch;
align-items: stretch;
animation: slideUp 0.7s ease-out 0.1s backwards;
min-width: 0;
}
.feature-card {
display: flex;
flex-direction: column;
align-items: center;
text-align: center;
gap: 8px;
padding: clamp(12px, 2vh, 18px) clamp(10px, 1.5vw, 14px);
background: linear-gradient(
135deg,
rgba(96, 165, 250, 0.08),
rgba(37, 99, 235, 0.04)
);
border: 1px solid rgba(96, 165, 250, 0.18);
border-radius: 14px;
backdrop-filter: blur(8px);
transform: rotate(-2deg);
transform-origin: center;
transition:
transform 0.25s ease,
box-shadow 0.25s ease,
border-color 0.25s ease,
background 0.25s ease;
cursor: default;
min-width: 0;
&:nth-child(2) { transform: rotate(1.5deg); }
&:nth-child(3) { transform: rotate(-1deg); }
&:nth-child(4) { transform: rotate(2deg); }
&:hover {
transform: rotate(0deg) translateY(-4px) scale(1.04);
box-shadow: 0 10px 24px rgba(59, 130, 246, 0.22);
border-color: rgba(96, 165, 250, 0.4);
background: linear-gradient(
135deg,
rgba(96, 165, 250, 0.16),
rgba(37, 99, 235, 0.1)
);
.feature-card__icon-wrap {
transform: scale(1.08);
box-shadow: 0 6px 18px rgba(59, 130, 246, 0.4);
}
}
}
.feature-card__icon-wrap {
display: flex;
align-items: center;
justify-content: center;
width: 40px;
height: 40px;
border-radius: 11px;
background: linear-gradient(135deg, #3b82f6, #2563eb);
box-shadow: 0 4px 12px rgba(59, 130, 246, 0.3);
transition:
transform 0.25s ease,
box-shadow 0.25s ease;
flex-shrink: 0;
}
.feature-card__icon {
font-size: 22px;
width: 22px;
height: 22px;
color: white;
}
.feature-card__title {
font-family: Roboto, 'Helvetica Neue', sans-serif;
font-size: 0.9rem;
font-weight: 600;
margin: 0;
line-height: 1.25;
word-break: break-word;
}
.feature-card__desc {
font-family: Roboto, 'Helvetica Neue', sans-serif;
font-size: 0.78125rem;
opacity: 0.7;
margin: 0;
line-height: 1.35;
word-break: break-word;
}
}
@media (max-width: 640px) {
.welcome-dashboard {
gap: 16px;
.welcome-dashboard__cards {
grid-template-columns: repeat(2, minmax(0, 1fr));
max-width: 420px;
}
}
}
@media (max-width: 380px) {
.welcome-dashboard .welcome-dashboard__cards {
grid-template-columns: 1fr;
}
}
@media (max-height: 620px) {
.welcome-dashboard {
gap: 12px;
.welcome-dashboard__description {
display: none;
}
.welcome-dashboard__hero {
gap: 6px;
}
}
}
@media (prefers-reduced-motion: reduce) {
.welcome-dashboard {
animation: none;
.welcome-dashboard__hero,
.welcome-dashboard__cards {
animation: none;
}
.feature-card,
.feature-card:nth-child(n) {
transform: none;
&:hover {
transform: translateY(-2px);
}
}
.welcome-dashboard__cta:hover {
transform: none;
}
}
}
@media (prefers-color-scheme: light) {
.welcome-dashboard {
&::before {
background-image:
repeating-linear-gradient(
0deg,
transparent,
transparent 39px,
rgba(37, 99, 235, 0.04) 39px,
rgba(37, 99, 235, 0.04) 41px
),
repeating-linear-gradient(
90deg,
transparent,
transparent 39px,
rgba(37, 99, 235, 0.04) 39px,
rgba(37, 99, 235, 0.04) 41px
),
repeating-linear-gradient(
45deg,
transparent,
transparent 79px,
rgba(37, 99, 235, 0.02) 79px,
rgba(37, 99, 235, 0.02) 81px
);
}
.welcome-dashboard__title {
background: linear-gradient(135deg, #2563eb 0%, #1d4ed8 50%, #1e40af 100%);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
.welcome-dashboard__tagline {
color: #1e3a8a;
opacity: 0.85;
}
.welcome-dashboard__description {
color: #64748b;
opacity: 0.85;
}
.welcome-dashboard__illustration {
filter: drop-shadow(0 10px 30px rgba(37, 99, 235, 0.15));
color: #3b82f6;
.icon-background {
fill: #2563eb;
opacity: 0.12;
}
.monitor-base,
.monitor-stand {
fill: #2563eb;
}
.screen-border {
stroke: #2563eb;
}
.wifi-dot {
fill: #3b82f6;
}
.wifi-arc {
stroke: #3b82f6;
}
}
.feature-card {
background: linear-gradient(135deg, rgba(37, 99, 235, 0.06), rgba(29, 78, 216, 0.03));
border-color: rgba(37, 99, 235, 0.15);
&:hover {
background: linear-gradient(135deg, rgba(37, 99, 235, 0.12), rgba(29, 78, 216, 0.06));
border-color: rgba(37, 99, 235, 0.3);
}
}
.feature-card__desc {
color: #475569;
opacity: 0.85;
}
}
}
@@ -0,0 +1,259 @@
.welcome-sources {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 100%;
width: 100%;
box-sizing: border-box;
padding: clamp(16px, 3vh, 36px) clamp(12px, 3vw, 32px);
overflow: hidden;
gap: clamp(16px, 3vh, 28px);
animation: fadeIn 0.5s ease-out;
min-width: 0;
min-height: 0;
.welcome-sources__header {
display: flex;
flex-direction: column;
align-items: center;
text-align: center;
gap: 8px;
max-width: 540px;
width: 100%;
animation: slideUp 0.6s ease-out;
}
.welcome-sources__icon-wrap {
display: flex;
align-items: center;
justify-content: center;
margin-bottom: 12px;
animation: gentleFloat 0.6s ease-out;
}
.welcome-sources__icon {
font-size: 72px;
width: 72px;
height: 72px;
color: #60a5fa;
opacity: 0.85;
}
.welcome-sources__title {
font-family: Roboto, 'Helvetica Neue', sans-serif;
font-weight: 500;
font-size: 1.5rem;
margin: 0 0 8px 0;
background: linear-gradient(135deg, #60a5fa 0%, #3b82f6 50%, #2563eb 100%);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
.welcome-sources__body {
font-family: Roboto, 'Helvetica Neue', sans-serif;
font-size: 0.95rem;
line-height: 1.6;
opacity: 0.7;
margin: 0;
}
.welcome-sources__grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: clamp(10px, 1.6vw, 18px);
width: 100%;
max-width: 980px;
margin: 0 auto;
animation: slideUp 0.7s ease-out 0.1s backwards;
min-width: 0;
}
.source-card {
display: flex;
flex-direction: column;
gap: 10px;
padding: clamp(14px, 2.2vh, 22px);
background: linear-gradient(
145deg,
rgba(96, 165, 250, 0.08),
rgba(37, 99, 235, 0.04)
);
border: 1px solid rgba(96, 165, 250, 0.2);
border-radius: 16px;
transition:
transform 0.2s ease,
box-shadow 0.2s ease,
border-color 0.2s ease;
min-width: 0;
&:hover {
transform: translateY(-3px);
box-shadow: 0 12px 28px rgba(59, 130, 246, 0.22);
border-color: rgba(96, 165, 250, 0.4);
}
}
.source-card__head {
display: flex;
align-items: center;
gap: 10px;
min-width: 0;
}
.source-card__icon {
font-size: 28px;
width: 28px;
height: 28px;
color: #60a5fa;
flex-shrink: 0;
}
.source-card__name {
font-family: Roboto, 'Helvetica Neue', sans-serif;
font-size: 1.05rem;
font-weight: 600;
margin: 0;
line-height: 1.2;
word-break: break-word;
}
.source-card__needs {
font-family: Roboto, 'Helvetica Neue', sans-serif;
font-size: 0.8125rem;
opacity: 0.75;
margin: 0;
line-height: 1.45;
min-height: 2.5em;
word-break: break-word;
}
.source-card__chips {
display: flex;
flex-wrap: wrap;
gap: 6px;
margin: 0;
padding: 0;
list-style: none;
}
.source-card__chip {
font-family: Roboto, 'Helvetica Neue', sans-serif;
font-size: 0.7rem;
font-weight: 500;
padding: 3px 9px;
border-radius: 999px;
background: rgba(96, 165, 250, 0.14);
color: #93c5fd;
border: 1px solid rgba(96, 165, 250, 0.22);
text-transform: uppercase;
letter-spacing: 0.04em;
}
.source-card__cta {
margin-top: auto;
align-self: stretch;
background: linear-gradient(135deg, #3b82f6 0%, #2563eb 100%);
color: white;
font-weight: 500;
font-size: 0.875rem;
padding: 8px 18px;
border-radius: 10px;
box-shadow: 0 4px 12px rgba(59, 130, 246, 0.25);
transition:
transform 0.2s ease,
box-shadow 0.2s ease;
text-transform: none;
mat-icon {
margin-right: 6px;
font-size: 18px;
width: 18px;
height: 18px;
}
&:hover {
transform: translateY(-1px);
box-shadow: 0 6px 18px rgba(59, 130, 246, 0.35);
}
}
}
@media (max-width: 760px) {
.welcome-sources .welcome-sources__grid {
grid-template-columns: 1fr;
max-width: 480px;
}
}
@media (max-height: 620px) {
.welcome-sources {
gap: 14px;
.source-card__needs {
min-height: 0;
}
}
}
@media (prefers-reduced-motion: reduce) {
.welcome-sources {
animation: none;
.welcome-sources__header,
.welcome-sources__grid {
animation: none;
}
.source-card:hover,
.source-card__cta:hover {
transform: none;
}
}
}
@media (prefers-color-scheme: light) {
.welcome-sources {
.welcome-sources__icon {
color: #2563eb;
}
.welcome-sources__title {
background: linear-gradient(135deg, #2563eb 0%, #1d4ed8 50%, #1e40af 100%);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
.welcome-sources__body {
color: #64748b;
opacity: 0.85;
}
.source-card {
background: linear-gradient(145deg, rgba(37, 99, 235, 0.06), rgba(29, 78, 216, 0.03));
border-color: rgba(37, 99, 235, 0.15);
&:hover {
background: linear-gradient(145deg, rgba(37, 99, 235, 0.1), rgba(29, 78, 216, 0.05));
border-color: rgba(37, 99, 235, 0.3);
}
}
.source-card__icon {
color: #2563eb;
}
.source-card__needs {
color: #475569;
opacity: 0.9;
}
.source-card__chip {
background: rgba(37, 99, 235, 0.08);
color: #1d4ed8;
border-color: rgba(37, 99, 235, 0.18);
}
}
}
@@ -0,0 +1,193 @@
<form
[formGroup]="playlistDetails"
(ngSubmit)="saveChanges(playlistDetails.value)"
>
<h2 mat-dialog-title>
{{ 'HOME.PLAYLISTS.INFO_DIALOG.PLAYLIST_DETAILS' | translate }}
</h2>
<mat-dialog-content class="mat-typography">
<mat-form-field class="w-full">
<mat-label>{{
'HOME.PLAYLISTS.INFO_DIALOG.TITLE' | translate
}}</mat-label>
<input formControlName="title" matInput />
</mat-form-field>
@if (playlist.portalUrl) {
<mat-form-field class="w-full">
<mat-label>{{
'HOME.PLAYLISTS.INFO_DIALOG.SERVER_URL' | translate
}}</mat-label>
<input matInput formControlName="portalUrl" />
</mat-form-field>
}
@if (playlist.macAddress) {
<mat-form-field class="w-full">
<mat-label>{{
'HOME.PLAYLISTS.INFO_DIALOG.MAC_ADDRESS' | translate
}}</mat-label>
<input matInput formControlName="macAddress" />
</mat-form-field>
}
@if (playlist.portalUrl) {
<mat-form-field class="w-full">
<mat-label>{{
'HOME.STALKER_PORTAL.SERIAL_NUMBER' | translate
}}</mat-label>
<input matInput formControlName="stalkerSerialNumber" />
</mat-form-field>
<mat-form-field class="w-full">
<mat-label>{{
'HOME.STALKER_PORTAL.DEVICE_ID_1' | translate
}}</mat-label>
<input matInput formControlName="stalkerDeviceId1" />
</mat-form-field>
<mat-form-field class="w-full">
<mat-label>{{
'HOME.STALKER_PORTAL.DEVICE_ID_2' | translate
}}</mat-label>
<input matInput formControlName="stalkerDeviceId2" />
</mat-form-field>
<mat-form-field class="w-full">
<mat-label>{{
'HOME.STALKER_PORTAL.SIGNATURE_1' | translate
}}</mat-label>
<input matInput formControlName="stalkerSignature1" />
</mat-form-field>
<mat-form-field class="w-full">
<mat-label>{{
'HOME.STALKER_PORTAL.SIGNATURE_2' | translate
}}</mat-label>
<input matInput formControlName="stalkerSignature2" />
</mat-form-field>
}
@if (playlist.serverUrl) {
<mat-form-field class="w-full">
<mat-label>{{
'HOME.PLAYLISTS.INFO_DIALOG.SERVER_URL' | translate
}}</mat-label>
<input matInput formControlName="serverUrl" />
</mat-form-field>
}
@if (playlist.username) {
<mat-form-field class="w-full">
<mat-label>{{
'HOME.PLAYLISTS.INFO_DIALOG.USERNAME' | translate
}}</mat-label>
<input matInput formControlName="username" />
</mat-form-field>
}
@if (playlist.password) {
<mat-form-field class="w-full">
<mat-label>{{
'HOME.PLAYLISTS.INFO_DIALOG.PASSWORD' | translate
}}</mat-label>
<input matInput formControlName="password" />
</mat-form-field>
}
@if (playlist.url) {
<mat-form-field class="w-full">
<mat-label>{{
'HOME.PLAYLISTS.INFO_DIALOG.FROM_URL' | translate
}}</mat-label>
<input matInput formControlName="url" />
<button
mat-icon-button
matSuffix
type="button"
(click)="copyUrl()"
[matTooltip]="
'HOME.PLAYLISTS.INFO_DIALOG.COPY_URL' | translate
"
>
<mat-icon>content_copy</mat-icon>
</button>
</mat-form-field>
}
@if (playlist.filename) {
<mat-form-field class="w-full">
<mat-label>{{
'HOME.PLAYLISTS.INFO_DIALOG.ORIGINAL_FILENAME' | translate
}}</mat-label>
<input matInput formControlName="filename" />
</mat-form-field>
<mat-form-field class="w-full">
<mat-label>{{
'HOME.PLAYLISTS.INFO_DIALOG.IMPORT_DATE' | translate
}}</mat-label>
<input matInput formControlName="importDate" />
</mat-form-field>
}
@if (playlist.count) {
<mat-form-field class="w-full">
<mat-label>{{
'HOME.PLAYLISTS.INFO_DIALOG.CHANNELS' | translate
}}</mat-label>
<input matInput formControlName="count" />
</mat-form-field>
}
@if (isDesktop) {
<mat-form-field class="w-full mb-10">
<mat-label>{{
'HOME.PLAYLISTS.INFO_DIALOG.USER_AGENT' | translate
}}</mat-label>
<input formControlName="userAgent" matInput />
<mat-hint>{{
'HOME.PLAYLISTS.INFO_DIALOG.CUSTOM_USER_AGENT' | translate
}}</mat-hint>
</mat-form-field>
}
@if (playlist.filePath) {
<mat-form-field class="w-full">
<mat-label>
{{ 'HOME.PLAYLISTS.INFO_DIALOG.FILE_PATH' | translate }}
</mat-label>
<input formControlName="filePath" matInput />
@if (playlist.updateState === 2) {
<mat-hint>
{{
'HOME.PLAYLISTS.INFO_DIALOG.UPDATE_FAILED'
| translate
}}
</mat-hint>
}
</mat-form-field>
}
@if (isDesktop && (playlist.url || playlist.filePath)) {
<mat-checkbox class="w-full" formControlName="autoRefresh">
{{ 'HOME.PLAYLISTS.INFO_DIALOG.AUTO_UPDATE' | translate }}
</mat-checkbox>
<p>
{{
'HOME.PLAYLISTS.INFO_DIALOG.AUTO_UPDATE_DESCRIPTION'
| translate
}}
</p>
}
</mat-dialog-content>
<mat-dialog-actions align="end">
@if (!playlist.serverUrl && !playlist.portalUrl) {
<button
mat-flat-button
color="accent"
type="button"
(click)="exportPlaylist()"
>
<mat-icon>download</mat-icon>
{{ 'HOME.PLAYLISTS.INFO_DIALOG.EXPORT_PLAYLIST' | translate }}
</button>
}
<span class="spacer"></span>
<button mat-button mat-dialog-close>
{{ 'HOME.PLAYLISTS.INFO_DIALOG.CLOSE' | translate }}
</button>
<button
mat-flat-button
mat-dialog-close
color="accent"
type="submit"
[disabled]="!playlistDetails.valid || playlistDetails.pristine"
>
{{ 'HOME.PLAYLISTS.INFO_DIALOG.SAVE' | translate }}
</button>
</mat-dialog-actions>
</form>
@@ -0,0 +1,275 @@
import { Clipboard, ClipboardModule } from '@angular/cdk/clipboard';
import { DatePipe } from '@angular/common';
import { Component, inject } from '@angular/core';
import {
FormControl,
ReactiveFormsModule,
UntypedFormBuilder,
UntypedFormGroup,
Validators,
} from '@angular/forms';
import { MatButton, MatIconButton } from '@angular/material/button';
import { MatCheckboxModule } from '@angular/material/checkbox';
import { MAT_DIALOG_DATA, MatDialogModule } from '@angular/material/dialog';
import { MatIcon } from '@angular/material/icon';
import { MatInputModule } from '@angular/material/input';
import { MatSnackBar } from '@angular/material/snack-bar';
import { MatTooltip } from '@angular/material/tooltip';
import { Store } from '@ngrx/store';
import { TranslatePipe, TranslateService } from '@ngx-translate/core';
import { PlaylistActions } from 'm3u-state';
import { firstValueFrom } from 'rxjs';
import { DatabaseService, PlaylistsService } from 'services';
import { Playlist, PlaylistMeta } from 'shared-interfaces';
@Component({
selector: 'app-playlist-info',
templateUrl: './playlist-info.component.html',
styles: [
`
.spacer {
flex: 1 1 auto;
}
mat-dialog-content {
display: flex;
flex-direction: column;
gap: 14px;
}
// Material's '.mat-mdc-dialog-title + .mat-mdc-dialog-content' rule
// zeroes padding-top with higher specificity than a scoped override,
// which clips the first field's floating label. Pushing the first
// field down with margin-top side-steps that entirely.
mat-dialog-content > mat-form-field:first-child {
margin-top: 10px;
}
mat-dialog-content mat-checkbox {
margin-top: 4px;
}
mat-dialog-content p {
margin: 0;
color: var(--app-muted-color);
font-size: 12.5px;
line-height: 1.45;
}
`,
],
providers: [DatePipe],
imports: [
ClipboardModule,
MatButton,
MatCheckboxModule,
MatDialogModule,
MatIcon,
MatIconButton,
MatInputModule,
MatTooltip,
ReactiveFormsModule,
TranslatePipe,
],
})
export class PlaylistInfoComponent {
private clipboard = inject(Clipboard);
private datePipe = inject(DatePipe);
private formBuilder = inject(UntypedFormBuilder);
private playlistsService = inject(PlaylistsService);
private store = inject(Store);
private databaseService = inject(DatabaseService);
private snackBar = inject(MatSnackBar);
private translate = inject(TranslateService);
public playlistData = inject<Playlist & { id: string }>(MAT_DIALOG_DATA);
readonly isDesktop = !!window.electron;
/** Playlist object */
playlist: Playlist & { id: string };
/** Form group with playlist details */
playlistDetails!: UntypedFormGroup;
constructor() {
this.playlist = this.playlistData;
this.createForm();
}
/**
* Create the form and set initial data on component init
*/
createForm(): void {
this.playlistDetails = this.formBuilder.group({
_id: this.playlist._id,
title: new FormControl(this.playlist.title, Validators.required),
userAgent: this.playlist.userAgent || '',
filename: new FormControl({
value: this.playlist.filename || '',
disabled: true,
}),
count: new FormControl({
value: this.playlist.count,
disabled: true,
}),
importDate: new FormControl({
value: this.datePipe.transform(this.playlist.importDate),
disabled: true,
}),
url: new FormControl({
value: this.playlist.url,
disabled: true,
}),
filePath: new FormControl({
value: this.playlist.filePath,
disabled: true,
}),
autoRefresh: new FormControl(this.playlist.autoRefresh),
serverUrl: new FormControl(this.playlist.serverUrl),
username: new FormControl(this.playlist.username),
password: new FormControl(this.playlist.password),
macAddress: new FormControl(this.playlist.macAddress),
portalUrl: new FormControl(this.playlist.portalUrl),
stalkerSerialNumber: new FormControl(
this.playlist.stalkerSerialNumber
),
stalkerDeviceId1: new FormControl(this.playlist.stalkerDeviceId1),
stalkerDeviceId2: new FormControl(this.playlist.stalkerDeviceId2),
stalkerSignature1: new FormControl(this.playlist.stalkerSignature1),
stalkerSignature2: new FormControl(this.playlist.stalkerSignature2),
});
}
async saveChanges(playlist: PlaylistMeta): Promise<void> {
try {
const isXtream =
this.playlist &&
this.playlist.username &&
this.playlist.password &&
this.playlist.serverUrl;
if (isXtream) {
await this.updateXtreamPlaylist(playlist);
}
// Dispatch store action to update UI
this.store.dispatch(
PlaylistActions.updatePlaylistMeta({ playlist })
);
this.snackBar.open(
this.translate.instant(
'HOME.PLAYLISTS.PLAYLIST_UPDATE_SUCCESS'
),
this.translate.instant('CLOSE'),
{ duration: 3000 }
);
} catch (error) {
console.error('Error updating playlist:', error);
this.snackBar.open(
this.translate.instant('HOME.PLAYLISTS.PLAYLIST_UPDATE_FAILED'),
this.translate.instant('CLOSE'),
{
duration: 3000,
}
);
}
}
async updateXtreamPlaylist(playlist: PlaylistMeta) {
const success = await this.databaseService.updateXtreamPlaylistDetails({
id: this.playlist._id,
title: playlist.title,
username: playlist.username,
password: playlist.password,
serverUrl: playlist.serverUrl,
});
if (!success) {
throw new Error('Failed to update playlist in database');
}
// TODO: circular dependency
/* this.xtreamStore.updatePlaylist({
name: playlist.title,
username: playlist.username,
password: playlist.password,
serverUrl: playlist.serverUrl,
}); */
}
async exportPlaylist() {
const playlistAsString = await firstValueFrom(
this.playlistsService.getRawPlaylistById(this.playlist._id)
);
if (this.isDesktop) {
try {
const savePath = await window.electron.saveFileDialog(
`${this.playlist.title || 'exported'}.m3u8`,
[
{
name: 'Playlist',
extensions: ['m3u8', 'm3u'],
},
]
);
if (savePath) {
await window.electron.writeFile(savePath, playlistAsString);
this.snackBar.open(
this.translate.instant(
'HOME.PLAYLISTS.INFO_DIALOG.PLAYLIST_EXPORT_SUCCESS'
),
this.translate.instant('CLOSE'),
{ duration: 3000 }
);
}
} catch (error) {
console.error('Failed to export playlist:', error);
this.snackBar.open(
this.translate.instant(
'HOME.PLAYLISTS.INFO_DIALOG.EXPORT_PLAYLIST_FAILED'
),
this.translate.instant('CLOSE'),
{
duration: 3000,
}
);
}
} else {
const element = document.createElement('a');
element.setAttribute(
'href',
'data:text/plain;charset=utf-8,' +
encodeURIComponent(playlistAsString)
);
element.setAttribute(
'download',
this.playlist.title || 'exported.m3u'
);
element.style.display = 'none';
document.body.appendChild(element);
element.click();
document.body.removeChild(element);
}
}
/**
* Copy URL to clipboard
*/
copyUrl(): void {
const url = this.playlistDetails.get('url')?.value;
if (url) {
const success = this.clipboard.copy(url);
if (success) {
this.snackBar.open(
this.translate.instant(
'HOME.PLAYLISTS.INFO_DIALOG.URL_COPIED'
),
this.translate.instant('CLOSE'),
{ duration: 2000 }
);
}
}
}
}
@@ -0,0 +1,244 @@
<div
class="playlist-item"
cdkDrag
[cdkDragDisabled]="!isDraggable() || isBusy()"
cdkDragPreviewContainer="parent"
[class.selected]="isSelected()"
[class.is-busy]="isBusy()"
(click)="onPlaylistClick()"
>
@if (isDraggable()) {
<mat-icon cdkDragHandle class="drag-icon">drag_indicator</mat-icon>
}
<div class="playlist-icon">
@if (item.url) {
<div class="icon-container">
<mat-icon
class="upload-type-icon"
[matTooltip]="
('HOME.PLAYLISTS.ADDED_VIA_URL' | translate) +
' ' +
item.url
"
>cloud</mat-icon
>
@if (item.autoRefresh) {
<mat-icon
class="auto-refresh-indicator"
[matTooltip]="
'HOME.PLAYLISTS.AUTO_REFRESH_ENABLED' | translate
"
>autorenew</mat-icon
>
}
</div>
}
@if (!item.url && !item.serverUrl && !item.macAddress) {
<div class="icon-container">
<mat-icon
class="upload-type-icon"
[matTooltip]="'HOME.PLAYLISTS.ADDED_VIA_FILE' | translate"
>folder</mat-icon
>
@if (item.autoRefresh) {
<mat-icon
class="auto-refresh-indicator"
[matTooltip]="
'HOME.PLAYLISTS.AUTO_REFRESH_ENABLED' | translate
"
>autorenew</mat-icon
>
}
</div>
}
@if (item.serverUrl) {
<div class="icon-container">
<mat-icon
class="upload-type-icon"
[matTooltip]="'HOME.PLAYLISTS.XTREAM_PLAYLIST' | translate"
>public</mat-icon
>
<span
class="status-dot"
[class]="getStatusClass()"
[matTooltip]="'Portal status: ' + portalStatus"
[attr.aria-label]="'Portal status: ' + portalStatus"
></span>
</div>
}
@if (item.macAddress) {
<mat-icon
class="upload-type-icon"
[matTooltip]="'HOME.PLAYLISTS.STALKER_PORTAL' | translate"
>dashboard</mat-icon
>
}
</div>
<div class="playlist-content">
<div class="playlist-title">
{{ item.title || item.filename }}
</div>
<div class="meta">
@if (!item.serverUrl && !item.portalUrl) {
{{ 'HOME.PLAYLISTS.CHANNELS' | translate }}: {{ item.count }}
@if (item.importDate.length > 3) {
| {{ 'HOME.PLAYLISTS.ADDED' | translate }}:
{{ item.importDate | date: undefined : undefined : currentLocale() }}
}
@if (item.updateDate) {
| {{ 'HOME.PLAYLISTS.UPDATED' | translate }}:
{{
item.updateDate
| date
: 'MMMM d, yyyy, HH:mm'
: undefined
: currentLocale()
}}
}
@if (item.updateState === 2) {
<mat-icon
[matTooltip]="
'HOME.PLAYLISTS.INFO_DIALOG.UPDATE_FAILED'
| translate
"
[inline]="true"
>warning</mat-icon
>
}
} @else {
@if (item.macAddress) {
Stalker
} @else {
Xtream
}
@if (item.importDate.length > 3) {
| {{ 'HOME.PLAYLISTS.ADDED' | translate }}:
{{ item.importDate | date: undefined : undefined : currentLocale() }}
}
@if (item.updateDate) {
| {{ 'HOME.PLAYLISTS.UPDATED' | translate }}:
{{
item.updateDate
| date
: 'MMMM d, yyyy, HH:mm'
: undefined
: currentLocale()
}}
}
}
</div>
@if (isBusy() && busyMessage()) {
<div class="busy-state">
<span class="busy-state__message">{{ busyMessage() }}</span>
@if (busyProgress() !== null) {
<span class="busy-state__value">{{ busyProgress() }}%</span>
}
</div>
}
@if (isBusy() && busyMessage() && busyProgress() !== null) {
<div class="busy-state__bar">
<mat-progress-bar
mode="determinate"
[value]="busyProgress() ?? 0"
/>
</div>
}
@if (isBusy() && busyMessage() && busyProgress() === null) {
<div class="busy-state__bar">
<mat-progress-bar mode="indeterminate" />
</div>
}
</div>
@if (showActions()) {
<div class="action-buttons">
@if (canCancelBusyAction()) {
<button
mat-icon-button
color="warn"
class="cancel-btn"
(click)="
$event.stopPropagation(); cancelBusyActionClicked.emit()
"
[matTooltip]="
'WORKSPACE.SHELL.XTREAM_IMPORT_CANCEL' | translate
"
>
<mat-icon>close</mat-icon>
</button>
}
@if (item.url || item.filePath) {
<button
mat-icon-button
class="refresh-btn"
(click)="
$event.stopPropagation(); refreshClicked.emit(item)
"
[disabled]="isBusy()"
[matTooltip]="'HOME.PLAYLISTS.REFRESH' | translate"
>
@if (isRefreshing()) {
<mat-spinner
class="action-spinner"
diameter="18"
strokeWidth="3"
/>
} @else {
<mat-icon>sync</mat-icon>
}
</button>
}
@if (item.serverUrl && isElectron) {
<button
mat-icon-button
class="refresh-btn"
(click)="
$event.stopPropagation(); refreshClicked.emit(item)
"
[disabled]="isBusy()"
[matTooltip]="'HOME.PLAYLISTS.REFRESH_XTREAM' | translate"
>
@if (isRefreshing()) {
<mat-spinner
class="action-spinner"
diameter="18"
strokeWidth="3"
/>
} @else {
<mat-icon>sync</mat-icon>
}
</button>
}
<button
mat-icon-button
class="edit-btn"
(click)="
$event.stopPropagation(); editPlaylistClicked.emit(item)
"
[disabled]="isBusy()"
[matTooltip]="'HOME.PLAYLISTS.SHOW_DETAILS' | translate"
>
<mat-icon>edit</mat-icon>
</button>
<button
mat-icon-button
class="delete-btn"
(click)="$event.stopPropagation(); removeClicked.emit(item._id)"
[disabled]="isBusy()"
[matTooltip]="'HOME.PLAYLISTS.REMOVE' | translate"
>
@if (isDeleting()) {
<mat-spinner
class="action-spinner"
diameter="18"
strokeWidth="3"
/>
} @else {
<mat-icon>delete</mat-icon>
}
</button>
</div>
}
</div>
@@ -0,0 +1,273 @@
:host {
display: block;
width: 100%;
}
.playlist-item {
width: 100%;
min-height: 68px;
display: flex;
align-items: center;
gap: 12px;
padding: 8px 10px 8px 12px;
border: 1px solid transparent;
border-radius: 8px;
background: transparent;
color: inherit;
cursor: pointer;
box-sizing: border-box;
transition:
background 0.15s ease,
border-color 0.15s ease,
box-shadow 0.18s ease,
transform 0.18s ease,
color 0.15s ease;
&:hover {
background: color-mix(in srgb, currentColor 8%, transparent);
}
&.selected {
background: linear-gradient(
135deg,
var(--app-selection-surface-strong),
var(--app-selection-surface)
);
border-color: var(--app-selection-border);
color: var(--app-selection-color);
box-shadow:
inset 0 0 0 1px var(--app-selection-border),
0 12px 20px -18px var(--app-selection-glow);
transform: translateY(-1px);
.meta,
.upload-type-icon,
.drag-icon,
.action-buttons button,
.action-buttons mat-icon {
color: inherit;
}
.status-dot {
border-color: color-mix(
in srgb,
var(--app-selection-surface-strong) 85%,
transparent
);
}
}
&.is-busy {
cursor: progress;
}
}
.playlist-icon {
flex-shrink: 0;
display: flex;
align-items: center;
}
.playlist-content {
min-width: 0;
flex: 1;
display: flex;
flex-direction: column;
justify-content: center;
gap: 2px;
}
.playlist-title {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 0.96rem;
font-weight: 600;
letter-spacing: -0.01em;
line-height: 1.2;
}
.meta {
font-size: 0.82rem;
color: var(--mat-sys-on-surface-variant);
opacity: 0.84;
line-height: 1.2;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.busy-state {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
font-size: 0.7rem;
color: var(--app-selection-border, #8aa4c8);
margin-top: 2px;
}
.busy-state__message {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.busy-state__value {
opacity: 0.8;
}
.busy-state__bar {
margin-top: 4px;
}
.cdk-drag-preview {
box-sizing: border-box;
border-radius: 8px;
box-shadow:
0 8px 24px rgba(0, 0, 0, 0.25),
0 4px 12px rgba(0, 0, 0, 0.15),
0 2px 6px rgba(0, 0, 0, 0.1);
opacity: 0.9;
}
.cdk-drag-placeholder {
opacity: 0;
}
.cdk-drag-animating {
transition: transform 300ms cubic-bezier(0.4, 0, 0.2, 1);
}
.drag-icon {
flex-shrink: 0;
cursor: move;
opacity: 0.46;
transition: opacity 0.15s ease;
&:hover {
opacity: 1;
}
}
.action-buttons {
flex-shrink: 0;
display: flex;
align-items: center;
gap: 2px;
margin-inline-start: auto;
button {
width: 34px;
height: 34px;
border-radius: 10px;
color: inherit;
opacity: 0.76;
transition:
opacity 0.15s ease,
background-color 0.15s ease,
transform 0.15s ease;
&:hover {
opacity: 1;
background: color-mix(in srgb, currentColor 10%, transparent);
transform: translateY(-1px);
}
mat-icon {
width: 20px;
height: 20px;
font-size: 20px;
}
&:disabled {
transform: none;
opacity: 0.38;
}
}
}
.action-spinner {
display: block;
margin: 0 auto;
}
.icon-container {
position: relative;
display: inline-flex;
.upload-type-icon {
opacity: 0.75;
transition: opacity 0.15s ease;
}
.playlist-item:hover & .upload-type-icon {
opacity: 0.95;
}
}
.status-dot {
position: absolute;
width: 10px;
height: 10px;
border-radius: 50%;
right: 0;
bottom: 0;
border: 2px solid var(--mat-sys-surface);
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.3);
transition: all 0.15s ease;
}
.status-active {
background-color: #4caf50;
box-shadow: 0 0 8px rgba(76, 175, 80, 0.4);
}
.status-inactive {
background-color: #f44336;
box-shadow: 0 0 8px rgba(244, 67, 54, 0.4);
}
.status-expired {
background-color: #ff9800;
box-shadow: 0 0 8px rgba(255, 152, 0, 0.4);
}
.status-unavailable {
background-color: var(--mat-sys-on-surface-variant);
opacity: 0.5;
}
.auto-refresh-indicator {
position: absolute;
right: -2px;
bottom: -2px;
font-size: 14px !important;
width: 14px !important;
height: 14px !important;
color: #4caf50;
background-color: var(--mat-sys-surface);
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.3);
transition: all 0.15s ease;
.playlist-item:hover & {
color: #66bb6a;
transform: rotate(90deg);
}
}
@media only screen and (max-width: 480px) {
.upload-type-icon,
.icon-container,
.action-buttons .refresh-btn,
.action-buttons .edit-btn,
.action-buttons .delete-btn {
display: none;
}
}
@@ -0,0 +1,95 @@
import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing';
import { MatIconModule } from '@angular/material/icon';
import { MatListModule } from '@angular/material/list';
import { MatTooltipModule } from '@angular/material/tooltip';
import { TranslateModule } from '@ngx-translate/core';
import { MockModule } from 'ng-mocks';
import { PortalStatusService } from 'services';
import { PlaylistItemComponent } from './playlist-item.component';
describe('PlaylistItemComponent', () => {
let component: PlaylistItemComponent;
let fixture: ComponentFixture<PlaylistItemComponent>;
beforeEach(waitForAsync(() => {
TestBed.configureTestingModule({
imports: [
PlaylistItemComponent,
MockModule(MatIconModule),
MockModule(MatListModule),
MockModule(MatTooltipModule),
TranslateModule.forRoot(),
],
providers: [
{
provide: PortalStatusService,
useValue: {
checkPortalStatus: jest
.fn()
.mockResolvedValue('active'),
getStatusClass: jest.fn(() => 'status-active'),
getStatusIcon: jest.fn(() => 'check_circle'),
},
},
],
}).compileComponents();
}));
beforeEach(() => {
fixture = TestBed.createComponent(PlaylistItemComponent);
component = fixture.componentInstance;
component.item = {
title: 'Playlist',
_id: '1',
count: 10,
importDate: Date.now().toString(),
autoRefresh: false,
};
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
it('shows busy UI and suppresses playlist clicks while deleting', () => {
const emitSpy = jest.spyOn(component.playlistClicked, 'emit');
fixture.componentRef.setInput('isDeleting', true);
fixture.detectChanges();
const nativeElement = fixture.nativeElement as HTMLElement;
const deleteButton = nativeElement.querySelector(
'.delete-btn'
) as HTMLButtonElement;
const editButton = nativeElement.querySelector(
'.edit-btn'
) as HTMLButtonElement;
expect(component.isBusy()).toBe(true);
expect(deleteButton.disabled).toBe(true);
expect(editButton.disabled).toBe(true);
expect(nativeElement.querySelector('.action-spinner')).not.toBeNull();
component.onPlaylistClick();
expect(emitSpy).not.toHaveBeenCalled();
});
it('renders cancel and progress UI for long-running playlist actions', () => {
fixture.componentRef.setInput('isDeleting', true);
fixture.componentRef.setInput('busyMessage', 'Removing cached content...');
fixture.componentRef.setInput('busyProgress', 42);
fixture.componentRef.setInput('canCancelBusyAction', true);
fixture.detectChanges();
const nativeElement = fixture.nativeElement as HTMLElement;
expect(nativeElement.querySelector('.busy-state__message')?.textContent).toContain(
'Removing cached content...'
);
expect(nativeElement.querySelector('.busy-state__value')?.textContent).toContain(
'42%'
);
expect(nativeElement.querySelector('.cancel-btn')).not.toBeNull();
});
});
@@ -0,0 +1,102 @@
import { DragDropModule } from '@angular/cdk/drag-drop';
import { DatePipe } from '@angular/common';
import {
Component,
Input,
OnInit,
computed,
inject,
input,
output,
} from '@angular/core';
import { toSignal } from '@angular/core/rxjs-interop';
import { MatIconButton } from '@angular/material/button';
import { MatIcon } from '@angular/material/icon';
import { MatProgressBarModule } from '@angular/material/progress-bar';
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
import { MatTooltip } from '@angular/material/tooltip';
import { normalizeDateLocale } from '@iptvnator/pipes';
import { TranslatePipe, TranslateService } from '@ngx-translate/core';
import { startWith } from 'rxjs';
import { PortalStatus, PortalStatusService } from 'services';
import { PlaylistMeta } from 'shared-interfaces';
@Component({
selector: 'app-playlist-item',
templateUrl: './playlist-item.component.html',
styleUrls: ['./playlist-item.component.scss'],
imports: [
DatePipe,
DragDropModule,
MatIconButton,
MatIcon,
MatProgressBarModule,
MatProgressSpinnerModule,
MatTooltip,
TranslatePipe,
],
})
export class PlaylistItemComponent implements OnInit {
@Input() item!: PlaylistMeta;
readonly showActions = input(true);
readonly isDraggable = input(false);
readonly isSelected = input(false);
readonly isRefreshing = input(false);
readonly isDeleting = input(false);
readonly busyMessage = input('');
readonly busyProgress = input<number | null>(null);
readonly canCancelBusyAction = input(false);
readonly isBusy = computed(() => this.isRefreshing() || this.isDeleting());
readonly editPlaylistClicked = output<PlaylistMeta>();
readonly playlistClicked = output<string>();
readonly refreshClicked = output<PlaylistMeta>();
readonly removeClicked = output<string>();
readonly cancelBusyActionClicked = output<void>();
portalStatus: PortalStatus = 'unavailable';
private readonly portalStatusService = inject(PortalStatusService);
private readonly translate = inject(TranslateService);
private readonly languageTick = toSignal(
this.translate.onLangChange.pipe(startWith(null)),
{ initialValue: null }
);
readonly isElectron = !!window.electron;
readonly currentLocale = computed(() => {
this.languageTick();
return normalizeDateLocale(
this.translate.currentLang || this.translate.defaultLang
);
});
async ngOnInit() {
await this.checkPortalStatus();
}
private async checkPortalStatus() {
if (this.item.serverUrl && this.item.username && this.item.password) {
this.portalStatus = await this.portalStatusService.checkPortalStatus(
this.item.serverUrl,
this.item.username,
this.item.password
);
}
}
getStatusClass(): string {
return this.portalStatusService.getStatusClass(this.portalStatus);
}
getStatusIcon(): string {
return this.portalStatusService.getStatusIcon(this.portalStatus);
}
onPlaylistClick(): void {
if (this.isBusy()) {
return;
}
this.playlistClicked.emit(this.item._id);
}
}
@@ -0,0 +1,45 @@
@if (playlistsData(); as data) {
@if (data.playlists.length === 0) {
@if (data.totalCount === 0) {
<!-- Welcome state: no playlists at all -->
<app-empty-state
[type]="'welcome-sources'"
[showElectronOnlyValueProps]="isElectron"
(addPlaylistClicked)="onAddPlaylist($event)"
/>
} @else {
<!-- No results state: search/filter returned nothing -->
<app-empty-state [type]="'no-results'" />
}
} @else {
<div
class="playlists-list"
cdkDropList
[cdkDropListDisabled]="!isCustomSortActive()"
(cdkDropListDropped)="drop($event, data.playlists)"
>
@for (
item of data.playlists;
track item._id;
let last = $last
) {
<app-playlist-item
[item]="item"
[isSelected]="activePlaylistId() === item._id"
[showActions]="!sidebarMode()"
[isDraggable]="isCustomSortActive()"
[isRefreshing]="isRefreshPending(item._id)"
[isDeleting]="isDeletePending(item._id)"
[busyMessage]="getBusyMessage(item)"
[busyProgress]="getBusyProgress(item._id)"
[canCancelBusyAction]="canCancelBusyOperation(item)"
(editPlaylistClicked)="openInfoDialog($event)"
(playlistClicked)="getPlaylist(item)"
(refreshClicked)="refreshPlaylist($event)"
(removeClicked)="removeClicked(item)"
(cancelBusyActionClicked)="cancelBusyOperation(item)"
/>
}
</div>
}
}
@@ -0,0 +1,49 @@
:host {
display: flex;
flex-direction: column;
align-items: center;
width: 100%;
min-width: 0;
min-height: 0;
}
:host app-empty-state {
display: flex;
flex: 1;
width: 100%;
height: 100%;
min-height: 0;
}
.playlists-list {
padding: 0 6px;
width: 100%;
overflow-x: hidden;
box-sizing: border-box;
}
#skeleton-container {
padding-top: 10px;
width: 100%;
.skeleton-item {
display: flex;
padding: 0 20px;
height: 72px;
.text {
flex: 1;
align-self: center;
padding-left: 6px;
flex-direction: column;
display: flex;
}
}
}
@media only screen and (max-width: 480px) {
.favorites-icon {
padding-left: 0 !important;
display: none;
}
}
@@ -0,0 +1,420 @@
import { signal } from '@angular/core';
import { TestBed } from '@angular/core/testing';
import { MatDialog } from '@angular/material/dialog';
import { MatSnackBar } from '@angular/material/snack-bar';
import { Router } from '@angular/router';
import { MockStore, provideMockStore } from '@ngrx/store/testing';
import { TranslateService } from '@ngx-translate/core';
import { PlaylistContextFacade } from '@iptvnator/playlist/shared/util';
import {
PlaylistActions,
selectActiveTypeFilters,
selectAllPlaylistsMeta,
selectPlaylistsLoadingFlag,
} from 'm3u-state';
import { of } from 'rxjs';
import { DialogService } from 'components';
import {
DatabaseService,
DataService,
PlaybackPositionService,
SortBy,
SortOrder,
SortService,
} from 'services';
import { PLAYLIST_UPDATE, PlaylistMeta } from 'shared-interfaces';
import { RecentPlaylistsComponent } from './recent-playlists.component';
function createDeferred<T>() {
let resolve!: (value: T) => void;
let reject!: (reason?: unknown) => void;
const promise = new Promise<T>((res, rej) => {
resolve = res;
reject = rej;
});
return { promise, resolve, reject };
}
function createAbortError(): Error {
const error = new Error('Cancelled');
error.name = 'AbortError';
return error;
}
function createPlaylistMeta(
overrides: Partial<PlaylistMeta> = {}
): PlaylistMeta {
return {
_id: 'playlist-1',
title: 'Xtream Playlist',
count: 0,
importDate: new Date('2026-03-28T00:00:00.000Z').toISOString(),
autoRefresh: false,
serverUrl: 'http://localhost:8080',
username: 'demo',
password: 'secret',
...overrides,
} as PlaylistMeta;
}
describe('RecentPlaylistsComponent busy state', () => {
let component: RecentPlaylistsComponent;
let databaseService: {
cancelOperation: jest.Mock;
createOperationId: jest.Mock;
deletePlaylist: jest.Mock;
deleteXtreamPlaylistContent: jest.Mock;
updateXtreamPlaylistDetails: jest.Mock;
};
let dialogService: {
openConfirmDialog: jest.Mock;
};
let dataService: {
sendIpcEvent: jest.Mock;
};
let playbackPositionService: {
getAllPlaybackPositions: jest.Mock;
};
let router: {
navigate: jest.Mock;
};
let snackBar: {
open: jest.Mock;
};
let store: MockStore;
beforeEach(async () => {
databaseService = {
cancelOperation: jest.fn().mockResolvedValue(true),
createOperationId: jest.fn((prefix: string) => `${prefix}-op`),
deletePlaylist: jest.fn(),
deleteXtreamPlaylistContent: jest.fn(),
updateXtreamPlaylistDetails: jest.fn().mockResolvedValue(undefined),
};
dialogService = {
openConfirmDialog: jest.fn(),
};
dataService = {
sendIpcEvent: jest.fn(),
};
playbackPositionService = {
getAllPlaybackPositions: jest.fn().mockResolvedValue([]),
};
router = {
navigate: jest.fn(),
};
snackBar = {
open: jest.fn(),
};
await TestBed.configureTestingModule({
imports: [RecentPlaylistsComponent],
providers: [
provideMockStore({
selectors: [
{ selector: selectPlaylistsLoadingFlag, value: true },
{
selector: selectAllPlaylistsMeta,
value: [],
},
{
selector: selectActiveTypeFilters,
value: ['xtream', 'm3u', 'stalker'],
},
],
}),
{
provide: DatabaseService,
useValue: databaseService,
},
{
provide: DialogService,
useValue: dialogService,
},
{
provide: DataService,
useValue: dataService,
},
{
provide: PlaybackPositionService,
useValue: playbackPositionService,
},
{
provide: PlaylistContextFacade,
useValue: {
resolvedPlaylistId: signal<string | null>(null),
},
},
{
provide: MatDialog,
useValue: {
open: jest.fn(),
},
},
{
provide: MatSnackBar,
useValue: snackBar,
},
{
provide: Router,
useValue: router,
},
{
provide: SortService,
useValue: {
getSortOptions: jest.fn(() =>
of({
by: SortBy.DATE_ADDED,
order: SortOrder.DESC,
})
),
sortPlaylists: jest.fn(
(playlists: PlaylistMeta[]) => playlists
),
},
},
{
provide: TranslateService,
useValue: {
instant: jest.fn((key: string) => key),
},
},
],
}).compileComponents();
component = TestBed.createComponent(
RecentPlaylistsComponent
).componentInstance;
store = TestBed.inject(MockStore);
jest.spyOn(store, 'dispatch');
});
afterEach(() => {
jest.restoreAllMocks();
localStorage.clear();
});
it('tracks delete progress and clears the busy row after completion', async () => {
const item = createPlaylistMeta({ _id: 'playlist-delete-1' });
const deletion = createDeferred<boolean>();
databaseService.deletePlaylist.mockImplementation(
(
_playlistId: string,
options?: {
onEvent?: (event: any) => void;
operationId?: string;
}
) => {
options?.onEvent?.({
operation: 'delete-playlist',
operationId: 'playlist-delete-op',
status: 'progress',
phase: 'deleting-content',
current: 25,
total: 100,
});
return deletion.promise;
}
);
const removalPromise = component.removePlaylist(item);
expect(component.isDeletePending(item._id)).toBe(true);
expect(component.getBusyMessage(item)).toBe(
'HOME.PLAYLISTS.REMOVE_DIALOG.DELETING_CONTENT'
);
expect(component.getBusyProgress(item._id)).toBe(25);
expect(component.canCancelBusyOperation(item)).toBe(true);
await component.cancelBusyOperation(item);
expect(databaseService.cancelOperation).toHaveBeenCalledWith(
'playlist-delete-op'
);
deletion.resolve(true);
await removalPromise;
expect(component.isDeletePending(item._id)).toBe(false);
expect(component.getBusyProgress(item._id)).toBeNull();
expect(store.dispatch).toHaveBeenCalledWith(
PlaylistActions.removePlaylist({ playlistId: item._id })
);
expect(snackBar.open).toHaveBeenCalledWith(
'HOME.PLAYLISTS.REMOVE_DIALOG.SUCCESS',
undefined,
{ duration: 2000 }
);
});
it('tracks Xtream refresh progress and clears the busy row after abort', async () => {
const item = createPlaylistMeta({ _id: 'playlist-refresh-1' });
const refresh = createDeferred<{
success: boolean;
favorites: Array<{ xtreamId: number; contentType: string }>;
recentlyViewed: Array<{
xtreamId: number;
contentType: string;
viewedAt: string;
}>;
hiddenCategories: Array<{
xtreamId: number;
categoryType: string;
}>;
}>();
let confirmPromise: Promise<void> | undefined;
dialogService.openConfirmDialog.mockImplementation(
({ onConfirm }: { onConfirm?: () => Promise<void> }) => {
confirmPromise = onConfirm?.();
}
);
databaseService.deleteXtreamPlaylistContent.mockImplementation(
(
_playlistId: string,
options?: {
onEvent?: (event: any) => void;
operationId?: string;
}
) => {
options?.onEvent?.({
operation: 'delete-xtream-content',
operationId: 'xtream-refresh-op',
status: 'progress',
phase: 'collecting-user-data',
current: 1,
total: 4,
});
return refresh.promise;
}
);
component.refreshXtreamPlaylist(item);
await Promise.resolve();
expect(component.isRefreshPending(item._id)).toBe(true);
expect(component.getBusyMessage(item)).toBe(
'HOME.PLAYLISTS.REFRESH_XTREAM_DIALOG.COLLECTING_DATA'
);
expect(component.getBusyProgress(item._id)).toBe(25);
expect(component.canCancelBusyOperation(item)).toBe(true);
await component.cancelBusyOperation(item);
expect(databaseService.cancelOperation).toHaveBeenCalledWith(
'xtream-refresh-op'
);
refresh.reject(createAbortError());
await confirmPromise;
expect(component.isRefreshPending(item._id)).toBe(false);
expect(component.getBusyProgress(item._id)).toBeNull();
expect(router.navigate).not.toHaveBeenCalled();
});
it('dispatches playlist meta with updateDate after Xtream refresh setup succeeds', async () => {
const item = createPlaylistMeta({ _id: 'playlist-refresh-success-1' });
let confirmPromise: Promise<void> | undefined;
const executionOrder: string[] = [];
const dateNowSpy = jest
.spyOn(Date, 'now')
.mockReturnValue(1712145600000);
const originalSetItem = Storage.prototype.setItem;
const setItemSpy = jest
.spyOn(Storage.prototype, 'setItem')
.mockImplementation(function (key: string, value: string) {
executionOrder.push('setItem');
return originalSetItem.call(this, key, value);
});
dialogService.openConfirmDialog.mockImplementation(
({ onConfirm }: { onConfirm?: () => Promise<void> }) => {
confirmPromise = onConfirm?.();
}
);
(store.dispatch as jest.Mock).mockImplementation((action: unknown) => {
executionOrder.push('dispatch');
return action;
});
router.navigate.mockImplementation((commands: unknown[]) => {
executionOrder.push('navigate');
return Promise.resolve(Boolean(commands));
});
databaseService.deleteXtreamPlaylistContent.mockResolvedValue({
success: true,
favorites: [
{ xtreamId: 101, contentType: 'live' },
{ xtreamId: 202, contentType: 'movie' },
],
recentlyViewed: [
{
xtreamId: 303,
contentType: 'series',
viewedAt: '2026-04-03T11:15:00.000Z',
},
],
hiddenCategories: [{ xtreamId: 404, categoryType: 'live' }],
});
component.refreshXtreamPlaylist(item);
await confirmPromise;
expect(
databaseService.updateXtreamPlaylistDetails
).toHaveBeenCalledWith({
id: item._id,
updateDate: 1712145600000,
});
expect(store.dispatch).toHaveBeenCalledWith(
PlaylistActions.updatePlaylistMeta({
playlist: { ...item, updateDate: 1712145600000 },
})
);
expect(setItemSpy).toHaveBeenCalledWith(
`xtream-restore-${item._id}`,
JSON.stringify({
hiddenCategories: [{ xtreamId: 404, categoryType: 'live' }],
favorites: [
{ xtreamId: 101, contentType: 'live' },
{ xtreamId: 202, contentType: 'movie' },
],
recentlyViewed: [
{
xtreamId: 303,
contentType: 'series',
viewedAt: '2026-04-03T11:15:00.000Z',
},
],
playbackPositions: [],
})
);
expect(router.navigate).toHaveBeenCalledWith([
'/workspace',
'xtreams',
item._id,
]);
expect(executionOrder).toEqual(['setItem', 'dispatch', 'navigate']);
setItemSpy.mockRestore();
dateNowSpy.mockRestore();
});
it('uses the legacy IPC refresh flow for non-Xtream playlists', () => {
const item = createPlaylistMeta({
_id: 'playlist-m3u-1',
serverUrl: undefined,
username: undefined,
password: undefined,
filePath: '/tmp/test.m3u',
});
component.refreshPlaylist(item);
expect(dataService.sendIpcEvent).toHaveBeenCalledWith(PLAYLIST_UPDATE, {
id: item._id,
title: item.title,
filePath: item.filePath,
});
});
});
@@ -0,0 +1,739 @@
import {
CdkDragDrop,
DragDropModule,
moveItemInArray,
} from '@angular/cdk/drag-drop';
import {
Component,
effect,
inject,
input,
output,
signal,
} from '@angular/core';
import { toSignal } from '@angular/core/rxjs-interop';
import { MatDialog } from '@angular/material/dialog';
import { MatInputModule } from '@angular/material/input';
import { MatSnackBar } from '@angular/material/snack-bar';
import { Router } from '@angular/router';
import { Store } from '@ngrx/store';
import { TranslateService } from '@ngx-translate/core';
import { PlaylistContextFacade } from '@iptvnator/playlist/shared/util';
import type { WorkspacePlaylistType } from '@iptvnator/workspace/shell/util';
import {
PlaylistActions,
selectActiveTypeFilters,
selectAllPlaylistsMeta,
selectPlaylistsLoadingFlag,
} from 'm3u-state';
import { BehaviorSubject, combineLatest, map } from 'rxjs';
import { DialogService } from 'components';
import {
DatabaseService,
DataService,
DbOperationEvent,
isDbAbortError,
PlaybackPositionService,
PlaylistRefreshService,
SortBy,
SortService,
XtreamPendingRestoreService,
} from 'services';
import {
PLAYLIST_UPDATE,
PlaylistMeta,
PlaylistRefreshEvent,
} from 'shared-interfaces';
import { EmptyStateComponent } from './empty-state/empty-state.component';
import { PlaylistInfoComponent } from './playlist-info/playlist-info.component';
import { PlaylistItemComponent } from './playlist-item/playlist-item.component';
type PlaylistBusyOperation = {
current?: number;
operation: string;
operationId?: string;
phase?: string;
status: DbOperationEvent['status'];
total?: number;
};
@Component({
selector: 'app-recent-playlists',
templateUrl: './recent-playlists.component.html',
styleUrls: ['./recent-playlists.component.scss'],
imports: [
DragDropModule,
EmptyStateComponent,
MatInputModule,
PlaylistItemComponent,
],
})
export class RecentPlaylistsComponent {
private readonly databaseService = inject(DatabaseService);
private readonly dialog = inject(MatDialog);
private readonly dialogService = inject(DialogService);
private readonly dataService = inject(DataService);
private readonly playbackPositionService = inject(PlaybackPositionService);
private readonly playlistRefreshService = inject(PlaylistRefreshService);
private readonly router = inject(Router);
private readonly snackBar = inject(MatSnackBar);
private readonly sortService = inject(SortService);
private readonly store = inject(Store);
private readonly translate = inject(TranslateService);
private readonly playlistContext = inject(PlaylistContextFacade);
private readonly pendingRestoreService = inject(
XtreamPendingRestoreService
);
readonly sidebarMode = input(false);
readonly searchQueryInput = input<string>('');
readonly playlistClicked = output<string>();
readonly addPlaylistClicked = output<WorkspacePlaylistType | undefined>();
readonly isElectron = !!window.electron;
readonly allPlaylistsLoaded = this.store.selectSignal(
selectPlaylistsLoadingFlag
);
readonly activePlaylistId = this.playlistContext.resolvedPlaylistId;
private readonly currentSortOptions = toSignal(
this.sortService.getSortOptions(),
{ requireSync: true }
);
readonly isCustomSortActive = () =>
this.currentSortOptions().by === SortBy.CUSTOM;
readonly searchQuery = new BehaviorSubject<string>('');
readonly pendingDeletionIds = signal<Set<string>>(new Set());
readonly pendingRefreshIds = signal<Set<string>>(new Set());
readonly busyOperations = signal<Map<string, PlaylistBusyOperation>>(
new Map()
);
constructor() {
// Update searchQuery when input changes
effect(() => {
this.searchQuery.next(this.searchQueryInput());
});
}
readonly playlistsData$ = combineLatest([
this.store.select(selectAllPlaylistsMeta),
this.searchQuery,
this.store.select(selectActiveTypeFilters),
this.sortService.getSortOptions(),
]).pipe(
map(([playlists, searchQuery, filters, sortOptions]) => {
const filteredPlaylists = playlists
.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 && filters.includes('stalker')) ||
(isXtreamFilter && filters.includes('xtream')) ||
(isM3uFilter && filters.includes('m3u'))
);
})
.filter((item) =>
(item.title || '')
.toLowerCase()
.includes(searchQuery.toLowerCase())
);
// Apply sorting using the SortService
const sortedPlaylists = this.sortService.sortPlaylists(
filteredPlaylists,
sortOptions
);
return {
playlists: sortedPlaylists,
totalCount: playlists.length,
};
})
);
readonly playlistsData = toSignal(this.playlistsData$, {
initialValue: {
playlists: [] as PlaylistMeta[],
totalCount: 0,
},
});
/**
* Opens the details dialog with the information about the provided playlist
* @param data selected playlist
*/
openInfoDialog(data: PlaylistMeta): void {
this.dialog.open(PlaylistInfoComponent, {
data,
});
}
/**
* Drop event handler - applies the new sort order to the playlists array
* @param event drop event
*/
drop(event: CdkDragDrop<PlaylistMeta[]>, playlists: PlaylistMeta[]): void {
moveItemInArray(playlists, event.previousIndex, event.currentIndex);
this.store.dispatch(
PlaylistActions.updatePlaylistPositions({
positionUpdates: playlists.map((item, index) => ({
id: item._id,
changes: { position: index },
})),
})
);
}
onAddPlaylist(type?: WorkspacePlaylistType) {
this.addPlaylistClicked.emit(type);
}
getPlaylist(playlistMeta: PlaylistMeta): void {
if (playlistMeta.serverUrl) {
this.router.navigate(['/workspace', 'xtreams', playlistMeta._id]);
} else if (playlistMeta.macAddress) {
this.router.navigate(['/workspace', 'stalker', playlistMeta._id]);
} else {
this.router.navigate(['/workspace', 'playlists', playlistMeta._id]);
this.playlistClicked.emit(playlistMeta._id);
}
}
/**
* Triggers on remove click
* @param playlistId playlist id to remove
*/
removeClicked(item: PlaylistMeta): void {
if (this.isDeletePending(item._id) || this.isRefreshPending(item._id)) {
return;
}
this.dialogService.openConfirmDialog({
title: this.translate.instant('HOME.PLAYLISTS.REMOVE_DIALOG.TITLE'),
message: this.translate.instant(
'HOME.PLAYLISTS.REMOVE_DIALOG.MESSAGE'
),
onConfirm: () => {
this.removePlaylist(item);
},
});
}
/**
* Removes the provided playlist from the database
* @param playlistId playlist id to remove
*/
async removePlaylist(item: PlaylistMeta) {
if (this.isDeletePending(item._id) || this.isRefreshPending(item._id)) {
return;
}
this.setPendingDeletion(item._id, true);
const operationId = item.serverUrl
? this.databaseService.createOperationId('playlist-delete')
: undefined;
try {
const deleted = await this.databaseService.deletePlaylist(
item._id,
operationId
? {
operationId,
onEvent: (event) =>
this.updateBusyOperation(item._id, event),
}
: undefined
);
if (deleted) {
this.store.dispatch(
PlaylistActions.removePlaylist({ playlistId: item._id })
);
this.snackBar.open(
this.translate.instant(
'HOME.PLAYLISTS.REMOVE_DIALOG.SUCCESS'
),
undefined,
{
duration: 2000,
}
);
}
} finally {
this.clearBusyOperation(item._id);
this.setPendingDeletion(item._id, false);
}
}
/**
* Sends an IPC event with the playlist details to the main process to trigger the refresh operation
* @param item playlist to update
*/
refreshPlaylist(item: PlaylistMeta) {
if (this.isDeletePending(item._id) || this.isRefreshPending(item._id)) {
return;
}
if (item.serverUrl) {
// For Xtream playlists, delete and re-import
this.refreshXtreamPlaylist(item);
} else if (window.electron && (item.url || item.filePath)) {
void this.refreshM3uPlaylist(item);
} else {
// For M3U playlists, use existing refresh logic
this.dataService.sendIpcEvent(PLAYLIST_UPDATE, {
id: item._id,
title: item.title,
...(item.url ? { url: item.url } : { filePath: item.filePath }),
});
}
}
/**
* Refresh Xtream playlist by deleting all data and re-importing from remote
* @param item Xtream playlist to refresh
*/
async refreshXtreamPlaylist(item: PlaylistMeta) {
if (this.isDeletePending(item._id) || this.isRefreshPending(item._id)) {
return;
}
this.dialogService.openConfirmDialog({
title: this.translate.instant(
'HOME.PLAYLISTS.REFRESH_XTREAM_DIALOG.TITLE'
),
message: this.translate.instant(
'HOME.PLAYLISTS.REFRESH_XTREAM_DIALOG.MESSAGE'
),
onConfirm: async () => {
if (
this.isDeletePending(item._id) ||
this.isRefreshPending(item._id)
) {
return;
}
this.setPendingRefresh(item._id, true);
const operationId =
this.databaseService.createOperationId('xtream-refresh');
try {
// Show immediate feedback — deletion can take several seconds
// for large playlists.
this.snackBar.open(
this.translate.instant(
'HOME.PLAYLISTS.REFRESH_XTREAM_DIALOG.STARTED'
),
undefined,
{ duration: 2000 }
);
// Delete content/categories and update the timestamp in
// parallel — both operations are fully independent.
const updateDate = Date.now();
const [restoreState, playbackPositions] = await Promise.all(
[
this.databaseService.deleteXtreamPlaylistContent(
item._id,
{
operationId,
onEvent: (workerEvent) =>
this.updateBusyOperation(
item._id,
workerEvent
),
}
),
this.playbackPositionService.getAllPlaybackPositions(
item._id
),
this.databaseService.updateXtreamPlaylistDetails({
id: item._id,
updateDate,
}),
]
);
this.pendingRestoreService.set(item._id, {
...restoreState,
playbackPositions,
});
// Update the timestamp in NgRx / IndexedDB
this.store.dispatch(
PlaylistActions.updatePlaylistMeta({
playlist: { ...item, updateDate },
})
);
// Navigate to the playlist to trigger re-import
this.router.navigate(['/workspace', 'xtreams', item._id]);
} catch (error) {
if (!isDbAbortError(error)) {
console.error(
'Error refreshing Xtream playlist:',
error
);
this.snackBar.open(
this.translate.instant(
'HOME.PLAYLISTS.REFRESH_XTREAM_DIALOG.ERROR'
),
undefined,
{ duration: 3000 }
);
}
} finally {
this.clearBusyOperation(item._id);
this.setPendingRefresh(item._id, false);
}
},
});
}
private async refreshM3uPlaylist(item: PlaylistMeta): Promise<void> {
if (this.isDeletePending(item._id) || this.isRefreshPending(item._id)) {
return;
}
this.setPendingRefresh(item._id, true);
const operationId =
this.databaseService.createOperationId('playlist-refresh');
try {
const refreshedPlaylist =
await this.playlistRefreshService.refreshPlaylist(
{
operationId,
playlistId: item._id,
title: item.title,
url: item.url,
filePath: item.filePath,
},
{
onEvent: (event) =>
this.updateBusyOperation(
item._id,
this.toPlaylistRefreshBusyEvent(event)
),
}
);
this.updateBusyOperation(item._id, {
operationId,
operation: 'playlist-refresh',
playlistId: item._id,
phase: 'saving',
status: 'progress',
});
this.store.dispatch(
PlaylistActions.updatePlaylist({
playlist: {
...refreshedPlaylist,
_id: item._id,
},
playlistId: item._id,
})
);
this.clearBusyOperation(item._id);
this.snackBar.open(
this.translate.instant(
'HOME.PLAYLISTS.PLAYLIST_UPDATE_SUCCESS'
),
null,
{ duration: 2000 }
);
} catch (error) {
if (!isDbAbortError(error)) {
console.error('Error refreshing playlist:', error);
this.snackBar.open(
this.getPlaylistRefreshErrorMessage(error, item),
this.translate.instant('CLOSE'),
{ duration: 5000 }
);
}
} finally {
this.clearBusyOperation(item._id);
this.setPendingRefresh(item._id, false);
}
}
isDeletePending(playlistId: string): boolean {
return this.pendingDeletionIds().has(playlistId);
}
isRefreshPending(playlistId: string): boolean {
return this.pendingRefreshIds().has(playlistId);
}
getBusyMessage(item: PlaylistMeta): string {
const operation = this.busyOperations().get(item._id);
if (!operation) {
return '';
}
switch (operation.operation) {
case 'delete-playlist':
return this.translateDeletePhase(operation.phase);
case 'delete-xtream-content':
return this.translateRefreshPhase(operation.phase);
case 'playlist-refresh':
return this.translatePlaylistRefreshPhase(operation.phase);
default:
return '';
}
}
getBusyProgress(playlistId: string): number | null {
const operation = this.busyOperations().get(playlistId);
if (
!operation ||
operation.current == null ||
operation.total == null ||
operation.total <= 0
) {
return null;
}
return Math.min(
100,
Math.round((operation.current / operation.total) * 100)
);
}
canCancelBusyOperation(item: PlaylistMeta): boolean {
const operation = this.busyOperations().get(item._id);
if (!operation?.operationId) {
return false;
}
if (operation.operation === 'playlist-refresh') {
return true;
}
return Boolean(item.serverUrl);
}
async cancelBusyOperation(item: PlaylistMeta): Promise<void> {
const operation = this.busyOperations().get(item._id);
if (!operation?.operationId) {
return;
}
if (operation.operation === 'playlist-refresh') {
await this.playlistRefreshService.cancelRefresh(
operation.operationId
);
return;
}
await this.databaseService.cancelOperation(operation.operationId);
}
private setPendingDeletion(playlistId: string, isPending: boolean): void {
this.pendingDeletionIds.update((current) => {
const next = new Set(current);
if (isPending) {
next.add(playlistId);
} else {
next.delete(playlistId);
}
return next;
});
}
private setPendingRefresh(playlistId: string, isPending: boolean): void {
this.pendingRefreshIds.update((current) => {
const next = new Set(current);
if (isPending) {
next.add(playlistId);
} else {
next.delete(playlistId);
}
return next;
});
}
private updateBusyOperation(
playlistId: string,
event: DbOperationEvent
): void {
this.busyOperations.update((current) => {
const next = new Map(current);
if (
event.status === 'completed' ||
event.status === 'cancelled' ||
event.status === 'error'
) {
next.delete(playlistId);
return next;
}
next.set(playlistId, {
operation: event.operation,
operationId: event.operationId,
phase: event.phase,
current: event.current,
total: event.total,
status: event.status,
});
return next;
});
}
private clearBusyOperation(playlistId: string): void {
this.busyOperations.update((current) => {
const next = new Map(current);
next.delete(playlistId);
return next;
});
}
private toPlaylistRefreshBusyEvent(
event: PlaylistRefreshEvent
): DbOperationEvent {
return {
operationId: event.operationId,
operation: 'playlist-refresh',
playlistId: event.playlistId,
phase: event.phase,
status: event.status,
error: event.error,
};
}
private translateDeletePhase(phase?: string): string {
switch (phase) {
case 'deleting-favorites':
return this.translate.instant(
'HOME.PLAYLISTS.REMOVE_DIALOG.DELETING_FAVORITES'
);
case 'deleting-recently-viewed':
return this.translate.instant(
'HOME.PLAYLISTS.REMOVE_DIALOG.DELETING_RECENT'
);
case 'deleting-playback-positions':
return this.translate.instant(
'HOME.PLAYLISTS.REMOVE_DIALOG.DELETING_PROGRESS'
);
case 'deleting-downloads':
return this.translate.instant(
'HOME.PLAYLISTS.REMOVE_DIALOG.DELETING_DOWNLOADS'
);
case 'deleting-content':
return this.translate.instant(
'HOME.PLAYLISTS.REMOVE_DIALOG.DELETING_CONTENT'
);
case 'deleting-categories':
return this.translate.instant(
'HOME.PLAYLISTS.REMOVE_DIALOG.DELETING_CATEGORIES'
);
case 'deleting-playlist':
return this.translate.instant(
'HOME.PLAYLISTS.REMOVE_DIALOG.DELETING_PLAYLIST'
);
default:
return this.translate.instant(
'HOME.PLAYLISTS.REMOVE_DIALOG.IN_PROGRESS'
);
}
}
private translateRefreshPhase(phase?: string): string {
switch (phase) {
case 'collecting-user-data':
return this.translate.instant(
'HOME.PLAYLISTS.REFRESH_XTREAM_DIALOG.COLLECTING_DATA'
);
case 'deleting-content':
return this.translate.instant(
'HOME.PLAYLISTS.REFRESH_XTREAM_DIALOG.DELETING_CONTENT'
);
case 'deleting-categories':
return this.translate.instant(
'HOME.PLAYLISTS.REFRESH_XTREAM_DIALOG.DELETING_CATEGORIES'
);
default:
return this.translate.instant(
'HOME.PLAYLISTS.REFRESH_XTREAM_DIALOG.IN_PROGRESS'
);
}
}
private translatePlaylistRefreshPhase(phase?: string): string {
switch (phase) {
case 'fetching':
return this.translateWithFallback(
'HOME.PLAYLISTS.REFRESH_FETCHING',
'Fetching playlist...'
);
case 'reading-file':
return this.translateWithFallback(
'HOME.PLAYLISTS.REFRESH_READING_FILE',
'Reading playlist file...'
);
case 'parsing':
return this.translateWithFallback(
'HOME.PLAYLISTS.REFRESH_PARSING',
'Parsing playlist...'
);
case 'saving':
return this.translateWithFallback(
'HOME.PLAYLISTS.REFRESH_SAVING',
'Saving playlist...'
);
default:
return this.translate.instant('HOME.PLAYLISTS.REFRESH');
}
}
private getPlaylistRefreshErrorMessage(
error: unknown,
item: PlaylistMeta
): string {
if (item.filePath) {
const message = String(
(error as { message?: string })?.message ?? error
);
if (/(ENOENT|no such file or directory|not found)/i.test(message)) {
return this.translateWithFallback(
'HOME.PLAYLISTS.PLAYLIST_UPDATE_FILE_NOT_FOUND',
'Playlist refresh failed. The local file is no longer available. Check the file path or re-import the playlist.'
);
}
if (/(EACCES|EPERM|permission denied)/i.test(message)) {
return this.translateWithFallback(
'HOME.PLAYLISTS.PLAYLIST_UPDATE_FILE_ACCESS_ERROR',
'Playlist refresh failed. The app can no longer access the local file.'
);
}
}
return this.translate.instant('HOME.PLAYLISTS.PLAYLIST_UPDATE_ERROR');
}
private translateWithFallback(key: string, fallback: string): string {
const translated = this.translate.instant(key);
return translated === key ? fallback : translated;
}
}
@@ -0,0 +1,6 @@
import { setupZoneTestEnv } from 'jest-preset-angular/setup-env/zone';
setupZoneTestEnv({
errorOnUnknownElements: true,
errorOnUnknownProperties: true,
});
+28
View File
@@ -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"
}
]
}
+12
View File
@@ -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"]
}

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