mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-08 17:06:15 -08:00
feat: add xtream code api support
This commit is contained in:
1 parent
d4a2741e45
commit
eb87c0fe0a
34 files changed
+1451
-109
No files matched your search
+63
-35
@@ -29,6 +29,9 @@ import {
|
||||
PLAYLIST_PARSE_RESPONSE,
|
||||
PLAYLIST_UPDATE,
|
||||
PLAYLIST_UPDATE_RESPONSE,
|
||||
SET_MPV_PLAYER_PATH,
|
||||
XTREAM_REQUEST,
|
||||
XTREAM_RESPONSE,
|
||||
} from '../shared/ipc-commands';
|
||||
import { Playlist } from '../shared/playlist.interface';
|
||||
import { createPlaylistObject } from '../shared/playlist.utils';
|
||||
@@ -38,9 +41,6 @@ const fs = require('fs');
|
||||
const https = require('https');
|
||||
|
||||
const mpvAPI = require('node-mpv');
|
||||
const createMpvInstance = () => new mpvAPI({}, ['--autofit=70%']);
|
||||
let mpv = createMpvInstance();
|
||||
mpv.on('quit', () => (mpv = null)).on('crash', () => (mpv = null));
|
||||
|
||||
/** @deprecated - used only for migration */
|
||||
const Nedb = require('nedb-promises');
|
||||
@@ -62,6 +62,8 @@ const agent = new https.Agent({
|
||||
rejectUnauthorized: false,
|
||||
});
|
||||
|
||||
const MPV_PLAYER_PATH = 'MPV_PLAYER_PATH';
|
||||
|
||||
export class Api {
|
||||
/** Instance of the main application window */
|
||||
mainWindow: BrowserWindow;
|
||||
@@ -75,7 +77,17 @@ export class Api {
|
||||
/** Instance of the epg browser window */
|
||||
workerWindow: BrowserWindow;
|
||||
|
||||
constructor() {
|
||||
store;
|
||||
|
||||
mpv;
|
||||
|
||||
constructor(store) {
|
||||
this.store = store;
|
||||
this.mpv = this.createMpvInstance();
|
||||
this.mpv
|
||||
.on('quit', () => (this.mpv = null))
|
||||
.on('crash', () => (this.mpv = null));
|
||||
|
||||
ipcMain
|
||||
.on(PLAYLIST_PARSE_BY_URL, (event, args) => {
|
||||
try {
|
||||
@@ -218,14 +230,14 @@ export class Api {
|
||||
// eslint-disable-next-line @typescript-eslint/no-misused-promises
|
||||
.on(OPEN_MPV_PLAYER, async (event, { url }) => {
|
||||
try {
|
||||
if (mpv === null) {
|
||||
mpv = createMpvInstance();
|
||||
if (this.mpv === null) {
|
||||
this.mpv = this.createMpvInstance();
|
||||
}
|
||||
if (mpv.isRunning()) {
|
||||
await mpv.load(url);
|
||||
if (this.mpv.isRunning()) {
|
||||
await this.mpv.load(url);
|
||||
} else {
|
||||
await mpv.start();
|
||||
await mpv.load(url);
|
||||
await this.mpv.start();
|
||||
await this.mpv.load(url);
|
||||
}
|
||||
} catch (error) {
|
||||
console.log(error);
|
||||
@@ -234,6 +246,14 @@ export class Api {
|
||||
'Error: Something went wrong. Make sure that mpv player is installed on your system.',
|
||||
});
|
||||
}
|
||||
})
|
||||
.on(SET_MPV_PLAYER_PATH, (_event, mpvPlayerPath) => {
|
||||
console.log('... setting mpv player path', mpvPlayerPath);
|
||||
store.set(MPV_PLAYER_PATH, mpvPlayerPath);
|
||||
|
||||
// recreate mpv player instance with new binary path if it was changed
|
||||
if (store.get(MPV_PLAYER_PATH, mpvPlayerPath) !== mpvPlayerPath)
|
||||
this.mpv = this.createMpvInstance();
|
||||
});
|
||||
|
||||
// listeners for EPG events
|
||||
@@ -275,7 +295,39 @@ export class Api {
|
||||
this.workerWindow.webContents.send(EPG_FORCE_FETCH, arg)
|
||||
);
|
||||
|
||||
this.setTitleBarListeners();
|
||||
ipcMain.on(
|
||||
XTREAM_REQUEST,
|
||||
(event, arg: { url: string; params: Record<string, string> }) => {
|
||||
const xtreamApiPath = '/player_api.php';
|
||||
|
||||
axios
|
||||
.get(arg.url + xtreamApiPath, {
|
||||
params: arg.params ?? {},
|
||||
})
|
||||
.then((result) => {
|
||||
event.sender.send(XTREAM_RESPONSE, {
|
||||
payload: result.data,
|
||||
action: arg.params.action,
|
||||
});
|
||||
})
|
||||
.catch((err) => {
|
||||
event.sender.send(ERROR, {
|
||||
message:
|
||||
err.response?.statusText ?? 'Error: not found',
|
||||
status: err.response?.status ?? 404,
|
||||
});
|
||||
});
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
createMpvInstance() {
|
||||
const mpvPlayerPath = this.store.get(MPV_PLAYER_PATH);
|
||||
console.log('... getting mpv player path', mpvPlayerPath);
|
||||
return new mpvAPI(
|
||||
{ ...(mpvPlayerPath ? { binary: mpvPlayerPath } : {}) },
|
||||
['--autofit=70%']
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -357,30 +409,6 @@ export class Api {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Set default listeners for custom-titlebar
|
||||
*/
|
||||
setTitleBarListeners() {
|
||||
ipcMain.on('window-minimize', function (event) {
|
||||
BrowserWindow.fromWebContents(event.sender).minimize();
|
||||
});
|
||||
|
||||
ipcMain.on('window-maximize', function (event) {
|
||||
const window = BrowserWindow.fromWebContents(event.sender);
|
||||
window.isMaximized() ? window.unmaximize() : window.maximize();
|
||||
});
|
||||
|
||||
ipcMain.on('window-close', function (event) {
|
||||
BrowserWindow.fromWebContents(event.sender).close();
|
||||
});
|
||||
|
||||
ipcMain.on('window-is-maximized', function (event) {
|
||||
event.returnValue = BrowserWindow.fromWebContents(
|
||||
event.sender
|
||||
).isMaximized();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches the playlist from the given url and triggers the update operation
|
||||
* @param id id of the playlist to update
|
||||
|
||||
+15
-2
@@ -1,5 +1,5 @@
|
||||
/* eslint-disable no-useless-catch */
|
||||
import { app, BrowserWindow, Menu } from 'electron';
|
||||
import { app, BrowserWindow, Menu, session } from 'electron';
|
||||
import * as path from 'path';
|
||||
import * as url from 'url';
|
||||
import { Api } from './api';
|
||||
@@ -11,6 +11,7 @@ const {
|
||||
const contextMenu = require('electron-context-menu');
|
||||
const Store = require('electron-store');
|
||||
const store = new Store();
|
||||
const os = require('os');
|
||||
|
||||
const WINDOW_BOUNDS = 'WINDOW_BOUNDS';
|
||||
|
||||
@@ -19,7 +20,7 @@ let win: BrowserWindow | null = null;
|
||||
const args = process.argv.slice(1),
|
||||
serve = args.some((val) => val === '--serve');
|
||||
|
||||
const api = new Api();
|
||||
const api = new Api(store);
|
||||
contextMenu();
|
||||
require('@electron/remote/main').initialize();
|
||||
|
||||
@@ -145,6 +146,18 @@ try {
|
||||
app.on('before-quit', () => {
|
||||
store.set(WINDOW_BOUNDS, win?.getNormalBounds());
|
||||
});
|
||||
|
||||
if (serve && process.platform === 'darwin') {
|
||||
// add redux dev tools extension
|
||||
const reduxDevToolsPath = path.join(
|
||||
os.homedir(),
|
||||
'Library/Application Support/Google/Chrome/Default/Extensions/lmhkpmbekcpmknklioeibfkpmmfibljd/3.0.19_14'
|
||||
);
|
||||
|
||||
app.whenReady().then(async () => {
|
||||
await session.defaultSession.loadExtension(reduxDevToolsPath);
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
// Catch Error
|
||||
throw e;
|
||||
|
||||
@@ -29,6 +29,13 @@ const routes: Routes = [
|
||||
(c) => c.SettingsComponent
|
||||
),
|
||||
},
|
||||
{
|
||||
path: 'xtreams/:id',
|
||||
loadComponent: () =>
|
||||
import('./xtream/xtream-main-container.component').then(
|
||||
(c) => c.XtreamMainContainerComponent
|
||||
),
|
||||
},
|
||||
{
|
||||
path: '**',
|
||||
redirectTo: '',
|
||||
|
||||
@@ -35,7 +35,7 @@
|
||||
<app-file-upload
|
||||
(fileRejected)="rejectFile($event)"
|
||||
(fileSelected)="handlePlaylist($event)"
|
||||
></app-file-upload>
|
||||
/>
|
||||
</ng-template>
|
||||
</mat-tab>
|
||||
<mat-tab>
|
||||
@@ -48,7 +48,7 @@
|
||||
<ng-template matTabContent>
|
||||
<app-url-upload
|
||||
(urlAdded)="sendPlaylistsUrl($event)"
|
||||
></app-url-upload>
|
||||
/>
|
||||
</ng-template>
|
||||
</mat-tab>
|
||||
<mat-tab>
|
||||
@@ -61,7 +61,18 @@
|
||||
<ng-template matTabContent>
|
||||
<app-text-import
|
||||
(textAdded)="uploadAsText($event)"
|
||||
></app-text-import>
|
||||
/>
|
||||
</ng-template>
|
||||
</mat-tab>
|
||||
<mat-tab>
|
||||
<ng-template mat-tab-label>
|
||||
<mat-icon class="tab-icon">text_format</mat-icon>
|
||||
<div class="tab-title">
|
||||
{{ 'HOME.TABS.XTREME_IMPORT' | translate }}
|
||||
</div>
|
||||
</ng-template>
|
||||
<ng-template matTabContent>
|
||||
<app-xtream-code-import />
|
||||
</ng-template>
|
||||
</mat-tab>
|
||||
</mat-tab-group>
|
||||
|
||||
@@ -7,8 +7,15 @@ import { HomeComponent } from './home.component';
|
||||
import { HomeRoutingModule } from './home.routing';
|
||||
import { TextImportComponent } from './text-import/text-import.component';
|
||||
import { UrlUploadComponent } from './url-upload/url-upload.component';
|
||||
import { XtreamCodeImportComponent } from './xtream-code-import/xtream-code-import.component';
|
||||
|
||||
@NgModule({
|
||||
imports: [CommonModule, HomeRoutingModule, SharedModule],
|
||||
imports: [
|
||||
CommonModule,
|
||||
HomeRoutingModule,
|
||||
SharedModule,
|
||||
XtreamCodeImportComponent,
|
||||
],
|
||||
declarations: [
|
||||
DragDropFileUploadDirective,
|
||||
HomeComponent,
|
||||
|
||||
@@ -28,31 +28,26 @@ describe('RecentPlaylistsComponent', () => {
|
||||
let mockStore: MockStore;
|
||||
const actions$ = new Observable<Actions>();
|
||||
|
||||
beforeEach(
|
||||
waitForAsync(() => {
|
||||
TestBed.configureTestingModule({
|
||||
declarations: [
|
||||
RecentPlaylistsComponent,
|
||||
MockPipe(TranslatePipe),
|
||||
],
|
||||
imports: [
|
||||
MockModule(MatDialogModule),
|
||||
MockModule(MatListModule),
|
||||
MockModule(MatIconModule),
|
||||
MockModule(MatTooltipModule),
|
||||
MockModule(NgxSkeletonLoaderModule),
|
||||
],
|
||||
providers: [
|
||||
{ provide: DataService, useClass: ElectronServiceStub },
|
||||
MockProvider(TranslateService),
|
||||
MockProvider(DialogService),
|
||||
MatSnackBar,
|
||||
provideMockStore(),
|
||||
provideMockActions(actions$),
|
||||
],
|
||||
}).compileComponents();
|
||||
})
|
||||
);
|
||||
beforeEach(waitForAsync(() => {
|
||||
TestBed.configureTestingModule({
|
||||
declarations: [RecentPlaylistsComponent, MockPipe(TranslatePipe)],
|
||||
imports: [
|
||||
MockModule(MatDialogModule),
|
||||
MockModule(MatListModule),
|
||||
MockModule(MatIconModule),
|
||||
MockModule(MatTooltipModule),
|
||||
MockModule(NgxSkeletonLoaderModule),
|
||||
],
|
||||
providers: [
|
||||
{ provide: DataService, useClass: ElectronServiceStub },
|
||||
MockProvider(TranslateService),
|
||||
MockProvider(DialogService),
|
||||
MatSnackBar,
|
||||
provideMockStore(),
|
||||
provideMockActions(actions$),
|
||||
],
|
||||
}).compileComponents();
|
||||
}));
|
||||
|
||||
beforeEach(() => {
|
||||
fixture = TestBed.createComponent(RecentPlaylistsComponent);
|
||||
@@ -128,7 +123,7 @@ describe('RecentPlaylistsComponent', () => {
|
||||
it('should send an event to the main process to get a playlist', () => {
|
||||
const playlistId = '6789';
|
||||
jest.spyOn(component.playlistClicked, 'emit');
|
||||
component.getPlaylist(playlistId);
|
||||
component.getPlaylist({ _id: playlistId } as unknown as PlaylistMeta);
|
||||
expect(component.playlistClicked.emit).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,43 @@
|
||||
<form [formGroup]="form">
|
||||
<mat-form-field class="full-width">
|
||||
<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="full-width">
|
||||
<mat-label for="serverUrl">{{
|
||||
'HOME.XTREAM_PLAYLIST.SERVER_URL' | translate
|
||||
}}</mat-label>
|
||||
<input
|
||||
matInput
|
||||
type="text"
|
||||
id="serverUrl"
|
||||
formControlName="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="full-width">
|
||||
<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="full-width">
|
||||
<mat-label for="password">{{
|
||||
'HOME.XTREAM_PLAYLIST.PASSWORD' | translate
|
||||
}}</mat-label>
|
||||
<input matInput type="text" id="password" formControlName="password" />
|
||||
</mat-form-field>
|
||||
<button
|
||||
mat-button
|
||||
type="submit"
|
||||
[disabled]="!form.valid"
|
||||
(click)="addPlaylist()"
|
||||
>
|
||||
{{ 'HOME.XTREAM_PLAYLIST.ADD' | translate }}
|
||||
</button>
|
||||
</form>
|
||||
@@ -0,0 +1,68 @@
|
||||
import { Component, inject } 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 { Store } from '@ngrx/store';
|
||||
import { TranslateModule } from '@ngx-translate/core';
|
||||
import { v4 as uuid } from 'uuid';
|
||||
import { Playlist } from '../../../../shared/playlist.interface';
|
||||
import { DataService } from '../../services/data.service';
|
||||
import { addPlaylist } from '../../state/actions';
|
||||
|
||||
@Component({
|
||||
standalone: true,
|
||||
imports: [
|
||||
FormsModule,
|
||||
ReactiveFormsModule,
|
||||
MatFormFieldModule,
|
||||
MatInputModule,
|
||||
MatButtonModule,
|
||||
TranslateModule,
|
||||
],
|
||||
selector: 'app-xtream-code-import',
|
||||
templateUrl: './xtream-code-import.component.html',
|
||||
styles: [
|
||||
`
|
||||
:host {
|
||||
display: flex;
|
||||
margin: 10px;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
form {
|
||||
width: 100%;
|
||||
}
|
||||
`,
|
||||
],
|
||||
})
|
||||
export class XtreamCodeImportComponent {
|
||||
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()),
|
||||
});
|
||||
|
||||
dataService = inject(DataService);
|
||||
store = inject(Store);
|
||||
|
||||
addPlaylist() {
|
||||
this.store.dispatch(
|
||||
addPlaylist({ playlist: this.form.value as Playlist })
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,8 @@ import {
|
||||
PLAYLIST_PARSE_BY_URL,
|
||||
PLAYLIST_PARSE_RESPONSE,
|
||||
PLAYLIST_UPDATE,
|
||||
XTREAM_REQUEST,
|
||||
XTREAM_RESPONSE,
|
||||
} from '../../../shared/ipc-commands';
|
||||
import { Playlist } from '../../../shared/playlist.interface';
|
||||
import { AppConfig } from '../../environments/environment';
|
||||
@@ -65,6 +67,10 @@ export class PwaService extends DataService {
|
||||
this.fetchFromUrl(payload);
|
||||
} else if (type === PLAYLIST_UPDATE) {
|
||||
this.refreshPlaylist(payload);
|
||||
} else if (type === XTREAM_REQUEST) {
|
||||
this.forwardXtreamRequest(
|
||||
payload as { url: string; params: Record<string, string> }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -136,6 +142,26 @@ export class PwaService extends DataService {
|
||||
return message;
|
||||
}
|
||||
|
||||
forwardXtreamRequest(payload: {
|
||||
url: string;
|
||||
params: Record<string, string>;
|
||||
}) {
|
||||
return this.http
|
||||
.get(`${this.corsProxyUrl}/xtream`, {
|
||||
params: {
|
||||
url: payload.url,
|
||||
...payload.params,
|
||||
},
|
||||
})
|
||||
.subscribe((response) => {
|
||||
window.postMessage({
|
||||
type: XTREAM_RESPONSE,
|
||||
payload: (response as any).payload,
|
||||
action: payload.params.action,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
getPlaylistFromUrl(url: string) {
|
||||
return this.http.get(`${this.corsProxyUrl}/parse`, {
|
||||
params: { url },
|
||||
@@ -146,7 +172,7 @@ export class PwaService extends DataService {
|
||||
// not implemented
|
||||
}
|
||||
|
||||
listenOn(command: string, callback: (...args: any[]) => void): void {
|
||||
listenOn(_command: string, callback: (...args: any[]) => void): void {
|
||||
window.addEventListener('message', callback);
|
||||
}
|
||||
}
|
||||
@@ -60,6 +60,7 @@ const DEFAULT_SETTINGS = {
|
||||
language: Language.ENGLISH,
|
||||
showCaptions: false,
|
||||
theme: Theme.LightTheme,
|
||||
mpvPlayerPath: '',
|
||||
};
|
||||
|
||||
describe('SettingsComponent', () => {
|
||||
@@ -70,46 +71,44 @@ describe('SettingsComponent', () => {
|
||||
let storage: StorageMap;
|
||||
let translate: TranslateService;
|
||||
|
||||
beforeEach(
|
||||
waitForAsync(() => {
|
||||
TestBed.configureTestingModule({
|
||||
declarations: [
|
||||
SettingsComponent,
|
||||
MockComponent(HeaderComponent),
|
||||
MockPipe(TranslatePipe),
|
||||
],
|
||||
providers: [
|
||||
UntypedFormBuilder,
|
||||
MockProvider(TranslateService),
|
||||
{ provide: MatSnackBar, useClass: MatSnackBarStub },
|
||||
{ provide: DataService, useClass: ElectronServiceStub },
|
||||
{
|
||||
provide: Router,
|
||||
useClass: MockRouter,
|
||||
},
|
||||
StorageMap,
|
||||
provideMockStore(),
|
||||
MockProviders(NgxIndexedDBService, PlaylistsService),
|
||||
],
|
||||
imports: [
|
||||
HttpClientTestingModule,
|
||||
MockModule(FormsModule),
|
||||
MockModule(MatSelectModule),
|
||||
MockModule(MatIconModule),
|
||||
MockModule(MatTooltipModule),
|
||||
MockModule(ReactiveFormsModule),
|
||||
MockModule(RouterTestingModule),
|
||||
MockModule(MatCardModule),
|
||||
MockModule(MatListModule),
|
||||
MockModule(MatFormFieldModule),
|
||||
MockModule(MatCheckboxModule),
|
||||
MockModule(MatDividerModule),
|
||||
MockModule(TranslateModule),
|
||||
MockModule(SharedModule),
|
||||
],
|
||||
}).compileComponents();
|
||||
})
|
||||
);
|
||||
beforeEach(waitForAsync(() => {
|
||||
TestBed.configureTestingModule({
|
||||
declarations: [
|
||||
SettingsComponent,
|
||||
MockComponent(HeaderComponent),
|
||||
MockPipe(TranslatePipe),
|
||||
],
|
||||
providers: [
|
||||
UntypedFormBuilder,
|
||||
MockProvider(TranslateService),
|
||||
{ provide: MatSnackBar, useClass: MatSnackBarStub },
|
||||
{ provide: DataService, useClass: ElectronServiceStub },
|
||||
{
|
||||
provide: Router,
|
||||
useClass: MockRouter,
|
||||
},
|
||||
StorageMap,
|
||||
provideMockStore(),
|
||||
MockProviders(NgxIndexedDBService, PlaylistsService),
|
||||
],
|
||||
imports: [
|
||||
HttpClientTestingModule,
|
||||
MockModule(FormsModule),
|
||||
MockModule(MatSelectModule),
|
||||
MockModule(MatIconModule),
|
||||
MockModule(MatTooltipModule),
|
||||
MockModule(ReactiveFormsModule),
|
||||
MockModule(RouterTestingModule),
|
||||
MockModule(MatCardModule),
|
||||
MockModule(MatListModule),
|
||||
MockModule(MatFormFieldModule),
|
||||
MockModule(MatCheckboxModule),
|
||||
MockModule(MatDividerModule),
|
||||
MockModule(TranslateModule),
|
||||
MockModule(SharedModule),
|
||||
],
|
||||
}).compileComponents();
|
||||
}));
|
||||
|
||||
beforeEach(() => {
|
||||
fixture = TestBed.createComponent(SettingsComponent);
|
||||
@@ -127,7 +126,12 @@ describe('SettingsComponent', () => {
|
||||
});
|
||||
|
||||
describe('Get and set settings on component init', () => {
|
||||
const settings = { player: 'test', showCaptions: true, epgUrl: [] };
|
||||
const settings = {
|
||||
player: 'test',
|
||||
showCaptions: true,
|
||||
epgUrl: [],
|
||||
mpvPlayerPath: '',
|
||||
};
|
||||
let spyOnStorageGet;
|
||||
|
||||
beforeEach(() => {
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
<ng-container *ngIf="items.length > 0; else noItems">
|
||||
<div class="search">
|
||||
<input
|
||||
class="search-input"
|
||||
placeholder="Search"
|
||||
[(ngModel)]="searchText"
|
||||
type="search"
|
||||
/>
|
||||
</div>
|
||||
<div class="grid">
|
||||
<mat-card
|
||||
*ngFor="
|
||||
let item of items | filterBy : searchText : 'name';
|
||||
trackBy: trackByFn
|
||||
"
|
||||
(click)="itemClicked.emit(item)"
|
||||
>
|
||||
<mat-card-content>
|
||||
<img
|
||||
class="stream-icon"
|
||||
*ngIf="item.stream_icon"
|
||||
[src]="item.stream_icon"
|
||||
/>
|
||||
<img
|
||||
class="stream-icon"
|
||||
*ngIf="item.cover"
|
||||
[src]="item.cover"
|
||||
/>
|
||||
<div
|
||||
class="stream-icon-placeholder"
|
||||
*ngIf="!item.stream_icon && !item.cover"
|
||||
></div>
|
||||
<div class="title">{{ item.name }}</div>
|
||||
</mat-card-content>
|
||||
</mat-card>
|
||||
</div>
|
||||
</ng-container>
|
||||
<ng-template #noItems>
|
||||
<div class="no-content">
|
||||
<mat-icon class="icon">list</mat-icon>
|
||||
<div>This category is empty, try another one</div>
|
||||
</div>
|
||||
</ng-template>
|
||||
@@ -0,0 +1,67 @@
|
||||
:host {
|
||||
display: block;
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.search {
|
||||
text-align: center;
|
||||
margin-bottom: 10px;
|
||||
|
||||
.search-input {
|
||||
padding: 10px;
|
||||
width: 300px;
|
||||
text-align: center;
|
||||
border-radius: 5px;
|
||||
border: 1px solid #333;
|
||||
}
|
||||
}
|
||||
|
||||
.grid {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
justify-content: center;
|
||||
|
||||
mat-card {
|
||||
cursor: pointer;
|
||||
width: 200px;
|
||||
|
||||
mat-card-content {
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.stream-icon {
|
||||
width: 100%;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.stream-icon-placeholder {
|
||||
width: 100%;
|
||||
background: #ccc;
|
||||
height: 300px;
|
||||
|
||||
&::after {
|
||||
content: 'No cover';
|
||||
position: absolute;
|
||||
top: 130px;
|
||||
left: 70px;
|
||||
}
|
||||
}
|
||||
|
||||
.title {
|
||||
word-break: break-all;
|
||||
padding: 8px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.no-content {
|
||||
text-align: center;
|
||||
|
||||
.icon {
|
||||
font-size: 64px;
|
||||
height: 64px;
|
||||
width: 64px;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import { NgFor, NgIf } from '@angular/common';
|
||||
import { Component, EventEmitter, Input, Output } from '@angular/core';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { MatCardModule } from '@angular/material/card';
|
||||
import { MatIconModule } from '@angular/material/icon';
|
||||
import { XtreamItem } from '../../../../shared/xtream-item.interface';
|
||||
import { FilterPipe } from '../../shared/pipes/filter.pipe';
|
||||
|
||||
@Component({
|
||||
selector: 'app-category-content-view',
|
||||
templateUrl: './category-content-view.component.html',
|
||||
styleUrls: ['./category-content-view.component.scss'],
|
||||
standalone: true,
|
||||
imports: [
|
||||
NgFor,
|
||||
MatCardModule,
|
||||
MatIconModule,
|
||||
NgIf,
|
||||
FilterPipe,
|
||||
FormsModule,
|
||||
],
|
||||
})
|
||||
export class CategoryContentViewComponent {
|
||||
@Input({ required: true }) items: XtreamItem[];
|
||||
@Output() itemClicked = new EventEmitter<any>();
|
||||
|
||||
searchText: string;
|
||||
|
||||
trackByFn(_index: number, item: XtreamItem) {
|
||||
return item.stream_id;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
import { NgFor, NgIf } from '@angular/common';
|
||||
import { Component, EventEmitter, Input, Output } from '@angular/core';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { MatCardModule } from '@angular/material/card';
|
||||
import { MatIconModule } from '@angular/material/icon';
|
||||
import { XtreamCategory } from '../../../../shared/xtream-category.interface';
|
||||
import { FilterPipe } from '../../shared/pipes/filter.pipe';
|
||||
|
||||
@Component({
|
||||
selector: 'app-category-view',
|
||||
standalone: true,
|
||||
template: `
|
||||
<ng-container *ngIf="items?.length > 0; else noItems">
|
||||
<div class="search">
|
||||
<input
|
||||
class="search-input"
|
||||
placeholder="Search"
|
||||
[(ngModel)]="searchText"
|
||||
type="search"
|
||||
/>
|
||||
</div>
|
||||
<div class="grid">
|
||||
<mat-card
|
||||
class="category-item"
|
||||
*ngFor="
|
||||
let item of items
|
||||
| filterBy : searchText : 'category_name';
|
||||
trackBy: trackByFn
|
||||
"
|
||||
(click)="categoryClicked.emit(item)"
|
||||
>
|
||||
<mat-card-content>
|
||||
{{ item.category_name }}
|
||||
</mat-card-content>
|
||||
</mat-card>
|
||||
</div>
|
||||
</ng-container>
|
||||
<ng-template #noItems>
|
||||
<div class="no-content">
|
||||
<mat-icon class="icon">warning</mat-icon>
|
||||
<div>
|
||||
Oops, no content here, please change the category or content
|
||||
type
|
||||
</div>
|
||||
</div>
|
||||
</ng-template>
|
||||
`,
|
||||
styles: [
|
||||
`
|
||||
:host {
|
||||
margin: 10px;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.no-content {
|
||||
text-align: center;
|
||||
|
||||
.icon {
|
||||
font-size: 64px;
|
||||
height: 64px;
|
||||
width: 64px;
|
||||
}
|
||||
}
|
||||
|
||||
.grid {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
justify-content: center;
|
||||
|
||||
.category-item {
|
||||
cursor: pointer;
|
||||
width: 200px;
|
||||
}
|
||||
}
|
||||
|
||||
.search {
|
||||
text-align: center;
|
||||
margin-bottom: 10px;
|
||||
|
||||
.search-input {
|
||||
padding: 10px;
|
||||
width: 300px;
|
||||
text-align: center;
|
||||
border-radius: 5px;
|
||||
border: 1px solid #333;
|
||||
}
|
||||
}
|
||||
`,
|
||||
],
|
||||
imports: [
|
||||
FilterPipe,
|
||||
FormsModule,
|
||||
MatCardModule,
|
||||
MatIconModule,
|
||||
NgFor,
|
||||
NgIf,
|
||||
],
|
||||
})
|
||||
export class CategoryViewComponent {
|
||||
@Input({ required: true }) items: XtreamCategory[];
|
||||
|
||||
@Output() categoryClicked = new EventEmitter<XtreamCategory>();
|
||||
|
||||
searchText: string;
|
||||
|
||||
trackByFn(_index: number, item: XtreamCategory) {
|
||||
return item.category_id;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
export enum ContentType {
|
||||
LIVE_STREAMS = 'LIVE_STREAMS',
|
||||
VODS = 'VODS',
|
||||
SERIES = 'SERIES',
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
.container {
|
||||
display: grid;
|
||||
grid-gap: 10px;
|
||||
grid-template-columns: 340px auto;
|
||||
|
||||
&::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
background-size: cover;
|
||||
z-index: -1;
|
||||
background-repeat: no-repeat;
|
||||
}
|
||||
}
|
||||
|
||||
.image {
|
||||
width: 300px;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.image img {
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.container {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
margin: 0 auto;
|
||||
max-width: 1200px;
|
||||
}
|
||||
|
||||
.image,
|
||||
.details {
|
||||
flex: 1 1 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.label {
|
||||
font-weight: 100;
|
||||
font-size: 0.9em;
|
||||
padding: 5px 0;
|
||||
text-decoration: underline;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.action-buttons {
|
||||
margin-top: 10px;
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
<div id="navigation">
|
||||
<button mat-icon-button routerLink="/">
|
||||
<mat-icon>home</mat-icon>
|
||||
</button>
|
||||
|
||||
<div>
|
||||
<mat-button-toggle-group
|
||||
name="contentType"
|
||||
aria-label="Content Type"
|
||||
[value]="contentType"
|
||||
(change)="contentTypeChanged.emit($event.value)"
|
||||
>
|
||||
<mat-button-toggle [value]="ContentTypeEnum.LIVE_STREAMS"
|
||||
>Live Streams</mat-button-toggle
|
||||
>
|
||||
<mat-button-toggle [value]="ContentTypeEnum.VODS"
|
||||
>VOD Streams</mat-button-toggle
|
||||
>
|
||||
<mat-button-toggle [value]="ContentTypeEnum.SERIES"
|
||||
>Series</mat-button-toggle
|
||||
>
|
||||
</mat-button-toggle-group>
|
||||
</div>
|
||||
|
||||
<button mat-icon-button>
|
||||
<mat-icon>info</mat-icon>
|
||||
</button>
|
||||
</div>
|
||||
<div id="breadcrumbs">
|
||||
<span
|
||||
*ngFor="let item of breadcrumbs"
|
||||
(click)="breadcrumbClicked.emit(item)"
|
||||
>{{ item.title }}</span
|
||||
>
|
||||
</div>
|
||||
@@ -0,0 +1,32 @@
|
||||
:host {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
#navigation {
|
||||
height: 70px;
|
||||
background: black;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
#breadcrumbs {
|
||||
background: black;
|
||||
border-bottom: 1px solid #333;
|
||||
padding: 10px;
|
||||
|
||||
span {
|
||||
cursor: pointer;
|
||||
|
||||
&:not(:last-child)::after {
|
||||
content: '>';
|
||||
margin: 0 10px;
|
||||
}
|
||||
}
|
||||
|
||||
span:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import { NgFor } from '@angular/common';
|
||||
import { Component, EventEmitter, Input, Output } from '@angular/core';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { MatButtonToggleModule } from '@angular/material/button-toggle';
|
||||
import { MatIconModule } from '@angular/material/icon';
|
||||
import { RouterLink } from '@angular/router';
|
||||
import { ContentType } from '../content-type.enum';
|
||||
import { Breadcrumb } from '../xtream-main-container.component';
|
||||
|
||||
@Component({
|
||||
selector: 'app-navigation-bar',
|
||||
templateUrl: './navigation-bar.component.html',
|
||||
styleUrls: ['./navigation-bar.component.scss'],
|
||||
standalone: true,
|
||||
imports: [
|
||||
MatButtonToggleModule,
|
||||
MatIconModule,
|
||||
MatButtonModule,
|
||||
RouterLink,
|
||||
NgFor,
|
||||
],
|
||||
})
|
||||
export class NavigationBarComponent {
|
||||
@Input({ required: true }) breadcrumbs: Breadcrumb[];
|
||||
@Input({ required: true }) contentType: ContentType;
|
||||
|
||||
@Output() contentTypeChanged = new EventEmitter<ContentType>();
|
||||
@Output() breadcrumbClicked = new EventEmitter<Breadcrumb>();
|
||||
|
||||
ContentTypeEnum = ContentType;
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
<h2 mat-dialog-title>Title</h2>
|
||||
<mat-dialog-content class="mat-typography">
|
||||
<app-vjs-player
|
||||
[options]="{
|
||||
sources: [
|
||||
{
|
||||
src: streamUrl,
|
||||
type: 'application/x-mpegURL'
|
||||
}
|
||||
]
|
||||
}"
|
||||
></app-vjs-player>
|
||||
</mat-dialog-content>
|
||||
@@ -0,0 +1,17 @@
|
||||
import { Component, Inject } from '@angular/core';
|
||||
import { MAT_DIALOG_DATA, MatDialogModule } from '@angular/material/dialog';
|
||||
import { VjsPlayerComponent } from '../../player/components/vjs-player/vjs-player.component';
|
||||
|
||||
@Component({
|
||||
selector: 'app-player-dialog',
|
||||
templateUrl: './player-dialog.component.html',
|
||||
standalone: true,
|
||||
imports: [VjsPlayerComponent, MatDialogModule],
|
||||
})
|
||||
export class PlayerDialogComponent {
|
||||
streamUrl: string;
|
||||
|
||||
constructor(@Inject(MAT_DIALOG_DATA) streamUrl: any) {
|
||||
this.streamUrl = streamUrl;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
<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 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>
|
||||
@@ -0,0 +1,30 @@
|
||||
import { Component } from '@angular/core';
|
||||
import { MatIconModule } from '@angular/material/icon';
|
||||
import { RouterLink } from '@angular/router';
|
||||
|
||||
@Component({
|
||||
selector: 'app-player-view',
|
||||
templateUrl: './player-view.component.html',
|
||||
standalone: true,
|
||||
imports: [MatIconModule, RouterLink],
|
||||
styles: [
|
||||
`
|
||||
.centered {
|
||||
text-align: center;
|
||||
line-height: 24px;
|
||||
margin: 20px;
|
||||
|
||||
.icon {
|
||||
font-size: 64px;
|
||||
height: 64px;
|
||||
width: 64px;
|
||||
}
|
||||
}
|
||||
`,
|
||||
],
|
||||
})
|
||||
export class PlayerViewComponent {
|
||||
openUrl(url: string) {
|
||||
window.open(url, '_blank');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
<h3>Seasons and episodes</h3>
|
||||
<div class="seasons-container">
|
||||
<ng-container *ngIf="!selectedSeason; else episodesView">
|
||||
<mat-card
|
||||
class="season-item"
|
||||
*ngFor="let season of seasons | keyvalue"
|
||||
(click)="selectSeason(season.key)"
|
||||
>
|
||||
<mat-card-content>Season {{ season.key }}</mat-card-content>
|
||||
</mat-card>
|
||||
</ng-container>
|
||||
|
||||
<ng-template #episodesView>
|
||||
<mat-card class="episode-item" (click)="selectedSeason = undefined">
|
||||
<mat-card-title>< Back to seasons</mat-card-title>
|
||||
</mat-card>
|
||||
<mat-card
|
||||
class="episode-item"
|
||||
*ngFor="let episode of seasons[selectedSeason]"
|
||||
(click)="selectEpisode(episode)"
|
||||
>
|
||||
<mat-card-content
|
||||
><img [src]="episode.info.movie_image" class="episode-cover"
|
||||
/></mat-card-content>
|
||||
<div class="episode-title">
|
||||
{{ episode.episode_num }}. {{ episode.title }}
|
||||
</div>
|
||||
</mat-card>
|
||||
</ng-template>
|
||||
</div>
|
||||
@@ -0,0 +1,36 @@
|
||||
:host {
|
||||
margin: 10px;
|
||||
display: block;
|
||||
padding: 20px 0;
|
||||
}
|
||||
|
||||
.seasons-container {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
flex-flow: wrap;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.season-item,
|
||||
.episode-item {
|
||||
width: 150px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.episode-item {
|
||||
mat-card-content {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
mat-card-title {
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.episode-title {
|
||||
padding: 5px;
|
||||
}
|
||||
|
||||
.episode-cover {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import { JsonPipe, KeyValuePipe, NgFor, NgIf } from '@angular/common';
|
||||
import { Component, EventEmitter, Input, Output } from '@angular/core';
|
||||
import { MatCardModule } from '@angular/material/card';
|
||||
import { XtreamSerieEpisode } from '../../../../shared/xtream-serie-details.interface';
|
||||
|
||||
@Component({
|
||||
selector: 'app-season-container',
|
||||
templateUrl: './season-container.component.html',
|
||||
styleUrls: ['./season-container.component.scss'],
|
||||
standalone: true,
|
||||
imports: [JsonPipe, KeyValuePipe, NgFor, MatCardModule, NgIf],
|
||||
})
|
||||
export class SeasonContainerComponent {
|
||||
@Input({ required: true }) seasons: Record<string, XtreamSerieEpisode[]>;
|
||||
|
||||
@Output() episodeClicked = new EventEmitter<any>();
|
||||
|
||||
selectedSeason: string;
|
||||
|
||||
selectSeason(seasonKey: string) {
|
||||
this.selectedSeason = seasonKey;
|
||||
}
|
||||
|
||||
selectEpisode(episode: any) {
|
||||
this.episodeClicked.emit(episode);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
<div
|
||||
class="container"
|
||||
[style.background]="
|
||||
item.info?.backdrop_path && item.info.backdrop_path.length > 0
|
||||
? 'linear-gradient(to top, rgba(29,29,29,1) 0%, rgba(0,0,0,0.5) 100%), url(' +
|
||||
item.info.backdrop_path[0] +
|
||||
') repeat-x'
|
||||
: 'none'
|
||||
"
|
||||
>
|
||||
<div class="image">
|
||||
<img *ngIf="item.info.cover" [src]="item.info.cover" />
|
||||
</div>
|
||||
<div class="details">
|
||||
<h2>{{ item.info.name }}</h2>
|
||||
<label *ngIf="item.info.plot">
|
||||
{{ item.info.plot }}
|
||||
</label>
|
||||
<label *ngIf="item.info.releaseDate">
|
||||
<div class="label">Release date:</div>
|
||||
{{ item.info.releaseDate }}
|
||||
</label>
|
||||
<label *ngIf="item.info.genre">
|
||||
<div class="label">Genre:</div>
|
||||
{{ item.info.genre }}
|
||||
</label>
|
||||
<label *ngIf="item.info.rating">
|
||||
<div class="label">Rating:</div>
|
||||
{{ item.info.rating }}
|
||||
</label>
|
||||
<div *ngIf="item.info.cast">
|
||||
<div class="label">Cast:</div>
|
||||
{{ item.info.cast }}
|
||||
</div>
|
||||
<label *ngIf="item.info.director">
|
||||
<div class="label">Director:</div>
|
||||
{{ item.info.director }}
|
||||
</label>
|
||||
<label *ngIf="item.info.episode_run_time">
|
||||
<div class="label">Episode run time:</div>
|
||||
{{ item.info.episode_run_time }}
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<app-season-container
|
||||
[seasons]="item.episodes"
|
||||
(episodeClicked)="playEpisode($event)"
|
||||
/>
|
||||
@@ -0,0 +1,38 @@
|
||||
import { JsonPipe, NgIf } from '@angular/common';
|
||||
import { Component, EventEmitter, Input, Output, inject } from '@angular/core';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { MatDialog } from '@angular/material/dialog';
|
||||
import { Store } from '@ngrx/store';
|
||||
import { OPEN_MPV_PLAYER } from '../../../../shared/ipc-commands';
|
||||
import { XtreamSerieDetails } from '../../../../shared/xtream-serie-details.interface';
|
||||
import { DataService } from '../../services/data.service';
|
||||
import { selectCurrentPlaylist } from '../../state/selectors';
|
||||
import { SeasonContainerComponent } from '../season-container/season-container.component';
|
||||
|
||||
@Component({
|
||||
selector: 'app-serial-details',
|
||||
templateUrl: './serial-details.component.html',
|
||||
styleUrls: ['../detail-view.scss'],
|
||||
standalone: true,
|
||||
imports: [JsonPipe, MatButtonModule, NgIf, SeasonContainerComponent],
|
||||
})
|
||||
export class SerialDetailsComponent {
|
||||
@Input({ required: true }) item: XtreamSerieDetails;
|
||||
|
||||
@Output() playClicked = new EventEmitter();
|
||||
|
||||
dialog = inject(MatDialog);
|
||||
store = inject(Store);
|
||||
dataService = inject(DataService);
|
||||
currentPlaylist = this.store.selectSignal(selectCurrentPlaylist);
|
||||
|
||||
playEpisode(episode: any) {
|
||||
const { serverUrl, username, password } = this.currentPlaylist();
|
||||
|
||||
const streamUrl = `${serverUrl}/series/${username}/${password}/${episode.id}.${episode.container_extension}`;
|
||||
/* this.dialog.open(PlayerDialogComponent, {
|
||||
data: streamUrl,
|
||||
}); */
|
||||
this.dataService.sendIpcEvent(OPEN_MPV_PLAYER, { url: streamUrl });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
<div
|
||||
class="container"
|
||||
[style.background]="
|
||||
item.info?.backdrop_path && item.info.backdrop_path.length > 0
|
||||
? 'linear-gradient(to top, rgba(29,29,29,1) 0%, rgba(0,0,0,0.5) 100%), url(' +
|
||||
item.info.backdrop_path[0] +
|
||||
') repeat-x'
|
||||
: 'none'
|
||||
"
|
||||
>
|
||||
<div class="image">
|
||||
<img *ngIf="item.info.movie_image" [src]="item.info.movie_image" />
|
||||
</div>
|
||||
<div class="details">
|
||||
<h2>{{ item.info.name }}</h2>
|
||||
<label *ngIf="item.info.description">
|
||||
{{ item.info.description }}
|
||||
</label>
|
||||
<label *ngIf="item.info.releasedate">
|
||||
<div class="label">Release date:</div>
|
||||
{{ item.info.releasedate }}
|
||||
</label>
|
||||
<label *ngIf="item.info.genre">
|
||||
<div class="label">Genre:</div>
|
||||
{{ item.info.genre }}
|
||||
</label>
|
||||
<label *ngIf="item.info.country">
|
||||
<div class="label">Country:</div>
|
||||
{{ item.info.country }}
|
||||
</label>
|
||||
<div *ngIf="item.info.actors">
|
||||
<div class="label">Actors:</div>
|
||||
{{ item.info.actors }}
|
||||
</div>
|
||||
<label *ngIf="item.info.director">
|
||||
<div class="label">Director:</div>
|
||||
{{ item.info.director }}
|
||||
</label>
|
||||
<label *ngIf="item.info.duration">
|
||||
<div class="label">Duration:</div>
|
||||
{{ item.info.duration }}
|
||||
</label>
|
||||
<div class="action-buttons">
|
||||
<button mat-flat-button color="accent" (click)="playClicked.emit()">
|
||||
Play
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,17 @@
|
||||
import { JsonPipe, NgIf } from '@angular/common';
|
||||
import { Component, EventEmitter, Input, Output } from '@angular/core';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { XtreamVodDetails } from '../../../../shared/xtream-vod-details.interface';
|
||||
|
||||
@Component({
|
||||
selector: 'app-vod-details',
|
||||
templateUrl: './vod-details.component.html',
|
||||
styleUrls: ['../detail-view.scss'],
|
||||
standalone: true,
|
||||
imports: [JsonPipe, MatButtonModule, NgIf],
|
||||
})
|
||||
export class VodDetailsComponent {
|
||||
@Input({ required: true }) item: XtreamVodDetails;
|
||||
|
||||
@Output() playClicked = new EventEmitter<void>();
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
<app-navigation-bar
|
||||
[breadcrumbs]="breadcrumbs"
|
||||
[contentType]="selectedContentType"
|
||||
(breadcrumbClicked)="breadcrumbClicked($event)"
|
||||
(contentTypeChanged)="changeContentType($event)"
|
||||
/>
|
||||
|
||||
<div class="container">
|
||||
<app-category-view
|
||||
*ngIf="currentLayout === 'category'"
|
||||
(categoryClicked)="categoryClicked($event)"
|
||||
[items]="items"
|
||||
/>
|
||||
|
||||
<app-category-content-view
|
||||
*ngIf="currentLayout === 'category_content'"
|
||||
[items]="items"
|
||||
(itemClicked)="itemClicked($event)"
|
||||
/>
|
||||
|
||||
<app-vod-details
|
||||
*ngIf="currentLayout === 'vod-details'"
|
||||
[item]="vodDetails"
|
||||
(playClicked)="playVod()"
|
||||
/>
|
||||
|
||||
<app-serial-details
|
||||
*ngIf="currentLayout === 'serie-details'"
|
||||
[item]="vodDetails"
|
||||
/>
|
||||
|
||||
<app-player-view *ngIf="currentLayout === 'player'" />
|
||||
</div>
|
||||
@@ -0,0 +1,4 @@
|
||||
.container {
|
||||
height: calc(100vh - 140px);
|
||||
overflow-y: auto;
|
||||
}
|
||||
@@ -0,0 +1,326 @@
|
||||
import { JsonPipe, KeyValuePipe, NgFor, NgIf, NgSwitch } from '@angular/common';
|
||||
import { Component, NgZone, OnInit, effect, inject } from '@angular/core';
|
||||
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { MatButtonToggleModule } from '@angular/material/button-toggle';
|
||||
import { MatCardModule } from '@angular/material/card';
|
||||
import { MatIconModule } from '@angular/material/icon';
|
||||
import { MatSnackBar } from '@angular/material/snack-bar';
|
||||
import { Store } from '@ngrx/store';
|
||||
import { IpcCommand } from '../../../shared/ipc-command.class';
|
||||
import {
|
||||
ERROR,
|
||||
OPEN_MPV_PLAYER,
|
||||
XTREAM_REQUEST,
|
||||
XTREAM_RESPONSE,
|
||||
} from '../../../shared/ipc-commands';
|
||||
import { XtreamCategory } from '../../../shared/xtream-category.interface';
|
||||
import { XtreamCodeActions } from '../../../shared/xtream-code-actions';
|
||||
import { XtreamLiveStream } from '../../../shared/xtream-live-stream.interface';
|
||||
import { XtreamResponse } from '../../../shared/xtream-response.interface';
|
||||
import { XtreamVodDetails } from '../../../shared/xtream-vod-details.interface';
|
||||
import { DataService } from '../services/data.service';
|
||||
import { selectCurrentPlaylist } from '../state/selectors';
|
||||
import { CategoryContentViewComponent } from './category-content-view/category-content-view.component';
|
||||
import { CategoryViewComponent } from './category-view/category-view.component';
|
||||
import { ContentType } from './content-type.enum';
|
||||
import { NavigationBarComponent } from './navigation-bar/navigation-bar.component';
|
||||
import { PlayerViewComponent } from './player-view/player-view.component';
|
||||
import { VodDetailsComponent } from './vod-details/vod-details.component';
|
||||
|
||||
import { XtreamSerieDetails } from '../../../shared/xtream-serie-details.interface';
|
||||
import { SerialDetailsComponent } from './serial-details/serial-details.component';
|
||||
|
||||
const ContentTypes = {
|
||||
LIVE_STREAMS: {
|
||||
title: 'Live streams',
|
||||
getContentAction: XtreamCodeActions.GetLiveStreams,
|
||||
getCategoryAction: XtreamCodeActions.GetLiveCategories,
|
||||
},
|
||||
VODS: {
|
||||
title: 'VOD streams',
|
||||
getContentAction: XtreamCodeActions.GetVodStreams,
|
||||
getCategoryAction: XtreamCodeActions.GetVodCategories,
|
||||
getDetailsAction: XtreamCodeActions.GetVodInfo,
|
||||
},
|
||||
SERIES: {
|
||||
title: 'Series',
|
||||
getContentAction: XtreamCodeActions.GetSeries,
|
||||
getCategoryAction: XtreamCodeActions.GetSeriesCategories,
|
||||
getDetailsAction: XtreamCodeActions.GetSeriesInfo,
|
||||
},
|
||||
};
|
||||
|
||||
export interface Breadcrumb {
|
||||
action: XtreamCodeActions;
|
||||
title: string;
|
||||
category_id?: string;
|
||||
}
|
||||
|
||||
type LayoutView =
|
||||
| 'category'
|
||||
| 'category_content'
|
||||
| 'vod-details'
|
||||
| 'player'
|
||||
| 'serie-details';
|
||||
|
||||
@Component({
|
||||
selector: 'app-xtream-main-container',
|
||||
templateUrl: './xtream-main-container.component.html',
|
||||
styleUrls: ['./xtream-main-container.component.scss'],
|
||||
standalone: true,
|
||||
imports: [
|
||||
KeyValuePipe,
|
||||
MatButtonToggleModule,
|
||||
NgFor,
|
||||
NgIf,
|
||||
JsonPipe,
|
||||
CategoryViewComponent,
|
||||
NgSwitch,
|
||||
MatButtonModule,
|
||||
MatCardModule,
|
||||
MatIconModule,
|
||||
NavigationBarComponent,
|
||||
VodDetailsComponent,
|
||||
PlayerViewComponent,
|
||||
CategoryContentViewComponent,
|
||||
SerialDetailsComponent,
|
||||
],
|
||||
})
|
||||
export class XtreamMainContainerComponent implements OnInit {
|
||||
dataService = inject(DataService);
|
||||
ngZone = inject(NgZone);
|
||||
snackBar = inject(MatSnackBar);
|
||||
store = inject(Store);
|
||||
currentPlaylist = this.store.selectSignal(selectCurrentPlaylist);
|
||||
|
||||
breadcrumbs: Breadcrumb[] = [];
|
||||
items = [];
|
||||
listeners = [];
|
||||
selectedContentType = ContentType.VODS;
|
||||
currentLayout: LayoutView = 'category';
|
||||
vodDetails!: XtreamVodDetails | XtreamSerieDetails;
|
||||
|
||||
commandsList = [
|
||||
new IpcCommand(XTREAM_RESPONSE, (response: XtreamResponse) =>
|
||||
this.handleResponse(response)
|
||||
),
|
||||
new IpcCommand(ERROR, (response: { message: string; status: number }) =>
|
||||
this.showErrorAsNotification(response)
|
||||
),
|
||||
];
|
||||
|
||||
constructor() {
|
||||
effect(() => {
|
||||
if (this.currentPlaylist()) {
|
||||
this.getCategories(this.selectedContentType);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
ngOnInit() {
|
||||
this.setInitialBreadcrumb();
|
||||
|
||||
this.commandsList.forEach((command) => {
|
||||
if (this.dataService.isElectron) {
|
||||
this.dataService.listenOn(command.id, (_event, response) =>
|
||||
this.ngZone.run(() => command.callback(response))
|
||||
);
|
||||
} else {
|
||||
const cb = (response) => {
|
||||
if (response.data.type === command.id) {
|
||||
command.callback(response.data);
|
||||
}
|
||||
};
|
||||
this.dataService.listenOn(command.id, cb);
|
||||
this.listeners.push(cb);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
handleResponse(response: XtreamResponse) {
|
||||
console.log(response.payload);
|
||||
switch (response.action) {
|
||||
case XtreamCodeActions.GetSeriesCategories:
|
||||
case XtreamCodeActions.GetVodCategories:
|
||||
case XtreamCodeActions.GetLiveCategories:
|
||||
case XtreamCodeActions.GetVodStreams:
|
||||
case XtreamCodeActions.GetLiveStreams:
|
||||
case XtreamCodeActions.GetSeries:
|
||||
this.items = response.payload as unknown[];
|
||||
break;
|
||||
case XtreamCodeActions.GetVodInfo:
|
||||
this.currentLayout = 'vod-details';
|
||||
this.vodDetails = response.payload as XtreamVodDetails;
|
||||
break;
|
||||
case XtreamCodeActions.GetSeriesInfo:
|
||||
this.currentLayout = 'serie-details';
|
||||
this.vodDetails = response.payload as XtreamSerieDetails;
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
getCategories(contentType: ContentType = this.selectedContentType) {
|
||||
this.currentLayout = 'category';
|
||||
const action = ContentTypes[contentType].getCategoryAction;
|
||||
this.setInitialBreadcrumb(action);
|
||||
this.sendRequest({ action });
|
||||
}
|
||||
|
||||
// TODO: MAKE IT ABSTRACT SOMEHOW
|
||||
ngOnDestroy(): void {
|
||||
if (this.dataService.isElectron) {
|
||||
this.commandsList.forEach((command) =>
|
||||
this.dataService.removeAllListeners(command.id)
|
||||
);
|
||||
} else {
|
||||
this.listeners.forEach((listener) => {
|
||||
window.removeEventListener('message', listener);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
setInitialBreadcrumb(action = XtreamCodeActions.GetVodCategories) {
|
||||
this.breadcrumbs = [{ title: 'All categories', action }];
|
||||
}
|
||||
|
||||
categoryClicked(item: XtreamCategory) {
|
||||
this.items = [];
|
||||
const action = ContentTypes[this.selectedContentType].getContentAction;
|
||||
this.breadcrumbs.push({
|
||||
title: item.category_name,
|
||||
category_id: item.category_id,
|
||||
action,
|
||||
});
|
||||
this.sendRequest({ action, category_id: item.category_id });
|
||||
this.currentLayout = 'category_content';
|
||||
}
|
||||
|
||||
itemClicked(
|
||||
item: any /* XtreamLiveStream | XtreamVodStream | XtreamSerieItem */
|
||||
) {
|
||||
let action;
|
||||
|
||||
this.items = [];
|
||||
if (item.stream_type && item.stream_type === 'movie') {
|
||||
action = XtreamCodeActions.GetVodInfo;
|
||||
this.breadcrumbs.push({ title: item.name, action });
|
||||
this.sendRequest({ action, vod_id: item.stream_id });
|
||||
} else if (item.stream_type && item.stream_type === 'live') {
|
||||
this.breadcrumbs.push({
|
||||
title: item.name,
|
||||
action: XtreamCodeActions.GetLiveStreams,
|
||||
});
|
||||
this.playStream(item);
|
||||
} else if (item.series_id) {
|
||||
action = XtreamCodeActions.GetSeriesInfo;
|
||||
this.breadcrumbs.push({ title: item.name, action });
|
||||
this.sendRequest({ action, series_id: item.series_id });
|
||||
}
|
||||
}
|
||||
|
||||
playStream(item: XtreamLiveStream) {
|
||||
this.currentLayout = 'player';
|
||||
const { serverUrl, username, password } = this.currentPlaylist();
|
||||
|
||||
const streamUrl = `${serverUrl}/${item.stream_type}/${username}/${password}/${item.stream_id}.ts`;
|
||||
|
||||
this.dataService.sendIpcEvent(OPEN_MPV_PLAYER, {
|
||||
url: streamUrl,
|
||||
});
|
||||
}
|
||||
|
||||
playVod() {
|
||||
this.currentLayout = 'player';
|
||||
const { serverUrl, username, password } = this.currentPlaylist();
|
||||
|
||||
this.items = [];
|
||||
const streamUrl = `${serverUrl}/movie/${username}/${password}/${
|
||||
(this.vodDetails as XtreamVodDetails).movie_data.stream_id
|
||||
}.${
|
||||
(this.vodDetails as XtreamVodDetails).movie_data.container_extension
|
||||
}`;
|
||||
|
||||
this.dataService.sendIpcEvent(OPEN_MPV_PLAYER, {
|
||||
url: streamUrl,
|
||||
});
|
||||
}
|
||||
|
||||
changeContentType(contentType: ContentType) {
|
||||
this.selectedContentType = contentType;
|
||||
this.getCategories();
|
||||
}
|
||||
|
||||
showErrorAsNotification(response: { message: string; status: number }) {
|
||||
this.snackBar.open(
|
||||
`Error: ${response?.message ?? 'Something went wrong'} (Status: ${
|
||||
response?.status ?? 0
|
||||
})`,
|
||||
null,
|
||||
{ duration: 4000 }
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Should get the position of the clicked breadcrumb and remove all the items after
|
||||
* @param breadcrumb clicked breadcrumb item
|
||||
*/
|
||||
breadcrumbClicked(breadcrumb: Breadcrumb) {
|
||||
this.items = [];
|
||||
const itemIndex = this.breadcrumbs.findIndex((i) => i === breadcrumb);
|
||||
|
||||
// do nothing if last breadcrumb child was clicked
|
||||
if (itemIndex === this.breadcrumbs.length - 1) return;
|
||||
|
||||
this.breadcrumbs.splice(
|
||||
itemIndex + 1,
|
||||
this.breadcrumbs.length - itemIndex - 1
|
||||
);
|
||||
this.currentLayout = this.getLayoutViewBasedOnAction(breadcrumb.action);
|
||||
this.sendRequest({
|
||||
action: breadcrumb.action,
|
||||
...(breadcrumb.category_id
|
||||
? { category_id: breadcrumb.category_id }
|
||||
: {}),
|
||||
});
|
||||
}
|
||||
|
||||
getLayoutViewBasedOnAction(action: XtreamCodeActions) {
|
||||
let result: LayoutView = 'category';
|
||||
switch (action) {
|
||||
case XtreamCodeActions.GetLiveCategories:
|
||||
case XtreamCodeActions.GetVodCategories:
|
||||
case XtreamCodeActions.GetSeriesCategories:
|
||||
result = 'category';
|
||||
break;
|
||||
case XtreamCodeActions.GetLiveStreams:
|
||||
case XtreamCodeActions.GetVodStreams:
|
||||
case XtreamCodeActions.GetSeries:
|
||||
result = 'category_content';
|
||||
break;
|
||||
case XtreamCodeActions.GetVodInfo:
|
||||
case XtreamCodeActions.GetSeriesInfo:
|
||||
result = 'vod-details';
|
||||
break;
|
||||
default:
|
||||
console.error(`Error: Unknown action was provided: ${action}`);
|
||||
break;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
sendRequest(params: Record<string, string | number>) {
|
||||
const { serverUrl, username, password } = this.currentPlaylist();
|
||||
this.dataService.sendIpcEvent(XTREAM_REQUEST, {
|
||||
url: serverUrl,
|
||||
params: {
|
||||
password,
|
||||
username,
|
||||
...params,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import { Injectable } from '@angular/core';
|
||||
import { ComponentStore } from '@ngrx/component-store';
|
||||
import { XtreamCategory } from '../../../shared/xtream-category.interface';
|
||||
|
||||
export interface XtreamState {
|
||||
liveStreams: any[];
|
||||
series: any[];
|
||||
vods: any[];
|
||||
vodCategories: XtreamCategory[];
|
||||
}
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class XtreamStore extends ComponentStore<XtreamState> {
|
||||
constructor() {
|
||||
super({ liveStreams: [], series: [], vods: [], vodCategories: [] });
|
||||
}
|
||||
|
||||
readonly liveStreams = this.selectSignal((state) => state.liveStreams);
|
||||
readonly series = this.selectSignal((state) => state.series);
|
||||
readonly vods = this.selectSignal((state) => state.vods);
|
||||
readonly vodCategories = this.selectSignal((state) => state.vodCategories);
|
||||
|
||||
readonly setLiveStreams = this.updater(
|
||||
(state, liveStreams: any[]): XtreamState => ({
|
||||
...state,
|
||||
liveStreams,
|
||||
})
|
||||
);
|
||||
|
||||
readonly setSerials = this.updater(
|
||||
(state, series: any[]): XtreamState => ({
|
||||
...state,
|
||||
series,
|
||||
})
|
||||
);
|
||||
|
||||
readonly setVods = this.updater(
|
||||
(state, vods: any[]): XtreamState => ({
|
||||
...state,
|
||||
vods,
|
||||
})
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user