refactor: update nx monorepo structure

This commit is contained in:
4gray committed 2026-03-09 20:28:31 +01:00
1 parent 9834c9fc0d
commit 0ad1eeb785
193 files changed
+1908 -3370

No files matched your search

+5 -3
View File
@@ -5,6 +5,11 @@ import { Router, RouterOutlet } from '@angular/router';
import { Actions, ofType } from '@ngrx/effects';
import { Store } from '@ngrx/store';
import { TranslateService } from '@ngx-translate/core';
import {
GlobalRecentlyViewedComponent,
GlobalSearchResultsComponent,
} from '@iptvnator/portal/xtream/feature';
import { EpgProgressPanelComponent } from 'components';
import { PlaylistActions, selectAllPlaylistsMeta } from 'm3u-state';
import { filter, take } from 'rxjs';
import { DataService, EpgService } from 'services';
@@ -17,9 +22,6 @@ import {
Theme,
} from 'shared-interfaces';
import { SettingsService } from './services/settings.service';
import { EpgProgressPanelComponent } from './shared/epg-progress-panel/epg-progress-panel.component';
import { GlobalRecentlyViewedComponent } from './xtream-electron/recently-viewed/global-recently-viewed.component';
import { GlobalSearchResultsComponent } from './xtream-electron/search-results/global-search-results.component';
@Component({
selector: 'app-root',
+22 -2
View File
@@ -20,16 +20,26 @@ import { TranslateHttpLoader } from '@ngx-translate/http-loader';
import { PlaylistEffects, playlistReducer } from 'm3u-state';
import { NgxIndexedDBModule, NgxIndexedDBService } from 'ngx-indexed-db';
import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader';
import { PORTAL_PLAYER } from '@iptvnator/portal/shared/util';
import {
PORTAL_EXTERNAL_PLAYBACK,
PORTAL_PLAYER,
} from '@iptvnator/portal/shared/util';
import { PLAYLIST_PLAYER_ACTIONS } from '@iptvnator/playlist/m3u/feature-player';
import { provideXtreamDataSource } from '@iptvnator/portal/xtream/data-access';
import { DataService } from 'services';
import { dbConfig } from 'shared-interfaces';
import { AppConfig } from '../environments/environment';
import { routes } from './app.routes';
import { ElectronService } from './services/electron.service';
import { ExternalPlaybackService } from './services/external-playback.service';
import { PlayerService } from './services/player.service';
import { providePortalNavigationActions } from './services/portal-navigation-actions.service';
import {
AppPortalNavigationActionsService,
providePortalNavigationActions,
} from './services/portal-navigation-actions.service';
import { providePortalPlaybackPositions } from './services/portal-playback-positions.service';
import { PwaService } from './services/pwa.service';
import { provideWorkspaceShellActions } from './services/workspace-shell-actions.service';
// AoT requires an exported function for factories
export function HttpLoaderFactory(http: HttpClient): TranslateHttpLoader {
@@ -92,7 +102,17 @@ export const appConfig: ApplicationConfig = {
provide: PORTAL_PLAYER,
useExisting: PlayerService,
},
{
provide: PORTAL_EXTERNAL_PLAYBACK,
useExisting: ExternalPlaybackService,
},
...providePortalPlaybackPositions(),
...providePortalNavigationActions(),
{
provide: PLAYLIST_PLAYER_ACTIONS,
useExisting: AppPortalNavigationActionsService,
},
...provideWorkspaceShellActions(),
...provideXtreamDataSource(),
],
};
+6 -12
View File
@@ -1,8 +1,6 @@
import { Route, Routes } from '@angular/router';
import { createStalkerRoutes } from '@iptvnator/portal/stalker/feature';
import { createXtreamRoutes } from '@iptvnator/portal/xtream/feature';
import { stalkerFeatureRouteOptions } from './stalker/stalker-feature-loaders';
import { xtreamFeatureRouteOptions } from './xtream-electron/xtream-feature-loaders';
function withWorkspaceLayout(routes: Route[]): Route[] {
return routes.map((route) => ({
@@ -26,7 +24,7 @@ export const routes: Routes = [
{
path: 'workspace',
loadComponent: () =>
import('./workspace/workspace-shell.component').then(
import('@iptvnator/workspace/shell/feature').then(
(c) => c.WorkspaceShellComponent
),
children: [
@@ -45,7 +43,7 @@ export const routes: Routes = [
{
path: 'sources',
loadComponent: () =>
import('./workspace/workspace-sources.component').then(
import('@iptvnator/workspace/shell/feature').then(
(c) => c.WorkspaceSourcesComponent
),
},
@@ -60,7 +58,7 @@ export const routes: Routes = [
layout: 'workspace',
},
loadComponent: () =>
import('./home/video-player/video-player.component').then(
import('@iptvnator/playlist/m3u/feature-player').then(
(c) => c.VideoPlayerComponent
),
},
@@ -70,7 +68,7 @@ export const routes: Routes = [
layout: 'workspace',
},
loadComponent: () =>
import('./workspace/global-favorites/global-favorites-page.component').then(
import('@iptvnator/workspace/shell/feature').then(
(c) => c.GlobalFavoritesPageComponent
),
},
@@ -82,12 +80,8 @@ export const routes: Routes = [
(c) => c.DownloadsComponent
),
},
...withWorkspaceLayout(
createXtreamRoutes(xtreamFeatureRouteOptions)
),
...withWorkspaceLayout(
createStalkerRoutes(stalkerFeatureRouteOptions)
),
...withWorkspaceLayout(createXtreamRoutes()),
...withWorkspaceLayout(createStalkerRoutes()),
{
path: 'settings',
data: { layout: 'workspace' },
@@ -1,38 +0,0 @@
import {
Directive,
EventEmitter,
HostBinding,
HostListener,
Output,
} from '@angular/core';
@Directive({
standalone: true,
selector: '[appDragDropFileUpload]',
})
export class DragDropFileUploadDirective {
defaultColor = 'rgb(255 255 255 / 15%)';
@Output() fileDropped = new EventEmitter<any>();
@HostBinding('style.background-color') background = this.defaultColor;
@HostListener('dragover', ['$event']) dragOver(event: any) {
event.preventDefault();
event.stopPropagation();
this.background = 'rgb(255 255 255 / 25%)';
}
@HostListener('dragleave', ['$event']) public dragLeave(event: any) {
event.preventDefault();
event.stopPropagation();
this.background = this.defaultColor;
}
@HostListener('drop', ['$event']) public drop(event: any) {
event.preventDefault();
event.stopPropagation();
this.background = this.defaultColor;
const files = event.dataTransfer.files;
if (files.length > 0) {
this.fileDropped.emit(files);
}
}
}
@@ -1,26 +0,0 @@
<div
class="file-upload"
appDragDropFileUpload
(click)="openDialog(fileField)"
(fileDropped)="upload($event)"
>
<mat-icon class="upload-icon">upload_file</mat-icon>
<span class="label"
>{{
('HOME.FILE_UPLOAD.DRAG_DROP' | translate) +
' ' +
('HOME.FILE_UPLOAD.OR' | translate)
}}
<br />
{{ 'HOME.FILE_UPLOAD.CHOOSE_PLAYLIST' | translate }}</span
>
<input
type="file"
name="playlist"
#fileField
(change)="upload($any($event).target.files)"
accept=".m3u,.m3u8"
hidden
multiple
/>
</div>
@@ -1,27 +0,0 @@
$electron-title-width: 28px;
.file-upload {
cursor: pointer;
border: 1px dashed #999;
border-radius: 5px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
margin: 20px;
padding: 10px;
gap: 20px;
.upload-icon {
color: #999;
font-size: 60px;
width: 60px;
height: 60px;
}
.label {
display: block;
text-align: center;
color: #999;
}
}
@@ -1,80 +0,0 @@
import { Component, EventEmitter, inject, Output } from '@angular/core';
import { MatIconModule } from '@angular/material/icon';
import { Store } from '@ngrx/store';
import { TranslatePipe } from '@ngx-translate/core';
import { PlaylistActions } from 'm3u-state';
import { DragDropFileUploadDirective } from './drag-drop-file-upload.directive';
@Component({
imports: [DragDropFileUploadDirective, MatIconModule, TranslatePipe],
selector: 'app-file-upload',
templateUrl: './file-upload.component.html',
styleUrls: ['./file-upload.component.scss'],
})
export class FileUploadComponent {
@Output() fileSelected = new EventEmitter<{
uploadEvent: Event;
file: File;
}>();
@Output() fileRejected = new EventEmitter<string>();
@Output() addClicked = new EventEmitter<void>();
@Output() closeDialog = new EventEmitter<void>();
private readonly store = inject(Store);
private isDesktop = !!window.electron;
allowedContentTypes = [
'application/mpegurl',
'application/x-mpegurl',
'application/octet-stream',
'application/vnd.apple.mpegurl',
'application/vnd.apple.mpegurl.audio',
'audio/x-mpegurl',
'audio/mpegurl',
];
async openDialog(fileField: HTMLInputElement) {
if (this.isDesktop) {
window.electron
.openPlaylistFromFile()
.then((playlistObject) => {
if (playlistObject) {
console.log(
'Received playlist from Electron:',
playlistObject
);
this.store.dispatch(
PlaylistActions.addPlaylist({ playlist: playlistObject })
);
} else {
// User canceled the dialog
console.log('File selection was canceled.');
}
})
.catch((error) => {
console.error('Error opening file:', error);
});
this.closeDialog.emit();
} else {
fileField.click();
}
}
upload(fileList: FileList) {
if (this.isDesktop) return;
if (!this.allowedContentTypes.includes(fileList[0].type)) {
this.fileRejected.emit(fileList[0].name);
return;
}
const fileReader = new FileReader();
fileReader.onload = (uploadEvent) =>
this.fileSelected.emit({
uploadEvent,
file: fileList[0],
});
fileReader.readAsText(fileList[0]);
this.addClicked.emit();
}
}
@@ -1,136 +0,0 @@
<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"
placeholder="Optional - auto-generated if empty"
/>
<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"
placeholder="Optional - auto-generated if empty"
/>
<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"
placeholder="Optional - auto-generated if empty"
/>
<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"
placeholder="Optional"
/>
<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"
placeholder="Optional"
/>
<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> -->
@if (isLoading()) {
<div class="loading-container">
<mat-spinner diameter="20"></mat-spinner>
<span>{{ 'HOME.STALKER_PORTAL.VALIDATING' | translate }}</span>
</div>
} @else {
<button
mat-button
type="submit"
[disabled]="!form.valid"
(click)="addPlaylist()"
>
{{ 'HOME.STALKER_PORTAL.ADD' | translate }}
</button>
}
</form>
@@ -1,241 +0,0 @@
import { Component, inject, output, signal } from '@angular/core';
import {
FormControl,
FormGroup,
FormsModule,
ReactiveFormsModule,
Validators,
} from '@angular/forms';
import { MatButtonModule } from '@angular/material/button';
import { MatFormFieldModule } from '@angular/material/form-field';
import { MatInputModule } from '@angular/material/input';
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
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 'services';
import { Playlist } from 'shared-interfaces';
import { v4 as uuid } from 'uuid';
@Component({
imports: [
FormsModule,
MatButtonModule,
MatFormFieldModule,
MatInputModule,
MatProgressSpinnerModule,
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;
}
}
@@ -1,18 +0,0 @@
<form (ngSubmit)="textAdded.emit(textForm.value.text)" [formGroup]="textForm">
<mat-form-field class="w-full" appearance="fill">
<mat-label>{{ 'HOME.TEXT_IMPORT.LABEL' | translate }}</mat-label>
<textarea
formControlName="text"
matInput
placeholder="#EXTM3U url-tvg=..."
></textarea>
</mat-form-field>
<button
mat-flat-button
color="primary"
type="submit"
[disabled]="!textForm.valid"
>
{{ 'HOME.TEXT_IMPORT.BUTTON_LABEL' | translate }}
</button>
</form>
@@ -1,7 +0,0 @@
button {
margin: 0 15px;
}
textarea {
height: 300px !important;
}
@@ -1,34 +0,0 @@
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();
});
});
@@ -1,24 +0,0 @@
import { Component, EventEmitter, Output } from '@angular/core';
import {
FormControl,
FormGroup,
ReactiveFormsModule,
Validators,
} from '@angular/forms';
import { MatButton } from '@angular/material/button';
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: [MatButton, MatInputModule, ReactiveFormsModule, TranslatePipe],
})
export class TextImportComponent {
@Output() textAdded = new EventEmitter<string>();
textForm = new FormGroup({
text: new FormControl('', Validators.required),
});
}
@@ -1,24 +0,0 @@
<form (ngSubmit)="urlAdded.emit(form.value.playlistUrl)" [formGroup]="form">
<mat-form-field class="w-full">
<input
type="url"
matInput
[placeholder]="'HOME.URL_UPLOAD.PLAYLIST_URL' | translate"
formControlName="playlistUrl"
/>
</mat-form-field>
@if (!isDesktop) {
<p>
{{ 'HOME.URL_UPLOAD.CORS_NOTE' | translate }}
</p>
}
<button
class="add-button"
mat-flat-button
[disabled]="!form.valid"
color="primary"
type="submit"
>
{{ 'HOME.URL_UPLOAD.ADD_PLAYLIST' | translate }}
</button>
</form>
@@ -1,70 +0,0 @@
import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing';
import { FormsModule, ReactiveFormsModule } from '@angular/forms';
import { MatCardModule } from '@angular/material/card';
import { MatInputModule } from '@angular/material/input';
import { By } from '@angular/platform-browser';
import { NoopAnimationsModule } from '@angular/platform-browser/animations';
import { TranslateModule } from '@ngx-translate/core';
import { MockModule } from 'ng-mocks';
import { DataService } from '../../../../../../libs/services/src/lib/data.service';
import { ElectronServiceStub } from '../../services/electron.service.stub';
import { UrlUploadComponent } from './url-upload.component';
describe('UrlUploadComponent', () => {
let component: UrlUploadComponent;
let fixture: ComponentFixture<UrlUploadComponent>;
beforeEach(waitForAsync(() => {
TestBed.configureTestingModule({
imports: [
UrlUploadComponent,
MockModule(MatInputModule),
MockModule(MatCardModule),
MockModule(FormsModule),
MockModule(ReactiveFormsModule),
TranslateModule.forRoot(),
NoopAnimationsModule,
],
providers: [
{
provide: DataService,
useClass: ElectronServiceStub,
},
],
}).compileComponents();
}));
beforeEach(() => {
fixture = TestBed.createComponent(UrlUploadComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
it('submit form with playlist url', waitForAsync(() => {
jest.spyOn(component.urlAdded, 'emit');
const TEST_URL = 'http://example.org/playlist.m3u';
const submitButton =
fixture.debugElement.nativeElement.querySelector('button');
// test input field validation
expect(submitButton.disabled).toBeTruthy();
component.form.setValue({ playlistUrl: 'wrong url here' });
fixture.detectChanges();
expect(submitButton.disabled).toBeTruthy();
component.form.setValue({ playlistUrl: TEST_URL + '8' });
fixture.detectChanges();
expect(submitButton.disabled).toBeFalsy();
component.form.setValue({ playlistUrl: TEST_URL });
fixture.detectChanges();
expect(submitButton.disabled).toBeFalsy();
const form = fixture.debugElement.query(By.css('form'));
form.triggerEventHandler('ngSubmit', null);
expect(component.urlAdded.emit).toHaveBeenCalledWith(TEST_URL);
}));
});
@@ -1,35 +0,0 @@
import { Component, EventEmitter, inject, OnInit, Output } from '@angular/core';
import {
FormBuilder,
FormGroup,
ReactiveFormsModule,
Validators,
} from '@angular/forms';
import { MatButton } from '@angular/material/button';
import { MatInputModule } from '@angular/material/input';
import { TranslatePipe } from '@ngx-translate/core';
@Component({
selector: 'app-url-upload',
templateUrl: './url-upload.component.html',
imports: [MatButton, MatInputModule, ReactiveFormsModule, TranslatePipe],
})
export class UrlUploadComponent implements OnInit {
private readonly fb = inject(FormBuilder);
/** Emits url string to the parent component on form submit */
@Output() urlAdded: EventEmitter<string> = new EventEmitter();
form: FormGroup;
readonly isDesktop = !!window.electron;
ngOnInit() {
const urlRegex = '(https?://.*?)';
this.form = this.fb.group({
playlistUrl: [
'',
[Validators.required, Validators.pattern(urlRegex)],
],
});
}
}
@@ -1,155 +0,0 @@
<mat-drawer-container
class="main-container"
[class.main-container--workspace]="isWorkspaceLayout"
autosize
>
<!-- sidebar content -->
<mat-drawer
#drawer
mode="side"
opened
disableClose
class="mat-drawer"
appResizable
[minWidth]="200"
[maxWidth]="600"
[defaultWidth]="350"
storageKey="sidebar-width"
handlePosition="right"
>
@defer {
<app-sidebar
[channels]="(channels$ | async)!"
[showPlaylistHeader]="!isWorkspaceLayout"
[activeView]="activeView()"
/>
} @loading {
<p>Loading...</p>
} @placeholder {
<p>Loading...</p>
}
</mat-drawer>
<mat-drawer-content class="drawer-content">
@if (activeChannel(); as activeChannel) {
<!-- toolbar with drawer icon -->
<app-toolbar
(toggleLeftDrawerClicked)="drawer.toggle()"
(toggleRightDrawerClicked)="drawerRight.toggle()"
(multiEpgClicked)="openMultiEpgView()"
(infoOverlayClicked)="toggleInfoOverlay()"
[activeChannel]="activeChannel"
[isLeftDrawerOpened]="drawer.opened"
(settingsClicked)="openSettings()"
/>
@if (activeChannel.radio === 'true') {
<app-audio-player
[url]="activeChannel.url"
[icon]="activeChannel?.tvg?.logo!"
/>
} @else {
<!-- video.js player -->
@if (playerSettings.player === 'videojs') {
<app-vjs-player
[options]="{
sources: [
{
src:
activeChannel.url +
activeChannel.epgParams,
type: 'application/x-mpegURL',
},
],
}"
[volume]="volume"
[class.hide-captions]="!playerSettings.showCaptions"
/>
}
<!-- default html player component -->
@if (playerSettings.player === 'html5') {
<app-html-video-player
[channel]="activeChannel"
[volume]="volume"
[showCaptions]="playerSettings.showCaptions!"
/>
}
<!-- Add ArtPlayer component -->
@if (playerSettings.player === 'artplayer') {
<app-art-player
[channel]="activeChannel"
[volume]="volume"
[showCaptions]="playerSettings.showCaptions!"
/>
}
@if (playerSettings.player === 'mpv') {
<div class="custom-player">
<img
src="./assets/images/custom-player.png"
alt="external player"
/>
<p>
The channel is played in a separate window in the
MPV player. (<a
[routerLink]
style="cursor: pointer"
(click)="
openUrl(
'https://github.com/4gray/iptvnator/wiki/What-is-mpv-video-player-and-how-to-install-it-on-different-operating-systems%3F'
)
"
>Installation instructions</a
>)
</p>
</div>
}
@if (playerSettings.player === 'vlc') {
<div class="custom-player">
<img
src="./assets/images/custom-player.png"
alt="external player"
/>
<p>
The channel is played in a separate window in VLC
player. Please make sure that this player is
installed in your system and the correct path is
defined in the app settings.
</p>
</div>
}
<!-- channel overlay -->
<app-info-overlay
[channel]="activeChannel"
[epgProgram]="epgProgram()"
/>
<!-- channel number overlay -->
@if (showChannelNumberOverlay) {
<div class="channel-number-overlay">
<div class="channel-number-display">
{{ channelNumberInput }}
</div>
</div>
}
}
} @else {
<!-- Minimal toolbar when no channel is active so drawer works -->
<app-toolbar
(toggleLeftDrawerClicked)="drawer.toggle()"
(toggleRightDrawerClicked)="drawerRight.toggle()"
[isLeftDrawerOpened]="drawer.opened"
(settingsClicked)="openSettings()"
/>
<div class="empty-state-container">
<mat-icon class="empty-state-icon">live_tv</mat-icon>
<h2 class="empty-state-title">
{{ 'CHANNELS.SELECT_CHANNEL_PLAYBACK' | translate }}
</h2>
</div>
}
</mat-drawer-content>
<!-- right sidebar content -->
<mat-drawer position="end" #drawerRight mode="side" disableClose>
@if (isDesktop) {
<app-epg-list />
}
</mat-drawer>
</mat-drawer-container>
@@ -1,155 +0,0 @@
:host {
display: block;
width: 100%;
height: 100%;
min-height: 0;
}
.main-container {
position: absolute;
top: 0;
bottom: 0;
left: 0;
right: 0;
overflow: hidden; // Prevent horizontal scrollbar from resize handle
}
.main-container.main-container--workspace {
position: relative;
top: auto;
right: auto;
bottom: auto;
left: auto;
width: 100%;
height: 100%;
min-height: 0;
}
.main-container.main-container--workspace .mat-drawer-content,
.main-container.main-container--workspace .drawer-content {
height: 100%;
min-height: 0;
}
.mat-drawer-content {
background: #000;
color: #fff;
overflow: hidden;
border-radius: 20px 0 0 0;
}
.mat-drawer {
// Width is controlled by appResizable directive
// Default: 350px, min: 200px, max: 600px
overflow-x: hidden !important; // Hide horizontal scrollbar
border-radius: 0 !important;
.resize-handle--right {
right: 0;
}
}
// Ensure mat-drawer inner container fills the drawer width
::ng-deep .mat-drawer-inner-container {
width: 100% !important;
}
.custom-player {
padding: 20px;
}
@media only screen and (max-width: 599px) {
.main-container {
display: flex;
flex-direction: column-reverse;
top: unset;
}
.mat-drawer {
height: 50vh;
width: 100%;
position: unset;
}
.drawer-content {
height: 50vh;
margin-left: 0 !important;
}
}
@media (prefers-color-scheme: dark) {
.custom-player {
display: flex;
margin-top: 50px;
flex-direction: column;
align-items: center;
padding: 10px;
img {
filter: invert(100%);
width: 400px;
}
}
}
.channel-number-overlay {
position: fixed;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
z-index: 1000;
pointer-events: none;
}
.channel-number-display {
background: rgba(0, 0, 0, 0.85);
color: #fff;
font-size: 6rem;
font-weight: 700;
font-family: 'DM Sans', sans-serif;
padding: 2rem 3rem;
border-radius: 16px;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.5);
border: 2px solid rgba(255, 255, 255, 0.1);
min-width: 120px;
text-align: center;
letter-spacing: 0.05em;
animation: scaleIn 0.15s ease-out;
}
@keyframes scaleIn {
from {
opacity: 0;
transform: scale(0.8);
}
to {
opacity: 1;
transform: scale(1);
}
}
.empty-state-container {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: calc(100% - 64px);
text-align: center;
color: rgba(255, 255, 255, 0.5);
.empty-state-icon {
font-size: 72px;
width: 72px;
height: 72px;
margin-bottom: 24px;
opacity: 0.2;
}
.empty-state-title {
font-family: 'DM Sans', sans-serif;
font-weight: 500;
font-size: 1.25rem;
margin: 0;
opacity: 0.5;
}
}
@@ -1,101 +0,0 @@
/* import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing';
import { MatDividerModule } from '@angular/material/divider';
import { MatIconModule } from '@angular/material/icon';
import { MatSidenavModule } from '@angular/material/sidenav';
import { MatSnackBar } from '@angular/material/snack-bar';
import { MatToolbarModule } from '@angular/material/toolbar';
import { MatTooltipModule } from '@angular/material/tooltip';
import { ActivatedRoute } from '@angular/router';
import { RouterTestingModule } from '@angular/router/testing';
import { MockStore, provideMockStore } from '@ngrx/store/testing';
import { TranslateModule } from '@ngx-translate/core';
import { MockComponent, MockModule, MockProviders } from 'ng-mocks';
import { DataService } from '../../../../../../libs/services/src/lib/data.service';
import { VideoPlayer } from '../../../../../../libs/shared/interfaces/src/lib/settings.interface';
import { ChannelListContainerComponent } from '../../../../../../libs/ui/components/src/lib/channel-list-container/channel-list-container.component';
import { EpgListComponent } from '../../../../../../libs/ui/components/src/lib/epg-list/epg-list.component';
import { HtmlVideoPlayerComponent } from '../../../../../../libs/ui/components/src/lib/html-video-player/html-video-player.component';
import { InfoOverlayComponent } from '../../../../../../libs/ui/components/src/lib/info-overlay/info-overlay.component';
import { VjsPlayerComponent } from '../../../../../../libs/ui/components/src/lib/vjs-player/vjs-player.component';
import { ElectronServiceStub } from '../../services/electron.service.stub';
import { VideoPlayerComponent } from './video-player.component';
import { CommonModule } from '@angular/common';
import { Actions } from '@ngrx/effects';
import { provideMockActions } from '@ngrx/effects/testing';
import { NgxIndexedDBService } from 'ngx-indexed-db';
import { NgxSkeletonLoaderComponent } from 'ngx-skeleton-loader';
import { Observable, of } from 'rxjs';
import { initialState } from '../../../../../../apps/web/src/app/state/state';
import { PlaylistsService } from 'services';
class MatSnackBarStub {
open(): void {}
}
describe('VideoPlayerComponent', () => {
let component: VideoPlayerComponent;
let fixture: ComponentFixture<VideoPlayerComponent>;
let mockStore: MockStore;
const actions$ = new Observable<Actions>();
beforeEach(waitForAsync(() => {
TestBed.configureTestingModule({
providers: [
{ provide: MatSnackBar, useClass: MatSnackBarStub },
{ provide: DataService, useClass: ElectronServiceStub },
{
provide: ActivatedRoute,
useValue: {
params: of({ id: '1' }),
snapshot: {
queryParams: {
url: 'https://iptvnator/list.m3u',
},
},
},
},
provideMockStore(),
provideMockActions(actions$),
MockProviders(NgxIndexedDBService, PlaylistsService),
],
imports: [
MockComponent(EpgListComponent),
MockComponent(HtmlVideoPlayerComponent),
MockComponent(VjsPlayerComponent),
MockComponent(VideoPlayerComponent),
MockComponent(ChannelListContainerComponent),
MockComponent(InfoOverlayComponent),
VideoPlayerComponent,
CommonModule,
TranslateModule.forRoot(),
MockModule(MatSidenavModule),
MockModule(MatIconModule),
MockModule(MatToolbarModule),
MockModule(MatTooltipModule),
MockModule(RouterTestingModule),
MockModule(MatDividerModule),
MockComponent(NgxSkeletonLoaderComponent),
],
}).compileComponents();
}));
beforeEach(() => {
fixture = TestBed.createComponent(VideoPlayerComponent);
component = fixture.componentInstance;
mockStore = TestBed.inject(MockStore);
mockStore.setState({
playlistState: initialState,
});
fixture.detectChanges();
});
it('should check default component settings', () => {
expect(component.playerSettings).toEqual({
player: VideoPlayer.VideoJs,
showCaptions: false,
});
});
});
*/
@@ -1,627 +0,0 @@
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,
effect,
inject,
viewChild,
} from '@angular/core';
import { toSignal } from '@angular/core/rxjs-interop';
import { MatDialog } from '@angular/material/dialog';
import { MatIcon } from '@angular/material/icon';
import { MatSidenavModule } from '@angular/material/sidenav';
import { ActivatedRoute, Router, RouterLink } from '@angular/router';
import { Store } from '@ngrx/store';
import { StorageMap } from '@ngx-pwa/local-storage';
import { TranslatePipe } from '@ngx-translate/core';
import {
ArtPlayerComponent,
AudioPlayerComponent,
COMPONENT_OVERLAY_REF,
EpgListComponent,
HtmlVideoPlayerComponent,
InfoOverlayComponent,
MultiEpgContainerComponent,
ResizableDirective,
SidebarComponent,
ToolbarComponent,
VjsPlayerComponent,
} from 'components';
import {
ChannelActions,
FavoritesActions,
PlaylistActions,
selectActive,
selectChannels,
selectCurrentEpgProgram,
} from 'm3u-state';
import {
Observable,
Subscription,
combineLatest,
combineLatestWith,
distinctUntilChanged,
filter,
map,
startWith,
switchMap,
take,
} from 'rxjs';
import { DataService, PlaylistsService } from 'services';
import {
Channel,
PLAYLIST_PARSE_BY_URL,
STORE_KEY,
Settings,
SidebarView,
VideoPlayer,
} from 'shared-interfaces';
import { SettingsStore } from '../../services/settings-store.service';
import { SettingsComponent } from '../../settings/settings.component';
import {
getAdjacentChannelItem,
getChannelItemByNumber,
} from '../../shared/services/remote-channel-navigation.util';
@Component({
selector: 'app-video-player',
imports: [
ArtPlayerComponent,
AsyncPipe,
AudioPlayerComponent,
CommonModule,
EpgListComponent,
HtmlVideoPlayerComponent,
InfoOverlayComponent,
MatIcon,
MatSidenavModule,
ResizableDirective,
RouterLink,
SidebarComponent,
ToolbarComponent,
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 dialog = inject(MatDialog);
private readonly overlay = inject(Overlay);
private readonly playlistsService = inject(PlaylistsService);
private readonly router = inject(Router);
private readonly settingsStore = inject(SettingsStore);
private readonly storage = inject(StorageMap);
private readonly store = inject(Store);
/** Active selected channel */
readonly activeChannel = this.store.selectSignal(selectActive);
/** 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) */
readonly activeView = toSignal(
this.activatedRoute.params.pipe(
map((params) => {
if (this.router.url.includes('/workspace/global-favorites')) {
return 'favorites';
}
return params['view'] || 'all';
})
),
{
initialValue: this.router.url.includes(
'/workspace/global-favorites'
)
? 'favorites'
: 'all',
}
);
/** Selected video player options */
playerSettings: Partial<Settings> = {
player: VideoPlayer.VideoJs,
showCaptions: false,
};
readonly isDesktop = !!window['electron'];
readonly isWorkspaceLayout =
this.activatedRoute.snapshot.data['layout'] === 'workspace';
sidebarView: SidebarView = 'CHANNELS';
/** EPG overlay reference */
private overlayRef!: OverlayRef;
private unsubscribeRemoteChannelChange?: () => void;
private unsubscribeRemoteCommand?: () => void;
private statusSubscription?: Subscription;
private routeSubscription?: Subscription;
private lastKnownVolume = 1;
/** Info overlay component reference for manual triggering */
readonly infoOverlay = viewChild(InfoOverlayComponent);
/** 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(),
};
});
}
/**
* Sets video player and subscribes to channel list from the store
*/
ngOnInit(): void {
this.applySettings();
this.getPlaylistUrlAsParam();
// 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.routeSubscription = this.activatedRoute.params
.pipe(
distinctUntilChanged((prev, curr) => prev['id'] === curr['id']),
combineLatestWith(this.activatedRoute.queryParams),
switchMap(([params, queryParams]) => {
const isGlobalFavorites = this.router.url.includes(
'/workspace/global-favorites'
);
if (isGlobalFavorites) {
this.store.dispatch(
ChannelActions.resetActiveChannel()
);
this.store.dispatch(
PlaylistActions.setActivePlaylist({
playlistId: '',
})
);
return this.playlistsService
.getPlaylistWithGlobalFavorites()
.pipe(
map((playlist) => {
this.store.dispatch(
ChannelActions.setChannels({
channels: playlist.playlist.items,
})
);
const stringFavs = (
playlist.favorites ?? []
).filter(
(f): f is string =>
typeof f === 'string'
);
this.store.dispatch(
FavoritesActions.setFavorites({
channelIds: stringFavs,
})
);
})
);
} else if (params['id']) {
this.store.dispatch(
ChannelActions.resetActiveChannel()
);
this.store.dispatch(
PlaylistActions.setActivePlaylist({
playlistId: params['id'],
})
);
return this.playlistsService
.getPlaylist(params['id'])
.pipe(
map((playlist) => {
// Set user agent if specified on playlist level
if (playlist.userAgent) {
window.electron?.setUserAgent(
playlist.userAgent,
'localhost'
);
}
this.store.dispatch(
ChannelActions.setChannels({
channels: playlist.playlist.items,
})
);
// Load favorites from the playlist
const stringFavs = (
playlist.favorites ?? []
).filter(
(f): f is string =>
typeof f === 'string'
);
this.store.dispatch(
FavoritesActions.setFavorites({
channelIds: stringFavs,
})
);
})
);
}
return [];
})
)
.subscribe();
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 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: (epgProgram as any)?.title,
epgStart: (epgProgram as any)?.start,
epgEnd: (epgProgram as any)?.end,
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.unsubscribeRemoteChannelChange?.();
this.unsubscribeRemoteCommand?.();
this.statusSubscription?.unsubscribe();
this.routeSubscription?.unsubscribe();
}
/**
* 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,
};
}
});
}
/**
* 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();
});
}
openUrl(url: string) {
window.open(url, '_blank');
}
navigateHome() {
this.router.navigate(['/']);
}
openSettings() {
this.dialog.open(SettingsComponent, {
width: '1200px',
maxWidth: '96vw',
maxHeight: '92vh',
data: { isDialog: true },
});
}
/**
* Keyboard shortcut: Press 'I' to toggle EPG info overlay
*/
@HostListener('document:keydown.i', ['$event'])
handleInfoKeyPress(event: Event): void {
// Don't trigger hotkeys when user is typing in input fields
if (this.isTypingInInput(event)) {
return;
}
// Prevent default behavior and show info overlay
event.preventDefault();
this.toggleInfoOverlay();
}
/**
* Handle digit key presses for channel number input
*/
@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);
}
}
/**
* Toggles the EPG info overlay visibility
* Called by 'I' keyboard shortcut or info button
*/
toggleInfoOverlay(): void {
const overlay = this.infoOverlay();
if (overlay) {
overlay.showOverlay();
}
}
/**
* 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,
});
}
}
}
@@ -1,64 +0,0 @@
<form [formGroup]="form">
<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>
<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>
<div class="button-row">
<button
mat-button
type="button"
[disabled]="!form.valid || isTestingConnection"
(click)="testConnection()"
>
{{ isTestingConnection ? 'Testing...' : 'Test Connection' }}
</button>
<button
mat-button
type="submit"
[disabled]="!form.valid"
(click)="addPlaylist()"
>
{{ 'HOME.XTREAM_PLAYLIST.ADD' | translate }}
</button>
</div>
@if (connectionStatus) {
<div class="connection-status">
<mat-icon [class]="getStatusClass()">
{{ getStatusIcon() }}
</mat-icon>
<span [class]="getStatusClass()">{{ getStatusMessage() }}</span>
</div>
}
</form>
@@ -1,170 +0,0 @@
import { Component, EventEmitter, Output, inject } from '@angular/core';
import {
FormControl,
FormGroup,
FormsModule,
ReactiveFormsModule,
Validators,
} from '@angular/forms';
import { MatButton } from '@angular/material/button';
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 } from 'services';
import { Playlist } from 'shared-interfaces';
import { v4 as uuid } from 'uuid';
@Component({
imports: [
FormsModule,
MatButton,
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;
}
.button-row {
display: flex;
justify-content: space-between;
}
`,
],
})
export class XtreamCodeImportComponent {
@Output() addClicked = new EventEmitter<void>();
URL_REGEX = /^(http|https|file):\/\/[^ "]+$/;
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;
async testConnection(): Promise<void> {
if (!this.form.valid) return;
this.isTestingConnection = true;
const serverUrlAsString = this.form.value.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.value.username as string,
this.form.value.password as string
);
} catch (error) {
console.error('Error testing connection:', error);
this.connectionStatus = 'unavailable';
} 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);
}
addPlaylist() {
const serverUrlAsString = this.form.value.serverUrl as string;
const url = new URL(serverUrlAsString);
const serverUrl = `${url.protocol}//${url.hostname}${
url.port ? ':' + url.port : ''
}`;
this.store.dispatch(
PlaylistActions.addPlaylist({
playlist: {
...this.form.value,
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);
}
}
}
@@ -1,134 +0,0 @@
@if (selectedCategory()) {
@if (isStalker && selectedStalkerItem()) {
@if (contentType() === 'vod') {
@if (selectedStalkerItem().series?.length > 0) {
<!-- VOD item with embedded series (vclub or Ministra) -->
<app-stalker-series-view [vodWithSeries]="selectedStalkerItem()" />
} @else if (selectedStalkerItem().is_series) {
<!-- VOD item that is actually a series (Ministra plugin is_series=1) -->
<app-stalker-series-view />
} @else {
@if (vodDetailsItem()) {
<app-vod-details
[item]="vodDetailsItem()!"
[isFavorite]="isSelectedVodFavorite()"
[playbackPosition]="selectedVodPlaybackPosition()"
[inlinePlayback]="inlinePlayback()"
[externalPlayback]="externalPlayback.activeSession()"
(playClicked)="onVodPlay($event)"
(resumeClicked)="onVodResume($event)"
(favoriteToggled)="onVodFavoriteToggled($event)"
(downloadRequested)="onVodDownload($event)"
(backClicked)="onVodBack()"
(inlineTimeUpdated)="handleInlineTimeUpdate($event)"
(inlinePlaybackClosed)="closeInlinePlayer()"
(streamUrlCopied)="showCopyNotification()"
/>
}
}
} @else if (contentType() === 'series') {
<app-stalker-series-view />
}
} @else {
<div class="category-content-layout">
<div class="category-content-header">
<div class="category-meta">
<h2 class="category-title">
{{ selectedCategoryTitle() }}
</h2>
<div class="category-subtitle">
{{ categoryItemSubtitle() }}
</div>
</div>
@if (!isStalker) {
<button
type="button"
mat-icon-button
class="sort-action"
[matMenuTriggerFor]="sortContentMenu"
matTooltip="Sort content"
aria-label="Sort content"
>
<mat-icon>sort</mat-icon>
</button>
<mat-menu #sortContentMenu="matMenu">
<button
mat-menu-item
(click)="setContentSortMode('date-desc')"
>
<span>{{
'WORKSPACE.SORT_DATE_DESC' | translate
}}</span>
@if (contentSortMode() === 'date-desc') {
<mat-icon>check</mat-icon>
}
</button>
<button
mat-menu-item
(click)="setContentSortMode('date-asc')"
>
<span>{{
'WORKSPACE.SORT_DATE_ASC' | translate
}}</span>
@if (contentSortMode() === 'date-asc') {
<mat-icon>check</mat-icon>
}
</button>
<button
mat-menu-item
(click)="setContentSortMode('name-asc')"
>
<span>{{
'WORKSPACE.SORT_NAME_ASC' | translate
}}</span>
@if (contentSortMode() === 'name-asc') {
<mat-icon>check</mat-icon>
}
</button>
<button
mat-menu-item
(click)="setContentSortMode('name-desc')"
>
<span>{{
'WORKSPACE.SORT_NAME_DESC' | translate
}}</span>
@if (contentSortMode() === 'name-desc') {
<mat-icon>check</mat-icon>
}
</button>
</mat-menu>
}
@if (categoryItemCount() > 0) {
<mat-paginator
[pageIndex]="pageIndex()"
[length]="categoryItemCount()"
[pageSize]="limit()"
[pageSizeOptions]="pageSizeOptions"
(page)="onPageChange($event)"
aria-label="Select page"
/>
}
</div>
<app-grid-list
[isLoading]="isPaginatedContentLoading()"
[items]="contentWithProgress()"
(itemClicked)="onItemClick($event)"
[pageIndex]="pageIndex()"
[totalPages]="totalPages()"
[limit]="limit()"
[pageSizeOptions]="pageSizeOptions"
[showPaginator]="false"
(pageChange)="onPageChange($event)"
/>
</div>
}
} @else {
<app-playlist-error-view
[title]="'PORTALS.ERROR_VIEW.NO_CATEGORY_SELECTED.TITLE' | translate"
[description]="
'PORTALS.ERROR_VIEW.NO_CATEGORY_SELECTED.DESCRIPTION' | translate
"
[showActionButtons]="false"
[viewType]="'EMPTY_CATEGORY'"
/>
}
@@ -1,105 +0,0 @@
:host {
display: flex;
flex-direction: column;
height: 100%;
min-height: 0;
overflow: hidden;
}
.category-content-layout {
height: 100%;
min-height: 0;
display: flex;
flex-direction: column;
overflow: hidden;
}
// ─── Sticky header bar — styled like a widget header ─────────────────────────
.category-content-header {
display: flex;
justify-content: space-between;
align-items: center;
gap: 12px;
padding: 0 20px;
min-height: 52px;
flex-shrink: 0;
position: sticky;
top: 0;
z-index: 2;
// distinct widget-header surface so it reads over the content below
background: var(--app-widget-header-bg, var(--mat-sys-surface-container-high));
border-bottom: 1px solid var(--app-widget-header-border, rgba(255, 255, 255, 0.05));
}
// ─── Category meta (title + subtitle) ────────────────────────────────────────
.category-meta {
min-width: 0;
display: flex;
align-items: baseline;
gap: 10px;
flex: 1;
}
.category-title {
margin: 0;
font-size: 0.95rem;
font-weight: 600;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
// Explicit token so it never blends with the header background
color: var(--app-heading-color, var(--mat-sys-on-surface));
letter-spacing: -0.01em;
}
.category-subtitle {
white-space: nowrap;
font-size: 0.78rem;
font-weight: 500;
color: var(--app-body-color, var(--mat-sys-on-surface-variant));
// pill chip appearance
background: var(--mat-sys-surface-container);
padding: 2px 8px;
border-radius: 999px;
flex-shrink: 0;
}
// ─── Paginator — flush right, no ghost background ────────────────────────────
mat-paginator {
background: transparent !important;
flex-shrink: 0;
// Shrink paginator text to fit header height comfortably
--mat-paginator-container-size: 40px;
--mat-paginator-enabled-icon-color: var(--app-body-color, var(--mat-sys-on-surface-variant));
--mat-paginator-disabled-icon-color: rgba(255, 255, 255, 0.18);
}
// ─── Grid scroll area ─────────────────────────────────────────────────────────
app-grid-list {
flex: 1;
min-height: 0;
overflow-y: auto;
padding: 16px 20px 20px;
display: block;
scrollbar-width: thin;
scrollbar-color: rgba(255, 255, 255, 0.08) transparent;
}
@media (max-width: 768px) {
.category-content-header {
flex-direction: column;
align-items: flex-start;
padding: 10px 16px;
min-height: unset;
}
.category-meta {
flex-direction: column;
align-items: flex-start;
gap: 4px;
}
app-grid-list {
padding: 12px 16px 16px;
}
}
@@ -1,620 +0,0 @@
import {
Component,
computed,
effect,
inject,
OnDestroy,
OnInit,
signal,
} from '@angular/core';
import { MatIconButton } from '@angular/material/button';
import { MatIcon } from '@angular/material/icon';
import { MatMenuModule } from '@angular/material/menu';
import { MatPaginatorModule, PageEvent } from '@angular/material/paginator';
import { MatSnackBar } from '@angular/material/snack-bar';
import { MatTooltip } from '@angular/material/tooltip';
import { ActivatedRoute, Router } from '@angular/router';
import { TranslatePipe, TranslateService } from '@ngx-translate/core';
import { PlaylistsService } from 'services';
import {
createStalkerVodItem,
PlaybackPositionData,
ResolvedPortalPlayback,
StalkerVodDetails,
VodDetailsItem,
} from 'shared-interfaces';
import { PlaylistErrorViewComponent } from '@iptvnator/portal/shared/ui';
import {
createLogger,
isStalkerPortalCatalogFacade,
PortalCatalogFacade,
PortalCatalogSortMode,
PORTAL_CATALOG_FACADE,
StalkerPortalCatalogFacade,
} from '@iptvnator/portal/shared/util';
import {
createPortalFavoritesResource,
createRefreshTrigger,
isSelectedStalkerVodFavorite,
normalizeStalkerEntityId,
normalizeStalkerEntityIdAsNumber,
StalkerSelectedVodItem,
toggleStalkerVodFavorite,
} from '@iptvnator/portal/stalker/data-access';
import { XTREAM_DATA_SOURCE } from '@iptvnator/portal/xtream/data-access';
import { DownloadsService } from '../../services/downloads.service';
import { ExternalPlaybackService } from '../../services/external-playback.service';
import { PlayerService } from '../../services/player.service';
import { GridListComponent } from '../../shared/components/grid-list/grid-list.component';
import { StalkerSeriesViewComponent } from '../../stalker/stalker-series-view/stalker-series-view.component';
import { VodDetailsComponent } from '../vod-details/vod-details.component';
interface CategoryContentItem {
id?: number | string;
is_series?: number | string | boolean;
xtream_id?: number | string;
series_id?: number | string;
stream_id?: number | string;
category_id?: number | string;
[key: string]: unknown;
}
interface DownloadVodData {
id?: string | number;
has_files?: unknown;
info?: {
name?: string;
movie_image?: string;
};
title?: string;
}
@Component({
selector: 'app-category-content-view',
templateUrl: './category-content-view.component.html',
styleUrls: ['./category-content-view.component.scss'],
imports: [
GridListComponent,
MatIcon,
MatIconButton,
MatMenuModule,
MatPaginatorModule,
MatTooltip,
PlaylistErrorViewComponent,
StalkerSeriesViewComponent,
TranslatePipe,
VodDetailsComponent,
],
})
export class CategoryContentViewComponent implements OnInit, OnDestroy {
private readonly activatedRoute = inject(ActivatedRoute);
private readonly router = inject(Router);
private readonly dataSource = inject(XTREAM_DATA_SOURCE);
private readonly playerService = inject(PlayerService);
readonly externalPlayback = inject(ExternalPlaybackService);
private readonly snackBar = inject(MatSnackBar);
private readonly translateService = inject(TranslateService);
private readonly playlistService = inject(PlaylistsService);
private readonly downloadsService = inject(DownloadsService);
private readonly catalog = inject(
PORTAL_CATALOG_FACADE
) as PortalCatalogFacade<
CategoryContentItem,
CategoryContentItem,
CategoryContentItem
>;
private readonly stalkerCatalog:
| StalkerPortalCatalogFacade<
CategoryContentItem,
CategoryContentItem,
CategoryContentItem
>
| null = isStalkerPortalCatalogFacade(this.catalog)
? (this.catalog as StalkerPortalCatalogFacade<
CategoryContentItem,
CategoryContentItem,
CategoryContentItem
>)
: null;
private readonly logger = createLogger('CategoryContentView');
private readonly favoritesRefresh = createRefreshTrigger();
readonly isStalker = this.catalog.provider === 'stalker';
/** Unsubscribe function for playback position updates */
private unsubscribePositionUpdates: (() => void) | null = null;
readonly contentType = this.catalog.contentType;
readonly inlinePlayback = signal<ResolvedPortalPlayback | null>(null);
private lastInlineSaveTime = 0;
readonly limit = this.catalog.limit;
readonly pageIndex = this.catalog.pageIndex;
readonly pageSizeOptions = Array.from(this.catalog.pageSizeOptions);
readonly selectedCategory = this.catalog.selectedCategory;
readonly paginatedContent = this.catalog.paginatedContent;
readonly selectedCategoryTitle = this.catalog.selectedCategoryTitle;
readonly categoryItemCount = this.catalog.categoryItemCount;
readonly categoryItemSubtitle = computed(() => {
const itemCount = this.categoryItemCount();
return `${itemCount} ${itemCount === 1 ? 'item' : 'items'}`;
});
/** Stalker playback positions (key: `${contentType}_${id}`) */
private readonly stalkerPositions = signal<
Map<string, PlaybackPositionData>
>(new Map());
/** Stalker series positions (key: seriesId, value: episode positions) */
private readonly stalkerSeriesPositions = signal<
Map<number, PlaybackPositionData[]>
>(new Map());
/** Track which playlist we've loaded positions for */
private loadedPositionsForPlaylistId: string | null = null;
constructor() {
if (this.isStalker) {
effect(() => {
const playlistId = this.catalog.playlist()?.id;
if (
playlistId &&
playlistId !== this.loadedPositionsForPlaylistId
) {
this.loadedPositionsForPlaylistId = playlistId;
this.loadStalkerPositions(playlistId);
}
});
effect(() => {
const item = this.selectedItem();
const playlistId = this.catalog.playlist()?.id;
if (item && playlistId && this.contentType() === 'vod') {
this.loadSingleVodPosition(playlistId, Number(item.id));
}
});
if (window.electron?.onPlaybackPositionUpdate) {
this.unsubscribePositionUpdates =
window.electron.onPlaybackPositionUpdate(
(data: PlaybackPositionData) => {
if (
data.contentType === 'vod' &&
data.contentXtreamId
) {
const key = `vod_${data.contentXtreamId}`;
const updated = new Map(
this.stalkerPositions()
);
updated.set(key, data);
this.stalkerPositions.set(updated);
}
}
);
}
effect(() => {
this.selectedItem()?.id;
this.closeInlinePlayer();
});
}
}
readonly contentWithProgress = computed(() => {
const items = this.paginatedContent();
if (!items) return [];
if (this.isStalker) {
const positions = this.stalkerPositions();
const seriesPositions = this.stalkerSeriesPositions();
const contentType = this.contentType();
return items.map((item: CategoryContentItem) => {
const id = Number(item.id);
const hasSeriesProgress = Boolean(
seriesPositions.get(id)?.length
);
const isSeries =
contentType === 'series' ||
item.is_series === '1' ||
item.is_series === 1;
if (hasSeriesProgress) {
return { ...item, hasSeriesProgress: true };
}
if (isSeries) {
return { ...item, hasSeriesProgress: false };
}
const vodKey = `vod_${id}`;
const vodPosition = positions.get(vodKey);
const vodProgress = this.calculateProgress(vodPosition);
return {
...item,
progress: vodProgress,
isWatched: vodProgress >= 90,
};
});
}
return items.map((item: CategoryContentItem) => ({
...item,
...this.catalog.getItemProgress(item),
}));
});
readonly isPaginatedContentLoading =
this.catalog.isPaginatedContentLoading;
readonly selectedItem = this.catalog.selectedItem;
readonly totalPages = this.catalog.totalPages;
readonly contentSortMode = this.catalog.contentSortMode;
readonly selectedStalkerItem = computed<StalkerSelectedVodItem | null>(
() => {
if (!this.isStalker) {
return null;
}
return (this.selectedItem() as unknown as StalkerSelectedVodItem | null) ?? null;
}
);
/** Computed VodDetailsItem for the vod-details component */
readonly vodDetailsItem = computed<VodDetailsItem | null>(() => {
const item = this.selectedStalkerItem();
if (!item || !this.isStalker) return null;
return createStalkerVodItem(
item as unknown as StalkerVodDetails,
this.catalog.playlist()?.id ?? ''
);
});
/** Playback position for the selected VOD item (Stalker) */
readonly selectedVodPlaybackPosition = computed<number | null>(() => {
if (!this.isStalker) return null;
const item = this.selectedStalkerItem();
if (!item) return null;
const id = Number(item.id);
const vodKey = `vod_${id}`;
const position = this.stalkerPositions().get(vodKey);
return position?.positionSeconds ?? null;
});
readonly portalFavorites = createPortalFavoritesResource(
this.playlistService,
() => (this.isStalker ? this.catalog.playlist()?.id : undefined),
() => this.favoritesRefresh.refreshVersion()
);
readonly isSelectedVodFavorite = computed<boolean>(() =>
isSelectedStalkerVodFavorite(
this.vodDetailsItem(),
this.portalFavorites.value() ?? []
)
);
seasons = [];
setContentSortMode(mode: PortalCatalogSortMode): void {
this.catalog.setContentSortMode(mode);
}
ngOnInit() {
const { categoryId } = this.activatedRoute.snapshot.params;
this.catalog.initialize(categoryId ?? null);
if (this.isStalker) {
const selectedCategory =
(this.selectedCategory() as CategoryContentItem | null) ?? null;
this.logger.warn('Stalker category init from route', {
routeCategoryId: categoryId ?? null,
selectedCategoryId:
selectedCategory?.category_id ??
selectedCategory?.id ??
null,
selectedContentType: this.contentType(),
});
}
}
/**
* Load all playback positions for a Stalker portal
*/
private async loadStalkerPositions(playlistId: string): Promise<void> {
const positions =
await this.dataSource.getAllPlaybackPositions(playlistId);
const positionsMap = new Map<string, PlaybackPositionData>();
const seriesMap = new Map<number, PlaybackPositionData[]>();
positions.forEach((pos) => {
const key = `${pos.contentType}_${pos.contentXtreamId}`;
positionsMap.set(key, pos);
if (pos.contentType === 'episode' && pos.seriesXtreamId) {
const existing = seriesMap.get(pos.seriesXtreamId) || [];
existing.push(pos);
seriesMap.set(pos.seriesXtreamId, existing);
}
});
this.stalkerPositions.set(positionsMap);
this.stalkerSeriesPositions.set(seriesMap);
}
/**
* Load playback position for a single VOD item
*/
private async loadSingleVodPosition(
playlistId: string,
vodId: number
): Promise<void> {
const position = await this.dataSource.getPlaybackPosition(
playlistId,
vodId,
'vod'
);
if (position) {
const key = `vod_${vodId}`;
const updated = new Map(this.stalkerPositions());
updated.set(key, position);
this.stalkerPositions.set(updated);
}
}
/**
* Calculate progress percentage from position data
*/
private calculateProgress(
position: PlaybackPositionData | undefined
): number {
if (!position || !position.durationSeconds) return 0;
const percent =
(position.positionSeconds / position.durationSeconds) * 100;
if (position.positionSeconds > 10 && percent < 1) {
return 1;
}
return Math.min(100, Math.round(percent));
}
onPageChange(event: PageEvent) {
this.catalog.setPage(event.pageIndex);
this.catalog.setLimit(event.pageSize);
}
onItemClick(item: CategoryContentItem) {
const navigation = this.catalog.selectItem(item);
if (navigation?.length) {
this.router.navigate(navigation, {
relativeTo: this.activatedRoute,
});
}
}
async createLinkToPlayVod(
cmd?: string,
title?: string,
thumbnail?: string
) {
if (!this.stalkerCatalog) {
return;
}
await this.stalkerCatalog.createLinkToPlayVod(cmd, title, thumbnail);
}
addToFavorites(item: Record<string, unknown>, onDone?: () => void) {
this.logger.debug('Add to favorites', item);
if (this.stalkerCatalog) {
this.stalkerCatalog.addToFavorites(item, onDone);
return;
}
onDone?.();
}
removeFromFavorites(favoriteId: string, onDone?: () => void) {
this.logger.debug('Remove from favorites', favoriteId);
if (this.stalkerCatalog) {
this.stalkerCatalog.removeFromFavorites(favoriteId, onDone);
return;
}
onDone?.();
}
/** Handle play from vod-details component */
onVodPlay(item: VodDetailsItem): void {
if (item.type === 'stalker') {
void this.startStalkerVodPlayback(
item.cmd,
item.data.info?.name,
item.data.info?.movie_image
);
}
}
/** Handle resume from vod-details component */
onVodResume(event: {
item: VodDetailsItem;
positionSeconds: number;
}): void {
if (event.item.type === 'stalker') {
void this.startStalkerVodPlayback(
event.item.cmd,
event.item.data.info?.name,
event.item.data.info?.movie_image,
event.positionSeconds
);
}
}
/** Handle favorite toggle from vod-details component */
onVodFavoriteToggled(event: {
item: VodDetailsItem;
isFavorite: boolean;
}): void {
toggleStalkerVodFavorite(event, {
addToFavorites: (item, onDone) => this.addToFavorites(item, onDone),
removeFromFavorites: (favoriteId, onDone) =>
this.removeFromFavorites(favoriteId, onDone),
onComplete: () => {
this.favoritesRefresh.refresh();
},
});
}
/** Handle back from vod-details component */
onVodBack(): void {
this.closeInlinePlayer();
this.catalog.clearSelectedItem();
}
handleInlineTimeUpdate(event: {
currentTime: number;
duration: number;
}): void {
const playback = this.inlinePlayback();
if (!playback?.contentInfo) return;
const now = Date.now();
if (now - this.lastInlineSaveTime <= 15000) return;
this.lastInlineSaveTime = now;
const position: PlaybackPositionData = {
...playback.contentInfo,
positionSeconds: Math.floor(event.currentTime),
durationSeconds: Math.floor(event.duration),
};
void this.dataSource.savePlaybackPosition(
playback.contentInfo.playlistId,
position
);
const key = `vod_${position.contentXtreamId}`;
const updated = new Map(this.stalkerPositions());
updated.set(key, position);
this.stalkerPositions.set(updated);
}
closeInlinePlayer(): void {
this.inlinePlayback.set(null);
this.lastInlineSaveTime = 0;
}
showCopyNotification(): void {
this.snackBar.open(
this.translateService.instant('PORTALS.STREAM_URL_COPIED'),
null,
{
duration: 2000,
}
);
}
/** Handle download from vod-details component */
async onVodDownload(item: VodDetailsItem): Promise<void> {
if (item.type !== 'stalker' || !this.stalkerCatalog) return;
const playlist = this.catalog.playlist();
if (!playlist || !playlist.portalUrl || !playlist.macAddress) return;
let cmdToUse = item.cmd;
const itemData = item.data as DownloadVodData;
const normalizedItemId = normalizeStalkerEntityId(itemData?.id);
if (
itemData?.has_files !== undefined &&
cmdToUse &&
!cmdToUse.includes('://') &&
cmdToUse.includes('/media/') &&
!cmdToUse.includes('/media/file_')
) {
const fileId =
await this.stalkerCatalog.fetchMovieFileId(normalizedItemId);
if (fileId) {
cmdToUse = `/media/file_${fileId}.mpg`;
}
}
const url = await this.stalkerCatalog.fetchLinkToPlay(
playlist.portalUrl,
playlist.macAddress,
cmdToUse
);
if (!url) return;
const numericId = normalizeStalkerEntityIdAsNumber(itemData?.id) ?? 0;
await this.downloadsService.startDownload({
playlistId: playlist.id,
xtreamId: numericId,
contentType: 'vod',
title: itemData?.info?.name || itemData?.title || 'Unknown',
url,
posterUrl: itemData?.info?.movie_image,
headers: {
userAgent: playlist.userAgent,
referer: playlist.referer,
origin: playlist.origin,
},
playlistName: playlist.title || 'Stalker Portal',
playlistType: 'stalker',
portalUrl: playlist.portalUrl,
macAddress: playlist.macAddress,
});
}
ngOnDestroy(): void {
this.closeInlinePlayer();
this.unsubscribePositionUpdates?.();
}
private async startStalkerVodPlayback(
cmd?: string,
title?: string,
thumbnail?: string,
startTime?: number
): Promise<void> {
if (!this.stalkerCatalog) {
return;
}
try {
const playback = await this.stalkerCatalog.resolveVodPlayback(
cmd,
title,
thumbnail,
startTime
);
this.lastInlineSaveTime = 0;
if (this.playerService.isEmbeddedPlayer()) {
this.inlinePlayback.set(playback);
return;
}
this.closeInlinePlayer();
void this.playerService.openResolvedPlayback(playback, true);
} catch (error) {
this.logger.error('Failed to start inline VOD playback', error);
const errorMessage =
error instanceof Error && error.message === 'nothing_to_play'
? this.translateService.instant(
'PORTALS.CONTENT_NOT_AVAILABLE'
)
: this.translateService.instant('PORTALS.PLAYBACK_ERROR');
this.snackBar.open(errorMessage, null, {
duration: 3000,
});
}
}
}
@@ -1,588 +0,0 @@
// ============================================================================
// Netflix-style Detail View - Cinematic Streaming Design
// ============================================================================
:host {
--poster-width: 220px;
--poster-shadow: 0 8px 40px rgba(0, 0, 0, 0.15);
--content-max-width: 1400px;
// Light theme colors (default - no .dark-theme class on body)
--hero-gradient-start: rgba(255, 250, 245, 0);
--hero-gradient-mid: rgba(255, 250, 245, 0.75);
--hero-gradient-end: rgba(255, 250, 245, 1);
--surface-bg: #fffaf5;
--text-primary: #1a1a1a;
--text-secondary: rgba(26, 26, 26, 0.75);
--text-muted: rgba(26, 26, 26, 0.5);
--accent-color: #b81d24;
--tag-bg: rgba(0, 0, 0, 0.06);
--tag-border: rgba(0, 0, 0, 0.12);
--play-idle-bg: linear-gradient(135deg, #fffaf2 0%, #ffffff 52%, #f7ecdf 100%);
--play-idle-fg: #161310;
--play-idle-border: rgba(88, 48, 18, 0.12);
--play-idle-shadow: 0 18px 38px rgba(91, 56, 24, 0.14);
--play-launching-bg: linear-gradient(135deg, #ffd36f 0%, #ffb14c 46%, #f57d35 100%);
--play-launching-fg: #241103;
--play-launching-border: rgba(142, 66, 0, 0.2);
--play-launching-shadow: 0 22px 44px rgba(244, 136, 55, 0.32);
--play-stop-bg: linear-gradient(135deg, #ffd1c8 0%, #ff9f8d 42%, #ef5e64 100%);
--play-stop-fg: #321012;
--play-stop-border: rgba(162, 38, 45, 0.18);
--play-stop-shadow: 0 22px 46px rgba(214, 74, 86, 0.26);
--play-icon-shell-bg: rgba(255, 255, 255, 0.32);
--play-icon-shell-border: rgba(255, 255, 255, 0.3);
width: 100%;
height: 100%;
display: block;
overflow: hidden;
background: var(--surface-bg);
color: var(--text-primary);
}
// Dark theme overrides - cinematic dark mode
:host-context(.dark-theme) {
--hero-gradient-start: rgba(20, 20, 20, 0);
--hero-gradient-mid: rgba(20, 20, 20, 0.7);
--hero-gradient-end: rgba(20, 20, 20, 1);
--surface-bg: #141414;
--text-primary: #fff;
--text-secondary: rgba(255, 255, 255, 0.7);
--text-muted: rgba(255, 255, 255, 0.5);
--accent-color: #e50914;
--tag-bg: rgba(255, 255, 255, 0.1);
--tag-border: rgba(255, 255, 255, 0.2);
--poster-shadow: 0 8px 40px rgba(0, 0, 0, 0.5);
--play-idle-bg: linear-gradient(135deg, #fff5e6 0%, #f6efe5 58%, #e7dac9 100%);
--play-idle-fg: #15110d;
--play-idle-border: rgba(255, 255, 255, 0.18);
--play-idle-shadow: 0 20px 42px rgba(0, 0, 0, 0.36);
--play-launching-bg: linear-gradient(135deg, #ffdd79 0%, #ffb347 42%, #ef6a3a 100%);
--play-launching-fg: #1f1105;
--play-launching-border: rgba(255, 223, 136, 0.26);
--play-launching-shadow: 0 24px 50px rgba(255, 153, 71, 0.3);
--play-stop-bg: linear-gradient(135deg, #ffd7cf 0%, #ff9b8b 40%, #eb5961 100%);
--play-stop-fg: #2d1012;
--play-stop-border: rgba(255, 205, 205, 0.24);
--play-stop-shadow: 0 24px 50px rgba(235, 89, 97, 0.24);
--play-icon-shell-bg: rgba(255, 255, 255, 0.28);
--play-icon-shell-border: rgba(255, 255, 255, 0.24);
}
// ============================================================================
// Details Section - Content Elements
// ============================================================================
.details {
// Styles for projected content elements
&__tag {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 4px 12px;
font-size: 0.8125rem;
font-weight: 500;
background: var(--tag-bg);
border: 1px solid var(--tag-border);
border-radius: 4px;
backdrop-filter: blur(8px);
&--rating {
color: #ffd700;
border-color: rgba(255, 215, 0, 0.3);
}
}
&__meta-item {
font-size: 0.875rem;
.label {
color: var(--text-muted);
font-weight: 400;
margin-bottom: 2px;
text-decoration: none;
display: block;
}
.value {
color: var(--text-primary);
font-weight: 500;
}
}
}
// ============================================================================
// Action Buttons - Play, Favorite
// ============================================================================
.play-btn {
--play-btn-bg: var(--play-idle-bg);
--play-btn-fg: var(--play-idle-fg);
--play-btn-border: var(--play-idle-border);
--play-btn-shadow: var(--play-idle-shadow);
position: relative;
isolation: isolate;
overflow: hidden;
display: inline-flex;
align-items: center;
gap: 12px;
padding: 12px 26px;
min-height: 54px;
font-size: 1rem;
font-weight: 600;
letter-spacing: 0.01em;
background: var(--play-btn-bg);
background-size: 140% 140%;
background-position: 0% 50%;
color: var(--play-btn-fg);
border: 1px solid var(--play-btn-border);
border-radius: 16px;
box-shadow: var(--play-btn-shadow);
cursor: pointer;
transition:
transform 220ms cubic-bezier(0.22, 1, 0.36, 1),
background-position 280ms ease,
box-shadow 260ms ease,
border-color 220ms ease,
color 220ms ease,
filter 220ms ease;
&::before {
content: '';
position: absolute;
inset: 1px;
border-radius: inherit;
background:
linear-gradient(115deg, rgba(255, 255, 255, 0.34), transparent 36%),
linear-gradient(180deg, rgba(255, 255, 255, 0.18), transparent 62%);
opacity: 0.7;
transition: opacity 220ms ease;
z-index: -1;
}
&:hover:not(:disabled) {
transform: translateY(-2px);
background-position: 100% 50%;
box-shadow:
var(--play-btn-shadow),
0 10px 20px rgba(255, 255, 255, 0.16) inset;
}
&:hover:not(:disabled)::before {
opacity: 0.95;
}
&:active:not(:disabled) {
transform: translateY(0);
box-shadow: var(--play-btn-shadow);
}
&:disabled {
cursor: progress;
opacity: 1;
filter: saturate(1.05);
}
mat-icon {
font-size: 24px;
width: 24px;
height: 24px;
transition:
transform 220ms cubic-bezier(0.22, 1, 0.36, 1),
color 220ms ease,
filter 220ms ease;
}
&:hover:not(:disabled) .play-btn__icon {
transform: scale(1.08);
}
&__icon-shell {
flex: 0 0 auto;
display: inline-flex;
align-items: center;
justify-content: center;
width: 34px;
height: 34px;
border-radius: 999px;
background: var(--play-icon-shell-bg);
border: 1px solid var(--play-icon-shell-border);
box-shadow:
inset 0 1px 0 rgba(255, 255, 255, 0.38),
0 6px 14px rgba(0, 0, 0, 0.08);
backdrop-filter: blur(10px);
transition:
transform 220ms ease,
background-color 220ms ease,
border-color 220ms ease,
box-shadow 220ms ease;
}
&__label {
display: inline-flex;
align-items: center;
min-width: 0;
}
&--state-launching,
&--state-stop {
color: var(--play-btn-fg);
}
&--state-launching {
--play-btn-bg: var(--play-launching-bg);
--play-btn-fg: var(--play-launching-fg);
--play-btn-border: var(--play-launching-border);
--play-btn-shadow: var(--play-launching-shadow);
animation: playButtonPulse 1.8s ease-in-out infinite;
.play-btn__icon-shell {
box-shadow:
inset 0 1px 0 rgba(255, 255, 255, 0.42),
0 10px 22px rgba(184, 89, 26, 0.2);
}
.play-btn__icon {
animation: playIconLaunch 1.15s cubic-bezier(0.55, 0, 0.45, 1) infinite;
}
}
&--state-stop {
--play-btn-bg: var(--play-stop-bg);
--play-btn-fg: var(--play-stop-fg);
--play-btn-border: var(--play-stop-border);
--play-btn-shadow: var(--play-stop-shadow);
.play-btn__icon-shell {
box-shadow:
inset 0 1px 0 rgba(255, 255, 255, 0.4),
0 12px 24px rgba(145, 38, 53, 0.18);
}
.play-btn__icon {
animation: playIconStop 1.2s ease-in-out infinite;
}
}
}
@keyframes playButtonPulse {
0%,
100% {
box-shadow: var(--play-btn-shadow);
}
50% {
box-shadow:
var(--play-btn-shadow),
0 0 0 6px rgba(255, 190, 92, 0.14);
}
}
@keyframes playIconLaunch {
0%,
100% {
transform: rotate(0deg) scale(1);
}
50% {
transform: rotate(18deg) scale(1.1);
}
}
@keyframes playIconStop {
0%,
100% {
transform: scale(1);
}
50% {
transform: scale(1.08);
}
}
@media (prefers-reduced-motion: reduce) {
.play-btn,
.play-btn::before,
.play-btn mat-icon,
.play-btn__icon-shell {
animation: none !important;
transition-duration: 1ms !important;
}
}
.favorite-btn,
.download-btn {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 12px 20px;
font-size: 0.9375rem;
font-weight: 500;
background: rgba(109, 109, 110, 0.7);
color: #fff;
border: none;
border-radius: 4px;
cursor: pointer;
transition: all 0.2s ease;
&:hover {
background: rgba(109, 109, 110, 0.5);
}
mat-icon {
font-size: 20px;
width: 20px;
height: 20px;
}
}
.favorite-btn {
&--active {
background: rgba(229, 9, 20, 0.8);
&:hover {
background: rgba(229, 9, 20, 0.6);
}
}
}
.download-btn {
background: rgba(33, 150, 243, 0.8);
&:hover {
background: rgba(33, 150, 243, 0.6);
}
&--completed {
background: rgba(76, 175, 80, 0.8);
&:hover {
background: rgba(76, 175, 80, 0.6);
}
}
&--downloading {
background: rgba(109, 109, 110, 0.5);
cursor: not-allowed;
opacity: 0.7;
&:hover {
background: rgba(109, 109, 110, 0.5);
}
}
}
// ============================================================================
// Content Sections - Below the hero fold
// ============================================================================
.content-section {
padding: 20px 0;
width: 100%;
box-sizing: border-box;
&__title {
font-size: 1.375rem;
font-weight: 600;
margin: 0 0 20px 0;
color: var(--text-primary);
}
}
// ============================================================================
// Seasons & Episodes - Inside details section
// ============================================================================
.seasons {
margin-top: 24px;
width: 100%;
.season-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 0;
cursor: pointer;
transition: opacity 0.2s ease;
&:hover {
opacity: 0.8;
}
h4 {
margin: 0;
font-size: 1rem;
font-weight: 600;
}
.expand-icon {
font-size: 0.75rem;
color: var(--text-muted);
}
}
.episodes {
display: flex;
flex-wrap: wrap;
gap: 10px;
padding: 8px 0 16px;
button {
margin: 0;
font-weight: 500;
}
}
.episodes-loading,
.seasons-loading {
display: flex;
align-items: center;
gap: 12px;
padding: 20px 0;
color: var(--text-secondary);
}
.no-episodes {
color: var(--text-muted);
font-style: italic;
padding: 8px 0;
}
}
// Seasons loading when inside details
.details .seasons-loading {
display: flex;
align-items: center;
gap: 12px;
padding: 20px 0;
color: var(--text-secondary);
margin-top: 24px;
}
// Season container when inside details section
.details app-season-container {
display: block;
width: 100%;
margin-top: 24px;
box-sizing: border-box;
}
// ============================================================================
// YouTube Trailer Section - Inside details
// ============================================================================
.youtube-trailer {
margin-top: 24px;
width: 100%;
h3 {
font-size: 1.125rem;
font-weight: 600;
margin: 0 0 12px 0;
color: var(--text-primary);
}
.trailer-wrapper {
position: relative;
width: 100%;
max-width: 560px;
aspect-ratio: 16 / 9;
border-radius: 8px;
overflow: hidden;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.3);
iframe {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
border: none;
}
}
}
// ============================================================================
// Legacy container support (for gradual migration)
// ============================================================================
.container {
display: contents;
}
.image {
display: none; // Hidden - now using .poster in hero
}
// ============================================================================
// Mobile Responsive - Option A: Keep poster grid layout
// ============================================================================
@media (max-width: 800px) {
:host {
--poster-width: 120px;
}
.action-buttons {
width: 100%;
// This class is in ContentHeroComponent now, but these rules apply to children?
// No, .play-btn is a child of the PROJECTED content.
// So we should target the buttons directly for mobile
}
.play-btn,
.favorite-btn,
.download-btn {
flex: 1;
justify-content: center;
padding: 10px 16px;
}
.youtube-trailer {
margin-top: 16px;
.trailer-wrapper {
max-width: 100%;
border-radius: 4px;
}
}
.seasons {
padding: 16px 0 0;
}
}
// ============================================================================
// Animations
// ============================================================================
.youtube-trailer {
animation: fadeInUp 0.5s ease-out 0.4s both;
}
@keyframes fadeInUp {
from {
opacity: 0;
transform: translateY(20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
// ============================================================================
// Utility - keep .label for backwards compatibility
// ============================================================================
.label {
font-weight: 400;
font-size: 0.8125rem;
color: var(--text-muted);
text-decoration: none;
padding: 0;
opacity: 1;
}
@@ -1 +0,0 @@
@forward '../../../../../libs/portal/shared/ui/src/lib/styles/portal-main-container';
@@ -1,322 +0,0 @@
<section class="seasons-section">
<div class="section-header">
<div class="section-header__left">
<h3 class="section-title">Seasons and Episodes</h3>
@if (selectedSeason) {
<button class="back-btn" (click)="selectedSeason = undefined">
<mat-icon>arrow_back</mat-icon>
<span>Back to seasons</span>
</button>
}
</div>
@if (selectedSeason) {
<div class="view-toggle">
<mat-button-toggle-group
[value]="viewMode()"
(change)="setViewMode($event.value)"
hideSingleSelectionIndicator
>
<mat-button-toggle value="grid" [matTooltip]="'Grid view'">
<mat-icon>grid_view</mat-icon>
</mat-button-toggle>
<mat-button-toggle value="list" [matTooltip]="'List view'">
<mat-icon>view_list</mat-icon>
</mat-button-toggle>
</mat-button-toggle-group>
</div>
}
</div>
<div class="content-container">
@if (isLoading()) {
<div class="loading-container">
<mat-spinner diameter="40"></mat-spinner>
</div>
} @else if (!selectedSeason) {
<!-- Seasons Grid -->
<div class="seasons-grid">
@for (
season of seasons() | keyvalue: compareSeasons;
track $index
) {
<button
class="season-card"
(click)="selectSeason(season.key)"
[class.season-card--has-episodes]="season.value?.length"
>
<div class="season-card__number">{{ season.key }}</div>
<div class="season-card__label">Season</div>
@if (season.value?.length) {
<div class="season-card__episodes">
{{ season.value.length }}
{{
season.value.length === 1
? 'Episode'
: 'Episodes'
}}
</div>
}
<div class="season-card__arrow">
<mat-icon>chevron_right</mat-icon>
</div>
</button>
}
</div>
} @else {
@if (viewMode() === 'grid') {
<!-- Episodes Grid View -->
<div class="episodes-grid">
@for (episode of seasons()[selectedSeason]; track $index) {
@let episodeInfo = getEpisodeInfo(episode);
<article
class="episode-card"
(click)="selectEpisode(episode)"
[class.episode-card--watched]="
isEpisodeWatched(episode)
"
[class.episode-card--in-progress]="
isEpisodeInProgress(episode)
"
[class.episode-card--launching]="
isEpisodeLaunching(episode)
"
[class.episode-card--active-external]="
isEpisodeActiveExternal(episode)
"
>
<div class="episode-card__thumbnail">
@if (episodeInfo?.movie_image) {
<img
[src]="episodeInfo.movie_image"
(error)="
$event.target.src =
'./assets/images/default-episode.png'
"
alt="Episode thumbnail"
loading="lazy"
/>
} @else {
<div class="episode-card__placeholder">
<mat-icon>play_circle_outline</mat-icon>
</div>
}
@if (getEpisodeProgress(episode) > 0) {
<app-progress-capsule
[progress]="getEpisodeProgress(episode)"
/>
@if (getEpisodePositionText(episode); as time) {
<div class="episode-card__position-text">
{{ time }}
</div>
}
}
<app-watched-badge
[isWatched]="isEpisodeWatched(episode)"
/>
<div class="episode-card__actions">
@if (isElectron()) {
@if (isEpisodeDownloaded(episode)) {
<button
mat-icon-button
class="download-btn download-btn--completed"
(click)="playFromLocal($event, episode)"
[matTooltip]="'DOWNLOADS.PLAY_LOCAL' | translate"
>
<mat-icon>play_circle</mat-icon>
</button>
} @else if (isEpisodeDownloading(episode)) {
<button
mat-icon-button
class="download-btn download-btn--downloading"
disabled
[matTooltip]="'DOWNLOADS.STATUS.DOWNLOADING' | translate"
>
<mat-icon>downloading</mat-icon>
</button>
} @else {
<button
mat-icon-button
class="download-btn"
(click)="downloadEpisode($event, episode)"
[matTooltip]="'DOWNLOADS.DOWNLOAD' | translate"
>
<mat-icon>download</mat-icon>
</button>
}
}
<button
mat-icon-button
class="toggle-watched-btn"
(click)="toggleWatched($event, episode)"
[matTooltip]="
(isEpisodeWatched(episode)
? 'XTREAM.MARK_UNWATCHED'
: 'XTREAM.MARK_WATCHED') | translate
"
>
<mat-icon>{{
isEpisodeWatched(episode)
? 'check_circle'
: 'radio_button_unchecked'
}}</mat-icon>
</button>
</div>
<div class="episode-card__overlay">
<div class="episode-card__play">
@if (isEpisodeLaunching(episode)) {
<mat-spinner diameter="26"></mat-spinner>
} @else {
<mat-icon>{{
isEpisodeActiveExternal(episode)
? 'equalizer'
: 'play_arrow'
}}</mat-icon>
}
</div>
</div>
<div class="episode-card__number">
{{ episode.episode_num }}
</div>
</div>
<div class="episode-card__info">
<h4 class="episode-card__title">
{{ episode.episode_num }}. {{ episode.title }}
</h4>
@if (episodeInfo?.plot) {
<p class="episode-card__plot">
{{ episodeInfo.plot }}
</p>
}
@if (episodeInfo?.duration) {
<span class="episode-card__duration">
{{ episodeInfo.duration }}
</span>
}
</div>
</article>
}
</div>
} @else {
<!-- Episodes List View -->
<div class="episodes-list">
@for (episode of seasons()[selectedSeason]; track $index) {
@let episodeInfo = getEpisodeInfo(episode);
<article
class="episode-list-item"
(click)="selectEpisode(episode)"
[class.episode-list-item--watched]="isEpisodeWatched(episode)"
[class.episode-list-item--in-progress]="isEpisodeInProgress(episode)"
[class.episode-list-item--launching]="isEpisodeLaunching(episode)"
[class.episode-list-item--active-external]="isEpisodeActiveExternal(episode)"
>
<div class="episode-list-item__number">
{{ episode.episode_num }}
</div>
<div class="episode-list-item__content">
<div class="episode-list-item__header">
<h4 class="episode-list-item__title">
{{ episode.title }}
@if (isEpisodeWatched(episode)) {
<mat-icon class="watched-icon">check_circle</mat-icon>
}
</h4>
<div class="episode-list-item__meta">
@if (episodeInfo?.duration) {
<span class="episode-list-item__duration">
{{ episodeInfo.duration }}
</span>
}
@if (getEpisodePositionText(episode); as time) {
<span class="episode-list-item__progress-text">
{{ time }}
</span>
}
</div>
</div>
@if (episodeInfo?.plot) {
<p class="episode-list-item__description">
{{ episodeInfo.plot }}
</p>
}
@if (getEpisodeProgress(episode) > 0) {
<div class="episode-list-item__progress-bar">
<div
class="episode-list-item__progress-fill"
[style.width.%]="getEpisodeProgress(episode)"
></div>
</div>
}
</div>
<div class="episode-list-item__actions">
<button
mat-icon-button
class="action-btn play-btn"
(click)="selectEpisode(episode); $event.stopPropagation()"
[matTooltip]="'Play'"
[disabled]="isEpisodeLaunching(episode)"
>
@if (isEpisodeLaunching(episode)) {
<mat-spinner diameter="18"></mat-spinner>
} @else {
<mat-icon>{{
isEpisodeActiveExternal(episode)
? 'equalizer'
: 'play_arrow'
}}</mat-icon>
}
</button>
@if (isElectron()) {
@if (isEpisodeDownloaded(episode)) {
<button
mat-icon-button
class="action-btn action-btn--success"
(click)="playFromLocal($event, episode)"
[matTooltip]="'DOWNLOADS.PLAY_LOCAL' | translate"
>
<mat-icon>folder_open</mat-icon>
</button>
} @else if (isEpisodeDownloading(episode)) {
<button
mat-icon-button
class="action-btn"
disabled
[matTooltip]="'DOWNLOADS.STATUS.DOWNLOADING' | translate"
>
<mat-icon>downloading</mat-icon>
</button>
} @else {
<button
mat-icon-button
class="action-btn"
(click)="downloadEpisode($event, episode)"
[matTooltip]="'DOWNLOADS.DOWNLOAD' | translate"
>
<mat-icon>download</mat-icon>
</button>
}
}
<button
mat-icon-button
class="action-btn"
(click)="toggleWatched($event, episode)"
[matTooltip]="
(isEpisodeWatched(episode)
? 'XTREAM.MARK_UNWATCHED'
: 'XTREAM.MARK_WATCHED') | translate
"
[class.action-btn--success]="isEpisodeWatched(episode)"
>
<mat-icon>{{
isEpisodeWatched(episode)
? 'check_circle'
: 'radio_button_unchecked'
}}</mat-icon>
</button>
</div>
</article>
}
</div>
}
}
</div>
</section>
@@ -1,708 +0,0 @@
.seasons-section {
width: 100%;
margin-top: 24px;
}
.section-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 20px;
min-height: 32px;
gap: 16px;
&__left {
display: flex;
align-items: center;
gap: 16px;
flex: 1;
}
.section-title {
font-size: 1.25rem;
font-weight: 600;
margin: 0;
color: var(--text-primary);
letter-spacing: -0.01em;
}
.back-btn {
display: flex;
align-items: center;
gap: 6px;
background: rgba(255, 255, 255, 0.05);
border: 1px solid rgba(255, 255, 255, 0.1);
color: var(--text-secondary);
font-size: 0.8125rem;
font-weight: 500;
cursor: pointer;
transition: all 0.2s ease;
padding: 6px 12px;
border-radius: 4px;
&:hover {
color: var(--text-primary);
background: rgba(255, 255, 255, 0.1);
border-color: rgba(255, 255, 255, 0.2);
}
mat-icon {
font-size: 18px;
width: 18px;
height: 18px;
}
}
.view-toggle {
mat-button-toggle-group {
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 6px;
overflow: hidden;
background: rgba(255, 255, 255, 0.03);
::ng-deep {
.mat-button-toggle {
background: transparent;
.mat-button-toggle-button {
padding: 0 8px;
height: 32px;
}
mat-icon {
font-size: 18px;
width: 18px;
height: 18px;
color: var(--text-secondary);
}
&.mat-button-toggle-checked {
background: rgba(255, 255, 255, 0.1);
mat-icon {
color: var(--accent-color);
}
}
&:hover:not(.mat-button-toggle-checked) {
background: rgba(255, 255, 255, 0.05);
}
}
.mat-button-toggle-appearance-standard {
.mat-button-toggle-label-content {
line-height: 32px;
padding: 0;
}
}
.mat-button-toggle + .mat-button-toggle {
border-left: 1px solid rgba(255, 255, 255, 0.1);
}
}
}
}
}
.loading-container {
display: flex;
justify-content: center;
align-items: center;
padding: 30px 0;
width: 100%;
}
// Grid Layouts
.seasons-grid,
.episodes-grid {
display: grid;
gap: 12px;
width: 100%;
}
.seasons-grid {
grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));
}
.episodes-grid {
grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));
}
// Season Card
.season-card {
display: flex;
align-items: center;
gap: 12px;
padding: 12px;
background: rgba(255, 255, 255, 0.04);
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 6px;
cursor: pointer;
transition: all 0.2s ease;
text-align: left;
position: relative;
overflow: hidden;
color: var(--text-primary);
font-size: 1rem;
font-family: inherit;
&:hover {
background: rgba(255, 255, 255, 0.1);
transform: translateY(-2px);
border-color: rgba(255, 255, 255, 0.15);
.season-card__arrow {
opacity: 1;
transform: translateX(0);
}
}
&__progress-ring {
position: relative;
width: 42px;
height: 42px;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
svg {
width: 100%;
height: 100%;
transform: rotate(-90deg);
}
.progress-ring__bg {
fill: none;
stroke: rgba(255, 255, 255, 0.1);
stroke-width: 3;
}
.progress-ring__fill {
fill: none;
stroke: var(--accent-color);
stroke-width: 3;
stroke-linecap: round;
transition: stroke-dasharray 0.5s ease-out;
}
.season-card__number {
position: absolute;
font-size: 1rem !important;
color: var(--text-primary);
font-weight: 700;
opacity: 1;
margin: 0;
padding: 0;
background: transparent;
}
}
&__info {
flex: 1;
display: flex;
flex-direction: column;
gap: 2px;
}
&__label {
font-size: 0.9375rem !important;
font-weight: 600;
color: var(--text-primary);
}
&__progress-text {
font-size: 0.75rem;
color: var(--text-muted);
}
&__episodes {
font-size: 0.75rem;
color: var(--text-muted);
}
&__arrow {
opacity: 0;
transform: translateX(-5px);
transition: all 0.2s ease;
color: var(--text-muted);
mat-icon {
font-size: 18px;
width: 18px;
height: 18px;
}
}
&--has-episodes {
/* background: rgba(var(--accent-color-rgb), 0.1);
border-color: rgba(var(--accent-color-rgb), 0.3); */
.season-card__number {
color: var(--text-primary);
}
/* .season-card__label {
color: var(--accent-color);
} */
}
}
// Episode Card
.episode-card {
background: rgba(255, 255, 255, 0.03);
border-radius: 6px;
overflow: hidden;
cursor: pointer;
transition: all 0.2s ease;
border: 1px solid rgba(255, 255, 255, 0.05);
position: relative;
// In-progress state
&--in-progress {
border-color: color-mix(in srgb, var(--mat-sys-primary) 60%, transparent);
background: color-mix(in srgb, var(--mat-sys-primary) 5%, transparent);
&::before {
content: '';
position: absolute;
inset: 0;
background: linear-gradient(
135deg,
color-mix(in srgb, var(--mat-sys-primary) 10%, transparent) 0%,
transparent 50%
);
pointer-events: none;
z-index: 1;
}
}
&--launching,
&--active-external {
border-color: color-mix(in srgb, var(--mat-sys-tertiary) 56%, transparent);
box-shadow: 0 0 0 1px color-mix(in srgb, var(--mat-sys-tertiary) 28%, transparent);
}
&--launching {
.episode-card__overlay {
opacity: 1;
background: rgba(0, 0, 0, 0.56);
}
}
&--active-external {
background: color-mix(in srgb, var(--mat-sys-tertiary-container) 18%, transparent);
}
// Watched state
&--watched {
opacity: 0.7;
&:hover {
opacity: 1;
}
.episode-card__title::after {
content: ' ✓';
color: var(--mat-sys-tertiary);
font-weight: bold;
margin-left: 4px;
}
}
&:hover {
transform: translateY(-2px);
background: rgba(255, 255, 255, 0.07);
border-color: rgba(255, 255, 255, 0.1);
.episode-card__overlay {
opacity: 1;
}
.episode-card__play {
transform: scale(1);
}
}
&__thumbnail {
position: relative;
aspect-ratio: 16 / 9;
background: #000;
overflow: hidden;
img {
width: 100%;
height: 100%;
object-fit: cover;
}
}
&__position-text {
position: absolute;
bottom: 8px;
left: 8px;
background: rgba(0, 0, 0, 0.85);
padding: 2px 6px;
border-radius: 3px;
font-size: 0.7rem;
font-weight: 600;
color: white;
z-index: 2;
}
&__placeholder {
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
background: linear-gradient(135deg, #2a2a2a 0%, #1a1a1a 100%);
color: var(--text-muted);
mat-icon {
font-size: 32px;
width: 32px;
height: 32px;
opacity: 0.3;
}
}
&__overlay {
position: absolute;
inset: 0;
background: rgba(0, 0, 0, 0.4);
display: flex;
align-items: center;
justify-content: center;
opacity: 0;
transition: opacity 0.2s ease;
z-index: 1;
}
&__actions {
position: absolute;
top: 4px;
right: 4px;
z-index: 2;
opacity: 0;
transition: opacity 0.2s ease;
display: flex;
gap: 4px;
.download-btn,
.toggle-watched-btn {
color: rgba(255, 255, 255, 0.7);
background: rgba(0, 0, 0, 0.5);
width: 32px;
height: 32px;
padding: 4px;
&:hover {
color: white;
background: rgba(0, 0, 0, 0.8);
}
mat-icon {
font-size: 20px;
width: 20px;
height: 20px;
}
}
.download-btn:hover {
background: rgba(25, 118, 210, 0.8);
}
.download-btn--completed {
color: var(--mat-sys-tertiary) !important;
&:hover {
background: color-mix(in srgb, var(--mat-sys-tertiary) 80%, transparent);
}
}
.download-btn--downloading {
color: rgba(255, 255, 255, 0.5) !important;
cursor: not-allowed;
}
}
&:hover {
.episode-card__actions {
opacity: 1;
}
}
&__play {
width: 40px;
height: 40px;
border-radius: 50%;
background: var(--accent-color);
display: flex;
align-items: center;
justify-content: center;
color: white;
transform: scale(0.8);
transition: transform 0.2s cubic-bezier(0.175, 0.885, 0.32, 1.275);
mat-icon {
font-size: 24px;
width: 24px;
height: 24px;
}
}
&__number {
position: absolute;
bottom: 6px;
right: 6px;
background: rgba(0, 0, 0, 0.85);
padding: 2px 6px;
border-radius: 3px;
font-size: 0.7rem;
font-weight: 600;
color: white;
}
&__info {
padding: 12px;
}
&__title {
font-size: 0.875rem;
font-weight: 600;
margin: 0 0 4px 0;
color: var(--text-primary);
line-height: 1.4;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
&__plot {
font-size: 0.75rem;
color: var(--text-secondary);
margin: 0 0 8px 0;
line-height: 1.4;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
&__duration {
font-size: 0.6875rem;
color: var(--text-muted);
display: inline-block;
padding: 1px 6px;
background: rgba(255, 255, 255, 0.08);
border-radius: 10px;
}
}
// Episodes List View
.episodes-list {
display: flex;
flex-direction: column;
gap: 2px;
width: 100%;
}
.episode-list-item {
display: flex;
align-items: center;
gap: 16px;
padding: 12px 16px;
background: rgba(255, 255, 255, 0.02);
border-radius: 6px;
cursor: pointer;
transition: all 0.15s ease;
border: 1px solid transparent;
&:hover {
background: rgba(255, 255, 255, 0.06);
border-color: rgba(255, 255, 255, 0.08);
.episode-list-item__actions {
opacity: 1;
}
}
&--watched {
opacity: 0.6;
&:hover {
opacity: 1;
}
}
&--in-progress {
border-color: color-mix(in srgb, var(--mat-sys-primary) 40%, transparent);
background: color-mix(in srgb, var(--mat-sys-primary) 5%, transparent);
&:hover {
border-color: color-mix(in srgb, var(--mat-sys-primary) 60%, transparent);
background: color-mix(in srgb, var(--mat-sys-primary) 8%, transparent);
}
}
&__number {
display: flex;
align-items: center;
justify-content: center;
min-width: 36px;
height: 36px;
background: rgba(255, 255, 255, 0.06);
border-radius: 6px;
font-size: 0.875rem;
font-weight: 600;
color: var(--text-secondary);
flex-shrink: 0;
}
&__content {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 4px;
}
&__header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
&__title {
margin: 0;
font-size: 0.9375rem;
font-weight: 500;
color: var(--text-primary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
display: flex;
align-items: center;
gap: 6px;
.watched-icon {
font-size: 16px;
width: 16px;
height: 16px;
color: var(--mat-sys-tertiary);
}
}
&__meta {
display: flex;
align-items: center;
gap: 12px;
flex-shrink: 0;
}
&__duration {
font-size: 0.75rem;
color: var(--text-muted);
padding: 2px 8px;
background: rgba(255, 255, 255, 0.06);
border-radius: 10px;
}
&__progress-text {
font-size: 0.75rem;
font-weight: 500;
color: var(--mat-sys-primary);
padding: 2px 8px;
background: color-mix(in srgb, var(--mat-sys-primary) 15%, transparent);
border-radius: 10px;
}
&__description {
margin: 0;
font-size: 0.8125rem;
color: var(--text-secondary);
line-height: 1.4;
display: -webkit-box;
-webkit-line-clamp: 1;
-webkit-box-orient: vertical;
overflow: hidden;
}
&__progress-bar {
height: 3px;
background: rgba(255, 255, 255, 0.1);
border-radius: 2px;
overflow: hidden;
margin-top: 4px;
}
&__progress-fill {
height: 100%;
background: var(--mat-sys-primary);
border-radius: 2px;
transition: width 0.3s ease;
}
&__actions {
display: flex;
align-items: center;
gap: 4px;
opacity: 0;
transition: opacity 0.15s ease;
.action-btn {
width: 36px;
height: 36px;
color: var(--text-secondary);
transition: all 0.15s ease;
mat-icon {
font-size: 20px;
width: 20px;
height: 20px;
}
&:hover {
color: var(--text-primary);
background: rgba(255, 255, 255, 0.1);
}
&.play-btn {
color: var(--accent-color);
&:hover {
background: rgba(var(--accent-color-rgb), 0.15);
}
}
&--success {
color: var(--mat-sys-tertiary);
&:hover {
background: color-mix(in srgb, var(--mat-sys-tertiary) 15%, transparent);
}
}
&:disabled {
color: var(--text-muted);
cursor: not-allowed;
opacity: 0.5;
}
}
}
&--launching,
&--active-external {
border-color: color-mix(in srgb, var(--mat-sys-tertiary) 46%, transparent);
background: color-mix(in srgb, var(--mat-sys-tertiary-container) 12%, transparent);
}
// Always show actions on touch devices
@media (hover: none) {
.episode-list-item__actions {
opacity: 1;
}
}
}
@@ -1,450 +0,0 @@
import { KeyValuePipe } from '@angular/common';
import { Component, input, inject, output, signal, OnInit } from '@angular/core';
import { MatButtonModule } from '@angular/material/button';
import { MatButtonToggleModule } from '@angular/material/button-toggle';
import { MatIcon } from '@angular/material/icon';
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
import { MatTooltipModule } from '@angular/material/tooltip';
import { TranslateModule } from '@ngx-translate/core';
import {
ProgressCapsuleComponent,
WatchedBadgeComponent,
} from '@iptvnator/portal/shared/ui';
import { XtreamSerieEpisode, XtreamSerieEpisodeInfo } from 'shared-interfaces';
import { XtreamStore } from '@iptvnator/portal/xtream/data-access';
import { StalkerStore } from '@iptvnator/portal/stalker/data-access';
import { DownloadsService } from '../../services/downloads.service';
import { createLogger } from '@iptvnator/portal/shared/util';
type EpisodeViewMode = 'grid' | 'list';
const EPISODE_VIEW_MODE_KEY = 'iptvnator_episode_view_mode';
@Component({
selector: 'app-season-container',
templateUrl: './season-container.component.html',
styleUrls: ['./season-container.component.scss'],
imports: [
KeyValuePipe,
MatButtonModule,
MatButtonToggleModule,
MatIcon,
MatProgressSpinnerModule,
MatTooltipModule,
ProgressCapsuleComponent,
TranslateModule,
WatchedBadgeComponent,
],
})
export class SeasonContainerComponent implements OnInit {
private readonly xtreamStore = inject(XtreamStore);
private readonly stalkerStore = inject(StalkerStore);
private readonly downloadsService = inject(DownloadsService);
private readonly logger = createLogger('SeasonContainer');
readonly seasons = input.required<Record<string, XtreamSerieEpisode[]>>();
readonly seriesId = input.required<number>();
readonly seriesTitle = input<string>('');
readonly isLoading = input<boolean>(false);
readonly openingEpisodeId = input<number | null>(null);
readonly activeEpisodeId = input<number | null>(null);
readonly isElectron = this.downloadsService.isAvailable;
/** Current view mode for episodes (grid or list) */
readonly viewMode = signal<EpisodeViewMode>('grid');
ngOnInit() {
// Load saved view mode preference
const savedMode = localStorage.getItem(EPISODE_VIEW_MODE_KEY) as EpisodeViewMode;
if (savedMode === 'grid' || savedMode === 'list') {
this.viewMode.set(savedMode);
}
}
/** Toggle between grid and list view modes */
setViewMode(mode: EpisodeViewMode) {
this.viewMode.set(mode);
localStorage.setItem(EPISODE_VIEW_MODE_KEY, mode);
}
/**
* Detect if episode is from Stalker portal based on custom_sid marker
*/
private isStalkerEpisode(episode: XtreamSerieEpisode): boolean {
return (
(episode as any).custom_sid === 'vod-series' ||
(episode as any).custom_sid === 'regular-series'
);
}
/**
* Get the appropriate playlist ID based on mode
* Note: XtreamStore uses 'id', StalkerStore uses '_id'
*/
private getPlaylistId(episode: XtreamSerieEpisode): string | undefined {
if (this.isStalkerEpisode(episode)) {
return this.stalkerStore.currentPlaylist()?._id;
}
return this.xtreamStore.currentPlaylist()?.id;
}
/**
* Get the content ID for playback position lookup.
* For Stalker VOD series, use the generated unique ID (episode.id).
* The API-provided originalId is the same for all episodes (it's the series ID),
* so we use the hash-generated ID which is unique per episode.
* For Xtream episodes, use episode.id directly.
*/
private getEpisodeContentId(episode: XtreamSerieEpisode): number {
const customSid = (episode as any).custom_sid;
if (customSid === 'vod-series') {
// For vod-series, use the generated unique ID (episode.id)
// NOT originalId, which is the same for all episodes (series ID)
return Number(episode.id);
}
if (customSid === 'regular-series') {
// For regular-series, the hashed id is used for tracking
// (regular series don't have individual episode IDs from API)
return Number(episode.id);
}
// For xtream (non-Stalker), use episode.id directly
return Number(episode.id);
}
/**
* Get a stable numeric ID for an episode (used for downloads tracking)
* For Stalker episodes, use originalCmd/originalId; for Xtream use episode.id
*/
private getEpisodeDownloadId(episode: XtreamSerieEpisode): number {
const customSid = (episode as any).custom_sid;
if (customSid === 'regular-series') {
// For regular-series, use originalCmd (like "/media/file_123.mpg")
const cmd = (episode as any).originalCmd as string;
if (cmd) {
const match = cmd.match(/file_(\d+)/);
if (match) {
return Number(match[1]);
}
// Fallback: hash the cmd string to get a consistent number
return this.hashString(cmd);
}
// Final fallback: use the hashed id
return Number(episode.id);
}
if (customSid === 'vod-series') {
// For vod-series, use originalId from the API
const originalId = (episode as any).originalId;
const numId = Number(originalId);
return isNaN(numId) ? this.hashString(String(originalId)) : numId;
}
// For xtream (non-Stalker), use the numeric id directly
const numId = Number(episode.id);
return isNaN(numId) ? this.hashString(String(episode.id)) : numId;
}
/**
* Simple string hash function to generate consistent numeric IDs
*/
private hashString(str: string): number {
let hash = 0;
for (let i = 0; i < str.length; i++) {
const char = str.charCodeAt(i);
hash = (hash << 5) - hash + char;
hash = hash & hash; // Convert to 32-bit integer
}
return Math.abs(hash);
}
readonly episodeClicked = output<any>();
readonly seasonSelected = output<string>();
selectedSeason: string;
compareSeasons(a: any, b: any): number {
return Number(a.key) - Number(b.key);
}
selectSeason(seasonKey: string) {
this.selectedSeason = seasonKey;
this.seasonSelected.emit(seasonKey);
}
selectEpisode(episode: XtreamSerieEpisode) {
this.episodeClicked.emit(episode);
}
toggleWatched(event: Event, episode: XtreamSerieEpisode) {
event.stopPropagation();
const playlistId = this.getPlaylistId(episode);
if (!playlistId) {
this.logger.warn('Cannot toggle watched: no playlist ID');
return;
}
// For Stalker episodes, we need to use the content ID that matches
// what the player uses (originalId for vod-series)
const contentId = this.getEpisodeContentId(episode);
const episodeWithCorrectId = {
...episode,
id: String(contentId),
} as XtreamSerieEpisode;
this.xtreamStore.toggleEpisodeWatched(
playlistId,
episodeWithCorrectId,
this.seriesId()
);
}
/**
* Safely get episode info - returns undefined if info is an empty array
* (Xtream API returns [] when no metadata available instead of null/object)
*/
getEpisodeInfo(
episode: XtreamSerieEpisode
): XtreamSerieEpisodeInfo | undefined {
if (Array.isArray(episode.info) || !episode.info) {
return undefined;
}
return episode.info;
}
isEpisodeWatched(episode: XtreamSerieEpisode) {
const contentId = this.getEpisodeContentId(episode);
return this.xtreamStore.isWatched(contentId, 'episode');
}
isEpisodeInProgress(episode: XtreamSerieEpisode) {
const contentId = this.getEpisodeContentId(episode);
return this.xtreamStore.isInProgress(contentId, 'episode');
}
isEpisodeLaunching(episode: XtreamSerieEpisode): boolean {
return this.openingEpisodeId() === this.getEpisodeContentId(episode);
}
isEpisodeActiveExternal(episode: XtreamSerieEpisode): boolean {
return this.activeEpisodeId() === this.getEpisodeContentId(episode);
}
getEpisodeProgress(episode: XtreamSerieEpisode) {
const contentId = this.getEpisodeContentId(episode);
return this.xtreamStore.getProgressPercent(contentId, 'episode');
}
getEpisodePositionText(episode: XtreamSerieEpisode): string | null {
if (this.isEpisodeWatched(episode)) return null;
const contentId = this.getEpisodeContentId(episode);
const position = this.xtreamStore
.playbackPositions()
.get(`episode_${contentId}`);
if (!position || !position.positionSeconds) return null;
let seconds = position.positionSeconds;
let suffix = '';
// Calculate remaining time if duration is available
if (position.durationSeconds > 0) {
const remaining = Math.max(
0,
position.durationSeconds - position.positionSeconds
);
if (remaining <= 0) return null;
seconds = remaining;
suffix = ' left';
}
const hours = Math.floor(seconds / 3600);
const minutes = Math.floor((seconds % 3600) / 60);
const secs = seconds % 60;
const formatted = [hours, minutes, secs]
.map((n) => String(n).padStart(2, '0'))
.filter((v, i) => (i === 0 ? v !== '00' : true))
.join(':');
return `${formatted}${suffix}`;
}
getSeasonWatchedCount(seasonKey: string): number {
const episodes = this.seasons()[seasonKey];
if (!episodes) return 0;
return episodes.filter((e) => this.isEpisodeWatched(e)).length;
}
getSeasonProgressDash(seasonKey: string): string {
const episodes = this.seasons()[seasonKey];
if (!episodes || episodes.length === 0) return '0, 100';
const watched = this.getSeasonWatchedCount(seasonKey);
const percent = (watched / episodes.length) * 100;
return `${percent}, 100`;
}
async downloadEpisode(event: Event, episode: XtreamSerieEpisode) {
event.stopPropagation();
// Handle Stalker mode
if (this.isStalkerEpisode(episode)) {
await this.downloadStalkerEpisode(episode);
return;
}
// Xtream mode
const playlist = this.xtreamStore.currentPlaylist();
if (!playlist) return;
// Construct the episode stream URL
const serverUrl = playlist.serverUrl?.replace(/\/$/, '') || '';
const username = playlist.username || '';
const password = playlist.password || '';
const extension = episode.container_extension || 'mp4';
const url = `${serverUrl}/series/${username}/${password}/${episode.id}.${extension}`;
// Get episode info for poster
const episodeInfo = this.getEpisodeInfo(episode);
const posterUrl = episodeInfo?.movie_image;
// Build episode title
const seriesName = this.seriesTitle() || 'Series';
const seasonNum = episode.season || Number(this.selectedSeason) || 1;
const episodeNum = episode.episode_num || 1;
const episodeTitle = `${seriesName} - S${String(seasonNum).padStart(2, '0')}E${String(episodeNum).padStart(2, '0')} - ${episode.title}`;
await this.downloadsService.startDownload({
playlistId: playlist.id,
xtreamId: Number(episode.id),
contentType: 'episode',
title: episodeTitle,
url,
posterUrl,
seriesXtreamId: this.seriesId(),
seasonNumber: seasonNum,
episodeNumber: episodeNum,
});
}
/**
* Download episode from Stalker portal
*/
private async downloadStalkerEpisode(episode: XtreamSerieEpisode) {
const playlist = this.stalkerStore.currentPlaylist();
if (!playlist || !playlist.portalUrl || !playlist.macAddress) return;
const customSid = (episode as any).custom_sid;
let cmd: string;
if (customSid === 'vod-series') {
// VOD series episode - use originalId for the cmd
const originalId = (episode as any).originalId;
cmd = `/media/file_${originalId}.mpg`;
} else {
// Regular series - use originalCmd for the cmd
cmd = (episode as any).originalCmd as string;
}
// Resolve the actual stream URL via Stalker API
// The cmd is a reference that needs to be resolved to get the tokenized URL
let url: string;
try {
url = await this.stalkerStore.fetchLinkToPlay(
playlist.portalUrl,
playlist.macAddress,
cmd,
episode.episode_num // series param for episode number
);
if (!url) {
this.logger.error('Failed to resolve Stalker stream URL');
return;
}
} catch (error) {
this.logger.error('Error resolving Stalker stream URL', error);
return;
}
// Get episode info for poster
const episodeInfo = this.getEpisodeInfo(episode);
const posterUrl = episodeInfo?.movie_image;
// Build episode title
const selectedItem = this.stalkerStore.selectedItem();
const seriesName = selectedItem?.info?.name || this.seriesTitle() || 'Series';
const seasonNum = Number(this.selectedSeason) || 1;
const episodeNum = episode.episode_num || 1;
const episodeTitle = `${seriesName} - S${String(seasonNum).padStart(2, '0')}E${String(episodeNum).padStart(2, '0')} - ${episode.title}`;
await this.downloadsService.startDownload({
playlistId: playlist._id,
xtreamId: this.getEpisodeDownloadId(episode),
contentType: 'episode',
title: episodeTitle,
url,
posterUrl,
seriesXtreamId: this.seriesId(),
seasonNumber: seasonNum,
episodeNumber: episodeNum,
headers: {
userAgent: playlist.userAgent,
referer: playlist.referrer,
origin: playlist.origin,
},
// Playlist info for auto-creation (Stalker playlists)
playlistName: playlist.title || 'Stalker Portal',
playlistType: 'stalker',
portalUrl: playlist.portalUrl,
macAddress: playlist.macAddress,
});
}
/** Check if episode is already downloaded */
isEpisodeDownloaded(episode: XtreamSerieEpisode): boolean {
const playlistId = this.getPlaylistId(episode);
if (!playlistId) return false;
// Access downloads signal to make this reactive
this.downloadsService.downloads();
return this.downloadsService.isDownloaded(
this.getEpisodeDownloadId(episode),
playlistId,
'episode'
);
}
/** Check if episode is currently downloading */
isEpisodeDownloading(episode: XtreamSerieEpisode): boolean {
const playlistId = this.getPlaylistId(episode);
if (!playlistId) return false;
// Access downloads signal to make this reactive
this.downloadsService.downloads();
return this.downloadsService.isDownloading(
this.getEpisodeDownloadId(episode),
playlistId,
'episode'
);
}
/** Play episode from local file */
async playFromLocal(event: Event, episode: XtreamSerieEpisode) {
event.stopPropagation();
const playlistId = this.getPlaylistId(episode);
if (!playlistId) return;
const filePath = this.downloadsService.getDownloadedFilePath(
this.getEpisodeDownloadId(episode),
playlistId,
'episode'
);
if (filePath) {
await this.downloadsService.playDownload(filePath);
}
}
}
@@ -1,164 +0,0 @@
@let meta = normalizedMeta();
<!-- Hero Section with Backdrop -->
<app-content-hero
[title]="meta.title"
[description]="meta.description"
[posterUrl]="meta.posterUrl"
[backdropUrl]="meta.backdropUrl"
(backClicked)="goBack()"
>
<!-- Tags: Year, Genre, Duration, Rating -->
<ng-container hero-tags>
@if (meta.year) {
<span class="details__tag">{{ meta.year }}</span>
}
@if (meta.genre) {
<span class="details__tag">{{ meta.genre }}</span>
}
@if (meta.duration) {
<span class="details__tag">{{ meta.duration }}</span>
}
@if (meta.ratingImdb) {
<span class="details__tag details__tag--rating">
⭐ {{ meta.ratingImdb }}
</span>
}
@if (meta.country) {
<span class="details__tag">{{ meta.country }}</span>
}
</ng-container>
<!-- Metadata Grid -->
<ng-container hero-meta>
@if (meta.actors) {
<div class="details__meta-item">
<span class="label">{{ 'XTREAM.ACTORS' | translate }}</span>
<span class="value">{{ meta.actors }}</span>
</div>
}
@if (meta.director) {
<div class="details__meta-item">
<span class="label">{{ 'XTREAM.DIRECTOR' | translate }}</span>
<span class="value">{{ meta.director }}</span>
</div>
}
@if (meta.ratingKinopoisk) {
<div class="details__meta-item">
<span class="label">{{ 'XTREAM.KINOPOISK_RATING' | translate }}</span>
<span class="value">{{ meta.ratingKinopoisk }}</span>
</div>
}
</ng-container>
<!-- Action Buttons -->
<ng-container hero-actions>
<!-- Resume/Play buttons -->
@if (hasPlaybackPosition()) {
<button
class="play-btn play-btn--resume"
(click)="onPrimaryAction()"
[disabled]="isExternalLaunchPending()"
[class.play-btn--state-launching]="externalPrimaryButtonState() === 'launching'"
[class.play-btn--state-stop]="externalPrimaryButtonState() === 'stop'"
>
<span class="play-btn__icon-shell" aria-hidden="true">
<mat-icon class="play-btn__icon">{{ externalPrimaryIcon() }}</mat-icon>
</span>
<span class="play-btn__label">
@if (externalPrimaryLabel(); as label) {
{{ label }}
} @else {
{{ 'XTREAM.RESUME' | translate }} {{ formattedPosition() }}
}
</span>
</button>
@if (!isExternalStopAction()) {
<button class="play-btn play-btn--restart" (click)="onPlay()">
<mat-icon>replay</mat-icon>
{{ 'XTREAM.RESTART' | translate }}
</button>
}
} @else {
<button
class="play-btn"
(click)="onPrimaryAction()"
[disabled]="isExternalLaunchPending()"
[class.play-btn--state-launching]="externalPrimaryButtonState() === 'launching'"
[class.play-btn--state-stop]="externalPrimaryButtonState() === 'stop'"
>
<span class="play-btn__icon-shell" aria-hidden="true">
<mat-icon class="play-btn__icon">{{ externalPrimaryIcon() }}</mat-icon>
</span>
<span class="play-btn__label">
@if (externalPrimaryLabel(); as label) {
{{ label }}
} @else {
{{ 'XTREAM.PLAY' | translate }}
}
</span>
</button>
}
<!-- Favorites button -->
@if (!isFavorite()) {
<button class="favorite-btn" (click)="toggleFavorite()">
<mat-icon>add</mat-icon>
{{ 'PORTALS.ADD_TO_FAVORITES' | translate }}
</button>
} @else {
<button
class="favorite-btn favorite-btn--active"
(click)="toggleFavorite()"
>
<mat-icon>check</mat-icon>
{{ 'PORTALS.REMOVE_FROM_FAVORITES' | translate }}
</button>
}
<!-- Download buttons (Electron only) -->
@if (isElectron()) {
@if (isDownloaded()) {
<button class="download-btn download-btn--completed" (click)="playFromLocal()">
<mat-icon>play_circle</mat-icon>
{{ 'DOWNLOADS.PLAY_LOCAL' | translate }}
</button>
} @else if (isDownloading()) {
<button class="download-btn download-btn--downloading" disabled>
<mat-icon>downloading</mat-icon>
{{ 'DOWNLOADS.STATUS.DOWNLOADING' | translate }}
</button>
} @else {
<button class="download-btn" (click)="onDownload()">
<mat-icon>download</mat-icon>
{{ 'DOWNLOADS.DOWNLOAD' | translate }}
</button>
}
}
</ng-container>
@if (inlinePlayback(); as playback) {
<app-portal-inline-player
[playback]="playback"
(timeUpdate)="onInlineTimeUpdate($event)"
(closed)="closeInlinePlayback()"
(streamUrlCopied)="onStreamUrlCopied()"
/>
}
<!-- YouTube Trailer (Xtream only) -->
@if (meta.youtubeTrailer) {
<div class="youtube-trailer">
<h3>{{ 'XTREAM.YOUTUBE_TRAILER' | translate }}</h3>
<div class="trailer-wrapper">
<iframe
[src]="'https://www.youtube.com/embed/' + meta.youtubeTrailer | safe"
title="YouTube video player"
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"
referrerpolicy="strict-origin-when-cross-origin"
allowfullscreen
></iframe>
</div>
</div>
}
</app-content-hero>
@@ -1,327 +0,0 @@
import {
Component,
computed,
inject,
input,
output,
OnDestroy,
} from '@angular/core';
import { MatIcon } from '@angular/material/icon';
import { TranslatePipe } from '@ngx-translate/core';
import { ContentHeroComponent } from 'components';
import {
ExternalPlayerSession,
ResolvedPortalPlayback,
VodDetailsItem,
normalizeVodDetails,
getVodNumericId,
} from 'shared-interfaces';
import { DownloadsService } from '../../services/downloads.service';
import { ExternalPlaybackService } from '../../services/external-playback.service';
import { SafePipe } from '@iptvnator/pipes';
import { StalkerStore } from '@iptvnator/portal/stalker/data-access';
import { PortalInlinePlayerComponent } from '../../shared/components/portal-inline-player/portal-inline-player.component';
/**
* Unified VOD details component for both Xtream and Stalker portals.
*
* Uses discriminated union (VodDetailsItem) for type-safe handling.
* All actions are emitted as outputs - parent components handle
* store-specific operations (play, favorites, downloads).
*
* @example
* ```html
* <app-vod-details
* [item]="vodItem"
* [isFavorite]="isFavorite()"
* [playbackPosition]="position()"
* (playClicked)="onPlay($event)"
* (resumeClicked)="onResume($event)"
* (favoriteToggled)="onToggleFavorite($event)"
* />
* ```
*/
@Component({
selector: 'app-vod-details',
templateUrl: './vod-details.component.html',
styleUrls: ['../detail-view.scss'],
imports: [
ContentHeroComponent,
MatIcon,
PortalInlinePlayerComponent,
SafePipe,
TranslatePipe,
],
})
export class VodDetailsComponent implements OnDestroy {
// ============ Inputs ============
/** VOD item with discriminated union type */
readonly item = input.required<VodDetailsItem>();
/** Whether this item is in favorites (managed by parent) */
readonly isFavorite = input<boolean>(false);
/** Playback position in seconds for resume feature (managed by parent) */
readonly playbackPosition = input<number | null>(null);
/** Inline playback payload for embedded players (managed by parent) */
readonly inlinePlayback = input<ResolvedPortalPlayback | null>(null);
/** Active external playback session for launch state */
readonly externalPlayback = input<ExternalPlayerSession | null>(null);
// ============ Outputs ============
/** Emitted when play button is clicked */
readonly playClicked = output<VodDetailsItem>();
/** Emitted when resume button is clicked (includes position) */
readonly resumeClicked = output<{ item: VodDetailsItem; positionSeconds: number }>();
/** Emitted when favorite toggle is clicked */
readonly favoriteToggled = output<{ item: VodDetailsItem; isFavorite: boolean }>();
/** Emitted when back button is clicked */
readonly backClicked = output<void>();
/** Emitted when download is requested (parent handles URL construction) */
readonly downloadRequested = output<VodDetailsItem>();
/** Emitted when inline playback position changes */
readonly inlineTimeUpdated = output<{
currentTime: number;
duration: number;
}>();
/** Emitted when the inline player should be closed */
readonly inlinePlaybackClosed = output<void>();
/** Emitted when the stream url is copied */
readonly streamUrlCopied = output<void>();
// ============ Services ============
private readonly downloadsService = inject(DownloadsService);
private readonly externalPlaybackService = inject(ExternalPlaybackService);
private readonly stalkerStore = inject(StalkerStore);
// ============ Computed State ============
/** Whether running in Electron (downloads available) */
readonly isElectron = computed(() => this.downloadsService.isAvailable());
/** Normalized metadata for display */
readonly normalizedMeta = computed(() => {
return normalizeVodDetails(this.item());
});
/** Whether item is from Stalker portal */
readonly isStalkerItem = computed(() => this.item().type === 'stalker');
/** Whether there's a playback position to resume from */
readonly hasPlaybackPosition = computed(() => {
const pos = this.playbackPosition();
return pos !== null && pos > 0;
});
/** Formatted playback position (e.g., "12:34" or "1:23:45") */
readonly formattedPosition = computed(() => {
const pos = this.playbackPosition();
if (!pos || pos <= 0) return '';
const hours = Math.floor(pos / 3600);
const minutes = Math.floor((pos % 3600) / 60);
const seconds = Math.floor(pos % 60);
if (hours > 0) {
return `${hours}:${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}`;
}
return `${minutes}:${seconds.toString().padStart(2, '0')}`;
});
/** Whether VOD is already downloaded */
readonly isDownloaded = computed(() => {
const item = this.item();
// Access signal to create reactive dependency
this.downloadsService.downloads();
const vodId = getVodNumericId(item);
return this.downloadsService.isDownloaded(vodId, item.playlistId, 'vod');
});
/** Whether VOD is currently downloading */
readonly isDownloading = computed(() => {
const item = this.item();
// Access signal to create reactive dependency
this.downloadsService.downloads();
const vodId = getVodNumericId(item);
return this.downloadsService.isDownloading(vodId, item.playlistId, 'vod');
});
readonly matchedExternalPlayback = computed(() => {
const session = this.externalPlayback();
const item = this.item();
if (
!session?.contentInfo ||
session.status === 'closed' ||
session.status === 'error'
) {
return null;
}
const contentInfo = session.contentInfo;
if (
contentInfo.playlistId !== item.playlistId ||
contentInfo.contentType !== 'vod' ||
contentInfo.contentXtreamId !== getVodNumericId(item)
) {
return null;
}
return session;
});
readonly externalPrimaryLabel = computed(() => {
const session = this.matchedExternalPlayback();
if (!session) {
return null;
}
const player = session.player.toUpperCase();
switch (session.status) {
case 'launching':
return `Opening in ${player}...`;
case 'opened':
case 'playing':
return `Stop ${player}`;
default:
return null;
}
});
readonly externalPrimaryIcon = computed(() => {
const session = this.matchedExternalPlayback();
switch (session?.status) {
case 'launching':
return 'hourglass_top';
case 'opened':
case 'playing':
return 'stop_circle';
default:
return 'play_arrow';
}
});
readonly isExternalLaunchPending = computed(
() => this.matchedExternalPlayback()?.status === 'launching'
);
readonly isExternalStopAction = computed(() => {
const status = this.matchedExternalPlayback()?.status;
return status === 'opened' || status === 'playing';
});
readonly externalPrimaryButtonState = computed(() => {
if (this.isExternalLaunchPending()) {
return 'launching';
}
return this.isExternalStopAction() ? 'stop' : 'idle';
});
// ============ Lifecycle ============
ngOnDestroy(): void {
// Clear selected item when leaving Stalker mode
if (this.isStalkerItem()) {
this.stalkerStore.clearSelectedItem();
}
}
// ============ Actions ============
/** Handle play button click */
onPlay(): void {
this.playClicked.emit(this.item());
}
onPrimaryAction(): void {
if (this.isExternalStopAction()) {
void this.stopExternalPlayback();
return;
}
if (this.hasPlaybackPosition()) {
this.onResume();
return;
}
this.onPlay();
}
/** Handle resume button click */
onResume(): void {
const pos = this.playbackPosition();
if (pos && pos > 0) {
this.resumeClicked.emit({
item: this.item(),
positionSeconds: pos,
});
}
}
/** Handle favorite toggle - emits the desired new state */
toggleFavorite(): void {
this.favoriteToggled.emit({
item: this.item(),
isFavorite: !this.isFavorite(),
});
}
/** Handle back navigation - emit event for parent to handle */
goBack(): void {
this.backClicked.emit();
}
/** Handle download request */
onDownload(): void {
this.downloadRequested.emit(this.item());
}
onInlineTimeUpdate(event: {
currentTime: number;
duration: number;
}): void {
this.inlineTimeUpdated.emit(event);
}
closeInlinePlayback(): void {
this.inlinePlaybackClosed.emit();
}
onStreamUrlCopied(): void {
this.streamUrlCopied.emit();
}
async stopExternalPlayback(): Promise<void> {
await this.externalPlaybackService.closeSession(
this.matchedExternalPlayback()
);
}
/** Play from local downloaded file */
async playFromLocal(): Promise<void> {
const item = this.item();
const vodId = getVodNumericId(item);
const filePath = this.downloadsService.getDownloadedFilePath(
vodId,
item.playlistId,
'vod'
);
if (filePath) {
await this.downloadsService.playDownload(filePath);
}
}
}
@@ -1,92 +0,0 @@
import { Injectable, signal, computed } from '@angular/core';
export interface EpgImportProgress {
url: string;
status: 'queued' | 'loading' | 'complete' | 'error';
stats?: { totalChannels: number; totalPrograms: number };
error?: string;
queuePosition?: number;
}
/**
* Service to track EPG import progress across multiple sources.
* Uses signals for reactive state management.
*/
@Injectable({ providedIn: 'root' })
export class EpgProgressService {
private _imports = signal<Map<string, EpgImportProgress>>(new Map());
private _initialized = false;
/** All current import progress entries */
readonly imports = computed(() => Array.from(this._imports().values()));
/** Whether any imports are currently loading */
readonly hasActiveImports = computed(() =>
this.imports().some((i) => i.status === 'loading')
);
/** Count of active imports */
readonly activeCount = computed(() =>
this.imports().filter((i) => i.status === 'loading').length
);
/** Queued imports waiting to be processed */
readonly queuedImports = computed(() =>
this.imports()
.filter((i) => i.status === 'queued')
.sort((a, b) => (a.queuePosition ?? 0) - (b.queuePosition ?? 0))
);
/** Count of queued imports */
readonly queuedCount = computed(() => this.queuedImports().length);
/** Whether the panel should be visible */
readonly isVisible = computed(() => this.imports().length > 0);
constructor() {
this.initializeListener();
}
private initializeListener(): void {
if (this._initialized) return;
this._initialized = true;
// Listen for EPG progress updates from Electron
if (window.electron?.onEpgProgress) {
window.electron.onEpgProgress((data) => {
this.updateProgress(data);
});
}
}
private updateProgress(progress: EpgImportProgress): void {
this._imports.update((current) => {
const updated = new Map(current);
updated.set(progress.url, progress);
return updated;
});
// Auto-remove completed/error entries after delay
if (progress.status === 'complete' || progress.status === 'error') {
setTimeout(() => this.removeImport(progress.url), 5000);
}
}
private removeImport(url: string): void {
this._imports.update((current) => {
const updated = new Map(current);
updated.delete(url);
return updated;
});
}
/** Manually dismiss an import notification */
dismiss(url: string): void {
this.removeImport(url);
}
/** Dismiss all notifications */
dismissAll(): void {
this._imports.set(new Map());
}
}
+7 -6
View File
@@ -1,5 +1,10 @@
import { Injectable, inject } from '@angular/core';
import { MatDialog } from '@angular/material/dialog';
import { ExternalPlayerInfoDialogComponent } from 'components';
import {
PlayerDialogComponent,
PlayerDialogData,
} from '@iptvnator/portal/xtream/feature';
import { DataService } from 'services';
import {
ExternalPlayerSession,
@@ -9,11 +14,6 @@ import {
ResolvedPortalPlayback,
VideoPlayer,
} from 'shared-interfaces';
import { ExternalPlayerInfoDialogComponent } from '../shared/components/external-player-info-dialog/external-player-info-dialog.component';
import {
PlayerDialogComponent,
PlayerDialogData,
} from '../xtream-electron/player-dialog/player-dialog.component';
import { SettingsStore } from './settings-store.service';
@Injectable({
@@ -39,7 +39,7 @@ export class PlayerService {
title: string,
thumbnail?: string,
hideExternalInfoDialog = true,
isLiveContent = false,
_isLiveContent = false,
userAgent?: string,
referer?: string,
origin?: string,
@@ -47,6 +47,7 @@ export class PlayerService {
startTime?: number,
headers?: Record<string, string>
): Promise<ExternalPlayerSession | void> {
void _isLiveContent;
return this.openResolvedPlayback(
{
streamUrl,
@@ -5,10 +5,10 @@ import {
PORTAL_NAVIGATION_ACTIONS,
PortalNavigationActions,
} from '@iptvnator/portal/shared/util';
import { AccountInfoComponent } from '@iptvnator/portal/xtream/feature';
import { XtreamStore } from '@iptvnator/portal/xtream/data-access';
import { Playlist } from 'shared-interfaces';
import { SettingsComponent } from '../settings/settings.component';
import { AccountInfoComponent } from '../xtream-electron/account-info/account-info.component';
@Injectable({ providedIn: 'root' })
export class AppPortalNavigationActionsService
@@ -0,0 +1,72 @@
import { Injectable, inject } from '@angular/core';
import {
PORTAL_PLAYBACK_POSITIONS,
PortalPlaybackPositions,
} from '@iptvnator/portal/shared/util';
import {
PlaybackPositionData,
XTREAM_DATA_SOURCE,
} from '@iptvnator/portal/xtream/data-access';
@Injectable({
providedIn: 'root',
})
export class AppPortalPlaybackPositionsService
implements PortalPlaybackPositions
{
private readonly dataSource = inject(XTREAM_DATA_SOURCE);
async savePlaybackPosition(
playlistId: string,
data: PlaybackPositionData
): Promise<void> {
await this.dataSource.savePlaybackPosition(playlistId, data);
}
async getPlaybackPosition(
playlistId: string,
contentXtreamId: number,
contentType: 'vod' | 'episode'
): Promise<PlaybackPositionData | null> {
return this.dataSource.getPlaybackPosition(
playlistId,
contentXtreamId,
contentType
);
}
async getSeriesPlaybackPositions(
playlistId: string,
seriesXtreamId: number
): Promise<PlaybackPositionData[]> {
return this.dataSource.getSeriesPlaybackPositions(
playlistId,
seriesXtreamId
);
}
async getAllPlaybackPositions(
playlistId: string
): Promise<PlaybackPositionData[]> {
return this.dataSource.getAllPlaybackPositions(playlistId);
}
async clearPlaybackPosition(
playlistId: string,
contentXtreamId: number,
contentType: 'vod' | 'episode'
): Promise<void> {
await this.dataSource.clearPlaybackPosition(
playlistId,
contentXtreamId,
contentType
);
}
}
export const providePortalPlaybackPositions = () => [
{
provide: PORTAL_PLAYBACK_POSITIONS,
useExisting: AppPortalPlaybackPositionsService,
},
];
@@ -0,0 +1,70 @@
import { inject, Injectable, Provider } from '@angular/core';
import { MatDialog } from '@angular/material/dialog';
import { AddPlaylistDialogComponent } from '@iptvnator/playlist/import/feature';
import {
AccountInfoComponent,
GlobalRecentlyViewedComponent,
GlobalSearchResultsComponent,
} from '@iptvnator/portal/xtream/feature';
import { PlaylistType } from 'components';
import {
WORKSPACE_SHELL_ACTIONS,
WorkspaceAccountInfoData,
WorkspaceShellActions,
} from '@iptvnator/workspace/shell/feature';
@Injectable({ providedIn: 'root' })
export class AppWorkspaceShellActionsService implements WorkspaceShellActions {
private readonly dialog = inject(MatDialog);
openAddPlaylistDialog(type: PlaylistType): void {
this.dialog.open(AddPlaylistDialogComponent, {
width: '600px',
data: { type },
});
}
openGlobalSearch(initialQuery = ''): void {
this.dialog.open(GlobalSearchResultsComponent, {
width: '100%',
height: '100%',
maxWidth: '100%',
panelClass: 'global-search-overlay',
data: {
isGlobalSearch: true,
initialQuery,
},
});
}
openGlobalRecent(): void {
this.dialog.open(GlobalRecentlyViewedComponent, {
width: '100%',
height: '100%',
maxWidth: '100%',
panelClass: 'global-search-overlay',
data: { isGlobal: true },
hasBackdrop: true,
disableClose: false,
});
}
openAccountInfo(data: WorkspaceAccountInfoData): void {
this.dialog.open(AccountInfoComponent, {
width: '80%',
maxWidth: '1200px',
maxHeight: '90vh',
data,
});
}
}
export function provideWorkspaceShellActions(): Provider[] {
return [
AppWorkspaceShellActionsService,
{
provide: WORKSPACE_SHELL_ACTIONS,
useExisting: AppWorkspaceShellActionsService,
},
];
}
@@ -1,4 +1,3 @@
/* eslint-disable @typescript-eslint/await-thenable */
import { HttpClientTestingModule } from '@angular/common/http/testing';
import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing';
import {
@@ -20,29 +19,27 @@ import { RouterTestingModule } from '@angular/router/testing';
import { provideMockStore } from '@ngrx/store/testing';
import { TranslateModule, TranslateService } from '@ngx-translate/core';
import {
MockComponent,
MockModule,
MockProvider,
MockProviders,
} from 'ng-mocks';
import { DialogService } from 'components';
import { DataService, EpgService, PlaylistsService } from 'services';
import { Language, StreamFormat, Theme, VideoPlayer } from 'shared-interfaces';
import { DataService } from '../../../../../libs/services/src/lib/data.service';
import { HeaderComponent } from '../shared/components';
import { SettingsComponent } from './settings.component';
import { signal } from '@angular/core';
import { NgxIndexedDBService } from 'ngx-indexed-db';
import { of } from 'rxjs';
import { SETTINGS_UPDATE } from 'shared-interfaces';
import { EpgService } from '../../../../../libs/services/src/lib/epg.service';
import { PlaylistsService } from '../../../../../libs/services/src/lib/playlists.service';
import { DialogService } from '../../../../../libs/ui/components/src/lib/confirm-dialog/dialog.service';
import { ElectronServiceStub } from '../services/electron.service.stub';
import { SettingsStore } from '../services/settings-store.service';
import { SettingsService } from '../services/settings.service';
class MatSnackBarStub {
open() {}
open() {
return undefined;
}
}
export class MockRouter {
@@ -74,8 +71,11 @@ class MockSettingsStore {
updateSettings = jest.fn().mockResolvedValue(undefined);
// Helper method for tests to modify settings
_setSettings(newSettings: any) {
this._settings.set(newSettings);
_setSettings(newSettings: Partial<typeof DEFAULT_SETTINGS>) {
this._settings.set({
...this._settings(),
...newSettings,
});
}
}
@@ -89,7 +89,7 @@ describe('SettingsComponent', () => {
let fixture: ComponentFixture<SettingsComponent>;
let electronService: DataService;
let router: Router;
let settingsStore: any;
let settingsStore: MockSettingsStore;
let translate: TranslateService;
let epgService: EpgService;
@@ -112,7 +112,6 @@ describe('SettingsComponent', () => {
],
imports: [
SettingsComponent,
MockComponent(HeaderComponent),
HttpClientTestingModule,
FormsModule,
MockModule(MatSelectModule),
@@ -49,8 +49,8 @@ import {
Theme,
VideoPlayer,
} from 'shared-interfaces';
import { SettingsContextService } from '@iptvnator/workspace/shell/feature';
import { SettingsStore } from '../services/settings-store.service';
import { SettingsContextService } from '../workspace/settings-context.service';
import { SettingsService } from './../services/settings.service';
interface SettingsSection {
@@ -1,70 +0,0 @@
import { Component, inject } from '@angular/core';
import { MAT_DIALOG_DATA } from '@angular/material/dialog';
import { MatIconModule } from '@angular/material/icon';
import { TranslateModule } from '@ngx-translate/core';
@Component({
imports: [MatIconModule, TranslateModule],
template: `
<div mat-dialog-content>
<img
src="./assets/icons/icon-tv-256.png"
width="128"
alt="logo"
/><br />
<h2 mat-dialog-title>{{ 'ABOUT.TITLE' | translate }}</h2>
<p>{{ 'ABOUT.DESCRIPTION' | translate }}</p>
<p>{{ 'ABOUT.VERSION' | translate }}: {{ appVersion }}</p>
<p>
<a
href="https://github.com/4gray/iptvnator"
target="_blank"
[title]="'ABOUT.GITHUB_TOOLTIP' | translate"
[attr.aria-label]="'ABOUT.GITHUB_TOOLTIP' | translate"
><img
src="./assets/icons/github-light.png"
[title]="'ABOUT.GITHUB_TOOLTIP' | translate"
alt="GitHub" /></a
>&nbsp;
<a
href="http://twitter.com/share?text=IPTVnator &mdash; free cross-platform IPTV player. Available as PWA and as native application.&url=https://github.com/4gray/iptvnator&hashtags=iptv,m3u,video-player"
[title]="'ABOUT.TWITTER_TOOLTIP' | translate"
>
<img
height="32"
src="./assets/icons/twitter-light.png"
[title]="'ABOUT.TWITTER_TOOLTIP' | translate"
alt="Twitter"
/>
</a>
<a
href="https://github.com/sponsors/4gray"
target="_blank"
[title]="'ABOUT.BUY_ME_A_COFFEE_TOOLTIP' | translate"
[attr.aria-label]="
'ABOUT.BUY_ME_A_COFFEE_TOOLTIP' | translate
"
><mat-icon>local_cafe</mat-icon></a
>
</p>
</div>
`,
styles: [
`
button {
text-transform: uppercase;
}
a {
color: #fff;
}
.mat-icon {
font-size: 32px;
}
`,
],
})
export class AboutDialogComponent {
public appVersion = inject<string>(MAT_DIALOG_DATA);
}
@@ -1,20 +0,0 @@
<h2 mat-dialog-title>
{{ 'HOME.URL_UPLOAD.ADD_PLAYLIST' | translate }}: {{ playlistType }}
</h2>
<mat-dialog-content class="mat-typography">
@if (playlistType === 'file') {
<app-file-upload
(fileRejected)="rejectFile($event)"
(fileSelected)="handlePlaylist($event)"
(closeDialog)="closeDialog()"
/>
} @else if (playlistType === 'url') {
<app-url-upload (urlAdded)="sendPlaylistsUrl($event)" />
} @else if (playlistType === 'text') {
<app-text-import (textAdded)="uploadAsText($event)" />
} @else if (playlistType === 'stalker') {
<app-stalker-portal-import (addClicked)="closeDialog()" />
} @else if (playlistType === 'xtream') {
<app-xtream-code-import (addClicked)="closeDialog()" />
}
</mat-dialog-content>
@@ -1,108 +0,0 @@
import { Component, inject } 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 { PlaylistActions } from 'm3u-state';
import { getFilenameFromUrl } from 'm3u-utils';
import { DataService } from 'services';
import { PLAYLIST_PARSE_BY_URL } from 'shared-interfaces';
import { FileUploadComponent } from '../../../home/file-upload/file-upload.component';
import { StalkerPortalImportComponent } from '../../../home/stalker-portal-import/stalker-portal-import.component';
import { TextImportComponent } from '../../../home/text-import/text-import.component';
import { UrlUploadComponent } from '../../../home/url-upload/url-upload.component';
import { XtreamCodeImportComponent } from '../../../home/xtream-code-import/xtream-code-import.component';
export type PlaylistType = 'xtream' | 'url' | 'text' | 'file' | 'stalker';
@Component({
imports: [
FileUploadComponent,
MatButtonModule,
MatDialogModule,
StalkerPortalImportComponent,
TextImportComponent,
TranslateModule,
UrlUploadComponent,
XtreamCodeImportComponent,
],
selector: 'app-add-playlist',
templateUrl: './add-playlist-dialog.component.html',
})
export class AddPlaylistDialogComponent {
private dataService = inject(DataService);
private dialogRef = inject(MatDialogRef<AddPlaylistDialogComponent>);
private store = inject(Store);
private snackBar = inject(MatSnackBar);
private translateService = inject(TranslateService);
readonly data = inject<{ type: PlaylistType }>(MAT_DIALOG_DATA);
readonly playlistType!: PlaylistType;
constructor() {
this.playlistType = this.data.type;
}
/**
* Parse and store uploaded playlist
* @param payload
*/
handlePlaylist(payload: { uploadEvent: Event; file: File }): void {
const playlist = (payload.uploadEvent.target as FileReader)
.result as string;
this.store.dispatch(
PlaylistActions.parsePlaylist({
uploadType: 'FILE',
playlist,
title: payload.file.name,
path: (payload.file as File & { path?: string }).path,
})
);
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
* @param playlistUrl url of the added playlist
*/
sendPlaylistsUrl(playlistUrl: string): void {
this.dataService.sendIpcEvent(PLAYLIST_PARSE_BY_URL, {
title: getFilenameFromUrl(playlistUrl),
url: playlistUrl,
});
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() {
this.dialogRef.close();
}
}
@@ -1,66 +0,0 @@
<section
class="external-playback-dock"
[class.external-playback-dock--compact]="compact()"
[class.external-playback-dock--error]="session().status === 'error'"
>
<div class="external-playback-dock__artwork">
@if (session().thumbnail) {
<img
[src]="session().thumbnail!"
[alt]="session().title"
loading="lazy"
/>
} @else {
<div class="external-playback-dock__placeholder">
<mat-icon>play_circle</mat-icon>
</div>
}
</div>
<div class="external-playback-dock__meta">
<div class="external-playback-dock__topline">
<span class="external-playback-dock__chip">{{ playerLabel() }}</span>
@if (!compact()) {
<span class="external-playback-dock__caption">
External player
</span>
}
</div>
<div class="external-playback-dock__title">{{ session().title }}</div>
<div class="external-playback-dock__status">
@if (showSpinner()) {
<mat-spinner diameter="14" strokeWidth="3"></mat-spinner>
} @else {
<mat-icon>{{
session().status === 'error' ? 'error' : 'play_arrow'
}}</mat-icon>
}
<span>{{ statusLabel() }}</span>
</div>
</div>
<div class="external-playback-dock__actions">
@if (showCloseButton()) {
<button
type="button"
mat-stroked-button
class="external-playback-dock__button"
(click)="closeClicked.emit()"
>
<mat-icon>stop_circle</mat-icon>
@if (!compact()) {
<span>Close player</span>
}
</button>
}
<button
type="button"
mat-icon-button
class="external-playback-dock__dismiss"
(click)="dismissClicked.emit()"
aria-label="Dismiss now playing panel"
>
<mat-icon>close</mat-icon>
</button>
</div>
</section>
@@ -1,169 +0,0 @@
:host {
display: block;
}
.external-playback-dock {
display: grid;
grid-template-columns: auto minmax(0, 1fr) auto;
align-items: center;
gap: 14px;
min-height: 72px;
padding: 12px 14px;
border: 1px solid color-mix(in srgb, var(--mat-sys-outline-variant) 60%, transparent);
border-radius: 20px;
background:
linear-gradient(
135deg,
color-mix(in srgb, var(--mat-sys-surface-container-high) 88%, white 12%),
color-mix(in srgb, var(--mat-sys-surface-container) 92%, transparent)
);
box-shadow:
0 18px 42px rgba(15, 23, 42, 0.12),
inset 0 1px 0 rgba(255, 255, 255, 0.28);
backdrop-filter: blur(18px);
}
.external-playback-dock--error {
border-color: color-mix(in srgb, var(--mat-sys-error) 50%, transparent);
background:
linear-gradient(
135deg,
color-mix(in srgb, var(--mat-sys-error-container) 72%, transparent),
color-mix(in srgb, var(--mat-sys-surface-container) 90%, transparent)
);
}
.external-playback-dock--compact {
min-height: 48px;
padding: 8px 10px;
gap: 10px;
border-radius: 16px;
box-shadow:
0 12px 28px rgba(15, 23, 42, 0.16),
inset 0 1px 0 rgba(255, 255, 255, 0.18);
.external-playback-dock__artwork {
width: 38px;
height: 38px;
border-radius: 12px;
}
.external-playback-dock__title {
font-size: 0.86rem;
}
.external-playback-dock__status {
font-size: 0.72rem;
}
}
.external-playback-dock__artwork {
width: 48px;
height: 48px;
border-radius: 14px;
overflow: hidden;
background: color-mix(in srgb, var(--mat-sys-surface-container-highest) 92%, transparent);
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.18);
img,
.external-playback-dock__placeholder {
width: 100%;
height: 100%;
}
img {
object-fit: cover;
display: block;
}
}
.external-playback-dock__placeholder {
display: grid;
place-items: center;
color: var(--mat-sys-on-surface-variant);
}
.external-playback-dock__meta {
min-width: 0;
}
.external-playback-dock__topline {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 3px;
}
.external-playback-dock__chip {
display: inline-flex;
align-items: center;
height: 20px;
padding: 0 8px;
border-radius: 999px;
background: color-mix(in srgb, var(--mat-sys-primary-container) 82%, transparent);
color: var(--mat-sys-on-primary-container);
font-size: 0.68rem;
font-weight: 700;
letter-spacing: 0.08em;
}
.external-playback-dock__caption {
color: var(--mat-sys-on-surface-variant);
font-size: 0.72rem;
text-transform: uppercase;
letter-spacing: 0.08em;
}
.external-playback-dock__title {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 0.95rem;
font-weight: 700;
color: var(--mat-sys-on-surface);
}
.external-playback-dock__status {
display: flex;
align-items: center;
gap: 6px;
margin-top: 4px;
color: var(--mat-sys-on-surface-variant);
font-size: 0.78rem;
mat-icon {
width: 16px;
height: 16px;
font-size: 16px;
}
mat-spinner {
flex-shrink: 0;
}
}
.external-playback-dock__actions {
display: flex;
align-items: center;
gap: 6px;
}
.external-playback-dock__button {
border-radius: 999px;
}
.external-playback-dock__dismiss {
color: var(--mat-sys-on-surface-variant);
}
@media (max-width: 720px) {
.external-playback-dock {
grid-template-columns: auto minmax(0, 1fr) auto;
}
.external-playback-dock:not(.external-playback-dock--compact) {
.external-playback-dock__button span {
display: none;
}
}
}
@@ -1,52 +0,0 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { By } from '@angular/platform-browser';
import { ExternalPlayerSession } from 'shared-interfaces';
import { ExternalPlaybackDockComponent } from './external-playback-dock.component';
describe('ExternalPlaybackDockComponent', () => {
let fixture: ComponentFixture<ExternalPlaybackDockComponent>;
let component: ExternalPlaybackDockComponent;
const session: ExternalPlayerSession = {
id: 'session-1',
player: 'mpv',
status: 'launching',
title: 'Example Video',
streamUrl: 'https://example.com/video.mp4',
startedAt: '2026-03-07T10:00:00.000Z',
updatedAt: '2026-03-07T10:00:00.000Z',
canClose: true,
};
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [ExternalPlaybackDockComponent],
}).compileComponents();
fixture = TestBed.createComponent(ExternalPlaybackDockComponent);
component = fixture.componentInstance;
fixture.componentRef.setInput('session', session);
fixture.detectChanges();
});
it('renders launch state copy for the active session', () => {
const text = fixture.nativeElement.textContent;
expect(text).toContain('Example Video');
expect(text).toContain('Opening in MPV...');
expect(text).toContain('Close player');
});
it('emits close and dismiss actions', () => {
const closeSpy = jest.fn();
const dismissSpy = jest.fn();
component.closeClicked.subscribe(closeSpy);
component.dismissClicked.subscribe(dismissSpy);
const buttons = fixture.debugElement.queryAll(By.css('button'));
buttons[0].nativeElement.click();
buttons[1].nativeElement.click();
expect(closeSpy).toHaveBeenCalled();
expect(dismissSpy).toHaveBeenCalled();
});
});
@@ -1,52 +0,0 @@
import {
ChangeDetectionStrategy,
Component,
computed,
input,
output,
} from '@angular/core';
import { MatButtonModule } from '@angular/material/button';
import { MatIcon } from '@angular/material/icon';
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
import { ExternalPlayerSession } from 'shared-interfaces';
@Component({
selector: 'app-external-playback-dock',
imports: [MatButtonModule, MatIcon, MatProgressSpinnerModule],
templateUrl: './external-playback-dock.component.html',
styleUrl: './external-playback-dock.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class ExternalPlaybackDockComponent {
readonly session = input.required<ExternalPlayerSession>();
readonly compact = input(false);
readonly closeClicked = output<void>();
readonly dismissClicked = output<void>();
readonly playerLabel = computed(() => this.session().player.toUpperCase());
readonly statusLabel = computed(() => {
const session = this.session();
const player = this.playerLabel();
switch (session.status) {
case 'launching':
return `Opening in ${player}...`;
case 'opened':
return `Opened in ${player}`;
case 'playing':
return `Playing in ${player}`;
case 'error':
return session.error || `${player} failed to launch`;
default:
return `${player} closed`;
}
});
readonly showSpinner = computed(
() => this.session().status === 'launching'
);
readonly showCloseButton = computed(
() => this.session().canClose && this.session().status !== 'error'
);
}
@@ -1,77 +0,0 @@
import { Component } from '@angular/core';
import { MatButtonModule } from '@angular/material/button';
import { MatCheckboxModule } from '@angular/material/checkbox';
import { MatDialogModule } from '@angular/material/dialog';
import { MatIconModule } from '@angular/material/icon';
import { RouterLink } from '@angular/router';
import { TranslateModule } from '@ngx-translate/core';
@Component({
template: `
<h2 mat-dialog-title>{{ 'INFORMATION' | translate }}</h2>
<mat-dialog-content class="mat-typography">
<div class="centered">
<mat-icon class="icon">live_tv</mat-icon>
<div>
The video is playing in external player window.<br />
Please make sure that mpv/vlc player is correctly installed
on your system.<br />
See
<a
[routerLink]
style="cursor: pointer"
(click)="
openUrl(
'https://github.com/4gray/iptvnator/wiki/What-is-mpv-video-player-and-how-to-install-it-on-different-operating-systems%3F'
)
"
>installation instructions</a
>
for more details.
</div>
</div>
</mat-dialog-content>
<mat-dialog-actions style="justify-content: space-between;">
<div>
<mat-checkbox (change)="setVisibility($event.checked)"
>Don't show anymore</mat-checkbox
>
</div>
<button mat-button mat-dialog-close cdkFocusInitial color="accent">
{{ 'CLOSE' | translate }}
</button>
</mat-dialog-actions>
`,
styles: [
`
.centered {
text-align: center;
line-height: 24px;
margin: 20px;
.icon {
font-size: 64px;
height: 64px;
width: 64px;
}
}
`,
],
imports: [
MatButtonModule,
MatCheckboxModule,
MatDialogModule,
MatIconModule,
RouterLink,
TranslateModule,
],
})
export class ExternalPlayerInfoDialogComponent {
openUrl(url: string) {
window.open(url, '_blank');
}
setVisibility(value: boolean) {
localStorage.setItem('hideExternalInfoDialog', value.toString());
}
}
@@ -1,143 +0,0 @@
<mat-menu #filterSortMenu="matMenu" class="filter-sort-menu" xPosition="before">
<!-- Filter section -->
<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>
<!-- Sort 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>
<!-- Close button -->
<div class="menu-footer">
<button mat-button (click)="closeMenu()">
{{ 'CLOSE' | translate }}
</button>
</div>
</mat-menu>
@@ -1,90 +0,0 @@
// Combined Filter & Sort menu styles
.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);
}
}
// Menu item layout with right-aligned checkmark
::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
.menu-footer {
padding: 4px 8px 4px;
display: flex;
justify-content: center;
}
@@ -1,119 +0,0 @@
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 store = inject(Store);
private 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(
(f) => f.checked
).length;
// Prevent deselecting if it's the last selected option
if (type.checked && currentlySelectedCount === 1) {
return;
}
type.checked = !type.checked;
this.store.dispatch(
FilterActions.setSelectedFilters({
selectedFilters: this.playlistTypes
.filter((f) => f.checked)
.map((f) => f.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();
}
}
@@ -1,240 +0,0 @@
// ─── Card base ───────────────────────────────────────────────────────────────
mat-card {
overflow: hidden;
transition:
transform 0.18s ease,
box-shadow 0.18s ease;
box-shadow: none;
background: transparent;
}
mat-card:hover {
transform: translateY(-3px);
box-shadow:
0 8px 24px rgba(0, 0, 0, 0.35),
0 2px 8px rgba(0, 0, 0, 0.2);
}
// ─── Grid layout ─────────────────────────────────────────────────────────────
.grid {
mat-card {
cursor: pointer;
width: 100%;
mat-card-content {
padding: 0;
margin: 0;
}
.card-thumbnail-container {
position: relative;
width: 100%;
border-radius: 10px;
overflow: hidden;
aspect-ratio: 2 / 3; // maintain poster ratio
background: var(--mat-sys-surface-container-high);
}
.stream-icon {
width: 100%;
height: 100%;
object-fit: cover;
border-radius: 10px;
display: block;
// No hardcoded light bg — uses parent container bg
background: transparent;
}
.stream-icon-placeholder {
width: 100%;
height: 100%;
min-height: 200px;
// Use graphite surface token, not hardcoded grey
background: var(--mat-sys-surface-container-high);
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
gap: 8px;
color: var(--app-muted-color, var(--mat-sys-on-surface-variant));
border-radius: 10px;
&::after {
content: 'No cover';
font-size: 0.75rem;
font-weight: 500;
letter-spacing: 0.03em;
}
}
.title {
margin-top: 8px;
padding: 0 2px;
word-break: break-word;
text-align: center;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
font-size: 0.8rem;
line-height: 1.35;
color: var(--app-body-color, var(--mat-sys-on-surface-variant));
}
}
}
// ─── Rating badge ─────────────────────────────────────────────────────────────
.rating {
position: absolute;
top: 6px;
right: 6px;
// Use a solid or highly opaque dark pill to avoid expensive backdrop-filter
background: rgba(0, 0, 0, 0.85);
color: #4ade80; // green-400, readable across themes
font-size: 0.72rem;
font-weight: 600;
border-radius: 6px;
display: flex;
justify-content: center;
align-items: center;
padding: 2px 5px;
gap: 2px;
mat-icon {
font-size: 13px;
height: 14px;
width: 14px;
}
}
.grid-skeleton-card {
width: 100%;
}
.grid-empty-state {
grid-column: 1 / -1;
min-height: clamp(320px, 56vh, 620px);
display: flex;
align-items: center;
justify-content: center;
padding: clamp(12px, 2.8vw, 30px);
box-sizing: border-box;
app-playlist-error-view {
--error-empty-illustration-size: clamp(170px, 18vw, 250px);
--error-empty-max-width: min(480px, 100%);
--error-empty-title-size: clamp(1.45rem, 2vw, 1.9rem);
--error-empty-description-size: clamp(0.95rem, 1.15vw, 1.05rem);
--error-empty-title-weight: 660;
--error-empty-title-spacing: -0.014em;
--error-empty-copy-gap: 10px;
--error-empty-description-line-height: 1.44;
--error-empty-description-opacity: 0.9;
}
}
.grid-skeleton-thumb {
position: relative;
width: 100%;
aspect-ratio: 2 / 3;
border-radius: 10px;
overflow: hidden;
background: color-mix(
in srgb,
var(--app-widget-header-bg, var(--mat-sys-surface-container-high)) 84%,
var(--app-widget-bg, var(--mat-sys-surface-container)) 16%
);
border: 1px solid
color-mix(
in srgb,
var(--app-widget-header-border, var(--mat-sys-outline-variant)) 94%,
transparent
);
}
.grid-skeleton-badge {
position: absolute;
top: 8px;
right: 8px;
width: 34px;
height: 14px;
border-radius: 999px;
background: color-mix(
in srgb,
var(--app-widget-header-bg, var(--mat-sys-surface-container-high)) 56%,
var(--app-body-color, var(--mat-sys-on-surface-variant)) 44%
);
}
.grid-skeleton-title {
margin-top: 10px;
padding: 0 3px;
display: grid;
gap: 6px;
}
.grid-skeleton-line {
height: 9px;
border-radius: 999px;
background: color-mix(
in srgb,
var(--app-widget-header-bg, var(--mat-sys-surface-container-high)) 52%,
var(--app-body-color, var(--mat-sys-on-surface-variant)) 48%
);
&--primary {
width: 92%;
}
&--secondary {
width: 68%;
}
}
.grid-skeleton-thumb,
.grid-skeleton-badge,
.grid-skeleton-line {
position: relative;
overflow: hidden;
&::after {
content: '';
position: absolute;
inset: 0;
background: linear-gradient(
100deg,
transparent 10%,
color-mix(
in srgb,
var(--app-heading-color, var(--mat-sys-on-surface)) 20%,
transparent
)
46%,
transparent 76%
);
transform: translateX(-120%);
animation: grid-skeleton-shimmer 1.2s ease-in-out infinite;
}
}
@keyframes grid-skeleton-shimmer {
100% {
transform: translateX(120%);
}
}
@media (max-width: 768px) {
.grid-empty-state {
min-height: clamp(260px, 48vh, 460px);
padding: 14px;
app-playlist-error-view {
--error-empty-illustration-size: 165px;
--error-empty-max-width: 310px;
--error-empty-title-size: 1.3rem;
--error-empty-description-size: 0.9rem;
--error-empty-title-weight: 640;
--error-empty-copy-gap: 8px;
}
}
}
@@ -1,161 +0,0 @@
import { Component, computed, input, output } from '@angular/core';
import { MatCardModule } from '@angular/material/card';
import { MatIcon } from '@angular/material/icon';
import { MatPaginatorModule, PageEvent } from '@angular/material/paginator';
import { MatTooltip } from '@angular/material/tooltip';
import { TranslatePipe } from '@ngx-translate/core';
import {
PlaylistErrorViewComponent,
ProgressCapsuleComponent,
WatchedBadgeComponent,
} from '@iptvnator/portal/shared/ui';
interface GridListItem {
id?: number | string;
is_series?: number | string | boolean;
xtream_id?: number | string;
series_id?: number | string;
stream_id?: number | string;
category_id?: number | string;
poster_url?: string;
cover?: string;
title?: string;
o_name?: string;
name?: string;
rating?: string | number;
rating_imdb?: string | number;
progress?: number;
isWatched?: boolean;
hasSeriesProgress?: boolean;
[key: string]: unknown;
}
@Component({
selector: 'app-grid-list',
template: `<div
class="grid grid-cols-[repeat(auto-fill,minmax(148px,1fr))] gap-4"
>
@if (isLoading()) {
@for (row of skeletonRows(); track row) {
<div class="grid-skeleton-card" aria-hidden="true">
<div class="grid-skeleton-thumb">
<span class="grid-skeleton-badge"></span>
</div>
<div class="grid-skeleton-title">
<span
class="grid-skeleton-line grid-skeleton-line--primary"
></span>
<span
class="grid-skeleton-line grid-skeleton-line--secondary"
></span>
</div>
</div>
}
} @else {
@for (item of items(); track $index) {
@let i = $any(item);
<mat-card (click)="itemClicked.emit(item)">
@let poster = i.poster_url ?? i.cover;
<div class="card-thumbnail-container">
<img
class="stream-icon"
[src]="
poster ||
'./assets/images/default-poster.png'
"
(error)="
$event.target.src =
'./assets/images/default-poster.png'
"
loading="lazy"
alt="logo"
/>
@if (i.progress && i.progress > 0) {
<app-progress-capsule [progress]="i.progress" />
}
@if (i.isWatched) {
<app-watched-badge
[isWatched]="true"
icon="check_circle"
/>
} @else if (i.hasSeriesProgress) {
<app-watched-badge
[isWatched]="true"
icon="remove_red_eye"
/>
}
</div>
@let rating = i.rating ?? i.rating_imdb;
@if (rating) {
<div
class="rating"
[matTooltip]="'XTREAM.IMDB_RATING' | translate"
>
<mat-icon>star</mat-icon>{{ rating }}
</div>
}
@let title = i.title ?? i.o_name ?? i.name;
<mat-card-actions>
<div class="title">
{{ title || 'No name' }}
</div>
</mat-card-actions>
</mat-card>
} @empty {
<div class="grid-empty-state">
<app-playlist-error-view
[title]="
'PORTALS.ERROR_VIEW.EMPTY_CATEGORY.TITLE'
| translate
"
[description]="
'PORTALS.ERROR_VIEW.EMPTY_CATEGORY.DESCRIPTION'
| translate
"
[showActionButtons]="false"
[viewType]="'EMPTY_CATEGORY'"
/>
</div>
}
}
</div>
@if (showPaginator() && items()?.length > 0) {
<mat-paginator
[pageIndex]="pageIndex()"
[length]="totalPages() * limit()"
[pageSize]="limit()"
[pageSizeOptions]="pageSizeOptions()"
(page)="pageChange.emit($event)"
aria-label="Select page"
/>
} `,
styleUrl: './grid-list.component.scss',
imports: [
TranslatePipe,
PlaylistErrorViewComponent,
MatCardModule,
MatIcon,
MatTooltip,
MatPaginatorModule,
ProgressCapsuleComponent,
WatchedBadgeComponent,
],
})
export class GridListComponent {
readonly items = input<GridListItem[]>();
readonly isLoading = input<boolean>();
readonly showPaginator = input(true);
readonly itemClicked = output<GridListItem>();
readonly pageChange = output<PageEvent>();
readonly pageIndex = input<number>();
readonly totalPages = input<number>();
readonly limit = input<number>();
readonly pageSizeOptions = input<number[]>();
readonly skeletonRows = computed(() => {
const preferredCount = this.limit() ?? 12;
const count = Math.max(8, Math.min(18, preferredCount));
return Array.from({ length: count }, (_, index) => index);
});
}
@@ -1,265 +0,0 @@
<div class="modern-header">
<div class="brand-block">
<div class="logo-pulse">
<img src="./assets/icons/icon-tv-256.png" alt="iptvnator logo" />
</div>
<div class="brand-text">
<div class="title">{{ title() }}</div>
</div>
</div>
@if (isHome) {
<div class="search-container">
<mat-icon class="search-icon">search</mat-icon>
<input
#searchInput
type="search"
class="search-input"
spellcheck="false"
autocomplete="off"
[placeholder]="'HOME.PLAYLISTS.SEARCH_PLAYLISTS' | translate"
(input)="onSearchQueryUpdate(searchInput.value)"
/>
</div>
<!-- Desktop menu buttons -->
<div class="menu desktop-menu">
<button
mat-icon-button
class="add-playlist-btn"
[matMenuTriggerFor]="addPlaylistMenuComponent().menu()"
[matTooltip]="'HOME.URL_UPLOAD.ADD_PLAYLIST' | translate"
data-test-id="add-playlist"
>
<mat-icon>add</mat-icon>
</button>
<app-add-playlist-menu
(playlistTypeSelected)="openAddPlaylistDialog($event)"
/>
<button
mat-icon-button
(click)="navigateToGlobalFavorites()"
[matTooltip]="'HOME.PLAYLISTS.GLOBAL_FAVORITES' | translate"
data-test-id="global-favorites"
>
<mat-icon>star</mat-icon>
</button>
<button
mat-icon-button
(click)="onGlobalSearchClick()"
[matTooltip]="
('PORTALS.SIDEBAR.SEARCH' | translate) + ' (Ctrl/Cmd+F)'
"
data-test-id="global-search"
>
<mat-icon>travel_explore</mat-icon>
</button>
<button
mat-icon-button
(click)="onGlobalRecentClick()"
[matTooltip]="
('PORTALS.SIDEBAR.RECENT' | translate) + ' (Ctrl/Cmd+R)'
"
data-test-id="global-recent"
>
<mat-icon>history</mat-icon>
</button>
<button
mat-icon-button
#filterMenuTrigger="matMenuTrigger"
[matMenuTriggerFor]="filterSortMenuComponent().menu()"
[matTooltip]="'HOME.FILTER_AND_SORT' | translate"
[matBadge]="filterSortMenuComponent().activeFiltersCount"
[matBadgeHidden]="
filterSortMenuComponent().activeFiltersCount === 0
"
matBadgeSize="small"
matBadgeColor="accent"
data-test-id="filter-sort-playlists"
>
<mat-icon>tune</mat-icon>
</button>
<app-filter-sort-menu [menuTrigger]="filterMenuTrigger" />
@if (isDesktop) {
<button
mat-icon-button
(click)="openSettings()"
[matTooltip]="'MENU.SETTINGS' | translate"
data-test-id="open-settings"
>
<mat-icon>settings</mat-icon>
</button>
} @else {
<button
mat-icon-button
[matMenuTriggerFor]="pwaMenu"
[attr.aria-label]="'MENU.OPEN' | translate"
data-test-id="pwa-menu"
>
<mat-icon>menu</mat-icon>
</button>
<mat-menu #pwaMenu="matMenu">
@if (isHome) {
<button
mat-menu-item
[attr.aria-label]="'MENU.SETTINGS_ARIA' | translate"
(click)="openSettings()"
data-test-id="pwa-open-settings"
>
<mat-icon>settings</mat-icon>
<span>{{ 'MENU.SETTINGS' | translate }}</span>
</button>
<mat-divider></mat-divider>
}
<button
mat-menu-item
[attr.aria-label]="'MENU.SUPPORT_ARIA' | translate"
(click)="openUrl('https://github.com/4gray/iptvnator')"
>
<mat-icon>recommend</mat-icon>
<span>GitHub</span>
</button>
<button
mat-menu-item
[attr.aria-label]="'MENU.BUG_REPORT' | translate"
(click)="
openUrl('https://github.com/4gray/iptvnator/issues')
"
>
<mat-icon>bug_report</mat-icon>
<span>{{ 'MENU.BUG_REPORT' | translate }}</span>
</button>
<mat-divider />
<button
mat-menu-item
[attr.aria-label]="'MENU.SUPPORT_PROJECT' | translate"
(click)="openUrl('https://github.com/sponsors/4gray')"
>
<mat-icon>savings</mat-icon>
<span>{{ 'MENU.SUPPORT_PROJECT' | translate }}</span>
</button>
<mat-divider></mat-divider>
<button
mat-menu-item
[attr.aria-label]="'MENU.ABOUT_ARIA' | translate"
(click)="openAboutDialog()"
>
<mat-icon>info</mat-icon>
<span>{{ 'MENU.ABOUT' | translate }}</span>
</button>
</mat-menu>
}
</div>
<!-- Mobile three-dots menu (all buttons consolidated) -->
<div class="menu mobile-menu">
<button
mat-icon-button
[matMenuTriggerFor]="mobileActionsMenu"
[attr.aria-label]="'MENU.OPEN' | translate"
data-test-id="mobile-menu"
>
<mat-icon>more_vert</mat-icon>
</button>
<app-add-playlist-menu
(playlistTypeSelected)="openAddPlaylistDialog($event)"
/>
<app-filter-sort-menu [menuTrigger]="mobileFilterTrigger" />
<mat-menu #mobileActionsMenu="matMenu">
<button
mat-menu-item
[matMenuTriggerFor]="addPlaylistMenuComponent().menu()"
data-test-id="mobile-add-playlist"
>
<mat-icon>add</mat-icon>
<span>{{ 'HOME.URL_UPLOAD.ADD_PLAYLIST' | translate }}</span>
</button>
<button
mat-menu-item
(click)="navigateToGlobalFavorites()"
data-test-id="mobile-global-favorites"
>
<mat-icon>star</mat-icon>
<span>{{ 'HOME.PLAYLISTS.GLOBAL_FAVORITES' | translate }}</span>
</button>
<button
mat-menu-item
(click)="onGlobalSearchClick()"
data-test-id="mobile-global-search"
>
<mat-icon>travel_explore</mat-icon>
<span
>{{ 'PORTALS.SIDEBAR.SEARCH' | translate }}
(Ctrl/Cmd+F)</span
>
</button>
<button
mat-menu-item
(click)="onGlobalRecentClick()"
data-test-id="mobile-global-recent"
>
<mat-icon>history</mat-icon>
<span
>{{ 'PORTALS.SIDEBAR.RECENT' | translate }}
(Ctrl/Cmd+R)</span
>
</button>
<button
mat-menu-item
#mobileFilterTrigger="matMenuTrigger"
[matMenuTriggerFor]="filterSortMenuComponent().menu()"
data-test-id="mobile-filter-sort"
>
<mat-icon
[matBadge]="filterSortMenuComponent().activeFiltersCount"
[matBadgeHidden]="filterSortMenuComponent().activeFiltersCount === 0"
matBadgeSize="small"
matBadgeColor="accent"
>tune</mat-icon>
<span>{{ 'HOME.FILTER_AND_SORT' | translate }}</span>
</button>
<mat-divider />
<button
mat-menu-item
(click)="openSettings()"
data-test-id="mobile-settings"
>
<mat-icon>settings</mat-icon>
<span>{{ 'MENU.SETTINGS' | translate }}</span>
</button>
@if (!isDesktop) {
<mat-divider />
<button
mat-menu-item
(click)="openUrl('https://github.com/4gray/iptvnator')"
>
<mat-icon>recommend</mat-icon>
<span>GitHub</span>
</button>
<button
mat-menu-item
(click)="openUrl('https://github.com/4gray/iptvnator/issues')"
>
<mat-icon>bug_report</mat-icon>
<span>{{ 'MENU.BUG_REPORT' | translate }}</span>
</button>
<button
mat-menu-item
(click)="openUrl('https://github.com/sponsors/4gray')"
>
<mat-icon>savings</mat-icon>
<span>{{ 'MENU.SUPPORT_PROJECT' | translate }}</span>
</button>
<mat-divider />
<button
mat-menu-item
(click)="openAboutDialog()"
>
<mat-icon>info</mat-icon>
<span>{{ 'MENU.ABOUT' | translate }}</span>
</button>
}
</mat-menu>
</div>
}
</div>
@@ -1,556 +0,0 @@
:host {
display: block;
width: 100%;
}
.modern-header {
background: #131316;
color: #fff;
display: flex;
align-items: center;
width: 100%;
height: 70px;
padding: 0 16px;
app-region: drag;
position: relative;
overflow: hidden;
box-sizing: border-box;
border-bottom: 1px solid rgba(255, 255, 255, 0.03);
// Subtle depth layer
&::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: linear-gradient(
180deg,
rgba(255, 255, 255, 0.01) 0%,
rgba(255, 255, 255, 0) 100%
);
pointer-events: none;
}
}
// Refined home page styling - seamless with #131316
:host.home-header .modern-header {
background: #131316;
// Elegant ambient glow
&::before {
content: '';
position: absolute;
top: -100%;
left: 0;
width: 100%;
height: 200%;
background: radial-gradient(
ellipse at 50% 0%,
rgba(99, 102, 241, 0.04) 0%,
rgba(139, 92, 246, 0.02) 30%,
transparent 60%
);
animation: headerAmbience 20s ease-in-out infinite;
pointer-events: none;
}
// Refined bottom accent
&::after {
content: '';
position: absolute;
bottom: 0;
left: 0;
right: 0;
height: 1px;
background: linear-gradient(
90deg,
transparent 0%,
rgba(139, 92, 246, 0.15) 50%,
transparent 100%
);
opacity: 0.5;
}
}
@keyframes headerAmbience {
0%,
100% {
opacity: 0.3;
transform: translateY(0);
}
50% {
opacity: 0.6;
transform: translateY(-5%);
}
}
// Refined brand block with elegant glass effect
.brand-block {
display: flex;
align-items: center;
gap: 12px;
padding: 0 16px 0 8px;
background: rgba(255, 255, 255, 0.02);
border-radius: 32px;
border: 1px solid rgba(255, 255, 255, 0.04);
backdrop-filter: blur(12px);
transition: all 0.4s cubic-bezier(0.4, 0, 0.2, 1);
app-region: no-drag;
position: relative;
z-index: 1;
box-shadow:
0 2px 8px rgba(0, 0, 0, 0.1),
inset 0 1px 0 rgba(255, 255, 255, 0.03);
&:hover {
background: rgba(255, 255, 255, 0.04);
border-color: rgba(255, 255, 255, 0.08);
transform: translateY(-1px);
box-shadow:
0 4px 12px rgba(0, 0, 0, 0.15),
inset 0 1px 0 rgba(255, 255, 255, 0.05);
}
}
// Enhanced brand block on home page - refined elegance
:host.home-header .brand-block {
background: rgba(139, 92, 246, 0.04);
border-color: rgba(139, 92, 246, 0.08);
box-shadow:
0 2px 12px rgba(139, 92, 246, 0.06),
inset 0 1px 0 rgba(139, 92, 246, 0.08);
&:hover {
background: rgba(139, 92, 246, 0.08);
border-color: rgba(139, 92, 246, 0.15);
box-shadow:
0 4px 16px rgba(139, 92, 246, 0.12),
inset 0 1px 0 rgba(139, 92, 246, 0.12);
}
}
.logo-pulse {
position: relative;
width: 48px;
height: 48px;
display: flex;
align-items: center;
justify-content: center;
img {
width: 38px;
height: 38px;
object-fit: contain;
position: relative;
z-index: 2;
filter: drop-shadow(0 2px 6px rgba(255, 59, 48, 0.25));
transition: filter 0.3s ease;
}
// Subtle ambient glow
&::before {
content: '';
position: absolute;
width: 100%;
height: 100%;
background: radial-gradient(
circle,
rgba(255, 59, 48, 0.15) 0%,
rgba(255, 59, 48, 0.05) 50%,
transparent 70%
);
border-radius: 50%;
animation: logoGlow 3s cubic-bezier(0.4, 0, 0.6, 1) infinite;
}
.brand-block:hover & img {
filter: drop-shadow(0 2px 8px rgba(255, 59, 48, 0.4));
}
}
@keyframes logoGlow {
0%,
100% {
transform: scale(1);
opacity: 0.6;
}
50% {
transform: scale(1.15);
opacity: 1;
}
}
.brand-text {
display: flex;
flex-direction: column;
justify-content: center;
.title {
font-size: 1.05rem;
font-weight: 600;
letter-spacing: -0.015em;
color: rgba(255, 255, 255, 0.95);
transition: all 0.3s ease;
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.2);
}
.brand-block:hover & .title {
color: rgba(255, 255, 255, 1);
text-shadow: 0 1px 3px rgba(0, 0, 0, 0.3);
}
}
// Enhanced brand text on home page
:host.home-header .brand-text .title {
background: linear-gradient(
135deg,
rgba(255, 255, 255, 0.95) 0%,
rgba(139, 92, 246, 0.8) 100%
);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
.brand-block:hover & {
background: linear-gradient(
135deg,
rgba(255, 255, 255, 1) 0%,
rgba(139, 92, 246, 1) 100%
);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
}
}
// Refined search field with elegant glass-morphism
.search-container {
flex: 1;
max-width: 800px;
margin: 0 24px;
position: relative;
display: flex;
align-items: center;
background: rgba(255, 255, 255, 0.02);
border: 1px solid rgba(255, 255, 255, 0.06);
border-radius: 20px;
padding: 0 18px;
height: 46px;
transition: all 0.4s cubic-bezier(0.4, 0, 0.2, 1);
app-region: no-drag;
z-index: 1;
box-shadow:
0 1px 3px rgba(0, 0, 0, 0.08),
inset 0 1px 0 rgba(255, 255, 255, 0.02);
&:focus-within {
background: rgba(255, 255, 255, 0.04);
border-color: rgba(139, 92, 246, 0.3);
box-shadow:
0 0 0 2px rgba(139, 92, 246, 0.08),
0 2px 8px rgba(139, 92, 246, 0.12),
inset 0 1px 0 rgba(255, 255, 255, 0.04);
transform: translateY(-1px);
}
&:hover:not(:focus-within) {
background: rgba(255, 255, 255, 0.03);
border-color: rgba(255, 255, 255, 0.1);
}
}
// Enhanced search field on home page - refined elegance
:host.home-header .search-container {
background: rgba(139, 92, 246, 0.03);
border-color: rgba(139, 92, 246, 0.08);
box-shadow:
0 1px 4px rgba(139, 92, 246, 0.04),
inset 0 1px 0 rgba(139, 92, 246, 0.04);
&:focus-within {
background: rgba(139, 92, 246, 0.06);
border-color: rgba(139, 92, 246, 0.3);
box-shadow:
0 0 0 2px rgba(139, 92, 246, 0.1),
0 4px 16px rgba(139, 92, 246, 0.15),
inset 0 1px 0 rgba(139, 92, 246, 0.08);
}
&:hover:not(:focus-within) {
background: rgba(139, 92, 246, 0.05);
border-color: rgba(139, 92, 246, 0.12);
}
}
:host.home-header .search-icon {
color: rgba(139, 92, 246, 0.5);
.search-container:focus-within & {
color: rgba(139, 92, 246, 0.85);
}
}
.search-icon {
color: rgba(255, 255, 255, 0.35);
margin-right: 12px;
font-size: 20px;
width: 20px;
height: 20px;
transition: color 0.4s cubic-bezier(0.4, 0, 0.2, 1);
.search-container:focus-within & {
color: rgba(139, 92, 246, 0.85);
}
}
.search-input {
flex: 1;
background: transparent;
border: none;
outline: none;
color: rgba(255, 255, 255, 0.95);
font-size: 14.5px;
font-weight: 400;
letter-spacing: -0.005em;
&::placeholder {
color: rgba(255, 255, 255, 0.25);
font-weight: 400;
letter-spacing: 0;
}
&::-webkit-search-cancel-button {
-webkit-appearance: none;
appearance: none;
height: 16px;
width: 16px;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='rgba(255,255,255,0.35)'%3E%3Cpath d='M19 6.41L17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12z'/%3E%3C/svg%3E");
cursor: pointer;
opacity: 0.5;
transition: all 0.3s ease;
&:hover {
opacity: 1;
transform: scale(1.1);
}
}
}
.menu {
display: flex;
align-items: center;
gap: 4px;
app-region: no-drag; // Allow button interactions
}
.add-playlist-btn {
background-color: rgba(139, 92, 246, 0.12) !important;
color: rgba(139, 92, 246, 1) !important;
border: 1px solid rgba(139, 92, 246, 0.2);
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
&:hover {
background-color: rgba(139, 92, 246, 0.2) !important;
border-color: rgba(139, 92, 246, 0.3);
box-shadow: 0 2px 8px rgba(139, 92, 246, 0.15);
}
.mat-icon {
color: rgba(139, 92, 246, 1) !important;
}
}
// Badge styling for filter count
::ng-deep .mat-badge-accent .mat-badge-content {
background: rgba(139, 92, 246, 1);
color: white;
}
// Refined Material button and icon overrides
.modern-header {
// Elegant icon styling
::ng-deep .mat-icon {
color: rgba(255, 255, 255, 0.85) !important;
transition: color 0.3s ease;
}
// Refined icon buttons with glass effect
::ng-deep .mat-mdc-icon-button {
color: rgba(255, 255, 255, 0.85) !important;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
&:hover {
background-color: rgba(255, 255, 255, 0.06) !important;
.mat-icon {
color: rgba(255, 255, 255, 1) !important;
}
}
&:active {
background-color: rgba(255, 255, 255, 0.1) !important;
transform: scale(0.96);
}
}
// Menu items
::ng-deep .mat-mdc-menu-item {
color: rgba(255, 255, 255, 0.9) !important;
.mat-icon {
color: rgba(255, 255, 255, 0.8) !important;
}
span {
color: rgba(255, 255, 255, 0.9) !important;
}
&:hover {
.mat-icon {
color: rgba(255, 255, 255, 1) !important;
}
}
}
// Checkbox styling
::ng-deep .mat-mdc-checkbox {
color: rgba(255, 255, 255, 0.9) !important;
.mdc-checkbox__background {
border-color: rgba(255, 255, 255, 0.6) !important;
}
}
}
// Home page - refined purple accent for icons
:host.home-header .modern-header {
::ng-deep .mat-icon {
color: rgba(255, 255, 255, 0.85) !important;
}
::ng-deep .mat-mdc-icon-button {
&:hover {
background-color: rgba(139, 92, 246, 0.1) !important;
.mat-icon {
color: rgba(139, 92, 246, 1) !important;
}
}
&:active {
background-color: rgba(139, 92, 246, 0.15) !important;
}
}
}
// Mobile menu visibility
.mobile-menu {
display: none;
}
.desktop-menu {
display: flex;
}
// Responsive design
@media (max-width: 899px) {
.search-container {
max-width: 300px;
margin: 0 16px;
}
}
@media (max-width: 768px) {
// Hide desktop menu, show mobile menu
.desktop-menu {
display: none;
}
.mobile-menu {
display: flex;
}
// Hide brand text, keep only icon
.brand-text {
display: none;
}
.brand-block {
padding: 6px;
border-radius: 50%;
gap: 0;
}
// Smaller search bar
.search-container {
flex: 1;
max-width: none;
margin: 0 12px;
height: 40px;
padding: 0 14px;
border-radius: 16px;
.search-input {
font-size: 13px;
&::placeholder {
font-size: 13px;
}
}
.search-icon {
font-size: 18px;
width: 18px;
height: 18px;
margin-right: 10px;
}
}
}
@media (max-width: 599px) {
.modern-header {
height: 56px;
padding: 0 12px;
}
.brand-block {
padding: 4px;
}
.logo-pulse {
width: 36px;
height: 36px;
img {
width: 28px;
height: 28px;
}
}
.search-container {
margin: 0 8px;
height: 36px;
padding: 0 12px;
border-radius: 14px;
.search-input {
font-size: 12px;
&::placeholder {
font-size: 12px;
}
}
.search-icon {
font-size: 16px;
width: 16px;
height: 16px;
margin-right: 8px;
}
}
.menu {
gap: 0;
}
}
@@ -1,129 +0,0 @@
import {
Component,
OnInit,
inject,
input,
output,
viewChild,
} from '@angular/core';
import { FormsModule, ReactiveFormsModule } from '@angular/forms';
import { MatBadgeModule } from '@angular/material/badge';
import { MatButtonModule } from '@angular/material/button';
import { MatDialog } from '@angular/material/dialog';
import { MatDividerModule } from '@angular/material/divider';
import { MatIconModule } from '@angular/material/icon';
import { MatMenuModule } from '@angular/material/menu';
import { MatTooltipModule } from '@angular/material/tooltip';
import { ActivatedRoute, Router } from '@angular/router';
import { TranslateModule } from '@ngx-translate/core';
import { DataService } from 'services';
//import { shell } from 'electron';
import { AddPlaylistMenuComponent, PlaylistType } from 'components';
import { AboutDialogComponent } from '../about-dialog/about-dialog.component';
import { AddPlaylistDialogComponent } from '../add-playlist/add-playlist-dialog.component';
import { FilterSortMenuComponent } from '../filter-sort-menu/filter-sort-menu.component';
@Component({
selector: 'app-header',
templateUrl: './header.component.html',
styleUrls: ['./header.component.scss'],
imports: [
AddPlaylistMenuComponent,
FilterSortMenuComponent,
FormsModule,
MatBadgeModule,
MatButtonModule,
MatDividerModule,
MatIconModule,
MatMenuModule,
MatTooltipModule,
ReactiveFormsModule,
TranslateModule,
],
})
export class HeaderComponent implements OnInit {
private activatedRoute = inject(ActivatedRoute);
private dialog = inject(MatDialog);
private dataService = inject(DataService);
private router = inject(Router);
readonly addPlaylistMenuComponent = viewChild.required(
AddPlaylistMenuComponent
);
readonly filterSortMenuComponent = viewChild.required(
FilterSortMenuComponent
);
readonly isDesktop = !!window.electron;
readonly title = input.required<string>();
readonly subtitle = input.required<string>();
readonly searchQuery = output<string>();
readonly globalSearchClicked = output<void>();
readonly globalRecentClicked = output<void>();
readonly isHome = false;
ngOnInit() {
// Initialization if needed
}
/**
* Navigates to the settings page
*/
openSettings(): void {
this.router.navigate(['/settings']);
}
/**
* Navigates to the global favorites view
*/
navigateToGlobalFavorites(): void {
this.router.navigate(['/workspace', 'global-favorites']);
}
/**
* Opens the provided URL string in new browser window
* @param url url to open
*/
async openUrl(url: string): Promise<void> {
if (this.isDesktop) {
console.log('TODO: implement me');
// await shell.openExternal(url);
} else {
window.open(url, '_blank');
}
}
/**
* Opens the about dialog with description and version of
* the app
*/
openAboutDialog(): void {
this.dialog.open(AboutDialogComponent, {
panelClass: 'about-dialog-overlay',
width: '600px',
data: this.dataService.getAppVersion(),
});
}
openAddPlaylistDialog(type: PlaylistType) {
this.dialog.open<AddPlaylistDialogComponent, { type: PlaylistType }>(
AddPlaylistDialogComponent,
{
width: '600px',
data: { type },
}
);
}
onSearchQueryUpdate(query: string): void {
this.searchQuery.emit(query);
}
onGlobalSearchClick(): void {
this.globalSearchClicked.emit();
}
onGlobalRecentClick(): void {
this.globalRecentClicked.emit();
}
}
@@ -1,12 +0,0 @@
export {
CategoryViewComponent,
ContentCardComponent,
FavoritesLayoutComponent,
PlaylistErrorViewComponent,
PortalCollectionLiveShellComponent,
PortalCollectionShellComponent,
PortalEmptyStateComponent,
SearchFormComponent,
SearchLayoutComponent,
} from '@iptvnator/portal/shared/ui';
export * from './header/header.component';
@@ -1,39 +0,0 @@
<!-- @if ((activeProcesses$ | async)?.length) {
<div class="mpv-player-bar">
@for (process of activeProcesses$ | async; track process.id) {
<div class="player-item">
<div class="player-media">
@if (process.thumbnail) {
<img
[src]="process.thumbnail"
[alt]="process.title"
class="media-thumbnail"
(error)="
$event.target.src =
'./assets/images/default-poster.png'
"
/>
} @else {
<div class="media-thumbnail-placeholder">
<mat-icon>live_tv</mat-icon>
</div>
}
<div class="media-info">
<span class="media-title">{{ process.title }}</span>
<span class="media-url">{{ process.url }}</span>
</div>
</div>
<div class="player-controls">
<button
mat-icon-button
(click)="closeStream(process.id)"
title="Close"
>
<mat-icon>close</mat-icon>
</button>
</div>
</div>
}
</div>
}
-->
@@ -1,103 +0,0 @@
.mpv-player-bar {
background: #282828;
color: white;
padding: 16px;
box-shadow: 0 -2px 20px rgba(0, 0, 0, 0.3);
flex-shrink: 0; // Prevent shrinking
}
.player-items {
display: flex;
flex-direction: column;
gap: 8px;
}
.player-item {
display: flex;
align-items: center;
justify-content: space-between;
gap: 20px;
height: 56px;
}
.player-info {
display: flex;
flex-direction: column;
}
.player-title {
font-size: 14px;
font-weight: 500;
}
.player-url {
font-size: 12px;
color: #a0a0a0;
margin-top: 4px;
}
.player-media {
display: flex;
align-items: center;
gap: 16px;
flex: 1;
}
.media-thumbnail {
width: 56px;
height: 56px;
object-fit: cover;
border-radius: 4px;
}
.media-thumbnail-placeholder {
width: 56px;
height: 56px;
background: #404040;
border-radius: 4px;
display: flex;
align-items: center;
justify-content: center;
mat-icon {
font-size: 24px;
color: #b3b3b3;
}
}
.media-info {
display: flex;
flex-direction: column;
gap: 4px;
min-width: 0;
}
.media-title {
font-size: 14px;
font-weight: 500;
color: #fff;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.media-url {
font-size: 12px;
color: #b3b3b3;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.player-controls {
display: flex;
align-items: center;
gap: 8px;
button {
color: #fff;
&:hover {
color: #1db954;
}
}
}
@@ -1,24 +0,0 @@
import { Component } from '@angular/core';
@Component({
/* imports: [MatIcon, MatIconButton], */
selector: 'app-mpv-player-bar',
templateUrl: './mpv-player-bar.component.html',
styleUrls: ['./mpv-player-bar.component.scss'],
})
export class MpvPlayerBarComponent {
/* private mpvPlayerService = inject(MpvPlayerService);
activeProcesses$ = this.mpvPlayerService.activeProcesses$;
async closeStream(processId: number): Promise<void> {
await this.mpvPlayerService.closeStream(processId);
} */
/* async playStream(processId: number): Promise<void> {
await this.mpvPlayerService.playStream(processId);
}
async pauseStream(processId: number): Promise<void> {
await this.mpvPlayerService.pauseStream(processId);
} */
}
@@ -1,40 +0,0 @@
@if (hasPlayback()) {
<section class="player-shell" aria-label="Inline player">
<header class="player-shell__header">
<div class="player-shell__eyebrow">Now Playing</div>
<div class="player-shell__title-row">
<h3 class="player-shell__title" [matTooltip]="title()">
{{ title() }}
</h3>
<div class="player-shell__actions">
<button
mat-button
type="button"
[cdkCopyToClipboard]="streamUrl()"
[matTooltip]="streamUrl()"
(click)="onCopied()"
>
<mat-icon>content_copy</mat-icon>
Copy Stream URL
</button>
<button
mat-stroked-button
type="button"
(click)="onClose()"
>
<mat-icon>close</mat-icon>
Close Player
</button>
</div>
</div>
</header>
<div class="player-shell__viewport">
<app-web-player-view
[streamUrl]="streamUrl()"
[startTime]="startTime()"
(timeUpdate)="onTimeUpdate($event)"
/>
</div>
</section>
}
@@ -1,106 +0,0 @@
:host {
display: block;
width: 100%;
}
.player-shell {
display: grid;
gap: 14px;
padding: 18px;
margin-top: 8px;
border-radius: 18px;
border: 1px solid rgba(20, 20, 20, 0.08);
background:
linear-gradient(180deg, rgba(255, 255, 255, 0.78), rgba(255, 250, 245, 0.92)),
rgba(255, 250, 245, 0.9);
box-shadow:
0 20px 56px rgba(25, 20, 20, 0.12),
inset 0 1px 0 rgba(255, 255, 255, 0.8);
backdrop-filter: blur(18px);
}
.player-shell__header {
display: grid;
gap: 8px;
}
.player-shell__eyebrow {
font-size: 0.72rem;
font-weight: 700;
letter-spacing: 0.18em;
text-transform: uppercase;
color: rgba(26, 26, 26, 0.58);
}
.player-shell__title-row {
display: flex;
gap: 12px;
align-items: flex-start;
justify-content: space-between;
}
.player-shell__title {
margin: 0;
font-size: clamp(1.05rem, 1vw + 0.9rem, 1.4rem);
font-weight: 700;
line-height: 1.2;
color: #161616;
}
.player-shell__actions {
display: flex;
flex-wrap: wrap;
gap: 10px;
justify-content: flex-end;
}
.player-shell__viewport {
overflow: hidden;
min-height: 320px;
max-height: min(70vh, 720px);
aspect-ratio: 16 / 9;
border-radius: 14px;
background: #000;
}
.player-shell__viewport > app-web-player-view {
display: block;
width: 100%;
height: 100%;
}
:host-context(.dark-theme) .player-shell {
border-color: rgba(255, 255, 255, 0.12);
background:
linear-gradient(180deg, rgba(255, 255, 255, 0.08), transparent),
rgba(12, 12, 12, 0.72);
box-shadow:
0 24px 80px rgba(0, 0, 0, 0.28),
inset 0 1px 0 rgba(255, 255, 255, 0.08);
}
:host-context(.dark-theme) .player-shell__eyebrow {
color: rgba(255, 255, 255, 0.6);
}
:host-context(.dark-theme) .player-shell__title {
color: #fff;
}
@media (max-width: 900px) {
.player-shell {
padding: 14px;
}
.player-shell__title-row {
flex-direction: column;
}
.player-shell__actions {
justify-content: flex-start;
}
.player-shell__viewport {
min-height: 220px;
}
}
@@ -1,63 +0,0 @@
import { ClipboardModule } from '@angular/cdk/clipboard';
import {
ChangeDetectionStrategy,
Component,
computed,
input,
output,
} from '@angular/core';
import { MatButtonModule } from '@angular/material/button';
import { MatIconModule } from '@angular/material/icon';
import { MatTooltipModule } from '@angular/material/tooltip';
import {
PlayerContentInfo,
ResolvedPortalPlayback,
} from 'shared-interfaces';
import { WebPlayerViewComponent } from 'shared-portals';
@Component({
selector: 'app-portal-inline-player',
templateUrl: './portal-inline-player.component.html',
styleUrl: './portal-inline-player.component.scss',
imports: [
ClipboardModule,
MatButtonModule,
MatIconModule,
MatTooltipModule,
WebPlayerViewComponent,
],
changeDetection: ChangeDetectionStrategy.OnPush,
host: {
class: 'portal-inline-player',
'[attr.data-has-player]': 'hasPlayback()',
},
})
export class PortalInlinePlayerComponent {
readonly playback = input<ResolvedPortalPlayback | null>(null);
readonly title = computed(() => this.playback()?.title ?? '');
readonly streamUrl = computed(() => this.playback()?.streamUrl ?? '');
readonly startTime = computed(() => this.playback()?.startTime ?? 0);
readonly contentInfo = computed<PlayerContentInfo | undefined>(
() => this.playback()?.contentInfo
);
readonly hasPlayback = computed(() => !!this.playback()?.streamUrl);
readonly closed = output<void>();
readonly timeUpdate = output<{
currentTime: number;
duration: number;
}>();
readonly streamUrlCopied = output<void>();
onClose(): void {
this.closed.emit();
}
onTimeUpdate(event: { currentTime: number; duration: number }): void {
this.timeUpdate.emit(event);
}
onCopied(): void {
this.streamUrlCopied.emit();
}
}
@@ -1,117 +0,0 @@
@if (isVisible()) {
<div class="epg-progress-panel">
<div class="panel-header">
<span class="header-title">
<mat-icon>live_tv</mat-icon>
{{ 'EPG.IMPORT_PROGRESS' | translate }}
</span>
@if (imports().length > 1) {
<button
mat-icon-button
class="dismiss-all-btn"
(click)="dismissAll()"
[attr.aria-label]="'BUTTONS.CLOSE' | translate"
>
<mat-icon>close</mat-icon>
</button>
}
</div>
<div class="imports-list">
<!-- Active/Completed imports -->
@for (item of activeImports; track item.url) {
<div class="import-item" [class]="getStatusClass(item.status)">
<div class="item-header">
<div class="status-icon-wrapper">
<mat-icon
[class.spinning]="item.status === 'loading'"
>
{{ getStatusIcon(item.status) }}
</mat-icon>
</div>
<span class="item-url" [title]="item.url">
{{ getDisplayUrl(item.url) }}
</span>
<button
mat-icon-button
class="dismiss-btn"
(click)="dismiss(item.url)"
[attr.aria-label]="'BUTTONS.CLOSE' | translate"
>
<mat-icon>close</mat-icon>
</button>
</div>
@if (item.status === 'loading') {
<mat-progress-bar mode="indeterminate" />
}
<div class="item-stats">
@if (item.stats) {
<span
class="stat-badge"
matTooltip="{{
'EPG.TOTAL_CHANNELS' | translate
}}"
>
<mat-icon>tv</mat-icon>
{{ item.stats.totalChannels }}
</span>
<span
class="stat-badge"
matTooltip="{{
'EPG.TOTAL_PROGRAMS' | translate
}}"
>
<mat-icon>event</mat-icon>
{{ item.stats.totalPrograms }}
</span>
} @else if (item.error) {
<span class="error-text">{{ item.error }}</span>
} @else if (item.status === 'loading') {
<span>{{ 'EPG.LOADING' | translate }}...</span>
}
</div>
</div>
}
<!-- Queued imports section -->
@if (queuedImports.length > 0) {
<div class="queue-section">
<div class="queue-header">
<mat-icon>queue</mat-icon>
<span
>{{ 'EPG.QUEUED' | translate }} ({{
queuedImports.length
}})</span
>
</div>
<div class="queue-list">
@for (
item of queuedImports;
track item.url;
let i = $index
) {
<div class="queue-item">
<span class="queue-position">{{ i + 1 }}</span>
<span class="queue-url" [title]="item.url">
{{ getDisplayUrl(item.url) }}
</span>
<button
mat-icon-button
class="dismiss-btn"
(click)="dismiss(item.url)"
[attr.aria-label]="
'BUTTONS.CLOSE' | translate
"
>
<mat-icon>close</mat-icon>
</button>
</div>
}
</div>
</div>
}
</div>
</div>
}
@@ -1,500 +0,0 @@
// EPG Progress Panel - Broadcast Control Room Aesthetic
// Sleek, modern design with glassmorphism and subtle glow effects
// CSS Custom Properties for theming
:host {
--epg-panel-bg: rgba(18, 22, 28, 0.92);
--epg-panel-bg-solid: #12161c;
--epg-panel-border: rgba(56, 189, 248, 0.15);
--epg-panel-border-glow: rgba(56, 189, 248, 0.25);
--epg-header-bg: rgba(56, 189, 248, 0.08);
--epg-header-border: rgba(56, 189, 248, 0.12);
--epg-text-primary: rgba(255, 255, 255, 0.95);
--epg-text-secondary: rgba(255, 255, 255, 0.6);
--epg-text-tertiary: rgba(255, 255, 255, 0.4);
--epg-accent: #38bdf8;
--epg-accent-glow: rgba(56, 189, 248, 0.4);
--epg-success: #22c55e;
--epg-success-glow: rgba(34, 197, 94, 0.3);
--epg-error: #ef4444;
--epg-error-glow: rgba(239, 68, 68, 0.3);
--epg-queued: #a78bfa;
--epg-queued-glow: rgba(167, 139, 250, 0.3);
--epg-item-border: rgba(255, 255, 255, 0.06);
--epg-item-hover: rgba(56, 189, 248, 0.05);
--epg-progress-track: rgba(255, 255, 255, 0.08);
--epg-scrollbar-thumb: rgba(56, 189, 248, 0.3);
--epg-scrollbar-track: rgba(255, 255, 255, 0.05);
--epg-shadow: 0 8px 32px rgba(0, 0, 0, 0.4), 0 2px 8px rgba(0, 0, 0, 0.3);
--epg-icon-bg: rgba(56, 189, 248, 0.12);
--epg-queue-bg: rgba(167, 139, 250, 0.06);
--epg-queue-border: rgba(167, 139, 250, 0.15);
}
// Light theme overrides
:host-context(.light-theme),
:host-context([data-theme='light']),
:host-context(.mat-app-background.light) {
--epg-panel-bg: rgba(255, 255, 255, 0.95);
--epg-panel-bg-solid: #ffffff;
--epg-panel-border: rgba(15, 118, 110, 0.2);
--epg-panel-border-glow: rgba(15, 118, 110, 0.15);
--epg-header-bg: rgba(15, 118, 110, 0.06);
--epg-header-border: rgba(15, 118, 110, 0.1);
--epg-text-primary: rgba(15, 23, 42, 0.95);
--epg-text-secondary: rgba(15, 23, 42, 0.6);
--epg-text-tertiary: rgba(15, 23, 42, 0.4);
--epg-accent: #0f766e;
--epg-accent-glow: rgba(15, 118, 110, 0.25);
--epg-success: #16a34a;
--epg-success-glow: rgba(22, 163, 74, 0.2);
--epg-error: #dc2626;
--epg-error-glow: rgba(220, 38, 38, 0.2);
--epg-queued: #7c3aed;
--epg-queued-glow: rgba(124, 58, 237, 0.2);
--epg-item-border: rgba(15, 23, 42, 0.08);
--epg-item-hover: rgba(15, 118, 110, 0.04);
--epg-progress-track: rgba(15, 23, 42, 0.08);
--epg-scrollbar-thumb: rgba(15, 118, 110, 0.3);
--epg-scrollbar-track: rgba(15, 23, 42, 0.05);
--epg-shadow:
0 8px 32px rgba(15, 23, 42, 0.12), 0 2px 8px rgba(15, 23, 42, 0.08);
--epg-icon-bg: rgba(15, 118, 110, 0.1);
--epg-queue-bg: rgba(124, 58, 237, 0.05);
--epg-queue-border: rgba(124, 58, 237, 0.12);
}
.epg-progress-panel {
position: fixed;
bottom: 20px;
left: 20px;
width: 340px;
max-width: calc(100vw - 40px);
background: var(--epg-panel-bg);
backdrop-filter: blur(16px);
-webkit-backdrop-filter: blur(16px);
border-radius: 16px;
box-shadow: var(--epg-shadow);
z-index: 1000;
overflow: hidden;
border: 1px solid var(--epg-panel-border);
// Subtle glow effect on top edge
&::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
height: 1px;
background: linear-gradient(
90deg,
transparent 0%,
var(--epg-panel-border-glow) 20%,
var(--epg-accent-glow) 50%,
var(--epg-panel-border-glow) 80%,
transparent 100%
);
}
// Entry animation
animation: slideInFromLeft 0.35s cubic-bezier(0.16, 1, 0.3, 1);
}
@keyframes slideInFromLeft {
from {
opacity: 0;
transform: translateX(-24px) scale(0.96);
}
to {
opacity: 1;
transform: translateX(0) scale(1);
}
}
.panel-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 14px 16px;
background: var(--epg-header-bg);
border-bottom: 1px solid var(--epg-header-border);
position: relative;
.header-title {
display: flex;
align-items: center;
gap: 10px;
font-weight: 600;
font-size: 13px;
letter-spacing: 0.02em;
color: var(--epg-text-primary);
text-transform: uppercase;
mat-icon {
font-size: 20px;
width: 20px;
height: 20px;
color: var(--epg-accent);
filter: drop-shadow(0 0 6px var(--epg-accent-glow));
animation: pulse-glow 2s ease-in-out infinite;
}
}
.dismiss-all-btn {
width: 28px;
height: 28px;
padding: 2px;
line-height: 28px;
border-radius: 8px;
transition: all 0.2s ease;
color: var(--epg-text-tertiary);
mat-icon {
font-size: 18px;
width: 18px;
height: 18px;
}
}
}
@keyframes pulse-glow {
0%,
100% {
filter: drop-shadow(0 0 4px var(--epg-accent-glow));
}
50% {
filter: drop-shadow(0 0 10px var(--epg-accent-glow));
}
}
.imports-list {
max-height: 360px;
overflow-y: auto;
padding: 4px 0;
// Custom scrollbar
&::-webkit-scrollbar {
width: 6px;
}
&::-webkit-scrollbar-track {
background: var(--epg-scrollbar-track);
border-radius: 3px;
margin: 4px 0;
}
&::-webkit-scrollbar-thumb {
background: var(--epg-scrollbar-thumb);
border-radius: 3px;
&:hover {
background: var(--epg-accent);
}
}
}
.import-item {
padding: 6px 12px;
border-radius: 10px;
transition: all 0.2s ease;
position: relative;
margin: 0 6px;
&:not(:last-child) {
margin-bottom: 2px;
}
&:hover {
background: var(--epg-item-hover);
}
.item-header {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 10px;
.status-icon-wrapper {
display: flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
border-radius: 8px;
background: var(--epg-icon-bg);
flex-shrink: 0;
}
mat-icon {
font-size: 18px;
width: 18px;
height: 18px;
transition: all 0.3s ease;
}
.item-url {
flex: 1;
font-size: 12px;
font-weight: 500;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
color: var(--epg-text-secondary);
font-family: 'SF Mono', 'Fira Code', 'Monaco', monospace;
letter-spacing: -0.02em;
}
.dismiss-btn {
width: 26px;
height: 26px;
padding: 2px;
line-height: 26px;
border-radius: 6px;
opacity: 0;
transition: all 0.2s ease;
color: var(--epg-text-tertiary);
mat-icon {
font-size: 16px;
width: 16px;
height: 16px;
}
}
}
&:hover .dismiss-btn {
opacity: 1;
}
mat-progress-bar {
margin-bottom: 10px;
border-radius: 2px;
height: 4px;
::ng-deep {
.mdc-linear-progress__buffer {
background-color: var(--epg-progress-track);
}
.mdc-linear-progress__bar-inner {
border-color: var(--epg-accent);
}
.mdc-linear-progress__buffer-bar {
background-color: var(--epg-progress-track);
}
}
}
.item-stats {
font-size: 11px;
color: var(--epg-text-tertiary);
display: flex;
align-items: center;
gap: 8px;
.stat-badge {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 4px 10px;
background: var(--epg-icon-bg);
border-radius: 6px;
font-weight: 600;
font-variant-numeric: tabular-nums;
font-size: 12px;
color: var(--epg-text-secondary);
mat-icon {
font-size: 14px;
width: 14px;
height: 14px;
opacity: 0.7;
}
}
}
// Status variations
&.status-loading {
.status-icon-wrapper {
background: var(--epg-icon-bg);
}
.item-header mat-icon {
color: var(--epg-accent);
}
.item-stats {
color: var(--epg-text-tertiary);
}
}
&.status-complete {
.status-icon-wrapper {
background: var(--epg-success-glow);
}
.item-header mat-icon {
color: var(--epg-success);
filter: drop-shadow(0 0 6px var(--epg-success-glow));
}
.item-stats {
color: var(--epg-success);
.stat-badge {
background: var(--epg-success-glow);
color: var(--epg-success);
}
}
}
&.status-error {
.status-icon-wrapper {
background: var(--epg-error-glow);
}
.item-header mat-icon {
color: var(--epg-error);
filter: drop-shadow(0 0 6px var(--epg-error-glow));
}
.error-text {
color: var(--epg-error);
font-size: 11px;
font-weight: 500;
}
}
}
// Queue section styles
.queue-section {
//margin: 8px;
padding: 12px;
border-radius: 10px;
animation: fadeIn 0.3s ease;
.queue-header {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 10px;
padding-bottom: 8px;
border-bottom: 1px solid var(--epg-queue-border);
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--epg-queued);
mat-icon {
font-size: 16px;
width: 16px;
height: 16px;
}
}
.queue-list {
display: flex;
flex-direction: column;
gap: 6px;
}
.queue-item {
display: flex;
align-items: center;
gap: 10px;
padding: 8px 10px;
background: rgba(167, 139, 250, 0.04);
border-radius: 6px;
transition: all 0.2s ease;
&:hover {
background: rgba(167, 139, 250, 0.08);
.dismiss-btn {
opacity: 1;
}
}
.queue-position {
display: flex;
align-items: center;
justify-content: center;
width: 22px;
height: 22px;
border-radius: 6px;
background: var(--epg-queued-glow);
color: var(--epg-queued);
font-size: 11px;
font-weight: 700;
flex-shrink: 0;
}
.queue-url {
flex: 1;
font-size: 11px;
font-weight: 500;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
color: var(--epg-text-secondary);
font-family: 'SF Mono', 'Fira Code', 'Monaco', monospace;
letter-spacing: -0.02em;
}
.dismiss-btn {
width: 22px;
height: 22px;
padding: 0;
line-height: 22px;
border-radius: 4px;
opacity: 0;
transition: all 0.2s ease;
color: var(--epg-text-tertiary);
flex-shrink: 0;
mat-icon {
font-size: 14px;
width: 14px;
height: 14px;
}
}
}
}
@keyframes fadeIn {
from {
opacity: 0;
transform: translateY(4px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.spinning {
animation: spin 1.2s cubic-bezier(0.5, 0, 0.5, 1) infinite;
}
@keyframes spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
// Responsive adjustments
@media (max-width: 400px) {
.epg-progress-panel {
left: 12px;
right: 12px;
bottom: 12px;
width: auto;
max-width: none;
}
}
@@ -1,78 +0,0 @@
import { Component, inject } from '@angular/core';
import { MatButtonModule } from '@angular/material/button';
import { MatIconModule } from '@angular/material/icon';
import { MatProgressBar } from '@angular/material/progress-bar';
import { MatTooltip } from '@angular/material/tooltip';
import { TranslatePipe } from '@ngx-translate/core';
import {
EpgImportProgress,
EpgProgressService,
} from '../../services/epg-progress.service';
@Component({
selector: 'app-epg-progress-panel',
standalone: true,
imports: [
MatButtonModule,
MatIconModule,
MatTooltip,
MatProgressBar,
TranslatePipe,
],
templateUrl: './epg-progress-panel.component.html',
styleUrl: './epg-progress-panel.component.scss',
})
export class EpgProgressPanelComponent {
private epgProgress = inject(EpgProgressService);
readonly imports = this.epgProgress.imports;
readonly isVisible = this.epgProgress.isVisible;
readonly queuedCount = this.epgProgress.queuedCount;
readonly activeCount = this.epgProgress.activeCount;
/** Active and completed imports (not queued) */
get activeImports() {
return this.imports().filter((i) => i.status !== 'queued');
}
/** Queued imports waiting to be processed */
get queuedImports() {
return this.imports()
.filter((i) => i.status === 'queued')
.sort((a, b) => (a.queuePosition ?? 0) - (b.queuePosition ?? 0));
}
getStatusIcon(status: EpgImportProgress['status']): string {
switch (status) {
case 'queued':
return 'schedule';
case 'loading':
return 'sync';
case 'complete':
return 'check_circle';
case 'error':
return 'error';
}
}
getStatusClass(status: EpgImportProgress['status']): string {
return `status-${status}`;
}
getDisplayUrl(url: string): string {
try {
const urlObj = new URL(url);
return urlObj.hostname + urlObj.pathname.split('/').pop();
} catch {
return url.length > 40 ? url.substring(0, 40) + '...' : url;
}
}
dismiss(url: string): void {
this.epgProgress.dismiss(url);
}
dismissAll(): void {
this.epgProgress.dismissAll();
}
}
@@ -1,78 +0,0 @@
/* import { Injectable } from '@angular/core';
import { invoke } from '@tauri-apps/api/core';
import { listen } from '@tauri-apps/api/event';
import { BehaviorSubject } from 'rxjs';
export interface MpvProcess {
id: number;
url: string;
start_time: number;
last_known_time: number | null;
thumbnail?: string;
title: string;
}
@Injectable({
providedIn: 'root',
})
export class MpvPlayerService {
private activeProcessesSubject = new BehaviorSubject<MpvProcess[]>([]);
public activeProcesses$ = this.activeProcessesSubject.asObservable();
constructor() {
if (!window.electron) {
return;
}
this.initializeEventListeners();
this.loadActiveProcesses();
}
private async initializeEventListeners() {
// Listen for new processes
await listen('mpv-process-added', (event: any) => {
const newProcess = event.payload as MpvProcess;
if (newProcess) {
const currentProcesses = this.activeProcessesSubject.value;
this.activeProcessesSubject.next([
...currentProcesses,
newProcess,
]);
}
});
// Listen for removed processes
await listen('mpv-process-removed', (event: any) => {
const removedProcess = event.payload as MpvProcess;
if (removedProcess) {
const currentProcesses = this.activeProcessesSubject.value;
this.activeProcessesSubject.next(
currentProcesses.filter((p) => p.id !== removedProcess.id)
);
}
});
}
private async loadActiveProcesses() {
try {
const processes = await invoke<MpvProcess[]>(
'get_active_mpv_processes'
);
this.activeProcessesSubject.next(processes);
} catch (error) {
console.error('Failed to load active MPV processes:', error);
}
}
async playStream(processId: number): Promise<void> {
await invoke('mpv_play', { processId });
}
async pauseStream(processId: number): Promise<void> {
await invoke('mpv_pause', { processId });
}
async closeStream(processId: number): Promise<void> {
await invoke('close_mpv_process', { processId });
}
}
*/
@@ -1,57 +0,0 @@
export type RemoteChannelDirection = 'up' | 'down';
/**
* Returns the adjacent item in the list for remote channel navigation.
* Uses wraparound when reaching the start or end of the list.
*/
export function getAdjacentChannelItem<T>(
items: T[],
activeId: string | number | null | undefined,
direction: RemoteChannelDirection,
getId: (item: T) => string | number | null | undefined
): T | null {
if (!Array.isArray(items) || items.length === 0 || activeId == null) {
return null;
}
const activeIdAsString = String(activeId);
const currentIndex = items.findIndex((item) => {
const itemId = getId(item);
return itemId != null && String(itemId) === activeIdAsString;
});
if (currentIndex === -1) {
return null;
}
const length = items.length;
const nextIndex =
direction === 'up'
? (currentIndex - 1 + length) % length
: (currentIndex + 1) % length;
return items[nextIndex] ?? null;
}
/**
* Returns the Nth item in a list (1-based index), or null if out of range.
*/
export function getChannelItemByNumber<T>(
items: T[],
channelNumber: number
): T | null {
if (
!Array.isArray(items) ||
items.length === 0 ||
!Number.isFinite(channelNumber)
) {
return null;
}
const index = Math.floor(channelNumber) - 1;
if (index < 0 || index >= items.length) {
return null;
}
return items[index] ?? null;
}
@@ -1 +0,0 @@
@forward '../../../../../../libs/portal/shared/ui/src/lib/styles/content-grid';
@@ -1 +0,0 @@
@forward '../../../../../../libs/portal/shared/ui/src/lib/styles/portal-layout';
@@ -1 +0,0 @@
@forward '../../../../../../libs/portal/shared/ui/src/lib/styles/portal-sidebar';
@@ -1,12 +0,0 @@
@let isFavorite = isFavorite$ | async;
@if (isFavorite === false) {
<button class="favorite-btn" (click)="addToFavorites()">
<mat-icon>add</mat-icon>
{{ 'PORTALS.ADD_TO_FAVORITES' | translate }}
</button>
} @else {
<button class="favorite-btn favorite-btn--active" (click)="removeFromFavorites()">
<mat-icon>check</mat-icon>
{{ 'PORTALS.REMOVE_FROM_FAVORITES' | translate }}
</button>
}
@@ -1,32 +0,0 @@
.favorite-btn {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 12px 20px;
font-size: 0.9375rem;
font-weight: 500;
background: rgba(109, 109, 110, 0.7);
color: #fff;
border: none;
border-radius: 4px;
cursor: pointer;
transition: all 0.2s ease;
&:hover {
background: rgba(109, 109, 110, 0.5);
}
&--active {
background: rgba(229, 9, 20, 0.8);
&:hover {
background: rgba(229, 9, 20, 0.6);
}
}
mat-icon {
font-size: 20px;
width: 20px;
height: 20px;
}
}
@@ -1,67 +0,0 @@
import { AsyncPipe } from '@angular/common';
import { Component, inject, input } from '@angular/core';
import { MatIconModule } from '@angular/material/icon';
import { TranslateModule } from '@ngx-translate/core';
import { BehaviorSubject, map, switchMap } from 'rxjs';
import { PlaylistsService } from 'services';
import { StalkerStore } from '@iptvnator/portal/stalker/data-access';
import { matchesFavoriteById } from '@iptvnator/portal/stalker/data-access';
@Component({
selector: 'app-favorites-button',
templateUrl: './favorites-button.component.html',
styleUrls: ['./favorites-button.component.scss'],
imports: [AsyncPipe, MatIconModule, TranslateModule],
})
export class FavoritesButtonComponent {
private playlistService = inject(PlaylistsService);
private stalkerStore = inject(StalkerStore);
readonly itemId = input.required<string>();
readonly item = input.required<any>();
private readonly favoritesChanged$ = new BehaviorSubject<void>(undefined);
readonly isFavorite$ = this.favoritesChanged$.pipe(
switchMap(() =>
this.playlistService.getPortalFavorites(
this.stalkerStore.currentPlaylist()?._id
)
),
map((favorites) =>
favorites.some((favorite) =>
matchesFavoriteById(favorite, this.itemId())
)
)
);
removeFromFavorites() {
this.stalkerStore.removeFromFavorites(this.itemId(), () => {
this.favoritesChanged$.next();
});
}
addToFavorites() {
const item = this.item();
this.stalkerStore.addToFavorites(
{
...item,
title: item.info?.name ?? item.name ?? item.o_name,
cover: item.info?.movie_image ?? item.cover,
series_id: item.id,
added_at: new Date().toISOString(),
category_id: 'series',
},
() => {
this.favoritesChanged$.next();
}
);
}
forceRefresh() {
// Exposed for callers that need manual refresh after external updates.
if (this.stalkerStore.currentPlaylist()?._id) {
this.favoritesChanged$.next();
}
}
}
@@ -1,49 +0,0 @@
@if (isLiveCategory()) {
<app-portal-collection-live-shell
[title]="'PORTALS.SIDEBAR.RECENT' | translate"
[subtitle]="'PORTALS.SIDEBAR.LIVE_TV' | translate"
[count]="liveItemsToShow().length"
[streamUrl]="liveStreamUrl()"
[isEmbeddedPlayer]="isEmbeddedPlayer()"
[hasSelection]="!!selectedLiveItem()"
[epgItems]="epgItems()"
[isLoadingEpg]="isLoadingEpg()"
[hasMoreEpg]="hasMoreEpg()"
(loadMoreEpgClicked)="loadMoreEpg()"
>
<app-stalker-collection-channels-list
liveChannels
[items]="liveItemsToShow()"
[selectedItemId]="selectedLiveItem()?.id ?? null"
[favoriteIds]="liveFavoriteIds()"
(playClicked)="selectLiveItem($event)"
(favoriteToggled)="toggleLiveFavorite($event)"
/>
</app-portal-collection-live-shell>
} @else {
<app-portal-collection-shell
[categories]="categories() ?? []"
[items]="itemsToShow() ?? []"
[playlistSubtitle]="playlistSubtitle"
[playlistTitle]="playlistTitle()"
[selectedCategoryId]="selectedCategoryId()"
[layout]="layout"
[mode]="mode"
(categoryClicked)="setCategoryId($event)"
(removeItem)="removeFromRecentlyViewed($event)"
(headerActionClicked)="clearAllRecentlyViewed()"
(openItem)="openItem($event)"
>
<app-stalker-inline-detail
details
[categoryId]="inlineDetail().categoryId"
[seriesItem]="inlineDetail().seriesItem"
[isSeries]="inlineDetail().isSeries"
[vodDetailsItem]="inlineDetail().vodDetailsItem"
[isFavorite]="isSelectedVodFavorite()"
(playClicked)="onVodPlay($event)"
(favoriteToggled)="onVodFavoriteToggled($event)"
(backClicked)="onVodBack()"
/>
</app-portal-collection-shell>
}
@@ -1,585 +0,0 @@
import {
Component,
computed,
effect,
inject,
signal,
} from '@angular/core';
import { MatSnackBar } from '@angular/material/snack-bar';
import { ActivatedRoute, Router } from '@angular/router';
import { TranslatePipe, TranslateService } from '@ngx-translate/core';
import { PlaylistsService } from 'services';
import { EpgItem, VodDetailsItem } from 'shared-interfaces';
import { PlayerService } from '../../services/player.service';
import { PortalCollectionLiveShellComponent } from '@iptvnator/portal/shared/ui';
import {
PortalCollectionMode,
PortalCollectionShellComponent,
PortalCollectionShellLayout,
} from '@iptvnator/portal/shared/ui';
import { StalkerInlineDetailComponent } from '../stalker-inline-detail/stalker-inline-detail.component';
import {
isWorkspaceLayoutRoute,
queryParamSignal,
} from '@iptvnator/portal/shared/util';
import { createPortalCollectionContext } from '@iptvnator/portal/shared/util';
import {
buildStandardCollectionCategories,
filterCollectionBucket,
} from '@iptvnator/portal/shared/util';
import { createLogger } from '@iptvnator/portal/shared/util';
import { FavoritesContextService } from '@iptvnator/portal/shared/util';
import { StalkerSelectedVodItem, StalkerVodSource } from '@iptvnator/portal/stalker/data-access';
import {
clearStalkerDetailViewState,
createStalkerInlineDetailState,
createPortalCollectionResource,
createPortalFavoritesResource,
createRefreshTrigger,
createStalkerDetailViewState,
isSelectedStalkerVodFavorite,
normalizeStalkerVodDetailsItem,
toggleStalkerVodFavorite,
} from '@iptvnator/portal/stalker/data-access';
import { StalkerCollectionChannelsListComponent } from '../stalker-collection-channels-list/stalker-collection-channels-list.component';
import { StalkerStore } from '@iptvnator/portal/stalker/data-access';
const STALKER_RECENT_LAYOUT: Omit<
PortalCollectionShellLayout,
'showHeaderAction'
> = {
titleTranslationKey: 'PORTALS.SIDEBAR.RECENT',
removeTooltip: 'Remove from recent',
emptyIcon: 'history',
headerActionIcon: 'delete_sweep',
headerActionTooltip: 'Clear recently viewed',
};
@Component({
selector: 'app-recently-viewed',
templateUrl: './recently-viewed.component.html',
imports: [
PortalCollectionLiveShellComponent,
PortalCollectionShellComponent,
StalkerCollectionChannelsListComponent,
StalkerInlineDetailComponent,
TranslatePipe,
],
styles: [
`
:host {
display: block;
height: 100%;
width: 100%;
position: relative;
}
.back-button {
margin: 10px 0 0 16px;
}
`,
],
})
export class RecentlyViewedComponent {
private static isCategoryType(
value: string
): value is 'vod' | 'series' | 'itv' {
return value === 'vod' || value === 'series' || value === 'itv';
}
private readonly route = inject(ActivatedRoute);
private readonly playlistService = inject(PlaylistsService);
private readonly collectionRefresh = createRefreshTrigger();
private readonly favoritesRefresh = createRefreshTrigger();
private readonly stalkerStore = inject(StalkerStore);
private readonly playerService = inject(PlayerService);
private readonly snackBar = inject(MatSnackBar);
private readonly router = inject(Router);
private readonly translate = inject(TranslateService);
private readonly logger = createLogger('StalkerRecentlyViewed');
private readonly favoritesCtx = inject(FavoritesContextService);
private previousSelectedCategoryId: string | null = null;
itemDetails: StalkerSelectedVodItem | null = null;
vodDetailsItem: VodDetailsItem | null = null;
readonly isSelectedVodFavorite = signal<boolean>(false);
readonly selectedLiveItem = signal<
(StalkerVodSource & { category_id: string }) | null
>(null);
readonly liveStreamUrl = signal('');
readonly epgItems = signal<EpgItem[]>([]);
readonly isLoadingEpg = signal(false);
readonly hasMoreEpg = signal(false);
readonly isResolvingPlayback = signal(false);
readonly currentPlaylist = this.stalkerStore.currentPlaylist;
readonly playlistSubtitle = 'Stalker Portal';
readonly playlistTitle = computed(
() => this.currentPlaylist()?.title || 'Portal'
);
readonly allFavorites = createPortalCollectionResource(
this.playlistService,
() => this.stalkerStore.currentPlaylist()?._id,
() => this.collectionRefresh.refreshVersion(),
(playlistService, portalId) =>
playlistService.getPortalRecentlyViewed(portalId)
);
readonly portalFavorites = createPortalFavoritesResource(
this.playlistService,
() => this.stalkerStore.currentPlaylist()?._id,
() => this.favoritesRefresh.refreshVersion()
);
readonly categories = computed(() =>
buildStandardCollectionCategories({
labels: {
all: this.translate.instant('PORTALS.ALL_CATEGORIES'),
movie: this.translate.instant('PORTALS.SIDEBAR.MOVIES'),
live: this.translate.instant('PORTALS.SIDEBAR.LIVE_TV'),
series: this.translate.instant('PORTALS.SIDEBAR.SERIES'),
},
counts: {
all: this.allFavorites.value()?.length ?? 0,
movie: this.movies()?.length ?? 0,
live: this.live()?.length ?? 0,
series: this.series()?.length ?? 0,
},
includeLive: true,
liveCategoryId: 'itv',
})
);
readonly collectionContext = createPortalCollectionContext({
ctx: this.favoritesCtx,
categories: this.categories,
});
readonly itemsToShow = computed(() => {
return filterCollectionBucket({
selectedCategoryId: this.selectedCategoryId(),
allItems: this.allFavorites.value(),
buckets: {
movie: this.movies(),
live: this.live(),
series: this.series(),
},
searchTerm: this.searchTerm(),
liveCategoryId: 'itv',
textOf: (item: any) => `${item?.name ?? ''} ${item?.o_name ?? ''}`,
});
});
/** Synced with workspace context service so panel clicks are reactive */
readonly selectedCategoryId = this.collectionContext.selectedCategoryId;
readonly isWorkspaceLayout = isWorkspaceLayoutRoute(this.route);
readonly layout: PortalCollectionShellLayout = {
...STALKER_RECENT_LAYOUT,
showHeaderAction: !this.isWorkspaceLayout,
};
readonly searchTerm = queryParamSignal(this.route, 'q', (value) =>
(value ?? '').trim().toLowerCase()
);
readonly refreshToken = queryParamSignal(
this.route,
'refresh',
(value) => value ?? ''
);
readonly liveItemsToShow = computed<
(StalkerVodSource & { category_id: string })[]
>(() =>
((this.allFavorites.value() ?? []) as (StalkerVodSource & {
category_id: string;
})[]).filter((item) => {
if (item.category_id !== 'itv') {
return false;
}
const term = this.searchTerm();
if (!term) {
return true;
}
return `${item?.name ?? ''} ${item?.o_name ?? ''}`
.toLowerCase()
.includes(term);
})
);
readonly isLiveCategory = computed(() => this.selectedCategoryId() === 'itv');
readonly isEmbeddedPlayer = computed(() =>
this.playerService.isEmbeddedPlayer()
);
private epgPageSize = 10;
private epgChannelId: number | string | null = null;
readonly series = computed(() =>
this.allFavorites
.value()
?.filter((item) => item.category_id === 'series')
);
readonly movies = computed(() =>
this.allFavorites.value()?.filter((item) => item.category_id === 'vod')
);
readonly live = computed(() =>
this.allFavorites.value()?.filter((item) => item.category_id === 'itv')
);
get mode(): PortalCollectionMode {
if (this.isLiveCategory()) {
return 'live';
}
return this.showDetails() ? 'detail' : 'grid';
}
constructor() {
effect(() => {
this.portalFavorites.value();
this.syncSelectedVodFavorite();
});
effect(() => {
const selectedCategoryId = this.selectedCategoryId();
const hasInlineDetail = this.inlineDetail().categoryId !== null;
const previousCategoryId = this.previousSelectedCategoryId;
this.previousSelectedCategoryId = selectedCategoryId;
if (
hasInlineDetail &&
previousCategoryId !== null &&
previousCategoryId !== selectedCategoryId
) {
this.clearDetailsView();
}
});
effect(() => {
const refresh = this.refreshToken();
if (!refresh) {
return;
}
this.collectionRefresh.refresh();
});
effect(() => {
const playlist = this.currentPlaylist();
if (!playlist?._id) return;
const state =
this.router.currentNavigation()?.extras?.state ??
window.history.state;
const item = state?.openRecentItem;
if (
!item ||
!RecentlyViewedComponent.isCategoryType(item.category_id)
) {
return;
}
this.openItem(item);
// Clear consumed history state to avoid reopening item on reload/back.
try {
window.history.replaceState({}, document.title);
} catch {
// no-op
}
});
effect(() => {
const selectedItem = this.selectedLiveItem();
if (!selectedItem) {
return;
}
const stillExists = this.liveItemsToShow().some(
(item) => String(item.id ?? '') === String(selectedItem.id ?? '')
);
if (!stillExists) {
this.clearLiveSelection();
}
});
effect(() => {
if (this.isLiveCategory()) {
return;
}
this.clearLiveSelection();
});
}
removeFromRecentlyViewed(item: Pick<StalkerVodSource, 'id'>) {
const id = String(item.id ?? '').trim();
if (!id) return;
this.stalkerStore.removeFromRecentlyViewed(id, () => {
this.collectionRefresh.refresh();
});
}
clearAllRecentlyViewed() {
const playlistId = this.stalkerStore.currentPlaylist()?._id;
if (!playlistId) return;
this.playlistService.clearPortalRecentlyViewed(playlistId).subscribe({
next: () => this.collectionRefresh.refresh(),
error: (error) =>
this.logger.error(
'Failed to clear recently viewed items',
error
),
});
}
setCategoryId(categoryId: string) {
this.collectionContext.setCategoryId(categoryId);
}
openItem(item: StalkerVodSource & { category_id: string }) {
if (!RecentlyViewedComponent.isCategoryType(item.category_id)) {
return;
}
this.stalkerStore.setSelectedContentType(item.category_id);
switch (item.category_id) {
case 'itv':
this.setCategoryId('itv');
const cleared = clearStalkerDetailViewState();
this.itemDetails = cleared.itemDetails;
this.vodDetailsItem = cleared.vodDetailsItem;
this.isSelectedVodFavorite.set(false);
void this.selectLiveItem(item);
break;
case 'vod': {
// Normalize the item to ensure is_series flag is properly set
const normalizedItem = this.normalizeRecentItem(item);
const detailViewState = createStalkerDetailViewState(
normalizedItem,
this.currentPlaylist()?._id ?? ''
);
this.itemDetails = {
...(detailViewState.itemDetails ?? normalizedItem),
category_id: 'vod',
};
this.vodDetailsItem = detailViewState.vodDetailsItem;
this.stalkerStore.setSelectedItem(detailViewState.itemDetails);
this.syncSelectedVodFavorite();
break;
}
case 'series':
this.itemDetails = this.normalizeRecentItem(item);
this.stalkerStore.setSelectedItem(this.itemDetails);
break;
default:
break;
}
}
/** Handle play from vod-details component */
onVodPlay(item: VodDetailsItem): void {
if (item.type === 'stalker') {
this.createLinkToPlayVodItv(
item.cmd,
item.data.info?.name,
item.data.info?.movie_image
);
}
}
/** Handle favorite toggle from vod-details component */
onVodFavoriteToggled(event: {
item: VodDetailsItem;
isFavorite: boolean;
}): void {
toggleStalkerVodFavorite(event, {
addToFavorites: (item, onDone) => this.addToFavorites(item, onDone),
removeFromFavorites: (favoriteId, onDone) =>
this.removeFromFavorites(favoriteId, onDone),
onComplete: () => {
this.favoritesRefresh.refresh();
this.syncSelectedVodFavorite();
},
});
}
/** Handle back from vod-details component */
onVodBack(): void {
this.clearDetailsView();
}
async selectLiveItem(item: StalkerVodSource & { category_id: string }) {
this.selectedLiveItem.set(item);
this.liveStreamUrl.set('');
this.stalkerStore.setSelectedContentType('itv');
this.stalkerStore.setSelectedItem(item as any);
this.isResolvingPlayback.set(true);
try {
const playback = await this.stalkerStore.resolveItvPlayback(
item as any
);
await this.loadEpgForChannel(item.id);
this.liveStreamUrl.set(playback.streamUrl);
if (this.isEmbeddedPlayer()) {
} else {
void this.playerService.openResolvedPlayback(playback, true);
}
} catch (error) {
this.logger.error('Failed to resolve ITV playback', error);
this.showPlaybackError(error);
} finally {
this.isResolvingPlayback.set(false);
}
}
async loadMoreEpg() {
if (!this.epgChannelId || this.isLoadingEpg()) {
return;
}
this.epgPageSize += 10;
this.isLoadingEpg.set(true);
try {
const items = await this.stalkerStore.fetchChannelEpg(
this.epgChannelId,
this.epgPageSize
);
this.epgItems.set(items);
this.hasMoreEpg.set(items.length >= this.epgPageSize);
} catch {
this.hasMoreEpg.set(false);
} finally {
this.isLoadingEpg.set(false);
}
}
async createLinkToPlayVodItv(
cmd?: string,
title?: string,
thumbnail?: string
) {
this.logger.debug('Create link to play VOD/ITV', {
cmd,
title,
thumbnail,
});
await this.stalkerStore.createLinkToPlayVod(cmd, title, thumbnail);
}
addToFavorites(item: Record<string, unknown>, onDone?: () => void) {
this.stalkerStore.addToFavorites(item, onDone);
}
removeFromFavorites(favoriteId: string, onDone?: () => void) {
this.stalkerStore.removeFromFavorites(favoriteId, onDone);
}
toggleLiveFavorite(item: StalkerVodSource & { category_id: string }): void {
const itemId = String(item.id ?? '');
if (this.liveFavoriteIds().has(itemId)) {
this.removeFromFavorites(itemId);
return;
}
this.addToFavorites({
...item,
category_id: 'itv',
title: item.o_name || item.name,
cover: item.logo || item.cover,
added_at: new Date().toISOString(),
});
}
readonly liveFavoriteIds = computed(() => {
const ids = new Map<string | number, boolean>();
for (const item of (this.portalFavorites.value() ?? []) as StalkerVodSource[]) {
if (item.category_id === 'itv') {
ids.set(String(item.id ?? ''), true);
}
}
return ids;
});
private clearLiveSelection() {
this.selectedLiveItem.set(null);
this.liveStreamUrl.set('');
this.isResolvingPlayback.set(false);
this.epgItems.set([]);
this.isLoadingEpg.set(false);
this.hasMoreEpg.set(false);
this.epgChannelId = null;
this.stalkerStore.setSelectedItem(null);
}
/**
* Normalize recent item to ensure is_series flag is properly set
* and all required fields are present for the series view
*/
private normalizeRecentItem(
item: StalkerVodSource
): StalkerSelectedVodItem {
return normalizeStalkerVodDetailsItem(item);
}
private syncSelectedVodFavorite(): void {
this.isSelectedVodFavorite.set(
isSelectedStalkerVodFavorite(
this.vodDetailsItem,
this.portalFavorites.value() ?? []
)
);
}
private clearDetailsView(): void {
const cleared = clearStalkerDetailViewState();
this.itemDetails = cleared.itemDetails;
this.vodDetailsItem = cleared.vodDetailsItem;
this.isSelectedVodFavorite.set(false);
}
inlineDetail() {
return createStalkerInlineDetailState(
this.itemDetails,
this.vodDetailsItem
);
}
showDetails() {
return this.inlineDetail().categoryId !== null;
}
private async loadEpgForChannel(channelId: number | string | undefined) {
if (channelId === undefined || channelId === null) {
this.epgItems.set([]);
this.hasMoreEpg.set(false);
return;
}
this.epgChannelId = channelId;
this.epgPageSize = 10;
this.isLoadingEpg.set(true);
this.epgItems.set([]);
this.hasMoreEpg.set(false);
try {
const items = await this.stalkerStore.fetchChannelEpg(
channelId,
this.epgPageSize
);
this.epgItems.set(items);
this.hasMoreEpg.set(items.length >= this.epgPageSize);
} catch {
this.epgItems.set([]);
} finally {
this.isLoadingEpg.set(false);
}
}
private showPlaybackError(error: unknown): void {
const errorMessage =
error instanceof Error && error.message === 'nothing_to_play'
? this.translate.instant('PORTALS.CONTENT_NOT_AVAILABLE')
: this.translate.instant('PORTALS.PLAYBACK_ERROR');
this.snackBar.open(errorMessage, undefined, { duration: 3000 });
}
}
@@ -1,168 +0,0 @@
import { Provider, Injectable, computed, inject } from '@angular/core';
import {
buildStalkerSelectedVodItem,
StalkerStore,
StalkerVodSource,
} from '@iptvnator/portal/stalker/data-access';
import {
PortalCatalogItemProgress,
PortalCatalogPlaylistMeta,
PortalCatalogSortMode,
PORTAL_CATALOG_FACADE,
StalkerPortalCatalogFacade,
} from '@iptvnator/portal/shared/util';
@Injectable()
export class StalkerCatalogFacadeService
implements
StalkerPortalCatalogFacade<
Record<string, unknown>,
StalkerVodSource,
StalkerVodSource
>
{
private readonly stalkerStore = inject(StalkerStore);
readonly provider = 'stalker' as const;
readonly pageSizeOptions = [14] as const;
readonly contentType = this.stalkerStore.selectedContentType;
readonly limit = this.stalkerStore.limit;
readonly pageIndex = this.stalkerStore.page;
readonly selectedCategory = this.stalkerStore.getSelectedCategory;
readonly paginatedContent = computed(
() => this.stalkerStore.getPaginatedContent() ?? []
);
readonly selectedItem = this.stalkerStore.selectedItem;
readonly totalPages = this.stalkerStore.getTotalPages;
readonly isPaginatedContentLoading =
this.stalkerStore.isPaginatedContentLoading;
readonly selectedCategoryTitle = computed(() => {
const category = this.selectedCategory();
const fromCategory = String(
category?.['category_name'] ?? category?.['name'] ?? ''
);
if (fromCategory) {
return fromCategory;
}
return this.stalkerStore.getSelectedCategoryName() ?? '';
});
readonly categoryItemCount = computed(() => this.stalkerStore.totalCount());
readonly contentSortMode = computed<PortalCatalogSortMode | null>(
() => null
);
readonly playlist = computed<PortalCatalogPlaylistMeta | null>(() => {
const playlist = this.stalkerStore.currentPlaylist();
if (!playlist?._id) {
return null;
}
return {
id: playlist._id,
title: playlist.title ?? 'Stalker Portal',
portalUrl: playlist.portalUrl,
macAddress: playlist.macAddress,
userAgent: playlist.userAgent,
referer: playlist.referrer,
origin: playlist.origin,
};
});
initialize(categoryId?: string | null): void {
this.clearSelectedItem();
if (categoryId) {
this.stalkerStore.setSelectedCategory(categoryId);
return;
}
this.stalkerStore.setSelectedCategory('*');
}
clearSelectedItem(): void {
this.stalkerStore.clearSelectedItem();
}
setPage(page: number): void {
this.stalkerStore.setPage(page);
}
setLimit(limit: number): void {
this.stalkerStore.setLimit(limit);
}
setContentSortMode(_mode: PortalCatalogSortMode): void {
// Stalker catalog content is server-paginated and does not support local sort modes.
}
selectItem(item: StalkerVodSource): string[] | null {
const needsSeriesFetch =
this.contentType() === 'vod' &&
(item.is_series === '1' || item.is_series === 1);
this.stalkerStore.setSelectedItem(
buildStalkerSelectedVodItem(item, needsSeriesFetch)
);
return null;
}
getItemProgress(
_item: StalkerVodSource
): PortalCatalogItemProgress {
return {};
}
async createLinkToPlayVod(
cmd?: string,
title?: string,
thumbnail?: string
): Promise<void> {
await this.stalkerStore.createLinkToPlayVod(cmd, title, thumbnail);
}
addToFavorites(item: Record<string, unknown>, onDone?: () => void): void {
this.stalkerStore.addToFavorites(item, onDone);
}
removeFromFavorites(favoriteId: string, onDone?: () => void): void {
this.stalkerStore.removeFromFavorites(favoriteId, onDone);
}
fetchMovieFileId(itemId: string): Promise<string | null> {
return this.stalkerStore.fetchMovieFileId(itemId);
}
async fetchLinkToPlay(
portalUrl: string,
macAddress: string,
cmd: string
): Promise<string> {
return this.stalkerStore.fetchLinkToPlay(portalUrl, macAddress, cmd);
}
resolveVodPlayback(
cmd?: string,
title?: string,
thumbnail?: string,
startTime?: number
) {
return this.stalkerStore.resolveVodPlayback(
cmd,
title,
thumbnail,
undefined,
undefined,
startTime
);
}
}
export function provideStalkerCatalogFacade(): Provider[] {
return [
StalkerCatalogFacadeService,
{
provide: PORTAL_CATALOG_FACADE,
useExisting: StalkerCatalogFacadeService,
},
];
}
@@ -1,42 +0,0 @@
<div class="search-panel">
<label class="search-field">
<mat-icon>search</mat-icon>
<input
type="search"
spellcheck="false"
[placeholder]="'CHANNELS.SEARCH_CHANNEL' | translate"
[(ngModel)]="searchString"
/>
</label>
</div>
<div class="channels-list">
@if (filteredItems().length > 0) {
@for (item of filteredItems(); track item.id) {
<app-channel-list-item
[name]="item.o_name || item.name || ''"
[logo]="item.logo"
[selected]="isSelected(item)"
[epgProgram]="
epgPrograms.get(normalizeStalkerEntityId(item.id)) ?? null
"
[progressPercentage]="
currentProgramsProgress.get(normalizeStalkerEntityId(item.id)) ?? 0
"
[showFavoriteButton]="true"
[showProgramInfoButton]="false"
[isFavorite]="isFavorite(item)"
(clicked)="onPlay(item)"
(favoriteToggled)="onFavoriteToggle(item)"
/>
}
} @else {
<div class="empty-search-state">
<div class="empty-icon-wrapper">
<mat-icon>search_off</mat-icon>
</div>
<h3>{{ 'CHANNELS.NO_CHANNELS_FOUND' | translate }}</h3>
<p>We couldn't find anything matching "{{ searchString() }}"</p>
</div>
}
</div>
@@ -1,126 +0,0 @@
:host {
display: block;
height: 100%;
width: 100%;
overflow: hidden;
container-type: inline-size;
}
.channels-list {
display: flex;
flex-direction: column;
height: calc(100% - 56px);
overflow-y: auto;
overflow-x: hidden;
width: 100%;
padding: 4px 8px 12px;
box-sizing: border-box;
gap: 2px;
}
.search-panel {
padding: 10px 12px;
flex-shrink: 0;
height: 56px;
box-sizing: border-box;
display: flex;
align-items: center;
}
.search-field {
flex: 1;
width: 100%;
height: 36px;
border-radius: 10px;
border: 1px solid rgba(127, 127, 127, 0.22);
background:
linear-gradient(180deg, rgba(255, 255, 255, 0.06), rgba(255, 255, 255, 0)),
rgba(127, 127, 127, 0.08);
display: flex;
align-items: center;
gap: 8px;
padding: 0 12px;
box-sizing: border-box;
cursor: text;
transition:
border-color 0.15s ease,
background-color 0.15s ease,
box-shadow 0.15s ease;
&:focus-within {
border-color: var(--app-selection-border);
box-shadow: 0 0 0 3px color-mix(in srgb, var(--app-selection-color) 16%, transparent);
}
mat-icon {
width: 18px;
height: 18px;
font-size: 18px;
opacity: 0.68;
flex-shrink: 0;
}
input {
flex: 1;
border: none;
outline: none;
background: transparent;
color: inherit;
font: inherit;
font-size: 0.875rem;
}
}
.empty-search-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 100%;
padding: 32px 24px;
text-align: center;
.empty-icon-wrapper {
width: 64px;
height: 64px;
border-radius: 20px;
background:
linear-gradient(180deg, rgba(255, 255, 255, 0.08), rgba(255, 255, 255, 0)),
rgba(127, 127, 127, 0.1);
display: flex;
align-items: center;
justify-content: center;
margin-bottom: 20px;
box-shadow:
0 8px 24px -8px rgba(0, 0, 0, 0.1),
inset 0 1px 1px rgba(255, 255, 255, 0.05);
mat-icon {
width: 32px;
height: 32px;
font-size: 32px;
opacity: 0.8;
}
}
h3 {
font-size: 1.125rem;
font-weight: 600;
margin: 0 0 8px;
letter-spacing: -0.01em;
}
p {
font-size: 0.875rem;
margin: 0;
opacity: 0.7;
line-height: 1.4;
max-width: 260px;
}
}
@container (max-width: 350px) {
.channels-list {
padding-inline: 6px;
}
}
@@ -1,159 +0,0 @@
import {
ChangeDetectionStrategy,
Component,
computed,
effect,
inject,
input,
output,
signal,
} from '@angular/core';
import { FormsModule } from '@angular/forms';
import { MatIconModule } from '@angular/material/icon';
import { TranslatePipe } from '@ngx-translate/core';
import { ChannelListItemComponent } from 'components';
import { EpgItem, EpgProgram } from 'shared-interfaces';
import { StalkerVodSource } from '@iptvnator/portal/stalker/data-access';
import { normalizeStalkerEntityId } from '@iptvnator/portal/stalker/data-access';
import { StalkerStore } from '@iptvnator/portal/stalker/data-access';
@Component({
selector: 'app-stalker-collection-channels-list',
imports: [ChannelListItemComponent, FormsModule, MatIconModule, TranslatePipe],
templateUrl: './stalker-collection-channels-list.component.html',
styleUrl: './stalker-collection-channels-list.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class StalkerCollectionChannelsListComponent {
readonly items = input<StalkerVodSource[]>([]);
readonly selectedItemId = input<string | number | null>(null);
readonly favoriteIds = input<Map<string | number, boolean>>(new Map());
readonly playClicked = output<any>();
readonly favoriteToggled = output<any>();
private readonly stalkerStore = inject(StalkerStore);
protected readonly normalizeStalkerEntityId = normalizeStalkerEntityId;
readonly searchString = signal('');
readonly filteredItems = computed(() => {
const search = this.searchString().trim().toLowerCase();
if (!search) {
return this.items();
}
return this.items().filter((item) =>
`${item.o_name ?? ''} ${item.name ?? ''}`
.toLowerCase()
.includes(search)
);
});
readonly epgPrograms = new Map<string | number, EpgProgram>();
readonly currentProgramsProgress = new Map<string | number, number>();
private readonly requestedChannels = new Set<string | number>();
constructor() {
effect(() => {
const items = this.items();
if (!items.length) {
this.epgPrograms.clear();
this.currentProgramsProgress.clear();
this.requestedChannels.clear();
return;
}
void this.loadEpgPreviews(items);
});
}
onPlay(item: StalkerVodSource): void {
this.playClicked.emit(item);
}
onFavoriteToggle(item: StalkerVodSource): void {
this.favoriteToggled.emit(item);
}
isSelected(item: StalkerVodSource): boolean {
return String(this.selectedItemId() ?? '') === normalizeStalkerEntityId(item.id);
}
isFavorite(item: StalkerVodSource): boolean {
return this.favoriteIds().get(normalizeStalkerEntityId(item.id)) ?? false;
}
private async loadEpgPreviews(items: StalkerVodSource[]): Promise<void> {
const newItems = items.filter((item) => {
const id = normalizeStalkerEntityId(item.id);
return id && !this.requestedChannels.has(id);
});
if (!newItems.length) {
return;
}
for (const item of newItems) {
this.requestedChannels.add(normalizeStalkerEntityId(item.id));
}
const batchSize = 3;
for (let i = 0; i < newItems.length; i += batchSize) {
const batch = newItems.slice(i, i + batchSize);
await Promise.all(
batch.map((item) => this.loadSingleEpgPreview(item.id))
);
if (i + batchSize < newItems.length) {
await new Promise((resolve) => setTimeout(resolve, 150));
}
}
}
private async loadSingleEpgPreview(channelId: number | string): Promise<void> {
try {
const items = await this.stalkerStore.fetchChannelEpg(channelId, 1);
if (!items.length) {
return;
}
const id = normalizeStalkerEntityId(channelId);
const program = this.toPreviewProgram(items[0], id);
this.epgPrograms.set(id, program);
this.updateProgramProgress(id, items[0]);
} catch {
// Ignore preview failures for collection live lists.
}
}
private updateProgramProgress(
channelId: string | number,
item: EpgItem
): void {
const now = Date.now() / 1000;
const start = parseInt(item.start_timestamp, 10);
const end = parseInt(item.stop_timestamp, 10);
if (start && end && now >= start && now <= end) {
this.currentProgramsProgress.set(
channelId,
((now - start) / (end - start)) * 100
);
return;
}
this.currentProgramsProgress.delete(channelId);
}
private toPreviewProgram(
item: EpgItem,
channelId: string | number
): EpgProgram {
return {
start: item.start,
stop: item.stop || item.end,
channel: String(channelId),
title: item.title,
desc: item.description || null,
category: null,
};
}
}
@@ -1,48 +0,0 @@
@if (isLiveCategory()) {
<app-portal-collection-live-shell
[title]="'CHANNELS.FAVORITES' | translate"
[subtitle]="'PORTALS.SIDEBAR.LIVE_TV' | translate"
[count]="liveItemsToShow().length"
[streamUrl]="liveStreamUrl()"
[isEmbeddedPlayer]="isEmbeddedPlayer()"
[hasSelection]="!!selectedLiveItem()"
[epgItems]="epgItems()"
[isLoadingEpg]="isLoadingEpg()"
[hasMoreEpg]="hasMoreEpg()"
(loadMoreEpgClicked)="loadMoreEpg()"
>
<app-stalker-collection-channels-list
liveChannels
[items]="liveItemsToShow()"
[selectedItemId]="selectedLiveItem()?.id ?? null"
[favoriteIds]="liveFavoriteIds()"
(playClicked)="selectLiveItem($event)"
(favoriteToggled)="toggleLiveFavorite($event)"
/>
</app-portal-collection-live-shell>
} @else {
<app-portal-collection-shell
[categories]="categories() ?? []"
[items]="favoritesToShow() ?? []"
[playlistSubtitle]="playlistSubtitle"
[playlistTitle]="playlistTitle()"
[selectedCategoryId]="selectedCategoryId()"
[layout]="layout"
[mode]="mode"
(categoryClicked)="setCategoryId($event)"
(removeItem)="removeFromFavorites($event)"
(openItem)="openItem($event)"
>
<app-stalker-inline-detail
details
[categoryId]="inlineDetail().categoryId"
[seriesItem]="inlineDetail().seriesItem"
[isSeries]="inlineDetail().isSeries"
[vodDetailsItem]="inlineDetail().vodDetailsItem"
[isFavorite]="isSelectedVodFavorite()"
(playClicked)="onVodPlay($event)"
(favoriteToggled)="onVodFavoriteToggled($event)"
(backClicked)="onSeriesBack()"
/>
</app-portal-collection-shell>
}
@@ -1,541 +0,0 @@
import {
Component,
computed,
effect,
inject,
signal,
} from '@angular/core';
import { MatSnackBar } from '@angular/material/snack-bar';
import { ActivatedRoute, Router } from '@angular/router';
import { TranslatePipe, TranslateService } from '@ngx-translate/core';
import { PlaylistsService } from 'services';
import { EpgItem, VodDetailsItem } from 'shared-interfaces';
import { PlayerService } from '../../services/player.service';
import { PortalCollectionLiveShellComponent } from '@iptvnator/portal/shared/ui';
import {
PortalCollectionMode,
PortalCollectionShellComponent,
PortalCollectionShellLayout,
} from '@iptvnator/portal/shared/ui';
import { StalkerInlineDetailComponent } from '../stalker-inline-detail/stalker-inline-detail.component';
import { queryParamSignal } from '@iptvnator/portal/shared/util';
import { createPortalCollectionContext } from '@iptvnator/portal/shared/util';
import {
buildStandardCollectionCategories,
filterCollectionBucket,
} from '@iptvnator/portal/shared/util';
import { createLogger } from '@iptvnator/portal/shared/util';
import { FavoritesContextService } from '@iptvnator/portal/shared/util';
import { StalkerFavoriteItem } from '@iptvnator/portal/stalker/data-access';
import {
clearStalkerDetailViewState,
createStalkerInlineDetailState,
createPortalFavoritesResource,
createRefreshTrigger,
createStalkerDetailViewState,
isSelectedStalkerVodFavorite,
NormalizedStalkerFavoriteItem,
normalizeStalkerEntityId,
normalizeStalkerFavoriteItem,
toggleStalkerVodFavorite,
} from '@iptvnator/portal/stalker/data-access';
import { StalkerCollectionChannelsListComponent } from '../stalker-collection-channels-list/stalker-collection-channels-list.component';
import { StalkerStore } from '@iptvnator/portal/stalker/data-access';
const STALKER_FAVORITES_LAYOUT: PortalCollectionShellLayout = {};
@Component({
selector: 'app-stalker-favorites',
templateUrl: './stalker-favorites.component.html',
imports: [
PortalCollectionLiveShellComponent,
PortalCollectionShellComponent,
StalkerCollectionChannelsListComponent,
StalkerInlineDetailComponent,
TranslatePipe,
],
styles: [
`
:host {
display: block;
height: 100%;
width: 100%;
position: relative;
}
.back-button {
margin: 10px 0 0 16px;
}
`,
],
})
export class StalkerFavoritesComponent {
private static isCategoryType(
value: unknown
): value is 'vod' | 'series' | 'itv' {
const normalized = String(value ?? '').toLowerCase();
return (
normalized === 'vod' ||
normalized === 'series' ||
normalized === 'itv'
);
}
private readonly route = inject(ActivatedRoute);
private readonly router = inject(Router);
private readonly playlistService = inject(PlaylistsService);
private readonly favoritesRefresh = createRefreshTrigger();
private readonly stalkerStore = inject(StalkerStore);
private readonly playerService = inject(PlayerService);
private readonly snackBar = inject(MatSnackBar);
private readonly translate = inject(TranslateService);
private readonly logger = createLogger('StalkerFavorites');
private readonly favoritesCtx = inject(FavoritesContextService);
private previousSelectedCategoryId: string | null = null;
itemDetails: NormalizedStalkerFavoriteItem | null = null;
vodDetailsItem: VodDetailsItem | null = null;
readonly isSelectedVodFavorite = signal<boolean>(false);
readonly selectedLiveItem = signal<StalkerFavoriteItem | null>(null);
readonly liveStreamUrl = signal('');
readonly epgItems = signal<EpgItem[]>([]);
readonly isLoadingEpg = signal(false);
readonly hasMoreEpg = signal(false);
readonly isResolvingPlayback = signal(false);
readonly currentPlaylist = this.stalkerStore.currentPlaylist;
readonly layout = STALKER_FAVORITES_LAYOUT;
readonly playlistSubtitle = 'Stalker Portal';
readonly playlistTitle = computed(
() => this.currentPlaylist()?.title || 'Portal'
);
readonly allFavorites = createPortalFavoritesResource(
this.playlistService,
() => this.stalkerStore.currentPlaylist()?._id,
() => this.favoritesRefresh.refreshVersion()
);
readonly categories = computed(() =>
buildStandardCollectionCategories({
labels: {
all: this.translate.instant('PORTALS.ALL_CATEGORIES'),
movie: this.translate.instant('PORTALS.SIDEBAR.MOVIES'),
live: this.translate.instant('PORTALS.SIDEBAR.LIVE_TV'),
series: this.translate.instant('PORTALS.SIDEBAR.SERIES'),
},
counts: {
all: this.allFavorites.value()?.length ?? 0,
movie: this.movies()?.length ?? 0,
live: this.live()?.length ?? 0,
series: this.series()?.length ?? 0,
},
includeLive: true,
liveCategoryId: 'itv',
})
);
readonly collectionContext = createPortalCollectionContext({
ctx: this.favoritesCtx,
categories: this.categories,
});
readonly liveItemsToShow = computed<StalkerFavoriteItem[]>(() =>
((this.allFavorites.value() ?? []) as StalkerFavoriteItem[]).filter((item) => {
if (item.category_id !== 'itv') {
return false;
}
const term = this.searchTerm();
if (!term) {
return true;
}
return `${item?.name ?? ''} ${item?.o_name ?? ''}`
.toLowerCase()
.includes(term);
})
);
readonly isLiveCategory = computed(() => this.selectedCategoryId() === 'itv');
readonly isEmbeddedPlayer = computed(() =>
this.playerService.isEmbeddedPlayer()
);
private epgPageSize = 10;
private epgChannelId: number | string | null = null;
readonly favoritesToShow = computed(() => {
return filterCollectionBucket({
selectedCategoryId: this.selectedCategoryId(),
allItems: this.allFavorites.value(),
buckets: {
movie: this.movies(),
live: this.live(),
series: this.series(),
},
searchTerm: this.searchTerm(),
liveCategoryId: 'itv',
textOf: (item: any) => `${item?.name ?? ''} ${item?.o_name ?? ''}`,
});
});
/** Synced with workspace context service so panel clicks are reactive */
readonly selectedCategoryId = this.collectionContext.selectedCategoryId;
readonly searchTerm = queryParamSignal(this.route, 'q', (value) =>
(value ?? '').trim().toLowerCase()
);
readonly series = computed(() =>
this.allFavorites
.value()
?.filter((item) => item.category_id === 'series')
);
readonly movies = computed(() =>
this.allFavorites.value()?.filter((item) => item.category_id === 'vod')
);
readonly live = computed(() =>
this.allFavorites.value()?.filter((item) => item.category_id === 'itv')
);
get mode(): PortalCollectionMode {
if (this.isLiveCategory()) {
return 'live';
}
return this.showDetails() ? 'detail' : 'grid';
}
constructor() {
effect(() => {
this.allFavorites.value();
this.syncSelectedVodFavorite();
});
effect(() => {
const selectedCategoryId = this.selectedCategoryId();
const hasInlineDetail = this.inlineDetail().categoryId !== null;
const previousCategoryId = this.previousSelectedCategoryId;
this.previousSelectedCategoryId = selectedCategoryId;
if (
hasInlineDetail &&
previousCategoryId !== null &&
previousCategoryId !== selectedCategoryId
) {
this.resetDetailsView(false);
}
});
effect(() => {
const selectedItem = this.selectedLiveItem();
if (!selectedItem) {
return;
}
const stillExists = this.liveItemsToShow().some(
(item) => normalizeStalkerEntityId(item.id) === normalizeStalkerEntityId(selectedItem.id)
);
if (!stillExists) {
this.clearLiveSelection();
}
});
effect(() => {
if (this.isLiveCategory()) {
return;
}
this.clearLiveSelection();
});
effect(() => {
const playlist = this.currentPlaylist();
if (!playlist?._id) {
return;
}
const state =
this.router.currentNavigation()?.extras?.state ??
window.history.state;
const item = state?.openFavoriteItem;
if (
!item ||
!StalkerFavoritesComponent.isCategoryType(item.category_id)
) {
return;
}
this.openItem(item);
try {
window.history.replaceState({}, document.title);
} catch {
// no-op
}
});
}
removeFromFavorites(
item: Pick<StalkerFavoriteItem, 'id'>,
onDone?: () => void
) {
this.stalkerStore.removeFromFavorites(
normalizeStalkerEntityId(item.id),
() => {
this.favoritesRefresh.refresh();
this.syncSelectedVodFavorite();
onDone?.();
}
);
}
addToFavorites(item: Record<string, unknown>, onDone?: () => void) {
this.stalkerStore.addToFavorites(item, () => {
this.favoritesRefresh.refresh();
this.syncSelectedVodFavorite();
onDone?.();
});
}
setCategoryId(categoryId: string) {
this.collectionContext.setCategoryId(categoryId);
}
openItem(item: StalkerFavoriteItem) {
this.logger.debug('Open item', item);
const normalizedCategory =
item.category_id === 'movie' ? 'vod' : item.category_id;
const itemToOpen = {
...item,
category_id: normalizedCategory,
} as StalkerFavoriteItem;
const normalizedItem = normalizeStalkerFavoriteItem(itemToOpen);
switch (itemToOpen.category_id) {
case 'itv':
this.setCategoryId('itv');
this.resetDetailsView(false);
void this.selectLiveItem(itemToOpen);
break;
case 'vod': {
this.itemDetails = normalizedItem;
this.stalkerStore.setSelectedItem(normalizedItem.details);
this.stalkerStore.setSelectedContentType('vod');
const detailViewState = createStalkerDetailViewState(
normalizedItem.details,
this.currentPlaylist()?._id ?? ''
);
this.itemDetails = {
...normalizedItem,
details: detailViewState.itemDetails,
};
this.vodDetailsItem = detailViewState.vodDetailsItem;
this.syncSelectedVodFavorite();
break;
}
case 'series':
this.itemDetails = normalizedItem;
this.stalkerStore.setSelectedItem(normalizedItem.details);
this.stalkerStore.setSelectedContentType('series');
break;
default:
break;
}
}
/** Handle play from vod-details component */
onVodPlay(item: VodDetailsItem): void {
if (item.type === 'stalker') {
this.createLinkToPlayVodItv(
item.cmd,
item.data.info?.name,
item.data.info?.movie_image
);
}
}
/** Handle favorite toggle from vod-details component */
onVodFavoriteToggled(event: {
item: VodDetailsItem;
isFavorite: boolean;
}): void {
toggleStalkerVodFavorite(event, {
addToFavorites: (item, onDone) => this.addToFavorites(item, onDone),
removeFromFavorites: (favoriteId, onDone) =>
this.removeFromFavorites({ id: favoriteId }, onDone),
onComplete: () => {
this.favoritesRefresh.refresh();
this.syncSelectedVodFavorite();
},
});
}
/** Handle back from vod-details component */
onVodBack(): void {
this.resetDetailsView(true);
}
onSeriesBack(): void {
this.resetDetailsView(true);
}
private resetDetailsView(refreshList: boolean): void {
const cleared = clearStalkerDetailViewState();
this.itemDetails = null;
this.vodDetailsItem = cleared.vodDetailsItem;
this.isSelectedVodFavorite.set(false);
if (refreshList) {
this.favoritesRefresh.refresh();
}
}
private syncSelectedVodFavorite(): void {
this.isSelectedVodFavorite.set(
isSelectedStalkerVodFavorite(
this.vodDetailsItem,
this.allFavorites.value() ?? []
)
);
}
inlineDetail() {
return createStalkerInlineDetailState(
this.itemDetails,
this.vodDetailsItem
);
}
showDetails() {
return this.inlineDetail().categoryId !== null;
}
async selectLiveItem(item: StalkerFavoriteItem) {
this.selectedLiveItem.set(item);
this.liveStreamUrl.set('');
this.stalkerStore.setSelectedContentType('itv');
this.stalkerStore.setSelectedItem(item as any);
this.isResolvingPlayback.set(true);
try {
const playback = await this.stalkerStore.resolveItvPlayback(
item as any
);
await this.loadEpgForChannel(item.id);
this.liveStreamUrl.set(playback.streamUrl);
if (this.isEmbeddedPlayer()) {
} else {
void this.playerService.openResolvedPlayback(playback, true);
}
} catch (error) {
this.logger.error('Failed to resolve ITV playback', error);
this.showPlaybackError(error);
} finally {
this.isResolvingPlayback.set(false);
}
}
async loadMoreEpg() {
if (!this.epgChannelId || this.isLoadingEpg()) {
return;
}
this.epgPageSize += 10;
this.isLoadingEpg.set(true);
try {
const items = await this.stalkerStore.fetchChannelEpg(
this.epgChannelId,
this.epgPageSize
);
this.epgItems.set(items);
this.hasMoreEpg.set(items.length >= this.epgPageSize);
} catch {
this.hasMoreEpg.set(false);
} finally {
this.isLoadingEpg.set(false);
}
}
async createLinkToPlayVodItv(
cmd?: string,
title?: string,
thumbnail?: string
) {
this.logger.debug('Create link to play VOD/ITV', {
cmd,
title,
thumbnail,
});
await this.stalkerStore.createLinkToPlayVod(cmd, title, thumbnail);
}
toggleLiveFavorite(item: StalkerFavoriteItem): void {
const itemId = normalizeStalkerEntityId(item.id);
if (this.liveFavoriteIds().has(itemId)) {
this.removeFromFavorites({ id: item.id });
return;
}
this.addToFavorites({
...item,
category_id: 'itv',
title: item.o_name || item.name,
cover: item.logo,
added_at: new Date().toISOString(),
});
}
readonly liveFavoriteIds = computed(() => {
const ids = new Map<string | number, boolean>();
for (const item of this.liveItemsToShow()) {
ids.set(normalizeStalkerEntityId(item.id), true);
}
return ids;
});
private clearLiveSelection() {
this.selectedLiveItem.set(null);
this.liveStreamUrl.set('');
this.isResolvingPlayback.set(false);
this.epgItems.set([]);
this.isLoadingEpg.set(false);
this.hasMoreEpg.set(false);
this.epgChannelId = null;
this.stalkerStore.setSelectedItem(null);
}
private async loadEpgForChannel(channelId: number | string | undefined) {
if (channelId === undefined || channelId === null) {
this.epgItems.set([]);
this.hasMoreEpg.set(false);
return;
}
this.epgChannelId = channelId;
this.epgPageSize = 10;
this.isLoadingEpg.set(true);
this.epgItems.set([]);
this.hasMoreEpg.set(false);
try {
const items = await this.stalkerStore.fetchChannelEpg(
channelId,
this.epgPageSize
);
this.epgItems.set(items);
this.hasMoreEpg.set(items.length >= this.epgPageSize);
} catch {
this.epgItems.set([]);
} finally {
this.isLoadingEpg.set(false);
}
}
private showPlaybackError(error: unknown): void {
const errorMessage =
error instanceof Error && error.message === 'nothing_to_play'
? this.translate.instant('PORTALS.CONTENT_NOT_AVAILABLE')
: this.translate.instant('PORTALS.PLAYBACK_ERROR');
this.snackBar.open(errorMessage, undefined, { duration: 3000 });
}
}
@@ -1,27 +0,0 @@
import { StalkerFeatureRouteOptions } from '@iptvnator/portal/stalker/feature';
import { provideStalkerCatalogFacade } from './stalker-catalog-facade.service';
// Temporary app-side loader map while Stalker route screens remain in apps/web.
export const stalkerFeatureRouteOptions: StalkerFeatureRouteOptions = {
catalogProviders: provideStalkerCatalogFacade(),
loadCategoryContentViewComponent: () =>
import(
'../portal-shared/category-content-view/category-content-view.component'
).then((c) => c.CategoryContentViewComponent),
loadLiveStreamLayoutComponent: () =>
import(
'./stalker-live-stream-layout/stalker-live-stream-layout.component'
).then((c) => c.StalkerLiveStreamLayoutComponent),
loadFavoritesComponent: () =>
import('./stalker-favorites/stalker-favorites.component').then(
(c) => c.StalkerFavoritesComponent
),
loadRecentlyViewedComponent: () =>
import('./recently-viewed/recently-viewed.component').then(
(c) => c.RecentlyViewedComponent
),
loadSearchComponent: () =>
import('./stalker-search/stalker-search.component').then(
(c) => c.StalkerSearchComponent
),
};
@@ -1,24 +0,0 @@
@if (categoryId() === 'vod') {
@if ((seriesItem()?.series?.length ?? 0) > 0) {
<!-- VOD item with embedded series (vclub) -->
<app-stalker-series-view
[vodWithSeries]="seriesItem()"
(backClicked)="onBackClicked()"
/>
} @else if (isSeries()) {
<!-- VOD item that is actually a series (Ministra plugin is_series=1) -->
<app-stalker-series-view (backClicked)="onBackClicked()" />
} @else if (vodDetailsItem()) {
<!-- Regular VOD item -->
<app-vod-details
[item]="vodDetailsItem()"
[isFavorite]="isFavorite()"
[externalPlayback]="externalPlayback.activeSession()"
(playClicked)="onPlayClicked($event)"
(favoriteToggled)="onFavoriteToggled($event)"
(backClicked)="onBackClicked()"
/>
}
} @else if (categoryId() === 'series') {
<app-stalker-series-view (backClicked)="onBackClicked()" />
}
@@ -1,55 +0,0 @@
import {
ChangeDetectionStrategy,
Component,
inject,
input,
output,
} from '@angular/core';
import { ExternalPlaybackService } from '../../services/external-playback.service';
import { VodDetailsItem } from 'shared-interfaces';
import { VodDetailsComponent } from '../../portal-shared/vod-details/vod-details.component';
import { StalkerSelectedVodItem } from '@iptvnator/portal/stalker/data-access';
import { StalkerSeriesViewComponent } from '../stalker-series-view/stalker-series-view.component';
@Component({
selector: 'app-stalker-inline-detail',
imports: [StalkerSeriesViewComponent, VodDetailsComponent],
templateUrl: './stalker-inline-detail.component.html',
changeDetection: ChangeDetectionStrategy.OnPush,
styles: [
`
:host {
display: block;
height: 100%;
width: 100%;
}
`,
],
})
export class StalkerInlineDetailComponent {
readonly externalPlayback = inject(ExternalPlaybackService);
readonly categoryId = input<'vod' | 'series' | null>(null);
readonly seriesItem = input<StalkerSelectedVodItem | null>(null);
readonly isSeries = input<boolean>(false);
readonly vodDetailsItem = input<VodDetailsItem | null>(null);
readonly isFavorite = input<boolean>(false);
readonly backClicked = output<void>();
readonly playClicked = output<VodDetailsItem>();
readonly favoriteToggled = output<{
item: VodDetailsItem;
isFavorite: boolean;
}>();
onBackClicked() {
this.backClicked.emit();
}
onPlayClicked(item: VodDetailsItem) {
this.playClicked.emit(item);
}
onFavoriteToggled(event: { item: VodDetailsItem; isFavorite: boolean }) {
this.favoriteToggled.emit(event);
}
}
@@ -1,143 +0,0 @@
@let isEmbeddedPlayer =
player() === 'videojs' || player() === 'html5' || player() === 'artplayer';
@if (!isWorkspaceLayout || stalkerStore.selectedCategoryId()) {
<div
class="sidebar"
appResizable
[minWidth]="250"
[maxWidth]="600"
[defaultWidth]="400"
storageKey="sidebar-width"
>
@if (stalkerStore.selectedCategoryId() || !isWorkspaceLayout) {
<div class="sidebar-header">
@if (stalkerStore.selectedCategoryId()) {
<button mat-icon-button (click)="backToCategories()">
<mat-icon>chevron_left</mat-icon>
</button>
<h2>{{ selectedCategoryTitle() }}</h2>
} @else {
<app-playlist-switcher
class="switcher"
[currentTitle]="currentPlaylist()?.title || 'Portal'"
[subtitle]="'Stalker Portal'"
/>
}
</div>
}
@if (stalkerStore.selectedCategoryId()) {
<!-- <div class="search-panel">
<mat-form-field class="search-bar" subscriptSizing="dynamic">
<mat-icon matPrefix>search</mat-icon>
<input
matInput
[placeholder]="'PORTALS.SIDEBAR.SEARCH' | translate"
[ngModel]="searchString()"
(ngModelChange)="searchString.set($event)"
/>
@if (searchString()) {
<button matSuffix mat-icon-button (click)="searchString.set('')">
<mat-icon>close</mat-icon>
</button>
}
</mat-form-field>
</div> -->
<div #scrollContainer class="channels-list">
@for (item of itvChannels(); track item.id ?? $index) {
<app-channel-list-item
[name]="item.o_name || item.name"
[logo]="item.logo"
[selected]="isSelectedChannel(item)"
[epgProgram]="
epgPreviewPrograms.get(
normalizeStalkerEntityId(item.id)
) ?? null
"
[progressPercentage]="
currentProgramsProgress.get(
normalizeStalkerEntityId(item.id)
) ?? 0
"
[showFavoriteButton]="true"
[showProgramInfoButton]="false"
[isFavorite]="
favorites.get(normalizeStalkerEntityId(item.id)) ??
false
"
(clicked)="playChannel(item)"
(favoriteToggled)="toggleFavorite(item)"
/>
}
@if (isLoadingMore()) {
<div class="loading-indicator">
<mat-spinner diameter="24" />
</div>
}
</div>
} @else {
@if (isCategoryLoading()) {
<ngx-skeleton-loader
count="20"
[theme]="{
width: '95%',
height: '48px',
borderRadius: '40px',
}"
/>
} @else if (isCategoryFailed()) {
<app-playlist-error-view
[title]="
'PORTALS.ERROR_VIEW.UNKNOWN_ERROR.TITLE' | translate
"
[description]="
'PORTALS.ERROR_VIEW.UNKNOWN_ERROR.DESCRIPTION'
| translate
"
/>
} @else {
<app-category-view
[items]="categories()"
[selectedCategoryId]="
$any(stalkerStore.selectedCategoryId())
"
(categoryClicked)="selectCategory($event)"
/>
}
}
</div>
}
<div class="content-container">
@if (streamUrl && isEmbeddedPlayer) {
<div class="video-player">
<app-web-player-view [streamUrl]="streamUrl" />
</div>
}
@if (stalkerStore.selectedItem()) {
<div class="epg">
<div class="epg-content">
<app-epg-view [epgItems]="epgItems()" />
@if (isLoadingEpg()) {
<div class="loading-indicator">
<mat-spinner diameter="24" />
</div>
}
@if (hasMoreEpg() && !isLoadingEpg()) {
<div class="load-more-epg">
<button mat-button (click)="loadMoreEpg()">
{{ 'CHANNELS.LOAD_MORE' | translate }}
</button>
</div>
}
</div>
</div>
} @else {
<app-portal-empty-state
icon="live_tv"
[message]="'CHANNELS.SELECT_CHANNEL_PLAYBACK' | translate"
/>
}
</div>
@@ -1,56 +0,0 @@
@use '@angular/material' as mat;
@use '../../shared/styles/portal-layout' as portal;
@include portal.live-layout;
.search-panel {
padding: 8px 12px;
flex-shrink: 0;
height: 56px;
box-sizing: border-box;
}
.search-bar {
width: 100%;
@include mat.form-field-density(-5);
}
::ng-deep {
.search-bar {
div {
border-radius: 20px;
}
}
div[matformfieldlineripple] {
display: none;
}
}
.channels-list {
flex: 1;
overflow-y: auto;
overflow-x: hidden;
padding: 4px 8px 12px;
display: flex;
flex-direction: column;
gap: 2px;
}
.loading-indicator {
display: flex;
justify-content: center;
padding: 16px;
}
.load-more-epg {
display: flex;
justify-content: center;
padding: 8px 0;
}
@container (max-width: 350px) {
.channels-list {
padding-inline: 6px;
}
}
@@ -1,522 +0,0 @@
import {
ChangeDetectionStrategy,
ChangeDetectorRef,
Component,
effect,
ElementRef,
inject,
NgZone,
OnDestroy,
signal,
viewChild,
} from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { FormsModule } from '@angular/forms';
import { MatButton, MatIconButton } from '@angular/material/button';
import { MatFormFieldModule } from '@angular/material/form-field';
import { MatIcon } from '@angular/material/icon';
import { MatInputModule } from '@angular/material/input';
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
import { MatSnackBar } from '@angular/material/snack-bar';
import { ActivatedRoute } from '@angular/router';
import { TranslatePipe, TranslateService } from '@ngx-translate/core';
import {
ChannelListItemComponent,
PlaylistSwitcherComponent,
ResizableDirective,
} from 'components';
import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader';
import { PlaylistsService, StalkerSessionService } from 'services';
import { EpgItem, EpgProgram } from 'shared-interfaces';
import { EpgViewComponent, WebPlayerViewComponent } from 'shared-portals';
import { PlayerService } from '../../services/player.service';
import { SettingsStore } from '../../services/settings-store.service';
import { CategoryViewComponent } from '@iptvnator/portal/shared/ui';
import { PlaylistErrorViewComponent } from '@iptvnator/portal/shared/ui';
import { PortalEmptyStateComponent } from '@iptvnator/portal/shared/ui';
import { isWorkspaceLayoutRoute } from '@iptvnator/portal/shared/util';
import {
getAdjacentChannelItem,
getChannelItemByNumber,
} from '../../shared/services/remote-channel-navigation.util';
import { createLogger } from '@iptvnator/portal/shared/util';
import {
StalkerCategoryItem,
StalkerFavoriteItem,
StalkerItvChannel,
} from '@iptvnator/portal/stalker/data-access';
import { normalizeStalkerEntityId } from '@iptvnator/portal/stalker/data-access';
import { StalkerStore } from '@iptvnator/portal/stalker/data-access';
@Component({
selector: 'app-stalker-live-stream-layout',
templateUrl: './stalker-live-stream-layout.component.html',
styleUrls: [
'./stalker-live-stream-layout.component.scss',
'../../shared/styles/portal-sidebar.scss',
],
imports: [
CategoryViewComponent,
ChannelListItemComponent,
EpgViewComponent,
FormsModule,
MatButton,
MatFormFieldModule,
MatIcon,
MatIconButton,
MatInputModule,
MatProgressSpinnerModule,
NgxSkeletonLoaderModule,
PlaylistErrorViewComponent,
PlaylistSwitcherComponent,
PortalEmptyStateComponent,
ResizableDirective,
TranslatePipe,
WebPlayerViewComponent,
],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class StalkerLiveStreamLayoutComponent implements OnDestroy {
private readonly route = inject(ActivatedRoute);
readonly stalkerStore = inject(StalkerStore);
private readonly settingsStore = inject(SettingsStore);
private readonly playlistService = inject(PlaylistsService);
private readonly stalkerSession = inject(StalkerSessionService);
private readonly playerService = inject(PlayerService);
private readonly snackBar = inject(MatSnackBar);
private readonly translate = inject(TranslateService);
private readonly logger = createLogger('StalkerLiveStream');
/** Categories */
readonly categories = this.stalkerStore.getCategoryResource;
readonly isCategoryLoading = this.stalkerStore.isCategoryResourceLoading;
readonly isCategoryFailed = this.stalkerStore.isCategoryResourceFailed;
readonly selectedCategoryTitle = this.stalkerStore.getSelectedCategoryName;
readonly currentPlaylist = this.stalkerStore.currentPlaylist;
readonly isWorkspaceLayout = isWorkspaceLayoutRoute(this.route);
/** Channels */
readonly itvChannels = this.stalkerStore.itvChannels;
readonly hasMoreItems = this.stalkerStore.hasMoreChannels;
readonly isLoadingMore = signal(false);
/** Search */
readonly searchString = signal('');
readonly selectedChannelId = this.stalkerStore.selectedItvId;
protected readonly normalizeStalkerEntityId = normalizeStalkerEntityId;
/** Player */
readonly player = this.settingsStore.player;
streamUrl = '';
/** EPG */
readonly epgItems = signal<EpgItem[]>([]);
readonly isLoadingEpg = signal(false);
readonly hasMoreEpg = signal(false);
private epgPageSize = 10;
private epgChannelId: number | string | null = null;
/** Channel list EPG preview */
readonly epgPreviewPrograms = new Map<string | number, EpgProgram>();
readonly currentProgramsProgress = new Map<string | number, number>();
private readonly requestedEpgChannels = new Set<string | number>();
private readonly cdr = inject(ChangeDetectorRef);
private readonly ngZone = inject(NgZone);
/** Favorites */
readonly favorites = new Map<string | number, boolean>();
/** Scroll */
readonly scrollContainer = viewChild<ElementRef>('scrollContainer');
private scrollListener: (() => void) | null = null;
private searchDebounceTimer: ReturnType<typeof setTimeout> | null = null;
private unsubscribeRemoteChannelChange?: () => void;
private unsubscribeRemoteCommand?: () => void;
constructor() {
// Load favorites for current playlist
this.playlistService
.getPortalFavorites(this.stalkerStore.currentPlaylist()?._id)
.pipe(takeUntilDestroyed())
.subscribe((favs) => {
favs.forEach((fav: StalkerFavoriteItem) => {
if (fav.id !== undefined) {
this.favorites.set(
normalizeStalkerEntityId(fav.id),
true
);
}
});
});
// Reset channels/page on category change
effect(() => {
this.stalkerStore.selectedCategoryId();
this.stalkerStore.setItvChannels([]);
this.stalkerStore.setPage(0);
this.clearEpgPreviewMaps();
});
// Reset loading state when channels load + check viewport fill + load EPG previews
effect(() => {
const channels = this.itvChannels();
if (channels.length > 0) {
this.isLoadingMore.set(false);
setTimeout(() => this.checkIfNeedsMoreContent(), 100);
this.loadEpgPreviewsForChannels(channels);
}
});
// Setup scroll listener when container becomes available
effect(() => {
const container = this.scrollContainer();
if (container) {
this.setupScrollListener();
}
});
// Debounced server-side search
effect(() => {
const search = this.searchString();
if (this.searchDebounceTimer)
clearTimeout(this.searchDebounceTimer);
this.searchDebounceTimer = setTimeout(() => {
this.stalkerStore.setItvChannels([]);
this.stalkerStore.setSearchPhrase(search);
}, 500);
});
effect(() => {
if (!window.electron?.updateRemoteControlStatus) {
return;
}
const selectedItem = this.stalkerStore.selectedItem();
const selectedType = this.stalkerStore.selectedContentType();
const channels = this.itvChannels();
const epgItems = this.epgItems();
if (selectedType !== 'itv' || !selectedItem?.id) {
window.electron.updateRemoteControlStatus({
portal: 'stalker',
isLiveView: false,
supportsVolume: false,
});
return;
}
const currentIndex = channels.findIndex(
(item) => Number(item.id) === Number(selectedItem.id)
);
const currentProgram = epgItems?.[0];
window.electron.updateRemoteControlStatus({
portal: 'stalker',
isLiveView: true,
channelName: selectedItem.o_name || selectedItem.name,
channelNumber: currentIndex >= 0 ? currentIndex + 1 : undefined,
epgTitle: currentProgram?.title,
epgStart: currentProgram?.start,
epgEnd: currentProgram?.end,
supportsVolume: false,
});
});
if (window.electron?.onChannelChange) {
const unsubscribe = window.electron.onChannelChange(
(data: { direction: 'up' | 'down' }) => {
this.handleRemoteChannelChange(data.direction);
}
);
if (typeof unsubscribe === 'function') {
this.unsubscribeRemoteChannelChange = unsubscribe;
}
}
if (window.electron?.onRemoteControlCommand) {
const unsubscribe = window.electron.onRemoteControlCommand(
(command) => {
this.handleRemoteControlCommand(command);
}
);
if (typeof unsubscribe === 'function') {
this.unsubscribeRemoteCommand = unsubscribe;
}
}
}
ngOnDestroy() {
this.unsubscribeRemoteChannelChange?.();
this.unsubscribeRemoteCommand?.();
this.removeScrollListener();
if (this.searchDebounceTimer) {
clearTimeout(this.searchDebounceTimer);
}
}
selectCategory(item: StalkerCategoryItem) {
this.stalkerStore.setSelectedCategory(item.category_id || '*');
this.stalkerStore.setPage(0);
}
backToCategories() {
this.searchString.set('');
this.stalkerStore.setSearchPhrase('');
this.stalkerStore.setSelectedCategory(null);
}
isSelectedChannel(item: StalkerItvChannel): boolean {
return (
this.selectedChannelId() === this.normalizeStalkerEntityId(item.id)
);
}
async playChannel(item: StalkerItvChannel) {
this.stalkerStore.setSelectedItem(item);
const isEmbeddedPlayer =
this.player() === 'videojs' ||
this.player() === 'html5' ||
this.player() === 'artplayer';
try {
const playback = await this.stalkerStore.resolveItvPlayback(item);
this.loadEpgForChannel(item.id);
if (isEmbeddedPlayer) {
this.streamUrl = playback.streamUrl;
} else {
void this.playerService.openResolvedPlayback(playback, true);
}
} catch (error) {
this.logger.error('Playback failed', error);
const errorMessage =
error?.message === 'nothing_to_play'
? this.translate.instant('PORTALS.CONTENT_NOT_AVAILABLE')
: this.translate.instant('PORTALS.PLAYBACK_ERROR');
this.snackBar.open(errorMessage, null, { duration: 3000 });
}
}
toggleFavorite(item: StalkerItvChannel) {
const itemId = normalizeStalkerEntityId(item.id);
if (this.favorites.has(itemId)) {
this.stalkerStore.removeFromFavorites(itemId);
this.favorites.delete(itemId);
} else {
this.stalkerStore.addToFavorites({
...item,
category_id: 'itv',
title: item.o_name || item.name,
cover: item.logo,
added_at: new Date().toISOString(),
});
this.favorites.set(itemId, true);
}
}
loadMore() {
if (this.isLoadingMore() || !this.hasMoreItems()) return;
this.isLoadingMore.set(true);
const nextPage = this.stalkerStore.page() + 1;
this.stalkerStore.setPage(nextPage);
}
async loadMoreEpg() {
if (!this.epgChannelId || this.isLoadingEpg()) return;
this.epgPageSize += 10;
this.isLoadingEpg.set(true);
try {
const items = await this.stalkerStore.fetchChannelEpg(
this.epgChannelId,
this.epgPageSize
);
this.epgItems.set(items);
this.hasMoreEpg.set(items.length >= this.epgPageSize);
} catch {
this.hasMoreEpg.set(false);
} finally {
this.isLoadingEpg.set(false);
}
}
private async loadEpgForChannel(channelId: number | string) {
this.epgChannelId = channelId;
this.epgPageSize = 10;
this.isLoadingEpg.set(true);
this.epgItems.set([]);
this.hasMoreEpg.set(false);
try {
const items = await this.stalkerStore.fetchChannelEpg(
channelId,
this.epgPageSize
);
this.epgItems.set(items);
this.hasMoreEpg.set(items.length >= this.epgPageSize);
} catch {
this.epgItems.set([]);
} finally {
this.isLoadingEpg.set(false);
}
}
private async loadEpgPreviewsForChannels(channels: StalkerItvChannel[]) {
const newChannels = channels.filter(
(ch) =>
ch.id &&
!this.requestedEpgChannels.has(normalizeStalkerEntityId(ch.id))
);
if (newChannels.length === 0) return;
// Mark all as requested immediately to avoid duplicates
for (const ch of newChannels) {
this.requestedEpgChannels.add(normalizeStalkerEntityId(ch.id));
}
// Process in batches of 3 with a small delay between batches
const batchSize = 3;
for (let i = 0; i < newChannels.length; i += batchSize) {
const batch = newChannels.slice(i, i + batchSize);
await Promise.all(
batch.map((ch) => this.loadSingleEpgPreview(ch.id))
);
// Re-enter Angular zone to trigger change detection for OnPush
this.ngZone.run(() => this.cdr.markForCheck());
// Small delay between batches to avoid overwhelming the portal
if (i + batchSize < newChannels.length) {
await new Promise((r) => setTimeout(r, 150));
}
}
}
private async loadSingleEpgPreview(channelId: number | string) {
try {
const items = await this.stalkerStore.fetchChannelEpg(channelId, 1);
if (items.length > 0) {
const program = items[0];
const id = normalizeStalkerEntityId(channelId);
this.epgPreviewPrograms.set(
id,
this.toPreviewProgram(program, id)
);
const now = Date.now() / 1000;
const start = parseInt(program.start_timestamp, 10);
const end = parseInt(program.stop_timestamp, 10);
if (start && end && now >= start && now <= end) {
const progress = ((now - start) / (end - start)) * 100;
this.currentProgramsProgress.set(id, progress);
} else {
this.currentProgramsProgress.delete(id);
}
}
} catch {
// Silently skip — channel just won't show EPG preview
}
}
private clearEpgPreviewMaps() {
this.epgPreviewPrograms.clear();
this.currentProgramsProgress.clear();
this.requestedEpgChannels.clear();
}
private setupScrollListener() {
this.removeScrollListener();
const container = this.scrollContainer()?.nativeElement;
if (!container) return;
const onScroll = () => {
if (this.isLoadingMore() || !this.hasMoreItems()) return;
const { scrollTop, scrollHeight, clientHeight } = container;
const scrollThreshold = 150;
if (scrollHeight - scrollTop - clientHeight <= scrollThreshold) {
this.loadMore();
}
};
container.addEventListener('scroll', onScroll, { passive: true });
this.scrollListener = () =>
container.removeEventListener('scroll', onScroll);
}
private checkIfNeedsMoreContent() {
const container = this.scrollContainer()?.nativeElement;
if (!container) return;
if (this.isLoadingMore() || !this.hasMoreItems()) return;
const { scrollHeight, clientHeight } = container;
if (scrollHeight <= clientHeight) {
this.loadMore();
}
}
private removeScrollListener() {
if (this.scrollListener) {
this.scrollListener();
this.scrollListener = null;
}
}
private toPreviewProgram(
item: EpgItem,
channelId: string | number
): EpgProgram {
return {
start: item.start,
stop: item.stop || item.end,
channel: String(channelId),
title: item.title,
desc: item.description || null,
category: null,
};
}
private handleRemoteChannelChange(direction: 'up' | 'down'): void {
const activeItem = this.stalkerStore.selectedItem();
if (!activeItem?.id) {
return;
}
const channels = this.itvChannels();
const nextItem = getAdjacentChannelItem(
channels,
activeItem.id,
direction,
(item) => item.id
);
if (!nextItem) {
return;
}
void this.playChannel(nextItem);
}
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) {
return;
}
const channel = getChannelItemByNumber(
this.itvChannels(),
command.number
);
if (!channel) {
return;
}
void this.playChannel(channel);
}
}
@@ -1,50 +0,0 @@
<app-search-layout
[searchTerm]="searchTerm()"
[resultsCount]="resultsCount"
[isLoading]="searchResultsResource.isLoading()"
[showResultsCount]="true"
[showSearchInput]="!isWorkspaceLayout"
[showDetails]="showingDetails"
(searchTermChange)="updateSearchTerm($event)"
>
@if (showingDetails) {
<app-stalker-inline-detail
details
[categoryId]="inlineDetail().categoryId"
[seriesItem]="inlineDetail().seriesItem"
[isSeries]="inlineDetail().isSeries"
[vodDetailsItem]="inlineDetail().vodDetailsItem"
[isFavorite]="isSelectedVodFavorite()"
(playClicked)="onVodPlay($event)"
(favoriteToggled)="onVodFavoriteToggled($event)"
(backClicked)="onVodBack()"
/>
}
<!-- Filters (single selection mode for stalker) -->
<ng-container filters>
@for (filter of filterConfig; track filter.key) {
<mat-checkbox
[ngModel]="filters()[filter.key]"
(ngModelChange)="updateFilter(filter.key, $event)"
>
{{ filter.translationKey | translate }}
</mat-checkbox>
}
</ng-container>
<!-- Results -->
<ng-container results>
<div class="results-grid">
@for (item of searchResultsResource.value(); track item.id) {
<app-content-card
[title]="item.o_name || item.name"
[posterUrl]="item.screenshot_uri"
[type]="selectedFilterType() === 'vod' ? 'movie' : 'series'"
[showPlaceholder]="true"
(cardClick)="selectItem(item)"
/>
}
</div>
</ng-container>
</app-search-layout>
@@ -1,20 +0,0 @@
@use '../../shared/styles/content-grid' as grid;
:host {
display: flex;
flex-direction: column;
overflow-y: auto;
height: 100%;
width: 100%;
}
.details-view {
height: 100%;
width: 100%;
overflow-y: auto;
}
.results-grid {
@include grid.content-grid(140px, 20px);
padding-bottom: 1rem;
}
@@ -1,345 +0,0 @@
import { Component, effect, inject, resource, signal } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { MatCheckboxModule } from '@angular/material/checkbox';
import { ActivatedRoute } from '@angular/router';
import { Store } from '@ngrx/store';
import { TranslatePipe } from '@ngx-translate/core';
import { selectPlaylistById } from 'm3u-state';
import { DataService, PlaylistsService, StalkerSessionService } from 'services';
import {
Playlist,
STALKER_REQUEST,
StalkerPortalActions,
VodDetailsItem,
} from 'shared-interfaces';
import { ContentCardComponent } from '@iptvnator/portal/shared/ui';
import { SearchLayoutComponent } from '@iptvnator/portal/shared/ui';
import { StalkerInlineDetailComponent } from '../stalker-inline-detail/stalker-inline-detail.component';
import { StalkerContentTypes } from '@iptvnator/portal/stalker/data-access';
import { StalkerStore } from '@iptvnator/portal/stalker/data-access';
import {
isWorkspaceLayoutRoute,
queryParamSignal,
} from '@iptvnator/portal/shared/util';
import { createLogger } from '@iptvnator/portal/shared/util';
import {
StalkerSelectedVodItem,
StalkerVodSource,
} from '@iptvnator/portal/stalker/data-access';
import {
buildStalkerSelectedVodItem,
clearStalkerDetailViewState,
createStalkerInlineDetailState,
createPortalFavoritesResource,
createRefreshTrigger,
createStalkerDetailViewState,
isSelectedStalkerVodFavorite,
isStalkerSeriesFlag,
toggleStalkerVodFavorite,
} from '@iptvnator/portal/stalker/data-access';
interface StalkerFilter {
key: string;
label: string;
translationKey: string;
}
@Component({
selector: 'app-stalker-search',
imports: [
ContentCardComponent,
FormsModule,
MatCheckboxModule,
SearchLayoutComponent,
StalkerInlineDetailComponent,
TranslatePipe,
],
templateUrl: './stalker-search.component.html',
styleUrl: './stalker-search.component.scss',
})
export class StalkerSearchComponent {
private readonly activatedRoute = inject(ActivatedRoute);
private readonly dataService = inject(DataService);
private readonly playlistService = inject(PlaylistsService);
private readonly stalkerStore = inject(StalkerStore);
private readonly stalkerSession = inject(StalkerSessionService);
private readonly store = inject(Store);
private readonly logger = createLogger('StalkerSearch');
readonly filters = signal({
series: false,
vod: true,
});
readonly isWorkspaceLayout = isWorkspaceLayoutRoute(this.activatedRoute);
readonly filterConfig: StalkerFilter[] = [
{
key: 'vod',
label: 'Movies',
translationKey: 'PORTALS.SIDEBAR.MOVIES',
},
{
key: 'series',
label: 'Series',
translationKey: 'PORTALS.SIDEBAR.SERIES',
},
];
readonly searchTerm = signal('');
readonly routeSearchTerm = queryParamSignal(
this.activatedRoute,
'q',
(value) => (value ?? '').trim()
);
private readonly currentPlaylist = this.store.selectSignal(
selectPlaylistById(this.activatedRoute?.parent?.snapshot.params.id)
);
readonly selectedFilterType = signal('vod');
private readonly favoritesRefresh = createRefreshTrigger();
itemDetails: StalkerSelectedVodItem | null = null;
vodDetailsItem: VodDetailsItem | null = null;
readonly portalFavorites = createPortalFavoritesResource(
this.playlistService,
() => this.currentPlaylist()?._id,
() => this.favoritesRefresh.refreshVersion()
);
readonly searchResultsResource = resource({
params: () => ({
contentType: this.selectedFilterType(),
search: this.searchTerm(),
action: StalkerPortalActions.GetOrderedList,
}),
loader: async ({ params }) => {
if (params.search.length < 3) {
return [];
}
const playlist = this.currentPlaylist();
if (!playlist) return [];
const { portalUrl, macAddress } = playlist;
let token: string | undefined;
let serialNumber: string | undefined;
if ((playlist as Playlist).isFullStalkerPortal) {
try {
const result = await this.stalkerSession.ensureToken(
playlist as Playlist
);
token = result.token ?? undefined;
serialNumber = (playlist as Playlist).stalkerSerialNumber;
} catch (error) {
this.logger.error('Failed to get stalker token', error);
}
}
const response = await this.dataService.sendIpcEvent(
STALKER_REQUEST,
{
url: portalUrl,
macAddress,
params: {
action: StalkerContentTypes[params.contentType]
.getContentAction,
type: params.contentType,
search: params.search,
max_page_items: 100,
},
token,
serialNumber,
}
);
if (response) {
const items = response.js?.data || [];
return items.map((item: StalkerVodSource) =>
this.processItemUrls(item, portalUrl)
);
} else {
throw new Error(
`Error: ${response.message} (Status: ${response.status})`
);
}
},
});
readonly isSelectedVodFavorite = signal<boolean>(false);
constructor() {
effect(() => {
const routeTerm = this.routeSearchTerm();
if (routeTerm !== this.searchTerm()) {
this.searchTerm.set(routeTerm);
}
});
effect(() => {
// Re-evaluate favorite state whenever favorites resource changes.
this.portalFavorites.value();
this.syncSelectedVodFavorite();
});
}
/** Check if showing item details */
get showingDetails(): boolean {
return this.inlineDetail().categoryId !== null;
}
/** Get results count for layout */
get resultsCount(): number {
return this.searchResultsResource.value()?.length ?? 0;
}
createLinkToPlayVodItv(cmd?: string, title?: string, thumbnail?: string) {
this.stalkerStore.createLinkToPlayVod(cmd, title, thumbnail);
}
updateSearchTerm(term: string) {
this.searchTerm.set(term);
}
updateFilter(key: string, value: boolean) {
if (value) {
// Single selection mode - set clicked filter, disable others
this.selectedFilterType.set(key);
this.filters.update((f) => {
const newFilters: Record<string, boolean> = {};
Object.keys(f).forEach((k) => {
newFilters[k] = k === key;
});
return newFilters as typeof f;
});
}
}
selectItem(item: StalkerVodSource) {
const hasEmbeddedSeries = item.series?.length > 0;
const needsSeriesFetch =
this.selectedFilterType() === 'vod' &&
!hasEmbeddedSeries &&
isStalkerSeriesFlag(item.is_series);
this.itemDetails = buildStalkerSelectedVodItem(item, needsSeriesFetch);
this.stalkerStore.setSelectedItem(this.itemDetails);
switch (this.selectedFilterType()) {
case 'vod':
this.stalkerStore.setSelectedContentType('vod');
if (!hasEmbeddedSeries && !needsSeriesFetch) {
const detailViewState = createStalkerDetailViewState(
this.itemDetails,
this.currentPlaylist()?._id ?? ''
);
this.itemDetails = detailViewState.itemDetails;
this.vodDetailsItem = detailViewState.vodDetailsItem;
this.syncSelectedVodFavorite();
} else {
const cleared = clearStalkerDetailViewState();
this.vodDetailsItem = cleared.vodDetailsItem;
this.isSelectedVodFavorite.set(false);
}
break;
case 'series':
this.stalkerStore.setSelectedContentType('series');
break;
default:
break;
}
}
onVodPlay(item: VodDetailsItem): void {
if (item.type === 'stalker') {
this.createLinkToPlayVodItv(
item.cmd,
item.data.info?.name,
item.data.info?.movie_image
);
}
}
onVodFavoriteToggled(event: {
item: VodDetailsItem;
isFavorite: boolean;
}): void {
toggleStalkerVodFavorite(event, {
addToFavorites: (item, onDone) => this.addToFavorites(item, onDone),
removeFromFavorites: (favoriteId, onDone) =>
this.removeFromFavorites(favoriteId, onDone),
onComplete: () => {
this.favoritesRefresh.refresh();
this.syncSelectedVodFavorite();
},
});
}
onVodBack(): void {
const cleared = clearStalkerDetailViewState();
this.itemDetails = cleared.itemDetails;
this.vodDetailsItem = cleared.vodDetailsItem;
this.isSelectedVodFavorite.set(false);
}
removeFromFavorites(favoriteId: string, onDone?: () => void) {
this.stalkerStore.removeFromFavorites(favoriteId, onDone);
}
addToFavorites(item: Record<string, unknown>, onDone?: () => void) {
this.stalkerStore.addToFavorites(item, onDone);
}
private syncSelectedVodFavorite(): void {
const item = this.vodDetailsItem;
this.isSelectedVodFavorite.set(
isSelectedStalkerVodFavorite(
item,
this.portalFavorites.value() ?? []
)
);
}
inlineDetail() {
return createStalkerInlineDetailState(
this.itemDetails,
this.vodDetailsItem,
this.selectedFilterType() === 'series' ? 'series' : 'vod'
);
}
private processItemUrls(
item: StalkerVodSource,
portalUrl: string
): StalkerVodSource {
const processed = { ...item };
if (processed.screenshot_uri) {
processed.screenshot_uri = this.makeAbsoluteUrl(
portalUrl,
processed.screenshot_uri
);
}
return processed;
}
private makeAbsoluteUrl(baseUrl: string, relativePath: string): string {
if (!relativePath) return '';
if (
relativePath.startsWith('http://') ||
relativePath.startsWith('https://')
) {
return relativePath;
}
try {
const url = new URL(baseUrl);
const path = relativePath.startsWith('/')
? relativePath
: `/${relativePath}`;
return `${url.origin}${path}`;
} catch {
return relativePath;
}
}
}
@@ -1,88 +0,0 @@
@if (displayItem(); as serial) {
<app-content-hero
[title]="serial.info.name"
[description]="serial.info.description"
[posterUrl]="serial.info.movie_image"
(backClicked)="goBack()"
>
<!-- Tags: Year, Genre, Rating -->
<ng-container hero-tags>
@if (serial.info.releasedate) {
<span class="details__tag">{{
serial.info.releasedate
}}</span>
}
@if (serial.info.genre) {
<span class="details__tag">{{
serial.info.genre
}}</span>
}
@if (serial.info.rating_imdb) {
<span class="details__tag details__tag--rating">
⭐ {{ serial.info.rating_imdb }}
</span>
}
@if (serial.info.rating_kinopoisk) {
<span class="details__tag">
KP {{ serial.info.rating_kinopoisk }}
</span>
}
</ng-container>
<!-- Metadata Grid -->
<ng-container hero-meta>
@if (serial.info.actors) {
<div class="details__meta-item">
<span class="label">{{
'XTREAM.ACTORS' | translate
}}</span>
<span class="value">{{ serial.info.actors }}</span>
</div>
}
@if (serial.info.director) {
<div class="details__meta-item">
<span class="label">{{
'XTREAM.DIRECTOR' | translate
}}</span>
<span class="value">{{ serial.info.director }}</span>
</div>
}
</ng-container>
<!-- Action Buttons -->
<ng-container hero-actions>
<app-favorites-button
[itemId]="serial.id"
[item]="serial"
/>
</ng-container>
@if (inlinePlayback(); as playback) {
<app-portal-inline-player
[playback]="playback"
(timeUpdate)="handleInlineTimeUpdate($event)"
(closed)="closeInlinePlayer()"
(streamUrlCopied)="showCopyNotification()"
/>
}
<!-- Seasons & Episodes -->
@if (mappedSeasons()) {
<app-season-container
[seasons]="mappedSeasons()"
[seriesId]="toSeriesId(serial.id)"
[seriesTitle]="serial.info?.name"
[openingEpisodeId]="openingEpisodeId()"
[activeEpisodeId]="activeEpisodeId()"
[isLoading]="
isVodSeriesSeasonsLoading() ||
(seasonContainer?.selectedSeason &&
isCurrentSeasonLoading(seasonContainer.selectedSeason))
"
(seasonSelected)="onSeasonSelected($event)"
(episodeClicked)="onEpisodeClicked($event)"
#seasonContainer
/>
}
</app-content-hero>
}
@@ -1,472 +0,0 @@
import {
Component,
computed,
effect,
inject,
input,
output,
signal,
} from '@angular/core';
import { MatSnackBar } from '@angular/material/snack-bar';
import { TranslatePipe, TranslateService } from '@ngx-translate/core';
import { FavoritesButtonComponent } from '../favorites-button/favorites-button.component';
import { ContentHeroComponent } from 'components';
import { StalkerStore } from '@iptvnator/portal/stalker/data-access';
import { SeasonContainerComponent } from '../../portal-shared/season-container/season-container.component';
import {
ResolvedPortalPlayback,
XtreamSerieEpisode,
} from 'shared-interfaces';
import { XtreamStore } from '@iptvnator/portal/xtream/data-access';
import { createLogger } from '@iptvnator/portal/shared/util';
import {
getVodSeriesSeasonKey,
isVodSeriesItem,
mapRegularSeriesEpisodes,
mapRegularSeriesSeasons,
mapVodSeriesEpisodes,
mapVodSeriesSeasonsToVm,
StalkerMappedEpisode,
StalkerSeriesSeasonVm,
VodSeriesSeasonVm,
} from '@iptvnator/portal/stalker/data-access';
import { StalkerSelectedVodItem, StalkerVodSource } from '@iptvnator/portal/stalker/data-access';
import { normalizeStalkerVodDetailsItem } from '@iptvnator/portal/stalker/data-access';
import { PlayerService } from '../../services/player.service';
import { ExternalPlaybackService } from '../../services/external-playback.service';
import { PortalInlinePlayerComponent } from '../../shared/components/portal-inline-player/portal-inline-player.component';
/**
* Component for displaying series/episodes for Stalker portal content.
* Supports three modes:
* 1. Regular series (type=series): Fetches seasons from API via serialSeasonsResource
* 2. VOD with embedded series (vclub): Uses the series array from the vodWithSeries input
* 3. VOD series (Ministra is_series=1): Fetches seasons/episodes using movie_id and season_id
*/
@Component({
selector: 'app-stalker-series-view',
templateUrl: './stalker-series-view.component.html',
styleUrls: ['../../portal-shared/detail-view.scss'],
imports: [
FavoritesButtonComponent,
ContentHeroComponent,
PortalInlinePlayerComponent,
TranslatePipe,
SeasonContainerComponent,
],
})
export class StalkerSeriesViewComponent {
readonly stalkerStore = inject(StalkerStore);
private readonly xtreamStore = inject(XtreamStore);
private readonly playerService = inject(PlayerService);
private readonly externalPlayback = inject(ExternalPlaybackService);
private readonly snackBar = inject(MatSnackBar);
private readonly translateService = inject(TranslateService);
readonly backClicked = output<void>();
private readonly logger = createLogger('StalkerSeriesView');
readonly inlinePlayback = signal<ResolvedPortalPlayback | null>(null);
private lastSaveTime = 0;
readonly openingEpisodeId = signal<number | null>(null);
readonly activeEpisodeId = signal<number | null>(null);
/**
* Optional input for VOD items with embedded series array (vclub mode)
* When provided, uses this instead of fetching seasons from API
*/
readonly vodWithSeries = input<StalkerVodSource | null>(null);
readonly selectedItem = this.stalkerStore.selectedItem;
/**
* Track VOD series seasons with their loaded episodes
*/
readonly vodSeriesSeasons = signal<VodSeriesSeasonVm[]>([]);
/**
* Indicates if this is a VOD series item (Ministra is_series=1)
* Note: is_series can be true, 1, or "1" depending on the source
*/
readonly isVodSeries = computed(() => {
return (
this.stalkerStore.selectedContentType() === 'vod' &&
isVodSeriesItem(this.displayItem())
);
});
/**
* Loading state for VOD series seasons
*/
readonly isVodSeriesSeasonsLoading =
this.stalkerStore.isVodSeriesSeasonsLoading;
/**
* Loading state for regular series seasons
*/
readonly isSerialSeasonsLoading = this.stalkerStore.isSerialSeasonsLoading;
constructor() {
// Effect to load VOD series seasons when a VOD series item is selected
effect(() => {
if (this.isVodSeries()) {
// Get seasons from the resource
const seasons = this.stalkerStore.getVodSeriesSeasonsResource();
this.vodSeriesSeasons.set(mapVodSeriesSeasonsToVm(seasons));
} else {
this.vodSeriesSeasons.set([]);
}
});
// Effect to load playback positions for Stalker series
effect(() => {
const item = this.displayItem();
const playlist = this.stalkerStore.currentPlaylist();
if (item && playlist?._id) {
const seriesId = Number(item.id);
const normalizedSeriesId = this.toSeriesId(item.id);
this.logger.debug(
'Loading positions for series',
{
id: item.id,
seriesId: normalizedSeriesId,
isSeries: item.is_series,
}
);
if (!isNaN(normalizedSeriesId)) {
// Load playback positions from the XtreamStore (works for any playlist type)
this.xtreamStore.loadSeriesPositions(
playlist._id,
normalizedSeriesId
);
}
}
});
effect(() => {
const session = this.externalPlayback.activeSession();
const item = this.displayItem();
const playlistId = this.stalkerStore.currentPlaylist()?._id;
const seriesId = item ? this.toSeriesId(item.id) : 0;
if (
!session?.contentInfo ||
!playlistId ||
!seriesId ||
session.contentInfo.contentType !== 'episode' ||
session.contentInfo.playlistId !== playlistId ||
session.contentInfo.seriesXtreamId !== seriesId
) {
this.openingEpisodeId.set(null);
this.activeEpisodeId.set(null);
return;
}
if (session.status === 'launching') {
this.openingEpisodeId.set(session.contentInfo.contentXtreamId);
this.activeEpisodeId.set(null);
return;
}
if (session.status === 'opened' || session.status === 'playing') {
this.openingEpisodeId.set(null);
this.activeEpisodeId.set(session.contentInfo.contentXtreamId);
return;
}
this.openingEpisodeId.set(null);
this.activeEpisodeId.set(null);
});
}
/**
* For VOD with embedded series, we create a single "season" with the episodes
* For regular series, we use the API-fetched seasons
*/
readonly regularSeasons = computed<StalkerSeriesSeasonVm[]>(() =>
mapRegularSeriesSeasons(
this.vodWithSeries(),
this.stalkerStore.getSerialSeasonsResource()
)
);
/**
* Get the item to display details for (either vodWithSeries or selectedItem from store)
*/
readonly displayItem = computed<StalkerSelectedVodItem | null>(() => {
const item = this.vodWithSeries() || this.selectedItem();
return item ? normalizeStalkerVodDetailsItem(item) : null;
});
/**
* Adapts both Regular and VOD series data into the format expected by SeasonContainerComponent.
* Record<string, XtreamSerieEpisode[]> where string is season number/name.
*/
readonly mappedSeasons = computed<Record<string, XtreamSerieEpisode[]>>(
() => {
if (this.isVodSeries()) {
return mapVodSeriesEpisodes(
this.vodSeriesSeasons(),
this.displayItem()?.info?.movie_image
);
}
return mapRegularSeriesEpisodes(
this.regularSeasons(),
this.displayItem()?.info?.movie_image
);
}
);
/**
* Handles season selection from the container.
* For VOD Series, triggers lazy loading of episodes.
*/
onSeasonSelected(seasonKey: string) {
if (!this.isVodSeries()) return;
const seasons = this.vodSeriesSeasons();
const season = seasons.find(
(s) => getVodSeriesSeasonKey(s) === seasonKey
);
if (season && season.episodes.length === 0) {
this.loadEpisodesForSeason(season);
}
}
/**
* Loads episodes for a specific VOD season
*/
async loadEpisodesForSeason(season: VodSeriesSeasonVm) {
// Set loading state in local signal
const seasons = this.vodSeriesSeasons();
const index = seasons.findIndex((s) => s.id === season.id);
if (index === -1) return;
const updatedSeasons = [...seasons];
updatedSeasons[index] = { ...updatedSeasons[index], isLoading: true };
this.vodSeriesSeasons.set(updatedSeasons);
try {
const episodes = await this.stalkerStore.fetchVodSeriesEpisodes(
season.video_id,
season.id
);
// Update with loaded episodes
const newSeasons = [...this.vodSeriesSeasons()];
const newIndex = newSeasons.findIndex((s) => s.id === season.id);
if (newIndex !== -1) {
newSeasons[newIndex] = {
...newSeasons[newIndex],
episodes: episodes,
isLoading: false,
};
this.vodSeriesSeasons.set(newSeasons);
}
} catch (error) {
this.logger.error('Failed to load episodes', error);
const newSeasons = [...this.vodSeriesSeasons()];
const newIndex = newSeasons.findIndex((s) => s.id === season.id);
if (newIndex !== -1) {
newSeasons[newIndex] = {
...newSeasons[newIndex],
isLoading: false,
};
this.vodSeriesSeasons.set(newSeasons);
}
}
}
/**
* Determines if the current selected season is loading
*/
isCurrentSeasonLoading(seasonKey: string): boolean {
if (!this.isVodSeries()) return false;
const season = this.vodSeriesSeasons().find(
(s) => getVodSeriesSeasonKey(s) === seasonKey
);
return season?.isLoading ?? false;
}
/**
* Handles episode click from the container
*/
onEpisodeClicked(episode: XtreamSerieEpisode) {
const mappedEpisode = episode as StalkerMappedEpisode;
if (mappedEpisode.custom_sid === 'vod-series') {
// It's a VOD series episode (is_series=1 mode)
// Use originalId for playback URL, but use generated id for tracking
this.playVodSeriesEpisode({
originalId: mappedEpisode.originalId, // For constructing playback URL
trackingId: episode.id, // The generated unique ID for position tracking
name: episode.title,
series_number: episode.episode_num,
});
} else {
// Regular series or vclub mode (VOD with embedded series array)
// Use originalCmd for playback, episode.id for tracking
const trackingId = Number(episode.id);
this.playEpisodeClicked(
episode.episode_num,
mappedEpisode.originalCmd,
trackingId
);
}
}
/**
* Play episode - handles regular series and vclub mode
*/
playEpisodeClicked(episodeNum: number, cmd?: string, trackingId?: number) {
const item = this.displayItem();
if (!item) return;
this.logger.debug('playEpisodeClicked', {
episodeNum,
cmd,
trackingId,
seriesId: item.id,
});
// Get playback position from XtreamStore if available
const position = trackingId
? this.xtreamStore.playbackPositions().get(`episode_${trackingId}`)
: undefined;
const startTime = position?.positionSeconds;
void this.startPlayback(
cmd,
item.info.name,
item.info.movie_image,
episodeNum,
trackingId,
startTime
);
}
/**
* Play VOD series episode
*/
playVodSeriesEpisode(episode: {
originalId?: string;
trackingId: string;
name: string;
series_number: number;
}) {
const item = this.displayItem();
if (!item) return;
// Use originalId for playback URL (this is what the Stalker API expects)
const cmd = `/media/file_${episode.originalId ?? ''}.mpg`;
const episodeName = episode.name || `Episode ${episode.series_number}`;
// Use trackingId (generated unique ID) for playback position tracking
const trackingId = Number(episode.trackingId);
this.logger.debug('playVodSeriesEpisode', {
originalId: episode.originalId,
trackingId,
seriesId: item.id,
episodeName,
});
// Get playback position from XtreamStore if available
const position = this.xtreamStore
.playbackPositions()
.get(`episode_${trackingId}`);
const startTime = position?.positionSeconds;
void this.startPlayback(
cmd,
`${item.info.name} - ${episodeName}`,
item.info.movie_image,
episode.series_number,
trackingId,
startTime
);
}
goBack() {
this.closeInlinePlayer();
this.backClicked.emit();
this.stalkerStore.clearSelectedItem();
}
toSeriesId(id: string | number): number {
const raw = String(id ?? '').trim();
if (!raw) return 0;
const primary = raw.includes(':') ? raw.split(':')[0] : raw;
const parsed = Number(primary);
return Number.isFinite(parsed) ? parsed : 0;
}
closeInlinePlayer(): void {
this.inlinePlayback.set(null);
this.lastSaveTime = 0;
}
handleInlineTimeUpdate(event: {
currentTime: number;
duration: number;
}): void {
const playback = this.inlinePlayback();
if (!playback?.contentInfo) return;
const now = Date.now();
if (now - this.lastSaveTime <= 15000) return;
this.lastSaveTime = now;
void this.xtreamStore.savePosition(playback.contentInfo.playlistId, {
...playback.contentInfo,
positionSeconds: Math.floor(event.currentTime),
durationSeconds: Math.floor(event.duration),
});
}
showCopyNotification(): void {
this.snackBar.open(
this.translateService.instant('PORTALS.STREAM_URL_COPIED'),
null,
{
duration: 2000,
}
);
}
private async startPlayback(
cmd?: string,
title?: string,
thumbnail?: string,
episodeNum?: number,
episodeId?: number,
startTime?: number
): Promise<void> {
try {
const playback = await this.stalkerStore.resolveVodPlayback(
cmd,
title,
thumbnail,
episodeNum,
episodeId,
startTime
);
this.lastSaveTime = 0;
if (this.playerService.isEmbeddedPlayer()) {
this.inlinePlayback.set(playback);
return;
}
this.closeInlinePlayer();
void this.playerService.openResolvedPlayback(playback, true);
} catch (error) {
this.logger.error('Failed to start inline series playback', error);
const errorMessage =
error instanceof Error &&
error.message === 'nothing_to_play'
? this.translateService.instant(
'PORTALS.CONTENT_NOT_AVAILABLE'
)
: this.translateService.instant('PORTALS.PLAYBACK_ERROR');
this.snackBar.open(errorMessage, null, {
duration: 3000,
});
}
}
}
@@ -1,161 +0,0 @@
<section class="dashboard-page">
<!-- ─── Hero ─────────────────────────────────────────────────────────── -->
<header class="hero">
<div class="hero-content">
<div class="hero-text">
<p class="eyebrow">
<span class="eyebrow-dot"></span>
Workspace
</p>
<h1>Your IPTV sources,<br />one place.</h1>
<p class="description">
Jump back into your active source, switch providers
instantly, or explore what's new across your library.
</p>
<div class="hero-actions">
@if (activePlaylist(); as active) {
<a
mat-flat-button
[routerLink]="getPlaylistLink(active)"
>
<mat-icon>play_arrow</mat-icon>
Continue with {{ active.title }}
</a>
} @else {
<a mat-flat-button routerLink="/workspace/sources">
<mat-icon>library_books</mat-icon>
Open Sources
</a>
}
<a mat-stroked-button routerLink="/settings">
<mat-icon>settings</mat-icon>
Settings
</a>
</div>
</div>
<div class="hero-visual" aria-hidden="true">
<div class="hero-icon-ring">
<mat-icon>tv</mat-icon>
</div>
</div>
</div>
</header>
<!-- ─── Stats Row ─────────────────────────────────────────────────────── -->
<div class="stat-grid">
<article class="stat-card stat-card--total">
<div class="stat-card__icon">
<mat-icon>folder_open</mat-icon>
</div>
<div class="stat-card__body">
<span class="stat-card__value">{{ stats().total }}</span>
<span class="stat-card__label">Total Sources</span>
</div>
</article>
<article class="stat-card stat-card--xtream">
<div class="stat-card__icon">
<mat-icon>stream</mat-icon>
</div>
<div class="stat-card__body">
<span class="stat-card__value">{{ stats().xtream }}</span>
<span class="stat-card__label">Xtream</span>
</div>
</article>
<article class="stat-card stat-card--stalker">
<div class="stat-card__icon">
<mat-icon>router</mat-icon>
</div>
<div class="stat-card__body">
<span class="stat-card__value">{{ stats().stalker }}</span>
<span class="stat-card__label">Stalker</span>
</div>
</article>
<article class="stat-card stat-card--m3u">
<div class="stat-card__icon">
<mat-icon>playlist_play</mat-icon>
</div>
<div class="stat-card__body">
<span class="stat-card__value">{{ stats().m3u }}</span>
<span class="stat-card__label">M3U</span>
</div>
</article>
</div>
<!-- ─── Recent Sources ────────────────────────────────────────────────── -->
<section class="sources-section">
<div class="section-header">
<h2>Recent Sources</h2>
<a routerLink="/workspace/sources" class="section-link">
<mat-icon>arrow_forward</mat-icon>
View all
</a>
</div>
@if (recentPlaylists().length > 0) {
<div class="sources-grid">
@for (
playlist of recentPlaylists();
track trackByPlaylistId($index, playlist)
) {
<a
class="source-card"
[routerLink]="getPlaylistLink(playlist)"
>
<div class="source-card__header">
<span
class="provider-badge"
[class]="
'provider-badge--' +
getPlaylistProvider(playlist).toLowerCase()
"
>
{{ getPlaylistProvider(playlist) }}
</span>
@if (playlist.count) {
<span class="source-card__count">
{{ playlist.count | number }} ch
</span>
}
</div>
<h3
[attr.title]="
playlist.title ||
playlist.filename ||
'Untitled source'
"
>
{{
playlist.title ||
playlist.filename ||
'Untitled source'
}}
</h3>
<p class="source-card__date">
<mat-icon>schedule</mat-icon>
{{
formatTimestamp(
playlist.updateDate || playlist.importDate
)
}}
</p>
</a>
}
</div>
} @else {
<article class="empty-state">
<div class="empty-state__icon">
<mat-icon>library_add</mat-icon>
</div>
<h3>No sources yet</h3>
<p>Add a playlist in Library to start using the workspace.</p>
<a mat-flat-button routerLink="/workspace/sources">
<mat-icon>add</mat-icon>
Add Source
</a>
</article>
}
</section>
</section>
@@ -1,439 +0,0 @@
:host {
display: block;
min-height: 100%;
padding: 20px;
box-sizing: border-box;
background: var(--app-content-bg, var(--mat-sys-surface));
color: var(--app-heading-color, var(--mat-sys-on-surface));
}
.dashboard-page {
max-width: 1280px;
margin: 0 auto;
display: flex;
flex-direction: column;
gap: 16px;
}
// ─── Hero ─────────────────────────────────────────────────────────────────────
.hero {
border-radius: 16px;
padding: 28px;
background: var(--app-widget-bg, var(--mat-sys-surface-container-high));
background-image: var(--app-accent-gradient);
border: none;
position: relative;
overflow: hidden;
}
.hero-content {
display: flex;
align-items: center;
gap: 24px;
}
.hero-text {
flex: 1;
}
.eyebrow {
margin: 0 0 10px;
display: flex;
align-items: center;
gap: 7px;
text-transform: uppercase;
letter-spacing: 0.1em;
font-size: 0.72rem;
font-weight: 600;
color: var(--app-eyebrow-color, var(--mat-sys-on-surface-variant));
}
.eyebrow-dot {
width: 6px;
height: 6px;
border-radius: 50%;
background: var(--mat-sys-primary);
flex-shrink: 0;
}
h1 {
margin: 0 0 12px;
font-size: clamp(1.5rem, 2.2vw, 2rem);
line-height: 1.2;
font-weight: 700;
letter-spacing: -0.02em;
color: var(--app-heading-color, var(--mat-sys-on-surface));
}
.description {
margin: 0;
max-width: 560px;
line-height: 1.55;
font-size: 0.9rem;
color: var(--app-body-color, var(--mat-sys-on-surface-variant));
}
.hero-actions {
margin-top: 20px;
display: flex;
flex-wrap: wrap;
gap: 10px;
a {
text-decoration: none;
}
}
// Visual accent element on the right
.hero-visual {
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
}
.hero-icon-ring {
width: 80px;
height: 80px;
border-radius: 24px;
background: var(--mat-sys-primary-container);
display: flex;
align-items: center;
justify-content: center;
border: 1px solid var(--mat-sys-outline-variant);
mat-icon {
font-size: 36px;
width: 36px;
height: 36px;
color: var(--mat-sys-on-primary-container);
}
}
// ─── Stat Grid ────────────────────────────────────────────────────────────────
.stat-grid {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 12px;
}
.stat-card {
border-radius: 14px;
padding: 16px;
background: var(--app-widget-bg, var(--mat-sys-surface-container));
border: none;
box-shadow: none;
display: flex;
align-items: center;
gap: 14px;
transition:
transform 0.15s ease,
background-color 0.15s ease;
overflow: hidden;
position: relative;
// Top accent line per type
&::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
height: 2px;
}
&:hover {
transform: translateY(-2px);
background: var(--app-card-hover-bg, var(--mat-sys-surface-container-high));
}
}
.stat-card--total::before {
background: var(--mat-sys-primary);
}
.stat-card--xtream::before {
background: var(--app-provider-xtream, #818cf8);
}
.stat-card--stalker::before {
background: var(--app-provider-stalker, #34d399);
}
.stat-card--m3u::before {
background: var(--app-provider-m3u, #fbbf24);
}
.stat-card__icon {
width: 40px;
height: 40px;
border-radius: 10px;
display: flex;
align-items: center;
justify-content: center;
background: var(--mat-sys-surface-container-high);
flex-shrink: 0;
mat-icon {
font-size: 20px;
width: 20px;
height: 20px;
color: var(--mat-sys-on-surface-variant);
}
}
.stat-card--xtream .stat-card__icon mat-icon {
color: var(--app-provider-xtream, #818cf8);
}
.stat-card--stalker .stat-card__icon mat-icon {
color: var(--app-provider-stalker, #34d399);
}
.stat-card--m3u .stat-card__icon mat-icon {
color: var(--app-provider-m3u, #fbbf24);
}
.stat-card__body {
display: flex;
flex-direction: column;
gap: 2px;
min-width: 0;
}
.stat-card__value {
font-size: 1.6rem;
font-weight: 700;
line-height: 1;
letter-spacing: -0.03em;
color: var(--app-heading-color, var(--mat-sys-on-surface));
}
.stat-card__label {
font-size: 0.8rem;
color: var(--app-body-color, var(--mat-sys-on-surface-variant));
font-weight: 500;
}
// ─── Sources Section ──────────────────────────────────────────────────────────
.sources-section {
border-radius: 14px;
padding: 18px;
background: var(--app-widget-bg, var(--mat-sys-surface-container));
border: none;
box-shadow: none;
}
.section-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-bottom: 14px;
h2 {
margin: 0;
font-size: 0.94rem;
font-weight: 600;
letter-spacing: -0.01em;
color: var(--app-heading-color, var(--mat-sys-on-surface));
}
}
.section-link {
display: flex;
align-items: center;
gap: 4px;
color: var(--mat-sys-primary);
text-decoration: none;
font-size: 0.82rem;
font-weight: 500;
transition: opacity 0.15s ease;
&:hover {
opacity: 0.75;
}
mat-icon {
font-size: 16px;
width: 16px;
height: 16px;
}
}
// ─── Source Cards ─────────────────────────────────────────────────────────────
.sources-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
gap: 10px;
}
.source-card {
border-radius: 12px;
padding: 14px;
border: 1px solid var(--mat-sys-outline-variant);
background: var(--mat-sys-surface-container-low);
text-decoration: none;
color: inherit;
display: flex;
flex-direction: column;
gap: 8px;
min-width: 0;
overflow: hidden;
transition:
border-color 0.15s ease,
background-color 0.15s ease,
transform 0.15s ease,
box-shadow 0.15s ease;
&:hover {
border-color: var(--mat-sys-primary);
background: var(--app-card-hover-bg, var(--mat-sys-surface-container-high));
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
}
}
.source-card__header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
min-width: 0;
}
// Provider Badges
.provider-badge {
display: inline-flex;
align-items: center;
padding: 2px 8px;
border-radius: 999px;
font-size: 0.68rem;
font-weight: 700;
letter-spacing: 0.06em;
text-transform: uppercase;
&--xtream {
background: rgba(129, 140, 248, 0.14);
color: var(--app-provider-xtream, #818cf8);
}
&--stalker {
background: rgba(52, 211, 153, 0.12);
color: var(--app-provider-stalker, #34d399);
}
&--m3u {
background: rgba(251, 191, 36, 0.12);
color: var(--app-provider-m3u, #fbbf24);
}
}
.source-card__count {
font-size: 0.72rem;
color: var(--mat-sys-on-surface-variant);
}
.source-card h3 {
margin: 0;
font-size: 0.9rem;
font-weight: 500;
line-height: 1.25;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.source-card__date {
margin: 0;
display: flex;
align-items: center;
gap: 4px;
font-size: 0.75rem;
color: var(--mat-sys-on-surface-variant);
mat-icon {
font-size: 13px;
width: 13px;
height: 13px;
flex-shrink: 0;
}
}
// ─── Empty State ──────────────────────────────────────────────────────────────
.empty-state {
border-radius: 14px;
border: 1px dashed var(--mat-sys-outline);
padding: 32px 24px;
text-align: center;
display: flex;
flex-direction: column;
align-items: center;
gap: 10px;
background: var(--mat-sys-surface-container-low);
h3 {
margin: 0;
font-size: 1rem;
font-weight: 600;
}
p {
margin: 0 0 6px;
color: var(--mat-sys-on-surface-variant);
font-size: 0.875rem;
max-width: 340px;
line-height: 1.5;
}
a {
text-decoration: none;
}
}
.empty-state__icon {
width: 56px;
height: 56px;
border-radius: 16px;
background: var(--mat-sys-surface-container-high);
display: flex;
align-items: center;
justify-content: center;
mat-icon {
font-size: 28px;
width: 28px;
height: 28px;
color: var(--mat-sys-on-surface-variant);
}
}
// ─── Responsive ───────────────────────────────────────────────────────────────
@media (max-width: 1024px) {
.stat-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
@media (max-width: 780px) {
.hero-visual {
display: none;
}
.stat-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.sources-grid {
grid-template-columns: 1fr;
}
}
@media (max-width: 560px) {
:host {
padding: 12px;
}
.hero {
padding: 20px;
}
.stat-grid {
grid-template-columns: 1fr 1fr;
}
}
@@ -1,98 +0,0 @@
import { DecimalPipe } from '@angular/common';
import { Component, computed, inject } from '@angular/core';
import { MatButton } from '@angular/material/button';
import { MatIcon } from '@angular/material/icon';
import { RouterLink } from '@angular/router';
import { Store } from '@ngrx/store';
import { selectActivePlaylist, selectAllPlaylistsMeta } from 'm3u-state';
import { PlaylistMeta } from 'shared-interfaces';
@Component({
selector: 'app-dashboard-placeholder',
imports: [DecimalPipe, MatButton, MatIcon, RouterLink],
templateUrl: './dashboard-placeholder.component.html',
styleUrl: './dashboard-placeholder.component.scss',
})
export class DashboardPlaceholderComponent {
private readonly store = inject(Store);
readonly activePlaylist = this.store.selectSignal(selectActivePlaylist);
readonly playlists = this.store.selectSignal(selectAllPlaylistsMeta);
readonly stats = computed(() => {
const items = this.playlists();
return {
total: items.length,
xtream: items.filter((item) => !!item.serverUrl).length,
stalker: items.filter((item) => !!item.macAddress).length,
m3u: items.filter((item) => !item.serverUrl && !item.macAddress)
.length,
};
});
readonly recentPlaylists = computed(() =>
[...this.playlists()]
.sort(
(a, b) =>
this.getRecentTimestamp(b) - this.getRecentTimestamp(a)
)
.slice(0, 8)
);
getPlaylistLink(playlist: PlaylistMeta): string[] {
if (playlist.serverUrl) {
return ['/workspace', 'xtreams', playlist._id, 'vod'];
}
if (playlist.macAddress) {
return ['/workspace', 'stalker', playlist._id, 'vod'];
}
return ['/workspace', 'playlists', playlist._id];
}
getPlaylistProvider(playlist: PlaylistMeta): 'Xtream' | 'Stalker' | 'M3U' {
if (playlist.serverUrl) {
return 'Xtream';
}
if (playlist.macAddress) {
return 'Stalker';
}
return 'M3U';
}
trackByPlaylistId(_: number, item: PlaylistMeta): string {
return item._id;
}
formatTimestamp(value?: string | number): string {
const timestamp = this.toTimestamp(value);
if (!timestamp) {
return 'Not yet synced';
}
return new Date(timestamp).toLocaleString();
}
private getRecentTimestamp(item: PlaylistMeta): number {
return (
this.toTimestamp(item.updateDate) ||
this.toTimestamp(item.importDate)
);
}
private toTimestamp(value?: string | number): number {
if (typeof value === 'number') {
return value;
}
if (typeof value === 'string') {
const parsed = Date.parse(value);
return Number.isNaN(parsed) ? 0 : parsed;
}
return 0;
}
}
@@ -1 +0,0 @@
export { FavoritesContextService } from '@iptvnator/portal/shared/util';
@@ -1,47 +0,0 @@
<div class="search-panel">
<mat-form-field class="search-bar">
<mat-icon matPrefix>search</mat-icon>
<input
matInput
type="search"
placeholder="Search favorites..."
[value]="searchTerm()"
(input)="searchTerm.set($any($event.target).value)"
/>
</mat-form-field>
</div>
<div class="nav-list" cdkDropList (cdkDropListDropped)="onDrop($event)">
@if (enrichedChannels().length === 0) {
<div class="empty-state">
<mat-icon class="empty-icon">star_border</mat-icon>
@if (searchTerm()) {
<p class="empty-title">No results</p>
<p class="empty-hint">Try a different search term</p>
} @else {
<p class="empty-title">No live TV favorites yet</p>
<p class="empty-hint">
Star live channels from M3U, Xtream, or Stalker playlists to
see them here.
</p>
}
</div>
}
@for (ch of enrichedChannels(); track trackByUid($index, ch)) {
<app-channel-list-item
[isDraggable]="!searchTerm()"
[logo]="ch.logo"
[name]="ch.name"
[selected]="activeUid() === ch.uid"
[showEpg]="true"
[epgProgram]="ch.currentEpgProgram"
[progressPercentage]="ch.progressPercentage"
[showFavoriteButton]="true"
[isFavorite]="true"
[showProgramInfoButton]="true"
(clicked)="onChannelClick(ch)"
(favoriteToggled)="onFavoriteToggled(ch)"
/>
}
</div>
@@ -1,81 +0,0 @@
@use '@angular/material' as mat;
:host {
display: flex;
flex-direction: column;
height: 100%;
min-height: 0;
overflow: hidden;
}
// ─── Search ────────────────────────────────────────────────────────────────────
.search-panel {
margin-top: 10px;
display: flex;
justify-content: center;
margin-bottom: -10px;
flex-shrink: 0;
}
.search-bar {
width: 90%;
@include mat.form-field-density(-5);
}
::ng-deep {
.search-bar div {
border-radius: 20px;
}
div[matformfieldlineripple],
.mat-mdc-form-field-subscript-wrapper {
display: none;
}
}
// ─── List ──────────────────────────────────────────────────────────────────────
.nav-list {
flex: 1;
overflow-y: auto;
overflow-x: hidden;
padding: 8px 8px 12px;
display: flex;
flex-direction: column;
gap: 2px;
}
// ─── Empty state ───────────────────────────────────────────────────────────────
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 48px 24px;
text-align: center;
color: var(--mat-sys-on-surface-variant);
.empty-icon {
font-size: 48px;
height: 48px;
width: 48px;
margin-bottom: 16px;
opacity: 0.5;
}
.empty-title {
font-size: 14px;
font-weight: 500;
margin: 0 0 8px;
}
.empty-hint {
font-size: 12px;
opacity: 0.7;
margin: 0;
max-width: 260px;
}
}
@@ -1,104 +0,0 @@
import {
CdkDragDrop,
DragDropModule,
moveItemInArray,
} from '@angular/cdk/drag-drop';
import {
ChangeDetectionStrategy,
Component,
computed,
input,
output,
signal,
} from '@angular/core';
import { MatFormFieldModule } from '@angular/material/form-field';
import { MatIconModule } from '@angular/material/icon';
import { MatInputModule } from '@angular/material/input';
import { ChannelListItemComponent } from 'components';
import { EpgProgram } from 'shared-interfaces';
import { UnifiedFavoriteChannel } from './unified-favorite-channel.interface';
export interface EnrichedUnifiedFavorite extends UnifiedFavoriteChannel {
currentEpgProgram: EpgProgram | null;
progressPercentage: number;
}
@Component({
selector: 'app-global-favorites-list',
templateUrl: './global-favorites-list.component.html',
styleUrl: './global-favorites-list.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [
ChannelListItemComponent,
DragDropModule,
MatFormFieldModule,
MatIconModule,
MatInputModule,
],
})
export class GlobalFavoritesListComponent {
readonly channels = input.required<UnifiedFavoriteChannel[]>();
readonly epgMap = input<Map<string, EpgProgram | null>>(new Map());
readonly progressTick = input<number>(0);
readonly activeUid = input<string | null>(null);
readonly channelSelected = output<UnifiedFavoriteChannel>();
readonly channelsReordered = output<UnifiedFavoriteChannel[]>();
readonly favoriteToggled = output<UnifiedFavoriteChannel>();
readonly searchTerm = signal('');
readonly enrichedChannels = computed((): EnrichedUnifiedFavorite[] => {
const channels = this.channels();
const epgMap = this.epgMap();
const term = this.searchTerm().toLowerCase();
// Read progressTick to trigger periodic recalculation
this.progressTick();
const filtered = term
? channels.filter((ch) => ch.name.toLowerCase().includes(term))
: channels;
return filtered.map((ch) => {
const epgKey = ch.tvgId?.trim() || ch.name?.trim();
const currentEpgProgram = epgKey
? (epgMap.get(epgKey) ?? null)
: null;
return {
...ch,
currentEpgProgram,
progressPercentage: this.calcProgress(currentEpgProgram),
};
});
});
onChannelClick(channel: UnifiedFavoriteChannel): void {
this.channelSelected.emit(channel);
}
onFavoriteToggled(channel: UnifiedFavoriteChannel): void {
this.favoriteToggled.emit(channel);
}
onDrop(event: CdkDragDrop<EnrichedUnifiedFavorite[]>): void {
// Disable reorder while searching to avoid index confusion
if (this.searchTerm()) return;
const list = [...this.channels()];
moveItemInArray(list, event.previousIndex, event.currentIndex);
this.channelsReordered.emit(list);
}
trackByUid(_: number, ch: EnrichedUnifiedFavorite): string {
return ch.uid;
}
private calcProgress(program: EpgProgram | null | undefined): number {
if (!program) return 0;
const now = Date.now();
const start = new Date(program.start).getTime();
const stop = new Date(program.stop).getTime();
const total = stop - start;
const elapsed = now - start;
return Math.min(100, Math.max(0, (elapsed / total) * 100));
}
}
@@ -1,117 +0,0 @@
<div class="global-favorites-page">
@if (isLoading()) {
<div class="loading-state">
<mat-spinner diameter="40" />
</div>
} @else {
<div
[class]="
activePlayback()
? isEmbeddedPlayer()
? 'itv-container'
: 'itv-container--external'
: 'itv-container-no-player'
"
>
<!-- Sidebar: resizable channel list -->
<div
class="sidebar"
appResizable
[minWidth]="250"
[maxWidth]="600"
[defaultWidth]="360"
storageKey="global-favorites-sidebar-width"
>
<div class="sidebar-header">
<span class="sidebar-title">Global Favorites</span>
<span class="sidebar-subtitle"
>Live TV from all playlists</span
>
</div>
<app-global-favorites-list
[channels]="channels()"
[epgMap]="epgMap()"
[progressTick]="progressTick()"
[activeUid]="activeUid()"
(channelSelected)="onChannelSelected($event)"
(channelsReordered)="onReorder($event)"
(favoriteToggled)="onFavoriteToggled($event)"
/>
</div>
@if (activePlayback()) {
<!-- Video player (embedded players only) -->
@if (isEmbeddedPlayer()) {
<div class="video-player">
<div class="player-toolbar">
<span class="player-title">{{
activePlayback()?.title
}}</span>
<button
mat-icon-button
(click)="onClose()"
class="close-btn"
>
<mat-icon>close</mat-icon>
</button>
</div>
@if (player() === 'videojs') {
<app-vjs-player
[options]="{
sources: [
{
src: currentStreamUrl(),
type: 'application/x-mpegURL',
},
],
}"
[volume]="1"
/>
}
@if (player() === 'html5') {
<app-html-video-player
[channel]="activeChannelForOverlay()!"
[volume]="1"
[showCaptions]="false"
/>
}
@if (player() === 'artplayer') {
<app-art-player
[channel]="activeChannelForOverlay()!"
[volume]="1"
[showCaptions]="false"
/>
}
</div>
}
<!-- EPG panel: spans full height for external players -->
<div class="epg">
@if (!isEmbeddedPlayer()) {
<div class="player-toolbar">
<span class="player-title">{{
activePlayback()?.title
}}</span>
<button
mat-icon-button
(click)="onClose()"
class="close-btn"
>
<mat-icon>close</mat-icon>
</button>
</div>
}
<div class="epg-content">
<app-epg-view [epgItems]="currentEpgItems()" />
</div>
</div>
} @else {
<div class="no-channel-selected">
<mat-icon>live_tv</mat-icon>
<p>Select a channel to start watching</p>
</div>
}
</div>
}
</div>
Loaded 100 of 193 files, more files were not shown because too many files have changed in this diff. Show more