diff --git a/electron/api.ts b/electron/api.ts index 3e6800f40..2b56d57cb 100644 --- a/electron/api.ts +++ b/electron/api.ts @@ -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 }) => { + 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 diff --git a/electron/main.ts b/electron/main.ts index 8e0d868cb..dc931d8c2 100644 --- a/electron/main.ts +++ b/electron/main.ts @@ -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; diff --git a/src/app/app-routing.module.ts b/src/app/app-routing.module.ts index ce0accfc8..e7f084c48 100644 --- a/src/app/app-routing.module.ts +++ b/src/app/app-routing.module.ts @@ -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: '', diff --git a/src/app/home/home.component.html b/src/app/home/home.component.html index 3a9decb21..72da861a7 100644 --- a/src/app/home/home.component.html +++ b/src/app/home/home.component.html @@ -35,7 +35,7 @@ + /> @@ -48,7 +48,7 @@ + /> @@ -61,7 +61,18 @@ + /> + + + + + text_format +
+ {{ 'HOME.TABS.XTREME_IMPORT' | translate }} +
+
+ +
diff --git a/src/app/home/home.module.ts b/src/app/home/home.module.ts index bf0d97f1d..f645965e6 100644 --- a/src/app/home/home.module.ts +++ b/src/app/home/home.module.ts @@ -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, diff --git a/src/app/home/recent-playlists/recent-playlists.component.spec.ts b/src/app/home/recent-playlists/recent-playlists.component.spec.ts index 97b0b5ec4..5966f0fad 100644 --- a/src/app/home/recent-playlists/recent-playlists.component.spec.ts +++ b/src/app/home/recent-playlists/recent-playlists.component.spec.ts @@ -28,31 +28,26 @@ describe('RecentPlaylistsComponent', () => { let mockStore: MockStore; const actions$ = new Observable(); - 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); }); }); diff --git a/src/app/home/xtream-code-import/xtream-code-import.component.html b/src/app/home/xtream-code-import/xtream-code-import.component.html new file mode 100644 index 000000000..46cf43eda --- /dev/null +++ b/src/app/home/xtream-code-import/xtream-code-import.component.html @@ -0,0 +1,43 @@ +
+ + {{ + 'HOME.XTREAM_PLAYLIST.TITLE' | translate + }} + + + + {{ + 'HOME.XTREAM_PLAYLIST.SERVER_URL' | translate + }} + + {{ + 'HOME.XTREAM_PLAYLIST.URL_VALIDATION_ERROR' | translate + }} + {{ 'SETTINGS.EPG_URL_ERROR' | translate }} + + + {{ + 'HOME.XTREAM_PLAYLIST.USERNAME' | translate + }} + + + + {{ + 'HOME.XTREAM_PLAYLIST.PASSWORD' | translate + }} + + + +
diff --git a/src/app/home/xtream-code-import/xtream-code-import.component.ts b/src/app/home/xtream-code-import/xtream-code-import.component.ts new file mode 100644 index 000000000..0ceb180da --- /dev/null +++ b/src/app/home/xtream-code-import/xtream-code-import.component.ts @@ -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 }) + ); + } +} diff --git a/src/app/services/pwa.service.ts b/src/app/services/pwa.service.ts index 3877283d9..ca7665e20 100644 --- a/src/app/services/pwa.service.ts +++ b/src/app/services/pwa.service.ts @@ -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 } + ); } } @@ -136,6 +142,26 @@ export class PwaService extends DataService { return message; } + forwardXtreamRequest(payload: { + url: string; + params: Record; + }) { + 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); } } diff --git a/src/app/settings/settings.component.spec.ts b/src/app/settings/settings.component.spec.ts index 5e6af8bb8..f636ce9f5 100644 --- a/src/app/settings/settings.component.spec.ts +++ b/src/app/settings/settings.component.spec.ts @@ -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(() => { diff --git a/src/app/xtream/category-content-view/category-content-view.component.html b/src/app/xtream/category-content-view/category-content-view.component.html new file mode 100644 index 000000000..eaae99d38 --- /dev/null +++ b/src/app/xtream/category-content-view/category-content-view.component.html @@ -0,0 +1,43 @@ + + +
+ + + + +
+
{{ item.name }}
+
+
+
+
+ +
+ list +
This category is empty, try another one
+
+
diff --git a/src/app/xtream/category-content-view/category-content-view.component.scss b/src/app/xtream/category-content-view/category-content-view.component.scss new file mode 100644 index 000000000..35b140ee9 --- /dev/null +++ b/src/app/xtream/category-content-view/category-content-view.component.scss @@ -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; + } +} diff --git a/src/app/xtream/category-content-view/category-content-view.component.ts b/src/app/xtream/category-content-view/category-content-view.component.ts new file mode 100644 index 000000000..969e2d0c7 --- /dev/null +++ b/src/app/xtream/category-content-view/category-content-view.component.ts @@ -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(); + + searchText: string; + + trackByFn(_index: number, item: XtreamItem) { + return item.stream_id; + } +} diff --git a/src/app/xtream/category-view/category-view.component.ts b/src/app/xtream/category-view/category-view.component.ts new file mode 100644 index 000000000..7ca2a4c6e --- /dev/null +++ b/src/app/xtream/category-view/category-view.component.ts @@ -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: ` + + +
+ + + {{ item.category_name }} + + +
+
+ +
+ warning +
+ Oops, no content here, please change the category or content + type +
+
+
+ `, + 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(); + + searchText: string; + + trackByFn(_index: number, item: XtreamCategory) { + return item.category_id; + } +} diff --git a/src/app/xtream/content-type.enum.ts b/src/app/xtream/content-type.enum.ts new file mode 100644 index 000000000..7125355a3 --- /dev/null +++ b/src/app/xtream/content-type.enum.ts @@ -0,0 +1,5 @@ +export enum ContentType { + LIVE_STREAMS = 'LIVE_STREAMS', + VODS = 'VODS', + SERIES = 'SERIES', +} diff --git a/src/app/xtream/detail-view.scss b/src/app/xtream/detail-view.scss new file mode 100644 index 000000000..e7ce35a6b --- /dev/null +++ b/src/app/xtream/detail-view.scss @@ -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; +} diff --git a/src/app/xtream/navigation-bar/navigation-bar.component.html b/src/app/xtream/navigation-bar/navigation-bar.component.html new file mode 100644 index 000000000..19bfdcf41 --- /dev/null +++ b/src/app/xtream/navigation-bar/navigation-bar.component.html @@ -0,0 +1,35 @@ + + diff --git a/src/app/xtream/navigation-bar/navigation-bar.component.scss b/src/app/xtream/navigation-bar/navigation-bar.component.scss new file mode 100644 index 000000000..b69bfb894 --- /dev/null +++ b/src/app/xtream/navigation-bar/navigation-bar.component.scss @@ -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; + } +} diff --git a/src/app/xtream/navigation-bar/navigation-bar.component.ts b/src/app/xtream/navigation-bar/navigation-bar.component.ts new file mode 100644 index 000000000..625e25e39 --- /dev/null +++ b/src/app/xtream/navigation-bar/navigation-bar.component.ts @@ -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(); + @Output() breadcrumbClicked = new EventEmitter(); + + ContentTypeEnum = ContentType; +} diff --git a/src/app/xtream/player-dialog/player-dialog.component.html b/src/app/xtream/player-dialog/player-dialog.component.html new file mode 100644 index 000000000..aa0d3184a --- /dev/null +++ b/src/app/xtream/player-dialog/player-dialog.component.html @@ -0,0 +1,13 @@ +

Title

+ + + diff --git a/src/app/xtream/player-dialog/player-dialog.component.ts b/src/app/xtream/player-dialog/player-dialog.component.ts new file mode 100644 index 000000000..1d79ac6ce --- /dev/null +++ b/src/app/xtream/player-dialog/player-dialog.component.ts @@ -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; + } +} diff --git a/src/app/xtream/player-view/player-view.component.html b/src/app/xtream/player-view/player-view.component.html new file mode 100644 index 000000000..0e927b7f4 --- /dev/null +++ b/src/app/xtream/player-view/player-view.component.html @@ -0,0 +1,20 @@ +
+ live_tv +
+ The video is playing in external player window.
+ Please make sure that mpv player is correctly installed on your + system.
+ See + installation instructions + for more details. +
+
diff --git a/src/app/xtream/player-view/player-view.component.ts b/src/app/xtream/player-view/player-view.component.ts new file mode 100644 index 000000000..0fce1e532 --- /dev/null +++ b/src/app/xtream/player-view/player-view.component.ts @@ -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'); + } +} diff --git a/src/app/xtream/season-container/season-container.component.html b/src/app/xtream/season-container/season-container.component.html new file mode 100644 index 000000000..52bbcb39e --- /dev/null +++ b/src/app/xtream/season-container/season-container.component.html @@ -0,0 +1,30 @@ +

Seasons and episodes

+
+ + + Season {{ season.key }} + + + + + + < Back to seasons + + + +
+ {{ episode.episode_num }}. {{ episode.title }} +
+
+
+
diff --git a/src/app/xtream/season-container/season-container.component.scss b/src/app/xtream/season-container/season-container.component.scss new file mode 100644 index 000000000..d3667809a --- /dev/null +++ b/src/app/xtream/season-container/season-container.component.scss @@ -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%; + } +} diff --git a/src/app/xtream/season-container/season-container.component.ts b/src/app/xtream/season-container/season-container.component.ts new file mode 100644 index 000000000..e003088ff --- /dev/null +++ b/src/app/xtream/season-container/season-container.component.ts @@ -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; + + @Output() episodeClicked = new EventEmitter(); + + selectedSeason: string; + + selectSeason(seasonKey: string) { + this.selectedSeason = seasonKey; + } + + selectEpisode(episode: any) { + this.episodeClicked.emit(episode); + } +} diff --git a/src/app/xtream/serial-details/serial-details.component.html b/src/app/xtream/serial-details/serial-details.component.html new file mode 100644 index 000000000..a92230b7c --- /dev/null +++ b/src/app/xtream/serial-details/serial-details.component.html @@ -0,0 +1,49 @@ +
+
+ +
+
+

{{ item.info.name }}

+ + + + +
+
Cast:
+ {{ item.info.cast }} +
+ + +
+
+ + diff --git a/src/app/xtream/serial-details/serial-details.component.ts b/src/app/xtream/serial-details/serial-details.component.ts new file mode 100644 index 000000000..12f94cbd1 --- /dev/null +++ b/src/app/xtream/serial-details/serial-details.component.ts @@ -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 }); + } +} diff --git a/src/app/xtream/vod-details/vod-details.component.html b/src/app/xtream/vod-details/vod-details.component.html new file mode 100644 index 000000000..bd6e790b2 --- /dev/null +++ b/src/app/xtream/vod-details/vod-details.component.html @@ -0,0 +1,49 @@ +
+
+ +
+
+

{{ item.info.name }}

+ + + + +
+
Actors:
+ {{ item.info.actors }} +
+ + +
+ +
+
+
diff --git a/src/app/xtream/vod-details/vod-details.component.ts b/src/app/xtream/vod-details/vod-details.component.ts new file mode 100644 index 000000000..21f210392 --- /dev/null +++ b/src/app/xtream/vod-details/vod-details.component.ts @@ -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(); +} diff --git a/src/app/xtream/xtream-main-container.component.html b/src/app/xtream/xtream-main-container.component.html new file mode 100644 index 000000000..3109fba86 --- /dev/null +++ b/src/app/xtream/xtream-main-container.component.html @@ -0,0 +1,33 @@ + + +
+ + + + + + + + + +
diff --git a/src/app/xtream/xtream-main-container.component.scss b/src/app/xtream/xtream-main-container.component.scss new file mode 100644 index 000000000..37eac69c2 --- /dev/null +++ b/src/app/xtream/xtream-main-container.component.scss @@ -0,0 +1,4 @@ +.container { + height: calc(100vh - 140px); + overflow-y: auto; +} diff --git a/src/app/xtream/xtream-main-container.component.ts b/src/app/xtream/xtream-main-container.component.ts new file mode 100644 index 000000000..8d145b947 --- /dev/null +++ b/src/app/xtream/xtream-main-container.component.ts @@ -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) { + const { serverUrl, username, password } = this.currentPlaylist(); + this.dataService.sendIpcEvent(XTREAM_REQUEST, { + url: serverUrl, + params: { + password, + username, + ...params, + }, + }); + } +} diff --git a/src/app/xtream/xtream.store.ts b/src/app/xtream/xtream.store.ts new file mode 100644 index 000000000..d83f8da98 --- /dev/null +++ b/src/app/xtream/xtream.store.ts @@ -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 { + 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, + }) + ); +}