Merge pull request #107 from 4gray/pwa

PWA support and dependency update
This commit is contained in:
4gray authored and GitHub committed 2022-01-05 17:51:18 +01:00
commit 444fd2aae6
66 files changed
+12046 -17427

No files matched your search

+2 -2
View File
@@ -9,8 +9,8 @@ FROM mcr.microsoft.com/vscode/devcontainers/typescript-node:0-${VARIANT}
# && apt-get -y install --no-install-recommends <your-package-list-here>
# [Optional] Uncomment if you want to install an additional version of node using nvm
# ARG EXTRA_NODE_VERSION=10
# RUN su node -c "source /usr/local/share/nvm/nvm.sh && nvm install ${EXTRA_NODE_VERSION}"
ARG EXTRA_NODE_VERSION=16.10
RUN su node -c "source /usr/local/share/nvm/nvm.sh && nvm install ${EXTRA_NODE_VERSION}"
# [Optional] Uncomment if you want to install more global node packages
# RUN su node -c "npm install -g <your-package-list -here>"
+51 -51
View File
@@ -1,62 +1,62 @@
name: Build and release
on:
push:
branches:
- electron
pull_request:
branches:
- electron
push:
branches:
- electron
pull_request:
branches:
- electron
jobs:
build-and-test:
runs-on: ubuntu-latest
build-and-test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
with:
persist-credentials: false
- name: Use Node.js
uses: actions/setup-node@v1
with:
node-version: "16.x"
- run: git config --global url."https://${{ secrets.PAT }}@github.com/".insteadOf ssh://git@github.com/
- run: npm ci
- run: npm test -- --coverage
- run: bash <(curl -s https://codecov.io/bash)
- run: xvfb-run --auto-servernum npm run e2e
- run: npm run build:prod
env:
CI: true
release:
needs: [build-and-test]
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v2
with:
persist-credentials: false
- name: Use Node.js
uses: actions/setup-node@v1
with:
node-version: '16.x'
- run: git config --global url."https://${{ secrets.PAT }}@github.com/".insteadOf ssh://git@github.com/
- run: npm ci
- run: npm test -- --coverage
- run: bash <(curl -s https://codecov.io/bash)
- run: xvfb-run --auto-servernum npm run e2e
- run: npm run build:prod
env:
CI: true
release:
needs: [build-and-test]
runs-on: ${{ matrix.os }}
strategy:
matrix:
os: [macos-latest, ubuntu-latest, windows-latest]
strategy:
matrix:
os: [macos-latest, ubuntu-latest, windows-latest]
steps:
- name: Check out Git repository
uses: actions/checkout@v1
steps:
- name: Check out Git repository
uses: actions/checkout@v1
- name: Install Node.js, NPM and Yarn
uses: actions/setup-node@v1
with:
node-version: "16.x"
- name: Install Node.js, NPM and Yarn
uses: actions/setup-node@v1
with:
node-version: '16.x'
- name: Build Electron app
uses: samuelmeuli/action-electron-builder@v1
with:
# GitHub token, automatically provided to the action
# (No need to define this secret in the repo settings)
github_token: ${{ secrets.github_token }}
- name: Build Electron app
uses: samuelmeuli/action-electron-builder@v1
with:
# GitHub token, automatically provided to the action
# (No need to define this secret in the repo settings)
github_token: ${{ secrets.github_token }}
# If the commit is tagged with a version (e.g. "v1.0.0"),
# release the app after building
release: ${{ startsWith(github.ref, 'refs/tags/v') }}
- name: Archive production artifacts
uses: actions/upload-artifact@v2
with:
name: dev-release
path: ./release/iptvnator*
# If the commit is tagged with a version (e.g. "v1.0.0"),
# release the app after building
release: ${{ startsWith(github.ref, 'refs/tags/v') }}
- name: Archive production artifacts
uses: actions/upload-artifact@v2
with:
name: dev-release
path: ./release/iptvnator*
+2
View File
@@ -16,6 +16,7 @@ src/**/*.js
!src/karma.conf.js
*.js.map
data.db
preload.js
# dependencies
/node_modules
@@ -45,6 +46,7 @@ npm-debug.log
testem.log
/typings
package-lock.json
.angular/*
# e2e
/e2e/*.js
+4 -1
View File
@@ -24,7 +24,10 @@
"ignoreAtRules": ["function", "if", "each", "include", "mixin"]
}
],
"value-keyword-case": null
"value-keyword-case": null,
"string-quotes": "single",
"value-no-vendor-prefix": null,
"property-no-vendor-prefix": null
},
"plugins": ["stylelint-scss"],
"syntax": "scss"
+67 -27
View File
@@ -1,37 +1,49 @@
{
"$schema": "./node_modules/@angular/cli/lib/config/schema.json",
"cli": {
"defaultCollection": "@angular-eslint/schematics"
},
"version": 1,
"newProjectRoot": "projects",
"projects": {
"angular-electron": {
"iptvnator": {
"root": "",
"sourceRoot": "src",
"projectType": "application",
"schematics": {
"@schematics/angular:application": {
"strict": true
}
},
"architect": {
"build": {
"builder": "@angular-builders/custom-webpack:browser",
"options": {
"outputPath": "dist",
"deleteOutputPath": false,
"index": "src/index.html",
"main": "src/main.ts",
"tsConfig": "src/tsconfig.app.json",
"polyfills": "src/polyfills.ts",
"assets": [
"src/assets"
"src/favicon.ico",
"src/assets",
"src/manifest.webmanifest"
],
"styles": [
"src/styles.scss"
],
"scripts": [],
"customWebpackConfig": {
"path": "./angular.webpack.js"
}
"path": "./angular.webpack.js",
"replaceDuplicatePlugins": true
},
"serviceWorker": true,
"ngswConfigPath": "ngsw-config.json"
},
"configurations": {
"dev": {
"optimization": false,
"outputHashing": "all",
"outputHashing": "none",
"sourceMap": true,
"namedChunks": false,
"aot": false,
@@ -45,6 +57,22 @@
}
]
},
"web": {
"optimization": false,
"outputHashing": "none",
"sourceMap": true,
"namedChunks": false,
"aot": false,
"extractLicenses": true,
"vendorChunk": false,
"buildOptimizer": false,
"fileReplacements": [
{
"replace": "src/environments/environment.ts",
"with": "src/environments/environment.web.ts"
}
]
},
"production": {
"optimization": true,
"outputHashing": "all",
@@ -66,61 +94,73 @@
"serve": {
"builder": "@angular-builders/custom-webpack:dev-server",
"options": {
"browserTarget": "angular-electron:build"
"browserTarget": "iptvnator:build"
},
"configurations": {
"dev": {
"browserTarget": "angular-electron:build:dev"
"browserTarget": "iptvnator:build:dev"
},
"web": {
"browserTarget": "angular-electron:build:web"
"browserTarget": "iptvnator:build:web"
},
"production": {
"browserTarget": "angular-electron:build:production"
"browserTarget": "iptvnator:build:production"
}
}
},
"extract-i18n": {
"builder": "@angular-devkit/build-angular:extract-i18n",
"options": {
"browserTarget": "angular-electron:build"
"browserTarget": "iptvnator:build"
}
},
"test": {
"builder": "@angular-builders/custom-webpack:karma",
"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
}
}
},
"lint": {
"builder": "@angular-eslint/builder:lint",
"options": {
"eslintConfig": ".eslintrc.json",
"tsConfig": [
"src/tsconfig.app.json",
"src/tsconfig.spec.json"
],
"exclude": [
"**/node_modules/**"
"lintFilePatterns": [
"src/**/*.ts",
"src/**/*.html"
]
}
}
}
},
"angular-electron-e2e": {
"iptvnator-e2e": {
"root": "e2e",
"projectType": "application",
"architect": {
"lint": {
"builder": "@angular-eslint/builder:lint",
"options": {
"eslintConfig": ".eslintrc.json",
"tsConfig": [
"e2e/tsconfig.e2e.json"
],
"exclude": [
"**/node_modules/**"
"lintFilePatterns": [
"e2e/**/*.ts"
]
}
}
}
}
},
"defaultProject": "angular-electron",
"defaultProject": "iptvnator",
"schematics": {
"@schematics/angular:component": {
"prefix": "app",
@@ -130,4 +170,4 @@
"prefix": "app"
}
}
}
}
+19 -8
View File
@@ -1,24 +1,35 @@
//Polyfill Node.js core modules in Webpack. This module is only needed for webpack 5+.
const NodePolyfillPlugin = require('node-polyfill-webpack-plugin');
/**
* Custom angular webpack configuration
*/
module.exports = (config, options) => {
config.target = 'electron-renderer';
if (options.customWebpackConfig.target) {
config.target = options.customWebpackConfig.target;
} else if (options.fileReplacements) {
for(let fileReplacement of options.fileReplacements) {
if (options.fileReplacements) {
for (let fileReplacement of options.fileReplacements) {
if (fileReplacement.replace !== 'src/environments/environment.ts') {
continue;
}
let fileReplacementParts = fileReplacement['with'].split('.');
if (['dev', 'prod', 'test', 'electron-renderer'].indexOf(fileReplacementParts[1]) < 0) {
config.target = fileReplacementParts[1];
if (
fileReplacementParts.length > 1 &&
['web'].indexOf(fileReplacementParts[1]) >= 0
) {
config.target = 'web';
}
break;
}
}
config.plugins = [
...config.plugins,
new NodePolyfillPlugin({
excludeAliases: ['console'],
}),
];
return config;
}
};
+17 -9
View File
@@ -19,6 +19,14 @@ import {
PLAYLIST_UPDATE,
PLAYLIST_UPDATE_RESPONSE,
PLAYLIST_UPDATE_POSITIONS,
PLAYLIST_GET_ALL_RESPONSE,
PLAYLIST_REMOVE_BY_ID_RESPONSE,
PLAYLIST_PARSE_BY_URL,
PLAYLIST_GET_ALL,
PLAYLIST_REMOVE_BY_ID,
PLAYLIST_GET_BY_ID,
OPEN_FILE,
PLAYLIST_UPDATE_FAVORITES,
} from './shared/ipc-commands';
const fs = require('fs');
@@ -42,7 +50,7 @@ export class Api {
workerWindow: BrowserWindow;
constructor() {
ipcMain.on('parse-playlist-by-url', (event, args) => {
ipcMain.on(PLAYLIST_PARSE_BY_URL, (event, args) => {
try {
axios.get(args.url).then((result) => {
const parsedPlaylist = this.convertFileStringToPlaylist(
@@ -81,9 +89,9 @@ export class Api {
});
});
ipcMain.on('playlists-all', (event) => this.sendAllPlaylists(event));
ipcMain.on(PLAYLIST_GET_ALL, (event) => this.sendAllPlaylists(event));
ipcMain.on('playlist-by-id', (event, args) => {
ipcMain.on(PLAYLIST_GET_BY_ID, (event, args) => {
db.findOne({ _id: args.id }).then((playlist) => {
this.setUserAgent(playlist.userAgent);
event.sender.send(PLAYLIST_PARSE_RESPONSE, {
@@ -92,10 +100,10 @@ export class Api {
});
});
ipcMain.on('playlist-remove-by-id', (event, args) => {
ipcMain.on(PLAYLIST_REMOVE_BY_ID, (event, args) => {
db.remove({ _id: args.id }).then((removed) => {
if (removed) {
event.sender.send('playlist-remove-by-id-result', {
event.sender.send(PLAYLIST_REMOVE_BY_ID_RESPONSE, {
message: 'playlist was removed',
});
}
@@ -103,7 +111,7 @@ export class Api {
});
// open playlist from file system
ipcMain.on('open-file', (event, args) => {
ipcMain.on(OPEN_FILE, (event, args) => {
fs.readFile(
args.filePath,
'utf-8',
@@ -131,7 +139,7 @@ export class Api {
);
});
ipcMain.on('update-favorites', (event, args) => {
ipcMain.on(PLAYLIST_UPDATE_FAVORITES, (event, args) => {
db.update(
{ id: args.id },
{ $set: { favorites: args.favorites } }
@@ -248,7 +256,7 @@ export class Api {
*/
sendAllPlaylists(event: Electron.IpcMainEvent): void {
this.getAllPlaylistsMeta().then((playlists) => {
event.sender.send('playlist-all-result', {
event.sender.send(PLAYLIST_GET_ALL_RESPONSE, {
payload: playlists,
});
});
@@ -318,7 +326,7 @@ export class Api {
}
/**
* Saves playlist to the localStorage
* Creates a playlist object
* @param name name of the playlist
* @param playlist playlist to save
* @param urlOrPath absolute fs path or url of the playlist
+1 -1
View File
@@ -3,7 +3,7 @@ import { SpectronClient } from 'spectron';
import commonSetup from './common-setup';
describe('angular-electron App', function () {
describe('iptvnator App', function () {
commonSetup.apply(this);
let client: SpectronClient;
+14
View File
@@ -0,0 +1,14 @@
module.exports = {
"preset": "jest-preset-angular",
"resetMocks": true,
"setupFilesAfterEnv": [
"<rootDir>/src/setup-jest.ts"
],
"testMatch": [
"**/+(*.)+(spec|test).+(ts)?(x)"
],
"coverageReporters": [
"html",
"lcov"
]
};
+1
View File
@@ -17,6 +17,7 @@ function createWindow(): BrowserWindow {
width: 1000,
height: 800,
webPreferences: {
preload: path.join(__dirname, 'preload.js'),
nodeIntegration: true,
allowRunningInsecureContent: serve ? true : false,
contextIsolation: false,
+5 -1
View File
@@ -1,6 +1,10 @@
import { app, BrowserWindow, Menu, MenuItem, shell } from 'electron';
import * as path from 'path';
import { SHOW_WHATS_NEW, VIEW_ADD_PLAYLIST, VIEW_SETTINGS } from './shared/ipc-commands';
import {
SHOW_WHATS_NEW,
VIEW_ADD_PLAYLIST,
VIEW_SETTINGS,
} from './shared/ipc-commands';
const openAboutWindow = require('about-window').default;
export class AppMenu {
+30
View File
@@ -0,0 +1,30 @@
{
"$schema": "./node_modules/@angular/service-worker/config/schema.json",
"index": "/index.html",
"assetGroups": [
{
"name": "app",
"installMode": "prefetch",
"resources": {
"files": [
"/favicon.ico",
"/index.html",
"/manifest.webmanifest",
"/*.css",
"/*.js"
]
}
},
{
"name": "assets",
"installMode": "lazy",
"updateMode": "prefetch",
"resources": {
"files": [
"/assets/**",
"/*.(svg|cur|jpg|jpeg|png|apng|webp|avif|gif|otf|ttf|woff|woff2)"
]
}
}
]
}
+10945 -16903
View File
File diff suppressed because it is too large. Load diff
+82 -89
View File
@@ -27,6 +27,7 @@
"build": "npm run electron:serve-tsc && ng build --base-href ./",
"build:dev": "npm run build -- -c dev",
"build:prod": "npm run build -- -c production",
"ng:build": "ng build -c production",
"ng:serve": "ng serve -c dev",
"ng:serve:web": "ng serve -c web -o",
"electron:serve-tsc": "tsc -p tsconfig.serve.json",
@@ -41,84 +42,18 @@
"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",
"version": "conventional-changelog -i CHANGELOG.md -p angular -s -r 0 && git add CHANGELOG.md",
"lint": "ng lint"
},
"devDependencies": {
"@angular-builders/custom-webpack": "12.1.0",
"@angular-devkit/build-angular": "12.1.0",
"@angular-eslint/builder": "12.5.0",
"@angular-eslint/eslint-plugin": "12.5.0",
"@angular-eslint/eslint-plugin-template": "12.5.0",
"@angular-eslint/template-parser": "12.5.0",
"@angular/cli": "12.1.0",
"@angular/common": "12.1.0",
"@angular/compiler": "12.1.0",
"@angular/compiler-cli": "12.1.0",
"@angular/core": "12.1.0",
"@angular/forms": "12.1.0",
"@angular/language-service": "12.1.0",
"@angular/platform-browser": "12.1.0",
"@angular/platform-browser-dynamic": "12.1.0",
"@angular/router": "12.1.0",
"@commitlint/cli": "12.1.4",
"@commitlint/config-conventional": "12.1.4",
"@ngx-translate/core": "13.0.0",
"@ngx-translate/http-loader": "6.0.0",
"@semantic-release/changelog": "5.0.1",
"@semantic-release/git": "9.0.0",
"@semantic-release/npm": "7.1.1",
"@types/jasmine": "~3.6.0",
"@types/jasminewd2": "2.0.8",
"@types/jest": "26.0.14",
"@types/mocha": "7.0.2",
"@types/node": "12.12.6",
"@typescript-eslint/eslint-plugin": "5.1.0",
"@typescript-eslint/eslint-plugin-tslint": "5.1.0",
"@typescript-eslint/parser": "5.1.0",
"chai": "4.2.0",
"conventional-changelog-cli": "2.0.34",
"core-js": "3.6.5",
"cross-env": "7.0.3",
"electron": "11.5.0",
"electron-builder": "22.13.1",
"eslint": "7.32.0",
"eslint-plugin-import": "2.25.2",
"husky": "5.0.9",
"jasmine-core": "~3.6.0",
"jasmine-spec-reporter": "~5.0.0",
"jest": "26.4.2",
"jest-preset-angular": "8.3.1",
"mocha": "9.1.3",
"ng-mocks": "12.5.0",
"npm-run-all": "4.1.5",
"prettier": "2.3.0",
"rxjs": "6.6.3",
"semantic-release": "17.2.3",
"spectron": "13.0.0",
"stylelint": "13.13.1",
"stylelint-config-standard": "22.0.0",
"stylelint-scss": "3.19.0",
"ts-node": "8.10.2",
"tslib": "2.3.0",
"typescript": "4.3.4",
"video.js": "7.15.4",
"wait-on": "5.2.1",
"webdriver-manager": "12.1.8",
"zone.js": "~0.11.4"
},
"engines": {
"node": ">=10.13.0"
"lint": "ng lint",
"build-pwa": "ng build -c production --base-href /iptvnator --deploy-url /iptvnator/"
},
"dependencies": {
"@angular/animations": "12.1.0",
"@angular/cdk": "12.1.0",
"@angular/flex-layout": "12.0.0-beta.34",
"@angular/material": "12.1.0",
"@angular/animations": "13.0.2",
"@angular/cdk": "13.0.2",
"@angular/flex-layout": "13.0.0-beta.36",
"@angular/material": "13.0.2",
"@datorama/akita": "5.2.6",
"@ngx-pwa/local-storage": "12.1.0",
"@types/video.js": "7.3.27",
"@types/videojs-hls-quality-selector": "1.1.0",
"@videojs/http-streaming": "2.11.1",
"@electron/remote": "2.0.1",
"@ngx-pwa/local-storage": "13.0.2",
"@videojs/http-streaming": "2.13.1",
"@yangkghjh/videojs-aspect-ratio-panel": "0.0.1",
"about-window": "1.13.4",
"axios": "0.21.2",
@@ -132,24 +67,82 @@
"nedb": "1.8.0",
"nedb-promises-ts": "0.0.1-4",
"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",
"semver": "7.3.5",
"uuid": "8.3.2",
"video.js": "7.17.0",
"videojs-contrib-quality-levels": "2.1.0",
"videojs-hls-quality-selector": "1.1.4"
"videojs-hls-quality-selector": "1.1.4",
"zone.js": "~0.11.4"
},
"jest": {
"preset": "jest-preset-angular",
"setupFilesAfterEnv": [
"<rootDir>/src/setupJest.ts"
],
"testMatch": [
"**/+(*.)+(spec|test).+(ts)?(x)"
],
"coverageReporters": [
"html",
"lcov"
]
}
"devDependencies": {
"@angular-builders/custom-webpack": "13.0.0",
"@angular-devkit/build-angular": "13.0.4",
"@angular-eslint/builder": "13.0.1",
"@angular-eslint/eslint-plugin": "13.0.1",
"@angular-eslint/eslint-plugin-template": "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",
"@commitlint/cli": "16.0.1",
"@commitlint/config-conventional": "16.0.0",
"@ngx-translate/core": "13.0.0",
"@ngx-translate/http-loader": "6.0.0",
"@semantic-release/changelog": "6.0.1",
"@semantic-release/git": "10.0.1",
"@semantic-release/npm": "8.0.3",
"@types/chai": "4.3.0",
"@types/jest": "27.0.3",
"@types/mocha": "9.0.0",
"@types/node": "16.11.7",
"@types/video.js": "7.3.29",
"@types/videojs-hls-quality-selector": "1.1.0",
"@typescript-eslint/eslint-plugin": "5.1.0",
"@typescript-eslint/eslint-plugin-tslint": "5.1.0",
"@typescript-eslint/parser": "5.1.0",
"chai": "4.3.4",
"conventional-changelog-cli": "2.2.2",
"core-js": "3.6.5",
"cross-env": "7.0.3",
"electron": "11.0.0",
"electron-builder": "22.13.1",
"eslint": "7.32.0",
"eslint-plugin-import": "2.25.2",
"husky": "5.0.9",
"jest": "27.4.5",
"jest-preset-angular": "11.0.1",
"mocha": "9.1.3",
"ng-mocks": "12.5.1",
"node-polyfill-webpack-plugin": "1.1.4",
"npm-run-all": "4.1.5",
"prettier": "2.5.1",
"semantic-release": "18.0.1",
"spectron": "13.0.0",
"stylelint": "14.2.0",
"stylelint-config-standard": "24.0.0",
"stylelint-scss": "4.1.0",
"ts-node": "8.10.2",
"tslib": "2.3.0",
"typescript": "4.4.4",
"wait-on": "6.0.0",
"webdriver-manager": "12.1.8"
},
"engines": {
"node": ">=10.13.0"
},
"browserslist": [
"chrome 91"
]
}
+9
View File
@@ -0,0 +1,9 @@
import { Color, Titlebar } from 'custom-electron-titlebar';
window.addEventListener('DOMContentLoaded', () => {
new Titlebar({
backgroundColor: Color.fromHex('#000'),
itemBackgroundColor: Color.fromHex('#222'),
enableMnemonics: true,
});
});
+11 -3
View File
@@ -6,20 +6,28 @@ export const EPG_GET_PROGRAM = 'EPG:GET_PROGRAM';
export const EPG_GET_PROGRAM_DONE = 'EPG:GET_PROGRAM_DONE';
// Playlist related commands
export const PLAYLIST_GET_ALL = 'PLAYLIST:GET_ALL';
export const PLAYLIST_GET_ALL_RESPONSE = 'PLAYLIST:GET_ALL_RESPONSE';
export const PLAYLIST_GET_BY_ID = 'PLAYLIST:GET_BY_ID';
export const PLAYLIST_SAVE_DETAILS = 'PLAYLIST:SAVE_DETAILS';
export const PLAYLIST_PARSE = 'PLAYLIST:PARSE_PLAYLIST';
export const PLAYLIST_PARSE_BY_URL = 'PLAYLIST:PARSE_PLAYLIST_BY_URL';
export const PLAYLIST_PARSE_RESPONSE = 'PLAYLIST:PARSE_PLAYLIST_RESPONSE';
export const PLAYLIST_UPDATE = 'PLAYLIST:UPDATE';
export const PLAYLIST_UPDATE_RESPONSE = 'PLAYLIST:UPDATE_RESPONSE';
export const PLAYLIST_UPDATE_POSITIONS = 'PLAYLIST:UPDATE_POSITIONS';
export const PLAYLIST_REMOVE_BY_ID = 'PLAYLIST:REMOVE_BY_ID';
export const PLAYLIST_REMOVE_BY_ID_RESPONSE = 'PLAYLIST:REMOVE_BY_ID_RESPONSE';
export const PLAYLIST_UPDATE_FAVORITES = 'PLAYLIST:UPDATE_FAVORITES';
// General
export const SHOW_WHATS_NEW = 'SHOW_WHATS_NEW';
export const ERROR = 'error';
export const ERROR = 'ERROR';
export const OPEN_FILE = 'OPEN_FILE';
// Channels
export const CHANNEL_SET_USER_AGENT = 'CHANNEL:SET_USER_AGENT';
// Views
export const VIEW_SETTINGS = 'settings-view';
export const VIEW_ADD_PLAYLIST = 'add-playlist-view';
export const VIEW_SETTINGS = 'VIEW:SETTINGS';
export const VIEW_ADD_PLAYLIST = 'VIEW:PLAYLISTS';
+60 -74
View File
@@ -7,15 +7,10 @@ import {
} from '@angular/core/testing';
import { RouterTestingModule } from '@angular/router/testing';
import { AppComponent } from './app.component';
import {
TranslateModule,
TranslatePipe,
TranslateService,
} from '@ngx-translate/core';
import { ElectronService } from './services/electron.service';
import { TranslatePipe, TranslateService } from '@ngx-translate/core';
import { ElectronServiceStub } from './services/electron.service.stub';
import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar';
import { MockModule, MockPipe } from 'ng-mocks';
import { MockModule, MockPipe, MockProviders } from 'ng-mocks';
import { of } from 'rxjs';
import { WhatsNewService } from './services/whats-new.service';
import { Theme } from './settings/theme.enum';
@@ -24,25 +19,17 @@ import { Router } from '@angular/router';
import { ChannelStore } from './state';
import { STORE_KEY } from './shared/enums/store-keys.enum';
import { WhatsNewServiceStub } from './services/whats-new.service.stub';
jest.mock('custom-electron-titlebar', () => {
return {
Titlebar: jest.fn().mockImplementation(() => {
return {};
}),
Color: {
fromHex: jest.fn(),
},
};
});
import { DataService } from './services/data.service';
class MatSnackBarStub {
open(): void {}
}
jest.spyOn(global.console, 'error').mockImplementation(() => {});
describe('AppComponent', () => {
let component: AppComponent;
let electronService: ElectronService;
let electronService: DataService;
let fixture: ComponentFixture<AppComponent>;
let settingsService: SettingsService;
let translateService: TranslateService;
@@ -54,24 +41,26 @@ describe('AppComponent', () => {
TestBed.configureTestingModule({
declarations: [AppComponent, MockPipe(TranslatePipe)],
providers: [
{ provide: ElectronService, useClass: ElectronServiceStub },
{ provide: MatSnackBar, useClass: MatSnackBarStub },
{ provide: WhatsNewService, useClass: WhatsNewServiceStub },
TranslateService,
MockProviders(TranslateService),
SettingsService, // TODO: stub
{
provide: DataService,
useClass: ElectronServiceStub,
},
],
imports: [
MockModule(MatSnackBarModule),
MockModule(NgxWhatsNewModule),
RouterTestingModule,
TranslateModule.forRoot(),
],
}).compileComponents();
})
);
beforeEach(() => {
electronService = TestBed.inject(ElectronService);
electronService = TestBed.inject(DataService);
fixture = TestBed.createComponent(AppComponent);
settingsService = TestBed.inject(SettingsService);
translateService = TestBed.inject(TranslateService);
@@ -82,8 +71,8 @@ describe('AppComponent', () => {
});
it('should create the component and set default language', () => {
spyOn(translateService, 'setDefaultLang');
spyOn(component, 'setRendererListeners');
jest.spyOn(translateService, 'setDefaultLang');
jest.spyOn(component, 'setRendererListeners');
const fixture = TestBed.createComponent(AppComponent);
const app = fixture.debugElement.componentInstance;
expect(app).toBeTruthy();
@@ -91,10 +80,10 @@ describe('AppComponent', () => {
});
it('should init component', () => {
spyOn(translateService, 'setDefaultLang');
spyOn(component, 'setRendererListeners');
spyOn(component, 'initSettings');
spyOn(component, 'handleWhatsNewDialog');
jest.spyOn(translateService, 'setDefaultLang');
jest.spyOn(component, 'setRendererListeners');
jest.spyOn(component, 'initSettings');
jest.spyOn(component, 'handleWhatsNewDialog');
component.ngOnInit();
expect(translateService.setDefaultLang).toHaveBeenCalledWith(
defaultLanguage
@@ -106,24 +95,22 @@ describe('AppComponent', () => {
describe('Test ipc listeners and commands', () => {
it('should set IPC listeners', () => {
spyOn(electronService.ipcRenderer, 'on');
jest.spyOn(electronService, 'listenOn');
component.setRendererListeners();
expect(electronService.ipcRenderer.on).toHaveBeenCalledTimes(5);
expect(electronService.listenOn).toHaveBeenCalledTimes(5);
});
it('should remove all ipc listeners on destroy', () => {
spyOn(electronService.ipcRenderer, 'removeAllListeners');
jest.spyOn(electronService, 'removeAllListeners');
component.ngOnDestroy();
expect(
electronService.ipcRenderer.removeAllListeners
).toHaveBeenCalledTimes(4);
expect(electronService.removeAllListeners).toHaveBeenCalledTimes(4);
});
it('should navigate to the provided route', inject(
[Router],
(router: Router) => {
const route = '/add-playlists';
spyOn(router, 'navigateByUrl');
jest.spyOn(router, 'navigateByUrl');
component.navigateToRoute(route);
expect(router.navigateByUrl).toHaveBeenCalledTimes(1);
expect(router.navigateByUrl).toHaveBeenCalledWith(
@@ -136,7 +123,7 @@ describe('AppComponent', () => {
it('should show a notification on epg error', inject(
[MatSnackBar],
(snackbar: MatSnackBar) => {
spyOn(snackbar, 'open');
jest.spyOn(snackbar, 'open');
component.onEpgError();
expect(snackbar.open).toHaveBeenCalledTimes(1);
}
@@ -145,8 +132,8 @@ describe('AppComponent', () => {
it('should handle epg download success', inject(
[MatSnackBar, ChannelStore],
(snackbar: MatSnackBar, channelStore: ChannelStore) => {
spyOn(snackbar, 'open');
spyOn(channelStore, 'setEpgAvailableFlag');
jest.spyOn(snackbar, 'open');
jest.spyOn(channelStore, 'setEpgAvailableFlag');
component.onEpgFetchDone();
expect(snackbar.open).toHaveBeenCalledTimes(1);
expect(channelStore.setEpgAvailableFlag).toHaveBeenCalledWith(
@@ -156,8 +143,8 @@ describe('AppComponent', () => {
));
it('show show whats new dialog', () => {
spyOn(whatsNewService, 'getModalsByVersion');
spyOn(component, 'setDialogVisibility');
jest.spyOn(whatsNewService, 'getModalsByVersion');
jest.spyOn(component, 'setDialogVisibility');
component.showWhatsNewDialog();
expect(whatsNewService.getModalsByVersion).toHaveBeenCalledTimes(1);
expect(component.setDialogVisibility).toHaveBeenCalledWith(true);
@@ -167,12 +154,13 @@ describe('AppComponent', () => {
describe('Test version handling', () => {
it('should get actual app version which is outdated and show updates dialog', () => {
const currentAppVersion = '0.0.1';
const spyOnSettingsGet = spyOn(
settingsService,
'getValueFromLocalStorage'
).and.returnValue(of(currentAppVersion));
spyOn(whatsNewService, 'getModalsByVersion').and.returnValue([{}]);
spyOn(whatsNewService, 'changeDialogVisibleState');
const spyOnSettingsGet = jest
.spyOn(settingsService, 'getValueFromLocalStorage')
.mockReturnValue(of(currentAppVersion));
jest.spyOn(whatsNewService, 'getModalsByVersion').mockReturnValue([
{},
]);
jest.spyOn(whatsNewService, 'changeDialogVisibleState');
component.handleWhatsNewDialog();
expect(spyOnSettingsGet).toHaveBeenCalled();
@@ -184,12 +172,13 @@ describe('AppComponent', () => {
it('should get actual app version which is not outdated and do not shop updates dialog', () => {
const currentAppVersion = '1.0.0';
const spyOnSettingsGet = spyOn(
settingsService,
'getValueFromLocalStorage'
).and.returnValue(of(currentAppVersion));
spyOn(whatsNewService, 'getModalsByVersion').and.returnValue([{}]);
spyOn(whatsNewService, 'changeDialogVisibleState');
const spyOnSettingsGet = jest
.spyOn(settingsService, 'getValueFromLocalStorage')
.mockReturnValue(of(currentAppVersion));
jest.spyOn(whatsNewService, 'getModalsByVersion').mockReturnValue([
{},
]);
jest.spyOn(whatsNewService, 'changeDialogVisibleState');
component.handleWhatsNewDialog();
expect(spyOnSettingsGet).toHaveBeenCalled();
@@ -200,7 +189,7 @@ describe('AppComponent', () => {
});
it('should change the visibility of the whats new dialog', () => {
spyOn(whatsNewService, 'changeDialogVisibleState');
jest.spyOn(whatsNewService, 'changeDialogVisibleState');
component.modals = [{}, {}];
const visibilityFlag = true;
component.setDialogVisibility(true);
@@ -214,7 +203,7 @@ describe('AppComponent', () => {
});
it('should not change the visibility of the whats new dialog', () => {
spyOn(whatsNewService, 'changeDialogVisibleState');
jest.spyOn(whatsNewService, 'changeDialogVisibleState');
component.modals = [];
component.setDialogVisibility(true);
expect(
@@ -229,51 +218,48 @@ describe('AppComponent', () => {
const epgUrl = 'http://localhost/epg.xml';
beforeEach(() => {
spyOn(electronService.ipcRenderer, 'send');
spyOn(settingsService, 'changeTheme');
spyOn(component, 'handleWhatsNewDialog');
spyOn(translateService, 'use');
jest.spyOn(electronService, 'sendIpcEvent');
jest.spyOn(settingsService, 'changeTheme');
jest.spyOn(component, 'handleWhatsNewDialog');
jest.spyOn(translateService, 'use');
});
it('should get and init settings (all settings are defined)', () => {
const spyOnSettingsGet = spyOn(
settingsService,
'getValueFromLocalStorage'
).and.returnValue(of({ theme, epgUrl, language }));
const spyOnSettingsGet = jest
.spyOn(settingsService, 'getValueFromLocalStorage')
.mockReturnValue(of({ theme, epgUrl, language }));
component.initSettings();
expect(spyOnSettingsGet).toHaveBeenCalledWith(STORE_KEY.Settings);
expect(settingsService.changeTheme).toHaveBeenCalledWith(theme);
expect(electronService.ipcRenderer.send).toHaveBeenCalledTimes(1);
expect(electronService.sendIpcEvent).toHaveBeenCalledTimes(1);
expect(translateService.use).toHaveBeenCalledWith(language);
});
it('should get and init settings (nothing is defined)', () => {
const spyOnSettingsGet = spyOn(
settingsService,
'getValueFromLocalStorage'
).and.returnValue(of());
const spyOnSettingsGet = jest
.spyOn(settingsService, 'getValueFromLocalStorage')
.mockReturnValue(of());
component.initSettings();
expect(spyOnSettingsGet).toHaveBeenCalledWith(STORE_KEY.Settings);
expect(settingsService.changeTheme).toHaveBeenCalledTimes(0);
expect(electronService.ipcRenderer.send).toHaveBeenCalledTimes(0);
expect(electronService.sendIpcEvent).toHaveBeenCalledTimes(0);
expect(translateService.use).toHaveBeenCalledTimes(0);
});
it('should get and init settings (only theme is defined)', () => {
const spyOnSettingsGet = spyOn(
settingsService,
'getValueFromLocalStorage'
).and.returnValue(of({ theme }));
const spyOnSettingsGet = jest
.spyOn(settingsService, 'getValueFromLocalStorage')
.mockReturnValue(of({ theme }));
component.initSettings();
expect(spyOnSettingsGet).toHaveBeenCalledWith(STORE_KEY.Settings);
expect(settingsService.changeTheme).toHaveBeenCalledWith(theme);
expect(electronService.ipcRenderer.send).toHaveBeenCalledTimes(0);
expect(electronService.sendIpcEvent).toHaveBeenCalledTimes(0);
expect(translateService.use).toHaveBeenCalledWith(defaultLanguage);
});
});
+19 -25
View File
@@ -1,16 +1,14 @@
import { Component, NgZone } from '@angular/core';
import { TranslateService } from '@ngx-translate/core';
import { Titlebar, Color } from 'custom-electron-titlebar';
import { ElectronService } from './services/electron.service';
import { Router } from '@angular/router';
import { MatSnackBar, MatSnackBarConfig } from '@angular/material/snack-bar';
import { ChannelStore } from './state';
import { StorageMap } from '@ngx-pwa/local-storage';
import { Settings } from './settings/settings.interface';
import {
EPG_ERROR,
EPG_FETCH,
EPG_FETCH_DONE,
OPEN_FILE,
SHOW_WHATS_NEW,
VIEW_ADD_PLAYLIST,
VIEW_SETTINGS,
@@ -21,13 +19,7 @@ import { WhatsNewService } from './services/whats-new.service';
import * as semver from 'semver';
import { ModalWindow } from 'ngx-whats-new/lib/modal-window.interface';
import { STORE_KEY } from './shared/enums/store-keys.enum';
// create custom title bar
new Titlebar({
backgroundColor: Color.fromHex('#000'),
itemBackgroundColor: Color.fromHex('#222'),
enableMnemonics: true,
});
import { DataService } from './services/data.service';
/**
* AppComponent
@@ -69,18 +61,18 @@ export class AppComponent {
*/
constructor(
private channelStore: ChannelStore,
private electronService: ElectronService,
private electronService: DataService,
private ngZone: NgZone,
private router: Router,
private translate: TranslateService,
private settingsService: SettingsService,
private snackBar: MatSnackBar,
private storage: StorageMap,
private whatsNewService: WhatsNewService
) {
if (
(this.electronService.remote.process.platform === 'linux' ||
this.electronService.remote.process.platform === 'win32') &&
((this.electronService.isElectron &&
this.electronService?.remote?.process.platform === 'linux') ||
this.electronService?.remote?.process.platform === 'win32') &&
this.electronService.remote.process.argv.length > 2
) {
const filePath = this.electronService.remote.process.argv.find(
@@ -90,7 +82,7 @@ export class AppComponent {
if (filePath) {
const filePathsArray = filePath.split('/');
const fileName = filePathsArray[filePathsArray.length - 1];
this.electronService.ipcRenderer.send('open-file', {
this.electronService.sendIpcEvent(OPEN_FILE, {
filePath,
fileName,
});
@@ -113,11 +105,13 @@ export class AppComponent {
* Initializes all necessary listeners for the events from the renderer process
*/
setRendererListeners(): void {
this.commandsList.forEach((command) =>
this.electronService.ipcRenderer.on(command.id, () =>
this.ngZone.run(() => command.callback())
)
);
if (this.electronService.isElectron) {
this.commandsList.forEach((command) =>
this.electronService.listenOn(command.id, () =>
this.ngZone.run(() => command.callback())
)
);
}
}
/**
@@ -131,7 +125,7 @@ export class AppComponent {
if (settings && Object.keys(settings).length > 0) {
this.translate.use(settings.language ?? this.DEFAULT_LANG);
if (settings.epgUrl) {
this.electronService.ipcRenderer.send(EPG_FETCH, {
this.electronService.sendIpcEvent(EPG_FETCH, {
url: settings.epgUrl,
});
this.snackBar.open(
@@ -229,9 +223,9 @@ export class AppComponent {
* Removes all ipc command listeners on component destroy
*/
ngOnDestroy(): void {
this.electronService.ipcRenderer.removeAllListeners(EPG_FETCH_DONE);
this.electronService.ipcRenderer.removeAllListeners(EPG_ERROR);
this.electronService.ipcRenderer.removeAllListeners(VIEW_ADD_PLAYLIST);
this.electronService.ipcRenderer.removeAllListeners(VIEW_SETTINGS);
this.electronService.removeAllListeners(EPG_FETCH_DONE);
this.electronService.removeAllListeners(EPG_ERROR);
this.electronService.removeAllListeners(VIEW_ADD_PLAYLIST);
this.electronService.removeAllListeners(VIEW_SETTINGS);
}
}
+43
View File
@@ -14,12 +14,38 @@ import { TranslateModule, TranslateLoader } from '@ngx-translate/core';
import { TranslateHttpLoader } from '@ngx-translate/http-loader';
import { AppComponent } from './app.component';
import { ElectronService } from './services/electron.service';
import { PwaService } from './services/pwa.service';
import { DataService } from './services/data.service';
import { NgxIndexedDBModule, NgxIndexedDBService } from 'ngx-indexed-db';
import { AppConfig } from '../environments/environment';
import { dbConfig } from './indexed-db.config';
import { ServiceWorkerModule } from '@angular/service-worker';
// AoT requires an exported function for factories
export function HttpLoaderFactory(http: HttpClient): TranslateHttpLoader {
return new TranslateHttpLoader(http, './assets/i18n/', '.json');
}
/**
* Returns true if the application runs in the electron based environment.
*/
function isElectron() {
return !!(window && window.process && (window.process as any).type);
}
/**
* Conditionally imports the necessary service based on the current environment
* @param dbService indexed db service
* @returns
*/
export function DataFactory(dbService: NgxIndexedDBService, http: HttpClient) {
if (isElectron()) {
return new ElectronService();
}
return new PwaService(dbService, http);
}
@NgModule({
declarations: [AppComponent],
imports: [
@@ -29,6 +55,10 @@ export function HttpLoaderFactory(http: HttpClient): TranslateHttpLoader {
HttpClientModule,
NgxWhatsNewModule,
SharedModule,
AppConfig.environment === 'WEB'
? NgxIndexedDBModule.forRoot(dbConfig)
: [],
NgxIndexedDBModule.forRoot(dbConfig),
TranslateModule.forRoot({
loader: {
provide: TranslateLoader,
@@ -36,6 +66,19 @@ export function HttpLoaderFactory(http: HttpClient): TranslateHttpLoader {
deps: [HttpClient],
},
}),
ServiceWorkerModule.register('ngsw-worker.js', {
enabled: AppConfig.production,
// Register the ServiceWorker as soon as the app is stable
// or after 30 seconds (whichever comes first).
registrationStrategy: 'registerWhenStable:30000',
}),
],
providers: [
{
provide: DataService,
useFactory: DataFactory,
deps: [NgxIndexedDBService, HttpClient],
},
],
bootstrap: [AppComponent],
})
@@ -14,7 +14,10 @@ describe('FileUploadComponent', () => {
waitForAsync(() => {
TestBed.configureTestingModule({
declarations: [FileUploadComponent, MockPipe(TranslatePipe)],
imports: [MockModule(MatIconModule), NgxUploaderModule],
imports: [
MockModule(MatIconModule),
MockModule(NgxUploaderModule),
],
providers: [
{
provide: TranslateService,
+43 -27
View File
@@ -1,6 +1,9 @@
import { UploadFile } from 'ngx-uploader';
import {
PLAYLIST_GET_BY_ID,
PLAYLIST_PARSE,
PLAYLIST_PARSE_BY_URL,
PLAYLIST_REMOVE_BY_ID,
PLAYLIST_UPDATE,
} from './../../../shared/ipc-commands';
import { TranslatePipe, TranslateService } from '@ngx-translate/core';
@@ -16,20 +19,28 @@ import { MatIconModule } from '@angular/material/icon';
import { MatProgressBarModule } from '@angular/material/progress-bar';
import { RouterTestingModule } from '@angular/router/testing';
import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar';
import { ElectronService } from '../services/electron.service';
import { Router } from '@angular/router';
import { DialogService } from '../services/dialog.service';
import { ElectronServiceStub } from '../services/electron.service.stub';
import { DataService } from '../services/data.service';
import { NgxIndexedDBModule, NgxIndexedDBService } from 'ngx-indexed-db';
import { of } from 'rxjs';
class MatSnackBarStub {
open(): void {}
}
class NgxIndexedDBServiceStub {
getAll() {
return of([] as any);
}
}
describe('HomeComponent', () => {
let component: HomeComponent;
let dialogService: DialogService;
let fixture: ComponentFixture<HomeComponent>;
let electronService: ElectronService;
let electronService: DataService;
let router: Router;
beforeEach(() => {
@@ -48,12 +59,17 @@ describe('HomeComponent', () => {
MockModule(MatProgressBarModule),
MockModule(MatSnackBarModule),
MockModule(RouterTestingModule),
MockModule(NgxIndexedDBModule),
],
providers: [
{ provide: MatSnackBar, useClass: MatSnackBarStub },
{ provide: ElectronService, useClass: ElectronServiceStub },
{ provide: DataService, useClass: ElectronServiceStub },
MockProvider(DialogService),
MockProvider(TranslateService),
{
provide: NgxIndexedDBService,
useClass: NgxIndexedDBServiceStub,
},
],
}).compileComponents();
});
@@ -61,10 +77,10 @@ describe('HomeComponent', () => {
beforeEach(() => {
fixture = TestBed.createComponent(HomeComponent);
component = fixture.componentInstance;
electronService = TestBed.inject(ElectronService);
electronService = TestBed.inject(DataService);
dialogService = TestBed.inject(DialogService);
router = TestBed.inject(Router);
TestBed.inject(ElectronService);
TestBed.inject(NgxIndexedDBService);
fixture.detectChanges();
});
@@ -74,17 +90,17 @@ describe('HomeComponent', () => {
it('should open the confirmation dialog on remove icon click', () => {
const playlistId = '12345';
spyOn(dialogService, 'openConfirmDialog');
jest.spyOn(dialogService, 'openConfirmDialog');
component.removeClicked(playlistId);
expect(dialogService.openConfirmDialog).toHaveBeenCalledTimes(1);
});
it('should send an event to the main process to remove a playlist', () => {
const playlistId = '12345';
spyOn(electronService.ipcRenderer, 'send');
jest.spyOn(electronService, 'sendIpcEvent');
component.removePlaylist(playlistId);
expect(electronService.ipcRenderer.send).toHaveBeenCalledWith(
'playlist-remove-by-id',
expect(electronService.sendIpcEvent).toHaveBeenCalledWith(
PLAYLIST_REMOVE_BY_ID,
{ id: playlistId }
);
});
@@ -94,9 +110,9 @@ describe('HomeComponent', () => {
_id: 'iptv1',
filePath: '/home/user/lists/iptv.m3u',
} as PlaylistMeta;
spyOn(electronService.ipcRenderer, 'send');
jest.spyOn(electronService, 'sendIpcEvent');
component.refreshPlaylist(playlistMeta);
expect(electronService.ipcRenderer.send).toHaveBeenCalledWith(
expect(electronService.sendIpcEvent).toHaveBeenCalledWith(
PLAYLIST_UPDATE,
{ id: playlistMeta._id, filePath: playlistMeta.filePath }
);
@@ -104,10 +120,10 @@ describe('HomeComponent', () => {
it('should send an event to the main process to get a playlist', () => {
const playlistId = '6789';
spyOn(electronService.ipcRenderer, 'send');
jest.spyOn(electronService, 'sendIpcEvent');
component.getPlaylist(playlistId);
expect(electronService.ipcRenderer.send).toHaveBeenCalledWith(
'playlist-by-id',
expect(electronService.sendIpcEvent).toHaveBeenCalledWith(
PLAYLIST_GET_BY_ID,
{
id: playlistId,
}
@@ -117,10 +133,10 @@ describe('HomeComponent', () => {
it('should send an event to the main process to get a playlist by URL', () => {
const playlistTitle = 'playlist.m3u';
const playlistUrl = 'http://test.com/' + playlistTitle;
spyOn(electronService.ipcRenderer, 'send');
jest.spyOn(electronService, 'sendIpcEvent');
component.sendPlaylistsUrl(playlistUrl);
expect(electronService.ipcRenderer.send).toHaveBeenCalledWith(
'parse-playlist-by-url',
expect(electronService.sendIpcEvent).toHaveBeenCalledWith(
PLAYLIST_PARSE_BY_URL,
{
title: playlistTitle,
url: playlistUrl,
@@ -129,7 +145,7 @@ describe('HomeComponent', () => {
});
it('should send an event to the main process to parse a playlist', () => {
spyOn(electronService.ipcRenderer, 'send');
jest.spyOn(electronService, 'sendIpcEvent');
const title = 'my-list.m3u';
const path = '/home/user/iptv/' + title;
const playlistContent = 'test';
@@ -141,7 +157,7 @@ describe('HomeComponent', () => {
target: { result: playlistContent },
} as unknown as Event;
component.handlePlaylist({ file, uploadEvent });
expect(electronService.ipcRenderer.send).toHaveBeenCalledWith(
expect(electronService.sendIpcEvent).toHaveBeenCalledWith(
PLAYLIST_PARSE,
{ title, playlist: [playlistContent], path }
);
@@ -160,21 +176,21 @@ describe('HomeComponent', () => {
});
it('should set IPC event listeners', () => {
spyOn(electronService.ipcRenderer, 'on');
jest.spyOn(electronService, 'listenOn');
component.setRendererListeners();
expect(electronService.ipcRenderer.on).toHaveBeenCalledTimes(
expect(electronService.listenOn).toHaveBeenCalledTimes(
component.commandsList.length
);
});
it('should send notification on file reject', () => {
spyOn(component, 'showNotification');
jest.spyOn(component, 'showNotification');
component.rejectFile('wrong-file.txt');
expect(component.showNotification).toHaveBeenCalledTimes(1);
});
it('should navigate to the player view', () => {
spyOn(router, 'navigateByUrl');
jest.spyOn(router, 'navigateByUrl');
component.navigateToPlayer();
expect(router.navigateByUrl).toHaveBeenCalledTimes(1);
expect(router.navigateByUrl).toHaveBeenCalledWith(
@@ -184,10 +200,10 @@ describe('HomeComponent', () => {
});
it('should remove all ipc listeners on destroy', () => {
spyOn(electronService.ipcRenderer, 'removeAllListeners');
jest.spyOn(electronService, 'removeAllListeners');
component.ngOnDestroy();
expect(
electronService.ipcRenderer.removeAllListeners
).toHaveBeenCalledTimes(component.commandsList.length);
expect(electronService.removeAllListeners).toHaveBeenCalledTimes(
component.commandsList.length
);
});
});
+35 -22
View File
@@ -5,15 +5,21 @@ import { ChannelStore, createChannel } from '../state';
import { Router } from '@angular/router';
import { MatSnackBar } from '@angular/material/snack-bar';
import { Playlist } from '../../../shared/playlist.interface';
import { ElectronService } from '../services/electron.service';
import {
ERROR,
PLAYLIST_GET_ALL,
PLAYLIST_GET_ALL_RESPONSE,
PLAYLIST_GET_BY_ID,
PLAYLIST_PARSE,
PLAYLIST_PARSE_BY_URL,
PLAYLIST_PARSE_RESPONSE,
PLAYLIST_REMOVE_BY_ID,
PLAYLIST_REMOVE_BY_ID_RESPONSE,
PLAYLIST_UPDATE,
PLAYLIST_UPDATE_RESPONSE,
} from '../../../shared/ipc-commands';
import { DialogService } from './../services/dialog.service';
import { DataService } from '../services/data.service';
/** Type to describe meta data of a playlist */
export type PlaylistMeta = Pick<
@@ -49,18 +55,18 @@ export class HomeComponent {
this.setPlaylist(response.payload),
},
{
id: 'playlist-all-result',
id: PLAYLIST_GET_ALL_RESPONSE,
execute: (response: { payload: Partial<PlaylistMeta[]> }): void => {
this.playlists = response.payload;
},
},
{
id: 'playlist-remove-by-id-result',
id: PLAYLIST_REMOVE_BY_ID_RESPONSE,
execute: (): void => {
this.snackBar.open('Done! Playlist was removed.', null, {
duration: 2000,
});
this.electronService.ipcRenderer.send('playlists-all');
this.electronService.sendIpcEvent(PLAYLIST_GET_ALL);
},
},
{
@@ -82,22 +88,24 @@ export class HomeComponent {
/**
* Creates an instanceof HomeComponent
* @param channelStore channels store
* @param dialogService dialog service
* @param electronService electron service
* @param ngZone angulars ngZone module
* @param router angulars router
* @param snackBar snackbars with notification messages
* @param ngZone angular ngZone module
* @param router angular router
* @param snackBar snackbar for notification messages
* @param translate translate service
*/
constructor(
private electronService: DataService,
private channelStore: ChannelStore,
private dialogService: DialogService,
private electronService: ElectronService,
private ngZone: NgZone,
private router: Router,
private snackBar: MatSnackBar,
private translate: TranslateService
) {
// get all playlists
this.electronService.ipcRenderer.send('playlists-all');
this.electronService.sendIpcEvent(PLAYLIST_GET_ALL);
// set all renderer listeners
this.setRendererListeners();
}
@@ -107,12 +115,17 @@ export class HomeComponent {
*/
setRendererListeners(): void {
this.commandsList.forEach((command) => {
this.electronService.ipcRenderer.on(
command.id,
(event, response) => {
this.ngZone.run(() => command.execute(response));
}
);
if (this.electronService.isElectron) {
this.electronService.listenOn(command.id, (event, response) =>
this.ngZone.run(() => command.execute(response))
);
} else {
this.electronService.listenOn(command.id, (response) => {
if (response.data.type === command.id) {
command.execute(response.data);
}
});
}
});
}
@@ -135,10 +148,10 @@ export class HomeComponent {
this.isLoading = true;
const result = (payload.uploadEvent.target as FileReader).result;
const array = (result as string).split('\n');
this.electronService.ipcRenderer.send(PLAYLIST_PARSE, {
this.electronService.sendIpcEvent(PLAYLIST_PARSE, {
title: payload.file.name,
playlist: array,
path: payload.file.nativeFile.path,
path: (payload.file.nativeFile as any).path,
});
}
@@ -156,7 +169,7 @@ export class HomeComponent {
*/
sendPlaylistsUrl(playlistUrl: string): void {
this.isLoading = true;
this.electronService.ipcRenderer.send('parse-playlist-by-url', {
this.electronService.sendIpcEvent(PLAYLIST_PARSE_BY_URL, {
title: this.getLastUrlSegment(playlistUrl),
url: playlistUrl,
});
@@ -201,7 +214,7 @@ export class HomeComponent {
* @param playlistId playlist id to remove
*/
removePlaylist(playlistId: string): void {
this.electronService.ipcRenderer.send('playlist-remove-by-id', {
this.electronService.sendIpcEvent(PLAYLIST_REMOVE_BY_ID, {
id: playlistId,
});
}
@@ -211,7 +224,7 @@ export class HomeComponent {
* @param item playlist to update
*/
refreshPlaylist(item: PlaylistMeta): void {
this.electronService.ipcRenderer.send(PLAYLIST_UPDATE, {
this.electronService.sendIpcEvent(PLAYLIST_UPDATE, {
id: item._id,
...(item.url ? { url: item.url } : { filePath: item.filePath }),
});
@@ -222,7 +235,7 @@ export class HomeComponent {
* @param playlistId playlist id
*/
getPlaylist(playlistId: string): void {
this.electronService.ipcRenderer.send('playlist-by-id', {
this.electronService.sendIpcEvent(PLAYLIST_GET_BY_ID, {
id: playlistId,
});
}
@@ -255,7 +268,7 @@ export class HomeComponent {
*/
ngOnDestroy(): void {
this.commandsList.forEach((command) =>
this.electronService.ipcRenderer.removeAllListeners(command.id)
this.electronService.removeAllListeners(command.id)
);
}
}
@@ -36,7 +36,7 @@
}}</mat-label>
<input matInput formControlName="count" />
</mat-form-field>
<mat-form-field class="full-width mb-10">
<mat-form-field class="full-width mb-10" *ngIf="isElectron">
<mat-label>{{
'HOME.PLAYLISTS.INFO_DIALOG.USER_AGENT' | translate
}}</mat-label>
@@ -54,18 +54,19 @@
{{ 'HOME.PLAYLISTS.INFO_DIALOG.UPDATE_FAILED' | translate }}
</mat-hint>
</mat-form-field>
<mat-checkbox
*ngIf="playlist.url || playlist.filePath"
class="full-width"
formControlName="autoRefresh"
>
{{ 'HOME.PLAYLISTS.INFO_DIALOG.AUTO_UPDATE' | translate }}
</mat-checkbox>
<p>
{{
'HOME.PLAYLISTS.INFO_DIALOG.AUTO_UPDATE_DESCRIPTION' | translate
}}
</p>
<ng-container *ngIf="playlist.url || playlist.filePath">
<mat-checkbox
class="full-width"
formControlName="autoRefresh"
>
{{ 'HOME.PLAYLISTS.INFO_DIALOG.AUTO_UPDATE' | translate }}
</mat-checkbox>
<p>
{{
'HOME.PLAYLISTS.INFO_DIALOG.AUTO_UPDATE_DESCRIPTION' | translate
}}
</p>
</ng-container>
</mat-dialog-content>
<mat-dialog-actions align="end">
<button
@@ -2,7 +2,7 @@ import { MatCheckboxModule } from '@angular/material/checkbox';
import { PLAYLIST_SAVE_DETAILS } from './../../../../../shared/ipc-commands';
/* eslint-disable @typescript-eslint/unbound-method */
import { ElectronServiceStub } from '../../../services/electron.service.stub';
import { ElectronService } from './../../../services/electron.service';
import { DataService } from '../../../services/data.service';
import { TranslatePipe } from '@ngx-translate/core';
import { MockModule, MockPipe } from 'ng-mocks';
import { MAT_DIALOG_DATA, MatDialogModule } from '@angular/material/dialog';
@@ -16,7 +16,7 @@ import { Playlist } from './../../../../../shared/playlist.interface';
describe('PlaylistInfoComponent', () => {
let component: PlaylistInfoComponent;
let fixture: ComponentFixture<PlaylistInfoComponent>;
let electronService: ElectronService;
let electronService: DataService;
beforeEach(
waitForAsync(() => {
@@ -35,7 +35,7 @@ describe('PlaylistInfoComponent', () => {
],
providers: [
{ provide: MAT_DIALOG_DATA, useValue: {} },
{ provide: ElectronService, useClass: ElectronServiceStub },
{ provide: DataService, useClass: ElectronServiceStub },
FormBuilder,
],
}).compileComponents();
@@ -45,7 +45,7 @@ describe('PlaylistInfoComponent', () => {
beforeEach(() => {
fixture = TestBed.createComponent(PlaylistInfoComponent);
component = fixture.componentInstance;
electronService = TestBed.inject(ElectronService);
electronService = TestBed.inject(DataService);
fixture.detectChanges();
});
@@ -55,10 +55,10 @@ describe('PlaylistInfoComponent', () => {
it('should send an event to the main process after save', () => {
const playlistToSave = { _id: 'a12345', title: 'Playlist' } as Playlist;
spyOn(electronService.ipcRenderer, 'send');
jest.spyOn(electronService, 'sendIpcEvent');
component.saveChanges(playlistToSave);
expect(electronService.ipcRenderer.send).toHaveBeenCalledTimes(1);
expect(electronService.ipcRenderer.send).toHaveBeenCalledWith(
expect(electronService.sendIpcEvent).toHaveBeenCalledTimes(1);
expect(electronService.sendIpcEvent).toHaveBeenCalledWith(
PLAYLIST_SAVE_DETAILS,
playlistToSave
);
@@ -9,8 +9,8 @@ import {
} from '@angular/forms';
import { MAT_DIALOG_DATA } from '@angular/material/dialog';
import { PLAYLIST_SAVE_DETAILS } from '../../../../../shared/ipc-commands';
import { ElectronService } from '../../../services/electron.service';
import { Playlist } from '../../../../../shared/playlist.interface';
import { DataService } from '../../../services/data.service';
@Component({
selector: 'app-playlist-info',
@@ -18,6 +18,9 @@ import { Playlist } from '../../../../../shared/playlist.interface';
providers: [DatePipe],
})
export class PlaylistInfoComponent {
/** Flag that returns true if application runs in electron-based environment */
isElectron = this.electronService.isElectron;
/** Playlist object */
playlist: Playlist;
@@ -34,7 +37,7 @@ export class PlaylistInfoComponent {
constructor(
private datePipe: DatePipe,
private formBuilder: FormBuilder,
private electronService: ElectronService,
private electronService: DataService,
@Inject(MAT_DIALOG_DATA) playlist: Playlist
) {
this.playlist = playlist;
@@ -76,6 +79,6 @@ export class PlaylistInfoComponent {
saveChanges(
data: Pick<Playlist, '_id' | 'title' | 'userAgent' | 'autoRefresh'>
): void {
this.electronService.ipcRenderer.send(PLAYLIST_SAVE_DETAILS, data);
this.electronService.sendIpcEvent(PLAYLIST_SAVE_DETAILS, data);
}
}
@@ -42,7 +42,7 @@ mat-nav-list {
.cdk-drag-preview {
box-sizing: border-box;
border-radius: 4px;
box-shadow: 0 5px 5px -3px rgba(0, 0, 0, 0.2), 0 8px 10px 1px rgba(0, 0, 0, 0.14), 0 3px 14px 2px rgba(0, 0, 0, 0.12);
box-shadow: 0 5px 5px -3px rgb(0 0 0 / 20%), 0 8px 10px 1px rgb(0 0 0 / 14%), 0 3px 14px 2px rgb(0 0 0 / 12%);
}
.cdk-drag-placeholder {
@@ -1,7 +1,7 @@
import { TranslatePipe, TranslateService } from '@ngx-translate/core';
import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing';
import { MockModule, MockPipe, MockProvider } from 'ng-mocks';
import { ElectronService } from '../../services/electron.service';
import { DataService } from '../../services/data.service';
import { MatDialog, MatDialogModule } from '@angular/material/dialog';
import { RecentPlaylistsComponent } from './recent-playlists.component';
import { PlaylistMeta } from '../home.component';
@@ -13,7 +13,7 @@ import { ElectronServiceStub } from '../../services/electron.service.stub';
describe('RecentPlaylistsComponent', () => {
let component: RecentPlaylistsComponent;
let fixture: ComponentFixture<RecentPlaylistsComponent>;
let electronService: ElectronService;
let electronService: DataService;
let dialog: MatDialog;
beforeEach(
@@ -30,7 +30,7 @@ describe('RecentPlaylistsComponent', () => {
MockModule(MatTooltipModule),
],
providers: [
{ provide: ElectronService, useClass: ElectronServiceStub },
{ provide: DataService, useClass: ElectronServiceStub },
MockProvider(TranslateService),
],
}).compileComponents();
@@ -42,8 +42,7 @@ describe('RecentPlaylistsComponent', () => {
component = fixture.componentInstance;
component.playlists = [];
dialog = TestBed.inject(MatDialog);
electronService = TestBed.inject(ElectronService);
TestBed.inject(ElectronService);
electronService = TestBed.inject(DataService);
fixture.detectChanges();
});
@@ -52,7 +51,7 @@ describe('RecentPlaylistsComponent', () => {
});
it('should open the info dialog', () => {
spyOn(dialog, 'open');
jest.spyOn(dialog, 'open');
component.openInfoDialog({} as PlaylistMeta);
expect(dialog.open).toHaveBeenCalledTimes(1);
});
@@ -68,7 +67,7 @@ describe('RecentPlaylistsComponent', () => {
distance: { x: 0, y: 0 },
dropPoint: { x: 0, y: 0 },
};
spyOn(electronService, 'sendIpcEvent');
jest.spyOn(electronService, 'sendIpcEvent');
component.drop(event);
expect(electronService.sendIpcEvent).toHaveBeenCalledTimes(1);
});
@@ -4,7 +4,7 @@ import { MatDialog } from '@angular/material/dialog';
import { PlaylistInfoComponent } from './playlist-info/playlist-info.component';
import { PlaylistMeta } from './../home.component';
import { CdkDragDrop, moveItemInArray } from '@angular/cdk/drag-drop';
import { ElectronService } from '../../services/electron.service';
import { DataService } from '../../services/data.service';
@Component({
selector: 'app-recent-playlists',
@@ -31,7 +31,7 @@ export class RecentPlaylistsComponent {
*/
constructor(
public dialog: MatDialog,
private electronService: ElectronService
private electronService: DataService
) {}
/**
@@ -39,11 +39,10 @@ describe('UrlUploadComponent', () => {
it(
'submit form with playlist url',
waitForAsync(() => {
spyOn(component.urlAdded, 'emit');
jest.spyOn(component.urlAdded, 'emit');
const TEST_URL = 'http://example.org/playlist.m3u';
const submitButton = fixture.debugElement.nativeElement.querySelector(
'button'
);
const submitButton =
fixture.debugElement.nativeElement.querySelector('button');
// test input field validation
expect(submitButton.disabled).toBeTruthy();
+69
View File
@@ -0,0 +1,69 @@
import { DBConfig } from 'ngx-indexed-db';
/**
* Contains names of the database stores
*/
export enum DbStores {
Playlists = 'playlists',
}
/** Defines db tables and schema */
export const dbConfig: DBConfig = {
name: 'iptvnator',
version: 1,
objectStoresMeta: [
{
store: DbStores.Playlists,
storeConfig: { keyPath: '_id', autoIncrement: false },
storeSchema: [
{
name: '_id',
keypath: '_id',
options: { unique: false },
},
{
name: 'filename',
keypath: 'filename',
options: { unique: false },
},
{ name: 'title', keypath: 'title', options: { unique: false } },
{ name: 'count', keypath: 'count', options: { unique: false } },
{
name: 'playlist',
keypath: 'playlist',
options: { unique: false },
},
{
name: 'importDate',
keypath: 'importDate',
options: { unique: false },
},
{
name: 'lastUsage',
keypath: 'lastUsage',
options: { unique: false },
},
{
name: 'favorites',
keypath: 'favorites',
options: { unique: false },
},
{
name: 'autoRefresh',
keypath: 'autoRefresh',
options: { unique: false },
},
{
name: 'url',
keypath: 'url',
options: { unique: false },
},
{
name: 'filePath',
keypath: 'filePath',
options: { unique: false },
},
],
},
],
};
@@ -69,7 +69,7 @@
appearance: none;
height: 10px;
width: 10px;
background-image: url(data:image/svg+xml;base64,PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0idXRmLTgiPz4NCjwhRE9DVFlQRSBzdmcgUFVCTElDICItLy9XM0MvL0RURCBTVkcgMS4xLy9FTiIgImh0dHA6Ly93d3cudzMub3JnL0dyYXBoaWNzL1NWRy8xLjEvRFREL3N2ZzExLmR0ZCI+DQo8c3ZnIHZlcnNpb249IjEuMSIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIiB4bWxuczp4bGluaz0iaHR0cDovL3d3dy53My5vcmcvMTk5OS94bGluayIgeD0iMHB4IiB5PSIwcHgiDQoJIHZpZXdCb3g9IjAgMCA5NiA5NiIgZW5hYmxlLWJhY2tncm91bmQ9Im5ldyAwIDAgOTYgOTYiIHhtbDpzcGFjZT0icHJlc2VydmUiPg0KPHBvbHlnb24gZmlsbD0iI2VlZSIgcG9pbnRzPSI5NiwxNCA4MiwwIDQ4LDM0IDE0LDAgMCwxNCAzNCw0OCAwLDgyIDE0LDk2IDQ4LDYyIDgyLDk2IDk2LDgyIDYyLDQ4ICIvPg0KPC9zdmc+DQo=);
background-image: url('data:image/svg+xml;base64,PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0idXRmLTgiPz4NCjwhRE9DVFlQRSBzdmcgUFVCTElDICItLy9XM0MvL0RURCBTVkcgMS4xLy9FTiIgImh0dHA6Ly93d3cudzMub3JnL0dyYXBoaWNzL1NWRy8xLjEvRFREL3N2ZzExLmR0ZCI+DQo8c3ZnIHZlcnNpb249IjEuMSIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIiB4bWxuczp4bGluaz0iaHR0cDovL3d3dy53My5vcmcvMTk5OS94bGluayIgeD0iMHB4IiB5PSIwcHgiDQoJIHZpZXdCb3g9IjAgMCA5NiA5NiIgZW5hYmxlLWJhY2tncm91bmQ9Im5ldyAwIDAgOTYgOTYiIHhtbDpzcGFjZT0icHJlc2VydmUiPg0KPHBvbHlnb24gZmlsbD0iI2VlZSIgcG9pbnRzPSI5NiwxNCA4MiwwIDQ4LDM0IDE0LDAgMCwxNCAzNCw0OCAwLDgyIDE0LDk2IDQ4LDYyIDgyLDk2IDk2LDgyIDYyLDQ4ICIvPg0KPC9zdmc+DQo=');
background-size: 10px 10px;
color: #fff;
}
@@ -1,26 +1,26 @@
import { MatTooltipModule } from '@angular/material/tooltip';
import { TranslatePipe } from '@ngx-translate/core';
import { ScrollingModule } from '@angular/cdk/scrolling';
/* eslint-disable @typescript-eslint/unbound-method */
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { ChannelListContainerComponent } from './channel-list-container.component';
import { FormsModule } from '@angular/forms';
import { MatExpansionModule } from '@angular/material/expansion';
import { MatIconModule } from '@angular/material/icon';
import { MatInputModule } from '@angular/material/input';
import { MatListModule } from '@angular/material/list';
import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar';
import { MatTabsModule } from '@angular/material/tabs';
import { MatTooltipModule } from '@angular/material/tooltip';
import { By } from '@angular/platform-browser';
import { RouterTestingModule } from '@angular/router/testing';
import { TranslatePipe } from '@ngx-translate/core';
import { MockModule, MockPipes } from 'ng-mocks';
import { FilterPipe } from 'ngx-filter-pipe';
import * as MOCKED_PLAYLIST from '../../../../mocks/playlist.json';
import { DataService } from '../../../services/data.service';
import { ElectronServiceStub } from '../../../services/electron.service.stub';
import { createChannel } from '../../../state';
import { ChannelQuery } from '../../../state/channel.query';
import { ChannelStore } from '../../../state/channel.store';
import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar';
import { MatInputModule } from '@angular/material/input';
import { MatIconModule } from '@angular/material/icon';
import { MatListModule } from '@angular/material/list';
import { MatTabsModule } from '@angular/material/tabs';
import { MatExpansionModule } from '@angular/material/expansion';
import { MockModule, MockPipe } from 'ng-mocks';
import { FormsModule } from '@angular/forms';
import { RouterTestingModule } from '@angular/router/testing';
import { FilterPipeModule } from 'ngx-filter-pipe';
import { By } from '@angular/platform-browser';
import { createChannel } from '../../../state';
import * as MOCKED_PLAYLIST from '../../../../mocks/playlist.json';
import { ElectronService } from '../../../services/electron.service';
import { ElectronServiceStub } from '../../../services/electron.service.stub';
import { ScrollingModule } from '@angular/cdk/scrolling';
import { ChannelListContainerComponent } from './channel-list-container.component';
class MatSnackBarStub {
open(): void {}
@@ -35,12 +35,12 @@ describe('ChannelListContainerComponent', () => {
TestBed.configureTestingModule({
declarations: [
ChannelListContainerComponent,
MockPipe(TranslatePipe),
MockPipes(TranslatePipe, FilterPipe),
],
providers: [
ChannelQuery,
{ provide: MatSnackBar, useClass: MatSnackBarStub },
{ provide: ElectronService, useClass: ElectronServiceStub },
{ provide: DataService, useClass: ElectronServiceStub },
],
imports: [
MockModule(MatSnackBarModule),
@@ -53,7 +53,6 @@ describe('ChannelListContainerComponent', () => {
MockModule(MatExpansionModule),
FormsModule,
RouterTestingModule,
FilterPipeModule,
],
}).compileComponents();
});
@@ -146,14 +145,14 @@ describe('ChannelListContainerComponent', () => {
});
it('should update store after channel was selected', () => {
spyOn(store, 'update');
jest.spyOn(store, 'update');
component.selectChannel(component._channelList[0]);
fixture.detectChanges();
expect(store.update).toHaveBeenCalledTimes(1);
});
it('should update store after channel was favorited', () => {
spyOn(store, 'updateFavorite');
jest.spyOn(store, 'updateFavorite');
component.toggleFavoriteChannel(
component._channelList[0],
new MouseEvent('click')
@@ -20,8 +20,7 @@ const EPG_PROGRAM_ITEM = {
desc: [
{
lang: 'en',
value:
"Jordan's Queen Rania has made job creation a priority to help curb the staggering unemployment rates among youths in the Middle East.",
value: "Jordan's Queen Rania has made job creation a priority to help curb the staggering unemployment rates among youths in the Middle East.",
},
],
date: ['20080711'],
@@ -93,7 +92,7 @@ describe('EpgListItemComponent', () => {
});
it('should trigger the function to open the details dialog', () => {
spyOn(dialog, 'open');
jest.spyOn(dialog, 'open');
component.showDescription({} as EpgProgram);
expect(dialog.open).toHaveBeenCalledTimes(1);
expect(dialog.open).toHaveBeenCalledWith(EpgItemDescriptionComponent, {
@@ -6,7 +6,7 @@ import { MatTooltipModule } from '@angular/material/tooltip';
import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing';
import { EpgListComponent, EpgData } from './epg-list.component';
import { MatListModule } from '@angular/material/list';
import { ElectronService } from '../../../services/electron.service';
import { DataService } from '../../../services/data.service';
import { ElectronServiceStub } from '../../../services/electron.service.stub';
import * as moment from 'moment';
import { EPG_GET_PROGRAM_DONE } from '../../../../../shared/ipc-commands';
@@ -18,7 +18,7 @@ import { EpgListItemComponent } from './epg-list-item/epg-list-item.component';
describe('EpgListComponent', () => {
let component: EpgListComponent;
let fixture: ComponentFixture<EpgListComponent>;
let electronService: ElectronService;
let electronService: DataService;
let channelStore: ChannelStore;
const MOCKED_PROGRAMS = {
@@ -100,7 +100,7 @@ describe('EpgListComponent', () => {
MockModule(MatDialogModule),
],
providers: [
{ provide: ElectronService, useClass: ElectronServiceStub },
{ provide: DataService, useClass: ElectronServiceStub },
MockProvider(MatDialog),
],
}).compileComponents();
@@ -110,7 +110,7 @@ describe('EpgListComponent', () => {
beforeEach(() => {
fixture = TestBed.createComponent(EpgListComponent);
component = fixture.componentInstance;
electronService = TestBed.inject(ElectronService);
electronService = TestBed.inject(DataService);
channelStore = TestBed.inject(ChannelStore);
channelStore.setActiveChannel({
id: '',
@@ -148,18 +148,16 @@ describe('EpgListComponent', () => {
});
it('should remove ipc listeners on destroy', () => {
spyOn(electronService.ipcRenderer, 'removeAllListeners');
jest.spyOn(electronService, 'removeAllListeners');
component.ngOnDestroy();
expect(
electronService.ipcRenderer.removeAllListeners
).toHaveBeenCalledTimes(1);
expect(
electronService.ipcRenderer.removeAllListeners
).toHaveBeenCalledWith(EPG_GET_PROGRAM_DONE);
expect(electronService.removeAllListeners).toHaveBeenCalledTimes(1);
expect(electronService.removeAllListeners).toHaveBeenCalledWith(
EPG_GET_PROGRAM_DONE
);
});
it('should set epg program as active', () => {
spyOn(channelStore, 'setActiveEpgProgram');
jest.spyOn(channelStore, 'setActiveEpgProgram');
component.setEpgProgram(MOCKED_PROGRAMS.items[0], false, true);
expect(channelStore.setActiveEpgProgram).toHaveBeenCalledTimes(1);
expect(channelStore.setActiveEpgProgram).toHaveBeenCalledWith(
@@ -168,7 +166,7 @@ describe('EpgListComponent', () => {
});
it('should reset active epg program', () => {
spyOn(channelStore, 'resetActiveEpgProgram');
jest.spyOn(channelStore, 'resetActiveEpgProgram');
component.setEpgProgram(MOCKED_PROGRAMS.items[0], true);
expect(channelStore.resetActiveEpgProgram).toHaveBeenCalledTimes(1);
component.setEpgProgram(MOCKED_PROGRAMS.items[0], true, true);
@@ -2,11 +2,11 @@ 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 { ElectronService } from '../../../services/electron.service';
import { EPG_GET_PROGRAM_DONE } from '../../../../../shared/ipc-commands';
import { ChannelQuery, ChannelStore } from '../../../state';
import { Observable } from 'rxjs';
import { map } from 'rxjs/operators';
import { DataService } from '../../../services/data.service';
export interface EpgData {
channel: EpgChannel;
@@ -58,10 +58,10 @@ export class EpgListComponent {
constructor(
private channelQuery: ChannelQuery,
private channelStore: ChannelStore,
private electronService: ElectronService,
private electronService: DataService,
private ngZone: NgZone
) {
this.electronService.ipcRenderer.on(
this.electronService.listenOn(
EPG_GET_PROGRAM_DONE,
(event, response) => {
this.ngZone.run(() => this.handleEpgData(response));
@@ -94,7 +94,7 @@ export class EpgListComponent {
* @param programs
*/
handleEpgData(programs: { payload: EpgData }): void {
if (programs.payload?.items?.length > 0) {
if (programs?.payload?.items?.length > 0) {
this.programs = programs;
this.timeNow = moment(Date.now()).format(DATE_TIME_FORMAT);
this.dateToday = moment(Date.now()).format(DATE_FORMAT);
@@ -183,8 +183,6 @@ export class EpgListComponent {
* Removes all ipc renderer listeners after destroy
*/
ngOnDestroy(): void {
this.electronService.ipcRenderer.removeAllListeners(
EPG_GET_PROGRAM_DONE
);
this.electronService.removeAllListeners(EPG_GET_PROGRAM_DONE);
}
}
@@ -1,9 +1,7 @@
import { MockPipe } from 'ng-mocks';
import { TranslatePipe } from '@ngx-translate/core';
/* eslint-disable @typescript-eslint/unbound-method */
import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing';
import { SimpleChange } from '@angular/core';
import { HtmlVideoPlayerComponent } from './html-video-player.component';
describe('HtmlVideoPlayerComponent', () => {
@@ -40,13 +38,15 @@ describe('HtmlVideoPlayerComponent', () => {
});
it('should call play channel function after input changes', () => {
spyOn(component, 'playChannel');
jest.spyOn(component, 'playChannel');
jest.spyOn(global.console, 'error').mockImplementation(() => {});
component.ngOnChanges({
channel: new SimpleChange(null, TEST_CHANNEL, true),
});
fixture.detectChanges();
expect(component.playChannel).toBeCalledTimes(1);
expect(component.playChannel).toBeCalledWith(TEST_CHANNEL);
expect(component.playChannel).toHaveBeenCalledTimes(1);
expect(component.playChannel).toHaveBeenCalledWith(TEST_CHANNEL);
expect(console.error).toHaveBeenCalledTimes(1);
});
});
@@ -49,7 +49,7 @@ export class HtmlVideoPlayerComponent implements OnChanges, OnDestroy {
playChannel(channel: Channel): void {
if (this.hls) this.hls.destroy();
const url = channel.url + channel.epgParams;
if (Hls.isSupported()) {
if (Hls && Hls.isSupported()) {
console.log('... switching channel to ', channel.name, url);
this.hls = new Hls();
this.hls.attachMedia(this.videoPlayer.nativeElement);
@@ -17,6 +17,8 @@ import { createChannel } from '../../../state';
import { HtmlVideoPlayerComponent } from '../html-video-player/html-video-player.component';
import { EpgListComponent } from '../epg-list/epg-list.component';
import { VideoPlayer } from '../../../settings/settings.interface';
import { DataService } from '../../../services/data.service';
import { ElectronServiceStub } from '../../../services/electron.service.stub';
class MatSnackBarStub {
open(): void {}
@@ -43,6 +45,7 @@ describe('VideoPlayerComponent', () => {
],
providers: [
{ provide: MatSnackBar, useClass: MatSnackBarStub },
{ provide: DataService, useClass: ElectronServiceStub },
],
imports: [
MockModule(MatSidenavModule),
@@ -67,9 +70,9 @@ describe('VideoPlayerComponent', () => {
it('should create and init component', () => {
expect(component).toBeTruthy();
spyOn(component, 'applySettings');
jest.spyOn(component, 'applySettings');
fixture.detectChanges();
expect(component.applySettings).toBeCalledTimes(1);
expect(component.applySettings).toHaveBeenCalledTimes(1);
});
it('should check default component settings', () => {
@@ -81,11 +84,11 @@ describe('VideoPlayerComponent', () => {
});
it('should update store after channel was faved', () => {
spyOn(store, 'updateFavorite');
jest.spyOn(store, 'updateFavorite');
const [firstChannel] = channels;
component.addToFavorites(firstChannel);
fixture.detectChanges();
expect(store.updateFavorite).toBeCalledTimes(1);
expect(store.updateFavorite).toBeCalledWith(firstChannel);
//fixture.detectChanges();
expect(store.updateFavorite).toHaveBeenCalledTimes(1);
expect(store.updateFavorite).toHaveBeenCalledWith(firstChannel);
});
});
+19
View File
@@ -0,0 +1,19 @@
import { ipcRenderer } from 'electron';
export abstract class DataService {
get isElectron(): boolean {
return !!(window && window.process && (window.process as any).type);
}
get remote() {
return this.isElectron ? window.require('electron').remote : null;
}
get ipcRenderer(): typeof ipcRenderer {
return this.isElectron ? window.require('electron').ipcRenderer : null;
}
abstract getAppVersion(): string;
abstract sendIpcEvent(type: string, payload?: unknown): void;
abstract removeAllListeners(type: string): void;
abstract listenOn(
command: string,
callback: (...args: any[]) => void
): void;
}
+3 -1
View File
@@ -25,7 +25,9 @@ describe('Service: Dialog', () => {
it('should open a confirm dialog', inject(
[MatDialog, DialogService],
(dialog: MatDialog, service: DialogService) => {
spyOn(dialog, 'open').and.returnValue({ afterClosed: () => EMPTY });
jest.spyOn(dialog, 'open').mockReturnValue({
afterClosed: () => EMPTY,
} as any);
service.openConfirmDialog({
title: 'Remove dialog',
message: 'Message',
+9 -5
View File
@@ -11,14 +11,18 @@ export class ElectronServiceStub {
},
app: {
getVersion: jest.fn(() => '1.0.0'),
}
},
};
isElectron = true;
removeAllListeners(type: string) {}
getAppVersion() {
return this.remote.app.getVersion();
}
sendIpcEvent() {
this.ipcRenderer.send();
}
}
sendIpcEvent() {}
listenOn() {}
}
+25 -26
View File
@@ -1,34 +1,16 @@
import { Injectable } from '@angular/core';
// If you import a module but never use any of the imported values other than as TypeScript types,
// the resulting javascript file will look as if you never imported the module at all.
import { ipcRenderer, webFrame, remote } from 'electron';
import * as childProcess from 'child_process';
import * as fs from 'fs';
import { DataService } from './data.service';
@Injectable({
providedIn: 'root',
})
export class ElectronService {
ipcRenderer: typeof ipcRenderer;
webFrame: typeof webFrame;
remote: typeof remote;
childProcess: typeof childProcess;
fs: typeof fs;
get isElectron(): boolean {
return !!(window && window.process && window.process.type);
}
export class ElectronService extends DataService {
/**
* Creates an instance of ElectronService.
*/
constructor() {
// Conditional imports
if (this.isElectron) {
this.ipcRenderer = window.require('electron').ipcRenderer;
this.webFrame = window.require('electron').webFrame;
this.remote = window.require('electron').remote;
this.childProcess = window.require('child_process');
this.fs = window.require('fs');
}
super();
console.log('Electron service initialized...');
}
/**
@@ -43,7 +25,24 @@ export class ElectronService {
* @param type event type
* @param payload data payload
*/
sendIpcEvent(type: string, payload: unknown): void {
sendIpcEvent(type: string, payload?: unknown): void {
this.ipcRenderer.send(type, payload);
}
/**
* Removes all IPC listeners
* @param type command name
*/
removeAllListeners(type: string): void {
this.ipcRenderer.removeAllListeners(type);
}
/**
* Listens on an IPC event from the main process
* @param command command name
* @param callback callback function
*/
listenOn(command: string, callback: (...args: any[]) => void): void {
this.ipcRenderer.on(command, callback);
}
}
+263
View File
@@ -0,0 +1,263 @@
import { HttpClient } from '@angular/common/http';
import { Injectable } from '@angular/core';
import { guid } from '@datorama/akita';
import { parse } from 'iptv-playlist-parser';
import { NgxIndexedDBService } from 'ngx-indexed-db';
import { switchMap } from 'rxjs';
import {
PLAYLIST_GET_ALL,
PLAYLIST_GET_ALL_RESPONSE,
PLAYLIST_GET_BY_ID,
PLAYLIST_PARSE,
PLAYLIST_PARSE_BY_URL,
PLAYLIST_PARSE_RESPONSE,
PLAYLIST_REMOVE_BY_ID,
PLAYLIST_REMOVE_BY_ID_RESPONSE,
PLAYLIST_SAVE_DETAILS,
PLAYLIST_UPDATE,
PLAYLIST_UPDATE_FAVORITES,
} from '../../../shared/ipc-commands';
import {
Playlist,
PlaylistUpdateState,
} from '../../../shared/playlist.interface';
import { AppConfig } from '../../environments/environment';
import { ParsedPlaylist } from '../../typings';
import { DbStores } from '../indexed-db.config';
import { DataService } from './data.service';
@Injectable({
providedIn: 'root',
})
export class PwaService extends DataService {
/**
* Creates an instance of PwaService.
* @param dbService database service
* @param http angular http client
*/
constructor(
private dbService: NgxIndexedDBService,
private http: HttpClient
) {
super();
console.log('PWA service initialized...');
}
/**
* Returns the current version of the app
*/
getAppVersion(): string {
return AppConfig.version;
}
/**
* Handles incoming IPC commands
* @param type ipc command type
* @param payload payload
*/
sendIpcEvent(type: string, payload?: any): void {
if (type === PLAYLIST_PARSE) {
const parsedPlaylist = this.parsePlaylist(payload.playlist);
const playlistObject = this.createPlaylistObject(
payload.title,
parsedPlaylist,
payload.path,
'FILE'
);
// save to db
this.dbService
.add(DbStores.Playlists, playlistObject)
.subscribe(() => {
console.log('playlist was saved to db...');
});
window.postMessage({
type: PLAYLIST_PARSE_RESPONSE,
payload: playlistObject,
});
} else if (type === PLAYLIST_GET_BY_ID) {
this.dbService
.getByIndex(DbStores.Playlists, '_id', payload.id)
.subscribe((playlist) => {
window.postMessage({
type: PLAYLIST_PARSE_RESPONSE,
payload: playlist,
});
});
} else if (type === PLAYLIST_REMOVE_BY_ID) {
this.dbService
.delete(DbStores.Playlists, payload.id)
.subscribe((playlist) => {
window.postMessage({
type: PLAYLIST_REMOVE_BY_ID_RESPONSE,
payload: playlist,
});
});
} else if (type === PLAYLIST_GET_ALL) {
this.dbService.getAll(DbStores.Playlists).subscribe((playlists) => {
window.postMessage({
type: PLAYLIST_GET_ALL_RESPONSE,
payload: playlists,
});
});
} else if (type === PLAYLIST_SAVE_DETAILS) {
this.dbService
.getByID(DbStores.Playlists, payload._id)
.pipe(
switchMap((playlist: Playlist) => {
return this.dbService.update(DbStores.Playlists, {
...playlist,
title: payload.title,
});
})
)
.subscribe((playlists) => {
window.postMessage({
type: PLAYLIST_GET_ALL_RESPONSE,
payload: playlists,
});
});
} else if (type === PLAYLIST_UPDATE_FAVORITES) {
this.dbService
.getByID(DbStores.Playlists, payload.id)
.pipe(
switchMap((playlist: Playlist) => {
return this.dbService.update(DbStores.Playlists, {
...playlist,
favorites: payload.favorites,
});
})
)
.subscribe(() => {
console.log('favorites were updated...');
});
} else if (type === PLAYLIST_PARSE_BY_URL) {
this.fetchFromUrl(payload);
} else if (type === PLAYLIST_UPDATE) {
this.http
.get(payload.url, { responseType: 'text' })
.subscribe((response: any) => {
const refreshedPlaylist =
this.convertFileStringToPlaylist(response);
this.dbService
.getByID(DbStores.Playlists, payload.id)
.pipe(
switchMap((currentPlaylist: Playlist) => {
return this.dbService.update(
DbStores.Playlists,
{
...currentPlaylist,
...refreshedPlaylist,
count: refreshedPlaylist.items.length,
updateDate: Date.now(),
updateState:
PlaylistUpdateState.UPDATED,
}
);
})
)
.subscribe((playlists) => {
console.log('playlist was updated...');
window.postMessage({
type: PLAYLIST_GET_ALL_RESPONSE,
payload: playlists,
});
});
});
}
}
/**
* Fetches playlist from the specified url
* @param payload playlist payload
*/
fetchFromUrl(payload: Partial<Playlist>): void {
this.http
.get(payload.url, { responseType: 'text' })
.subscribe((response: any) => {
const parsedPlaylist =
this.convertFileStringToPlaylist(response);
const playlistObject = this.createPlaylistObject(
payload.title,
parsedPlaylist,
payload.url,
'URL'
);
// save to db
this.dbService
.add(DbStores.Playlists, playlistObject)
.subscribe(() => {
console.log('playlist was added...');
});
window.postMessage({
type: PLAYLIST_PARSE_RESPONSE,
payload: playlistObject,
});
});
}
/**
* Converts file string to playlist object
* @param m3uString
*/
convertFileStringToPlaylist(m3uString: string): ParsedPlaylist {
return this.parsePlaylist(m3uString.split('\n'));
}
/**
* Saves playlist to the localStorage
* @param name name of the playlist
* @param playlist playlist to save
* @param urlOrPath absolute fs path or url of the playlist
* @param uploadType upload type - by file or via an url
*/
createPlaylistObject(
name: string,
playlist: ParsedPlaylist,
urlOrPath?: string,
uploadType?: 'URL' | 'FILE'
): Playlist {
const id = guid();
return {
id,
_id: id,
filename: name,
title: name,
count: playlist.items.length,
playlist: {
...playlist,
items: playlist.items.map((item) => ({
id: guid(),
...item,
})),
},
importDate: new Date().toISOString(),
lastUsage: new Date().toISOString(),
favorites: [],
autoRefresh: false,
...(uploadType === 'URL' ? { url: urlOrPath } : {}),
...(uploadType === 'FILE' ? { filePath: urlOrPath } : {}),
};
}
/**
* Parses string based array to playlist object
* @param m3uArray m3u playlist as array with strings
*/
parsePlaylist(m3uArray: string[]): ParsedPlaylist {
const playlistAsString = m3uArray.join('\n');
return parse(playlistAsString);
}
removeAllListeners(): void {
// not implemented
}
listenOn(command: string, callback: (...args: any[]) => void): void {
window.addEventListener('message', callback);
}
}
+7 -3
View File
@@ -23,7 +23,7 @@ describe('Service: Settings', () => {
[SettingsService, StorageMap],
(service: SettingsService, storage: StorageMap) => {
const version = '2.1.0';
spyOn(storage, 'set').and.returnValue(of([]));
jest.spyOn(storage, 'set').mockReturnValue(of([] as any));
service.setValueToLocalStorage(STORE_KEY.Version, version);
expect(storage.set).toHaveBeenCalledWith(
STORE_KEY.Version,
@@ -48,10 +48,14 @@ describe('Service: Settings', () => {
describe('Test theme switch', () => {
let spyOnAdd, spyOnRemove;
beforeEach(() => {
spyOnAdd = spyOn(document.body.classList, 'add');
spyOnRemove = spyOn(document.body.classList, 'remove');
spyOnAdd = jest.spyOn(document.body.classList, 'add');
spyOnRemove = jest.spyOn(document.body.classList, 'remove');
});
/* afterEach(() => {
jest.clearAllMocks();
}); */
it('should switch to the dark theme', inject(
[SettingsService],
(service: SettingsService) => {
+1 -1
View File
@@ -16,7 +16,7 @@ describe('Service: WhatsNew', () => {
it('should change the state', inject(
[WhatsNewService],
(service: WhatsNewService) => {
spyOn(service.dialogState$, 'next');
jest.spyOn(service.dialogState$, 'next');
service.changeDialogVisibleState(true);
expect(service.dialogState$.next).toHaveBeenCalledTimes(1);
expect(service.dialogState$.next).toHaveBeenCalledWith(true);
+2 -1
View File
@@ -11,7 +11,7 @@
</mat-card-header>
<mat-card-content>
<mat-list>
<mat-list-item>
<mat-list-item *ngIf="isElectron">
<div matLine fxLayout="row">
<p fxFlex>{{ 'SETTINGS.EPG_URL_LABEL' | translate }}</p>
<p fxFlex fxLayoutAlign="start center">
@@ -138,6 +138,7 @@
</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-card-actions>
+16 -16
View File
@@ -1,7 +1,7 @@
import { MatCheckboxModule } from '@angular/material/checkbox';
import { StorageMap } from '@ngx-pwa/local-storage';
/* eslint-disable @typescript-eslint/unbound-method */
import { ElectronService } from './../services/electron.service';
import { DataService } from '../services/data.service';
import { ElectronServiceStub } from '../services/electron.service.stub';
import { MatSelectModule } from '@angular/material/select';
import { MatFormFieldModule } from '@angular/material/form-field';
@@ -47,7 +47,7 @@ const DEFAULT_SETTINGS = {
describe('SettingsComponent', () => {
let component: SettingsComponent;
let fixture: ComponentFixture<SettingsComponent>;
let electronService: ElectronService;
let electronService: DataService;
let router: Router;
let storage: StorageMap;
let translate: TranslateService;
@@ -67,7 +67,7 @@ describe('SettingsComponent', () => {
provide: TranslateService,
useClass: TranslateServiceStub,
},
{ provide: ElectronService, useClass: ElectronServiceStub },
{ provide: DataService, useClass: ElectronServiceStub },
{
provide: Router,
useClass: MockRouter,
@@ -93,7 +93,7 @@ describe('SettingsComponent', () => {
beforeEach(() => {
fixture = TestBed.createComponent(SettingsComponent);
electronService = TestBed.inject(ElectronService);
electronService = TestBed.inject(DataService);
storage = TestBed.inject(StorageMap);
router = TestBed.inject(Router);
translate = TestBed.inject(TranslateService);
@@ -110,12 +110,12 @@ describe('SettingsComponent', () => {
let spyOnStorageGet;
beforeEach(() => {
spyOnStorageGet = spyOn(storage, 'get');
spyOnStorageGet = jest.spyOn(storage, 'get');
});
it('should init default settings if previous config was not saved', () => {
spyOnStorageGet.and.returnValue(of(null));
spyOn(component.settingsForm, 'setValue');
spyOnStorageGet.mockReturnValue(of(null));
jest.spyOn(component.settingsForm, 'setValue');
component.ngOnInit();
expect(storage.get).toHaveBeenCalled();
expect(component.settingsForm.setValue).toHaveBeenCalledTimes(0);
@@ -123,14 +123,14 @@ describe('SettingsComponent', () => {
});
it('should call set value function if custom config exists', () => {
spyOnStorageGet.and.returnValue(of(settings));
spyOn(component.settingsForm, 'setValue');
spyOnStorageGet.mockReturnValue(of(settings));
jest.spyOn(component.settingsForm, 'setValue');
component.ngOnInit();
expect(component.settingsForm.setValue).toHaveBeenCalled();
});
it('should get and apply custom settings', () => {
spyOnStorageGet.and.returnValue(of(settings));
spyOnStorageGet.mockReturnValue(of(settings));
component.ngOnInit();
expect(storage.get).toHaveBeenCalled();
expect(component.settingsForm.value).toEqual({
@@ -145,7 +145,7 @@ describe('SettingsComponent', () => {
const currentVersion = '0.1.0';
it('should return true if version is outdated', () => {
spyOn(electronService, 'getAppVersion').and.returnValue(
jest.spyOn(electronService, 'getAppVersion').mockReturnValue(
currentVersion
);
const isOutdated =
@@ -154,8 +154,8 @@ describe('SettingsComponent', () => {
});
it('should update notification message if version is outdated', () => {
spyOn(translate, 'instant');
spyOn(electronService, 'getAppVersion').and.returnValue(
jest.spyOn(translate, 'instant');
jest.spyOn(electronService, 'getAppVersion').mockReturnValue(
currentVersion
);
component.showVersionInformation(currentVersion);
@@ -165,7 +165,7 @@ describe('SettingsComponent', () => {
});
it('should send epg fetch command', () => {
spyOn(electronService, 'sendIpcEvent');
jest.spyOn(electronService, 'sendIpcEvent');
component.fetchEpg();
expect(electronService.sendIpcEvent).toHaveBeenCalledWith(EPG_FETCH, {
url: '',
@@ -173,13 +173,13 @@ describe('SettingsComponent', () => {
});
it('should navigate back to home page', () => {
spyOn(router, 'navigateByUrl');
jest.spyOn(router, 'navigateByUrl');
component.backToHome();
expect(router.navigateByUrl).toHaveBeenCalledTimes(1);
});
it('should save settings on submit', () => {
spyOn(storage, 'set').and.returnValue(of([]));
jest.spyOn(storage, 'set').mockReturnValue(of([] as any));
component.onSubmit();
expect(storage.set).toHaveBeenCalled();
});
+5 -2
View File
@@ -9,13 +9,13 @@ import { STORE_KEY } from '../shared/enums/store-keys.enum';
import { Settings, VideoPlayer } from './settings.interface';
import { HttpClient } from '@angular/common/http';
import * as semver from 'semver';
import { ElectronService } from '../services/electron.service';
import { ChannelQuery } from '../state';
import { EPG_FETCH } from '../../../shared/ipc-commands';
import { Language } from './language.enum';
import { Theme } from './theme.enum';
import { SettingsService } from './../services/settings.service';
import { catchError } from 'rxjs/operators';
import { DataService } from '../services/data.service';
/** Url of the package.json file in the app repository, required to get the version of the released app */
const PACKAGE_JSON_URL =
@@ -56,6 +56,9 @@ export class SettingsComponent implements OnInit {
(store) => store.epgAvailable
);
/** Flag that indicates whether the app runs in electron environment */
isElectron = this.electronService.isElectron;
/** All available visual themes */
themeEnum = Theme;
@@ -74,7 +77,7 @@ export class SettingsComponent implements OnInit {
*/
constructor(
private channelQuery: ChannelQuery,
private electronService: ElectronService,
private electronService: DataService,
private formBuilder: FormBuilder,
private http: HttpClient,
private router: Router,
+12 -14
View File
@@ -1,13 +1,14 @@
import { Injectable } from '@angular/core';
import { EntityState, EntityStore, StoreConfig } from '@datorama/akita';
import * as moment from 'moment';
import {
CHANNEL_SET_USER_AGENT,
EPG_GET_PROGRAM,
PLAYLIST_UPDATE_FAVORITES,
} from '../../../shared/ipc-commands';
import { ElectronService } from '../services/electron.service';
import { Channel } from './channel.model';
import * as moment from 'moment';
import { EpgProgram } from '../player/models/epg-program.model';
import { DataService } from '../services/data.service';
import { Channel } from './channel.model';
export interface ChannelState extends EntityState<Channel> {
active: Channel;
@@ -24,7 +25,7 @@ export class ChannelStore extends EntityStore<ChannelState> {
* Creates an instance of ChannelStore
* @param electronService electron service
*/
constructor(private electronService: ElectronService) {
constructor(private electronService: DataService) {
super({
active: undefined,
currentEpgProgram: undefined,
@@ -48,7 +49,7 @@ export class ChannelStore extends EntityStore<ChannelState> {
} else {
favorites = [...store.favorites, channel.id];
}
this.electronService.ipcRenderer.send('update-favorites', {
this.electronService.sendIpcEvent(PLAYLIST_UPDATE_FAVORITES, {
id: store.playlistId,
favorites,
});
@@ -63,17 +64,14 @@ export class ChannelStore extends EntityStore<ChannelState> {
setActiveChannel(channel: Channel): void {
this.update((store) => {
if (store.epgAvailable) {
this.electronService.ipcRenderer.send(EPG_GET_PROGRAM, {
this.electronService.sendIpcEvent(EPG_GET_PROGRAM, {
channel,
});
if (channel.http['user-agent']) {
this.electronService.ipcRenderer.send(
CHANNEL_SET_USER_AGENT,
{
referer: channel.http.referrer,
userAgent: channel.http['user-agent'],
}
);
this.electronService.sendIpcEvent(CHANNEL_SET_USER_AGENT, {
referer: channel.http.referrer,
userAgent: channel.http['user-agent'],
});
}
}
return {
@@ -117,7 +115,7 @@ export class ChannelStore extends EntityStore<ChannelState> {
setEpgAvailableFlag(value: boolean): void {
this.update((store) => {
if (store.active && store.active.name) {
this.electronService.ipcRenderer.send(EPG_GET_PROGRAM, {
this.electronService.sendIpcEvent(EPG_GET_PROGRAM, {
channelName: store.active.name,
});
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 91 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 131 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

+1
View File
@@ -6,4 +6,5 @@
export const AppConfig = {
production: false,
environment: 'DEV',
version: require('../../package.json').version,
};
+1
View File
@@ -1,4 +1,5 @@
export const AppConfig = {
production: true,
environment: 'PROD',
version: require('../../package.json').version,
};
+1
View File
@@ -1,4 +1,5 @@
export const AppConfig = {
production: false,
environment: 'LOCAL',
version: require('../../package.json').version,
};
+5
View File
@@ -0,0 +1,5 @@
export const AppConfig = {
production: false,
environment: 'WEB',
version: require('../../package.json').version,
};
+7
View File
@@ -6,7 +6,14 @@
<base href="/" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="manifest" href="manifest.webmanifest">
<link rel="apple-touch-icon" href="assets/icons/apple-touch-icon.png"/>
<link rel="icon" type="image/x-icon" href="assets/icons/favicon.ico" />
<script>
if (global === undefined) {
var global = window;
}
</script>
</head>
<body class="mat-app-background">
<app-root></app-root>
+34
View File
@@ -0,0 +1,34 @@
{
"name": "iptvnator",
"description": "IPTVnator is a tool to watch and manage your IPTV playlists.",
"short_name": "iptvnator",
"theme_color": "#000",
"background_color": "#fafafa",
"display": "standalone",
"scope": "./",
"start_url": "./",
"icons": [
{
"src": "assets/icons/android-chrome-192x192.png",
"sizes": "192x192",
"type": "image/png"
},
{
"src": "assets/icons/android-chrome-512x512.png",
"sizes": "512x512",
"type": "image/png"
},
{
"src": "assets/icons/android-chrome-maskable-192x192.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "maskable"
},
{
"src": "assets/icons/android-chrome-maskable-512x512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "maskable"
}
]
}
-1
View File
@@ -1,2 +1 @@
import 'core-js/es/reflect';
import 'zone.js';
+1 -12
View File
@@ -18,16 +18,6 @@
* BROWSER POLYFILLS
*/
/** IE10 and IE11 requires the following for NgClass support on SVG elements */
// import 'classlist.js'; // Run `npm install --save classlist.js`.
/**
* Web Animations `@angular/platform-browser/animations`
* Only required if AnimationBuilder is used within the application and using IE/Edge or Safari.
* Standard animation support in Angular DOES NOT require any polyfills (as of Angular 6.0).
*/
// import 'web-animations-js'; // Run `npm install --save web-animations-js`.
/**
* By default, zone.js will patch all possible macroTask and DomEvents
* user can disable parts of macroTask/DomEvents patch by setting following flags
@@ -55,8 +45,7 @@
/***************************************************************************************************
* Zone JS is required by default for Angular itself.
*/
import 'zone.js'; // Included with Angular CLI.
import 'zone.js'; // Included with Angular CLI.
/***************************************************************************************************
* APPLICATION IMPORTS
+1
View File
@@ -0,0 +1 @@
import 'jest-preset-angular/setup-jest';
-1
View File
@@ -1 +0,0 @@
import 'jest-preset-angular';
+2 -2
View File
@@ -49,11 +49,11 @@ textarea,
.dark-theme {
::-webkit-scrollbar {
overflow-y: scroll;
background: rgb(100, 100, 100);
background: rgb(100 100 100);
}
::-webkit-scrollbar-thumb {
background-color: rgb(60, 60, 60);
background-color: rgb(60 60 60);
}
}
+2 -1
View File
@@ -20,7 +20,8 @@
"api.ts",
"epg-worker.ts",
"main.ts",
"menu.ts"
"menu.ts",
"preload.ts"
],
"exclude": [
"node_modules",