Proyecto App Linux
This commit is contained in:
@@ -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';
|
||||
+155
@@ -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>
|
||||
+118
@@ -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);
|
||||
}
|
||||
}
|
||||
+132
@@ -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();
|
||||
});
|
||||
});
|
||||
+180
@@ -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);
|
||||
}
|
||||
}
|
||||
+116
@@ -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>
|
||||
+240
@@ -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: [''],
|
||||
});
|
||||
}
|
||||
}
|
||||
+56
@@ -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>
|
||||
+214
@@ -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';
|
||||
+38
@@ -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(),
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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',
|
||||
],
|
||||
};
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
+344
@@ -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.
|
||||
}
|
||||
}
|
||||
}
|
||||
+179
@@ -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>
|
||||
}
|
||||
}
|
||||
+176
@@ -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);
|
||||
}
|
||||
}
|
||||
+152
@@ -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();
|
||||
}
|
||||
}
|
||||
+41
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
+422
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
+259
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
+193
@@ -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>
|
||||
+275
@@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+244
@@ -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>
|
||||
+273
@@ -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;
|
||||
}
|
||||
}
|
||||
+95
@@ -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();
|
||||
});
|
||||
});
|
||||
+102
@@ -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,
|
||||
});
|
||||
@@ -0,0 +1,28 @@
|
||||
{
|
||||
"extends": "../../../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"isolatedModules": true,
|
||||
"target": "es2022",
|
||||
"moduleResolution": "bundler",
|
||||
"strict": true,
|
||||
"noImplicitOverride": true,
|
||||
"noPropertyAccessFromIndexSignature": true,
|
||||
"noImplicitReturns": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"emitDecoratorMetadata": false,
|
||||
"module": "preserve"
|
||||
},
|
||||
"angularCompilerOptions": {
|
||||
"enableI18nLegacyMessageIdFormat": false,
|
||||
"strictInjectionParameters": true,
|
||||
"strictInputAccessModifiers": true,
|
||||
"strictTemplates": true
|
||||
},
|
||||
"files": [],
|
||||
"include": [],
|
||||
"references": [
|
||||
{
|
||||
"path": "./tsconfig.lib.json"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "../../../../dist/out-tsc",
|
||||
"declaration": true,
|
||||
"declarationMap": true,
|
||||
"inlineSources": true,
|
||||
"types": []
|
||||
},
|
||||
"include": ["src/**/*.ts"],
|
||||
"exclude": ["src/**/*.spec.ts", "src/**/*.test.ts"]
|
||||
}
|
||||
@@ -0,0 +1,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"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
export default {
|
||||
displayName: 'playlist-shared-util',
|
||||
preset: '../../../../jest.preset.js',
|
||||
setupFilesAfterEnv: ['<rootDir>/src/test-setup.ts'],
|
||||
coverageDirectory: '../../../../coverage/libs/playlist/shared/util',
|
||||
transform: {
|
||||
'^.+\\.(ts|mjs|js|html)$': [
|
||||
'jest-preset-angular',
|
||||
{
|
||||
tsconfig: '<rootDir>/tsconfig.spec.json',
|
||||
stringifyContentPathRegex: '\\.(html|svg)$',
|
||||
},
|
||||
],
|
||||
},
|
||||
transformIgnorePatterns: ['node_modules/(?!.*\\.mjs$)'],
|
||||
snapshotSerializers: [
|
||||
'jest-preset-angular/build/serializers/no-ng-attributes',
|
||||
'jest-preset-angular/build/serializers/ng-snapshot',
|
||||
'jest-preset-angular/build/serializers/html-comment',
|
||||
],
|
||||
};
|
||||
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"name": "playlist-shared-util",
|
||||
"$schema": "../../../../node_modules/nx/schemas/project-schema.json",
|
||||
"sourceRoot": "libs/playlist/shared/util/src",
|
||||
"prefix": "lib",
|
||||
"projectType": "library",
|
||||
"tags": ["scope:playlist", "domain:m3u", "type:util"],
|
||||
"targets": {
|
||||
"test": {
|
||||
"executor": "@nx/jest:jest",
|
||||
"outputs": ["{workspaceRoot}/coverage/{projectRoot}"],
|
||||
"options": {
|
||||
"jestConfig": "libs/playlist/shared/util/jest.config.ts",
|
||||
"tsConfig": "libs/playlist/shared/util/tsconfig.spec.json"
|
||||
}
|
||||
},
|
||||
"lint": {
|
||||
"executor": "@nx/eslint:lint"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
export * from './lib/playlist-player-actions';
|
||||
export * from './lib/playlist-context.facade';
|
||||
export * from './lib/playlist-file-import.service';
|
||||
export * from './lib/playlist-refresh-action.service';
|
||||
@@ -0,0 +1,422 @@
|
||||
import { signal } from '@angular/core';
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { NavigationEnd, Router } from '@angular/router';
|
||||
import { Store } from '@ngrx/store';
|
||||
import {
|
||||
PlaylistActions,
|
||||
selectActivePlaylistId,
|
||||
selectAllPlaylistsMeta,
|
||||
selectPlaylistsLoadingFlag,
|
||||
} from 'm3u-state';
|
||||
import { Subject } from 'rxjs';
|
||||
import { PlaylistMeta } from 'shared-interfaces';
|
||||
import {
|
||||
PlaylistContextFacade,
|
||||
PlaylistRouteContext,
|
||||
} from './playlist-context.facade';
|
||||
|
||||
const LAST_SECTION_STORAGE_KEY = 'playlist-switcher:last-sections:v2';
|
||||
|
||||
function createPlaylist(
|
||||
overrides: Partial<PlaylistMeta> & { _id: string }
|
||||
): PlaylistMeta {
|
||||
return {
|
||||
_id: overrides._id,
|
||||
title: overrides.title ?? overrides.filename ?? overrides._id,
|
||||
count: overrides.count ?? 0,
|
||||
importDate:
|
||||
overrides.importDate ?? new Date('2026-04-05T10:00:00.000Z').toISOString(),
|
||||
autoRefresh: overrides.autoRefresh ?? false,
|
||||
...overrides,
|
||||
} as PlaylistMeta;
|
||||
}
|
||||
|
||||
describe('PlaylistContextFacade', () => {
|
||||
let facade: PlaylistContextFacade;
|
||||
let routerEvents: Subject<NavigationEnd>;
|
||||
let router: {
|
||||
url: string;
|
||||
events: ReturnType<Subject<NavigationEnd>['asObservable']>;
|
||||
navigate: jest.Mock;
|
||||
};
|
||||
let dispatch: jest.Mock;
|
||||
let playlistsSignal: ReturnType<typeof signal<PlaylistMeta[]>>;
|
||||
let loadedSignal: ReturnType<typeof signal<boolean>>;
|
||||
let activePlaylistIdSignal: ReturnType<typeof signal<string | null>>;
|
||||
let originalElectron: unknown;
|
||||
|
||||
const xtreamA = createPlaylist({
|
||||
_id: 'xtream-a',
|
||||
password: 'secret',
|
||||
serverUrl: 'http://127.0.0.1:3211',
|
||||
title: 'Xtream A',
|
||||
username: 'user-a',
|
||||
});
|
||||
const xtreamB = createPlaylist({
|
||||
_id: 'xtream-b',
|
||||
password: 'secret',
|
||||
serverUrl: 'http://127.0.0.1:3211',
|
||||
title: 'Xtream B',
|
||||
username: 'user-b',
|
||||
});
|
||||
const stalkerA = createPlaylist({
|
||||
_id: 'stalker-a',
|
||||
macAddress: '00:1A:79:00:00:01',
|
||||
portalUrl: 'http://127.0.0.1:3210/portal.php',
|
||||
title: 'Stalker A',
|
||||
});
|
||||
const stalkerB = createPlaylist({
|
||||
_id: 'stalker-b',
|
||||
macAddress: '00:1A:79:00:00:02',
|
||||
portalUrl: 'http://127.0.0.1:3210/portal.php',
|
||||
title: 'Stalker B',
|
||||
});
|
||||
const m3uA = createPlaylist({
|
||||
_id: 'm3u-a',
|
||||
title: 'M3U A',
|
||||
url: 'http://example.test/a.m3u',
|
||||
});
|
||||
const m3uB = createPlaylist({
|
||||
_id: 'm3u-b',
|
||||
title: 'M3U B',
|
||||
url: 'http://example.test/b.m3u',
|
||||
});
|
||||
|
||||
function setElectronAvailability(enabled: boolean): void {
|
||||
Object.defineProperty(window, 'electron', {
|
||||
configurable: true,
|
||||
value: enabled ? {} : undefined,
|
||||
});
|
||||
}
|
||||
|
||||
function instantiateFacade(): PlaylistContextFacade {
|
||||
facade = TestBed.inject(PlaylistContextFacade);
|
||||
dispatch.mockClear();
|
||||
router.navigate.mockClear();
|
||||
return facade;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
originalElectron = (window as Window & { electron?: unknown }).electron;
|
||||
|
||||
routerEvents = new Subject<NavigationEnd>();
|
||||
router = {
|
||||
url: '/workspace/xtreams/xtream-a/live',
|
||||
events: routerEvents.asObservable(),
|
||||
navigate: jest.fn().mockResolvedValue(true),
|
||||
};
|
||||
dispatch = jest.fn((action: unknown) => action);
|
||||
playlistsSignal = signal([
|
||||
xtreamA,
|
||||
xtreamB,
|
||||
stalkerA,
|
||||
stalkerB,
|
||||
m3uA,
|
||||
m3uB,
|
||||
]);
|
||||
loadedSignal = signal(true);
|
||||
activePlaylistIdSignal = signal<string | null>(xtreamA._id);
|
||||
setElectronAvailability(true);
|
||||
|
||||
TestBed.configureTestingModule({
|
||||
providers: [
|
||||
PlaylistContextFacade,
|
||||
{
|
||||
provide: Router,
|
||||
useValue: router,
|
||||
},
|
||||
{
|
||||
provide: Store,
|
||||
useValue: {
|
||||
dispatch,
|
||||
selectSignal: jest.fn((selector: unknown) => {
|
||||
if (selector === selectAllPlaylistsMeta) {
|
||||
return playlistsSignal;
|
||||
}
|
||||
if (selector === selectPlaylistsLoadingFlag) {
|
||||
return loadedSignal;
|
||||
}
|
||||
if (selector === selectActivePlaylistId) {
|
||||
return activePlaylistIdSignal;
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
`Unexpected selector: ${String(selector)}`
|
||||
);
|
||||
}),
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
localStorage.clear();
|
||||
Object.defineProperty(window, 'electron', {
|
||||
configurable: true,
|
||||
value: originalElectron,
|
||||
});
|
||||
routerEvents.complete();
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('preserves same-provider Xtream sections when switching through selectPlaylist', () => {
|
||||
router.url = '/workspace/xtreams/xtream-a/live';
|
||||
const service = instantiateFacade();
|
||||
|
||||
service.selectPlaylist(xtreamB);
|
||||
|
||||
expect(dispatch).toHaveBeenCalledWith(
|
||||
PlaylistActions.setActivePlaylist({ playlistId: xtreamB._id })
|
||||
);
|
||||
expect(router.navigate).toHaveBeenCalledWith([
|
||||
'workspace',
|
||||
'xtreams',
|
||||
xtreamB._id,
|
||||
'live',
|
||||
]);
|
||||
});
|
||||
|
||||
it('preserves same-provider Stalker sections when resolving target commands', () => {
|
||||
const service = instantiateFacade();
|
||||
|
||||
expect(
|
||||
service.resolveTargetCommands('stalker', stalkerB._id, {
|
||||
inWorkspace: true,
|
||||
provider: 'stalker',
|
||||
playlistId: stalkerA._id,
|
||||
section: 'downloads',
|
||||
})
|
||||
).toEqual(['workspace', 'stalker', stalkerB._id, 'downloads']);
|
||||
});
|
||||
|
||||
it('preserves same-provider M3U sections when resolving target commands', () => {
|
||||
const service = instantiateFacade();
|
||||
|
||||
expect(
|
||||
service.resolveTargetCommands('playlists', m3uB._id, {
|
||||
inWorkspace: true,
|
||||
provider: 'playlists',
|
||||
playlistId: m3uA._id,
|
||||
section: 'groups',
|
||||
})
|
||||
).toEqual(['workspace', 'playlists', m3uB._id, 'groups']);
|
||||
});
|
||||
|
||||
it('maps Stalker ITV routes to Xtream live routes', () => {
|
||||
const service = instantiateFacade();
|
||||
|
||||
expect(
|
||||
service.resolveTargetCommands('xtreams', xtreamA._id, {
|
||||
inWorkspace: true,
|
||||
provider: 'stalker',
|
||||
playlistId: stalkerA._id,
|
||||
section: 'itv',
|
||||
})
|
||||
).toEqual(['workspace', 'xtreams', xtreamA._id, 'live']);
|
||||
});
|
||||
|
||||
it('maps Xtream live routes to Stalker ITV routes', () => {
|
||||
const service = instantiateFacade();
|
||||
|
||||
expect(
|
||||
service.resolveTargetCommands('stalker', stalkerA._id, {
|
||||
inWorkspace: true,
|
||||
provider: 'xtreams',
|
||||
playlistId: xtreamA._id,
|
||||
section: 'live',
|
||||
})
|
||||
).toEqual(['workspace', 'stalker', stalkerA._id, 'itv']);
|
||||
});
|
||||
|
||||
it('maps Xtream recently-added routes to Stalker recent routes', () => {
|
||||
const service = instantiateFacade();
|
||||
|
||||
expect(
|
||||
service.resolveTargetCommands('stalker', stalkerA._id, {
|
||||
inWorkspace: true,
|
||||
provider: 'xtreams',
|
||||
playlistId: xtreamA._id,
|
||||
section: 'recently-added',
|
||||
})
|
||||
).toEqual(['workspace', 'stalker', stalkerA._id, 'recent']);
|
||||
});
|
||||
|
||||
it('preserves recent and favorites sections across provider changes when both providers support them', () => {
|
||||
const service = instantiateFacade();
|
||||
|
||||
expect(
|
||||
service.resolveTargetCommands('xtreams', xtreamA._id, {
|
||||
inWorkspace: true,
|
||||
provider: 'playlists',
|
||||
playlistId: m3uA._id,
|
||||
section: 'recent',
|
||||
})
|
||||
).toEqual(['workspace', 'xtreams', xtreamA._id, 'recent']);
|
||||
expect(
|
||||
service.resolveTargetCommands('stalker', stalkerA._id, {
|
||||
inWorkspace: true,
|
||||
provider: 'playlists',
|
||||
playlistId: m3uA._id,
|
||||
section: 'favorites',
|
||||
})
|
||||
).toEqual(['workspace', 'stalker', stalkerA._id, 'favorites']);
|
||||
});
|
||||
|
||||
it('falls back to M3U all when the current section has no M3U equivalent', () => {
|
||||
const service = instantiateFacade();
|
||||
|
||||
expect(
|
||||
service.resolveTargetCommands('playlists', m3uA._id, {
|
||||
inWorkspace: true,
|
||||
provider: 'xtreams',
|
||||
playlistId: xtreamA._id,
|
||||
section: 'live',
|
||||
})
|
||||
).toEqual(['workspace', 'playlists', m3uA._id, 'all']);
|
||||
});
|
||||
|
||||
it('does not let stale M3U memory override the fallback to all for portal-only sections', () => {
|
||||
localStorage.setItem(
|
||||
LAST_SECTION_STORAGE_KEY,
|
||||
JSON.stringify({
|
||||
providers: {
|
||||
playlists: 'recent',
|
||||
},
|
||||
playlists: {
|
||||
[m3uA._id]: {
|
||||
provider: 'playlists',
|
||||
section: 'recent',
|
||||
updatedAt: 1,
|
||||
},
|
||||
},
|
||||
})
|
||||
);
|
||||
const service = instantiateFacade();
|
||||
|
||||
expect(
|
||||
service.resolveTargetCommands('playlists', m3uA._id, {
|
||||
inWorkspace: true,
|
||||
provider: 'xtreams',
|
||||
playlistId: xtreamA._id,
|
||||
section: 'live',
|
||||
})
|
||||
).toEqual(['workspace', 'playlists', m3uA._id, 'all']);
|
||||
});
|
||||
|
||||
it('uses playlist-specific section memory when the current route has no section', () => {
|
||||
localStorage.setItem(
|
||||
LAST_SECTION_STORAGE_KEY,
|
||||
JSON.stringify({
|
||||
providers: {},
|
||||
playlists: {
|
||||
[xtreamB._id]: {
|
||||
provider: 'xtreams',
|
||||
section: 'series',
|
||||
updatedAt: 1,
|
||||
},
|
||||
},
|
||||
})
|
||||
);
|
||||
const service = instantiateFacade();
|
||||
|
||||
expect(
|
||||
service.resolveTargetCommands('xtreams', xtreamB._id, {
|
||||
inWorkspace: true,
|
||||
provider: 'xtreams',
|
||||
playlistId: xtreamA._id,
|
||||
section: null,
|
||||
})
|
||||
).toEqual(['workspace', 'xtreams', xtreamB._id, 'series']);
|
||||
});
|
||||
|
||||
it('uses provider-level section memory when no route section or playlist memory exists', () => {
|
||||
localStorage.setItem(
|
||||
LAST_SECTION_STORAGE_KEY,
|
||||
JSON.stringify({
|
||||
providers: {
|
||||
stalker: 'search',
|
||||
},
|
||||
playlists: {},
|
||||
})
|
||||
);
|
||||
const service = instantiateFacade();
|
||||
|
||||
expect(
|
||||
service.resolveTargetCommands('stalker', stalkerB._id, {
|
||||
inWorkspace: true,
|
||||
provider: 'xtreams',
|
||||
playlistId: xtreamA._id,
|
||||
section: null,
|
||||
})
|
||||
).toEqual(['workspace', 'stalker', stalkerB._id, 'search']);
|
||||
});
|
||||
|
||||
it('omits Xtream sections outside Electron where section navigation is unsupported', () => {
|
||||
setElectronAvailability(false);
|
||||
const service = instantiateFacade();
|
||||
|
||||
expect(
|
||||
service.resolveTargetCommands('xtreams', xtreamB._id, {
|
||||
inWorkspace: true,
|
||||
provider: 'xtreams',
|
||||
playlistId: xtreamA._id,
|
||||
section: 'live',
|
||||
})
|
||||
).toEqual(['workspace', 'xtreams', xtreamB._id]);
|
||||
});
|
||||
|
||||
it('returns null when switching from the workspace root before a provider route is selected', () => {
|
||||
const service = instantiateFacade();
|
||||
|
||||
expect(
|
||||
service.resolveTargetCommands('playlists', m3uA._id, {
|
||||
inWorkspace: true,
|
||||
provider: null,
|
||||
playlistId: null,
|
||||
section: null,
|
||||
})
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it('syncs section memory from router navigation events', () => {
|
||||
router.url = '/workspace/playlists/m3u-a/favorites';
|
||||
const service = instantiateFacade();
|
||||
|
||||
router.url = '/workspace/xtreams/xtream-a/recently-added';
|
||||
routerEvents.next(
|
||||
new NavigationEnd(
|
||||
1,
|
||||
'/workspace/xtreams/xtream-a/recently-added',
|
||||
'/workspace/xtreams/xtream-a/recently-added'
|
||||
)
|
||||
);
|
||||
|
||||
expect(service.routeContext()).toEqual<PlaylistRouteContext>({
|
||||
inWorkspace: true,
|
||||
provider: 'xtreams',
|
||||
playlistId: xtreamA._id,
|
||||
section: 'recently-added',
|
||||
});
|
||||
expect(JSON.parse(localStorage.getItem(LAST_SECTION_STORAGE_KEY) ?? '{}'))
|
||||
.toEqual({
|
||||
playlists: {
|
||||
[m3uA._id]: {
|
||||
provider: 'playlists',
|
||||
section: 'favorites',
|
||||
updatedAt: expect.any(Number),
|
||||
},
|
||||
[xtreamA._id]: {
|
||||
provider: 'xtreams',
|
||||
section: 'recently-added',
|
||||
updatedAt: expect.any(Number),
|
||||
},
|
||||
},
|
||||
providers: {
|
||||
playlists: 'favorites',
|
||||
xtreams: 'recently-added',
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,566 @@
|
||||
import {
|
||||
computed,
|
||||
DestroyRef,
|
||||
effect,
|
||||
inject,
|
||||
Injectable,
|
||||
signal,
|
||||
} from '@angular/core';
|
||||
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
|
||||
import { NavigationEnd, Router } from '@angular/router';
|
||||
import { Store } from '@ngrx/store';
|
||||
import {
|
||||
PlaylistActions,
|
||||
selectActivePlaylistId,
|
||||
selectAllPlaylistsMeta,
|
||||
selectPlaylistsLoadingFlag,
|
||||
} from 'm3u-state';
|
||||
import { filter } from 'rxjs';
|
||||
import { PlaylistMeta } from 'shared-interfaces';
|
||||
import {
|
||||
PortalProvider,
|
||||
PortalRailSection,
|
||||
} from '@iptvnator/portal/shared/util';
|
||||
|
||||
export interface PlaylistRouteContext {
|
||||
inWorkspace: boolean;
|
||||
provider: PortalProvider | null;
|
||||
playlistId: string | null;
|
||||
section: PortalRailSection | null;
|
||||
}
|
||||
|
||||
type PlaylistSectionMemory = {
|
||||
providers: Partial<Record<PortalProvider, string>>;
|
||||
playlists: Record<
|
||||
string,
|
||||
{
|
||||
provider: PortalProvider;
|
||||
section: string;
|
||||
updatedAt: number;
|
||||
}
|
||||
>;
|
||||
};
|
||||
|
||||
const LAST_SECTION_STORAGE_KEY = 'playlist-switcher:last-sections:v2';
|
||||
const LEGACY_LAST_SECTION_STORAGE_KEY = 'playlist-switcher:last-sections';
|
||||
const LAST_ACTIVE_PLAYLIST_STORAGE_KEY =
|
||||
'playlist-switcher:last-active-playlist-id';
|
||||
const XTREAM_SECTIONS = [
|
||||
'live',
|
||||
'vod',
|
||||
'series',
|
||||
'favorites',
|
||||
'recent',
|
||||
'search',
|
||||
'recently-added',
|
||||
'downloads',
|
||||
] as const;
|
||||
const STALKER_SECTIONS = [
|
||||
'itv',
|
||||
'vod',
|
||||
'series',
|
||||
'favorites',
|
||||
'recent',
|
||||
'search',
|
||||
'downloads',
|
||||
] as const;
|
||||
const M3U_SECTIONS = ['all', 'groups', 'favorites', 'recent'] as const;
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class PlaylistContextFacade {
|
||||
private readonly destroyRef = inject(DestroyRef);
|
||||
private readonly router = inject(Router);
|
||||
private readonly store = inject(Store);
|
||||
|
||||
readonly playlists = this.store.selectSignal(selectAllPlaylistsMeta);
|
||||
readonly allPlaylistsLoaded = this.store.selectSignal(
|
||||
selectPlaylistsLoadingFlag
|
||||
);
|
||||
readonly activePlaylistId = this.store.selectSignal(selectActivePlaylistId);
|
||||
|
||||
readonly currentUrl = signal(this.router.url);
|
||||
readonly routeContext = computed(() =>
|
||||
this.getRouteContext(this.currentUrl())
|
||||
);
|
||||
readonly routeProvider = computed(() => this.routeContext().provider);
|
||||
readonly routePlaylistId = computed(() => this.routeContext().playlistId);
|
||||
readonly routeSection = computed(() => this.routeContext().section);
|
||||
readonly resolvedPlaylistId = computed(() => {
|
||||
const routePlaylistId = this.routePlaylistId();
|
||||
if (routePlaylistId) {
|
||||
return routePlaylistId;
|
||||
}
|
||||
|
||||
const activePlaylistId = this.activePlaylistId();
|
||||
return activePlaylistId || null;
|
||||
});
|
||||
readonly activePlaylist = computed(() => {
|
||||
const playlistId = this.resolvedPlaylistId();
|
||||
if (!playlistId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
this.playlists().find((playlist) => playlist._id === playlistId) ??
|
||||
null
|
||||
);
|
||||
});
|
||||
readonly activeProvider = computed(() => {
|
||||
const playlist = this.activePlaylist();
|
||||
return playlist ? this.getProviderForPlaylist(playlist) : null;
|
||||
});
|
||||
|
||||
constructor() {
|
||||
this.clearLegacySectionMemory();
|
||||
this.syncFromUrl(this.router.url);
|
||||
|
||||
effect(() => {
|
||||
const allPlaylistsLoaded = this.allPlaylistsLoaded();
|
||||
const playlistIds = this.playlists().map(
|
||||
(playlist) => playlist._id
|
||||
);
|
||||
const playlistIdSet = new Set(playlistIds);
|
||||
const selectedId = this.activePlaylistId() || null;
|
||||
const routeId = this.routePlaylistId();
|
||||
const storedId = this.readPersistedActivePlaylistId();
|
||||
|
||||
if (!allPlaylistsLoaded) {
|
||||
if (routeId) {
|
||||
this.writePersistedActivePlaylistId(routeId);
|
||||
return;
|
||||
}
|
||||
|
||||
if (selectedId) {
|
||||
this.writePersistedActivePlaylistId(selectedId);
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
this.removeDeletedPlaylistsFromSectionMemory(playlistIds);
|
||||
|
||||
if (routeId && playlistIdSet.has(routeId)) {
|
||||
this.writePersistedActivePlaylistId(routeId);
|
||||
return;
|
||||
}
|
||||
|
||||
if (selectedId && playlistIdSet.has(selectedId)) {
|
||||
this.writePersistedActivePlaylistId(selectedId);
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
!routeId &&
|
||||
!selectedId &&
|
||||
storedId &&
|
||||
playlistIdSet.has(storedId)
|
||||
) {
|
||||
this.activatePlaylist(storedId);
|
||||
return;
|
||||
}
|
||||
|
||||
if (storedId && !playlistIdSet.has(storedId)) {
|
||||
this.clearPersistedActivePlaylistId();
|
||||
}
|
||||
});
|
||||
|
||||
this.router.events
|
||||
.pipe(
|
||||
filter(
|
||||
(event): event is NavigationEnd =>
|
||||
event instanceof NavigationEnd
|
||||
),
|
||||
takeUntilDestroyed(this.destroyRef)
|
||||
)
|
||||
.subscribe((event) => {
|
||||
this.syncFromUrl(event.urlAfterRedirects);
|
||||
});
|
||||
}
|
||||
|
||||
syncFromUrl(url = this.router.url): PlaylistRouteContext {
|
||||
this.currentUrl.set(url);
|
||||
const routeContext = this.routeContext();
|
||||
|
||||
this.rememberSection(routeContext);
|
||||
|
||||
if (routeContext.playlistId) {
|
||||
this.activatePlaylist(routeContext.playlistId);
|
||||
}
|
||||
|
||||
return routeContext;
|
||||
}
|
||||
|
||||
activatePlaylist(playlistId: string): void {
|
||||
if (!playlistId || this.activePlaylistId() === playlistId) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.store.dispatch(PlaylistActions.setActivePlaylist({ playlistId }));
|
||||
}
|
||||
|
||||
selectPlaylist(playlist: PlaylistMeta): void {
|
||||
const routeContext = this.routeContext();
|
||||
const provider = this.getProviderForPlaylist(playlist);
|
||||
|
||||
this.activatePlaylist(playlist._id);
|
||||
|
||||
const targetCommands = this.resolveTargetCommands(
|
||||
provider,
|
||||
playlist._id,
|
||||
routeContext
|
||||
);
|
||||
|
||||
if (targetCommands) {
|
||||
void this.router.navigate(targetCommands);
|
||||
}
|
||||
}
|
||||
|
||||
resolveTargetCommands(
|
||||
provider: PortalProvider,
|
||||
playlistId: string,
|
||||
routeContext = this.routeContext()
|
||||
): string[] | null {
|
||||
const prefix = routeContext.inWorkspace ? ['workspace'] : [];
|
||||
|
||||
if (routeContext.inWorkspace && !routeContext.provider) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (provider === 'playlists') {
|
||||
const section = this.resolveTargetSection(
|
||||
provider,
|
||||
routeContext.section,
|
||||
playlistId
|
||||
);
|
||||
return [...prefix, 'playlists', playlistId, section];
|
||||
}
|
||||
|
||||
if (!this.supportsSectionNavigation(provider)) {
|
||||
return [...prefix, provider, playlistId];
|
||||
}
|
||||
|
||||
const section = this.resolveTargetSection(
|
||||
provider,
|
||||
routeContext.section,
|
||||
playlistId
|
||||
);
|
||||
return [...prefix, provider, playlistId, section];
|
||||
}
|
||||
|
||||
getProviderForPlaylist(playlist: PlaylistMeta): PortalProvider {
|
||||
if (playlist.serverUrl) {
|
||||
return 'xtreams';
|
||||
}
|
||||
if (playlist.macAddress) {
|
||||
return 'stalker';
|
||||
}
|
||||
return 'playlists';
|
||||
}
|
||||
|
||||
private isWorkspaceRoute(url: string): boolean {
|
||||
return /^\/workspace(?:\/|$)/.test(url);
|
||||
}
|
||||
|
||||
private getRouteContext(url: string): PlaylistRouteContext {
|
||||
const match = url.match(
|
||||
/^\/(?:workspace\/)?(playlists|xtreams|stalker)\/([^/?]+)(?:\/([^/?]+))?/
|
||||
);
|
||||
|
||||
return {
|
||||
inWorkspace: this.isWorkspaceRoute(url),
|
||||
provider: (match?.[1] as PortalProvider | undefined) ?? null,
|
||||
playlistId: match?.[2] ?? null,
|
||||
section: (match?.[3] as PortalRailSection | undefined) ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
private rememberSection(routeContext: PlaylistRouteContext): void {
|
||||
if (!routeContext.provider || !routeContext.section) {
|
||||
return;
|
||||
}
|
||||
|
||||
const normalized = this.normalizeSectionForProvider(
|
||||
routeContext.section,
|
||||
routeContext.provider
|
||||
);
|
||||
if (!normalized) {
|
||||
return;
|
||||
}
|
||||
|
||||
const memory = this.readSectionMemory();
|
||||
memory.providers[routeContext.provider] = normalized;
|
||||
if (routeContext.playlistId) {
|
||||
memory.playlists[routeContext.playlistId] = {
|
||||
provider: routeContext.provider,
|
||||
section: normalized,
|
||||
updatedAt: Date.now(),
|
||||
};
|
||||
}
|
||||
this.writeSectionMemory(memory);
|
||||
}
|
||||
|
||||
private resolveTargetSection(
|
||||
provider: PortalProvider,
|
||||
currentSection: PortalRailSection | null,
|
||||
targetPlaylistId: string
|
||||
): string {
|
||||
const fromCurrent = this.normalizeSectionForProvider(
|
||||
currentSection,
|
||||
provider
|
||||
);
|
||||
if (fromCurrent) {
|
||||
return fromCurrent;
|
||||
}
|
||||
|
||||
if (provider === 'playlists' && currentSection) {
|
||||
return 'all';
|
||||
}
|
||||
|
||||
const memory = this.readSectionMemory();
|
||||
const playlistMemory = memory.playlists[targetPlaylistId];
|
||||
if (playlistMemory?.provider === provider) {
|
||||
const normalizedPlaylistSection = this.normalizeSectionForProvider(
|
||||
playlistMemory.section,
|
||||
provider
|
||||
);
|
||||
if (normalizedPlaylistSection) {
|
||||
return normalizedPlaylistSection;
|
||||
}
|
||||
}
|
||||
|
||||
const providerMemory = memory.providers[provider];
|
||||
const normalizedProviderSection = this.normalizeSectionForProvider(
|
||||
providerMemory,
|
||||
provider
|
||||
);
|
||||
if (normalizedProviderSection) {
|
||||
return normalizedProviderSection;
|
||||
}
|
||||
|
||||
if (provider === 'playlists') {
|
||||
return 'all';
|
||||
}
|
||||
|
||||
return 'vod';
|
||||
}
|
||||
|
||||
private supportsSectionNavigation(provider: PortalProvider): boolean {
|
||||
if (provider === 'xtreams') {
|
||||
return Boolean(window.electron);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private normalizeSectionForProvider(
|
||||
section: string | null | undefined,
|
||||
provider: PortalProvider
|
||||
): string | null {
|
||||
if (!section) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (provider === 'xtreams') {
|
||||
if (section === 'itv') {
|
||||
return 'live';
|
||||
}
|
||||
|
||||
return XTREAM_SECTIONS.includes(
|
||||
section as (typeof XTREAM_SECTIONS)[number]
|
||||
)
|
||||
? section
|
||||
: null;
|
||||
}
|
||||
|
||||
if (provider === 'playlists') {
|
||||
return M3U_SECTIONS.includes(
|
||||
section as (typeof M3U_SECTIONS)[number]
|
||||
)
|
||||
? section
|
||||
: null;
|
||||
}
|
||||
|
||||
if (section === 'live') {
|
||||
return 'itv';
|
||||
}
|
||||
if (section === 'recently-added') {
|
||||
return 'recent';
|
||||
}
|
||||
|
||||
return STALKER_SECTIONS.includes(
|
||||
section as (typeof STALKER_SECTIONS)[number]
|
||||
)
|
||||
? section
|
||||
: null;
|
||||
}
|
||||
|
||||
private readSectionMemory(): PlaylistSectionMemory {
|
||||
const empty: PlaylistSectionMemory = { providers: {}, playlists: {} };
|
||||
|
||||
try {
|
||||
this.clearLegacySectionMemory();
|
||||
const raw = localStorage.getItem(LAST_SECTION_STORAGE_KEY);
|
||||
if (!raw) {
|
||||
return empty;
|
||||
}
|
||||
const parsed = JSON.parse(raw);
|
||||
if (typeof parsed !== 'object' || parsed === null) {
|
||||
return empty;
|
||||
}
|
||||
|
||||
const providers: Partial<Record<PortalProvider, string>> = {};
|
||||
const playlists: PlaylistSectionMemory['playlists'] = {};
|
||||
|
||||
const parsedProviders = (parsed as Record<string, unknown>)[
|
||||
'providers'
|
||||
];
|
||||
if (typeof parsedProviders === 'object' && parsedProviders !== null) {
|
||||
const candidate = parsedProviders as Record<string, unknown>;
|
||||
if (typeof candidate['playlists'] === 'string') {
|
||||
providers.playlists = candidate['playlists'];
|
||||
}
|
||||
if (typeof candidate['xtreams'] === 'string') {
|
||||
providers.xtreams = candidate['xtreams'];
|
||||
}
|
||||
if (typeof candidate['stalker'] === 'string') {
|
||||
providers.stalker = candidate['stalker'];
|
||||
}
|
||||
}
|
||||
|
||||
const parsedPlaylists = (parsed as Record<string, unknown>)[
|
||||
'playlists'
|
||||
];
|
||||
if (
|
||||
typeof parsedPlaylists === 'object' &&
|
||||
parsedPlaylists !== null
|
||||
) {
|
||||
Object.entries(
|
||||
parsedPlaylists as Record<string, unknown>
|
||||
).forEach(([playlistId, value]) => {
|
||||
if (!value || typeof value !== 'object' || !playlistId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const row = value as Record<string, unknown>;
|
||||
const provider = row['provider'];
|
||||
const section = row['section'];
|
||||
const updatedAt = row['updatedAt'];
|
||||
const isProviderValid =
|
||||
provider === 'playlists' ||
|
||||
provider === 'xtreams' ||
|
||||
provider === 'stalker';
|
||||
if (!isProviderValid || typeof section !== 'string') {
|
||||
return;
|
||||
}
|
||||
|
||||
playlists[playlistId] = {
|
||||
provider,
|
||||
section,
|
||||
updatedAt:
|
||||
typeof updatedAt === 'number'
|
||||
? updatedAt
|
||||
: Date.now(),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
return { providers, playlists };
|
||||
} catch {
|
||||
return empty;
|
||||
}
|
||||
}
|
||||
|
||||
private writeSectionMemory(memory: PlaylistSectionMemory): void {
|
||||
try {
|
||||
localStorage.setItem(
|
||||
LAST_SECTION_STORAGE_KEY,
|
||||
JSON.stringify(memory)
|
||||
);
|
||||
} catch {
|
||||
// Ignore storage write failures.
|
||||
}
|
||||
}
|
||||
|
||||
private removeDeletedPlaylistsFromSectionMemory(
|
||||
existingIds: string[]
|
||||
): void {
|
||||
const existingIdSet = new Set(existingIds);
|
||||
const memory = this.readSectionMemory();
|
||||
const currentEntries = Object.entries(memory.playlists);
|
||||
if (currentEntries.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const nextPlaylists: PlaylistSectionMemory['playlists'] = {};
|
||||
let hasChanges = false;
|
||||
|
||||
currentEntries.forEach(([playlistId, entry]) => {
|
||||
if (!existingIdSet.has(playlistId)) {
|
||||
hasChanges = true;
|
||||
return;
|
||||
}
|
||||
|
||||
const normalizedSection = this.normalizeSectionForProvider(
|
||||
entry.section,
|
||||
entry.provider
|
||||
);
|
||||
if (!normalizedSection) {
|
||||
hasChanges = true;
|
||||
return;
|
||||
}
|
||||
|
||||
nextPlaylists[playlistId] = {
|
||||
...entry,
|
||||
section: normalizedSection,
|
||||
};
|
||||
});
|
||||
|
||||
if (!hasChanges) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.writeSectionMemory({
|
||||
providers: memory.providers,
|
||||
playlists: nextPlaylists,
|
||||
});
|
||||
}
|
||||
|
||||
private readPersistedActivePlaylistId(): string | null {
|
||||
try {
|
||||
const value = localStorage.getItem(
|
||||
LAST_ACTIVE_PLAYLIST_STORAGE_KEY
|
||||
);
|
||||
return value && value.trim().length > 0 ? value : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private writePersistedActivePlaylistId(playlistId: string): void {
|
||||
try {
|
||||
localStorage.setItem(LAST_ACTIVE_PLAYLIST_STORAGE_KEY, playlistId);
|
||||
} catch {
|
||||
// Ignore storage write failures.
|
||||
}
|
||||
}
|
||||
|
||||
private clearPersistedActivePlaylistId(): void {
|
||||
try {
|
||||
localStorage.removeItem(LAST_ACTIVE_PLAYLIST_STORAGE_KEY);
|
||||
} catch {
|
||||
// Ignore storage write failures.
|
||||
}
|
||||
}
|
||||
|
||||
private clearLegacySectionMemory(): void {
|
||||
try {
|
||||
if (!localStorage.getItem(LEGACY_LAST_SECTION_STORAGE_KEY)) {
|
||||
return;
|
||||
}
|
||||
|
||||
localStorage.removeItem(LEGACY_LAST_SECTION_STORAGE_KEY);
|
||||
} catch {
|
||||
// Ignore storage read/write failures.
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import { Injectable, inject } from '@angular/core';
|
||||
import { Store } from '@ngrx/store';
|
||||
import { PlaylistActions } from 'm3u-state';
|
||||
|
||||
const M3U_EXTENSIONS = ['.m3u', '.m3u8'];
|
||||
|
||||
export type PlaylistFileImportResult =
|
||||
| { ok: true; title: string }
|
||||
| { ok: false; reason: 'unsupported' | 'empty' | 'read-error' };
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class PlaylistFileImportService {
|
||||
private readonly store = inject(Store);
|
||||
|
||||
isSupportedFile(file: File): boolean {
|
||||
const lower = file.name.toLowerCase();
|
||||
return M3U_EXTENSIONS.some((ext) => lower.endsWith(ext));
|
||||
}
|
||||
|
||||
async importFile(file: File): Promise<PlaylistFileImportResult> {
|
||||
if (!this.isSupportedFile(file)) {
|
||||
return { ok: false, reason: 'unsupported' };
|
||||
}
|
||||
|
||||
let playlist: string;
|
||||
try {
|
||||
playlist = await file.text();
|
||||
} catch {
|
||||
return { ok: false, reason: 'read-error' };
|
||||
}
|
||||
|
||||
if (!playlist.trim()) {
|
||||
return { ok: false, reason: 'empty' };
|
||||
}
|
||||
|
||||
const title = this.normalizeTitle(file.name);
|
||||
this.store.dispatch(
|
||||
PlaylistActions.parsePlaylist({
|
||||
uploadType: 'FILE',
|
||||
playlist,
|
||||
title,
|
||||
path: (file as File & { path?: string }).path,
|
||||
})
|
||||
);
|
||||
|
||||
return { ok: true, title };
|
||||
}
|
||||
|
||||
private normalizeTitle(filename: string): string {
|
||||
const trimmed = filename.trim();
|
||||
if (!trimmed) {
|
||||
return filename;
|
||||
}
|
||||
return trimmed.replace(/\.(m3u8?|pls|txt)$/i, '') || trimmed;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import { InjectionToken } from '@angular/core';
|
||||
|
||||
export interface PlaylistPlayerActions {
|
||||
openSettings(): void;
|
||||
}
|
||||
|
||||
export const PLAYLIST_PLAYER_ACTIONS =
|
||||
new InjectionToken<PlaylistPlayerActions>('PLAYLIST_PLAYER_ACTIONS');
|
||||
@@ -0,0 +1,272 @@
|
||||
import { signal } from '@angular/core';
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { MatSnackBar } from '@angular/material/snack-bar';
|
||||
import { Router } from '@angular/router';
|
||||
import { Store } from '@ngrx/store';
|
||||
import { TranslateService } from '@ngx-translate/core';
|
||||
import { DialogService } from 'components';
|
||||
import {
|
||||
DatabaseService,
|
||||
PlaybackPositionService,
|
||||
PlaylistRefreshService,
|
||||
} from 'services';
|
||||
import { ChannelActions, PlaylistActions } from 'm3u-state';
|
||||
import { Playlist, PlaylistMeta } from 'shared-interfaces';
|
||||
import { PlaylistContextFacade } from './playlist-context.facade';
|
||||
import { PlaylistRefreshActionService } from './playlist-refresh-action.service';
|
||||
|
||||
function createPlaylistMeta(
|
||||
overrides: Partial<PlaylistMeta> = {}
|
||||
): PlaylistMeta {
|
||||
return {
|
||||
_id: 'playlist-1',
|
||||
title: 'Xtream Playlist',
|
||||
serverUrl: 'http://localhost:8080',
|
||||
username: 'demo',
|
||||
password: 'secret',
|
||||
...overrides,
|
||||
} as PlaylistMeta;
|
||||
}
|
||||
|
||||
describe('PlaylistRefreshActionService', () => {
|
||||
let service: PlaylistRefreshActionService;
|
||||
let databaseService: {
|
||||
createOperationId: jest.Mock;
|
||||
deleteXtreamPlaylistContent: jest.Mock;
|
||||
updateXtreamPlaylistDetails: jest.Mock;
|
||||
};
|
||||
let dialogService: {
|
||||
openConfirmDialog: jest.Mock;
|
||||
};
|
||||
let router: {
|
||||
navigate: jest.Mock;
|
||||
};
|
||||
let snackBar: {
|
||||
open: jest.Mock;
|
||||
};
|
||||
let store: {
|
||||
dispatch: jest.Mock;
|
||||
};
|
||||
let playlistRefreshService: {
|
||||
refreshPlaylist: jest.Mock;
|
||||
};
|
||||
let playbackPositionService: {
|
||||
getAllPlaybackPositions: jest.Mock;
|
||||
};
|
||||
let routeProvider: ReturnType<
|
||||
typeof signal<'playlists' | 'xtreams' | null>
|
||||
>;
|
||||
let resolvedPlaylistId: ReturnType<typeof signal<string | null>>;
|
||||
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
|
||||
databaseService = {
|
||||
createOperationId: jest.fn((prefix: string) => `${prefix}-op`),
|
||||
deleteXtreamPlaylistContent: jest.fn().mockResolvedValue({
|
||||
success: true,
|
||||
favorites: [
|
||||
{
|
||||
xtreamId: 101,
|
||||
contentType: 'live',
|
||||
},
|
||||
{
|
||||
xtreamId: 202,
|
||||
contentType: 'movie',
|
||||
},
|
||||
],
|
||||
recentlyViewed: [
|
||||
{
|
||||
xtreamId: 303,
|
||||
contentType: 'series',
|
||||
viewedAt: '2026-04-04T08:00:00.000Z',
|
||||
},
|
||||
],
|
||||
hiddenCategories: [{ xtreamId: 404, categoryType: 'live' }],
|
||||
}),
|
||||
updateXtreamPlaylistDetails: jest.fn().mockResolvedValue(true),
|
||||
};
|
||||
dialogService = {
|
||||
openConfirmDialog: jest.fn(),
|
||||
};
|
||||
router = {
|
||||
navigate: jest.fn().mockResolvedValue(true),
|
||||
};
|
||||
snackBar = {
|
||||
open: jest.fn(),
|
||||
};
|
||||
store = {
|
||||
dispatch: jest.fn(),
|
||||
};
|
||||
playlistRefreshService = {
|
||||
refreshPlaylist: jest.fn(),
|
||||
};
|
||||
playbackPositionService = {
|
||||
getAllPlaybackPositions: jest.fn().mockResolvedValue([]),
|
||||
};
|
||||
routeProvider = signal<'playlists' | 'xtreams' | null>('xtreams');
|
||||
resolvedPlaylistId = signal<string | null>(null);
|
||||
|
||||
TestBed.configureTestingModule({
|
||||
providers: [
|
||||
PlaylistRefreshActionService,
|
||||
{
|
||||
provide: Router,
|
||||
useValue: router,
|
||||
},
|
||||
{
|
||||
provide: Store,
|
||||
useValue: store,
|
||||
},
|
||||
{
|
||||
provide: TranslateService,
|
||||
useValue: {
|
||||
instant: jest.fn((key: string) => key),
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: MatSnackBar,
|
||||
useValue: snackBar,
|
||||
},
|
||||
{
|
||||
provide: DialogService,
|
||||
useValue: dialogService,
|
||||
},
|
||||
{
|
||||
provide: DatabaseService,
|
||||
useValue: databaseService,
|
||||
},
|
||||
{
|
||||
provide: PlaylistRefreshService,
|
||||
useValue: playlistRefreshService,
|
||||
},
|
||||
{
|
||||
provide: PlaybackPositionService,
|
||||
useValue: playbackPositionService,
|
||||
},
|
||||
{
|
||||
provide: PlaylistContextFacade,
|
||||
useValue: {
|
||||
routeProvider,
|
||||
resolvedPlaylistId,
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
service = TestBed.inject(PlaylistRefreshActionService);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.restoreAllMocks();
|
||||
localStorage.clear();
|
||||
});
|
||||
|
||||
it('stores Xtream restore data before updating playlist meta and navigating', async () => {
|
||||
const item = createPlaylistMeta();
|
||||
const executionOrder: string[] = [];
|
||||
let confirmPromise: Promise<void> | undefined;
|
||||
const dateNowSpy = jest
|
||||
.spyOn(Date, 'now')
|
||||
.mockReturnValue(1712217600000);
|
||||
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);
|
||||
});
|
||||
|
||||
store.dispatch.mockImplementation((action: unknown) => {
|
||||
executionOrder.push('dispatch');
|
||||
return action;
|
||||
});
|
||||
router.navigate.mockImplementation((commands: unknown[]) => {
|
||||
executionOrder.push('navigate');
|
||||
return Promise.resolve(Boolean(commands));
|
||||
});
|
||||
dialogService.openConfirmDialog.mockImplementation(
|
||||
({ onConfirm }: { onConfirm?: () => Promise<void> }) => {
|
||||
confirmPromise = onConfirm?.();
|
||||
}
|
||||
);
|
||||
|
||||
service.refresh(item);
|
||||
await confirmPromise;
|
||||
|
||||
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-04T08:00:00.000Z',
|
||||
},
|
||||
],
|
||||
playbackPositions: [],
|
||||
})
|
||||
);
|
||||
expect(store.dispatch).toHaveBeenCalledWith(
|
||||
PlaylistActions.updatePlaylistMeta({
|
||||
playlist: { ...item, updateDate: 1712217600000 },
|
||||
})
|
||||
);
|
||||
expect(router.navigate).toHaveBeenCalledWith([
|
||||
'/workspace',
|
||||
'xtreams',
|
||||
item._id,
|
||||
]);
|
||||
expect(executionOrder).toEqual(['setItem', 'dispatch', 'navigate']);
|
||||
|
||||
setItemSpy.mockRestore();
|
||||
dateNowSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('marks the active M3U route as loading before refreshing and clears loading after the update action', async () => {
|
||||
const item = createPlaylistMeta({
|
||||
_id: 'playlist-1',
|
||||
filePath: '/tmp/playlist.m3u',
|
||||
serverUrl: undefined,
|
||||
url: 'https://example.com/playlist.m3u',
|
||||
});
|
||||
const refreshedPlaylist = {
|
||||
_id: item._id,
|
||||
playlist: {
|
||||
items: [],
|
||||
},
|
||||
} as Playlist;
|
||||
routeProvider.set('playlists');
|
||||
resolvedPlaylistId.set(item._id);
|
||||
playlistRefreshService.refreshPlaylist.mockResolvedValue(
|
||||
refreshedPlaylist
|
||||
);
|
||||
|
||||
service.refresh(item);
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
|
||||
expect(store.dispatch).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
ChannelActions.setChannelsLoading({ loading: true })
|
||||
);
|
||||
expect(store.dispatch).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
PlaylistActions.updatePlaylist({
|
||||
playlist: refreshedPlaylist,
|
||||
playlistId: item._id,
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,210 @@
|
||||
import { inject, Injectable, signal } from '@angular/core';
|
||||
import { Router } from '@angular/router';
|
||||
import { Store } from '@ngrx/store';
|
||||
import { TranslateService } from '@ngx-translate/core';
|
||||
import { MatSnackBar } from '@angular/material/snack-bar';
|
||||
import { DialogService } from 'components';
|
||||
import {
|
||||
DatabaseService,
|
||||
isDbAbortError,
|
||||
PlaybackPositionService,
|
||||
PlaylistRefreshService,
|
||||
XtreamPendingRestoreService,
|
||||
} from 'services';
|
||||
import { ChannelActions, PlaylistActions } from 'm3u-state';
|
||||
import { PlaylistMeta } from 'shared-interfaces';
|
||||
import { PlaylistContextFacade } from './playlist-context.facade';
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class PlaylistRefreshActionService {
|
||||
private readonly router = inject(Router);
|
||||
private readonly store = inject(Store);
|
||||
private readonly translate = inject(TranslateService);
|
||||
private readonly snackBar = inject(MatSnackBar);
|
||||
private readonly dialogService = inject(DialogService);
|
||||
private readonly databaseService = inject(DatabaseService);
|
||||
private readonly playbackPositionService = inject(PlaybackPositionService);
|
||||
private readonly playlistRefreshService = inject(PlaylistRefreshService);
|
||||
private readonly playlistContext = inject(PlaylistContextFacade);
|
||||
private readonly pendingRestoreService = inject(
|
||||
XtreamPendingRestoreService
|
||||
);
|
||||
|
||||
readonly isRefreshing = signal(false);
|
||||
|
||||
canRefresh(playlist: PlaylistMeta | null): boolean {
|
||||
if (!playlist || !window.electron) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return Boolean(playlist.serverUrl || playlist.url || playlist.filePath);
|
||||
}
|
||||
|
||||
refresh(playlist: PlaylistMeta): void {
|
||||
if (this.isRefreshing()) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (playlist.serverUrl) {
|
||||
this.refreshXtream(playlist);
|
||||
} else if (playlist.url || playlist.filePath) {
|
||||
void this.refreshM3u(playlist);
|
||||
}
|
||||
}
|
||||
|
||||
private refreshXtream(item: PlaylistMeta): void {
|
||||
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.isRefreshing()) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.isRefreshing.set(true);
|
||||
const operationId =
|
||||
this.databaseService.createOperationId('xtream-refresh');
|
||||
|
||||
try {
|
||||
this.snackBar.open(
|
||||
this.translate.instant(
|
||||
'HOME.PLAYLISTS.REFRESH_XTREAM_DIALOG.STARTED'
|
||||
),
|
||||
undefined,
|
||||
{ duration: 2000 }
|
||||
);
|
||||
|
||||
const updateDate = Date.now();
|
||||
const [restoreState, playbackPositions] = await Promise.all(
|
||||
[
|
||||
this.databaseService.deleteXtreamPlaylistContent(
|
||||
item._id,
|
||||
{ operationId }
|
||||
),
|
||||
this.playbackPositionService.getAllPlaybackPositions(
|
||||
item._id
|
||||
),
|
||||
this.databaseService.updateXtreamPlaylistDetails({
|
||||
id: item._id,
|
||||
updateDate,
|
||||
}),
|
||||
]
|
||||
);
|
||||
|
||||
this.pendingRestoreService.set(item._id, {
|
||||
...restoreState,
|
||||
playbackPositions,
|
||||
});
|
||||
|
||||
this.store.dispatch(
|
||||
PlaylistActions.updatePlaylistMeta({
|
||||
playlist: { ...item, updateDate },
|
||||
})
|
||||
);
|
||||
|
||||
await 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.isRefreshing.set(false);
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
private async refreshM3u(item: PlaylistMeta): Promise<void> {
|
||||
const isActiveM3uRoute =
|
||||
this.playlistContext.routeProvider() === 'playlists' &&
|
||||
this.playlistContext.resolvedPlaylistId() === item._id;
|
||||
|
||||
this.isRefreshing.set(true);
|
||||
if (isActiveM3uRoute) {
|
||||
this.store.dispatch(
|
||||
ChannelActions.setChannelsLoading({ loading: true })
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
const refreshedPlaylist =
|
||||
await this.playlistRefreshService.refreshPlaylist({
|
||||
operationId:
|
||||
this.databaseService.createOperationId(
|
||||
'playlist-refresh'
|
||||
),
|
||||
playlistId: item._id,
|
||||
title: item.title,
|
||||
url: item.url,
|
||||
filePath: item.filePath,
|
||||
});
|
||||
|
||||
this.store.dispatch(
|
||||
PlaylistActions.updatePlaylist({
|
||||
playlist: {
|
||||
...refreshedPlaylist,
|
||||
_id: item._id,
|
||||
},
|
||||
playlistId: item._id,
|
||||
})
|
||||
);
|
||||
|
||||
this.snackBar.open(
|
||||
this.translate.instant(
|
||||
'HOME.PLAYLISTS.PLAYLIST_UPDATE_SUCCESS'
|
||||
),
|
||||
undefined,
|
||||
{ duration: 2000 }
|
||||
);
|
||||
} catch (error) {
|
||||
if (!isDbAbortError(error)) {
|
||||
console.error('Error refreshing playlist:', error);
|
||||
this.snackBar.open(
|
||||
this.getRefreshErrorMessage(error, item),
|
||||
this.translate.instant('CLOSE'),
|
||||
{ duration: 5000 }
|
||||
);
|
||||
}
|
||||
|
||||
if (isActiveM3uRoute) {
|
||||
this.store.dispatch(
|
||||
ChannelActions.setChannelsLoading({ loading: false })
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
this.isRefreshing.set(false);
|
||||
}
|
||||
}
|
||||
|
||||
private getRefreshErrorMessage(error: unknown, item: PlaylistMeta): string {
|
||||
if (
|
||||
error instanceof Error &&
|
||||
error.message?.includes('ENOENT') &&
|
||||
item.filePath
|
||||
) {
|
||||
return this.translate.instant(
|
||||
'HOME.PLAYLISTS.PLAYLIST_UPDATE_FILE_NOT_FOUND'
|
||||
);
|
||||
}
|
||||
|
||||
return this.translate.instant('HOME.PLAYLISTS.PLAYLIST_UPDATE_ERROR');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import { setupZoneTestEnv } from 'jest-preset-angular/setup-env/zone';
|
||||
|
||||
setupZoneTestEnv({
|
||||
errorOnUnknownElements: true,
|
||||
errorOnUnknownProperties: true,
|
||||
});
|
||||
@@ -0,0 +1,28 @@
|
||||
{
|
||||
"extends": "../../../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"isolatedModules": true,
|
||||
"target": "es2022",
|
||||
"moduleResolution": "bundler",
|
||||
"strict": true,
|
||||
"noImplicitOverride": true,
|
||||
"noPropertyAccessFromIndexSignature": true,
|
||||
"noImplicitReturns": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"emitDecoratorMetadata": false,
|
||||
"module": "preserve"
|
||||
},
|
||||
"angularCompilerOptions": {
|
||||
"enableI18nLegacyMessageIdFormat": false,
|
||||
"strictInjectionParameters": true,
|
||||
"strictInputAccessModifiers": true,
|
||||
"strictTemplates": true
|
||||
},
|
||||
"files": [],
|
||||
"include": [],
|
||||
"references": [
|
||||
{
|
||||
"path": "./tsconfig.lib.json"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "../../../../dist/out-tsc",
|
||||
"declaration": true,
|
||||
"declarationMap": true,
|
||||
"inlineSources": true,
|
||||
"types": []
|
||||
},
|
||||
"include": ["src/**/*.ts"],
|
||||
"exclude": ["src/**/*.spec.ts", "src/**/*.test.ts"]
|
||||
}
|
||||
@@ -0,0 +1,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"
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user