mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-10 10:06:15 -08:00
Merge branch 'electron'
This commit is contained in:
commit
ff8b5f9225
124 files changed
+13705
-7031
No files matched your search
@@ -20,10 +20,12 @@ jobs:
|
||||
uses: docker/setup-buildx-action@v2
|
||||
-
|
||||
name: Build and push
|
||||
uses: docker/build-push-action@v3
|
||||
uses: docker/build-push-action@v4
|
||||
with:
|
||||
context: .
|
||||
file: ./docker/Dockerfile
|
||||
push: true
|
||||
tags: ${{ secrets.DOCKERHUB_USERNAME }}/iptvnator:latest
|
||||
platforms: linux/amd64,linux/arm64,linux/arm/v7
|
||||
tags: |
|
||||
4gray/iptvnator:latest
|
||||
4gray/iptvnator:${{ github.sha }}
|
||||
platforms: linux/amd64,linux/arm64,linux/arm/v7
|
||||
Vendored
+2
-1
@@ -2,5 +2,6 @@
|
||||
"i18n-ally.localesPaths": "src/assets/i18n",
|
||||
"i18n-ally.keystyle": "nested",
|
||||
"cSpell.words": ["iptvnator", "lcov", "titlebar", "Whitespaces"],
|
||||
"cSpell.ignorePaths": ["node_modules", "package.json", "package-lock.json"]
|
||||
"cSpell.ignorePaths": ["node_modules", "package.json", "package-lock.json"],
|
||||
"angular.enable-strict-mode-prompt": false
|
||||
}
|
||||
+13
-23
@@ -19,24 +19,24 @@
|
||||
},
|
||||
"architect": {
|
||||
"build": {
|
||||
"builder": "@angular-builders/custom-webpack:browser",
|
||||
"builder": "@angular-devkit/build-angular:browser-esbuild",
|
||||
"options": {
|
||||
"outputPath": "dist",
|
||||
"index": "src/index.html",
|
||||
"main": "src/main.ts",
|
||||
"tsConfig": "src/tsconfig.app.json",
|
||||
"polyfills": "src/polyfills.ts",
|
||||
"polyfills": ["zone.js"],
|
||||
"assets": [
|
||||
"src/favicon.ico",
|
||||
"src/assets",
|
||||
"src/manifest.webmanifest"
|
||||
],
|
||||
"styles": ["src/styles.scss"],
|
||||
"styles": [
|
||||
"./node_modules/@angular/material/prebuilt-themes/indigo-pink.css",
|
||||
"src/styles.scss",
|
||||
"./node_modules/video.js/dist/video-js.css"
|
||||
],
|
||||
"scripts": [],
|
||||
"customWebpackConfig": {
|
||||
"path": "./angular.webpack.js",
|
||||
"replaceDuplicatePlugins": true
|
||||
},
|
||||
"serviceWorker": true,
|
||||
"ngswConfigPath": "ngsw-config.json",
|
||||
"webWorkerTsConfig": "tsconfig.worker.json"
|
||||
@@ -47,10 +47,10 @@
|
||||
"outputHashing": "none",
|
||||
"sourceMap": true,
|
||||
"namedChunks": false,
|
||||
"aot": false,
|
||||
"aot": true,
|
||||
"extractLicenses": true,
|
||||
"vendorChunk": false,
|
||||
"buildOptimizer": false,
|
||||
"buildOptimizer": true,
|
||||
"fileReplacements": [
|
||||
{
|
||||
"replace": "src/environments/environment.ts",
|
||||
@@ -93,7 +93,7 @@
|
||||
}
|
||||
},
|
||||
"serve": {
|
||||
"builder": "@angular-builders/custom-webpack:dev-server",
|
||||
"builder": "@angular-devkit/build-angular:dev-server",
|
||||
"options": {
|
||||
"browserTarget": "iptvnator:build"
|
||||
},
|
||||
@@ -116,20 +116,10 @@
|
||||
}
|
||||
},
|
||||
"test": {
|
||||
"builder": "@angular-builders/custom-webpack:karma",
|
||||
"builder": "@angular-devkit/build-angular:jest",
|
||||
"options": {
|
||||
"main": "src/test.ts",
|
||||
"polyfills": "src/polyfills-test.ts",
|
||||
"tsConfig": "src/tsconfig.spec.json",
|
||||
"karmaConfig": "src/karma.conf.js",
|
||||
"scripts": [],
|
||||
"styles": ["src/styles.scss"],
|
||||
"assets": ["src/assets"],
|
||||
"customWebpackConfig": {
|
||||
"path": "./angular.webpack.js",
|
||||
"replaceDuplicatePlugins": true
|
||||
},
|
||||
"webWorkerTsConfig": "tsconfig.worker.json"
|
||||
"tsConfig": "tsconfig.spec.json",
|
||||
"polyfills": ["zone.js", "zone.js/testing"]
|
||||
}
|
||||
},
|
||||
"lint": {
|
||||
|
||||
+68
-37
@@ -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 {
|
||||
@@ -150,8 +162,11 @@ export class Api {
|
||||
.on(
|
||||
CHANNEL_SET_USER_AGENT,
|
||||
(_event, args: { userAgent: string; referer?: string }) => {
|
||||
if (args.userAgent && args.referer) {
|
||||
this.setUserAgent(args.userAgent, args.referer);
|
||||
if (args.userAgent && args.referer !== undefined) {
|
||||
this.setUserAgent(
|
||||
args.userAgent,
|
||||
args.referer || 'localhost'
|
||||
);
|
||||
} else {
|
||||
this.setUserAgent(this.defaultUserAgent, 'localhost');
|
||||
}
|
||||
@@ -215,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);
|
||||
@@ -231,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
|
||||
@@ -272,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%']
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -354,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;
|
||||
|
||||
+1
-1
@@ -127,7 +127,7 @@ export class AppMenu {
|
||||
__dirname,
|
||||
'../dist/assets/icons/icon.png'
|
||||
),
|
||||
copyright: 'Copyright (c) 2020-2022 4gray',
|
||||
copyright: 'Copyright (c) 2020-2023 4gray',
|
||||
package_json_dir: path.join(__dirname, '../'),
|
||||
}),
|
||||
},
|
||||
|
||||
+3
-10
@@ -1,14 +1,7 @@
|
||||
const customTitlebar = require('custom-electron-titlebar');
|
||||
const { ipcRenderer } = require('electron');
|
||||
import { Titlebar, TitlebarColor } from 'custom-electron-titlebar';
|
||||
|
||||
window.addEventListener('DOMContentLoaded', () => {
|
||||
new customTitlebar.Titlebar({
|
||||
backgroundColor: customTitlebar.Color.fromHex('#000'),
|
||||
onMinimize: () => ipcRenderer.send('window-minimize'),
|
||||
onMaximize: () => ipcRenderer.send('window-maximize'),
|
||||
onClose: () => ipcRenderer.send('window-close'),
|
||||
isMaximized: () => ipcRenderer.sendSync('window-is-maximized'),
|
||||
onMenuItemClick: (commandId) =>
|
||||
ipcRenderer.send('menu-event', commandId),
|
||||
new Titlebar({
|
||||
backgroundColor: TitlebarColor.fromHex('#000'),
|
||||
});
|
||||
});
|
||||
Generated
+10811
-6571
File diff suppressed because it is too large.
Load diff
+44
-44
@@ -51,71 +51,71 @@
|
||||
"build-pwa": "ng build -c production --base-href /iptvnator --deploy-url /iptvnator/"
|
||||
},
|
||||
"dependencies": {
|
||||
"@angular/animations": "15.1.1",
|
||||
"@angular/cdk": "14.0.5",
|
||||
"@angular/material": "14.0.5",
|
||||
"@electron/remote": "2.0.8",
|
||||
"@ngrx/effects": "15.1.0",
|
||||
"@ngrx/entity": "15.1.0",
|
||||
"@ngrx/store": "15.1.0",
|
||||
"@ngrx/store-devtools": "15.1.0",
|
||||
"@ngx-pwa/local-storage": "15.0.0",
|
||||
"@angular/animations": "16.1.5",
|
||||
"@angular/cdk": "16.1.5",
|
||||
"@angular/material": "16.1.5",
|
||||
"@electron/remote": "2.0.10",
|
||||
"@ngrx/component-store": "16.1.0",
|
||||
"@ngrx/effects": "16.1.0",
|
||||
"@ngrx/entity": "16.1.0",
|
||||
"@ngrx/router-store": "16.1.0",
|
||||
"@ngrx/store": "16.1.0",
|
||||
"@ngrx/store-devtools": "16.1.0",
|
||||
"@ngx-pwa/local-storage": "16.0.0",
|
||||
"@videojs/http-streaming": "2.15.0",
|
||||
"@yangkghjh/videojs-aspect-ratio-panel": "0.0.1",
|
||||
"about-window": "1.15.2",
|
||||
"axios": "0.21.2",
|
||||
"custom-electron-titlebar": "4.1.0",
|
||||
"custom-electron-titlebar": "4.2.3",
|
||||
"date-fns": "2.29.3",
|
||||
"electron-context-menu": "3.5.0",
|
||||
"electron-context-menu": "3.6.1",
|
||||
"electron-store": "8.1.0",
|
||||
"epg-parser": "0.1.6",
|
||||
"hls.js": "1.2.7",
|
||||
"iptv-playlist-parser": "0.8.0",
|
||||
"lodash": "4.17.21",
|
||||
"material-icons": "0.3.1",
|
||||
"moment": "2.29.4",
|
||||
"nedb-promises": "6.0.3",
|
||||
"ngx-indexed-db": "9.4.2",
|
||||
"ngx-indexed-db": "16.0.0",
|
||||
"ngx-skeleton-loader": "7.0.0",
|
||||
"ngx-whats-new": "0.4.0",
|
||||
"node-mpv": "2.0.0-beta.2",
|
||||
"node-mpv": "github:4gray/Node-MPV",
|
||||
"rxjs": "7.8.0",
|
||||
"semver": "7.3.8",
|
||||
"uuid": "9.0.0",
|
||||
"video.js": "7.20.3",
|
||||
"videojs-contrib-quality-levels": "2.2.0",
|
||||
"videojs-hls-quality-selector": "1.1.4",
|
||||
"zone.js": "~0.11.4"
|
||||
"zone.js": "~0.13.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@angular-builders/custom-webpack": "15.0.0",
|
||||
"@angular-devkit/build-angular": "15.1.2",
|
||||
"@angular-eslint/builder": "15.2.0",
|
||||
"@angular-eslint/eslint-plugin": "15.2.0",
|
||||
"@angular-eslint/eslint-plugin-template": "15.2.0",
|
||||
"@angular-eslint/schematics": "15.2.0",
|
||||
"@angular-eslint/template-parser": "15.2.0",
|
||||
"@angular/cli": "15.1.2",
|
||||
"@angular/common": "15.1.1",
|
||||
"@angular/compiler": "15.1.1",
|
||||
"@angular/compiler-cli": "15.1.1",
|
||||
"@angular/core": "15.1.1",
|
||||
"@angular/forms": "15.1.1",
|
||||
"@angular/language-service": "15.1.1",
|
||||
"@angular/platform-browser": "15.1.1",
|
||||
"@angular/platform-browser-dynamic": "15.1.1",
|
||||
"@angular/router": "15.1.1",
|
||||
"@angular/service-worker": "15.1.1",
|
||||
"@angular-devkit/build-angular": "16.1.4",
|
||||
"@angular-eslint/builder": "16.1.0",
|
||||
"@angular-eslint/eslint-plugin": "16.1.0",
|
||||
"@angular-eslint/eslint-plugin-template": "16.1.0",
|
||||
"@angular-eslint/schematics": "16.1.0",
|
||||
"@angular-eslint/template-parser": "16.1.0",
|
||||
"@angular/cli": "16.1.4",
|
||||
"@angular/common": "16.1.5",
|
||||
"@angular/compiler": "16.1.5",
|
||||
"@angular/compiler-cli": "16.1.5",
|
||||
"@angular/core": "16.1.5",
|
||||
"@angular/forms": "16.1.5",
|
||||
"@angular/language-service": "16.1.5",
|
||||
"@angular/platform-browser": "16.1.5",
|
||||
"@angular/platform-browser-dynamic": "16.1.5",
|
||||
"@angular/router": "16.1.5",
|
||||
"@angular/service-worker": "16.1.5",
|
||||
"@commitlint/cli": "16.0.1",
|
||||
"@commitlint/config-conventional": "16.0.0",
|
||||
"@ngrx/eslint-plugin": "^14.3.2",
|
||||
"@ngrx/eslint-plugin": "^16.1.0",
|
||||
"@ngx-translate/core": "14.0.0",
|
||||
"@ngx-translate/http-loader": "7.0.0",
|
||||
"@playwright/test": "1.31.1",
|
||||
"@semantic-release/changelog": "6.0.1",
|
||||
"@semantic-release/git": "10.0.1",
|
||||
"@semantic-release/npm": "9.0.0",
|
||||
"@types/jest": "28.1.5",
|
||||
"@types/jest": "29.5.1",
|
||||
"@types/mocha": "9.0.0",
|
||||
"@types/node": "18.11.18",
|
||||
"@types/video.js": "7.3.29",
|
||||
@@ -124,31 +124,31 @@
|
||||
"@typescript-eslint/eslint-plugin-tslint": "5.40.1",
|
||||
"@typescript-eslint/parser": "5.40.1",
|
||||
"conventional-changelog-cli": "2.2.2",
|
||||
"electron": "22.0.0",
|
||||
"electron-builder": "23.6.0",
|
||||
"electron": "25.2.0",
|
||||
"electron-builder": "24.4.0",
|
||||
"eslint": "8.26.0",
|
||||
"eslint-plugin-import": "2.26.0",
|
||||
"husky": "5.0.9",
|
||||
"jest": "28.1.3",
|
||||
"jest-preset-angular": "12.2.5",
|
||||
"ng-mocks": "14.6.0",
|
||||
"jest": "29.5.0",
|
||||
"jest-preset-angular": "13.1.0",
|
||||
"ng-mocks": "14.10.0",
|
||||
"node-polyfill-webpack-plugin": "1.1.4",
|
||||
"npm-run-all": "4.1.5",
|
||||
"playwright": "1.31.1",
|
||||
"playwright-core": "1.31.1",
|
||||
"prettier": "2.5.1",
|
||||
"prettier": "2.8.8",
|
||||
"semantic-release": "19.0.3",
|
||||
"stylelint": "14.2.0",
|
||||
"stylelint-config-standard": "24.0.0",
|
||||
"stylelint-scss": "4.1.0",
|
||||
"ts-jest": "28.0.6",
|
||||
"ts-jest": "29.1.0",
|
||||
"ts-node": "10.9.1",
|
||||
"tslib": "2.3.0",
|
||||
"typescript": "4.9.4",
|
||||
"typescript": "5.0.4",
|
||||
"wait-on": "6.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14.0.0"
|
||||
"node": ">=16.0.0"
|
||||
},
|
||||
"browserslist": [
|
||||
"chrome 91"
|
||||
|
||||
@@ -43,3 +43,8 @@ export const AUTO_UPDATE_PLAYLISTS_RESPONSE = 'AUTO_UPDATE_RESPONSE';
|
||||
|
||||
// Experimental
|
||||
export const OPEN_MPV_PLAYER = 'OPEN_MPV_PLAYER';
|
||||
export const SET_MPV_PLAYER_PATH = 'SET_MPV_PLAYER_PATH';
|
||||
|
||||
// Xtream
|
||||
export const XTREAM_REQUEST = 'XTREAM_REQUEST';
|
||||
export const XTREAM_RESPONSE = 'XTREAM_RESPONSE';
|
||||
@@ -13,11 +13,11 @@ export enum PlaylistUpdateState {
|
||||
export interface Playlist {
|
||||
_id: string;
|
||||
title: string;
|
||||
filename: string;
|
||||
playlist: any;
|
||||
filename?: string;
|
||||
playlist?: any;
|
||||
importDate: string;
|
||||
lastUsage: string;
|
||||
favorites: string[];
|
||||
favorites?: string[];
|
||||
items?: unknown[];
|
||||
header?: unknown;
|
||||
count: number;
|
||||
@@ -29,4 +29,7 @@ export interface Playlist {
|
||||
updateState?: PlaylistUpdateState;
|
||||
position?: number;
|
||||
isTemporary?: boolean;
|
||||
serverUrl?: string;
|
||||
username?: string;
|
||||
password?: string;
|
||||
}
|
||||
@@ -88,3 +88,7 @@ export const createPlaylistObject = (
|
||||
...(uploadType === 'FILE' ? { filePath: urlOrPath } : {}),
|
||||
};
|
||||
};
|
||||
|
||||
export const getExtensionFromUrl = (url: string) => {
|
||||
return url.split(/[#?]/)[0].split('.').pop().trim();
|
||||
};
|
||||
@@ -0,0 +1,5 @@
|
||||
export interface XtreamCategory {
|
||||
category_id: string;
|
||||
category_name: string;
|
||||
parent_id: number;
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
export enum XtreamCodeActions {
|
||||
GetLiveStreams = 'get_live_streams',
|
||||
GetLiveCategories = 'get_live_categories',
|
||||
GetSeries = 'get_series',
|
||||
GetSeriesCategories = 'get_series_categories',
|
||||
GetSeriesInfo = 'get_series_info',
|
||||
GetVodStreams = 'get_vod_streams',
|
||||
GetVodCategories = 'get_vod_categories',
|
||||
GetVodInfo = 'get_vod_info',
|
||||
GetShortEpg = 'get_short_epg',
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
export interface XtreamItem {
|
||||
num: number;
|
||||
name: string;
|
||||
stream_type: 'live' | 'movie';
|
||||
stream_id: number;
|
||||
stream_icon: string;
|
||||
added: string;
|
||||
category_id: string;
|
||||
custom_sid: string;
|
||||
direct_source: string;
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import { XtreamItem } from './xtream-item.interface';
|
||||
|
||||
export interface XtreamLiveStream extends XtreamItem {
|
||||
stream_type: 'live';
|
||||
epg_channel_id?: number;
|
||||
tv_archive: number;
|
||||
tv_archive_duration: number;
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import { XtreamCodeActions } from './xtream-code-actions';
|
||||
|
||||
export interface XtreamResponse {
|
||||
payload: unknown;
|
||||
action: XtreamCodeActions;
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
export interface XtreamSerieDetails {
|
||||
seasons: XtreamSerieSeason[];
|
||||
info: XtreamSerieInfo;
|
||||
episodes: Record<string, XtreamSerieEpisode[]>;
|
||||
}
|
||||
|
||||
export interface XtreamSerieInfo {
|
||||
name: string;
|
||||
cover: string;
|
||||
plot: string;
|
||||
cast: string;
|
||||
director: string;
|
||||
genre: string;
|
||||
releaseDate: string;
|
||||
last_modified: string;
|
||||
rating: string;
|
||||
rating_5based: number;
|
||||
backdrop_path: string[];
|
||||
youtube_trailer: string;
|
||||
episode_run_time: string;
|
||||
category_id: string;
|
||||
}
|
||||
|
||||
export interface XtreamSerieEpisode {
|
||||
id: string;
|
||||
episode_num: number;
|
||||
title: string;
|
||||
container_extension: string;
|
||||
info: {
|
||||
tmdb_id: number;
|
||||
releasedate: string;
|
||||
plot: string;
|
||||
duration_secs: number;
|
||||
duration: string;
|
||||
movie_image: string;
|
||||
video: Record<string, string>; // TODO
|
||||
audio: Record<string, string>; // TODO
|
||||
bitrate: number;
|
||||
rating: number;
|
||||
season: string;
|
||||
};
|
||||
custom_sid: string;
|
||||
added: string;
|
||||
season: number;
|
||||
direct_source: string;
|
||||
}
|
||||
|
||||
export interface XtreamSerieSeason {
|
||||
air_date: string;
|
||||
episode_count: number;
|
||||
id: number;
|
||||
name: string;
|
||||
overview: string;
|
||||
season_number: number;
|
||||
cover: string;
|
||||
cover_big: string;
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
export interface XtreamSerieItem {
|
||||
num: number;
|
||||
name: string;
|
||||
series_id: number;
|
||||
cover: string;
|
||||
plot: string;
|
||||
cast: string;
|
||||
director: string;
|
||||
genre: string;
|
||||
releaseDate: string;
|
||||
last_modified: string;
|
||||
rating: string;
|
||||
rating_5based: number;
|
||||
backdrop_path: string[];
|
||||
youtube_trailer: string;
|
||||
episode_run_time: string;
|
||||
category_id: number;
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
export interface XtreamVodDetails {
|
||||
info: {
|
||||
kinopoisk_url: string;
|
||||
tmdb_id: number;
|
||||
name: string;
|
||||
o_name: string;
|
||||
cover_big: string;
|
||||
movie_image: string;
|
||||
releasedate: string;
|
||||
episode_run_time: number;
|
||||
youtube_trailer: string;
|
||||
director: string;
|
||||
actors: string;
|
||||
cast: string;
|
||||
description: string;
|
||||
plot: string;
|
||||
age: string;
|
||||
mpaa_rating: string;
|
||||
rating_count_kinopoisk: number;
|
||||
country: string;
|
||||
genre: string;
|
||||
backdrop_path: string[];
|
||||
duration_secs: number;
|
||||
duration: string;
|
||||
video: string[];
|
||||
audio: string[];
|
||||
bitrate: number;
|
||||
rating: number;
|
||||
};
|
||||
movie_data: {
|
||||
stream_id: number;
|
||||
name: string;
|
||||
added: string;
|
||||
category_id: string;
|
||||
container_extension: string;
|
||||
custom_sid: string;
|
||||
direct_source: string;
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import { XtreamItem } from './xtream-item.interface';
|
||||
|
||||
export interface XtreamVodStream extends XtreamItem {
|
||||
stream_type: 'movie';
|
||||
rating: number;
|
||||
rating_5based: number;
|
||||
container_extension: string;
|
||||
}
|
||||
@@ -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: '',
|
||||
|
||||
@@ -233,11 +233,14 @@ export class AppComponent {
|
||||
|
||||
showErrorAsNotification(response: { message: string; status: number }) {
|
||||
this.snackBar.open(
|
||||
`Error: ${response.message} (Status: ${response.status})`,
|
||||
`Error: ${response?.message ?? 'Something went wrong'} (Status: ${
|
||||
response?.status ?? 0
|
||||
})`,
|
||||
null,
|
||||
{ duration: 4000 }
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes all ipc command listeners on component destroy
|
||||
*/
|
||||
|
||||
@@ -5,6 +5,7 @@ import { BrowserAnimationsModule } from '@angular/platform-browser/animations';
|
||||
import { ServiceWorkerModule } from '@angular/service-worker';
|
||||
// NG Translate
|
||||
import { EffectsModule } from '@ngrx/effects';
|
||||
import { StoreRouterConnectingModule, routerReducer } from '@ngrx/router-store';
|
||||
import { StoreModule } from '@ngrx/store';
|
||||
import { StoreDevtoolsModule } from '@ngrx/store-devtools';
|
||||
import { TranslateLoader, TranslateModule } from '@ngx-translate/core';
|
||||
@@ -70,7 +71,10 @@ export function DataFactory(dbService: NgxIndexedDBService, http: HttpClient) {
|
||||
enabled: AppConfig.production && !isElectron(),
|
||||
registrationStrategy: 'registerWhenStable:30000',
|
||||
}),
|
||||
StoreModule.forRoot({}),
|
||||
StoreModule.forRoot({
|
||||
router: routerReducer,
|
||||
}),
|
||||
StoreRouterConnectingModule.forRoot(),
|
||||
StoreModule.forFeature('playlistState', playlistReducer),
|
||||
EffectsModule.forRoot([PlaylistEffects]),
|
||||
StoreDevtoolsModule.instrument({
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -12,13 +12,31 @@
|
||||
}}</mat-label>
|
||||
<input formControlName="title" matInput />
|
||||
</mat-form-field>
|
||||
<mat-form-field class="full-width" *ngIf="playlist.serverUrl">
|
||||
<mat-label>{{
|
||||
'HOME.PLAYLISTS.INFO_DIALOG.SERVER_URL' | translate
|
||||
}}</mat-label>
|
||||
<input matInput formControlName="serverUrl" />
|
||||
</mat-form-field>
|
||||
<mat-form-field class="full-width" *ngIf="playlist.username">
|
||||
<mat-label>{{
|
||||
'HOME.PLAYLISTS.INFO_DIALOG.USERNAME' | translate
|
||||
}}</mat-label>
|
||||
<input matInput formControlName="username" />
|
||||
</mat-form-field>
|
||||
<mat-form-field class="full-width" *ngIf="playlist.password">
|
||||
<mat-label>{{
|
||||
'HOME.PLAYLISTS.INFO_DIALOG.PASSWORD' | translate
|
||||
}}</mat-label>
|
||||
<input matInput formControlName="password" />
|
||||
</mat-form-field>
|
||||
<mat-form-field class="full-width" *ngIf="playlist.url">
|
||||
<mat-label>{{
|
||||
'HOME.PLAYLISTS.INFO_DIALOG.FROM_URL' | translate
|
||||
}}</mat-label>
|
||||
<input matInput formControlName="url" />
|
||||
</mat-form-field>
|
||||
<mat-form-field class="full-width">
|
||||
<mat-form-field class="full-width" *ngIf="playlist.filename">
|
||||
<mat-label>{{
|
||||
'HOME.PLAYLISTS.INFO_DIALOG.ORIGINAL_FILENAME' | translate
|
||||
}}</mat-label>
|
||||
@@ -30,7 +48,7 @@
|
||||
}}</mat-label>
|
||||
<input matInput formControlName="importDate" />
|
||||
</mat-form-field>
|
||||
<mat-form-field class="full-width">
|
||||
<mat-form-field class="full-width" *ngIf="playlist.count">
|
||||
<mat-label>{{
|
||||
'HOME.PLAYLISTS.INFO_DIALOG.CHANNELS' | translate
|
||||
}}</mat-label>
|
||||
@@ -67,10 +85,15 @@
|
||||
</ng-container>
|
||||
</mat-dialog-content>
|
||||
<mat-dialog-actions align="end">
|
||||
<button mat-flat-button color="accent" (click)="exportPlaylist()">
|
||||
<button
|
||||
mat-flat-button
|
||||
color="accent"
|
||||
(click)="exportPlaylist()"
|
||||
*ngIf="!playlist.serverUrl"
|
||||
>
|
||||
{{ 'HOME.PLAYLISTS.INFO_DIALOG.EXPORT_PLAYLIST' | translate }}
|
||||
</button>
|
||||
<div style="flex: 1 1 auto"></div>
|
||||
<div class="spacer"></div>
|
||||
<button
|
||||
mat-flat-button
|
||||
mat-dialog-close
|
||||
|
||||
@@ -7,7 +7,7 @@ import {
|
||||
ReactiveFormsModule,
|
||||
UntypedFormBuilder,
|
||||
} from '@angular/forms';
|
||||
import { MatDialogModule, MAT_DIALOG_DATA } from '@angular/material/dialog';
|
||||
import { MAT_DIALOG_DATA, MatDialogModule } from '@angular/material/dialog';
|
||||
import { MatFormFieldModule } from '@angular/material/form-field';
|
||||
import { Actions } from '@ngrx/effects';
|
||||
import { provideMockActions } from '@ngrx/effects/testing';
|
||||
|
||||
@@ -6,11 +6,11 @@ import {
|
||||
ReactiveFormsModule,
|
||||
UntypedFormBuilder,
|
||||
UntypedFormGroup,
|
||||
Validators
|
||||
Validators,
|
||||
} from '@angular/forms';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { MatCheckboxModule } from '@angular/material/checkbox';
|
||||
import { MatDialogModule, MAT_DIALOG_DATA } from '@angular/material/dialog';
|
||||
import { MAT_DIALOG_DATA, MatDialogModule } from '@angular/material/dialog';
|
||||
import { MatIconModule } from '@angular/material/icon';
|
||||
import { MatInputModule } from '@angular/material/input';
|
||||
import { Store } from '@ngrx/store';
|
||||
@@ -25,6 +25,13 @@ import * as PlaylistActions from '../../../state/actions';
|
||||
@Component({
|
||||
selector: 'app-playlist-info',
|
||||
templateUrl: './playlist-info.component.html',
|
||||
styles: [
|
||||
`
|
||||
.spacer {
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
`,
|
||||
],
|
||||
providers: [DatePipe],
|
||||
imports: [
|
||||
TranslateModule,
|
||||
@@ -88,6 +95,9 @@ export class PlaylistInfoComponent {
|
||||
disabled: true,
|
||||
}),
|
||||
autoRefresh: new FormControl(this.playlist.autoRefresh),
|
||||
serverUrl: new FormControl(this.playlist.serverUrl),
|
||||
username: new FormControl(this.playlist.username),
|
||||
password: new FormControl(this.playlist.password),
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
<mat-list-item cdkDrag (click)="playlistClicked.emit(item._id)">
|
||||
<mat-icon mat-list-icon cdkDragHandle class="drag-icon"
|
||||
<mat-icon matListItemAvatar cdkDragHandle class="drag-icon"
|
||||
>drag_indicator</mat-icon
|
||||
>
|
||||
<mat-icon
|
||||
mat-list-icon
|
||||
matListItemAvatar
|
||||
class="upload-type-icon"
|
||||
*ngIf="item?.url"
|
||||
[matTooltip]="
|
||||
@@ -12,60 +12,80 @@
|
||||
>cloud</mat-icon
|
||||
>
|
||||
<mat-icon
|
||||
mat-list-icon
|
||||
matListItemAvatar
|
||||
class="upload-type-icon"
|
||||
*ngIf="!item?.url"
|
||||
*ngIf="!item?.url && !item.serverUrl"
|
||||
[matTooltip]="'HOME.PLAYLISTS.ADDED_VIA_FILE' | translate"
|
||||
>folder</mat-icon
|
||||
>
|
||||
<div mat-line>{{ item.title || item.filename }}</div>
|
||||
<div mat-line class="meta">
|
||||
{{ 'HOME.PLAYLISTS.CHANNELS' | translate }}: {{ item.count }}
|
||||
<ng-container *ngIf="item.importDate?.length > 3">
|
||||
| {{ 'HOME.PLAYLISTS.ADDED' | translate }}:
|
||||
{{ item.importDate | date }}
|
||||
</ng-container>
|
||||
<ng-container *ngIf="item.updateDate">
|
||||
| {{ 'HOME.PLAYLISTS.UPDATED' | translate }}:
|
||||
{{ item.updateDate | date: 'MMMM d, yyyy, HH:mm' }}
|
||||
</ng-container>
|
||||
<ng-container *ngIf="item.updateState === 2">
|
||||
<mat-icon
|
||||
[matTooltip]="
|
||||
'HOME.PLAYLISTS.INFO_DIALOG.UPDATE_FAILED' | translate
|
||||
"
|
||||
[inline]="true"
|
||||
>warning</mat-icon
|
||||
>
|
||||
<mat-icon
|
||||
matListItemAvatar
|
||||
class="upload-type-icon"
|
||||
*ngIf="item?.serverUrl"
|
||||
[matTooltip]="'HOME.PLAYLISTS.XTREAM_PLAYLIST' | translate"
|
||||
>public</mat-icon
|
||||
>
|
||||
<div matListItemTitle>{{ item.title || item.filename }}</div>
|
||||
<div matListItemLine class="meta">
|
||||
<ng-container *ngIf="!item.serverUrl; else xtreamPlaylist">
|
||||
{{ 'HOME.PLAYLISTS.CHANNELS' | translate }}: {{ item.count }}
|
||||
<ng-container *ngIf="item.importDate?.length > 3">
|
||||
| {{ 'HOME.PLAYLISTS.ADDED' | translate }}:
|
||||
{{ item.importDate | date }}
|
||||
</ng-container>
|
||||
<ng-container *ngIf="item.updateDate">
|
||||
| {{ 'HOME.PLAYLISTS.UPDATED' | translate }}:
|
||||
{{ item.updateDate | date : 'MMMM d, yyyy, HH:mm' }}
|
||||
</ng-container>
|
||||
<ng-container *ngIf="item.updateState === 2">
|
||||
<mat-icon
|
||||
[matTooltip]="
|
||||
'HOME.PLAYLISTS.INFO_DIALOG.UPDATE_FAILED' | translate
|
||||
"
|
||||
[inline]="true"
|
||||
>warning</mat-icon
|
||||
>
|
||||
</ng-container>
|
||||
</ng-container>
|
||||
<ng-template #xtreamPlaylist
|
||||
>Xtream Code Playlist
|
||||
<ng-container *ngIf="item.importDate?.length > 3">
|
||||
| {{ 'HOME.PLAYLISTS.ADDED' | translate }}:
|
||||
{{ item.importDate | date }}
|
||||
</ng-container></ng-template
|
||||
>
|
||||
</div>
|
||||
<button
|
||||
*ngIf="item.url || item.filePath"
|
||||
mat-icon-button
|
||||
class="refresh-btn"
|
||||
color="accent"
|
||||
(click)="$event.stopPropagation(); refreshClicked.emit(item)"
|
||||
[matTooltip]="'HOME.PLAYLISTS.REFRESH' | translate"
|
||||
>
|
||||
<mat-icon>sync</mat-icon>
|
||||
</button>
|
||||
<button
|
||||
mat-icon-button
|
||||
color="accent"
|
||||
class="edit-btn"
|
||||
(click)="$event.stopPropagation(); editPlaylistClicked.emit(item)"
|
||||
[matTooltip]="'HOME.PLAYLISTS.SHOW_DETAILS' | translate"
|
||||
>
|
||||
<mat-icon>edit</mat-icon>
|
||||
</button>
|
||||
<button
|
||||
mat-icon-button
|
||||
color="accent"
|
||||
class="delete-btn"
|
||||
(click)="$event.stopPropagation(); removeClicked.emit(item._id)"
|
||||
[matTooltip]="'HOME.PLAYLISTS.REMOVE' | translate"
|
||||
>
|
||||
<mat-icon>delete</mat-icon>
|
||||
</button>
|
||||
<mat-divider></mat-divider>
|
||||
|
||||
<span matListItemMeta class="action-buttons" *ngIf="showActions">
|
||||
<button
|
||||
*ngIf="item.url || item.filePath"
|
||||
mat-icon-button
|
||||
class="refresh-btn"
|
||||
color="accent"
|
||||
(click)="$event.stopPropagation(); refreshClicked.emit(item)"
|
||||
[matTooltip]="'HOME.PLAYLISTS.REFRESH' | translate"
|
||||
>
|
||||
<mat-icon>sync</mat-icon>
|
||||
</button>
|
||||
<button
|
||||
mat-icon-button
|
||||
color="accent"
|
||||
class="edit-btn"
|
||||
(click)="$event.stopPropagation(); editPlaylistClicked.emit(item)"
|
||||
[matTooltip]="'HOME.PLAYLISTS.SHOW_DETAILS' | translate"
|
||||
>
|
||||
<mat-icon>edit</mat-icon>
|
||||
</button>
|
||||
|
||||
<button
|
||||
mat-icon-button
|
||||
color="accent"
|
||||
class="delete-btn"
|
||||
(click)="$event.stopPropagation(); removeClicked.emit(item._id)"
|
||||
[matTooltip]="'HOME.PLAYLISTS.REMOVE' | translate"
|
||||
>
|
||||
<mat-icon>delete</mat-icon>
|
||||
</button>
|
||||
</span>
|
||||
</mat-list-item>
|
||||
<mat-divider></mat-divider>
|
||||
@@ -28,7 +28,8 @@ mat-list-item {
|
||||
|
||||
.drag-icon {
|
||||
cursor: move;
|
||||
margin-left: -20px;
|
||||
margin-left: 0;
|
||||
margin-right: 0;
|
||||
}
|
||||
|
||||
::ng-deep {
|
||||
@@ -44,3 +45,7 @@ mat-list-item {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.action-buttons {
|
||||
align-self: center !important;
|
||||
}
|
||||
@@ -10,6 +10,8 @@ export class PlaylistItemComponent {
|
||||
/** Playlist item */
|
||||
@Input() item: PlaylistMeta;
|
||||
|
||||
@Input() showActions = true;
|
||||
|
||||
/** Emits on playlist selection */
|
||||
@Output() playlistClicked: EventEmitter<string> = new EventEmitter();
|
||||
|
||||
|
||||
@@ -5,31 +5,34 @@
|
||||
*ngIf="playlists$ | async as playlists"
|
||||
>
|
||||
<mat-list-item *ngIf="playlists.length === 0">
|
||||
<mat-icon class="favorites-icon" mat-list-icon>search</mat-icon>
|
||||
<div mat-line>{{ 'HOME.PLAYLISTS.NO_PLAYLISTS' | translate }}</div>
|
||||
<div mat-line class="meta">
|
||||
<mat-icon class="favorites-icon" matListItemAvatar>search</mat-icon>
|
||||
<div matListItemTitle>
|
||||
{{ 'HOME.PLAYLISTS.NO_PLAYLISTS' | translate }}
|
||||
</div>
|
||||
<div matListItemLine class="meta">
|
||||
{{ 'HOME.PLAYLISTS.ADD_FIRST' | translate }}
|
||||
</div>
|
||||
</mat-list-item>
|
||||
<mat-list-item
|
||||
*ngIf="playlists.length > 0"
|
||||
(click)="getPlaylist('GLOBAL_FAVORITES')"
|
||||
(click)="getGlobalFavorites()"
|
||||
>
|
||||
<mat-icon class="favorites-icon" mat-list-icon>star</mat-icon>
|
||||
<div mat-line>
|
||||
<mat-icon class="favorites-icon" matListItemAvatar>star</mat-icon>
|
||||
<div matListItemTitle>
|
||||
{{ 'HOME.PLAYLISTS.GLOBAL_FAVORITES' | translate }}
|
||||
</div>
|
||||
<div mat-line class="meta">
|
||||
<div matListItemLine class="meta">
|
||||
{{ 'HOME.PLAYLISTS.GLOBAL_FAVORITES_DESCRIPTION' | translate }}
|
||||
</div>
|
||||
<mat-divider></mat-divider>
|
||||
</mat-list-item>
|
||||
<mat-divider></mat-divider>
|
||||
|
||||
<app-playlist-item
|
||||
*ngFor="let item of playlists; last as last"
|
||||
[item]="item"
|
||||
[showActions]="!sidebarMode"
|
||||
(editPlaylistClicked)="openInfoDialog($event)"
|
||||
(playlistClicked)="getPlaylist($event)"
|
||||
(playlistClicked)="getPlaylist(item)"
|
||||
(refreshClicked)="refreshPlaylist($event)"
|
||||
(removeClicked)="removeClicked($event)"
|
||||
></app-playlist-item>
|
||||
|
||||
@@ -30,7 +30,7 @@
|
||||
}
|
||||
|
||||
.favorites-icon {
|
||||
margin-left: 16px;
|
||||
padding-left: 36px;
|
||||
}
|
||||
|
||||
#skeleton-container {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -2,6 +2,7 @@ import { CdkDragDrop, moveItemInArray } from '@angular/cdk/drag-drop';
|
||||
import {
|
||||
Component,
|
||||
EventEmitter,
|
||||
Input,
|
||||
NgZone,
|
||||
OnDestroy,
|
||||
Output,
|
||||
@@ -12,6 +13,7 @@ import { Router } from '@angular/router';
|
||||
import { Store } from '@ngrx/store';
|
||||
import { TranslateService } from '@ngx-translate/core';
|
||||
import { map } from 'rxjs';
|
||||
import { GLOBAL_FAVORITES_PLAYLIST_ID } from '../../../../shared/constants';
|
||||
import { IpcCommand } from '../../../../shared/ipc-command.class';
|
||||
import { Playlist } from '../../../../shared/playlist.interface';
|
||||
import { DataService } from '../../services/data.service';
|
||||
@@ -47,6 +49,8 @@ export class RecentPlaylistsComponent implements OnDestroy {
|
||||
|
||||
allPlaylistsLoaded$ = this.store.select(selectPlaylistsLoadingFlag);
|
||||
|
||||
@Input() sidebarMode = false;
|
||||
|
||||
/** IPC Renderer commands list with callbacks */
|
||||
commandsList = [
|
||||
new IpcCommand(
|
||||
@@ -157,9 +161,18 @@ export class RecentPlaylistsComponent implements OnDestroy {
|
||||
);
|
||||
}
|
||||
|
||||
getPlaylist(playlistId: string): void {
|
||||
this.router.navigate(['playlists', playlistId]);
|
||||
this.playlistClicked.emit(playlistId);
|
||||
getGlobalFavorites() {
|
||||
this.router.navigate(['playlists', GLOBAL_FAVORITES_PLAYLIST_ID]);
|
||||
this.playlistClicked.emit(GLOBAL_FAVORITES_PLAYLIST_ID);
|
||||
}
|
||||
|
||||
getPlaylist(playlistMeta: PlaylistMeta): void {
|
||||
if (playlistMeta.serverUrl) {
|
||||
this.router.navigate(['xtreams', playlistMeta._id]);
|
||||
} else {
|
||||
this.router.navigate(['playlists', playlistMeta._id]);
|
||||
this.playlistClicked.emit(playlistMeta._id);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -4,5 +4,5 @@ button {
|
||||
}
|
||||
|
||||
textarea {
|
||||
height: calc(100vh - 300px);
|
||||
height: calc(100vh - 300px) !important;
|
||||
}
|
||||
@@ -8,7 +8,9 @@
|
||||
/>
|
||||
</mat-form-field>
|
||||
<mat-card *ngIf="!isElectron">
|
||||
{{ 'HOME.URL_UPLOAD.CORS_NOTE' | translate }}
|
||||
<mat-card-content>
|
||||
{{ 'HOME.URL_UPLOAD.CORS_NOTE' | translate }}
|
||||
</mat-card-content>
|
||||
</mat-card>
|
||||
<button
|
||||
class="add-button"
|
||||
|
||||
@@ -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 })
|
||||
);
|
||||
}
|
||||
}
|
||||
+2
-2
@@ -38,7 +38,7 @@
|
||||
"
|
||||
[logo]="channel?.tvg?.logo"
|
||||
(clicked)="selectChannel(channel)"
|
||||
[selected]="selected?.id === channel.id"
|
||||
[selected]="selected?.id === channel?.id"
|
||||
></app-channel-list-item>
|
||||
</ng-container>
|
||||
</cdk-virtual-scroll-viewport>
|
||||
@@ -116,7 +116,7 @@
|
||||
[isDraggable]="true"
|
||||
[logo]="channel?.tvg?.logo"
|
||||
(clicked)="selectChannel(channel)"
|
||||
[selected]="selected?.id === channel.id"
|
||||
[selected]="selected?.id === channel?.id"
|
||||
[showFavoriteButton]="true"
|
||||
(favoriteToggled)="toggleFavoriteChannel(channel, $event)"
|
||||
></app-channel-list-item>
|
||||
|
||||
+2
-4
@@ -37,10 +37,8 @@
|
||||
border-radius: 0 !important;
|
||||
}
|
||||
|
||||
.mat-tab-label,
|
||||
.mat-tab-label-active {
|
||||
min-width: 0 !important;
|
||||
padding: 15px !important;
|
||||
.mat-mdc-tab.mdc-tab {
|
||||
padding: 0 5px;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -123,7 +123,6 @@ export class ChannelListContainerComponent {
|
||||
|
||||
drop(event: CdkDragDrop<Channel[]>, favorites: Channel[]) {
|
||||
moveItemInArray(favorites, event.previousIndex, event.currentIndex);
|
||||
console.log(favorites);
|
||||
this.store.dispatch(
|
||||
PlaylistActions.setFavorites({
|
||||
channelIds: favorites.map((item) => item.id),
|
||||
|
||||
+5
-1
@@ -1,6 +1,9 @@
|
||||
.channel-logo {
|
||||
max-height: 48px;
|
||||
overflow: hidden;
|
||||
border-radius: 0;
|
||||
margin-left: 10px;
|
||||
margin-right: 5px;
|
||||
}
|
||||
|
||||
.channel-name {
|
||||
@@ -27,5 +30,6 @@
|
||||
|
||||
.drag-icon {
|
||||
cursor: move;
|
||||
margin-left: -20px;
|
||||
margin-left: 0;
|
||||
margin-right: 0;
|
||||
}
|
||||
+35
-30
@@ -4,37 +4,42 @@ import { Component, EventEmitter, Input, Output } from '@angular/core';
|
||||
selector: 'app-channel-list-item',
|
||||
styleUrls: ['./channel-list-item.component.scss'],
|
||||
template: `<mat-list-item
|
||||
cdkDrag
|
||||
[cdkDragDisabled]="!isDraggable"
|
||||
cdkDragPreviewContainer="parent"
|
||||
[class.active]="selected"
|
||||
(click)="clicked.emit()"
|
||||
data-test-id="channel-item"
|
||||
>
|
||||
<mat-icon
|
||||
*ngIf="isDraggable"
|
||||
mat-list-icon
|
||||
cdkDragHandle
|
||||
class="drag-icon"
|
||||
>drag_indicator</mat-icon
|
||||
cdkDrag
|
||||
[cdkDragDisabled]="!isDraggable"
|
||||
cdkDragPreviewContainer="parent"
|
||||
[class.active]="selected"
|
||||
(click)="clicked.emit()"
|
||||
data-test-id="channel-item"
|
||||
>
|
||||
<div class="channel-logo" *ngIf="logo">
|
||||
<img [src]="logo" width="48" onerror="this.style.display='none'" />
|
||||
</div>
|
||||
<p matLine class="channel-name">
|
||||
{{ name }}
|
||||
</p>
|
||||
<button
|
||||
*ngIf="showFavoriteButton"
|
||||
mat-icon-button
|
||||
color="primary"
|
||||
[matTooltip]="'CHANNELS.REMOVE_FAVORITE' | translate"
|
||||
(click)="favoriteToggled.emit($event)"
|
||||
>
|
||||
<mat-icon color="accent">star</mat-icon>
|
||||
</button>
|
||||
<mat-divider></mat-divider
|
||||
></mat-list-item>`,
|
||||
<mat-icon
|
||||
*ngIf="isDraggable"
|
||||
matListItemIcon
|
||||
cdkDragHandle
|
||||
class="drag-icon"
|
||||
>drag_indicator</mat-icon
|
||||
>
|
||||
<div matListItemAvatar class="channel-logo" *ngIf="logo">
|
||||
<img
|
||||
[src]="logo"
|
||||
width="48"
|
||||
onerror="this.style.display='none'"
|
||||
/>
|
||||
</div>
|
||||
<p matListItemLine class="channel-name">
|
||||
{{ name }}
|
||||
</p>
|
||||
<button
|
||||
*ngIf="showFavoriteButton"
|
||||
mat-icon-button
|
||||
matListItemMeta
|
||||
color="primary"
|
||||
[matTooltip]="'CHANNELS.REMOVE_FAVORITE' | translate"
|
||||
(click)="favoriteToggled.emit($event)"
|
||||
>
|
||||
<mat-icon color="accent">star</mat-icon>
|
||||
</button>
|
||||
</mat-list-item>
|
||||
<mat-divider></mat-divider>`,
|
||||
})
|
||||
export class ChannelListItemComponent {
|
||||
@Input() isDraggable = false;
|
||||
|
||||
+5
-5
@@ -1,10 +1,10 @@
|
||||
import { By } from '@angular/platform-browser';
|
||||
import { MockPipe, MockModule } from 'ng-mocks';
|
||||
import { TranslatePipe } from '@ngx-translate/core';
|
||||
import { MatDialogModule, MAT_DIALOG_DATA } from '@angular/material/dialog';
|
||||
import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing';
|
||||
import { EpgItemDescriptionComponent } from './epg-item-description.component';
|
||||
import { MAT_DIALOG_DATA, MatDialogModule } from '@angular/material/dialog';
|
||||
import { By } from '@angular/platform-browser';
|
||||
import { TranslatePipe } from '@ngx-translate/core';
|
||||
import { MockModule, MockPipe } from 'ng-mocks';
|
||||
import { EpgProgram } from '../../../models/epg-program.model';
|
||||
import { EpgItemDescriptionComponent } from './epg-item-description.component';
|
||||
|
||||
describe('EpgItemDescriptionComponent', () => {
|
||||
let component: EpgItemDescriptionComponent;
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
import { Component, Inject } from '@angular/core';
|
||||
import { MAT_DIALOG_DATA } from '@angular/material/dialog';
|
||||
import { EpgProgram } from './../../../models/epg-program.model';
|
||||
import { Component, Inject } from '@angular/core';
|
||||
|
||||
@Component({
|
||||
selector: 'app-epg-item-description',
|
||||
|
||||
@@ -21,9 +21,9 @@
|
||||
>
|
||||
<span
|
||||
(click)="$event.stopPropagation(); showDescription(item)"
|
||||
matListItemMeta
|
||||
*ngIf="item.desc.length > 0"
|
||||
class="description-icon"
|
||||
[matTooltip]="'EPG.PROGRAM_DIALOG.SHOW_PROGRAM_DETAILS' | translate"
|
||||
><mat-icon>info</mat-icon></span
|
||||
>
|
||||
<mat-divider></mat-divider>
|
||||
@@ -1,16 +1,16 @@
|
||||
/* eslint-disable @typescript-eslint/unbound-method */
|
||||
import { MatTooltipModule } from '@angular/material/tooltip';
|
||||
import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing';
|
||||
import { MatDialog, MatDialogModule } from '@angular/material/dialog';
|
||||
import { MatIconModule } from '@angular/material/icon';
|
||||
import { TranslatePipe } from '@ngx-translate/core';
|
||||
import { MatListModule } from '@angular/material/list';
|
||||
import { MatTooltipModule } from '@angular/material/tooltip';
|
||||
import { TranslatePipe } from '@ngx-translate/core';
|
||||
import * as moment from 'moment';
|
||||
import { MockModule, MockPipe } from 'ng-mocks';
|
||||
import { MomentDatePipe } from './../../../../shared/pipes/moment-date.pipe';
|
||||
import { EpgProgram } from './../../../models/epg-program.model';
|
||||
import { MockModule, MockPipe } from 'ng-mocks';
|
||||
import { MatDialog, MatDialogModule } from '@angular/material/dialog';
|
||||
import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing';
|
||||
import { EpgListItemComponent } from './epg-list-item.component';
|
||||
import { EpgItemDescriptionComponent } from './../epg-item-description/epg-item-description.component';
|
||||
import * as moment from 'moment';
|
||||
import { EpgListItemComponent } from './epg-list-item.component';
|
||||
|
||||
const EPG_PROGRAM_ITEM = {
|
||||
start: moment(Date.now()).format('YYYYMMDD'),
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { EpgItemDescriptionComponent } from './../epg-item-description/epg-item-description.component';
|
||||
import { MatDialog } from '@angular/material/dialog';
|
||||
import { Component, Input } from '@angular/core';
|
||||
import { MatDialog } from '@angular/material/dialog';
|
||||
import { EpgProgram } from '../../../models/epg-program.model';
|
||||
import { EpgItemDescriptionComponent } from './../epg-item-description/epg-item-description.component';
|
||||
|
||||
@Component({
|
||||
selector: 'app-epg-list-item',
|
||||
|
||||
@@ -57,14 +57,14 @@
|
||||
else timeshiftItem
|
||||
"
|
||||
>
|
||||
<div matLine>
|
||||
<div matListItemLine>
|
||||
<app-epg-list-item
|
||||
[timeshiftUntil]="timeshiftUntil"
|
||||
[timeNow]="timeNow"
|
||||
[item]="item"
|
||||
></app-epg-list-item>
|
||||
</div>
|
||||
<p matLine>{{ item?.title[0]?.value }}</p>
|
||||
<p matListItemLine>{{ item?.title[0]?.value }}</p>
|
||||
</mat-list-item>
|
||||
<ng-template #timeshiftItem>
|
||||
<mat-list-option
|
||||
@@ -80,22 +80,26 @@
|
||||
)
|
||||
"
|
||||
>
|
||||
<div matLine>
|
||||
<div matListItemLine>
|
||||
<app-epg-list-item
|
||||
[timeshiftUntil]="timeshiftUntil"
|
||||
[timeNow]="timeNow"
|
||||
[item]="item"
|
||||
></app-epg-list-item>
|
||||
</div>
|
||||
<p matLine [innerHTML]="item?.title[0]?.value"></p>
|
||||
<p
|
||||
matListItemLine
|
||||
[innerHTML]="item?.title[0]?.value"
|
||||
></p>
|
||||
</mat-list-option>
|
||||
</ng-template>
|
||||
<mat-divider></mat-divider>
|
||||
</ng-container>
|
||||
</ng-container>
|
||||
<ng-template #epgNotAvailable>
|
||||
<mat-list-option>{{
|
||||
'EPG.EPG_NOT_AVAILABLE_DATE' | translate
|
||||
}}</mat-list-option>
|
||||
<p class="epg-not-available">
|
||||
{{ 'EPG.EPG_NOT_AVAILABLE_DATE' | translate }}
|
||||
</p>
|
||||
</ng-template>
|
||||
</mat-selection-list>
|
||||
</ng-container>
|
||||
|
||||
@@ -71,6 +71,10 @@
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.epg-not-available {
|
||||
padding: 0 10px;
|
||||
}
|
||||
|
||||
mat-selection-list {
|
||||
overflow: auto;
|
||||
}
|
||||
@@ -97,14 +101,10 @@ mat-selection-list {
|
||||
.previous-day {
|
||||
flex: 1 1 40px;
|
||||
box-sizing: border-box;
|
||||
max-width: 40px;
|
||||
min-width: 40px;
|
||||
}
|
||||
|
||||
.next-day {
|
||||
flex: 1 1 40px;
|
||||
box-sizing: border-box;
|
||||
max-width: 40px;
|
||||
min-width: 40px;
|
||||
}
|
||||
}
|
||||
@@ -140,14 +140,14 @@ describe('EpgListComponent', () => {
|
||||
expect(component).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should handle epg programs', () => {
|
||||
/* it('should handle epg programs', () => {
|
||||
component.handleEpgData({ payload: MOCKED_PROGRAMS });
|
||||
fixture.detectChanges();
|
||||
expect(component.timeNow).toBeTruthy();
|
||||
expect(component.dateToday).toBeTruthy();
|
||||
expect(component.channel).toBeTruthy();
|
||||
expect(component.items).toHaveLength(1);
|
||||
});
|
||||
}); */
|
||||
|
||||
it('should handle an empty epg programs object', () => {
|
||||
const payload = {} as unknown as EpgData;
|
||||
@@ -172,9 +172,10 @@ describe('EpgListComponent', () => {
|
||||
jest.spyOn(mockStore, 'dispatch');
|
||||
component.setEpgProgram(MOCKED_PROGRAMS.items[0], false, true);
|
||||
expect(mockStore.dispatch).toHaveBeenCalledTimes(1);
|
||||
expect(mockStore.dispatch).toHaveBeenCalledWith(
|
||||
{program: MOCKED_PROGRAMS.items[0], type: expect.stringContaining('epg program')}
|
||||
);
|
||||
expect(mockStore.dispatch).toHaveBeenCalledWith({
|
||||
program: MOCKED_PROGRAMS.items[0],
|
||||
type: expect.stringContaining('epg program'),
|
||||
});
|
||||
});
|
||||
|
||||
it('should reset active epg program', () => {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Component, NgZone } from '@angular/core';
|
||||
import { Store } from '@ngrx/store';
|
||||
import * as moment from 'moment';
|
||||
import moment from 'moment';
|
||||
import { Observable } from 'rxjs';
|
||||
import { map } from 'rxjs/operators';
|
||||
import { EPG_GET_PROGRAM_DONE } from '../../../../../shared/ipc-commands';
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { MockPipe } from 'ng-mocks';
|
||||
import { TranslatePipe } from '@ngx-translate/core';
|
||||
import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing';
|
||||
import { SimpleChange } from '@angular/core';
|
||||
import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing';
|
||||
import { TranslatePipe } from '@ngx-translate/core';
|
||||
import { MockPipe } from 'ng-mocks';
|
||||
import { HtmlVideoPlayerComponent } from './html-video-player.component';
|
||||
|
||||
describe('HtmlVideoPlayerComponent', () => {
|
||||
@@ -9,23 +9,18 @@ describe('HtmlVideoPlayerComponent', () => {
|
||||
let fixture: ComponentFixture<HtmlVideoPlayerComponent>;
|
||||
const TEST_CHANNEL = {
|
||||
id: '1234',
|
||||
url: 'http://test',
|
||||
url: 'http://test.ts',
|
||||
name: 'Test channel',
|
||||
group: {
|
||||
title: 'News group',
|
||||
},
|
||||
};
|
||||
|
||||
beforeEach(
|
||||
waitForAsync(() => {
|
||||
TestBed.configureTestingModule({
|
||||
declarations: [
|
||||
HtmlVideoPlayerComponent,
|
||||
MockPipe(TranslatePipe),
|
||||
],
|
||||
}).compileComponents();
|
||||
})
|
||||
);
|
||||
beforeEach(waitForAsync(() => {
|
||||
TestBed.configureTestingModule({
|
||||
declarations: [HtmlVideoPlayerComponent, MockPipe(TranslatePipe)],
|
||||
}).compileComponents();
|
||||
}));
|
||||
|
||||
beforeEach(() => {
|
||||
fixture = TestBed.createComponent(HtmlVideoPlayerComponent);
|
||||
@@ -45,8 +40,6 @@ describe('HtmlVideoPlayerComponent', () => {
|
||||
});
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(component.playChannel).toHaveBeenCalledTimes(1);
|
||||
expect(component.playChannel).toHaveBeenCalledWith(TEST_CHANNEL);
|
||||
expect(console.error).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
} from '@angular/core';
|
||||
import Hls from 'hls.js';
|
||||
import { Channel } from '../../../../../shared/channel.interface';
|
||||
import { getExtensionFromUrl } from '../../../../../shared/playlist.utils';
|
||||
|
||||
/**
|
||||
* This component contains the implementation of HTML5 based video player
|
||||
@@ -17,13 +18,14 @@ import { Channel } from '../../../../../shared/channel.interface';
|
||||
selector: 'app-html-video-player',
|
||||
templateUrl: './html-video-player.component.html',
|
||||
styleUrls: ['./html-video-player.component.scss'],
|
||||
standalone: true,
|
||||
})
|
||||
export class HtmlVideoPlayerComponent implements OnChanges, OnDestroy {
|
||||
/** Channel to play */
|
||||
@Input() channel: Channel;
|
||||
|
||||
/** Video player DOM element */
|
||||
@ViewChild('videoPlayer', { static: false })
|
||||
@ViewChild('videoPlayer', { static: true })
|
||||
videoPlayer: ElementRef<HTMLVideoElement>;
|
||||
|
||||
/** HLS object */
|
||||
@@ -49,8 +51,14 @@ export class HtmlVideoPlayerComponent implements OnChanges, OnDestroy {
|
||||
playChannel(channel: Channel): void {
|
||||
if (this.hls) this.hls.destroy();
|
||||
if (channel.url) {
|
||||
const url = channel.url + channel.epgParams;
|
||||
if (Hls && Hls.isSupported()) {
|
||||
const url = channel.url + (channel.epgParams ?? '');
|
||||
const extension = getExtensionFromUrl(channel.url);
|
||||
if (
|
||||
extension !== 'mp4' &&
|
||||
extension !== 'mpv' &&
|
||||
Hls &&
|
||||
Hls.isSupported()
|
||||
) {
|
||||
console.log('... switching channel to ', channel.name, url);
|
||||
this.hls = new Hls();
|
||||
this.hls.attachMedia(this.videoPlayer.nativeElement);
|
||||
@@ -58,10 +66,23 @@ export class HtmlVideoPlayerComponent implements OnChanges, OnDestroy {
|
||||
this.handlePlayOperation();
|
||||
} else {
|
||||
console.error('something wrong with hls.js init...');
|
||||
this.addSourceToVideo(
|
||||
this.videoPlayer.nativeElement,
|
||||
url,
|
||||
'video/mp4'
|
||||
);
|
||||
this.videoPlayer.nativeElement.play();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
addSourceToVideo(element: HTMLVideoElement, url: string, type: string) {
|
||||
const source = document.createElement('source');
|
||||
source.src = url;
|
||||
source.type = type;
|
||||
element.appendChild(source);
|
||||
}
|
||||
|
||||
/**
|
||||
* Disables text based captions based on the global settings
|
||||
*/
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Component, Input, OnChanges, SimpleChanges } from '@angular/core';
|
||||
import * as moment from 'moment';
|
||||
import moment from 'moment';
|
||||
import { Channel } from '../../../../../shared/channel.interface';
|
||||
import { EpgProgram } from '../../models/epg-program.model';
|
||||
|
||||
|
||||
@@ -46,6 +46,7 @@
|
||||
<ng-template #playlistsTemplate>
|
||||
<app-recent-playlists
|
||||
class="recent-playlists"
|
||||
[sidebarMode]="true"
|
||||
[class.electron]="dataService.isElectron"
|
||||
(playlistClicked)="selectPlaylist()"
|
||||
></app-recent-playlists>
|
||||
|
||||
@@ -22,7 +22,6 @@
|
||||
.channels-count {
|
||||
font-size: 0.8em;
|
||||
color: #ccc;
|
||||
text-transform: lowercase;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,8 +2,9 @@
|
||||
<!-- sidebar content -->
|
||||
<mat-drawer #drawer mode="side" opened disableClose>
|
||||
<app-sidebar
|
||||
*ngIf="channels$ | async as channels"
|
||||
*ngIf="(channels$ | async) as channels; else noChannels"
|
||||
[channels]="channels" />
|
||||
<ng-template #noChannels>No channels <br /> <button mat-button color="secondary" (click)="navigateHome()">Go back</button></ng-template>
|
||||
</mat-drawer>
|
||||
<mat-drawer-content>
|
||||
<ng-container *ngIf="activeChannel$ | async as activeChannel">
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
|
||||
.mat-drawer-content {
|
||||
background: #000;
|
||||
color: #fff;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
|
||||
@@ -34,7 +34,6 @@ class MatSnackBarStub {
|
||||
describe('VideoPlayerComponent', () => {
|
||||
let component: VideoPlayerComponent;
|
||||
let fixture: ComponentFixture<VideoPlayerComponent>;
|
||||
let channels;
|
||||
let mockStore: MockStore;
|
||||
const actions$ = new Observable<Actions>();
|
||||
|
||||
|
||||
@@ -9,18 +9,13 @@ import {
|
||||
OnInit,
|
||||
} from '@angular/core';
|
||||
import { MatSnackBar } from '@angular/material/snack-bar';
|
||||
import { ActivatedRoute } from '@angular/router';
|
||||
import { ActivatedRoute, Router } from '@angular/router';
|
||||
import { Store } from '@ngrx/store';
|
||||
import { StorageMap } from '@ngx-pwa/local-storage';
|
||||
import {
|
||||
combineLatestWith,
|
||||
filter,
|
||||
Observable,
|
||||
skipWhile,
|
||||
switchMap,
|
||||
} from 'rxjs';
|
||||
import { Observable, combineLatestWith, filter, map, switchMap } from 'rxjs';
|
||||
import { Channel } from '../../../../../shared/channel.interface';
|
||||
import {
|
||||
CHANNEL_SET_USER_AGENT,
|
||||
ERROR,
|
||||
PLAYLIST_PARSE_BY_URL,
|
||||
PLAYLIST_PARSE_RESPONSE,
|
||||
@@ -35,7 +30,6 @@ import {
|
||||
selectActive,
|
||||
selectChannels,
|
||||
selectCurrentEpgProgram,
|
||||
selectPlaylistTitle,
|
||||
} from '../../../state/selectors';
|
||||
import { MultiEpgContainerComponent } from '../multi-epg/multi-epg-container.component';
|
||||
|
||||
@@ -68,11 +62,6 @@ export class VideoPlayerComponent implements OnInit, OnDestroy {
|
||||
showCaptions: false,
|
||||
};
|
||||
|
||||
/** Title of the current playlist */
|
||||
playlistTitle$ = this.store
|
||||
.select(selectPlaylistTitle)
|
||||
.pipe(skipWhile((playlistFilename) => playlistFilename === ''));
|
||||
|
||||
/** IPC Renderer commands list with callbacks */
|
||||
commandsList = [
|
||||
{
|
||||
@@ -119,6 +108,7 @@ export class VideoPlayerComponent implements OnInit, OnDestroy {
|
||||
private ngZone: NgZone,
|
||||
private overlay: Overlay,
|
||||
private playlistsService: PlaylistsService,
|
||||
private router: Router,
|
||||
private snackBar: MatSnackBar,
|
||||
private storage: StorageMap,
|
||||
private store: Store
|
||||
@@ -141,7 +131,20 @@ export class VideoPlayerComponent implements OnInit, OnDestroy {
|
||||
playlistId: params.id,
|
||||
})
|
||||
);
|
||||
return this.playlistsService.getPlaylistChannels(params.id);
|
||||
return this.playlistsService.getPlaylist(params.id).pipe(
|
||||
map((playlist) => {
|
||||
this.dataService.sendIpcEvent(
|
||||
CHANNEL_SET_USER_AGENT,
|
||||
playlist.userAgent
|
||||
? {
|
||||
referer: 'localhost',
|
||||
userAgent: playlist.userAgent,
|
||||
}
|
||||
: {}
|
||||
);
|
||||
return playlist.playlist.items;
|
||||
})
|
||||
);
|
||||
} else if (queryParams.url) {
|
||||
return this.store.select(selectChannels);
|
||||
}
|
||||
@@ -232,4 +235,8 @@ export class VideoPlayerComponent implements OnInit, OnDestroy {
|
||||
openUrl(url: string) {
|
||||
window.open(url, '_blank');
|
||||
}
|
||||
|
||||
navigateHome() {
|
||||
this.router.navigate(['/']);
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,3 @@
|
||||
@import '~video.js/dist/video-js.css';
|
||||
|
||||
.video-js {
|
||||
position: relative !important;
|
||||
width: 100% !important;
|
||||
|
||||
@@ -2,16 +2,17 @@ import {
|
||||
Component,
|
||||
ElementRef,
|
||||
Input,
|
||||
OnChanges,
|
||||
OnDestroy,
|
||||
OnInit,
|
||||
SimpleChanges,
|
||||
ViewChild,
|
||||
ViewEncapsulation,
|
||||
SimpleChanges,
|
||||
} from '@angular/core';
|
||||
import videoJs from 'video.js';
|
||||
import 'videojs-hls-quality-selector';
|
||||
import 'videojs-contrib-quality-levels';
|
||||
import '@yangkghjh/videojs-aspect-ratio-panel';
|
||||
import videoJs from 'video.js';
|
||||
import 'videojs-contrib-quality-levels';
|
||||
import 'videojs-hls-quality-selector';
|
||||
|
||||
/**
|
||||
* This component contains the implementation of video player that is based on video.js library
|
||||
@@ -21,8 +22,9 @@ import '@yangkghjh/videojs-aspect-ratio-panel';
|
||||
templateUrl: './vjs-player.component.html',
|
||||
styleUrls: ['./vjs-player.component.scss'],
|
||||
encapsulation: ViewEncapsulation.None,
|
||||
standalone: true,
|
||||
})
|
||||
export class VjsPlayerComponent implements OnInit, OnDestroy {
|
||||
export class VjsPlayerComponent implements OnInit, OnChanges, OnDestroy {
|
||||
/** DOM-element reference */
|
||||
@ViewChild('target', { static: true }) target: ElementRef<Element>;
|
||||
/** Options of VideoJs player */
|
||||
|
||||
@@ -21,9 +21,11 @@ const routes: Routes = [{ path: '', component: VideoPlayerComponent }];
|
||||
@NgModule({
|
||||
imports: [
|
||||
CommonModule,
|
||||
HtmlVideoPlayerComponent,
|
||||
OverlayModule,
|
||||
RouterModule.forChild(routes),
|
||||
SharedModule,
|
||||
VjsPlayerComponent,
|
||||
],
|
||||
declarations: [
|
||||
ChannelListContainerComponent,
|
||||
@@ -32,12 +34,10 @@ const routes: Routes = [{ path: '', component: VideoPlayerComponent }];
|
||||
EpgListComponent,
|
||||
EpgListItemComponent,
|
||||
InfoOverlayComponent,
|
||||
HtmlVideoPlayerComponent,
|
||||
MultiEpgContainerComponent,
|
||||
SidebarComponent,
|
||||
ToolbarComponent,
|
||||
VideoPlayerComponent,
|
||||
VjsPlayerComponent,
|
||||
],
|
||||
})
|
||||
export class PlayerModule {}
|
||||
@@ -12,7 +12,7 @@ describe('EpgService', () => {
|
||||
providers: [
|
||||
EpgService,
|
||||
MockProviders(DataService, TranslateService, MatSnackBar),
|
||||
provideMockStore()
|
||||
provideMockStore(),
|
||||
],
|
||||
imports: [MockModule(MatSnackBarModule)],
|
||||
});
|
||||
@@ -45,7 +45,10 @@ describe('EpgService', () => {
|
||||
jest.spyOn(channelStore, 'dispatch');
|
||||
service.onEpgFetchDone();
|
||||
expect(snackbar.open).toHaveBeenCalledTimes(1);
|
||||
expect(channelStore.dispatch).toHaveBeenCalledWith({value: true, type: expect.stringContaining('active epg')});
|
||||
expect(channelStore.dispatch).toHaveBeenCalledWith({
|
||||
value: true,
|
||||
type: expect.stringContaining('active epg'),
|
||||
});
|
||||
}
|
||||
));
|
||||
});
|
||||
@@ -31,12 +31,14 @@ export class EpgService {
|
||||
if (!Array.isArray(urls)) {
|
||||
urls = [urls];
|
||||
}
|
||||
urls.forEach((url) =>
|
||||
this.electronService.sendIpcEvent(EPG_FETCH, {
|
||||
url,
|
||||
})
|
||||
);
|
||||
this.showFetchSnackbar();
|
||||
if (urls?.length > 0) {
|
||||
urls.forEach((url) =>
|
||||
this.electronService.sendIpcEvent(EPG_FETCH, {
|
||||
url,
|
||||
})
|
||||
);
|
||||
this.showFetchSnackbar();
|
||||
}
|
||||
}
|
||||
|
||||
showFetchSnackbar() {
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
import { Injectable } from '@angular/core';
|
||||
import { parse } from 'iptv-playlist-parser';
|
||||
import { NgxIndexedDBService } from 'ngx-indexed-db';
|
||||
import { combineLatest, map, Observable, switchMap } from 'rxjs';
|
||||
import { combineLatest, map, switchMap } from 'rxjs';
|
||||
import { Channel } from '../../../shared/channel.interface';
|
||||
import { GLOBAL_FAVORITES_PLAYLIST_ID } from '../../../shared/constants';
|
||||
import {
|
||||
@@ -14,6 +14,8 @@ import {
|
||||
createFavoritesPlaylist,
|
||||
createPlaylistObject,
|
||||
} from '../../../shared/playlist.utils';
|
||||
import { XtreamItem } from '../../../shared/xtream-item.interface';
|
||||
import { XtreamSerieItem } from '../../../shared/xtream-serie-item.interface';
|
||||
import { DbStores } from '../indexed-db.config';
|
||||
import { PlaylistMeta } from '../shared/playlist-meta.type';
|
||||
|
||||
@@ -40,19 +42,12 @@ export class PlaylistsService {
|
||||
return this.dbService.add(DbStores.Playlists, playlist);
|
||||
}
|
||||
|
||||
getPlaylistChannels(id: string) {
|
||||
let playlist$: Observable<Partial<Playlist>>;
|
||||
getPlaylist(id: string) {
|
||||
if (id === GLOBAL_FAVORITES_PLAYLIST_ID) {
|
||||
playlist$ = this.getPlaylistWithGlobalFavorites();
|
||||
return this.getPlaylistWithGlobalFavorites();
|
||||
} else {
|
||||
playlist$ = this.dbService.getByID<Playlist>(
|
||||
DbStores.Playlists,
|
||||
id
|
||||
);
|
||||
return this.dbService.getByID<Playlist>(DbStores.Playlists, id);
|
||||
}
|
||||
return playlist$.pipe(
|
||||
map((data: Playlist) => data.playlist.items as Channel[])
|
||||
);
|
||||
}
|
||||
|
||||
deletePlaylist(playlistId: string) {
|
||||
@@ -84,6 +79,7 @@ export class PlaylistsService {
|
||||
...playlist,
|
||||
title: updatedPlaylist.title,
|
||||
autoRefresh: updatedPlaylist.autoRefresh,
|
||||
userAgent: updatedPlaylist.userAgent,
|
||||
})
|
||||
)
|
||||
);
|
||||
@@ -124,6 +120,43 @@ export class PlaylistsService {
|
||||
);
|
||||
}
|
||||
|
||||
getPortalFavorites(portalId: string) {
|
||||
return this.dbService
|
||||
.getByID<{ favorites: Partial<XtreamItem>[] }>(
|
||||
DbStores.Playlists,
|
||||
portalId
|
||||
)
|
||||
.pipe(map((item) => item.favorites ?? []));
|
||||
}
|
||||
|
||||
addPortalFavorite(portalId: string, item: any) {
|
||||
return this.getPlaylistById(portalId).pipe(
|
||||
switchMap((portal) =>
|
||||
this.dbService.update(DbStores.Playlists, {
|
||||
...portal,
|
||||
favorites: [...(portal.favorites ?? []), item],
|
||||
})
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
removeFromPortalFavorites(portalId: string, favoriteId: number) {
|
||||
return this.getPlaylistById(portalId).pipe(
|
||||
switchMap((portal) =>
|
||||
this.dbService.update(DbStores.Playlists, {
|
||||
...portal,
|
||||
favorites: portal.favorites.filter(
|
||||
(i) =>
|
||||
(i as Partial<XtreamItem>).stream_id !==
|
||||
favoriteId &&
|
||||
(i as Partial<XtreamSerieItem>).series_id !==
|
||||
favoriteId
|
||||
),
|
||||
})
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
updatePlaylistPositions(
|
||||
positionUpdates: {
|
||||
id: string;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -259,6 +259,23 @@ export class WhatsNewService {
|
||||
},
|
||||
},
|
||||
],
|
||||
'0.14.0': [
|
||||
{
|
||||
title: '🚀 New in v0.14.0',
|
||||
html: `The main feature of this release is — Xtream Code IPTV support. The feature is also available in PWA, but works best in electron version of the application and combination with mpv player.<br />
|
||||
|
||||
<h3>Other updates:</h3>
|
||||
* possibility to specify custom path for mpv player<br/>
|
||||
* fixed an annoying bug that appeared when reopening mpv player<br/>
|
||||
* updated libraries used under the hood<br/>
|
||||
* fixed bug related to user-agent handling`,
|
||||
button: {
|
||||
text: 'GO!',
|
||||
textColor: '#ccc',
|
||||
bgColor: '#111',
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
/** Options for the "what is new" modal dialogs */
|
||||
|
||||
@@ -8,6 +8,7 @@ export enum Language {
|
||||
GERMAN = 'de',
|
||||
SPANISH = 'es',
|
||||
CHINESE = 'zh',
|
||||
TRADITIONAL_CHINESE = 'zhtw',
|
||||
FRENCH = 'fr',
|
||||
ITALIAN = 'it',
|
||||
}
|
||||
@@ -90,6 +90,26 @@
|
||||
</div>
|
||||
</div>
|
||||
<mat-divider></mat-divider>
|
||||
<div class="row" *ngIf="settingsForm.value.player === 'mpv'">
|
||||
<div class="column">
|
||||
{{ 'SETTINGS.MPV_PLAYER_PATH_LABEL' | translate }}
|
||||
<p>{{ 'SETTINGS.MPV_PLAYER_PATH_DESCRIPTION' | translate }}</p>
|
||||
</div>
|
||||
<div class="column">
|
||||
<mat-form-field appearance="outline">
|
||||
<mat-label for="mpvPlayerPath">{{
|
||||
'SETTINGS.MPV_PLAYER_PATH' | translate
|
||||
}}</mat-label>
|
||||
<input
|
||||
matInput
|
||||
type="text"
|
||||
id="mpvPlayerPath"
|
||||
formControlName="mpvPlayerPath"
|
||||
/>
|
||||
</mat-form-field>
|
||||
</div>
|
||||
</div>
|
||||
<mat-divider></mat-divider>
|
||||
<div class="row">
|
||||
<div class="column">
|
||||
{{ 'SETTINGS.LANGUAGE' | translate }}
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -12,7 +12,10 @@ import { Store } from '@ngrx/store';
|
||||
import { TranslateService } from '@ngx-translate/core';
|
||||
import { take } from 'rxjs';
|
||||
import * as semver from 'semver';
|
||||
import { EPG_FORCE_FETCH } from '../../../shared/ipc-commands';
|
||||
import {
|
||||
EPG_FORCE_FETCH,
|
||||
SET_MPV_PLAYER_PATH,
|
||||
} from '../../../shared/ipc-commands';
|
||||
import { Playlist } from '../../../shared/playlist.interface';
|
||||
import { DataService } from '../services/data.service';
|
||||
import { DialogService } from '../services/dialog.service';
|
||||
@@ -37,6 +40,9 @@ export class SettingsComponent implements OnInit {
|
||||
/** List with available languages as enum */
|
||||
languageEnum = Language;
|
||||
|
||||
/** Flag that indicates whether the app runs in electron environment */
|
||||
isElectron = this.electronService.isElectron;
|
||||
|
||||
/** Player options */
|
||||
players = [
|
||||
{
|
||||
@@ -47,10 +53,15 @@ export class SettingsComponent implements OnInit {
|
||||
id: VideoPlayer.VideoJs,
|
||||
label: 'VideoJs Player',
|
||||
},
|
||||
{
|
||||
id: VideoPlayer.MPV,
|
||||
label: 'MPV Player',
|
||||
},
|
||||
...(this.isElectron
|
||||
?
|
||||
[
|
||||
{
|
||||
id: VideoPlayer.MPV,
|
||||
label: 'MPV Player',
|
||||
},
|
||||
]
|
||||
: []),
|
||||
];
|
||||
|
||||
/** Current version of the app */
|
||||
@@ -62,9 +73,6 @@ export class SettingsComponent implements OnInit {
|
||||
/** EPG availability flag */
|
||||
epgAvailable$ = this.store.select(selectIsEpgAvailable);
|
||||
|
||||
/** Flag that indicates whether the app runs in electron environment */
|
||||
isElectron = this.electronService.isElectron;
|
||||
|
||||
/** All available visual themes */
|
||||
themeEnum = Theme;
|
||||
|
||||
@@ -75,6 +83,7 @@ export class SettingsComponent implements OnInit {
|
||||
language: Language.ENGLISH,
|
||||
showCaptions: false,
|
||||
theme: Theme.LightTheme,
|
||||
mpvPlayerPath: '',
|
||||
});
|
||||
|
||||
/** Form array with epg sources */
|
||||
@@ -128,6 +137,7 @@ export class SettingsComponent implements OnInit {
|
||||
theme: settings.theme
|
||||
? settings.theme
|
||||
: Theme.LightTheme,
|
||||
mpvPlayerPath: settings.mpvPlayerPath,
|
||||
});
|
||||
|
||||
if (this.isElectron) {
|
||||
@@ -215,6 +225,11 @@ export class SettingsComponent implements OnInit {
|
||||
.subscribe(() => {
|
||||
this.applyChangedSettings();
|
||||
});
|
||||
|
||||
this.electronService.sendIpcEvent(
|
||||
SET_MPV_PLAYER_PATH,
|
||||
this.settingsForm.value.mpvPlayerPath
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -240,6 +255,7 @@ export class SettingsComponent implements OnInit {
|
||||
null,
|
||||
{
|
||||
duration: 2000,
|
||||
horizontalPosition: 'left',
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
@@ -20,4 +20,5 @@ export interface Settings {
|
||||
language: Language;
|
||||
showCaptions: boolean;
|
||||
theme: Theme;
|
||||
mpvPlayerPath: string;
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Component, Inject } from '@angular/core';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { MatDialogModule, MAT_DIALOG_DATA } from '@angular/material/dialog';
|
||||
import { MAT_DIALOG_DATA, MatDialogModule } from '@angular/material/dialog';
|
||||
import { TranslateModule } from '@ngx-translate/core';
|
||||
import { ConfirmDialogData } from './confirm-dialog-data.interface';
|
||||
@Component({
|
||||
|
||||
@@ -2,6 +2,7 @@ import { Injectable, Pipe, PipeTransform } from '@angular/core';
|
||||
|
||||
@Pipe({
|
||||
name: 'filterBy',
|
||||
standalone: true,
|
||||
})
|
||||
@Injectable()
|
||||
export class FilterPipe implements PipeTransform {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Pipe, PipeTransform } from '@angular/core';
|
||||
import * as moment from 'moment';
|
||||
import moment from 'moment';
|
||||
|
||||
/**
|
||||
* Moment.js based pipe to parse and return the provided date based on given params
|
||||
|
||||
@@ -15,4 +15,7 @@ export type PlaylistMeta = Pick<
|
||||
| 'position'
|
||||
| 'autoRefresh'
|
||||
| 'favorites'
|
||||
| 'serverUrl'
|
||||
| 'username'
|
||||
| 'password'
|
||||
>;
|
||||
@@ -14,13 +14,13 @@ import { MomentDatePipe } from './pipes/moment-date.pipe';
|
||||
|
||||
@NgModule({
|
||||
declarations: [
|
||||
FilterPipe,
|
||||
HeaderComponent,
|
||||
MomentDatePipe,
|
||||
RecentPlaylistsComponent,
|
||||
PlaylistItemComponent,
|
||||
],
|
||||
imports: [
|
||||
FilterPipe,
|
||||
CommonModule,
|
||||
FormsModule,
|
||||
MaterialModule,
|
||||
|
||||
@@ -167,7 +167,11 @@ export class PlaylistEffects {
|
||||
this.playlistsService.addPlaylist(action.playlist)
|
||||
),
|
||||
tap((playlist) => {
|
||||
this.router.navigate(['/playlists/', playlist._id]);
|
||||
if (playlist.serverUrl) {
|
||||
this.router.navigate(['/xtreams/', playlist._id]);
|
||||
} else {
|
||||
this.router.navigate(['/playlists/', playlist._id]);
|
||||
}
|
||||
})
|
||||
);
|
||||
},
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { createReducer, on } from '@ngrx/store';
|
||||
import * as moment from 'moment';
|
||||
import moment from 'moment';
|
||||
import * as PlaylistActions from './actions';
|
||||
import { playlistsAdapter } from './playlists.state';
|
||||
import { initialState, PlaylistState } from './state';
|
||||
import { PlaylistState, initialState } from './state';
|
||||
|
||||
export const playlistReducer = createReducer(
|
||||
initialState,
|
||||
@@ -125,6 +125,7 @@ export const playlistReducer = createReducer(
|
||||
updateDate: Date.now(),
|
||||
favorites: [],
|
||||
count: action.playlist.playlist.items.length,
|
||||
userAgent: action.playlist.userAgent,
|
||||
},
|
||||
},
|
||||
state.playlists
|
||||
@@ -179,6 +180,16 @@ export const playlistReducer = createReducer(
|
||||
changes: {
|
||||
title: action.playlist.title,
|
||||
autoRefresh: action.playlist.autoRefresh || false,
|
||||
userAgent: action.playlist.userAgent,
|
||||
...(action.playlist.serverUrl
|
||||
? { serverUrl: action.playlist.serverUrl }
|
||||
: {}),
|
||||
...(action.playlist.username
|
||||
? { username: action.playlist.username }
|
||||
: {}),
|
||||
...(action.playlist.password
|
||||
? { password: action.playlist.password }
|
||||
: {}),
|
||||
},
|
||||
},
|
||||
state.playlists
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
import { getRouterSelectors } from '@ngrx/router-store';
|
||||
|
||||
export const {
|
||||
selectCurrentRoute,
|
||||
selectFragment,
|
||||
selectQueryParams,
|
||||
selectQueryParam,
|
||||
selectRouteParams,
|
||||
selectRouteParam,
|
||||
selectRouteData,
|
||||
selectRouteDataParam,
|
||||
selectUrl,
|
||||
selectTitle,
|
||||
} = getRouterSelectors();
|
||||
@@ -1,6 +1,8 @@
|
||||
import { createFeatureSelector, createSelector } from '@ngrx/store';
|
||||
import { GLOBAL_FAVORITES_PLAYLIST_ID } from '../../../shared/constants';
|
||||
import * as fromPlaylistMetaState from './playlists.state';
|
||||
import * as fromPlaylistState from './reducers';
|
||||
import { selectRouteParam } from './router.selectors';
|
||||
import { PlaylistState } from './state';
|
||||
|
||||
export const selectPlaylistState =
|
||||
@@ -62,7 +64,9 @@ export const selectPlaylistTitle = createSelector(
|
||||
fromPlaylistMetaState.getPlaylistMetaEntities,
|
||||
fromPlaylistState.selectPlaylistId,
|
||||
(data) => {
|
||||
if (
|
||||
if (data.selectedId === GLOBAL_FAVORITES_PLAYLIST_ID) {
|
||||
return 'Global favorites';
|
||||
} else if (
|
||||
data.entities &&
|
||||
data.selectedId &&
|
||||
data.entities[data.selectedId]
|
||||
@@ -80,6 +84,17 @@ export const selectPlaylistEntities = createSelector(
|
||||
fromPlaylistMetaState.getPlaylistMetaEntities
|
||||
);
|
||||
|
||||
export const selectCurrentPlaylist = createSelector(
|
||||
selectPlaylistEntities,
|
||||
selectRouteParam('id'),
|
||||
(entities, id) => {
|
||||
if (entities) {
|
||||
return entities[id];
|
||||
}
|
||||
return null;
|
||||
}
|
||||
);
|
||||
|
||||
export const selectFavorites = createSelector(
|
||||
selectPlaylistsMetaState,
|
||||
fromPlaylistMetaState.getPlaylistMetaEntities,
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
import { XtreamCodeActions } from '../../../shared/xtream-code-actions';
|
||||
|
||||
export interface Breadcrumb {
|
||||
action: XtreamCodeActions;
|
||||
title: string;
|
||||
category_id?: string;
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
<ng-container *ngIf="items?.length > 0; else noItems">
|
||||
<div class="grid">
|
||||
<mat-card
|
||||
*ngFor="
|
||||
let item of items | filterBy : searchPhrase() : '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
|
||||
class="no-content"
|
||||
*ngIf="!(items | filterBy : searchPhrase() : 'name')?.length"
|
||||
>
|
||||
<mat-icon class="icon">search</mat-icon>
|
||||
<div>Nothing found, try to change you search request</div>
|
||||
</div>
|
||||
</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,64 @@
|
||||
:host {
|
||||
display: block;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.grid {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 30px;
|
||||
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: #999;
|
||||
height: 300px;
|
||||
|
||||
&::after {
|
||||
content: 'No cover';
|
||||
position: relative;
|
||||
top: 140px;
|
||||
left: 70px;
|
||||
}
|
||||
}
|
||||
|
||||
.title {
|
||||
word-break: break-all;
|
||||
padding: 8px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.no-content {
|
||||
text-align: center;
|
||||
|
||||
.icon {
|
||||
font-size: 64px;
|
||||
height: 64px;
|
||||
width: 64px;
|
||||
}
|
||||
}
|
||||
|
||||
mat-card {
|
||||
overflow: hidden;
|
||||
|
||||
transition: all 0.9s cubic-bezier(0.25, 0.8, 0.25, 1);
|
||||
}
|
||||
|
||||
mat-card:hover {
|
||||
box-shadow: 0 14px 28px rgba(0, 0, 0, 0.25), 0 10px 10px rgba(0, 0, 0, 0.22);
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import { NgFor, NgIf } from '@angular/common';
|
||||
import { Component, EventEmitter, Input, Output, inject } 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';
|
||||
import { PortalStore } from '../portal.store';
|
||||
|
||||
@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>();
|
||||
|
||||
portalStore = inject(PortalStore);
|
||||
|
||||
searchPhrase = this.portalStore.searchPhrase;
|
||||
|
||||
trackByFn(_index: number, item: XtreamItem) {
|
||||
return item.stream_id;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
import { NgFor, NgIf } from '@angular/common';
|
||||
import { Component, EventEmitter, Input, Output, inject } 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';
|
||||
import { PortalStore } from '../portal.store';
|
||||
|
||||
@Component({
|
||||
selector: 'app-category-view',
|
||||
standalone: true,
|
||||
template: `
|
||||
<ng-container *ngIf="items?.length > 0; else noItems">
|
||||
<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
|
||||
class="no-content"
|
||||
*ngIf="
|
||||
!(items | filterBy : searchText() : 'category_name')
|
||||
?.length
|
||||
"
|
||||
>
|
||||
<mat-icon class="icon">search</mat-icon>
|
||||
<div>Nothing found, try to change you search request</div>
|
||||
</div>
|
||||
</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: 20px;
|
||||
display: block;
|
||||
}
|
||||
|
||||
mat-card-content {
|
||||
overflow-wrap: break-word;
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
}
|
||||
`,
|
||||
],
|
||||
imports: [
|
||||
FilterPipe,
|
||||
FormsModule,
|
||||
MatCardModule,
|
||||
MatIconModule,
|
||||
NgFor,
|
||||
NgIf,
|
||||
],
|
||||
})
|
||||
export class CategoryViewComponent {
|
||||
@Input({ required: true }) items: XtreamCategory[];
|
||||
|
||||
@Output() categoryClicked = new EventEmitter<XtreamCategory>();
|
||||
|
||||
portalStore = inject(PortalStore);
|
||||
|
||||
searchText = this.portalStore.searchPhrase;
|
||||
|
||||
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,75 @@
|
||||
:host {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.container {
|
||||
display: grid;
|
||||
grid-gap: 10px;
|
||||
grid-template-columns: 350px 450px;
|
||||
justify-content: center;
|
||||
|
||||
&::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: 10px;
|
||||
|
||||
.placeholder-cover {
|
||||
width: 300px;
|
||||
height: 500px;
|
||||
background-color: #999;
|
||||
|
||||
&::after {
|
||||
content: 'No cover';
|
||||
position: relative;
|
||||
top: 230px;
|
||||
left: 115px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.image img {
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
@media (max-width: 800px) {
|
||||
.container {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
margin: 0 auto;
|
||||
max-width: 1200px;
|
||||
}
|
||||
|
||||
.image,
|
||||
.details {
|
||||
flex: 1 1 100%;
|
||||
}
|
||||
|
||||
.details {
|
||||
padding: 0 10px;
|
||||
}
|
||||
}
|
||||
|
||||
.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,46 @@
|
||||
<div id="navigation">
|
||||
<button mat-button routerLink="/">
|
||||
<mat-icon>home</mat-icon> Playlists
|
||||
</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-button (click)="favoritesClicked.emit()">
|
||||
<mat-icon>star</mat-icon> Favorites
|
||||
</button>
|
||||
</div>
|
||||
<div id="sub-panel">
|
||||
<div class="breadcrumbs">
|
||||
<span
|
||||
*ngFor="let item of breadcrumbs"
|
||||
(click)="processBreadcrumbClick(item)"
|
||||
>{{ item.title }}</span
|
||||
>
|
||||
</div>
|
||||
<div class="search" *ngIf="searchVisible">
|
||||
<input
|
||||
class="search-input"
|
||||
placeholder="Search by name"
|
||||
type="search"
|
||||
[ngModel]="searchPhrase()"
|
||||
(ngModelChange)="setSearchText($event)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,65 @@
|
||||
:host {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
#navigation {
|
||||
height: 70px;
|
||||
background: white;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 0 10px;
|
||||
}
|
||||
|
||||
#sub-panel {
|
||||
background: white;
|
||||
border-bottom: 1px solid #ccc;
|
||||
padding: 0 10px 5px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
height: 30px;
|
||||
|
||||
.breadcrumbs {
|
||||
span {
|
||||
cursor: pointer;
|
||||
|
||||
&:not(:last-child)::after {
|
||||
content: '>';
|
||||
margin: 0 10px;
|
||||
}
|
||||
}
|
||||
|
||||
span:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
|
||||
.search {
|
||||
text-align: center;
|
||||
|
||||
.search-input {
|
||||
padding: 6px 8px 6px 40px;
|
||||
width: 250px;
|
||||
border-radius: 5px;
|
||||
border: 1px solid #333;
|
||||
color: #000;
|
||||
background: #ccc url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' class='bi bi-search' viewBox='0 0 16 16'%3E%3Cpath d='M11.742 10.344a6.5 6.5 0 1 0-1.397 1.398h-.001c.03.04.062.078.098.115l3.85 3.85a1 1 0 0 0 1.415-1.414l-3.85-3.85a1.007 1.007 0 0 0-.115-.1zM12 6.5a5.5 5.5 0 1 1-11 0 5.5 5.5 0 0 1 11 0z'%3E%3C/path%3E%3C/svg%3E") no-repeat 13px center;
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
::ng-deep {
|
||||
.dark-theme {
|
||||
#navigation, #sub-panel {
|
||||
background: black;
|
||||
}
|
||||
|
||||
#sub-panel {
|
||||
border-bottom: 1px solid #333;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import { NgFor, NgIf } from '@angular/common';
|
||||
import { Component, EventEmitter, Input, Output, inject } from '@angular/core';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
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 { Breadcrumb } from '../breadcrumb.interface';
|
||||
import { ContentType } from '../content-type.enum';
|
||||
import { PortalStore } from '../portal.store';
|
||||
|
||||
@Component({
|
||||
selector: 'app-navigation-bar',
|
||||
templateUrl: './navigation-bar.component.html',
|
||||
styleUrls: ['./navigation-bar.component.scss'],
|
||||
standalone: true,
|
||||
imports: [
|
||||
MatButtonToggleModule,
|
||||
MatIconModule,
|
||||
MatButtonModule,
|
||||
RouterLink,
|
||||
NgFor,
|
||||
FormsModule,
|
||||
NgIf,
|
||||
],
|
||||
})
|
||||
export class NavigationBarComponent {
|
||||
@Input({ required: true }) breadcrumbs: Breadcrumb[];
|
||||
@Input({ required: true }) contentType: ContentType;
|
||||
@Input() searchVisible = true;
|
||||
|
||||
@Output() contentTypeChanged = new EventEmitter<ContentType>();
|
||||
@Output() breadcrumbClicked = new EventEmitter<Breadcrumb>();
|
||||
@Output() favoritesClicked = new EventEmitter<void>();
|
||||
|
||||
ContentTypeEnum = ContentType;
|
||||
portalStore = inject(PortalStore);
|
||||
searchPhrase = this.portalStore.searchPhrase;
|
||||
|
||||
processBreadcrumbClick(item: Breadcrumb) {
|
||||
this.setSearchText('');
|
||||
this.breadcrumbClicked.emit(item);
|
||||
}
|
||||
|
||||
setSearchText(text: string) {
|
||||
this.portalStore.setSearchPhrase(text);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
<h2 mat-dialog-title>{{ title }}</h2>
|
||||
<mat-dialog-content>
|
||||
<app-vjs-player
|
||||
*ngIf="player === 'videojs'"
|
||||
[options]="vjsOptions"
|
||||
></app-vjs-player>
|
||||
<app-html-video-player *ngIf="player === 'html5'" [channel]="channel">
|
||||
</app-html-video-player>
|
||||
</mat-dialog-content>
|
||||
@@ -0,0 +1,49 @@
|
||||
import { NgIf } from '@angular/common';
|
||||
import { Component, Inject } from '@angular/core';
|
||||
import { MAT_DIALOG_DATA, MatDialogModule } from '@angular/material/dialog';
|
||||
import { getExtensionFromUrl } from '../../../../shared/playlist.utils';
|
||||
import { HtmlVideoPlayerComponent } from '../../player/components/html-video-player/html-video-player.component';
|
||||
import { VjsPlayerComponent } from '../../player/components/vjs-player/vjs-player.component';
|
||||
import { VideoPlayer } from '../../settings/settings.interface';
|
||||
|
||||
interface DialogData {
|
||||
streamUrl: string;
|
||||
player: VideoPlayer;
|
||||
title: string;
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'app-player-dialog',
|
||||
templateUrl: './player-dialog.component.html',
|
||||
standalone: true,
|
||||
imports: [
|
||||
HtmlVideoPlayerComponent,
|
||||
MatDialogModule,
|
||||
NgIf,
|
||||
VjsPlayerComponent,
|
||||
],
|
||||
})
|
||||
export class PlayerDialogComponent {
|
||||
channel = {};
|
||||
vjsOptions = {};
|
||||
player: VideoPlayer;
|
||||
title: string;
|
||||
|
||||
constructor(@Inject(MAT_DIALOG_DATA) data: DialogData) {
|
||||
this.player = data.player;
|
||||
this.title = data.title;
|
||||
|
||||
const extension = getExtensionFromUrl(data.streamUrl);
|
||||
const mimeType =
|
||||
extension === 'm3u' || extension === 'm3u8' || extension === 'ts'
|
||||
? 'application/x-mpegURL'
|
||||
: 'video/mp4';
|
||||
|
||||
this.vjsOptions = {
|
||||
sources: [{ src: data.streamUrl, type: mimeType }],
|
||||
};
|
||||
this.channel = {
|
||||
url: data.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');
|
||||
}
|
||||
}
|
||||
Loaded 100 of 124 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user