Merge pull request #149 from 4gray/141-multiple-epg-sources

Feature: Support multiple EPG sources
This commit is contained in:
4gray authored and GitHub committed 2022-07-16 10:03:01 +02:00
commit 01f4be1a19
30 files changed
+10058 -7309

No files matched your search

+1
View File
@@ -0,0 +1 @@
**/*.worker.ts
+4
View File
@@ -2,10 +2,14 @@
# compiled output
/dist
/build
/tmp
/out-tsc
/app-builds
/release
/e2e/tracing
/e2e/screenshots
/test-results
api.js
epg-worker.js
main.js
+5 -3
View File
@@ -19,7 +19,7 @@
"build": {
"builder": "@angular-builders/custom-webpack:browser",
"options": {
"outputPath": "dist",
"outputPath": "build",
"index": "src/index.html",
"main": "src/main.ts",
"tsConfig": "src/tsconfig.app.json",
@@ -38,7 +38,8 @@
"replaceDuplicatePlugins": true
},
"serviceWorker": true,
"ngswConfigPath": "ngsw-config.json"
"ngswConfigPath": "ngsw-config.json",
"webWorkerTsConfig": "tsconfig.worker.json"
},
"configurations": {
"dev": {
@@ -131,7 +132,8 @@
"customWebpackConfig": {
"path": "./angular.webpack.js",
"replaceDuplicatePlugins": true
}
},
"webWorkerTsConfig": "tsconfig.worker.json"
}
},
"lint": {
+58
View File
@@ -0,0 +1,58 @@
import { expect, test } from '@playwright/test';
import {
BrowserContext,
ElectronApplication,
Page,
_electron as electron,
} from 'playwright';
const PATH = require('path');
test.describe('Check Home Page', async () => {
let app: ElectronApplication;
let firstWindow: Page;
let context: BrowserContext;
test.beforeAll(async () => {
app = await electron.launch({
args: [
PATH.join(__dirname, '../electron/main.js'),
PATH.join(__dirname, '../electron/package.json'),
],
});
context = app.context();
//await context.tracing.start({ screenshots: true, snapshots: true });
firstWindow = await app.windows()[0];
const isMainWindow = (await firstWindow.title()) === 'IPTVnator';
if (!isMainWindow) {
firstWindow = await app.windows()[1];
}
await firstWindow.waitForLoadState('domcontentloaded');
});
test('Launch electron app', async () => {
const windowState = await app.evaluate(async (process) => {
let mainWindow = process.BrowserWindow.getAllWindows()[0];
const isMainWindow = mainWindow.title === 'IPTVnator';
if (!isMainWindow) {
mainWindow = process.BrowserWindow.getAllWindows()[1];
}
return {
isVisible: mainWindow.isVisible(),
isDevToolsOpened: mainWindow.webContents.isDevToolsOpened(),
isCrashed: mainWindow.webContents.isCrashed(),
};
});
expect(windowState.isVisible).toBeTruthy();
expect(windowState.isDevToolsOpened).toBeFalsy();
expect(windowState.isCrashed).toBeFalsy();
expect(app.windows()).toHaveLength(2);
});
test('Check title', async ({ page }) => {
const title = await firstWindow.title();
await firstWindow.screenshot({ path: 'e2e/screenshots/intro.png' });
expect(title).toBe('IPTVnator');
});
});
+8
View File
@@ -0,0 +1,8 @@
import { PlaywrightTestConfig } from '@playwright/test';
const config: PlaywrightTestConfig = {
testDir: '.',
maxFailures: 2,
};
export default config;
+5 -5
View File
@@ -1,7 +1,7 @@
import axios from 'axios';
import { app, BrowserWindow, ipcMain, session } from 'electron';
import { parse } from 'iptv-playlist-parser';
import { GLOBAL_FAVORITES_PLAYLIST_ID } from './shared/constants';
import { GLOBAL_FAVORITES_PLAYLIST_ID } from '../shared/constants';
import {
CHANNEL_SET_USER_AGENT,
EPG_ERROR,
@@ -26,13 +26,13 @@ import {
PLAYLIST_UPDATE_FAVORITES,
PLAYLIST_UPDATE_POSITIONS,
PLAYLIST_UPDATE_RESPONSE,
} from './shared/ipc-commands';
import { Playlist, PlaylistUpdateState } from './shared/playlist.interface';
} from '../shared/ipc-commands';
import { Playlist, PlaylistUpdateState } from '../shared/playlist.interface';
import {
aggregateFavoriteChannels,
createFavoritesPlaylist,
} from './shared/playlist.utils';
import { ParsedPlaylist } from './src/typings.d';
} from '../shared/playlist.utils';
import { ParsedPlaylist } from '../src/typings.d';
const Nedb = require('nedb-promises');
File renamed without changes.
+12 -5
View File
@@ -11,12 +11,15 @@ import {
EPG_GET_CHANNELS_DONE,
EPG_GET_PROGRAM,
EPG_GET_PROGRAM_DONE,
} from './shared/ipc-commands';
import { EpgChannel } from './src/app/player/models/epg-channel.model';
import { EpgProgram } from './src/app/player/models/epg-program.model';
} from '../shared/ipc-commands';
import { EpgChannel } from '../src/app/player/models/epg-channel.model';
import { EpgProgram } from '../src/app/player/models/epg-program.model';
// EPG data store
let EPG_DATA: { channels: EpgChannel[]; programs: EpgProgram[] };
let EPG_DATA: { channels: EpgChannel[]; programs: EpgProgram[] } = {
channels: [],
programs: [],
};
const loggerLabel = '[EPG Worker]';
/**
@@ -61,7 +64,11 @@ const fetchEpgDataFromUrl = (epgUrl: string) => {
*/
const parseAndSetEpg = (xmlString) => {
console.log(loggerLabel, 'start parsing...');
EPG_DATA = parser.parse(xmlString.toString());
const parsedEpg = parser.parse(xmlString.toString());
EPG_DATA = {
channels: [...EPG_DATA.channels, ...parsedEpg.channels],
programs: [...EPG_DATA.programs, ...parsedEpg.programs],
};
ipcRenderer.send(EPG_FETCH_DONE);
console.log(loggerLabel, 'done, parsing was finished...');
};
+4 -6
View File
@@ -6,7 +6,7 @@ import { Api } from './api';
import { AppMenu } from './menu';
const contextMenu = require('electron-context-menu');
let win: BrowserWindow = null;
let win: BrowserWindow | null = null;
const args = process.argv.slice(1),
serve = args.some((val) => val === '--serve');
@@ -28,7 +28,7 @@ function createWindow(): BrowserWindow {
},
resizable: true,
darkTheme: true,
icon: path.join(__dirname, 'dist/assets/icons/icon.png'),
icon: path.join(__dirname, '../build/assets/icons/icon.png'),
titleBarStyle: 'hidden',
frame: false,
minWidth: 900,
@@ -44,7 +44,7 @@ function createWindow(): BrowserWindow {
} else {
win.loadURL(
url.format({
pathname: path.join(__dirname, 'dist/index.html'),
pathname: path.join(__dirname, '../build/index.html'),
protocol: 'file:',
slashes: true,
})
@@ -79,11 +79,9 @@ function createEpgWorkerWindow() {
},
});
window.loadFile('./electron/epg-worker.html');
if (serve) {
window.loadFile('epg-worker.html');
window.webContents.openDevTools();
} else {
window.loadFile('epg-worker.html');
}
window.once('ready-to-show', () => {
+1 -1
View File
@@ -4,7 +4,7 @@ import {
SHOW_WHATS_NEW,
VIEW_ADD_PLAYLIST,
VIEW_SETTINGS,
} from './shared/ipc-commands';
} from '../shared/ipc-commands';
const openAboutWindow = require('about-window').default;
export class AppMenu {
+7
View File
@@ -0,0 +1,7 @@
{
"name": "iptvnator-electron",
"version": "0.10.0",
"main": "main.js",
"private": true,
"dependencies": {}
}
View File
File renamed without changes.
+1 -1
View File
@@ -2,7 +2,7 @@ module.exports = {
preset: 'jest-preset-angular',
resetMocks: true,
setupFilesAfterEnv: ['<rootDir>/src/setup-jest.ts'],
testMatch: ['**/+(*.)+(spec|test).+(ts)?(x)'],
testMatch: ['**/+(*.)+(spec).+(ts)?(x)'],
coverageReporters: ['html', 'lcov'],
transformIgnorePatterns: ['node_modules/(?!.*.mjs$|@datorama/akita)'],
};
+9500 -6953
View File
File diff suppressed because it is too large. Load diff
+35 -29
View File
@@ -18,13 +18,14 @@
"video",
"tv"
],
"main": "main.js",
"main": "electron/main.js",
"private": true,
"scripts": {
"postinstall": "electron-builder install-app-deps && husky install",
"ng": "ng",
"start": "npm-run-all -p electron:serve ng:serve",
"build": "npm run electron:serve-tsc && ng build --base-href ./",
"build:electron": "npm run electron:serve-tsc",
"build:dev": "npm run build -- -c dev",
"build:prod": "npm run build -- -c production",
"ng:build": "ng build -c production",
@@ -39,14 +40,14 @@
"electron:build:windows": "npm run build:prod && electron-builder build -w",
"test": "jest",
"test:watch": "jest --watch",
"e2e": "npm run build:prod && cross-env TS_NODE_PROJECT='e2e/tsconfig.e2e.json' mocha e2e/**/*.ts --config ./.mocharc.json --exit",
"e2e:watch": "npm run e2e -- --watch",
"e2e": "npm run build:electron && npx playwright test -c e2e/playwright.config.ts e2e/",
"e2e:debug": "npm run build:electron && PWDEBUG=1 npx playwright test -c e2e/playwright.config.ts e2e/",
"version": "conventional-changelog -i CHANGELOG.md -p angular -s -r 0 && git add CHANGELOG.md",
"lint": "ng lint",
"build-pwa": "ng build -c production --base-href /iptvnator --deploy-url /iptvnator/"
},
"dependencies": {
"@angular/animations": "13.0.2",
"@angular/animations": "13.3.11",
"@angular/cdk": "13.0.2",
"@angular/flex-layout": "13.0.0-beta.36",
"@angular/material": "13.0.2",
@@ -57,7 +58,7 @@
"@yangkghjh/videojs-aspect-ratio-panel": "0.0.1",
"about-window": "1.13.4",
"axios": "0.21.2",
"custom-electron-titlebar": "https://github.com/4gray/custom-electron-titlebar.git#testing",
"custom-electron-titlebar": "4.1.0",
"date-fns": "2.28.0",
"electron-context-menu": "3.1.1",
"epg-parser": "0.1.1",
@@ -70,8 +71,8 @@
"ngx-filter-pipe": "2.1.2",
"ngx-indexed-db": "9.4.2",
"ngx-uploader": "11.0.0",
"ngx-whats-new": "0.1.0",
"rxjs": "7.5.1",
"ngx-whats-new": "0.2.0",
"rxjs": "7.5.6",
"semver": "7.3.5",
"uuid": "8.3.2",
"video.js": "7.17.0",
@@ -81,32 +82,33 @@
},
"devDependencies": {
"@angular-builders/custom-webpack": "13.0.0",
"@angular-devkit/build-angular": "13.2.0",
"@angular-eslint/builder": "13.0.1",
"@angular-eslint/eslint-plugin": "13.0.1",
"@angular-eslint/eslint-plugin-template": "13.0.1",
"@angular-eslint/schematics": "13.0.1",
"@angular-eslint/template-parser": "13.0.1",
"@angular/cli": "13.0.2",
"@angular/common": "13.0.2",
"@angular/compiler": "13.0.2",
"@angular/compiler-cli": "13.0.2",
"@angular/core": "13.0.2",
"@angular/forms": "13.0.2",
"@angular/language-service": "13.0.2",
"@angular/platform-browser": "13.0.2",
"@angular/platform-browser-dynamic": "13.0.2",
"@angular/router": "13.0.2",
"@angular/service-worker": "13.0.2",
"@angular-devkit/build-angular": "13.3.8",
"@angular-eslint/builder": "13.3.0",
"@angular-eslint/eslint-plugin": "13.3.0",
"@angular-eslint/eslint-plugin-template": "13.3.0",
"@angular-eslint/schematics": "13.3.0",
"@angular-eslint/template-parser": "13.3.0",
"@angular/cli": "13.3.8",
"@angular/common": "13.3.11",
"@angular/compiler": "13.3.11",
"@angular/compiler-cli": "13.3.11",
"@angular/core": "13.3.11",
"@angular/forms": "13.3.11",
"@angular/language-service": "13.3.11",
"@angular/platform-browser": "13.3.11",
"@angular/platform-browser-dynamic": "13.3.11",
"@angular/router": "13.3.11",
"@angular/service-worker": "13.3.11",
"@commitlint/cli": "16.0.1",
"@commitlint/config-conventional": "16.0.0",
"@ngx-translate/core": "13.0.0",
"@ngx-translate/http-loader": "6.0.0",
"@playwright/test": "1.21.1",
"@semantic-release/changelog": "6.0.1",
"@semantic-release/git": "10.0.1",
"@semantic-release/npm": "9.0.0",
"@types/chai": "4.3.0",
"@types/jest": "27.4.0",
"@types/jest": "28.1.5",
"@types/mocha": "9.0.0",
"@types/node": "16.11.7",
"@types/video.js": "7.3.29",
@@ -123,20 +125,24 @@
"eslint": "7.32.0",
"eslint-plugin-import": "2.25.2",
"husky": "5.0.9",
"jest": "27.5.1",
"jest-preset-angular": "11.1.1",
"ng-mocks": "12.5.1",
"jest": "28.1.3",
"jest-preset-angular": "12.2.0",
"ng-mocks": "14.0.2",
"node-polyfill-webpack-plugin": "1.1.4",
"npm-run-all": "4.1.5",
"playwright": "1.21.1",
"playwright-core": "1.21.1",
"prettier": "2.5.1",
"semantic-release": "19.0.2",
"stylelint": "14.2.0",
"stylelint-config-standard": "24.0.0",
"stylelint-scss": "4.1.0",
"ts-jest": "28.0.6",
"ts-node": "8.10.2",
"tslib": "2.3.0",
"typescript": "4.4.4",
"wait-on": "6.0.0"
"wait-on": "6.0.0",
"xvfb-maybe": "0.2.1"
},
"engines": {
"node": ">=14.0.0"
-23
View File
@@ -19,7 +19,6 @@ import { WhatsNewService } from './services/whats-new.service';
import { WhatsNewServiceStub } from './services/whats-new.service.stub';
import { Theme } from './settings/theme.enum';
import { STORE_KEY } from './shared/enums/store-keys.enum';
import { ChannelStore } from './state';
class MatSnackBarStub {
open(): void {}
@@ -117,28 +116,6 @@ describe('AppComponent', () => {
}
));
it('should show a notification on epg error', inject(
[MatSnackBar],
(snackbar: MatSnackBar) => {
jest.spyOn(snackbar, 'open');
component.onEpgError();
expect(snackbar.open).toHaveBeenCalledTimes(1);
}
));
it('should handle epg download success', inject(
[MatSnackBar, ChannelStore],
(snackbar: MatSnackBar, channelStore: ChannelStore) => {
jest.spyOn(snackbar, 'open');
jest.spyOn(channelStore, 'setEpgAvailableFlag');
component.onEpgFetchDone();
expect(snackbar.open).toHaveBeenCalledTimes(1);
expect(channelStore.setEpgAvailableFlag).toHaveBeenCalledWith(
true
);
}
));
it('show show whats new dialog', () => {
jest.spyOn(whatsNewService, 'getModalsByVersion');
jest.spyOn(component, 'setDialogVisibility');
+5 -46
View File
@@ -1,5 +1,4 @@
import { Component, NgZone } from '@angular/core';
import { MatSnackBar, MatSnackBarConfig } from '@angular/material/snack-bar';
import { Router } from '@angular/router';
import { TranslateService } from '@ngx-translate/core';
import { ModalWindow } from 'ngx-whats-new/lib/modal-window.interface';
@@ -7,7 +6,6 @@ import * as semver from 'semver';
import { IpcCommand } from '../../shared/ipc-command.class';
import {
EPG_ERROR,
EPG_FETCH,
EPG_FETCH_DONE,
OPEN_FILE,
SHOW_WHATS_NEW,
@@ -15,13 +13,13 @@ import {
VIEW_SETTINGS,
} from '../../shared/ipc-commands';
import { DataService } from './services/data.service';
import { EpgService } from './services/epg.service';
import { SettingsService } from './services/settings.service';
import { WhatsNewService } from './services/whats-new.service';
import { Language } from './settings/language.enum';
import { Settings } from './settings/settings.interface';
import { Theme } from './settings/theme.enum';
import { STORE_KEY } from './shared/enums/store-keys.enum';
import { ChannelStore } from './state';
/**
* AppComponent
@@ -40,18 +38,12 @@ export class AppComponent {
/** Modals to show for the updated version of the application */
modals: ModalWindow[] = [];
/** Default options for epg snackbar notifications */
epgSnackBarOptions: MatSnackBarConfig = {
verticalPosition: 'bottom',
horizontalPosition: 'right',
};
/** List of ipc commands with function mapping */
commandsList = [
new IpcCommand(VIEW_ADD_PLAYLIST, () => this.navigateToRoute('/')),
new IpcCommand(VIEW_SETTINGS, () => this.navigateToRoute('/settings')),
new IpcCommand(EPG_FETCH_DONE, () => this.onEpgFetchDone()),
new IpcCommand(EPG_ERROR, () => this.onEpgError()),
new IpcCommand(EPG_FETCH_DONE, () => this.epgService.onEpgFetchDone()),
new IpcCommand(EPG_ERROR, () => this.epgService.onEpgError()),
new IpcCommand(SHOW_WHATS_NEW, () => this.showWhatsNewDialog()),
];
@@ -62,13 +54,12 @@ export class AppComponent {
* Creates an instance of AppComponent
*/
constructor(
private channelStore: ChannelStore,
private electronService: DataService,
private epgService: EpgService,
private ngZone: NgZone,
private router: Router,
private translate: TranslateService,
private settingsService: SettingsService,
private snackBar: MatSnackBar,
private whatsNewService: WhatsNewService
) {
if (
@@ -127,14 +118,7 @@ export class AppComponent {
if (settings && Object.keys(settings).length > 0) {
this.translate.use(settings.language ?? this.DEFAULT_LANG);
if (settings.epgUrl) {
this.electronService.sendIpcEvent(EPG_FETCH, {
url: settings.epgUrl,
});
this.snackBar.open(
this.translate.instant('EPG.FETCH_EPG'),
this.translate.instant('CLOSE'),
this.epgSnackBarOptions
);
this.epgService.fetchEpg(settings.epgUrl);
}
if (settings.theme) {
@@ -205,31 +189,6 @@ export class AppComponent {
this.router.navigateByUrl(route);
}
/**
* Handles the event when the EPG fetching is done
*/
onEpgFetchDone(): void {
this.channelStore.setEpgAvailableFlag(true);
this.snackBar.open(
this.translate.instant('EPG.DOWNLOAD_SUCCESS'),
null,
{
...this.epgSnackBarOptions,
duration: 2000,
}
);
}
/**
* Handles epg error
*/
onEpgError(): void {
this.snackBar.open(this.translate.instant('EPG.ERROR'), null, {
...this.epgSnackBarOptions,
duration: 2000,
});
}
/**
* Shows the "what is new" dialog
*/
@@ -1,12 +1,12 @@
import { Component, NgZone } from '@angular/core';
import { EpgChannel } from '../../models/epg-channel.model';
import { EpgProgram } from '../../models/epg-program.model';
import * as moment from 'moment';
import { EPG_GET_PROGRAM_DONE } from '../../../../../shared/ipc-commands';
import { ChannelQuery, ChannelStore } from '../../../state';
import { Observable } from 'rxjs';
import { map } from 'rxjs/operators';
import { EPG_GET_PROGRAM_DONE } from '../../../../../shared/ipc-commands';
import { DataService } from '../../../services/data.service';
import { ChannelQuery, ChannelStore } from '../../../state';
import { EpgChannel } from '../../models/epg-channel.model';
import { EpgProgram } from '../../models/epg-program.model';
export interface EpgData {
channel: EpgChannel;
@@ -76,7 +76,7 @@ export class EpgListComponent {
this.timeshiftUntil$ = this.channelQuery
.select(
(store) =>
store.active.tvg.rec ||
store.active.tvg?.rec ||
store.active.timeshift ||
store.active.catchup?.days
)
+50
View File
@@ -0,0 +1,50 @@
import { inject, TestBed } from '@angular/core/testing';
import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar';
import { TranslateService } from '@ngx-translate/core';
import { MockModule, MockProviders } from 'ng-mocks';
import { ChannelStore } from '../state';
import { DataService } from './data.service';
import { EpgService } from './epg.service';
describe('EpgService', () => {
beforeEach(() => {
TestBed.configureTestingModule({
providers: [
EpgService,
MockProviders(DataService, TranslateService, MatSnackBar),
],
imports: [MockModule(MatSnackBarModule)],
});
});
it('should create a service instance', inject(
[EpgService],
(service: EpgService) => {
expect(service).toBeTruthy();
}
));
it('should show a notification on epg error', inject(
[MatSnackBar, EpgService],
(snackbar: MatSnackBar, service: EpgService) => {
jest.spyOn(snackbar, 'open');
service.onEpgError();
expect(snackbar.open).toHaveBeenCalledTimes(1);
}
));
it('should handle epg download success', inject(
[MatSnackBar, ChannelStore, EpgService],
(
snackbar: MatSnackBar,
channelStore: ChannelStore,
service: EpgService
) => {
jest.spyOn(snackbar, 'open');
jest.spyOn(channelStore, 'setEpgAvailableFlag');
service.onEpgFetchDone();
expect(snackbar.open).toHaveBeenCalledTimes(1);
expect(channelStore.setEpgAvailableFlag).toHaveBeenCalledWith(true);
}
));
});
+69
View File
@@ -0,0 +1,69 @@
import { Injectable } from '@angular/core';
import { MatSnackBar, MatSnackBarConfig } from '@angular/material/snack-bar';
import { TranslateService } from '@ngx-translate/core';
import { EPG_FETCH } from '../../../shared/ipc-commands';
import { ChannelStore } from '../state';
import { DataService } from './data.service';
@Injectable({
providedIn: 'root',
})
export class EpgService {
/** Default options for epg snackbar notifications */
epgSnackBarOptions: MatSnackBarConfig = {
verticalPosition: 'bottom',
horizontalPosition: 'right',
};
constructor(
private channelStore: ChannelStore,
private electronService: DataService,
private snackBar: MatSnackBar,
private translate: TranslateService
) {}
/**
* Fetches and updates EPG from the given URL
* @param urls epg source urls
*/
fetchEpg(urls: string | string[]): void {
if (!Array.isArray(urls)) {
urls = [urls];
}
urls.forEach((url) =>
this.electronService.sendIpcEvent(EPG_FETCH, {
url,
})
);
this.snackBar.open(
this.translate.instant('EPG.FETCH_EPG'),
this.translate.instant('CLOSE'),
this.epgSnackBarOptions
);
}
/**
* Handles the event when the EPG fetching is done
*/
onEpgFetchDone(): void {
this.channelStore.setEpgAvailableFlag(true);
this.snackBar.open(
this.translate.instant('EPG.DOWNLOAD_SUCCESS'),
null,
{
...this.epgSnackBarOptions,
duration: 2000,
}
);
}
/**
* Handles epg error
*/
onEpgError(): void {
this.snackBar.open(this.translate.instant('EPG.ERROR'), null, {
...this.epgSnackBarOptions,
duration: 2000,
});
}
}
+187 -157
View File
@@ -1,187 +1,217 @@
<div class="main-container">
<app-header
[title]="'SETTINGS.TITLE' | translate"
[subtitle]="
('SETTINGS.SUBTITLE' | translate) +
'. ' +
('SETTINGS.DESCRIPTION' | translate)
"
></app-header>
<app-header
[title]="'SETTINGS.TITLE' | translate"
[subtitle]="
('SETTINGS.SUBTITLE' | translate) +
'. ' +
('SETTINGS.DESCRIPTION' | translate)
"
></app-header>
<div class="settings-container">
<form [formGroup]="settingsForm" (ngSubmit)="onSubmit()" novalidate>
<mat-card>
<mat-card-content>
<mat-list>
<mat-list-item *ngIf="isElectron">
<div class="settings-container">
<form [formGroup]="settingsForm" (ngSubmit)="onSubmit()" novalidate>
<mat-card>
<mat-card-content>
<mat-list>
<ng-container formArrayName="epgUrl" *ngIf="isElectron">
<mat-list-item
*ngFor="let _ of epgUrl.controls; index as i"
>
<div matLine fxLayout="row">
<p fxFlex>
{{ 'SETTINGS.EPG_URL_LABEL' | translate }}
</p>
<p fxFlex fxLayoutAlign="start center">
<p
fxFlex
fxLayoutAlign="start center"
fxLayout="row"
>
<mat-form-field class="full-width">
<input
matInput
[placeholder]="
'SETTINGS.EPG_URL_PLACEHOLDER'
| translate
"
matInput
type="url"
formControlName="epgUrl"
[formControlName]="i"
/>
<mat-error
>The value is not a valid
URL</mat-error
>
</mat-form-field>
<button
*ngIf="
(epgAvailable$ | async) &&
settingsForm.value.epgUrl
"
matLine
mat-icon-button
color="accent"
[matTooltip]="
'SETTINGS.REFRESH_EPG' | translate
"
type="button"
(click)="fetchEpg()"
(click)="fetchEpg(epgUrl.value)"
>
<mat-icon>refresh</mat-icon>
</button>
<button
mat-icon-button
matLine
color="accent"
[matTooltip]="
'SETTINGS.REFRESH_EPG' | translate
"
type="button"
(click)="removeEpgSource(i)"
>
<mat-icon>remove</mat-icon>
</button>
</p>
</div>
<p matLine></p>
</mat-list-item>
<mat-list-item>
<div matLine fxLayout="row">
<p fxFlex>
{{
'SETTINGS.VIDEO_PLAYER_LABEL'
</ng-container>
<mat-list-item *ngIf="isElectron">
<div matLine fxLayout="row">
<p fxFlex></p>
<p fxFlex fxLayoutAlign="start center">
<button
mat-button
color="accent"
type="button"
(click)="addEpgSource()"
>
Add another epg source
</button>
</p>
</div>
</mat-list-item>
<mat-list-item>
<div matLine fxLayout="row">
<p fxFlex>
{{ 'SETTINGS.VIDEO_PLAYER_LABEL' | translate }}
</p>
<p fxFlex fxLayoutAlign="start center">
<mat-form-field appearance="fill">
<mat-label>{{
'SETTINGS.VIDEO_PLAYER_PLACEHOLDER'
| translate
}}
</p>
<p fxFlex fxLayoutAlign="start center">
<mat-form-field appearance="fill">
<mat-label>{{
'SETTINGS.VIDEO_PLAYER_PLACEHOLDER'
| translate
}}</mat-label>
<mat-select formControlName="player">
<mat-option
*ngFor="let player of players"
[value]="player.id"
>{{ player.label }}
</mat-option>
</mat-select>
</mat-form-field>
</p>
</div>
</mat-list-item>
<mat-list-item>
<div matLine fxLayout="row">
<p fxFlex>
{{ 'SETTINGS.LANGUAGE' | translate }}
</p>
<p fxFlex fxLayoutAlign="start center">
<mat-form-field appearance="fill">
<mat-label>{{
'SETTINGS.VIDEO_PLAYER_PLACEHOLDER'
| translate
}}</mat-label>
<mat-select formControlName="language">
<mat-option
*ngFor="
let language of languageEnum
| keyvalue
"
[value]="language.value"
>{{
'LANGUAGES.' + language.key
| translate
}}</mat-option
>
</mat-select>
</mat-form-field>
</p>
</div>
</mat-list-item>
<mat-list-item>
<div matLine fxLayout="row">
<p fxFlex>{{ 'SETTINGS.THEME' | translate }}</p>
<p fxFlex>
<mat-form-field appearance="fill">
<mat-label>{{
'SETTINGS.VIDEO_PLAYER_PLACEHOLDER'
| translate
}}</mat-label>
<mat-select formControlName="theme">
<mat-option
*ngFor="
let theme of themeEnum
| keyvalue
"
[value]="theme.value"
>{{
'THEMES.' + theme.value
| translate
}}</mat-option
>
</mat-select>
</mat-form-field>
</p>
</div>
</mat-list-item>
<mat-list-item>
<div matLine fxLayout="row">
<p fxFlex>
{{ 'SETTINGS.SHOW_CAPTIONS' | translate }}
</p>
<p fxFlex>
<mat-checkbox
formControlName="showCaptions"
></mat-checkbox>
</p>
</div>
</mat-list-item>
<mat-list-item>
<div matLine fxLayout="row">
<p fxFlex>
{{ 'SETTINGS.VERSION' | translate }}
</p>
<p fxFlex>
{{ version }} <br />
{{ updateMessage }}
</p>
</div>
</mat-list-item>
<mat-list-item *ngIf="!isElectron"
>EPG is available in electron-based version of
IPTVnator. Check it&nbsp;<a
href="https://github.com/4gray/iptvnator/releases"
>here</a
></mat-list-item
>
</mat-list>
</mat-card-content>
<mat-divider></mat-divider>
<mat-card-actions align="end">
<button
mat-button
color="accent"
type="reset"
(click)="backToHome()"
}}</mat-label>
<mat-select formControlName="player">
<mat-option
*ngFor="let player of players"
[value]="player.id"
>{{ player.label }}
</mat-option>
</mat-select>
</mat-form-field>
</p>
</div>
</mat-list-item>
<mat-list-item>
<div matLine fxLayout="row">
<p fxFlex>
{{ 'SETTINGS.LANGUAGE' | translate }}
</p>
<p fxFlex fxLayoutAlign="start center">
<mat-form-field appearance="fill">
<mat-label>{{
'SETTINGS.VIDEO_PLAYER_PLACEHOLDER'
| translate
}}</mat-label>
<mat-select formControlName="language">
<mat-option
*ngFor="
let language of languageEnum
| keyvalue
"
[value]="language.value"
>{{
'LANGUAGES.' + language.key
| translate
}}</mat-option
>
</mat-select>
</mat-form-field>
</p>
</div>
</mat-list-item>
<mat-list-item>
<div matLine fxLayout="row">
<p fxFlex>{{ 'SETTINGS.THEME' | translate }}</p>
<p fxFlex>
<mat-form-field appearance="fill">
<mat-label>{{
'SETTINGS.VIDEO_PLAYER_PLACEHOLDER'
| translate
}}</mat-label>
<mat-select formControlName="theme">
<mat-option
*ngFor="
let theme of themeEnum
| keyvalue
"
[value]="theme.value"
>{{
'THEMES.' + theme.value
| translate
}}</mat-option
>
</mat-select>
</mat-form-field>
</p>
</div>
</mat-list-item>
<mat-list-item>
<div matLine fxLayout="row">
<p fxFlex>
{{ 'SETTINGS.SHOW_CAPTIONS' | translate }}
</p>
<p fxFlex>
<mat-checkbox
formControlName="showCaptions"
></mat-checkbox>
</p>
</div>
</mat-list-item>
<mat-list-item>
<div matLine fxLayout="row">
<p fxFlex>
{{ 'SETTINGS.VERSION' | translate }}
</p>
<p fxFlex>
{{ version }} <br />
{{ updateMessage }}
</p>
</div>
</mat-list-item>
<mat-list-item *ngIf="!isElectron"
>EPG is available in electron-based version of
IPTVnator. Check it&nbsp;<a
href="https://github.com/4gray/iptvnator/releases"
>here</a
></mat-list-item
>
<mat-icon>chevron_left</mat-icon>
{{ 'SETTINGS.BACK_TO_HOME' | translate }}
</button>
<button
mat-button
color="accent"
type="submit"
[disabled]="settingsForm.pristine"
>
<mat-icon>save</mat-icon>
{{ 'SETTINGS.SAVE_CHANGES' | translate }}
</button>
</mat-card-actions>
</mat-card>
</form>
</div>
</mat-list>
</mat-card-content>
<mat-divider></mat-divider>
<mat-card-actions align="end">
<button
mat-button
color="accent"
type="reset"
(click)="backToHome()"
>
<mat-icon>chevron_left</mat-icon>
{{ 'SETTINGS.BACK_TO_HOME' | translate }}
</button>
<button
mat-button
color="accent"
type="submit"
[disabled]="settingsForm.pristine || !settingsForm.valid"
>
<mat-icon>save</mat-icon>
{{ 'SETTINGS.SAVE_CHANGES' | translate }}
</button>
</mat-card-actions>
</mat-card>
</form>
</div>
-6
View File
@@ -16,12 +16,6 @@ button {
height: 70px !important;
}
.main-container {
display: grid;
grid-template-rows: 105px auto;
height: calc(100vh - 105px);
}
.settings-container {
overflow: auto;
height: calc(100vh - 140px);
+3 -3
View File
@@ -39,7 +39,7 @@ export class MockRouter {
const DEFAULT_SETTINGS = {
player: VideoPlayer.VideoJs,
epgUrl: '',
epgUrl: [],
language: Language.ENGLISH,
showCaptions: false,
theme: Theme.LightTheme,
@@ -108,7 +108,7 @@ describe('SettingsComponent', () => {
});
describe('Get and set settings on component init', () => {
const settings = { player: 'test', showCaptions: true };
const settings = { player: 'test', showCaptions: true, epgUrl: [] };
let spyOnStorageGet;
beforeEach(() => {
@@ -168,7 +168,7 @@ describe('SettingsComponent', () => {
it('should send epg fetch command', () => {
jest.spyOn(electronService, 'sendIpcEvent');
component.fetchEpg();
component.fetchEpg(['']);
expect(electronService.sendIpcEvent).toHaveBeenCalledWith(EPG_FETCH, {
url: '',
});
+65 -33
View File
@@ -1,6 +1,11 @@
import { HttpClient } from '@angular/common/http';
import { Component, OnInit } from '@angular/core';
import { FormBuilder, FormGroup } from '@angular/forms';
import {
FormArray,
FormBuilder,
FormControl,
Validators,
} from '@angular/forms';
import { MatSnackBar } from '@angular/material/snack-bar';
import { Router } from '@angular/router';
import { StorageMap } from '@ngx-pwa/local-storage';
@@ -8,8 +13,8 @@ import { TranslateService } from '@ngx-translate/core';
import { Observable } from 'rxjs';
import { catchError } from 'rxjs/operators';
import * as semver from 'semver';
import { EPG_FETCH } from '../../../shared/ipc-commands';
import { DataService } from '../services/data.service';
import { EpgService } from '../services/epg.service';
import { STORE_KEY } from '../shared/enums/store-keys.enum';
import { ChannelQuery } from '../state';
import { SettingsService } from './../services/settings.service';
@@ -43,7 +48,13 @@ export class SettingsComponent implements OnInit {
];
/** Settings form object */
settingsForm: FormGroup;
settingsForm = this.formBuilder.group({
player: [VideoPlayer.VideoJs],
epgUrl: new FormArray([]),
language: Language.ENGLISH,
showCaptions: false,
theme: Theme.LightTheme,
});
/** Current version of the app */
version: string;
@@ -62,22 +73,17 @@ export class SettingsComponent implements OnInit {
/** All available visual themes */
themeEnum = Theme;
/** Form array with epg sources */
epgUrl = this.settingsForm.get('epgUrl') as FormArray;
/**
* Creates an instance of SettingsComponent and injects
* required dependencies into the component
* @param channelQuery
* @param electronService
* @param formBuilder
* @param http
* @param router
* @param settingsService
* @param snackBar
* @param storage
* @param translate
*/
constructor(
private channelQuery: ChannelQuery,
private electronService: DataService,
private epgService: EpgService,
private formBuilder: FormBuilder,
private http: HttpClient,
private router: Router,
@@ -85,17 +91,7 @@ export class SettingsComponent implements OnInit {
private snackBar: MatSnackBar,
private storage: StorageMap,
private translate: TranslateService
) {
this.settingsForm = this.formBuilder.group({
player: [VideoPlayer.VideoJs],
epgUrl: '',
language: Language.ENGLISH,
showCaptions: false,
theme: Theme.LightTheme,
});
this.checkAppVersion();
}
) {}
/**
* Reads the config object from the browsers
@@ -110,7 +106,7 @@ export class SettingsComponent implements OnInit {
player: settings.player
? settings.player
: VideoPlayer.VideoJs,
epgUrl: settings.epgUrl ? settings.epgUrl : '',
epgUrl: [],
language: settings.language
? settings.language
: Language.ENGLISH,
@@ -121,8 +117,33 @@ export class SettingsComponent implements OnInit {
? settings.theme
: Theme.LightTheme,
});
this.setEpgUrls(settings.epgUrl);
}
});
this.checkAppVersion();
}
/**
* Sets the epg urls to the form array
* @param epgUrls urls of the EPG sources
*/
setEpgUrls(epgUrls: string[] | string): void {
const URL_REGEX = /^(http|https):\/\/[^ "]+$/;
if (!Array.isArray(epgUrls)) {
epgUrls = [epgUrls];
}
for (const url of epgUrls) {
this.epgUrl.push(
new FormControl(url, [
Validators.required,
Validators.pattern(URL_REGEX),
])
);
}
}
/**
@@ -187,7 +208,7 @@ export class SettingsComponent implements OnInit {
this.settingsForm.markAsPristine();
// check whether the epg url was changed or not
if (this.settingsForm.value.epgUrl) {
this.fetchEpg();
this.fetchEpg(this.settingsForm.value.epgUrl);
}
this.translate.use(this.settingsForm.value.language);
this.settingsService.changeTheme(this.settingsForm.value.theme);
@@ -210,14 +231,25 @@ export class SettingsComponent implements OnInit {
/**
* Fetches and updates EPG from the given URL
* @param urls epg source urls
*/
fetchEpg(): void {
this.electronService.sendIpcEvent(EPG_FETCH, {
url: this.settingsForm.value.epgUrl,
});
this.snackBar.open(this.translate.instant('EPG.FETCH_EPG'), 'Close', {
verticalPosition: 'bottom',
horizontalPosition: 'right',
});
fetchEpg(urls: string | string[]): void {
this.epgService.fetchEpg(urls);
}
/**
* Initializes new entry in form array for EPG URL
*/
addEpgSource(): void {
this.epgUrl.insert(this.epgUrl.length, new FormControl(''));
}
/**
* Removes entry from form array for EPG URL
* @param index index of the item to remove
*/
removeEpgSource(index: number): void {
this.epgUrl.removeAt(index);
this.settingsForm.markAsDirty();
}
}
+1 -1
View File
@@ -15,7 +15,7 @@ export enum VideoPlayer {
*/
export interface Settings {
player: VideoPlayer;
epgUrl: string;
epgUrl: string[];
language: Language;
showCaptions: boolean;
theme: Theme;
+2 -2
View File
@@ -4,10 +4,10 @@
"outDir": "../out-tsc/app",
"module": "es2020",
"baseUrl": "",
"types": []
"types": ["node"]
},
"include": ["**/*.ts"],
"exclude": ["**/*.spec.ts", "**/*.stub.ts"],
"exclude": ["**/*.spec.ts", "**/*.stub.ts", "**/*.worker.ts"],
"angularCompilerOptions": {
"fullTemplateTypeCheck": true,
"strictInjectionParameters": true,
+2 -1
View File
@@ -12,7 +12,8 @@
"typeRoots": ["node_modules/@types"],
"lib": ["es2017", "es2016", "es2015", "dom"],
"resolveJsonModule": true,
"strict": false
"strict": false,
"skipLibCheck": true
},
"files": ["src/main.ts", "src/polyfills.ts"],
"include": ["src/**/*.d.ts"],
+17 -28
View File
@@ -1,31 +1,20 @@
{
"compilerOptions": {
"sourceMap": true,
"declaration": false,
"moduleResolution": "node",
"emitDecoratorMetadata": true,
"experimentalDecorators": true,
"target": "es5",
"types": [
"node"
"compilerOptions": {
"sourceMap": true,
"declaration": false,
"moduleResolution": "node",
"emitDecoratorMetadata": true,
"experimentalDecorators": true,
"target": "es5",
"types": ["node"],
"lib": ["es2017", "es2016", "es2015", "dom"]
},
"files": [
"electron/api.ts",
"electron/epg-worker.ts",
"electron/main.ts",
"electron/menu.ts",
"electron/preload.ts"
],
"lib": [
"es2017",
"es2016",
"es2015",
"dom"
]
},
"files": [
"api.ts",
"epg-worker.ts",
"main.ts",
"menu.ts",
"preload.ts"
],
"exclude": [
"node_modules",
"**/*.spec.ts",
"**/*.stub.ts"
]
"exclude": ["node_modules", "**/*.spec.ts", "**/*.stub.ts"]
}
+1 -1
View File
@@ -3,7 +3,7 @@
"compilerOptions": {
"outDir": "../out-tsc/spec",
"baseUrl": "./",
"types": ["jest", "node"],
"types": ["jest", "node", "electron"],
"experimentalDecorators": true,
"emitDecoratorMetadata": true,
"resolveJsonModule": true
+10
View File
@@ -0,0 +1,10 @@
/* To learn more about this file see: https://angular.io/config/tsconfig. */
{
"extends": "./tsconfig.json",
"compilerOptions": {
"outDir": "./out-tsc/worker",
"lib": ["es2018", "webworker", "dom"],
"types": []
},
"include": ["src/**/*.worker.ts"]
}