mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-15 13:46:16 -08:00
refactor: update nx monorepo structure
This commit is contained in:
1 parent
9834c9fc0d
commit
0ad1eeb785
193 files changed
+1908
-3370
No files matched your search
@@ -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',
|
||||
|
||||
@@ -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(),
|
||||
],
|
||||
};
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
-134
@@ -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'"
|
||||
/>
|
||||
}
|
||||
-105
@@ -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;
|
||||
}
|
||||
}
|
||||
-620
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
>
|
||||
<a
|
||||
href="http://twitter.com/share?text=IPTVnator — 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();
|
||||
}
|
||||
}
|
||||
-66
@@ -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>
|
||||
-169
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
-52
@@ -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();
|
||||
});
|
||||
});
|
||||
-52
@@ -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'
|
||||
);
|
||||
}
|
||||
-77
@@ -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);
|
||||
} */
|
||||
}
|
||||
-40
@@ -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>
|
||||
}
|
||||
-106
@@ -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;
|
||||
}
|
||||
}
|
||||
-63
@@ -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,
|
||||
},
|
||||
];
|
||||
}
|
||||
-42
@@ -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>
|
||||
-126
@@ -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;
|
||||
}
|
||||
}
|
||||
-159
@@ -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);
|
||||
}
|
||||
}
|
||||
-143
@@ -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>
|
||||
-56
@@ -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;
|
||||
}
|
||||
}
|
||||
-522
@@ -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
Reference in new issue
Block a user