Merge branch 'electron'

This commit is contained in:
4gray committed 2023-07-20 21:07:54 +02:00
commit ff8b5f9225
124 files changed
+13705 -7031

No files matched your search

+5 -3
View File
@@ -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
+1
View File
@@ -1,2 +1,3 @@
legacy-peer-deps=true
save=true
save-exact=true
+2 -1
View File
@@ -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
View File
@@ -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
View File
@@ -29,6 +29,9 @@ import {
PLAYLIST_PARSE_RESPONSE,
PLAYLIST_UPDATE,
PLAYLIST_UPDATE_RESPONSE,
SET_MPV_PLAYER_PATH,
XTREAM_REQUEST,
XTREAM_RESPONSE,
} from '../shared/ipc-commands';
import { Playlist } from '../shared/playlist.interface';
import { createPlaylistObject } from '../shared/playlist.utils';
@@ -38,9 +41,6 @@ const fs = require('fs');
const https = require('https');
const mpvAPI = require('node-mpv');
const createMpvInstance = () => new mpvAPI({}, ['--autofit=70%']);
let mpv = createMpvInstance();
mpv.on('quit', () => (mpv = null)).on('crash', () => (mpv = null));
/** @deprecated - used only for migration */
const Nedb = require('nedb-promises');
@@ -62,6 +62,8 @@ const agent = new https.Agent({
rejectUnauthorized: false,
});
const MPV_PLAYER_PATH = 'MPV_PLAYER_PATH';
export class Api {
/** Instance of the main application window */
mainWindow: BrowserWindow;
@@ -75,7 +77,17 @@ export class Api {
/** Instance of the epg browser window */
workerWindow: BrowserWindow;
constructor() {
store;
mpv;
constructor(store) {
this.store = store;
this.mpv = this.createMpvInstance();
this.mpv
.on('quit', () => (this.mpv = null))
.on('crash', () => (this.mpv = null));
ipcMain
.on(PLAYLIST_PARSE_BY_URL, (event, args) => {
try {
@@ -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
View File
@@ -1,5 +1,5 @@
/* eslint-disable no-useless-catch */
import { app, BrowserWindow, Menu } from 'electron';
import { app, BrowserWindow, Menu, session } from 'electron';
import * as path from 'path';
import * as url from 'url';
import { Api } from './api';
@@ -11,6 +11,7 @@ const {
const contextMenu = require('electron-context-menu');
const Store = require('electron-store');
const store = new Store();
const os = require('os');
const WINDOW_BOUNDS = 'WINDOW_BOUNDS';
@@ -19,7 +20,7 @@ let win: BrowserWindow | null = null;
const args = process.argv.slice(1),
serve = args.some((val) => val === '--serve');
const api = new Api();
const api = new Api(store);
contextMenu();
require('@electron/remote/main').initialize();
@@ -145,6 +146,18 @@ try {
app.on('before-quit', () => {
store.set(WINDOW_BOUNDS, win?.getNormalBounds());
});
if (serve && process.platform === 'darwin') {
// add redux dev tools extension
const reduxDevToolsPath = path.join(
os.homedir(),
'Library/Application Support/Google/Chrome/Default/Extensions/lmhkpmbekcpmknklioeibfkpmmfibljd/3.0.19_14'
);
app.whenReady().then(async () => {
await session.defaultSession.loadExtension(reduxDevToolsPath);
});
}
} catch (e) {
// Catch Error
throw e;
+1 -1
View File
@@ -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
View File
@@ -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'),
});
});
+10811 -6571
View File
File diff suppressed because it is too large. Load diff
+44 -44
View File
@@ -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"
+5
View File
@@ -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';
+6 -3
View File
@@ -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;
}
+4
View File
@@ -88,3 +88,7 @@ export const createPlaylistObject = (
...(uploadType === 'FILE' ? { filePath: urlOrPath } : {}),
};
};
export const getExtensionFromUrl = (url: string) => {
return url.split(/[#?]/)[0].split('.').pop().trim();
};
+5
View File
@@ -0,0 +1,5 @@
export interface XtreamCategory {
category_id: string;
category_name: string;
parent_id: number;
}
+11
View File
@@ -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',
}
+11
View File
@@ -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;
}
+8
View File
@@ -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;
}
+6
View File
@@ -0,0 +1,6 @@
import { XtreamCodeActions } from './xtream-code-actions';
export interface XtreamResponse {
payload: unknown;
action: XtreamCodeActions;
}
+57
View File
@@ -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;
}
+18
View File
@@ -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;
}
+39
View File
@@ -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;
};
}
+8
View File
@@ -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;
}
+7
View File
@@ -29,6 +29,13 @@ const routes: Routes = [
(c) => c.SettingsComponent
),
},
{
path: 'xtreams/:id',
loadComponent: () =>
import('./xtream/xtream-main-container.component').then(
(c) => c.XtreamMainContainerComponent
),
},
{
path: '**',
redirectTo: '',
+4 -1
View File
@@ -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 -1
View File
@@ -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({
+14 -3
View File
@@ -35,7 +35,7 @@
<app-file-upload
(fileRejected)="rejectFile($event)"
(fileSelected)="handlePlaylist($event)"
></app-file-upload>
/>
</ng-template>
</mat-tab>
<mat-tab>
@@ -48,7 +48,7 @@
<ng-template matTabContent>
<app-url-upload
(urlAdded)="sendPlaylistsUrl($event)"
></app-url-upload>
/>
</ng-template>
</mat-tab>
<mat-tab>
@@ -61,7 +61,18 @@
<ng-template matTabContent>
<app-text-import
(textAdded)="uploadAsText($event)"
></app-text-import>
/>
</ng-template>
</mat-tab>
<mat-tab>
<ng-template mat-tab-label>
<mat-icon class="tab-icon">text_format</mat-icon>
<div class="tab-title">
{{ 'HOME.TABS.XTREME_IMPORT' | translate }}
</div>
</ng-template>
<ng-template matTabContent>
<app-xtream-code-import />
</ng-template>
</mat-tab>
</mat-tab-group>
+8 -1
View File
@@ -7,8 +7,15 @@ import { HomeComponent } from './home.component';
import { HomeRoutingModule } from './home.routing';
import { TextImportComponent } from './text-import/text-import.component';
import { UrlUploadComponent } from './url-upload/url-upload.component';
import { XtreamCodeImportComponent } from './xtream-code-import/xtream-code-import.component';
@NgModule({
imports: [CommonModule, HomeRoutingModule, SharedModule],
imports: [
CommonModule,
HomeRoutingModule,
SharedModule,
XtreamCodeImportComponent,
],
declarations: [
DragDropFileUploadDirective,
HomeComponent,
@@ -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 })
);
}
}
@@ -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>
@@ -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),
@@ -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;
}
@@ -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;
@@ -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,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 */
+2 -2
View File
@@ -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 {}
+5 -2
View File
@@ -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'),
});
}
));
});
+8 -6
View File
@@ -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() {
+44 -11
View File
@@ -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;
+27 -1
View File
@@ -10,6 +10,8 @@ import {
PLAYLIST_PARSE_BY_URL,
PLAYLIST_PARSE_RESPONSE,
PLAYLIST_UPDATE,
XTREAM_REQUEST,
XTREAM_RESPONSE,
} from '../../../shared/ipc-commands';
import { Playlist } from '../../../shared/playlist.interface';
import { AppConfig } from '../../environments/environment';
@@ -65,6 +67,10 @@ export class PwaService extends DataService {
this.fetchFromUrl(payload);
} else if (type === PLAYLIST_UPDATE) {
this.refreshPlaylist(payload);
} else if (type === XTREAM_REQUEST) {
this.forwardXtreamRequest(
payload as { url: string; params: Record<string, string> }
);
}
}
@@ -136,6 +142,26 @@ export class PwaService extends DataService {
return message;
}
forwardXtreamRequest(payload: {
url: string;
params: Record<string, string>;
}) {
return this.http
.get(`${this.corsProxyUrl}/xtream`, {
params: {
url: payload.url,
...payload.params,
},
})
.subscribe((response) => {
window.postMessage({
type: XTREAM_RESPONSE,
payload: (response as any).payload,
action: payload.params.action,
});
});
}
getPlaylistFromUrl(url: string) {
return this.http.get(`${this.corsProxyUrl}/parse`, {
params: { url },
@@ -146,7 +172,7 @@ export class PwaService extends DataService {
// not implemented
}
listenOn(command: string, callback: (...args: any[]) => void): void {
listenOn(_command: string, callback: (...args: any[]) => void): void {
window.addEventListener('message', callback);
}
}
+17
View File
@@ -259,6 +259,23 @@ export class WhatsNewService {
},
},
],
'0.14.0': [
{
title: '🚀 New in v0.14.0',
html: `The main feature of this release is &mdash; 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 */
+1
View File
@@ -8,6 +8,7 @@ export enum Language {
GERMAN = 'de',
SPANISH = 'es',
CHINESE = 'zh',
TRADITIONAL_CHINESE = 'zhtw',
FRENCH = 'fr',
ITALIAN = 'it',
}
+20
View File
@@ -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 }}
+45 -41
View File
@@ -60,6 +60,7 @@ const DEFAULT_SETTINGS = {
language: Language.ENGLISH,
showCaptions: false,
theme: Theme.LightTheme,
mpvPlayerPath: '',
};
describe('SettingsComponent', () => {
@@ -70,46 +71,44 @@ describe('SettingsComponent', () => {
let storage: StorageMap;
let translate: TranslateService;
beforeEach(
waitForAsync(() => {
TestBed.configureTestingModule({
declarations: [
SettingsComponent,
MockComponent(HeaderComponent),
MockPipe(TranslatePipe),
],
providers: [
UntypedFormBuilder,
MockProvider(TranslateService),
{ provide: MatSnackBar, useClass: MatSnackBarStub },
{ provide: DataService, useClass: ElectronServiceStub },
{
provide: Router,
useClass: MockRouter,
},
StorageMap,
provideMockStore(),
MockProviders(NgxIndexedDBService, PlaylistsService),
],
imports: [
HttpClientTestingModule,
MockModule(FormsModule),
MockModule(MatSelectModule),
MockModule(MatIconModule),
MockModule(MatTooltipModule),
MockModule(ReactiveFormsModule),
MockModule(RouterTestingModule),
MockModule(MatCardModule),
MockModule(MatListModule),
MockModule(MatFormFieldModule),
MockModule(MatCheckboxModule),
MockModule(MatDividerModule),
MockModule(TranslateModule),
MockModule(SharedModule),
],
}).compileComponents();
})
);
beforeEach(waitForAsync(() => {
TestBed.configureTestingModule({
declarations: [
SettingsComponent,
MockComponent(HeaderComponent),
MockPipe(TranslatePipe),
],
providers: [
UntypedFormBuilder,
MockProvider(TranslateService),
{ provide: MatSnackBar, useClass: MatSnackBarStub },
{ provide: DataService, useClass: ElectronServiceStub },
{
provide: Router,
useClass: MockRouter,
},
StorageMap,
provideMockStore(),
MockProviders(NgxIndexedDBService, PlaylistsService),
],
imports: [
HttpClientTestingModule,
MockModule(FormsModule),
MockModule(MatSelectModule),
MockModule(MatIconModule),
MockModule(MatTooltipModule),
MockModule(ReactiveFormsModule),
MockModule(RouterTestingModule),
MockModule(MatCardModule),
MockModule(MatListModule),
MockModule(MatFormFieldModule),
MockModule(MatCheckboxModule),
MockModule(MatDividerModule),
MockModule(TranslateModule),
MockModule(SharedModule),
],
}).compileComponents();
}));
beforeEach(() => {
fixture = TestBed.createComponent(SettingsComponent);
@@ -127,7 +126,12 @@ describe('SettingsComponent', () => {
});
describe('Get and set settings on component init', () => {
const settings = { player: 'test', showCaptions: true, epgUrl: [] };
const settings = {
player: 'test',
showCaptions: true,
epgUrl: [],
mpvPlayerPath: '',
};
let spyOnStorageGet;
beforeEach(() => {
+24 -8
View File
@@ -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',
}
);
}
+1
View File
@@ -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({
+1
View File
@@ -2,6 +2,7 @@ import { Injectable, Pipe, PipeTransform } from '@angular/core';
@Pipe({
name: 'filterBy',
standalone: true,
})
@Injectable()
export class FilterPipe implements PipeTransform {
+1 -1
View File
@@ -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
+3
View File
@@ -15,4 +15,7 @@ export type PlaylistMeta = Pick<
| 'position'
| 'autoRefresh'
| 'favorites'
| 'serverUrl'
| 'username'
| 'password'
>;
+1 -1
View File
@@ -14,13 +14,13 @@ import { MomentDatePipe } from './pipes/moment-date.pipe';
@NgModule({
declarations: [
FilterPipe,
HeaderComponent,
MomentDatePipe,
RecentPlaylistsComponent,
PlaylistItemComponent,
],
imports: [
FilterPipe,
CommonModule,
FormsModule,
MaterialModule,
+5 -1
View File
@@ -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]);
}
})
);
},
+13 -2
View File
@@ -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
+14
View File
@@ -0,0 +1,14 @@
import { getRouterSelectors } from '@ngrx/router-store';
export const {
selectCurrentRoute,
selectFragment,
selectQueryParams,
selectQueryParam,
selectRouteParams,
selectRouteParam,
selectRouteData,
selectRouteDataParam,
selectUrl,
selectTitle,
} = getRouterSelectors();
+16 -1
View File
@@ -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,
+7
View File
@@ -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;
}
}
+5
View File
@@ -0,0 +1,5 @@
export enum ContentType {
LIVE_STREAMS = 'LIVE_STREAMS',
VODS = 'VODS',
SERIES = 'SERIES',
}
+75
View File
@@ -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