feat: add xtream code api support

This commit is contained in:
4gray committed 2023-07-01 23:10:31 +02:00
1 parent d4a2741e45
commit eb87c0fe0a
34 files changed
+1451 -109

No files matched your search

+63 -35
View File
@@ -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
View File
@@ -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;
+7
View File
@@ -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: '',
+14 -3
View File
@@ -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>
+8 -1
View File
@@ -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 })
);
}
}
+27 -1
View File
@@ -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);
}
}
+45 -41
View File
@@ -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;
}
}
+5
View File
@@ -0,0 +1,5 @@
export enum ContentType {
LIVE_STREAMS = 'LIVE_STREAMS',
VODS = 'VODS',
SERIES = 'SERIES',
}
+53
View File
@@ -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,
},
});
}
}
+43
View File
@@ -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,
})
);
}