59 changes: 48 additions & 11 deletions api.ts → electron/api.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,15 @@
import axios from 'axios';
import { app, BrowserWindow, ipcMain, session } from 'electron';
import { parse } from 'iptv-playlist-parser';
import { GLOBAL_FAVORITES_PLAYLIST_ID } from './shared/constants';
import { GLOBAL_FAVORITES_PLAYLIST_ID } from '../shared/constants';
import {
CHANNEL_SET_USER_AGENT,
EPG_ERROR,
EPG_FETCH,
EPG_FETCH_DONE,
EPG_GET_CHANNELS,
EPG_GET_CHANNELS_BY_RANGE,
EPG_GET_CHANNELS_BY_RANGE_RESPONSE,
EPG_GET_CHANNELS_DONE,
EPG_GET_PROGRAM,
EPG_GET_PROGRAM_DONE,
Expand All @@ -19,20 +21,21 @@ import {
PLAYLIST_PARSE,
PLAYLIST_PARSE_BY_URL,
PLAYLIST_PARSE_RESPONSE,
PLAYLIST_PARSE_TEXT,
PLAYLIST_REMOVE_BY_ID,
PLAYLIST_REMOVE_BY_ID_RESPONSE,
PLAYLIST_SAVE_DETAILS,
PLAYLIST_UPDATE,
PLAYLIST_UPDATE_FAVORITES,
PLAYLIST_UPDATE_POSITIONS,
PLAYLIST_UPDATE_RESPONSE,
} from './shared/ipc-commands';
import { Playlist, PlaylistUpdateState } from './shared/playlist.interface';
} from '../shared/ipc-commands';
import { Playlist, PlaylistUpdateState } from '../shared/playlist.interface';
import {
aggregateFavoriteChannels,
createFavoritesPlaylist,
} from './shared/playlist.utils';
import { ParsedPlaylist } from './src/typings.d';
} from '../shared/playlist.utils';
import { ParsedPlaylist } from '../src/typings.d';

const Nedb = require('nedb-promises');

Expand Down Expand Up @@ -91,6 +94,27 @@ export class Api {
}
});

ipcMain.on(PLAYLIST_PARSE_TEXT, (event, args) => {
try {
const parsedPlaylist = this.parsePlaylist(
args.text.split('\n')
);
const playlistObject = this.createPlaylistObject(
'Imported as text',
parsedPlaylist
);
this.insertToDb(playlistObject);
event.sender.send(PLAYLIST_PARSE_RESPONSE, {
payload: playlistObject,
});
} catch (err) {
event.sender.send(ERROR, {
message: err.response.statusText,
status: err.response.status,
});
}
});

ipcMain.on(PLAYLIST_PARSE, (event, args) => {
const parsedPlaylist = this.parsePlaylist(args.playlist);
const playlistObject = this.createPlaylistObject(
Expand Down Expand Up @@ -192,6 +216,18 @@ export class Api {
)
.on(EPG_ERROR, (event, arg) =>
this.mainWindow.webContents.send(EPG_ERROR, arg)
)
.on(EPG_GET_CHANNELS_BY_RANGE, (event, arg) => {
this.workerWindow.webContents.send(
EPG_GET_CHANNELS_BY_RANGE,
arg
);
})
.on(EPG_GET_CHANNELS_BY_RANGE_RESPONSE, (event, arg) =>
this.mainWindow.webContents.send(
EPG_GET_CHANNELS_BY_RANGE_RESPONSE,
arg
)
);

ipcMain.on(
Expand Down Expand Up @@ -244,7 +280,7 @@ export class Api {
PLAYLIST_UPDATE_POSITIONS,
(event, playlists: Partial<Playlist[]>) =>
playlists.forEach((list, index) => {
this.updatePlaylistById(list._id, {
this.updatePlaylistById((list as Playlist)._id, {
...list,
position: index,
});
Expand Down Expand Up @@ -369,7 +405,7 @@ export class Api {
session.defaultSession.webRequest.onBeforeSendHeaders(
(details, callback) => {
details.requestHeaders['User-Agent'] = userAgent;
details.requestHeaders['Referer'] = referer;
details.requestHeaders['Referer'] = referer as string;
callback({ requestHeaders: details.requestHeaders });
}
);
Expand Down Expand Up @@ -505,10 +541,11 @@ export class Api {
this.updatePlaylistById(id, {
updateState: PlaylistUpdateState.NOT_UPDATED,
});
event.sender.send(ERROR, {
message: `File not found. Please check the entered playlist URL again.`,
status: err.response.status,
});
if (event)
event.sender.send(ERROR, {
message: `File not found. Please check the entered playlist URL again.`,
status: err.response.status,
});
}
}

Expand Down
File renamed without changes.
56 changes: 51 additions & 5 deletions epg-worker.ts → electron/epg-worker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,15 +8,23 @@ import {
EPG_FETCH,
EPG_FETCH_DONE,
EPG_GET_CHANNELS,
EPG_GET_CHANNELS_BY_RANGE,
EPG_GET_CHANNELS_BY_RANGE_RESPONSE,
EPG_GET_CHANNELS_DONE,
EPG_GET_PROGRAM,
EPG_GET_PROGRAM_DONE,
} from './shared/ipc-commands';
import { EpgChannel } from './src/app/player/models/epg-channel.model';
import { EpgProgram } from './src/app/player/models/epg-program.model';
} from '../shared/ipc-commands';
import { EpgChannel } from '../src/app/player/models/epg-channel.model';
import { EpgProgram } from '../src/app/player/models/epg-program.model';

// EPG data store
let EPG_DATA: { channels: EpgChannel[]; programs: EpgProgram[] };
let EPG_DATA: { channels: EpgChannel[]; programs: EpgProgram[] } = {
channels: [],
programs: [],
};
let EPG_DATA_MERGED: {
[id: string]: EpgChannel & { programs: EpgProgram[] };
} = {};
const loggerLabel = '[EPG Worker]';

/**
Expand Down Expand Up @@ -61,11 +69,41 @@ const fetchEpgDataFromUrl = (epgUrl: string) => {
*/
const parseAndSetEpg = (xmlString) => {
console.log(loggerLabel, 'start parsing...');
EPG_DATA = parser.parse(xmlString.toString());
const parsedEpg = parser.parse(xmlString.toString());
EPG_DATA = {
channels: [...EPG_DATA.channels, ...parsedEpg.channels],
programs: [...EPG_DATA.programs, ...parsedEpg.programs],
};
// map programs to channels
EPG_DATA_MERGED = convertEpgData();
ipcRenderer.send(EPG_FETCH_DONE);
console.log(loggerLabel, 'done, parsing was finished...');
};

const convertEpgData = () => {
let result: {
[id: string]: EpgChannel & { programs: EpgProgram[] };
} = {};

EPG_DATA?.programs?.forEach((program) => {
if (!result[program.channel]) {
const channel = EPG_DATA?.channels?.find(
(channel) => channel.id === program.channel
) as EpgChannel;
result[program.channel] = {
...channel,
programs: [program],
};
} else {
result[program.channel] = {
...result[program.channel],
programs: [...result[program.channel].programs, program],
};
}
});
return result;
};

// fetches epg data from the provided URL
ipcRenderer.on(EPG_FETCH, (event, arg) => {
console.log(loggerLabel, 'epg fetch command was triggered');
Expand Down Expand Up @@ -113,3 +151,11 @@ ipcRenderer.on(EPG_GET_CHANNELS, (event, args) => {
payload: EPG_DATA,
});
});

ipcRenderer.on(EPG_GET_CHANNELS_BY_RANGE, (event, args) => {
ipcRenderer.send(EPG_GET_CHANNELS_BY_RANGE_RESPONSE, {
payload: Object.entries(EPG_DATA_MERGED)
.slice(args.skip, args.limit)
.map((entry) => entry[1]),
});
});
16 changes: 10 additions & 6 deletions main.ts → electron/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,14 @@ import * as path from 'path';
import * as url from 'url';
import { Api } from './api';
import { AppMenu } from './menu';
const {
setupTitlebar,
attachTitlebarToWindow,
} = require('custom-electron-titlebar/main');
const contextMenu = require('electron-context-menu');

let win: BrowserWindow = null;
setupTitlebar();
let win: BrowserWindow | null = null;
const args = process.argv.slice(1),
serve = args.some((val) => val === '--serve');

Expand All @@ -28,13 +33,14 @@ function createWindow(): BrowserWindow {
},
resizable: true,
darkTheme: true,
icon: path.join(__dirname, 'dist/assets/icons/icon.png'),
icon: path.join(__dirname, '../build/assets/icons/icon.png'),
titleBarStyle: 'hidden',
frame: false,
minWidth: 900,
minHeight: 700,
title: 'IPTVnator',
});
attachTitlebarToWindow(win);

require('@electron/remote/main').enable(win.webContents);

Expand All @@ -44,7 +50,7 @@ function createWindow(): BrowserWindow {
} else {
win.loadURL(
url.format({
pathname: path.join(__dirname, 'dist/index.html'),
pathname: path.join(__dirname, '../build/index.html'),
protocol: 'file:',
slashes: true,
})
Expand Down Expand Up @@ -79,11 +85,9 @@ function createEpgWorkerWindow() {
},
});

window.loadFile('./electron/epg-worker.html');
if (serve) {
window.loadFile('epg-worker.html');
window.webContents.openDevTools();
} else {
window.loadFile('epg-worker.html');
}

window.once('ready-to-show', () => {
Expand Down
6 changes: 3 additions & 3 deletions menu.ts → electron/menu.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import {
SHOW_WHATS_NEW,
VIEW_ADD_PLAYLIST,
VIEW_SETTINGS,
} from './shared/ipc-commands';
} from '../shared/ipc-commands';
const openAboutWindow = require('about-window').default;

export class AppMenu {
Expand Down Expand Up @@ -111,9 +111,9 @@ export class AppMenu {
openAboutWindow({
icon_path: path.join(
__dirname,
'dist/assets/icons/icon.png'
'build/assets/icons/icon.png'
),
copyright: 'Copyright (c) 2020-2021 4gray',
copyright: 'Copyright (c) 2020-2022 4gray',
package_json_dir: __dirname,
}),
},
Expand Down
7 changes: 7 additions & 0 deletions electron/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"name": "iptvnator-electron",
"version": "0.10.0",
"main": "main.js",
"private": true,
"dependencies": {}
}
File renamed without changes.
2 changes: 1 addition & 1 deletion jest.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ module.exports = {
preset: 'jest-preset-angular',
resetMocks: true,
setupFilesAfterEnv: ['<rootDir>/src/setup-jest.ts'],
testMatch: ['**/+(*.)+(spec|test).+(ts)?(x)'],
testMatch: ['**/+(*.)+(spec).+(ts)?(x)'],
coverageReporters: ['html', 'lcov'],
transformIgnorePatterns: ['node_modules/(?!.*.mjs$|@datorama/akita)'],
};
25,136 changes: 15,441 additions & 9,695 deletions package-lock.json

Large diffs are not rendered by default.

97 changes: 51 additions & 46 deletions package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "iptvnator",
"version": "0.10.0",
"version": "0.11.0",
"description": "IPTV player application.",
"homepage": "https://github.com/4gray/iptvnator",
"author": {
Expand All @@ -18,13 +18,14 @@
"video",
"tv"
],
"main": "main.js",
"main": "electron/main.js",
"private": true,
"scripts": {
"postinstall": "electron-builder install-app-deps && husky install",
"ng": "ng",
"start": "npm-run-all -p electron:serve ng:serve",
"build": "npm run electron:serve-tsc && ng build --base-href ./",
"build:electron": "npm run electron:serve-tsc",
"build:dev": "npm run build -- -c dev",
"build:prod": "npm run build -- -c production",
"ng:build": "ng build -c production",
Expand All @@ -39,74 +40,75 @@
"electron:build:windows": "npm run build:prod && electron-builder build -w",
"test": "jest",
"test:watch": "jest --watch",
"e2e": "npm run build:prod && cross-env TS_NODE_PROJECT='e2e/tsconfig.e2e.json' mocha e2e/**/*.ts --config ./.mocharc.json --exit",
"e2e:watch": "npm run e2e -- --watch",
"e2e": "npm run build:electron && npx playwright test -c e2e/playwright.config.ts e2e/",
"e2e:debug": "npm run build:electron && PWDEBUG=1 npx playwright test -c e2e/playwright.config.ts e2e/",
"version": "conventional-changelog -i CHANGELOG.md -p angular -s -r 0 && git add CHANGELOG.md",
"lint": "ng lint",
"build-pwa": "ng build -c production --base-href /iptvnator --deploy-url /iptvnator/"
},
"dependencies": {
"@angular/animations": "13.0.2",
"@angular/cdk": "13.0.2",
"@angular/flex-layout": "13.0.0-beta.36",
"@angular/material": "13.0.2",
"@angular/animations": "14.0.6",
"@angular/cdk": "14.0.5",
"@angular/flex-layout": "14.0.0-beta.40",
"@angular/material": "14.0.5",
"@datorama/akita": "7.1.1",
"@electron/remote": "2.0.1",
"@ngx-pwa/local-storage": "13.0.2",
"@electron/remote": "2.0.8",
"@ngx-pwa/local-storage": "13.0.5",
"@videojs/http-streaming": "2.13.1",
"@yangkghjh/videojs-aspect-ratio-panel": "0.0.1",
"about-window": "1.13.4",
"about-window": "1.15.2",
"axios": "0.21.2",
"custom-electron-titlebar": "https://github.com/4gray/custom-electron-titlebar.git#testing",
"custom-electron-titlebar": "4.1.0",
"date-fns": "2.28.0",
"electron-context-menu": "3.1.1",
"epg-parser": "0.1.1",
"hls.js": "1.0.11",
"hls.js": "1.2.1",
"iptv-playlist-parser": "0.8.0",
"lodash": "4.17.21",
"material-icons": "0.3.1",
"moment": "2.29.2",
"moment": "2.29.4",
"nedb-promises": "6.0.3",
"ngx-filter-pipe": "2.1.2",
"ngx-indexed-db": "9.4.2",
"ngx-uploader": "11.0.0",
"ngx-whats-new": "0.1.0",
"rxjs": "7.5.1",
"ngx-whats-new": "0.3.0",
"rxjs": "7.5.6",
"semver": "7.3.5",
"uuid": "8.3.2",
"video.js": "7.17.0",
"video.js": "7.20.2",
"videojs-contrib-quality-levels": "2.1.0",
"videojs-hls-quality-selector": "1.1.4",
"zone.js": "~0.11.4"
},
"devDependencies": {
"@angular-builders/custom-webpack": "13.0.0",
"@angular-devkit/build-angular": "13.2.0",
"@angular-eslint/builder": "13.0.1",
"@angular-eslint/eslint-plugin": "13.0.1",
"@angular-eslint/eslint-plugin-template": "13.0.1",
"@angular-eslint/schematics": "13.0.1",
"@angular-eslint/template-parser": "13.0.1",
"@angular/cli": "13.0.2",
"@angular/common": "13.0.2",
"@angular/compiler": "13.0.2",
"@angular/compiler-cli": "13.0.2",
"@angular/core": "13.0.2",
"@angular/forms": "13.0.2",
"@angular/language-service": "13.0.2",
"@angular/platform-browser": "13.0.2",
"@angular/platform-browser-dynamic": "13.0.2",
"@angular/router": "13.0.2",
"@angular/service-worker": "13.0.2",
"@angular-builders/custom-webpack": "14.0.0",
"@angular-devkit/build-angular": "14.0.6",
"@angular-eslint/builder": "14.0.2",
"@angular-eslint/eslint-plugin": "14.0.2",
"@angular-eslint/eslint-plugin-template": "14.0.2",
"@angular-eslint/schematics": "14.0.2",
"@angular-eslint/template-parser": "14.0.2",
"@angular/cli": "14.0.6",
"@angular/common": "14.0.6",
"@angular/compiler": "14.0.6",
"@angular/compiler-cli": "14.0.6",
"@angular/core": "14.0.6",
"@angular/forms": "14.0.6",
"@angular/language-service": "14.0.6",
"@angular/platform-browser": "14.0.6",
"@angular/platform-browser-dynamic": "14.0.6",
"@angular/router": "14.0.6",
"@angular/service-worker": "14.0.6",
"@commitlint/cli": "16.0.1",
"@commitlint/config-conventional": "16.0.0",
"@ngx-translate/core": "13.0.0",
"@ngx-translate/http-loader": "6.0.0",
"@ngx-translate/core": "14.0.0",
"@ngx-translate/http-loader": "7.0.0",
"@playwright/test": "1.21.1",
"@semantic-release/changelog": "6.0.1",
"@semantic-release/git": "10.0.1",
"@semantic-release/npm": "9.0.0",
"@types/chai": "4.3.0",
"@types/jest": "27.4.0",
"@types/jest": "28.1.5",
"@types/mocha": "9.0.0",
"@types/node": "16.11.7",
"@types/video.js": "7.3.29",
Expand All @@ -118,28 +120,31 @@
"conventional-changelog-cli": "2.2.2",
"core-js": "3.6.5",
"cross-env": "7.0.3",
"electron": "16.0.6",
"electron-builder": "22.14.5",
"electron": "20.0.2",
"electron-builder": "23.3.3",
"eslint": "7.32.0",
"eslint-plugin-import": "2.25.2",
"husky": "5.0.9",
"jest": "27.5.1",
"jest-preset-angular": "11.1.1",
"ng-mocks": "12.5.1",
"jest": "28.1.3",
"jest-preset-angular": "12.2.0",
"ng-mocks": "14.0.2",
"node-polyfill-webpack-plugin": "1.1.4",
"npm-run-all": "4.1.5",
"playwright": "1.21.1",
"playwright-core": "1.21.1",
"prettier": "2.5.1",
"semantic-release": "19.0.2",
"semantic-release": "19.0.3",
"stylelint": "14.2.0",
"stylelint-config-standard": "24.0.0",
"stylelint-scss": "4.1.0",
"ts-jest": "28.0.6",
"ts-node": "8.10.2",
"tslib": "2.3.0",
"typescript": "4.4.4",
"typescript": "4.7.4",
"wait-on": "6.0.0"
},
"engines": {
"node": ">=12.18.4"
"node": ">=14.0.0"
},
"browserslist": [
"chrome 91"
Expand Down
4 changes: 4 additions & 0 deletions shared/ipc-commands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,9 @@ export const EPG_GET_PROGRAM = 'EPG:GET_PROGRAM';
export const EPG_GET_PROGRAM_DONE = 'EPG:GET_PROGRAM_DONE';
export const EPG_GET_CHANNELS = 'EPG:GET_CHANNELS';
export const EPG_GET_CHANNELS_DONE = 'EPG:GET_CHANNELS_DONE';
export const EPG_GET_CHANNELS_BY_RANGE = 'EPG:EPG_GET_CHANNELS_BY_RANGE';
export const EPG_GET_CHANNELS_BY_RANGE_RESPONSE =
'EPG:EPG_GET_CHANNELS_BY_RANGE_RESPONSE';

// Playlist related commands
export const PLAYLIST_GET_ALL = 'PLAYLIST:GET_ALL';
Expand All @@ -14,6 +17,7 @@ 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_TEXT = 'PLAYLIST:PLAYLIST_PARSE_TEXT';
export const PLAYLIST_PARSE_RESPONSE = 'PLAYLIST:PARSE_PLAYLIST_RESPONSE';
export const PLAYLIST_UPDATE = 'PLAYLIST:UPDATE';
export const PLAYLIST_UPDATE_RESPONSE = 'PLAYLIST:UPDATE_RESPONSE';
Expand Down
32 changes: 7 additions & 25 deletions src/app/app.component.spec.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { HttpClientTestingModule } from '@angular/common/http/testing';
import {
ComponentFixture,
inject,
Expand All @@ -17,9 +18,9 @@ import { ElectronServiceStub } from './services/electron.service.stub';
import { SettingsService } from './services/settings.service';
import { WhatsNewService } from './services/whats-new.service';
import { WhatsNewServiceStub } from './services/whats-new.service.stub';
import { Language } from './settings/language.enum';
import { Theme } from './settings/theme.enum';
import { STORE_KEY } from './shared/enums/store-keys.enum';
import { ChannelStore } from './state';

class MatSnackBarStub {
open(): void {}
Expand Down Expand Up @@ -54,6 +55,7 @@ describe('AppComponent', () => {
MockModule(MatSnackBarModule),
MockModule(NgxWhatsNewModule),
RouterTestingModule,
HttpClientTestingModule,
],
}).compileComponents();
})
Expand All @@ -76,7 +78,7 @@ describe('AppComponent', () => {
const fixture = TestBed.createComponent(AppComponent);
const app = fixture.debugElement.componentInstance;
expect(app).toBeTruthy();
expect(component.commandsList.length).toEqual(5);
expect(component.DEFAULT_LANG).toEqual(Language.ENGLISH);
});

it('should init component', () => {
Expand All @@ -97,7 +99,9 @@ describe('AppComponent', () => {
it('should set IPC listeners', () => {
jest.spyOn(electronService, 'listenOn');
component.setRendererListeners();
expect(electronService.listenOn).toHaveBeenCalledTimes(5);
expect(electronService.listenOn).toHaveBeenCalledTimes(
component.commandsList.length
);
});

it('should remove all ipc listeners on destroy', () => {
Expand All @@ -117,28 +121,6 @@ describe('AppComponent', () => {
}
));

it('should show a notification on epg error', inject(
[MatSnackBar],
(snackbar: MatSnackBar) => {
jest.spyOn(snackbar, 'open');
component.onEpgError();
expect(snackbar.open).toHaveBeenCalledTimes(1);
}
));

it('should handle epg download success', inject(
[MatSnackBar, ChannelStore],
(snackbar: MatSnackBar, channelStore: ChannelStore) => {
jest.spyOn(snackbar, 'open');
jest.spyOn(channelStore, 'setEpgAvailableFlag');
component.onEpgFetchDone();
expect(snackbar.open).toHaveBeenCalledTimes(1);
expect(channelStore.setEpgAvailableFlag).toHaveBeenCalledWith(
true
);
}
));

it('show show whats new dialog', () => {
jest.spyOn(whatsNewService, 'getModalsByVersion');
jest.spyOn(component, 'setDialogVisibility');
Expand Down
64 changes: 18 additions & 46 deletions src/app/app.component.ts
Original file line number Diff line number Diff line change
@@ -1,27 +1,27 @@
import { Component, NgZone } from '@angular/core';
import { MatSnackBar, MatSnackBarConfig } from '@angular/material/snack-bar';
import { MatSnackBar } from '@angular/material/snack-bar';
import { Router } from '@angular/router';
import { TranslateService } from '@ngx-translate/core';
import { ModalWindow } from 'ngx-whats-new/lib/modal-window.interface';
import * as semver from 'semver';
import { IpcCommand } from '../../shared/ipc-command.class';
import {
EPG_ERROR,
EPG_FETCH,
EPG_FETCH_DONE,
ERROR,
OPEN_FILE,
SHOW_WHATS_NEW,
VIEW_ADD_PLAYLIST,
VIEW_SETTINGS,
} from '../../shared/ipc-commands';
import { DataService } from './services/data.service';
import { EpgService } from './services/epg.service';
import { SettingsService } from './services/settings.service';
import { WhatsNewService } from './services/whats-new.service';
import { Language } from './settings/language.enum';
import { Settings } from './settings/settings.interface';
import { Theme } from './settings/theme.enum';
import { STORE_KEY } from './shared/enums/store-keys.enum';
import { ChannelStore } from './state';

/**
* AppComponent
Expand All @@ -40,19 +40,23 @@ export class AppComponent {
/** Modals to show for the updated version of the application */
modals: ModalWindow[] = [];

/** Default options for epg snackbar notifications */
epgSnackBarOptions: MatSnackBarConfig = {
verticalPosition: 'bottom',
horizontalPosition: 'right',
};

/** List of ipc commands with function mapping */
commandsList = [
new IpcCommand(VIEW_ADD_PLAYLIST, () => this.navigateToRoute('/')),
new IpcCommand(VIEW_SETTINGS, () => this.navigateToRoute('/settings')),
new IpcCommand(EPG_FETCH_DONE, () => this.onEpgFetchDone()),
new IpcCommand(EPG_ERROR, () => this.onEpgError()),
new IpcCommand(EPG_FETCH_DONE, () => this.epgService.onEpgFetchDone()),
new IpcCommand(EPG_ERROR, () => this.epgService.onEpgError()),
new IpcCommand(SHOW_WHATS_NEW, () => this.showWhatsNewDialog()),
new IpcCommand(
ERROR,
(response: { message: string; status: number }) => {
this.snackBar.open(
`Error: ${response.status} ${response.message}.`,
null,
{ duration: 2000 }
);
}
),
];

/** Default language as fallback */
Expand All @@ -62,13 +66,13 @@ export class AppComponent {
* Creates an instance of AppComponent
*/
constructor(
private channelStore: ChannelStore,
private electronService: DataService,
private epgService: EpgService,
private ngZone: NgZone,
private router: Router,
private snackBar: MatSnackBar,
private translate: TranslateService,
private settingsService: SettingsService,
private snackBar: MatSnackBar,
private whatsNewService: WhatsNewService
) {
if (
Expand Down Expand Up @@ -127,14 +131,7 @@ export class AppComponent {
if (settings && Object.keys(settings).length > 0) {
this.translate.use(settings.language ?? this.DEFAULT_LANG);
if (settings.epgUrl) {
this.electronService.sendIpcEvent(EPG_FETCH, {
url: settings.epgUrl,
});
this.snackBar.open(
this.translate.instant('EPG.FETCH_EPG'),
this.translate.instant('CLOSE'),
this.epgSnackBarOptions
);
this.epgService.fetchEpg(settings.epgUrl);
}

if (settings.theme) {
Expand Down Expand Up @@ -205,31 +202,6 @@ export class AppComponent {
this.router.navigateByUrl(route);
}

/**
* Handles the event when the EPG fetching is done
*/
onEpgFetchDone(): void {
this.channelStore.setEpgAvailableFlag(true);
this.snackBar.open(
this.translate.instant('EPG.DOWNLOAD_SUCCESS'),
null,
{
...this.epgSnackBarOptions,
duration: 2000,
}
);
}

/**
* Handles epg error
*/
onEpgError(): void {
this.snackBar.open(this.translate.instant('EPG.ERROR'), null, {
...this.epgSnackBarOptions,
duration: 2000,
});
}

/**
* Shows the "what is new" dialog
*/
Expand Down
5 changes: 0 additions & 5 deletions src/app/home/file-upload/file-upload.component.scss
Original file line number Diff line number Diff line change
@@ -1,9 +1,4 @@
.drop-container {
margin: 10px;
padding: 10px;
height: calc(100vh - 235px);
border: 2px dashed #ccc;
border-radius: 3px;
text-align: center;
color: #666;
}
Expand Down
31 changes: 26 additions & 5 deletions src/app/home/home.component.html
Original file line number Diff line number Diff line change
Expand Up @@ -15,15 +15,21 @@
<mat-tab>
<ng-template mat-tab-label>
<mat-icon class="tab-icon">list</mat-icon>
{{ 'HOME.TABS.RECENTLY_ADDED' | translate }}
<div class="tab-title">
{{ 'HOME.TABS.RECENTLY_ADDED' | translate }}
</div>
</ng-template>
<ng-template matTabContent>
<app-recent-playlists class="recent-playlists">
</app-recent-playlists>
</ng-template>
<app-recent-playlists class="recent-playlists">
</app-recent-playlists>
</mat-tab>
<mat-tab>
<ng-template mat-tab-label>
<mat-icon class="tab-icon">attach_file</mat-icon>
{{ 'HOME.TABS.FILE_UPLOAD' | translate }}
<div class="tab-title">
{{ 'HOME.TABS.FILE_UPLOAD' | translate }}
</div>
</ng-template>
<ng-template matTabContent>
<app-file-upload
Expand All @@ -35,14 +41,29 @@
<mat-tab>
<ng-template mat-tab-label>
<mat-icon class="tab-icon">link</mat-icon>
{{ 'HOME.TABS.URL_UPLOAD' | translate }}
<div class="tab-title">
{{ 'HOME.TABS.URL_UPLOAD' | translate }}
</div>
</ng-template>
<ng-template matTabContent>
<app-url-upload
(urlAdded)="sendPlaylistsUrl($event)"
></app-url-upload>
</ng-template>
</mat-tab>
<mat-tab>
<ng-template mat-tab-label>
<mat-icon class="tab-icon">text_format</mat-icon>
<div class="tab-title">
{{ 'HOME.TABS.TEXT_IMPORT' | translate }}
</div>
</ng-template>
<ng-template matTabContent>
<app-text-import
(textAdded)="uploadAsText($event)"
></app-text-import>
</ng-template>
</mat-tab>
</mat-tab-group>

<!-- Loading spinner -->
Expand Down
6 changes: 6 additions & 0 deletions src/app/home/home.component.scss
Original file line number Diff line number Diff line change
Expand Up @@ -18,3 +18,9 @@
width: 100%;
display: block;
}

@media only screen and (max-width: 480px) {
.tab-title {
display: none;
}
}
66 changes: 28 additions & 38 deletions src/app/home/home.component.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,32 +3,16 @@ import { MatSnackBar } from '@angular/material/snack-bar';
import { Router } from '@angular/router';
import { UploadFile } from 'ngx-uploader';
import {
ERROR,
PLAYLIST_PARSE,
PLAYLIST_PARSE_BY_URL,
PLAYLIST_PARSE_RESPONSE,
PLAYLIST_UPDATE_RESPONSE,
PLAYLIST_PARSE_TEXT,
} from '../../../shared/ipc-commands';
import { Playlist } from '../../../shared/playlist.interface';
import { DataService } from '../services/data.service';
import { PlaylistMeta } from '../shared/playlist-meta.type';
import { ChannelStore } from '../state';

/** Type to describe meta data of a playlist */
export type PlaylistMeta = Pick<
Playlist,
| 'count'
| 'title'
| 'filename'
| '_id'
| 'url'
| 'importDate'
| 'userAgent'
| 'filePath'
| 'updateDate'
| 'updateState'
| 'position'
>;

@Component({
selector: 'app-home',
templateUrl: './home.component.html',
Expand All @@ -50,22 +34,10 @@ export class HomeComponent {
this.navigateToPlayer();
},
},
{
id: ERROR,
execute: (response: { message: string; status: number }): void => {
this.isLoading = false;
this.showNotification(
`Error: ${response.status} ${response.message}.`
);
},
},
{
id: PLAYLIST_UPDATE_RESPONSE,
execute: (response: { message: string }): void =>
this.showNotification(response.message),
},
];

listeners = [];

/**
* Creates an instanceof HomeComponent
* @param channelStore channels store
Expand All @@ -81,7 +53,6 @@ export class HomeComponent {
private router: Router,
private snackBar: MatSnackBar
) {
// set all renderer listeners
this.setRendererListeners();
}

Expand All @@ -95,11 +66,13 @@ export class HomeComponent {
this.ngZone.run(() => command.execute(response))
);
} else {
this.electronService.listenOn(command.id, (response) => {
const cb = (response) => {
if (response.data.type === command.id) {
command.execute(response.data);
}
});
};
this.electronService.listenOn(command.id, cb);
this.listeners.push(cb);
}
});
}
Expand Down Expand Up @@ -150,6 +123,17 @@ export class HomeComponent {
});
}

/**
* Sends IPC event to the renderer process to parse playlist
* @param text playlist as string
*/
uploadAsText(text: string): void {
this.isLoading = true;
this.electronService.sendIpcEvent(PLAYLIST_PARSE_TEXT, {
text,
});
}

/**
* Returns last segment (part after last slash "/") of the given URL
* @param value URL as string
Expand Down Expand Up @@ -177,8 +161,14 @@ export class HomeComponent {
* Remove ipcRenderer listeners after component destroy
*/
ngOnDestroy(): void {
this.commandsList.forEach((command) =>
this.electronService.removeAllListeners(command.id)
);
if (this.electronService.isElectron) {
this.commandsList.forEach((command) =>
this.electronService.removeAllListeners(command.id)
);
} else {
this.listeners.forEach((listener) => {
window.removeEventListener('message', listener);
});
}
}
}
2 changes: 2 additions & 0 deletions src/app/home/home.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { FileUploadComponent } from './file-upload/file-upload.component';
import { HomeComponent } from './home.component';
import { HomeRoutingModule } from './home.routing';
import { PlaylistInfoComponent } from './recent-playlists/playlist-info/playlist-info.component';
import { TextImportComponent } from './text-import/text-import.component';
import { UrlUploadComponent } from './url-upload/url-upload.component';

@NgModule({
Expand All @@ -14,6 +15,7 @@ import { UrlUploadComponent } from './url-upload/url-upload.component';
HomeComponent,
FileUploadComponent,
PlaylistInfoComponent,
TextImportComponent,
UrlUploadComponent,
],
})
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import { MatFormFieldModule } from '@angular/material/form-field';
import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing';
import { DatePipe } from '@angular/common';
import { PlaylistInfoComponent } from './playlist-info.component';
import { FormsModule, ReactiveFormsModule, FormBuilder } from '@angular/forms';
import { FormsModule, ReactiveFormsModule, UntypedFormBuilder } from '@angular/forms';
import { Playlist } from './../../../../../shared/playlist.interface';

describe('PlaylistInfoComponent', () => {
Expand All @@ -36,7 +36,7 @@ describe('PlaylistInfoComponent', () => {
providers: [
{ provide: MAT_DIALOG_DATA, useValue: {} },
{ provide: DataService, useClass: ElectronServiceStub },
FormBuilder,
UntypedFormBuilder,
],
}).compileComponents();
})
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,9 @@
import { DatePipe } from '@angular/common';
import { Component, Inject } from '@angular/core';
import {
FormBuilder,
FormControl,
FormGroup,
UntypedFormBuilder,
UntypedFormControl,
UntypedFormGroup,
Validators,
} from '@angular/forms';
import { MAT_DIALOG_DATA } from '@angular/material/dialog';
Expand All @@ -25,7 +25,7 @@ export class PlaylistInfoComponent {
playlist: Playlist;

/** Form group with playlist details */
playlistDetails: FormGroup;
playlistDetails: UntypedFormGroup;

/**
* Creates an instance of the component and injects the selected playlist from the parent component
Expand All @@ -36,7 +36,7 @@ export class PlaylistInfoComponent {
*/
constructor(
private datePipe: DatePipe,
private formBuilder: FormBuilder,
private formBuilder: UntypedFormBuilder,
private electronService: DataService,
@Inject(MAT_DIALOG_DATA) playlist: Playlist
) {
Expand All @@ -49,26 +49,26 @@ export class PlaylistInfoComponent {
ngOnInit(): void {
this.playlistDetails = this.formBuilder.group({
_id: this.playlist._id,
title: new FormControl(this.playlist.title, Validators.required),
title: new UntypedFormControl(this.playlist.title, Validators.required),
userAgent: this.playlist.userAgent || '',
filename: new FormControl({
filename: new UntypedFormControl({
value: this.playlist.filename || '',
disabled: true,
}),
count: new FormControl({
count: new UntypedFormControl({
value: this.playlist.count,
disabled: true,
}),
importDate: new FormControl({
importDate: new UntypedFormControl({
value: this.datePipe.transform(this.playlist.importDate),
disabled: true,
}),
url: new FormControl({ value: this.playlist.url, disabled: true }),
filePath: new FormControl({
url: new UntypedFormControl({ value: this.playlist.url, disabled: true }),
filePath: new UntypedFormControl({
value: this.playlist.filePath,
disabled: true,
}),
autoRefresh: new FormControl(this.playlist.autoRefresh),
autoRefresh: new UntypedFormControl(this.playlist.autoRefresh),
});
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
>
<mat-icon
mat-list-icon
class="upload-type-icon"
*ngIf="item?.url"
[matTooltip]="
('HOME.PLAYLISTS.ADDED_VIA_URL' | translate) + ' ' + item?.url
Expand All @@ -12,6 +13,7 @@
>
<mat-icon
mat-list-icon
class="upload-type-icon"
*ngIf="!item?.url"
[matTooltip]="'HOME.PLAYLISTS.ADDED_VIA_FILE' | translate"
>folder</mat-icon
Expand Down Expand Up @@ -40,6 +42,7 @@
<button
*ngIf="item.url || item.filePath"
mat-icon-button
class="refresh-btn"
color="accent"
(click)="$event.stopPropagation(); refreshClicked.emit(item)"
[matTooltip]="'HOME.PLAYLISTS.REFRESH' | translate"
Expand All @@ -49,6 +52,7 @@
<button
mat-icon-button
color="accent"
class="edit-btn"
(click)="$event.stopPropagation(); editPlaylistClicked.emit(item)"
[matTooltip]="'HOME.PLAYLISTS.SHOW_DETAILS' | translate"
>
Expand All @@ -57,6 +61,7 @@
<button
mat-icon-button
color="accent"
class="delete-btn"
(click)="$event.stopPropagation(); removeClicked.emit(item._id)"
[matTooltip]="'HOME.PLAYLISTS.REMOVE' | translate"
>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -30,3 +30,17 @@ mat-list-item {
cursor: move;
margin-left: -20px;
}

::ng-deep {
@media only screen and (max-width: 480px) {
.main-container {
.favorites-icon,
.upload-type-icon,
.refresh-btn,
.edit-btn,
.delete-btn {
display: none;
}
}
}
}
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { Component, EventEmitter, Input, Output } from '@angular/core';
import { PlaylistMeta } from '../../home.component';
import { PlaylistMeta } from '../../../shared/playlist-meta.type';

@Component({
selector: 'app-playlist-item',
Expand Down
6 changes: 4 additions & 2 deletions src/app/home/recent-playlists/recent-playlists.component.html
Original file line number Diff line number Diff line change
Expand Up @@ -11,9 +11,11 @@
(click)="getPlaylist('GLOBAL_FAVORITES')"
>
<mat-icon class="favorites-icon" mat-list-icon>star</mat-icon>
<div mat-line>Global favorites</div>
<div mat-line>
{{ 'HOME.PLAYLISTS.GLOBAL_FAVORITES' | translate }}
</div>
<div mat-line class="meta">
Auto-generated playlist with aggregated favorites from all playlists
{{ 'HOME.PLAYLISTS.GLOBAL_FAVORITES_DESCRIPTION' | translate }}
</div>
<mat-divider></mat-divider>
</mat-list-item>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ import {
import { DataService } from '../../services/data.service';
import { DialogService } from '../../services/dialog.service';
import { ElectronServiceStub } from '../../services/electron.service.stub';
import { PlaylistMeta } from '../home.component';
import { PlaylistMeta } from '../../shared/playlist-meta.type';
import { RecentPlaylistsComponent } from './recent-playlists.component';

describe('RecentPlaylistsComponent', () => {
Expand Down Expand Up @@ -77,7 +77,7 @@ describe('RecentPlaylistsComponent', () => {
isPointerOverContainer: true,
distance: { x: 0, y: 0 },
dropPoint: { x: 0, y: 0 },
};
} as any;
jest.spyOn(electronService, 'sendIpcEvent');
component.drop(event);
expect(electronService.sendIpcEvent).toHaveBeenCalledTimes(1);
Expand Down
36 changes: 31 additions & 5 deletions src/app/home/recent-playlists/recent-playlists.component.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { CdkDragDrop, moveItemInArray } from '@angular/cdk/drag-drop';
import { Component, NgZone } from '@angular/core';
import { Component, NgZone, OnDestroy } from '@angular/core';
import { MatDialog } from '@angular/material/dialog';
import { MatSnackBar } from '@angular/material/snack-bar';
import { TranslateService } from '@ngx-translate/core';
Expand All @@ -13,17 +13,18 @@ import {
PLAYLIST_REMOVE_BY_ID_RESPONSE,
PLAYLIST_UPDATE,
PLAYLIST_UPDATE_POSITIONS,
PLAYLIST_UPDATE_RESPONSE,
} from './../../../../shared/ipc-commands';
import { DialogService } from './../../services/dialog.service';
import { PlaylistMeta } from './../home.component';
import { PlaylistMeta } from './../../shared/playlist-meta.type';
import { PlaylistInfoComponent } from './playlist-info/playlist-info.component';

@Component({
selector: 'app-recent-playlists',
templateUrl: './recent-playlists.component.html',
styleUrls: ['./recent-playlists.component.scss'],
})
export class RecentPlaylistsComponent {
export class RecentPlaylistsComponent implements OnDestroy {
/** All available playlists */
playlists: PlaylistMeta[] = [];

Expand All @@ -41,8 +42,16 @@ export class RecentPlaylistsComponent {
});
this.electronService.sendIpcEvent(PLAYLIST_GET_ALL);
}),
new IpcCommand(
PLAYLIST_UPDATE_RESPONSE,
(response: { message: string }) => {
this.snackBar.open(response.message, null, { duration: 2000 });
}
),
];

listeners = [];

/**
* Creates an instance of the component
* @param dialog angular material dialog reference
Expand Down Expand Up @@ -76,11 +85,13 @@ export class RecentPlaylistsComponent {
this.ngZone.run(() => command.callback(response))
);
} else {
this.electronService.listenOn(command.id, (response) => {
const cb = (response) => {
if (response.data.type === command.id) {
command.callback(response.data);
}
});
};
this.electronService.listenOn(command.id, cb);
this.listeners.push(cb);
}
});
}
Expand Down Expand Up @@ -155,4 +166,19 @@ export class RecentPlaylistsComponent {
...(item.url ? { url: item.url } : { filePath: item.filePath }),
});
}

/**
* Removes command listeners on component destroy
*/
ngOnDestroy(): void {
if (this.electronService.isElectron) {
this.commandsList.forEach((command) =>
this.electronService.removeAllListeners(command.id)
);
} else {
this.listeners.forEach((listener) => {
window.removeEventListener('message', listener);
});
}
}
}
18 changes: 18 additions & 0 deletions src/app/home/text-import/text-import.component.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
<form (ngSubmit)="textAdded.emit(textForm.value.text)" [formGroup]="textForm">
<mat-form-field class="full-width" appearance="fill">
<mat-label>{{ 'HOME.TEXT_IMPORT.LABEL' | translate }}</mat-label>
<textarea
formControlName="text"
matInput
placeholder="#EXTM3U url-tvg=..."
></textarea>
</mat-form-field>
<button
mat-flat-button
color="primary"
type="submit"
[disabled]="!textForm.valid"
>
{{ 'HOME.TEXT_IMPORT.BUTTON_LABEL' | translate }}
</button>
</form>
8 changes: 8 additions & 0 deletions src/app/home/text-import/text-import.component.scss
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
button {
margin: 0 15px;
text-transform: uppercase;
}

textarea {
height: calc(100vh - 300px);
}
35 changes: 35 additions & 0 deletions src/app/home/text-import/text-import.component.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing';
import { FormsModule, ReactiveFormsModule } from '@angular/forms';
import { MatInputModule } from '@angular/material/input';
import { TranslatePipe } from '@ngx-translate/core';

import { MockModule, MockPipe } from 'ng-mocks';
import { TextImportComponent } from './text-import.component';

describe('TextImportComponent', () => {
let component: TextImportComponent;
let fixture: ComponentFixture<TextImportComponent>;

beforeEach(
waitForAsync(() => {
TestBed.configureTestingModule({
declarations: [TextImportComponent, MockPipe(TranslatePipe)],
imports: [
MockModule(MatInputModule),
MockModule(FormsModule),
MockModule(ReactiveFormsModule),
],
}).compileComponents();
})
);

beforeEach(() => {
fixture = TestBed.createComponent(TextImportComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});

it('should create', () => {
expect(component).toBeTruthy();
});
});
15 changes: 15 additions & 0 deletions src/app/home/text-import/text-import.component.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import { Component, EventEmitter, Output } from '@angular/core';
import { FormControl, FormGroup, Validators } from '@angular/forms';

@Component({
selector: 'app-text-import',
templateUrl: './text-import.component.html',
styleUrls: ['./text-import.component.scss'],
})
export class TextImportComponent {
@Output() textAdded = new EventEmitter<string>();

textForm = new FormGroup({
text: new FormControl('', Validators.required),
});
}
6 changes: 3 additions & 3 deletions src/app/home/url-upload/url-upload.component.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { Component, EventEmitter, OnInit, Output } from '@angular/core';
import { FormBuilder, FormGroup, Validators } from '@angular/forms';
import { UntypedFormBuilder, UntypedFormGroup, Validators } from '@angular/forms';
import { DataService } from '../../services/data.service';

@Component({
Expand All @@ -12,7 +12,7 @@ export class UrlUploadComponent implements OnInit {
@Output() urlAdded: EventEmitter<string> = new EventEmitter();

/** Form group with playlist url */
form: FormGroup;
form: UntypedFormGroup;

/** Is true if app runs in electron-based environment */
isElectron = this.dataService.isElectron;
Expand All @@ -21,7 +21,7 @@ export class UrlUploadComponent implements OnInit {
* Creates an instance of component
* @param fb angular form builder
*/
constructor(private fb: FormBuilder, private dataService: DataService) {}
constructor(private fb: UntypedFormBuilder, private dataService: DataService) {}

ngOnInit(): void {
const urlRegex = '(https?://.*?)';
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,11 @@
<mat-tab-group color="primary" backgroundColor="primary">
<mat-tab [label]="'CHANNELS.ALL_CHANNELS' | translate">
<mat-tab>
<ng-template mat-tab-label>
<mat-icon class="tab-icon">list</mat-icon>
<div class="tab-title">
{{ 'CHANNELS.ALL_CHANNELS' | translate }}
</div>
</ng-template>
<mat-list>
<mat-list-item>
<input
Expand Down Expand Up @@ -42,7 +48,13 @@
</mat-nav-list>
</mat-tab>

<mat-tab [label]="'CHANNELS.GROUPS' | translate">
<mat-tab>
<ng-template mat-tab-label>
<mat-icon class="tab-icon">category</mat-icon>
<div class="tab-title">
{{ 'CHANNELS.GROUPS' | translate }}
</div>
</ng-template>
<mat-nav-list id="groups-list">
<mat-accordion multi>
<ng-container *ngFor="let groups of groupedChannels | keyvalue">
Expand Down Expand Up @@ -88,10 +100,13 @@
</mat-nav-list>
</mat-tab>

<mat-tab
*ngIf="playlistId !== 'GLOBAL_FAVORITES'"
[label]="'CHANNELS.FAVORITES' | translate"
>
<mat-tab *ngIf="(playlistId$ | async) !== 'GLOBAL_FAVORITES'">
<ng-template mat-tab-label>
<mat-icon class="tab-icon">star</mat-icon>
<div class="tab-title">
{{ 'CHANNELS.FAVORITES' | translate }}
</div>
</ng-template>
<mat-nav-list
*ngIf="favorites$ | async as favorites"
id="favorites-list"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,6 @@
padding-top: 0;
}

::ng-deep .mat-expansion-panel-body {
padding: 0 !important;
}

.scroll-viewport {
min-height: calc(100vh - 165px);
}
Expand All @@ -26,20 +22,24 @@
overflow: hidden;
}

::ng-deep .dark-theme {
.search-bar {
background: #666;
color: #fff;
::ng-deep {
.mat-expansion-panel-body {
padding: 0 !important;
}

.search-bar::placeholder {
color: #ccc;
}
}
.dark-theme {
.search-bar {
background: #666;
color: #fff;
}

::ng-deep {
.dark-theme .active {
background: #000;
.search-bar::placeholder {
color: #ccc;
}

.active {
background: #000;
}
}

.mat-tab-label-content {
Expand All @@ -54,7 +54,6 @@
.mat-tab-label-active {
min-width: 0 !important;
padding: 15px !important;
margin: 5px !important;
}
}

Expand All @@ -65,7 +64,7 @@
-webkit-line-clamp: 1;
-webkit-box-orient: vertical;
font-size: 0.9em;
width: 290px;
max-width: 290px;
}

[type='search']::-webkit-search-cancel-button {
Expand All @@ -78,3 +77,22 @@
background-size: 10px 10px;
color: #fff;
}

#groups-list,
#favorites-list {
height: calc(100vh - 120px);
}

@media only screen and (max-width: 480px) {
.channel-name {
max-width: 100px;
}

.tab-title {
display: none;
}

.tab-icon {
display: block;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import {
} from '@angular/core';
import { MatSnackBar } from '@angular/material/snack-bar';
import * as _ from 'lodash';
import { Observable } from 'rxjs';
import { map, Observable, skipWhile } from 'rxjs';
import { Channel } from '../../../../../shared/channel.interface';
import { ChannelQuery, ChannelStore } from '../../../state';

Expand All @@ -33,10 +33,10 @@ export class ChannelListContainerComponent {
}

/** Object with channels sorted by groups */
groupedChannels: { [key: string]: Channel[] };
groupedChannels!: { [key: string]: Channel[] };

/** Selected channel */
selected: Channel;
selected!: Channel;

/** List with favorited channels */
favorites$: Observable<Channel[]> = this.channelQuery.select((store) =>
Expand All @@ -62,21 +62,21 @@ export class ChannelListContainerComponent {
}

/** ID of the current playlist */
playlistId: string;
playlistId$ = this.channelQuery.select().pipe(
skipWhile(
(store) => store.playlistId === '' || store.playlistId === undefined
),
map((data) => data.playlistId)
);

/**
* Creates an instance of ChannelListContainerComponent
* @param channelQuery akita's channel query
* @param channelStore akita's channel store
* @param snackBar service to push snackbar notifications
*/
constructor(
private channelQuery: ChannelQuery,
private channelStore: ChannelStore,
private snackBar: MatSnackBar
) {
this.playlistId = this.channelQuery.getValue().playlistId;
}
) {}

/**
* Sets clicked channel as selected and emits them to the parent component
Expand Down
14 changes: 7 additions & 7 deletions src/app/player/components/epg-list/epg-list.component.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,12 @@
import { Component, NgZone } from '@angular/core';
import { EpgChannel } from '../../models/epg-channel.model';
import { EpgProgram } from '../../models/epg-program.model';
import * as moment from 'moment';
import { EPG_GET_PROGRAM_DONE } from '../../../../../shared/ipc-commands';
import { ChannelQuery, ChannelStore } from '../../../state';
import { Observable } from 'rxjs';
import { map } from 'rxjs/operators';
import { EPG_GET_PROGRAM_DONE } from '../../../../../shared/ipc-commands';
import { DataService } from '../../../services/data.service';
import { ChannelQuery, ChannelStore } from '../../../state';
import { EpgChannel } from '../../models/epg-channel.model';
import { EpgProgram } from '../../models/epg-program.model';

export interface EpgData {
channel: EpgChannel;
Expand Down Expand Up @@ -76,9 +76,9 @@ export class EpgListComponent {
this.timeshiftUntil$ = this.channelQuery
.select(
(store) =>
store.active.tvg.rec ||
store.active.timeshift ||
store.active.catchup?.days
store.active?.tvg?.rec ||
store.active?.timeshift ||
store.active?.catchup?.days
)
.pipe(
map((value) =>
Expand Down
Original file line number Diff line number Diff line change
@@ -1,18 +1,17 @@
<div
class="channel-overlay"
fxLayout="row"
*ngIf="channel && epgProgram"
[class.visible]="isVisible"
[class.hidden]="!isVisible"
>
<div fxFlex="100px" fxLayoutAlign="center start" class="channel-logo">
<div class="channel-logo">
<img
[src]="epgProgram?.icon[0] || channel?.tvg?.logo"
width="68"
onerror="this.style.display='none'"
/>
</div>
<div fxFlex>
<div class="content">
<div class="program-name">
{{ epgProgram?.title[0]?.value }} | {{ channel.name }}
</div>
Expand Down
15 changes: 15 additions & 0 deletions src/app/player/components/info-overlay/info-overlay.component.scss
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,9 @@
left: 0;
right: 0;
backdrop-filter: blur(10px);
flex-direction: row;
box-sizing: border-box;
display: flex;
}

.program-name,
Expand All @@ -23,6 +26,18 @@

.channel-logo {
margin-top: 20px;
place-content: flex-start center;
align-items: flex-start;
flex-direction: row;
box-sizing: border-box;
display: flex;
flex: 1 1 100px;
max-width: 100px;
min-width: 100px;
}

.content {
width: 100%;
}

.program-progress {
Expand Down
142 changes: 142 additions & 0 deletions src/app/player/components/multi-epg/multi-epg-container.component.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
<div id="epg-navigation">
<button
[matTooltip]="'CLOSE' | translate"
mat-button
(click)="close()"
color="accent"
>
<mat-icon>close</mat-icon> {{ 'CLOSE' | translate }}
</button>
<button
[matTooltip]="'EPG.NEXT_DAY' | translate"
mat-icon-button
(click)="switchDay('prev')"
>
<mat-icon>navigate_before</mat-icon>
</button>
<button
[matTooltip]="'EPG.PREVIOUS_DAY' | translate"
mat-icon-button
(click)="switchDay('next')"
>
<mat-icon>navigate_next</mat-icon>
</button>
<div class="today-date">
{{ today | momentDate: 'YYYYMMDD':'MMMM Do, dddd' }}
</div>
<button mat-icon-button (click)="zoomIn()" [disabled]="hourWidth >= 800">
<mat-icon>zoom_in</mat-icon>
</button>
<button mat-icon-button [disabled]="hourWidth <= 50" (click)="zoomOut()">
<mat-icon>zoom_out</mat-icon>
</button>
<button
mat-button
[disabled]="channelsLowerRange === 0"
(click)="previousChannels()"
>
Previous channels
</button>
<button mat-button (click)="nextChannels()">Next channels</button>
</div>
<div class="parent" #epgContainer>
<svg id="channels-column">
<g *ngFor="let item of channels; let i = index">
<!-- channel name -->
<g class="channel">
<rect
width="100"
[attr.height]="barHeight"
[attr.y]="barHeight * i + barHeight"
stroke="black"
></rect>
<image
*ngIf="item.icon[0]"
[attr.y]="barHeight * i + barHeight + 1"
[attr.href]="item.icon[0]"
[attr.height]="barHeight - 2"
width="100"
[matTooltip]="item.name[0].value"
></image>
<foreignObject
*ngIf="!item.icon[0]"
[attr.y]="barHeight * i + barHeight"
width="100"
[attr.height]="barHeight"
>
<div class="channel-name">
{{ item.name[0].value }}
</div>
</foreignObject>
</g>
</g>
</svg>
<div id="epg-container">
<svg [attr.width]="24 * hourWidth" height="100%" id="epg-svg">
<!-- time headline -->
<g *ngFor="let a of timeHeader; let i = index">
<rect
[matTooltip]="i + ':00'"
[attr.width]="hourWidth"
[attr.height]="barHeight"
[attr.x]="i * hourWidth"
fill="#000"
></rect>
<text
[attr.x]="i * hourWidth"
y="10"
font-size="14"
fill="white"
transform="translate(20,20)"
>
{{ i }}:00
</text>
</g>

<!-- epg channels with programs -->
<g *ngFor="let item of channels; let i = index">
<g
*ngFor="let program of item.programs; let a = index"
[matTooltip]="program.title[0].value"
class="program-item"
>
<!-- program item -->
<rect
[attr.width]="program.width"
[attr.height]="barHeight"
[attr.x]="program.startPosition"
[attr.y]="barHeight * i + barHeight"
fill="#000"
></rect>
<foreignObject
(click)="showDescription(program)"
[attr.width]="program.width"
[attr.height]="barHeight"
[attr.y]="barHeight * i + barHeight"
[attr.x]="program.startPosition"
>
<div
[innerHtml]="program.title[0]?.value"
class="program-name"
></div>
</foreignObject>
</g>
<line
[attr.x1]="0"
[attr.y1]="barHeight * i + barHeight"
[attr.x2]="24 * hourWidth"
[attr.y2]="barHeight * i + barHeight"
stroke="#676767"
/>
</g>
<line
[attr.x1]="currentTimeLine"
[attr.y1]="barHeight"
[attr.x2]="currentTimeLine"
id="current-time-line"
y2="1000"
stroke="black"
/>
</svg>
</div>
</div>
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
.program-item {
cursor: pointer;
}

rect {
stroke: #676767;
cursor: pointer;
}

.channel > rect {
stroke: #676767;
}

.program-item:hover > rect {
fill: #333;
}

.program-name,
.channel-name {
-webkit-line-clamp: 2;
height: 32px;
overflow: hidden;
text-overflow: ellipsis;
display: -webkit-box;
-webkit-box-orient: vertical;
padding: 6px 2px;
}

#epg-container {
width: calc(100vw - 100px);
height: calc(100vh - 73px);
overflow-x: scroll;
overflow-y: hidden;
}

.parent {
display: flex;
background-color: black;
}

#channels-column {
width: 100px;
flex: none;
border-right: 2px solid #ccc;
}

.channel-name {
margin: 0 5px;
text-align: center;
}

#epg-navigation {
display: flex;
background-color: black;
margin-top: 30px;
border-bottom: 2px solid #fff;
}

.today-date {
flex: 1;
align-items: center;
line-height: 40px;
height: 40px;
padding: 0 20px;
}

#current-time-line {
stroke-width: 2px;
stroke: white;
}
212 changes: 212 additions & 0 deletions src/app/player/components/multi-epg/multi-epg-container.component.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,212 @@
import { OverlayRef } from '@angular/cdk/overlay';
import {
AfterViewInit,
Component,
ElementRef,
Inject,
NgZone,
OnInit,
ViewChild,
} from '@angular/core';
import { MatDialog } from '@angular/material/dialog';
import { addDays, differenceInMinutes, format, parse, subDays } from 'date-fns';
import {
EPG_GET_CHANNELS_BY_RANGE,
EPG_GET_CHANNELS_BY_RANGE_RESPONSE,
} from '../../../../../shared/ipc-commands';
import { DataService } from '../../../services/data.service';
import { EpgChannel } from '../../models/epg-channel.model';
import { EpgProgram } from '../../models/epg-program.model';
import { EpgItemDescriptionComponent } from '../epg-list/epg-item-description/epg-item-description.component';
import { COMPONENT_OVERLAY_REF } from '../video-player/video-player.component';

@Component({
selector: 'app-multi-epg-container',
templateUrl: './multi-epg-container.component.html',
styleUrls: ['./multi-epg-container.component.scss'],
})
export class MultiEpgContainerComponent implements OnInit, AfterViewInit {
@ViewChild('epgContainer') epgContainer: ElementRef;
timeHeader = new Array(24);
hourWidth = 150;
barHeight = 50;
originalEpgData: (EpgChannel & { programs: EpgProgram[] })[] = [];
channels: (EpgChannel & { programs: EpgProgram[] })[] = [];
today = format(new Date(), 'yyyyMMdd');
currentTimeLine = 0;
visibleChannels;
channelsLowerRange = 0;
channelsUpperRange;

constructor(
private dataService: DataService,
private dialog: MatDialog,
private ngZone: NgZone,
@Inject(COMPONENT_OVERLAY_REF) private overlayRef: OverlayRef
) {
this.dataService.listenOn(
EPG_GET_CHANNELS_BY_RANGE_RESPONSE,
(event, response) =>
this.ngZone.run(() => {
if (response) {
this.originalEpgData = response.payload;
this.channels = this.enrichProgramData();
}
})
);
}

ngOnInit(): void {
this.calculateCurrentTimeBar();
}

ngAfterViewInit(): void {
const timeNow = new Date();
const scrollPosition =
(timeNow.getHours() + timeNow.getMinutes() / 60) * this.hourWidth;
document
.getElementById('epg-container')!
.scrollTo(scrollPosition < 1000 ? 0 : scrollPosition - 150, 0);

const borderInPx =
this.epgContainer.nativeElement.offsetHeight / this.barHeight;
this.visibleChannels = Math.floor(
(this.epgContainer.nativeElement.offsetHeight - borderInPx) /
this.barHeight -
1
);
this.channelsUpperRange = this.visibleChannels;
this.requestPrograms();
}

nextChannels(): void {
this.channelsLowerRange = this.channelsUpperRange;
this.channelsUpperRange =
this.channelsUpperRange + this.visibleChannels;
this.channels = [];
this.requestPrograms();
}

previousChannels(): void {
this.channelsUpperRange =
this.channelsUpperRange - this.visibleChannels;
this.channelsLowerRange =
this.channelsUpperRange - this.visibleChannels;

this.requestPrograms();
}

requestPrograms() {
this.dataService.sendIpcEvent(EPG_GET_CHANNELS_BY_RANGE, {
limit: this.channelsUpperRange,
skip: this.channelsLowerRange,
});
}

enrichProgramData() {
return this.originalEpgData.map((channel) => {
return {
...channel,
programs: channel.programs
.filter((item) => item.start.includes(this.today))
.map((program) => {
const startDate = parse(
program.start,
'yyyyMMddHHmmss XXXX',
addDays(new Date(), 1)
);
const stopDate = parse(
program.stop,
'yyyyMMddHHmmss XXXX',
addDays(new Date(), 1)
);
return {
...program,
startDate,
stopDate,
startPosition: this.positionToStartInPx(startDate),
width: this.programDurationInPx(
startDate,
stopDate
),
};
}),
};
});
}

positionToStartInPx(startDate: Date) {
return (
(startDate.getHours() + startDate.getMinutes() / 60) *
this.hourWidth
);
}

programDurationInPx(startDate: Date, stopDate: Date) {
const duration = differenceInMinutes(stopDate, startDate);
return (duration * this.hourWidth) / 60;
}

recalculate(): void {
this.channels.forEach((channel) => {
channel.programs = channel.programs.map((program: any) => {
return {
...program,
startPosition: this.positionToStartInPx(program.startDate),
width: this.programDurationInPx(
program.startDate,
program.stopDate
),
};
});
});
}

zoomIn(): void {
this.hourWidth += 50;
this.recalculate();
this.calculateCurrentTimeBar();
}

zoomOut(): void {
if (this.hourWidth <= 50) return;
this.hourWidth -= 50;
this.recalculate();
this.calculateCurrentTimeBar();
}

calculateCurrentTimeBar(): void {
const timeNow = new Date();
this.currentTimeLine =
(timeNow.getHours() + timeNow.getMinutes() / 60) * this.hourWidth;
}

switchDay(direction: 'prev' | 'next'): void {
this.today =
direction === 'prev'
? format(
subDays(parse(this.today, 'yyyyMMdd', new Date()), 1),
'yyyyMMdd'
)
: format(
addDays(parse(this.today, 'yyyyMMdd', new Date()), 1),
'yyyyMMdd'
);
this.calculateCurrentTimeBar();
this.channels = this.enrichProgramData();
}

/**
* Opens the dialog with details about the selected program
* @param program selected epg program
*/
showDescription(program: EpgProgram): void {
this.dialog.open(EpgItemDescriptionComponent, {
data: program,
});
}

close() {
this.overlayRef.detach();
}
}
28 changes: 21 additions & 7 deletions src/app/player/components/video-player/video-player.component.html
Original file line number Diff line number Diff line change
Expand Up @@ -19,11 +19,17 @@
</button>
<div class="playlist-info">
<div class="name">
{{
sidebarView === 'CHANNELS'
? playlistTitle
: 'My Playlists'
}}
<ng-container
*ngIf="
sidebarView === 'CHANNELS';
else allPlaylists
"
>
{{ playlistTitle$ | async }}
</ng-container>
<ng-template #allPlaylists>
{{ 'HOME.PLAYLISTS.MY_PLAYLISTS' | translate }}
</ng-template>
</div>
<div class="channels-count">
{{
Expand Down Expand Up @@ -74,7 +80,7 @@
<mat-icon>menu</mat-icon>
</button>
<button
*ngIf="playlistId !== 'GLOBAL_FAVORITES'"
*ngIf="(playlistId$ | async) !== 'GLOBAL_FAVORITES'"
mat-icon-button
(click)="addToFavorites(activeChannel)"
[matTooltip]="'TOP_MENU.TOGGLE_FAVORITE_FLAG' | translate"
Expand All @@ -89,6 +95,14 @@
</button>
{{ activeChannel?.name }}
<div class="spacer"></div>
<button
*ngIf="epgAvailable$ | async"
mat-icon-button
(click)="openMultiEpgView()"
[matTooltip]="'TOP_MENU.OPEN_MULTI_EPG' | translate"
>
<mat-icon>view_list</mat-icon>
</button>
<button
*ngIf="epgAvailable$ | async"
mat-button
Expand Down Expand Up @@ -135,6 +149,6 @@
(keydown.escape)="close()"
disableClose
>
<app-epg-list></app-epg-list>
<app-epg-list *ngIf="isElectron"></app-epg-list>
</mat-drawer>
</mat-drawer-container>
13 changes: 13 additions & 0 deletions src/app/player/components/video-player/video-player.component.scss
Original file line number Diff line number Diff line change
Expand Up @@ -58,3 +58,16 @@
.electron {
height: calc(100vh - 85px) !important;
}

@media only screen and (max-width: 480px) {
.mat-drawer {
width: 200px;
min-width: 200px;
}

.playlist-info {
.name {
max-width: 105px;
}
}
}
95 changes: 71 additions & 24 deletions src/app/player/components/video-player/video-player.component.ts
Original file line number Diff line number Diff line change
@@ -1,31 +1,45 @@
import { Component, NgZone, OnInit, ViewChild } from '@angular/core';
import { Overlay, OverlayRef } from '@angular/cdk/overlay';
import { ComponentPortal } from '@angular/cdk/portal';
import {
Component,
InjectionToken,
Injector,
NgZone,
OnDestroy,
OnInit,
ViewChild,
} from '@angular/core';
import { MatSidenav } from '@angular/material/sidenav';
import { MatSnackBar } from '@angular/material/snack-bar';
import { Router } from '@angular/router';
import { StorageMap } from '@ngx-pwa/local-storage';
import { filter, Observable } from 'rxjs';
import { filter, map, Observable, skipWhile } from 'rxjs';
import { Channel } from '../../../../../shared/channel.interface';
import {
PLAYLIST_GET_ALL,
PLAYLIST_GET_BY_ID,
PLAYLIST_PARSE_RESPONSE,
} from '../../../../../shared/ipc-commands';
import { Playlist } from '../../../../../shared/playlist.interface';
import { DataService } from '../../../services/data.service';
import { Settings, VideoPlayer } from '../../../settings/settings.interface';
import { STORE_KEY } from '../../../shared/enums/store-keys.enum';
import { ChannelQuery, ChannelStore } from '../../../state';
import { MultiEpgContainerComponent } from '../multi-epg/multi-epg-container.component';
import { EpgProgram } from './../../models/epg-program.model';

/** Possible sidebar view options */
type SidebarView = 'CHANNELS' | 'PLAYLISTS';

export const COMPONENT_OVERLAY_REF = new InjectionToken(
'COMPONENT_OVERLAY_REF'
);

@Component({
selector: 'app-video-player',
templateUrl: './video-player.component.html',
styleUrls: ['./video-player.component.scss'],
})
export class VideoPlayerComponent implements OnInit {
export class VideoPlayerComponent implements OnInit, OnDestroy {
/** Active selected channel */
activeChannel$: Observable<Channel> = this.channelQuery
.select((state) => state.active)
Expand Down Expand Up @@ -62,10 +76,20 @@ export class VideoPlayerComponent implements OnInit {
@ViewChild('sidenav') sideNav: MatSidenav;

/** ID of the current playlist */
playlistId = this.channelQuery.getValue().playlistId;
playlistId$ = this.channelQuery.select().pipe(
skipWhile(
(store) => store.playlistId === '' || store.playlistId === undefined
),
map((data) => data.playlistId)
);

/** Title of the current playlist */
playlistTitle = this.channelQuery.getValue().playlistFilename;
playlistTitle$ = this.channelQuery.select().pipe(
skipWhile((store) => store.playlistFilename === ''),
map((store) => store.playlistFilename)
);

isElectron = this.dataService.isElectron;

/** IPC Renderer commands list with callbacks */
commandsList = [
Expand All @@ -81,18 +105,20 @@ export class VideoPlayerComponent implements OnInit {
/** Current sidebar view */
sidebarView: SidebarView = 'CHANNELS';

listeners = [];

/** EPG overlay reference */
overlayRef: OverlayRef;

/**
* Creates an instance of VideoPlayerComponent
* @param channelQuery akita's channel query
* @param channelStore akita's channel store
* @param storage browser storage service
* @param snackBar service to push snackbar notifications
*/
constructor(
private channelQuery: ChannelQuery,
private channelStore: ChannelStore,
public dataService: DataService,
private ngZone: NgZone,
private overlay: Overlay,
private router: Router,
private snackBar: MatSnackBar,
private storage: StorageMap
Expand All @@ -118,11 +144,13 @@ export class VideoPlayerComponent implements OnInit {
this.ngZone.run(() => command.execute(response))
);
} else {
this.dataService.listenOn(command.id, (response) => {
const cb = (response) => {
if (response.data.type === command.id) {
command.execute(response.data);
}
});
};
this.dataService.listenOn(command.id, cb);
this.listeners.push(cb);
}
});
}
Expand All @@ -134,8 +162,8 @@ export class VideoPlayerComponent implements OnInit {
this.storage.get(STORE_KEY.Settings).subscribe((settings: Settings) => {
if (settings && Object.keys(settings).length > 0) {
this.playerSettings = {
player: settings.player,
showCaptions: settings.showCaptions,
player: settings.player || VideoPlayer.VideoJs,
showCaptions: settings.showCaptions || false,
};
}
});
Expand Down Expand Up @@ -165,16 +193,6 @@ export class VideoPlayerComponent implements OnInit {
this.sidebarView = view;
}

/**
* Requests playlist by id
* @param playlistId playlist id
*/
getPlaylist(playlistId: string): void {
this.dataService.sendIpcEvent(PLAYLIST_GET_BY_ID, {
id: playlistId,
});
}

/** Navigates back */
goBack(): void {
if (this.sidebarView === 'PLAYLISTS') {
Expand All @@ -183,4 +201,33 @@ export class VideoPlayerComponent implements OnInit {
this.sidebarView = 'PLAYLISTS';
}
}

ngOnDestroy() {
if (this.dataService.isElectron) {
this.dataService.removeAllListeners(PLAYLIST_PARSE_RESPONSE);
} else {
this.listeners.forEach((listener) =>
window.removeEventListener('message', listener)
);
}
}

/**
* Opens the overlay with multi EPG view
*/
openMultiEpgView() {
this.overlayRef = this.overlay.create();
const injector = Injector.create({
providers: [
{ provide: COMPONENT_OVERLAY_REF, useValue: this.overlayRef },
],
});
const componentPortal = new ComponentPortal(
MultiEpgContainerComponent,
undefined,
injector
);
this.overlayRef.addPanelClass('epg-overlay');
this.overlayRef.attach(componentPortal);
}
}
24 changes: 16 additions & 8 deletions src/app/player/player.module.ts
Original file line number Diff line number Diff line change
@@ -1,27 +1,35 @@
import { EpgListItemComponent } from './components/epg-list/epg-list-item/epg-list-item.component';
import { NgModule } from '@angular/core';
import { OverlayModule } from '@angular/cdk/overlay';
import { CommonModule } from '@angular/common';
import { ChannelListContainerComponent } from './components/channel-list-container/channel-list-container.component';
import { HtmlVideoPlayerComponent } from './components/html-video-player/html-video-player.component';
import { VideoPlayerComponent } from './components/video-player/video-player.component';
import { VjsPlayerComponent } from './components/vjs-player/vjs-player.component';
import { NgModule } from '@angular/core';
import { RouterModule, Routes } from '@angular/router';
import { SharedModule } from '../shared/shared.module';
import { EpgListComponent } from './components/epg-list/epg-list.component';
import { ChannelListContainerComponent } from './components/channel-list-container/channel-list-container.component';
import { EpgItemDescriptionComponent } from './components/epg-list/epg-item-description/epg-item-description.component';
import { EpgListItemComponent } from './components/epg-list/epg-list-item/epg-list-item.component';
import { EpgListComponent } from './components/epg-list/epg-list.component';
import { HtmlVideoPlayerComponent } from './components/html-video-player/html-video-player.component';
import { InfoOverlayComponent } from './components/info-overlay/info-overlay.component';
import { MultiEpgContainerComponent } from './components/multi-epg/multi-epg-container.component';
import { VideoPlayerComponent } from './components/video-player/video-player.component';
import { VjsPlayerComponent } from './components/vjs-player/vjs-player.component';

const routes: Routes = [{ path: '', component: VideoPlayerComponent }];

@NgModule({
imports: [CommonModule, RouterModule.forChild(routes), SharedModule],
imports: [
CommonModule,
OverlayModule,
RouterModule.forChild(routes),
SharedModule,
],
declarations: [
ChannelListContainerComponent,
EpgItemDescriptionComponent,
EpgListComponent,
EpgListItemComponent,
InfoOverlayComponent,
HtmlVideoPlayerComponent,
MultiEpgContainerComponent,
VideoPlayerComponent,
VjsPlayerComponent,
],
Expand Down
50 changes: 50 additions & 0 deletions src/app/services/epg.service.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import { inject, TestBed } from '@angular/core/testing';
import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar';
import { TranslateService } from '@ngx-translate/core';
import { MockModule, MockProviders } from 'ng-mocks';
import { ChannelStore } from '../state';
import { DataService } from './data.service';
import { EpgService } from './epg.service';

describe('EpgService', () => {
beforeEach(() => {
TestBed.configureTestingModule({
providers: [
EpgService,
MockProviders(DataService, TranslateService, MatSnackBar),
],
imports: [MockModule(MatSnackBarModule)],
});
});

it('should create a service instance', inject(
[EpgService],
(service: EpgService) => {
expect(service).toBeTruthy();
}
));

it('should show a notification on epg error', inject(
[MatSnackBar, EpgService],
(snackbar: MatSnackBar, service: EpgService) => {
jest.spyOn(snackbar, 'open');
service.onEpgError();
expect(snackbar.open).toHaveBeenCalledTimes(1);
}
));

it('should handle epg download success', inject(
[MatSnackBar, ChannelStore, EpgService],
(
snackbar: MatSnackBar,
channelStore: ChannelStore,
service: EpgService
) => {
jest.spyOn(snackbar, 'open');
jest.spyOn(channelStore, 'setEpgAvailableFlag');
service.onEpgFetchDone();
expect(snackbar.open).toHaveBeenCalledTimes(1);
expect(channelStore.setEpgAvailableFlag).toHaveBeenCalledWith(true);
}
));
});
69 changes: 69 additions & 0 deletions src/app/services/epg.service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
import { Injectable } from '@angular/core';
import { MatSnackBar, MatSnackBarConfig } from '@angular/material/snack-bar';
import { TranslateService } from '@ngx-translate/core';
import { EPG_FETCH } from '../../../shared/ipc-commands';
import { ChannelStore } from '../state';
import { DataService } from './data.service';

@Injectable({
providedIn: 'root',
})
export class EpgService {
/** Default options for epg snackbar notifications */
epgSnackBarOptions: MatSnackBarConfig = {
verticalPosition: 'bottom',
horizontalPosition: 'right',
};

constructor(
private channelStore: ChannelStore,
private electronService: DataService,
private snackBar: MatSnackBar,
private translate: TranslateService
) {}

/**
* Fetches and updates EPG from the given URL
* @param urls epg source urls
*/
fetchEpg(urls: string | string[]): void {
if (!Array.isArray(urls)) {
urls = [urls];
}
urls.forEach((url) =>
this.electronService.sendIpcEvent(EPG_FETCH, {
url,
})
);
this.snackBar.open(
this.translate.instant('EPG.FETCH_EPG'),
this.translate.instant('CLOSE'),
this.epgSnackBarOptions
);
}

/**
* Handles the event when the EPG fetching is done
*/
onEpgFetchDone(): void {
this.channelStore.setEpgAvailableFlag(true);
this.snackBar.open(
this.translate.instant('EPG.DOWNLOAD_SUCCESS'),
null,
{
...this.epgSnackBarOptions,
duration: 2000,
}
);
}

/**
* Handles epg error
*/
onEpgError(): void {
this.snackBar.open(this.translate.instant('EPG.ERROR'), null, {
...this.epgSnackBarOptions,
duration: 2000,
});
}
}
129 changes: 103 additions & 26 deletions src/app/services/pwa.service.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,20 @@
import { HttpClient } from '@angular/common/http';
import { Injectable } from '@angular/core';
import { ApplicationRef, inject, Injectable } from '@angular/core';
import { MatSnackBar } from '@angular/material/snack-bar';
import { SwUpdate } from '@angular/service-worker';
import { guid } from '@datorama/akita';
import { parse } from 'iptv-playlist-parser';
import { NgxIndexedDBService } from 'ngx-indexed-db';
import { catchError, combineLatest, map, switchMap, throwError } from 'rxjs';
import {
catchError,
combineLatest,
concat,
first,
interval,
map,
switchMap,
throwError,
} from 'rxjs';
import { GLOBAL_FAVORITES_PLAYLIST_ID } from '../../../shared/constants';
import {
ERROR,
Expand All @@ -13,12 +24,14 @@ import {
PLAYLIST_PARSE,
PLAYLIST_PARSE_BY_URL,
PLAYLIST_PARSE_RESPONSE,
PLAYLIST_PARSE_TEXT,
PLAYLIST_REMOVE_BY_ID,
PLAYLIST_REMOVE_BY_ID_RESPONSE,
PLAYLIST_SAVE_DETAILS,
PLAYLIST_UPDATE,
PLAYLIST_UPDATE_FAVORITES,
PLAYLIST_UPDATE_POSITIONS,
PLAYLIST_UPDATE_RESPONSE,
} from '../../../shared/ipc-commands';
import {
Playlist,
Expand All @@ -42,6 +55,10 @@ export class PwaService extends DataService {
? 'https://iptvnator-playlist-parser-api.vercel.app/parse?url='
: 'http://localhost:3000/parse?url=';

appRef = inject(ApplicationRef);
snackBar = inject(MatSnackBar);
swUpdate = inject(SwUpdate);

/**
* Creates an instance of PwaService.
* @param dbService database service
Expand All @@ -55,6 +72,36 @@ export class PwaService extends DataService {
console.log('PWA service initialized...');
}

/**
* Uses service worker mechanism to check for available application updates
*/
checkUpdates() {
if (this.swUpdate.isEnabled) {
const appIsStable$ = this.appRef.isStable.pipe(
first((isStable) => isStable === true)
);
const everySixHours$ = interval(6 * 60 * 60 * 1000);
const everySixHoursOnceAppIsStable$ = concat(
appIsStable$,
everySixHours$
);

everySixHoursOnceAppIsStable$.subscribe(() =>
this.swUpdate.checkForUpdate()
);

this.swUpdate.versionUpdates.subscribe(() => {
let snackBarRef = this.snackBar.open(
'Update available',
'Refresh'
);
snackBarRef.onAction().subscribe(() => {
document.location.reload();
});
});
}
}

/**
* Returns the current version of the app
*/
Expand Down Expand Up @@ -150,6 +197,34 @@ export class PwaService extends DataService {
payload: playlist,
});
});
} else if (type === PLAYLIST_PARSE_TEXT) {
try {
const parsedPlaylist = this.parsePlaylist(
payload.text.split('\n')
);
const playlistObject = this.createPlaylistObject(
'Imported as text',
parsedPlaylist
);

// 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,
});
} catch (error) {
window.postMessage({
type: ERROR,
status: '',
message: 'Validation error - invalid playlist',
});
}
} else if (type === PLAYLIST_GET_ALL) {
this.dbService
.getAll(DbStores.Playlists)
Expand Down Expand Up @@ -205,36 +280,38 @@ export class PwaService extends DataService {
type: ERROR,
});
return throwError(() => error);
})
)
.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,
{
}),
map((response) =>
this.convertFileStringToPlaylist(response)
),
switchMap((refreshedPlaylist) =>
this.dbService
.getByID(DbStores.Playlists, payload.id)
.pipe(
switchMap((currentPlaylist: Playlist) =>
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,
});
});
})
)
)
)
)
.subscribe((playlists) => {
console.log('playlist was updated...');
window.postMessage({
type: PLAYLIST_GET_ALL_RESPONSE,
payload: playlists,
});

window.postMessage({
type: PLAYLIST_UPDATE_RESPONSE,
message: `Success! The playlist was successfully updated.`,
});
});
} else if (type === PLAYLIST_UPDATE_POSITIONS) {
const requests = payload.map((playlist, index) => {
Expand Down
8 changes: 5 additions & 3 deletions src/app/services/settings.service.spec.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { TestBed, inject } from '@angular/core/testing';
import { HttpClientTestingModule } from '@angular/common/http/testing';
import { inject, TestBed } from '@angular/core/testing';
import { StorageMap } from '@ngx-pwa/local-storage';
import { of } from 'rxjs';
import { Theme } from '../settings/theme.enum';
Expand All @@ -9,6 +10,7 @@ describe('Service: Settings', () => {
beforeEach(() => {
TestBed.configureTestingModule({
providers: [SettingsService],
imports: [HttpClientTestingModule],
});
});

Expand All @@ -33,8 +35,8 @@ describe('Service: Settings', () => {
));

it('should get value from the local storage', inject(
[SettingsService, StorageMap],
(service: SettingsService, storage: StorageMap) => {
[SettingsService],
(service: SettingsService) => {
const version = '2.1.0';
service.setValueToLocalStorage(STORE_KEY.Version, version);
service
Expand Down
34 changes: 31 additions & 3 deletions src/app/services/settings.service.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,20 @@
import { HttpClient } from '@angular/common/http';
import { Injectable } from '@angular/core';
import { StorageMap } from '@ngx-pwa/local-storage';
import { catchError, map, Observable } from 'rxjs';
import { STORE_KEY } from '../shared/enums/store-keys.enum';
import { Theme } from './../settings/theme.enum';

/** Url of the package.json file in the app repository, required to get the version of the released app */
const PACKAGE_JSON_URL =
'https://raw.githubusercontent.com/4gray/iptvnator/master/package.json';

@Injectable({
providedIn: 'root',
})
export class SettingsService {
/** Creates an instance of SettingsService */
constructor(private storage: StorageMap) {}
constructor(private http: HttpClient, private storage: StorageMap) {}

/**
* Changes the visual theme of the application
Expand All @@ -35,8 +41,30 @@ export class SettingsService {
* Sets the given key/value pair in the local storage
* @param key key to set
* @param value value to set
* @param withCallback if true, the callback will be called after the value is set
*/
setValueToLocalStorage(
key: STORE_KEY,
value: unknown,
withCallback = false
): Observable<unknown> | never {
if (withCallback) {
return this.storage.set(key, value);
} else {
this.storage.set(key, value).subscribe(() => {});
}
}

/**
* Returns the version of the released app
*/
setValueToLocalStorage(key: STORE_KEY, value: unknown) {
this.storage.set(key, value).subscribe(() => {});
getAppVersion() {
return this.http.get<string>(PACKAGE_JSON_URL).pipe(
map((response) => response['version']),
catchError((err) => {
console.error(err);
throw new Error(err);
})
);
}
}
53 changes: 53 additions & 0 deletions src/app/services/whats-new.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -192,6 +192,59 @@ export class WhatsNewService {
},
},
],
'0.11.0': [
{
imageHeight: 400,
imageBgColor: '#333',
imageSrc: './assets/updates/0110/multiple-epg-sources.png',
title: 'Multiple EPG sources',
html: 'In the new version of the application, you can add more than one URL as a source of the EPG program.',
button: {
text: 'NEXT',
textColor: '#ccc',
bgColor: '#111',
},
},
{
imageHeight: 400,
imageBgColor: '#333',
imageSrc: './assets/updates/0110/multi-epg-view.png',
title: '🎉 Multi-EPG view',
html: 'The first version of multi-EPG view was developed, which is familiar to many users from set-top boxes. At this stage the view works in purely informative mode.',
button: {
text: 'NEXT',
textColor: '#ccc',
bgColor: '#111',
},
},
{
imageHeight: 400,
imageBgColor: '#333',
imageSrc: './assets/updates/0110/import-playlist-as-text.png',
title: 'Import playlist from plain text',
html: 'Another playlist import option became available - import m3u(8) as text. Just copy the playlist to the clipboard and paste it into the application without having to save it to disk.',
button: {
text: 'NEXT',
textColor: '#ccc',
bgColor: '#111',
},
},
{
title: 'This&that',
html: `<h2>Improvements in PWA</h2>
A number of visual changes have been made to improve the experience of using the app on mobile devices.
<br />
<h2>Internalization</h2> The localization of the project has been improved, but help is still needed with the translation into different languages.
<br />
<h2>Dependencies updates</h2>
The basic libraries used in the application have been updated (angular, electron etc)`,
button: {
text: 'CLOSE',
textColor: '#ccc',
bgColor: '#111',
},
},
],
};

/** Options for the "what is new" modal dialogs */
Expand Down
Loading