317 changes: 153 additions & 164 deletions angular.json
Original file line number Diff line number Diff line change
@@ -1,176 +1,165 @@
{
"$schema": "./node_modules/@angular/cli/lib/config/schema.json",
"cli": {
"schematicCollections": [
"@angular-eslint/schematics"
]
},
"version": 1,
"newProjectRoot": "projects",
"projects": {
"iptvnator": {
"root": "",
"sourceRoot": "src",
"projectType": "application",
"schematics": {
"@schematics/angular:application": {
"strict": true
}
},
"architect": {
"build": {
"builder": "@angular-builders/custom-webpack:browser",
"options": {
"outputPath": "dist",
"index": "src/index.html",
"main": "src/main.ts",
"tsConfig": "src/tsconfig.app.json",
"polyfills": "src/polyfills.ts",
"assets": [
"src/favicon.ico",
"src/assets",
"src/manifest.webmanifest"
],
"styles": [
"src/styles.scss"
],
"scripts": [],
"customWebpackConfig": {
"path": "./angular.webpack.js",
"replaceDuplicatePlugins": true
},
"serviceWorker": true,
"ngswConfigPath": "ngsw-config.json",
"webWorkerTsConfig": "tsconfig.worker.json"
},
"configurations": {
"dev": {
"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.dev.ts"
}
]
},
"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"
"$schema": "./node_modules/@angular/cli/lib/config/schema.json",
"cli": {
"schematicCollections": ["@angular-eslint/schematics"],
"analytics": false
},
"version": 1,
"newProjectRoot": "projects",
"projects": {
"iptvnator": {
"root": "",
"sourceRoot": "src",
"projectType": "application",
"schematics": {
"@schematics/angular:application": {
"strict": true,
"style": "scss"
}
]
},
"production": {
"optimization": true,
"outputHashing": "all",
"sourceMap": false,
"namedChunks": false,
"aot": true,
"extractLicenses": true,
"vendorChunk": false,
"buildOptimizer": true,
"fileReplacements": [
{
"replace": "src/environments/environment.ts",
"with": "src/environments/environment.prod.ts"
"architect": {
"build": {
"builder": "@angular-builders/custom-webpack:browser",
"options": {
"outputPath": "dist",
"index": "src/index.html",
"main": "src/main.ts",
"tsConfig": "src/tsconfig.app.json",
"polyfills": "src/polyfills.ts",
"assets": [
"src/favicon.ico",
"src/assets",
"src/manifest.webmanifest"
],
"styles": ["src/styles.scss"],
"scripts": [],
"customWebpackConfig": {
"path": "./angular.webpack.js",
"replaceDuplicatePlugins": true
},
"serviceWorker": true,
"ngswConfigPath": "ngsw-config.json",
"webWorkerTsConfig": "tsconfig.worker.json"
},
"configurations": {
"dev": {
"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.dev.ts"
}
]
},
"web": {
"optimization": true,
"outputHashing": "all",
"sourceMap": false,
"namedChunks": false,
"aot": true,
"extractLicenses": true,
"vendorChunk": false,
"buildOptimizer": true,
"fileReplacements": [
{
"replace": "src/environments/environment.ts",
"with": "src/environments/environment.web.ts"
}
]
},
"production": {
"optimization": true,
"outputHashing": "all",
"sourceMap": false,
"namedChunks": false,
"aot": true,
"extractLicenses": true,
"vendorChunk": false,
"buildOptimizer": true,
"fileReplacements": [
{
"replace": "src/environments/environment.ts",
"with": "src/environments/environment.prod.ts"
}
]
}
}
},
"serve": {
"builder": "@angular-builders/custom-webpack:dev-server",
"options": {
"browserTarget": "iptvnator:build"
},
"configurations": {
"dev": {
"browserTarget": "iptvnator:build:dev"
},
"web": {
"browserTarget": "iptvnator:build:web"
},
"production": {
"browserTarget": "iptvnator:build:production"
}
}
},
"extract-i18n": {
"builder": "@angular-devkit/build-angular:extract-i18n",
"options": {
"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
},
"webWorkerTsConfig": "tsconfig.worker.json"
}
},
"lint": {
"builder": "@angular-eslint/builder:lint",
"options": {
"lintFilePatterns": ["src/**/*.ts", "src/**/*.html"]
}
}
]
}
}
},
"serve": {
"builder": "@angular-builders/custom-webpack:dev-server",
"options": {
"browserTarget": "iptvnator:build"
},
"configurations": {
"dev": {
"browserTarget": "iptvnator:build:dev"
},
"web": {
"browserTarget": "iptvnator:build:web"
},
"production": {
"browserTarget": "iptvnator:build:production"
"iptvnator-e2e": {
"root": "e2e",
"projectType": "application",
"architect": {
"lint": {
"builder": "@angular-eslint/builder:lint",
"options": {
"lintFilePatterns": ["e2e/**/*.ts"]
}
}
}
}
},
"extract-i18n": {
"builder": "@angular-devkit/build-angular:extract-i18n",
"options": {
"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
},
"webWorkerTsConfig": "tsconfig.worker.json"
}
},
"lint": {
"builder": "@angular-eslint/builder:lint",
"options": {
"lintFilePatterns": [
"src/**/*.ts",
"src/**/*.html"
]
}
}
}
},
"iptvnator-e2e": {
"root": "e2e",
"projectType": "application",
"architect": {
"lint": {
"builder": "@angular-eslint/builder:lint",
"options": {
"lintFilePatterns": [
"e2e/**/*.ts"
]
}
"schematics": {
"@schematics/angular:component": {
"prefix": "app",
"style": "scss"
},
"@schematics/angular:directive": {
"prefix": "app"
}
}
}
},
"schematics": {
"@schematics/angular:component": {
"prefix": "app",
"style": "scss"
},
"@schematics/angular:directive": {
"prefix": "app"
}
}
}
31 changes: 31 additions & 0 deletions docker/Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
# Stage 1 - build environment
FROM node:18-alpine AS build

RUN apk add --no-cache python3 make g++

# Create app directory
WORKDIR /usr/src/app

# Swtich to node user
#RUN chown node:node ./
#USER node

COPY package*.json ./

# Install app dependencies
RUN npm ci && npm cache clean --force

# Copy all required files
COPY . .

# Build the application
RUN npm run build:web

# Stage 2 - the production environment
FROM nginx:stable-alpine

# Copy artifacts and nignx.conf
COPY --from=build /usr/src/app/dist/ /usr/share/nginx/html
COPY --from=build /usr/src/app/docker/nginx.conf /etc/nginx/conf.d/default.conf

CMD sed -i "s#http://localhost:3333#$BACKEND_URL#g" /usr/share/nginx/html/main.*.js && nginx -g 'daemon off;'
16 changes: 16 additions & 0 deletions docker/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
# Self-hosted version of IPTVnator

You can deploy and run the PWA version of IPTVnator on your own machine with `docker-compose` using the following command:

$ cd docker
$ docker-compose up -d

This command will launch the frontend and backend applications. By default, the application will be available at: http://localhost:4333/. The ports can be configured in the `docker-compose.yml` file.

## Build frontend

$ docker build -t 4gray/iptvnator -f docker/Dockerfile .

## Build backend

You can find the backend app with all instructions in a separate GitHub repository - https://github.com/4gray/iptvnator-backend
17 changes: 17 additions & 0 deletions docker/docker-compose.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
version: '3'

services:
backend:
image: 4gray/iptvnator-backend:latest
ports:
- "7333:3000"
environment:
- CLIENT_URL=http://localhost:4333 #this one should match with the address and port in frontend CLIENT_URL env

frontend:
image: 4gray/iptvnator:latest
ports:
- "4333:80"
environment:
- BACKEND_URL=http://192.168.172.67:7333 # this one should match with the address of the backend service

11 changes: 11 additions & 0 deletions docker/nginx.conf
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
server {
listen 80;

location / {
root /usr/share/nginx/html;
index index.html index.htm;
try_files $uri $uri/ /index.html =404;
}

include /etc/nginx/extra-conf.d/*.conf;
}
97 changes: 97 additions & 0 deletions e2e/basic.e2e.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
import { expect, test } from '@playwright/test';
import {
BrowserContext,
ElectronApplication,
Page,
_electron as electron,
} from 'playwright';
const PATH = require('path');
const fs = require('fs');

let app: ElectronApplication;
let page: Page;
let context: BrowserContext;

test.beforeAll(async () => {
app = await electron.launch({
args: [PATH.join(__dirname, '../electron/main.js')],
env: {
e2e: 'true',
},
});

context = app.context();
await context.tracing.start({ screenshots: true, snapshots: true });

page = await app.firstWindow();
const isMainWindow = (await page.title()) === 'IPTVnator';
if (!isMainWindow) {
page = app.windows()[1];
}
await page.waitForLoadState('domcontentloaded');
});

test.describe('Check Home Page', () => {
test('Launch electron app', async () => {
const windowState = await app.evaluate((electronProcess) => {
let mainWindow = electronProcess.BrowserWindow.getAllWindows()[0];
const isMainWindow = mainWindow.title === 'IPTVnator';
if (!isMainWindow) {
mainWindow = electronProcess.BrowserWindow.getAllWindows()[1];
}

return {
isVisible: mainWindow.isVisible(),
isDevToolsOpened: mainWindow.webContents.isDevToolsOpened(),
isCrashed: mainWindow.webContents.isCrashed(),
};
});

expect(windowState.isVisible).toBeTruthy();
expect(windowState.isDevToolsOpened).toBeFalsy();
expect(windowState.isCrashed).toBeFalsy();
expect(app.windows()).toHaveLength(2);
});

// eslint-disable-next-line no-empty-pattern
test('Check title of the application', async ({}, testInfo) => {
const title = await page.title();
const screenshot = await page.screenshot({
path: `./e2e/screenshots/home/${testInfo.title}.png`,
});
await testInfo.attach('screenshot', {
body: screenshot,
contentType: 'image/png',
});
expect(title).toBe('IPTVnator');
});
});

test.describe('Upload playlists', () => {
test('should upload m3u playlist via file upload', async () => {
await page.click('"Add via file upload"');
await page.setInputFiles(
'input[type="file"]',
'./e2e/fixtures/test.m3u'
);
await expect(page.getByTestId('channel-item')).toHaveCount(4);
});
});

test.afterAll(async () => {
deleteDbFile();
await page.close();
await app.close();
});

function deleteDbFile() {
const pathToFile = './e2e/db/data.db';

try {
fs.unlink(pathToFile, () => {
console.log('db file was deleted');
});
} catch (error) {
console.log('db file not found');
}
}
58 changes: 0 additions & 58 deletions e2e/basic.test.ts

This file was deleted.

11 changes: 11 additions & 0 deletions e2e/fixtures/test.m3u
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
#EXTM3U url-tvg="http://xml-url/path/full.xml.gz"
#EXTINF:0 tvg-id="1" tvg-logo="http://channel.icons.url/img/1.png" catchup="shift" catchup-days="3" timeshift="3" group-title="News", Channel 1
#EXTGRP:News
https://example.channels/path-to-file/1.m3u8
#EXTINF:0 tvg-id="2" tvg-logo="http://channel.icons.url/img/2.png" catchup="shift" catchup-days="3" timeshift="3" group-title="News", Positive News TV
#EXTGRP:News
https://example.channels/path-to-file/2.m3u8
#EXTINF:0 tvg-id="3" tvg-logo="http://channel.icons.url/img/3.png" catchup="shift" catchup-days="3" timeshift="3" group-title="Sport", Sport TVX
#EXTGRP:Sport
https://example.channels/path-to-file/3.m3u8
#EXTINF:0 tvg-id="4" tvg-logo="http://channel.icons.url/img/4.png" catchup="shift" catchup-days="3" timeshift="3" group-title="Kids", HappyKids TV
8 changes: 0 additions & 8 deletions e2e/playwright.config.ts

This file was deleted.

104 changes: 104 additions & 0 deletions e2e/settings.e2e.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
import { expect, test } from '@playwright/test';
import {
BrowserContext,
ElectronApplication,
Page,
_electron as electron,
} from 'playwright';
const PATH = require('path');

let app: ElectronApplication;
let page: Page;
let context: BrowserContext;

test.beforeAll(async () => {
app = await electron.launch({
args: [PATH.join(__dirname, '../electron/main.js')],
env: {
e2e: 'true',
},
});

context = app.context();
await context.tracing.start({ screenshots: true, snapshots: true });

page = await app.firstWindow();
const isMainWindow = (await page.title()) === 'IPTVnator';
if (!isMainWindow) {
page = app.windows()[1];
}
await page.waitForLoadState('domcontentloaded');
});

test.describe('Settings', () => {
test('Check settings page', async () => {
await page.getByTestId('open-settings').click();
await expect(page.getByTestId('settings-container')).toBeVisible();
await page.getByTestId('back-to-home').click();
});

test('Change video player', async () => {
await page.getByTestId('open-settings').click();

await expect(page.locator('text="VideoJs Player"')).toBeVisible();
await page.getByTestId('select-video-player').click();
await page.getByTestId('html5').click();

await page.getByTestId('save-settings').click();
await page.getByTestId('back-to-home').click();
await page.getByTestId('open-settings').click();

await expect(page.locator('text="HTML5 Video Player"')).toBeVisible();
});

test('Change app theme', async () => {
await page.getByTestId('open-settings').click();

await expect(page.locator('text="Light theme"')).toBeVisible();
await page.getByTestId('select-theme').click();
await page.getByTestId('DARK_THEME').click();

await page.getByTestId('save-settings').click();
await page.getByTestId('back-to-home').click();
await page.getByTestId('open-settings').click();

await expect(page.locator('text="Dark theme"')).toBeVisible();
});

test('Change app language', async () => {
await page.getByTestId('open-settings').click();

await expect(page.locator('text="English"')).toBeVisible();
await page.getByTestId('select-language').click();
await page.getByTestId('de').click();

await page.getByTestId('save-settings').click();
await page.getByTestId('back-to-home').click();
await page.getByTestId('open-settings').click();

await expect(page.locator('text="Deutsch"')).toBeVisible();
});
});

test.beforeEach(async () => {
await page.evaluate(async () => {
const dbNames = (await window.indexedDB.databases()).map(
(db) => db.name
);
dbNames.forEach((name) =>
name !== undefined ? window.indexedDB.deleteDatabase(name) : null
);
});
});

// eslint-disable-next-line no-empty-pattern
test.afterEach(async ({}, testInfo) => {
await page.screenshot({
path: `./e2e/screenshots/home/${testInfo.title}.png`,
});
});

test.afterAll(async () => {
await page.close();
await app.close();
});
26 changes: 9 additions & 17 deletions electron-builder.json
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
{
"asar": true,
"productName": "IPTVnator",
"directories": {
"output": "release/"
Expand All @@ -21,7 +22,10 @@
"!tsconfig.*.json",
"!tslint.json",
"!*.png",
"!coverage"
"!coverage",
"!e2e",
"!playwright-report",
"!test-report"
],
"win": {
"icon": "dist/assets/icons",
Expand All @@ -44,22 +48,10 @@
"icon": "dist/assets/icons",
"category": "Video",
"target": [
{
"target": "AppImage",
"arch": ["x64", "armv7l", "arm64"]
},
{
"target": "deb",
"arch": ["x64", "armv7l", "arm64"]
},
{
"target": "rpm",
"arch": ["x64", "arm64", "armv7l"]
},
{
"target": "Snap",
"arch": ["x64", "arm64", "armv7l"]
}
"AppImage",
"Snap",
"deb",
"rpm"
]
}
}
690 changes: 267 additions & 423 deletions electron/api.ts

Large diffs are not rendered by default.

26 changes: 20 additions & 6 deletions electron/epg-worker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
EPG_ERROR,
EPG_FETCH,
EPG_FETCH_DONE,
EPG_FORCE_FETCH,
EPG_GET_CHANNELS,
EPG_GET_CHANNELS_BY_RANGE,
EPG_GET_CHANNELS_BY_RANGE_RESPONSE,
Expand All @@ -27,6 +28,9 @@ let EPG_DATA_MERGED: {
} = {};
const loggerLabel = '[EPG Worker]';

/** List with fetched EPG URLs */
const fetchedUrls: string[] = [];

/**
* Fetches the epg data from the given url
* @param epgUrl url of the epg file
Expand Down Expand Up @@ -81,12 +85,13 @@ const parseAndSetEpg = (xmlString) => {
};

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

EPG_DATA?.programs?.forEach((program) => {
if (!result[program.channel]) {
// eslint-disable-next-line @typescript-eslint/no-unnecessary-type-assertion
const channel = EPG_DATA?.channels?.find(
(channel) => channel.id === program.channel
) as EpgChannel;
Expand All @@ -105,15 +110,20 @@ const convertEpgData = () => {
};

// fetches epg data from the provided URL
ipcRenderer.on(EPG_FETCH, (event, arg) => {
ipcRenderer.on(EPG_FETCH, (event, epgUrl: string) => {
console.log(loggerLabel, 'epg fetch command was triggered');
fetchEpgDataFromUrl(arg);
if (fetchedUrls.indexOf(epgUrl) > -1) {
ipcRenderer.send(EPG_FETCH_DONE);
return;
}
fetchedUrls.push(epgUrl);
fetchEpgDataFromUrl(epgUrl);
});

// returns the epg data for the provided channel name and date
ipcRenderer.on(EPG_GET_PROGRAM, (event, args) => {
const channelName = args.channel.name;
const tvgId = args.channel.tvg?.id;
const channelName = args.channel?.name;
const tvgId = args.channel?.tvg?.id;
if (!EPG_DATA || !EPG_DATA.channels) return;
const foundChannel = EPG_DATA?.channels?.find((epgChannel) => {
if (tvgId && tvgId === epgChannel.id) {
Expand Down Expand Up @@ -146,7 +156,7 @@ ipcRenderer.on(EPG_GET_PROGRAM, (event, args) => {
}
});

ipcRenderer.on(EPG_GET_CHANNELS, (event, args) => {
ipcRenderer.on(EPG_GET_CHANNELS, () => {
ipcRenderer.send(EPG_GET_CHANNELS_DONE, {
payload: EPG_DATA,
});
Expand All @@ -159,3 +169,7 @@ ipcRenderer.on(EPG_GET_CHANNELS_BY_RANGE, (event, args) => {
.map((entry) => entry[1]),
});
});

ipcRenderer.on(EPG_FORCE_FETCH, (event, url: string) => {
fetchEpgDataFromUrl(url);
});
36 changes: 32 additions & 4 deletions electron/main.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
/* eslint-disable no-useless-catch */
import { app, BrowserWindow, Menu } from 'electron';
import { app, BrowserWindow, globalShortcut, Menu } from 'electron';
import * as path from 'path';
import * as url from 'url';
import { Api } from './api';
Expand All @@ -9,6 +9,10 @@ const {
attachTitlebarToWindow,
} = require('custom-electron-titlebar/main');
const contextMenu = require('electron-context-menu');
const Store = require('electron-store');
const store = new Store();

const WINDOW_BOUNDS = 'WINDOW_BOUNDS';

setupTitlebar();
let win: BrowserWindow | null = null;
Expand Down Expand Up @@ -36,9 +40,10 @@ function createWindow(): BrowserWindow {
icon: path.join(__dirname, '../dist/assets/icons/icon.png'),
titleBarStyle: 'hidden',
frame: false,
minWidth: 900,
minHeight: 700,
minWidth: 400,
minHeight: 500,
title: 'IPTVnator',
...store.get(WINDOW_BOUNDS),
});
attachTitlebarToWindow(win);

Expand All @@ -57,6 +62,10 @@ function createWindow(): BrowserWindow {
);
}

win.on('close', () => {
store.set(WINDOW_BOUNDS, win?.getNormalBounds());
});

// Emitted when the window is closed.
win.on('closed', () => {
// Dereference the window object, usually you would store window
Expand All @@ -69,9 +78,21 @@ function createWindow(): BrowserWindow {
}
});

registerGlobalShortcuts();
return win;
}

/**
* Registers common keyboard shortcuts
*/
function registerGlobalShortcuts() {
if (process.platform === 'darwin') {
globalShortcut.register('Command+Q', () => {
app.quit();
});
}
}

/**
* Creates hidden window for EPG worker
* Hidden window is used as an additional thread to avoid blocking of the UI by long operations
Expand Down Expand Up @@ -126,9 +147,16 @@ try {
// On OS X it's common to re-create a window in the app when the
// dock icon is clicked and there are no other windows open.
if (win === null) {
createWindow();
win = createWindow();
const menu = new AppMenu(win);
Menu.setApplicationMenu(menu.getMenu());
api.setMainWindow(win);
}
});

app.on('before-quit', () => {
store.set(WINDOW_BOUNDS, win?.getNormalBounds());
});
} catch (e) {
// Catch Error
throw e;
Expand Down
33 changes: 23 additions & 10 deletions electron/menu.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,15 +51,20 @@ export class AppMenu {
submenu: [
{
label: 'Add playlist',
click: () =>
this.window.webContents.send(VIEW_ADD_PLAYLIST),
click: () => {
if (!this.window.isDestroyed())
this.window.webContents.send(VIEW_ADD_PLAYLIST);
},
},
{
type: 'separator',
},
{
label: 'Settings',
click: () => this.window.webContents.send(VIEW_SETTINGS),
click: () => {
if (!this.window.isDestroyed())
this.window.webContents.send(VIEW_SETTINGS);
},
},
{
type: 'separator',
Expand All @@ -82,25 +87,33 @@ export class AppMenu {
submenu: [
{
label: 'What is new',
click: () => this.window.webContents.send(SHOW_WHATS_NEW),
click: () => {
if (!this.window.isDestroyed())
this.window.webContents.send(SHOW_WHATS_NEW);
},
},
{
label: 'Report a bug',
click: () =>
click: () => {
shell.openExternal(
'https://github.com/4gray/iptvnator'
),
);
},
},
{
label: 'Buy me a coffee',
click: () =>
click: () => {
shell.openExternal(
'https://www.buymeacoffee.com/4gray'
),
);
},
},
{
label: 'Open DevTools',
click: () => this.window.webContents.openDevTools(),
click: () => {
if (!this.window.isDestroyed())
this.window.webContents.openDevTools();
},
},
{
type: 'separator',
Expand All @@ -114,7 +127,7 @@ export class AppMenu {
'../dist/assets/icons/icon.png'
),
copyright: 'Copyright (c) 2020-2022 4gray',
package_json_dir: __dirname,
package_json_dir: path.join(__dirname, '../'),
}),
},
],
Expand Down
7 changes: 0 additions & 7 deletions electron/package.json

This file was deleted.

22,830 changes: 8,407 additions & 14,423 deletions package-lock.json

Large diffs are not rendered by default.

116 changes: 60 additions & 56 deletions package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "iptvnator",
"version": "0.11.1",
"version": "0.12.0",
"description": "IPTV player application.",
"homepage": "https://github.com/4gray/iptvnator",
"author": {
Expand Down Expand Up @@ -28,6 +28,7 @@
"build:electron": "npm run electron:serve-tsc",
"build:dev": "npm run build -- -c dev",
"build:prod": "npm run build -- -c production",
"build:web": "npm run build -- -c web",
"ng:build": "ng build -c production",
"ng:serve": "ng serve -c dev",
"ng:serve:web": "ng serve -c web -o",
Expand All @@ -40,106 +41,109 @@
"electron:build:windows": "npm run build:prod && electron-builder build -w",
"test": "jest",
"test:watch": "jest --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/",
"e2e": "npm run build:electron && npx playwright test --retries 3",
"e2e:ci": "npx playwright test --retries 3 --workers=1",
"e2e:debug": "npm run build:electron && PWDEBUG=1 npx playwright test -c e2e/playwright.config.ts e2e/ /",
"e2e:html-report": "npm run build:electron && npm run e2e -- --reporter=html",
"e2e:show-report": "npx playwright show-report",
"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": "14.0.6",
"@angular/animations": "15.1.1",
"@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.8",
"@ngx-pwa/local-storage": "13.0.5",
"@videojs/http-streaming": "2.13.1",
"@ngrx/effects": "15.1.0",
"@ngrx/entity": "15.1.0",
"@ngrx/store": "15.1.0",
"@ngrx/store-devtools": "15.1.0",
"@ngx-pwa/local-storage": "15.0.0",
"@videojs/http-streaming": "2.15.0",
"@yangkghjh/videojs-aspect-ratio-panel": "0.0.1",
"about-window": "1.15.2",
"axios": "0.21.2",
"custom-electron-titlebar": "4.1.0",
"date-fns": "2.28.0",
"electron-context-menu": "3.1.1",
"epg-parser": "0.1.1",
"hls.js": "1.2.1",
"date-fns": "2.29.3",
"electron-context-menu": "3.5.0",
"electron-store": "8.1.0",
"epg-parser": "0.1.6",
"hls.js": "1.2.7",
"iptv-playlist-parser": "0.8.0",
"lodash": "4.17.21",
"material-icons": "0.3.1",
"moment": "2.29.4",
"nedb-promises": "6.0.3",
"ngx-filter-pipe": "2.1.2",
"ngx-indexed-db": "9.4.2",
"ngx-whats-new": "0.3.0",
"rxjs": "7.5.6",
"semver": "7.3.5",
"uuid": "8.3.2",
"video.js": "7.20.2",
"videojs-contrib-quality-levels": "2.1.0",
"ngx-skeleton-loader": "7.0.0",
"ngx-whats-new": "0.4.0",
"rxjs": "7.8.0",
"semver": "7.3.8",
"uuid": "9.0.0",
"video.js": "7.20.3",
"videojs-contrib-quality-levels": "2.2.0",
"videojs-hls-quality-selector": "1.1.4",
"zone.js": "~0.11.4"
},
"devDependencies": {
"@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",
"@angular-builders/custom-webpack": "15.0.0",
"@angular-devkit/build-angular": "15.1.2",
"@angular-eslint/builder": "15.2.0",
"@angular-eslint/eslint-plugin": "15.2.0",
"@angular-eslint/eslint-plugin-template": "15.2.0",
"@angular-eslint/schematics": "15.2.0",
"@angular-eslint/template-parser": "15.2.0",
"@angular/cli": "15.1.2",
"@angular/common": "15.1.1",
"@angular/compiler": "15.1.1",
"@angular/compiler-cli": "15.1.1",
"@angular/core": "15.1.1",
"@angular/forms": "15.1.1",
"@angular/language-service": "15.1.1",
"@angular/platform-browser": "15.1.1",
"@angular/platform-browser-dynamic": "15.1.1",
"@angular/router": "15.1.1",
"@angular/service-worker": "15.1.1",
"@commitlint/cli": "16.0.1",
"@commitlint/config-conventional": "16.0.0",
"@ngrx/eslint-plugin": "^14.3.2",
"@ngx-translate/core": "14.0.0",
"@ngx-translate/http-loader": "7.0.0",
"@playwright/test": "1.26.0",
"@playwright/test": "1.31.1",
"@semantic-release/changelog": "6.0.1",
"@semantic-release/git": "10.0.1",
"@semantic-release/npm": "9.0.0",
"@types/chai": "4.3.0",
"@types/jest": "28.1.5",
"@types/mocha": "9.0.0",
"@types/node": "16.11.7",
"@types/node": "18.11.18",
"@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",
"@typescript-eslint/eslint-plugin": "5.40.1",
"@typescript-eslint/eslint-plugin-tslint": "5.40.1",
"@typescript-eslint/parser": "5.40.1",
"conventional-changelog-cli": "2.2.2",
"core-js": "3.6.5",
"cross-env": "7.0.3",
"electron": "20.2.0",
"electron-builder": "23.3.3",
"eslint": "7.32.0",
"eslint-plugin-import": "2.25.2",
"electron": "22.0.0",
"electron-builder": "23.6.0",
"eslint": "8.26.0",
"eslint-plugin-import": "2.26.0",
"husky": "5.0.9",
"jest": "28.1.3",
"jest-preset-angular": "12.2.0",
"ng-mocks": "14.0.2",
"jest-preset-angular": "12.2.5",
"ng-mocks": "14.6.0",
"node-polyfill-webpack-plugin": "1.1.4",
"npm-run-all": "4.1.5",
"playwright": "1.26.0",
"playwright-core": "1.26.0",
"playwright": "1.31.1",
"playwright-core": "1.31.1",
"prettier": "2.5.1",
"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",
"ts-node": "10.9.1",
"tslib": "2.3.0",
"typescript": "4.7.4",
"typescript": "4.9.4",
"wait-on": "6.0.0"
},
"engines": {
Expand Down
38 changes: 38 additions & 0 deletions playwright.config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import { devices, PlaywrightTestConfig } from '@playwright/test';

const config: PlaywrightTestConfig = {
testDir: './e2e',
timeout: 45000,
maxFailures: 2,
testMatch: /.*\.e2e\.ts/,
projects: [
{
name: 'chromium',
use: {
...devices['Desktop Chrome'],
serviceWorkers: 'block',
},
},
/* {
name: 'firefox',
use: {
...devices['Desktop Firefox'],
serviceWorkers: 'block',
},
},
{
name: 'webkit',
use: {
...devices['Desktop Safari'],
serviceWorkers: 'block',
},
}, */
],
use: {
headless: false,
screenshot: 'only-on-failure',
testIdAttribute: 'data-test-id',
},
};

export default config;
23 changes: 13 additions & 10 deletions shared/ipc-commands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,22 +9,13 @@ 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';
export const EPG_FORCE_FETCH = 'EPG:EPG_FORCE_FETCH';

// 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_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';
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';
Expand All @@ -37,3 +28,15 @@ export const CHANNEL_SET_USER_AGENT = 'CHANNEL:SET_USER_AGENT';
// Views
export const VIEW_SETTINGS = 'VIEW:SETTINGS';
export const VIEW_ADD_PLAYLIST = 'VIEW:PLAYLISTS';

// Migration
export const IS_PLAYLISTS_MIGRATION_POSSIBLE = 'MIGRATION_POSSIBLE';
export const IS_PLAYLISTS_MIGRATION_POSSIBLE_RESPONSE =
'MIGRATION_POSSIBLE_RESPONSE';
export const MIGRATE_PLAYLISTS = 'MIGRATE_PLAYLISTS';
export const MIGRATE_PLAYLISTS_RESPONSE = 'MIGRATE_PLAYLISTS_RESPONSE';
export const DELETE_ALL_PLAYLISTS = 'DELETE_ALL_PLAYLISTS';

// Auto-update
export const AUTO_UPDATE_PLAYLISTS = 'AUTO_UPDATE';
export const AUTO_UPDATE_PLAYLISTS_RESPONSE = 'AUTO_UPDATE_RESPONSE';
6 changes: 3 additions & 3 deletions shared/playlist.interface.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,3 @@
import { ID } from '@datorama/akita';

/**
* An interface that describe the possible states of the playlist update/refresh process
*/
Expand All @@ -13,14 +11,15 @@ export enum PlaylistUpdateState {
* Describes playlist interface
*/
export interface Playlist {
id: ID;
_id: string;
title: string;
filename: string;
playlist: any;
importDate: string;
lastUsage: string;
favorites: string[];
items?: unknown[];
header?: unknown;
count: number;
url?: string;
userAgent?: string;
Expand All @@ -29,4 +28,5 @@ export interface Playlist {
updateDate?: number;
updateState?: PlaylistUpdateState;
position?: number;
isTemporary?: boolean;
}
48 changes: 47 additions & 1 deletion shared/playlist.utils.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { v4 as uuidv4 } from 'uuid';
import { ParsedPlaylist } from '../src/typings';
import { Channel } from './channel.interface';
import { GLOBAL_FAVORITES_PLAYLIST_ID } from './constants';
import { Playlist } from './playlist.interface';
Expand Down Expand Up @@ -33,7 +35,6 @@ export function createFavoritesPlaylist(
channels: Channel[]
): Partial<Playlist> {
return {
id: GLOBAL_FAVORITES_PLAYLIST_ID,
_id: GLOBAL_FAVORITES_PLAYLIST_ID,
count: channels.length,
playlist: {
Expand All @@ -42,3 +43,48 @@ export function createFavoritesPlaylist(
filename: 'Global favorites',
};
}

/**
* Returns last segment (part after last slash "/") of the given URL
* @param value URL as string
*/
export const getFilenameFromUrl = (value: string): string => {
if (value && value.length > 1) {
return value.substring(value.lastIndexOf('/') + 1);
}
return 'Untitled playlist';
};

/**
* 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
* @param uploadType upload type - by file or via an url
*/
export const createPlaylistObject = (
name: string,
playlist: ParsedPlaylist,
urlOrPath?: string,
uploadType?: 'URL' | 'FILE' | 'TEXT'
): Playlist => {
return {
_id: uuidv4(),
filename: name,
title: name,
count: playlist.items.length,
playlist: {
...playlist,
items: playlist.items.map((item) => ({
id: uuidv4(),
...item,
})),
},
importDate: new Date().toISOString(),
lastUsage: new Date().toISOString(),
favorites: [],
autoRefresh: false,
...(uploadType === 'URL' ? { url: urlOrPath } : {}),
...(uploadType === 'FILE' ? { filePath: urlOrPath } : {}),
};
};
20 changes: 16 additions & 4 deletions src/app/app-routing.module.ts
Original file line number Diff line number Diff line change
@@ -1,21 +1,33 @@
import { NgModule } from '@angular/core';
import { Routes, RouterModule } from '@angular/router';
import { RouterModule, Routes } from '@angular/router';

const routes: Routes = [
{
path: '',
loadChildren: () =>
import('./home/home.module').then((m) => m.HomeModule),
},
{
path: 'playlists',
loadChildren: () =>
import('./player/player.module').then((m) => m.PlayerModule),
},
{
path: 'iptv',
loadChildren: () =>
import('./player/player.module').then((m) => m.PlayerModule),
},
{
path: 'settings',
path: 'playlists/:id',
loadChildren: () =>
import('./settings/settings.module').then((m) => m.SettingsModule),
import('./player/player.module').then((m) => m.PlayerModule),
},
{
path: 'settings',
loadComponent: () =>
import('./settings/settings.component').then(
(c) => c.SettingsComponent
),
},
{
path: '**',
Expand All @@ -24,7 +36,7 @@ const routes: Routes = [
];

@NgModule({
imports: [RouterModule.forRoot(routes, { relativeLinkResolution: 'legacy' })],
imports: [RouterModule.forRoot(routes, {})],
exports: [RouterModule],
})
export class AppRoutingModule {}
25 changes: 17 additions & 8 deletions src/app/app.component.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,24 +8,23 @@ import {
import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar';
import { Router } from '@angular/router';
import { RouterTestingModule } from '@angular/router/testing';
import { provideMockStore } from '@ngrx/store/testing';
import { TranslatePipe, TranslateService } from '@ngx-translate/core';
import { MockModule, MockPipe, MockProviders } from 'ng-mocks';
import { NgxIndexedDBService } from 'ngx-indexed-db';
import { NgxWhatsNewModule } from 'ngx-whats-new';
import { of } from 'rxjs';
import { AppComponent } from './app.component';
import { DataService } from './services/data.service';
import { ElectronServiceStub } from './services/electron.service.stub';
import { PlaylistsService } from './services/playlists.service';
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';

class MatSnackBarStub {
open(): void {}
}

jest.spyOn(global.console, 'error').mockImplementation(() => {});

describe('AppComponent', () => {
Expand All @@ -42,14 +41,19 @@ describe('AppComponent', () => {
TestBed.configureTestingModule({
declarations: [AppComponent, MockPipe(TranslatePipe)],
providers: [
{ provide: MatSnackBar, useClass: MatSnackBarStub },
{ provide: WhatsNewService, useClass: WhatsNewServiceStub },
MockProviders(TranslateService),
SettingsService, // TODO: stub
MockProviders(
TranslateService,
PlaylistsService,
NgxIndexedDBService,
MatSnackBar
),
SettingsService,
{
provide: DataService,
useClass: ElectronServiceStub,
},
provideMockStore(),
],
imports: [
MockModule(MatSnackBarModule),
Expand All @@ -68,6 +72,9 @@ describe('AppComponent', () => {
translateService = TestBed.inject(TranslateService);
whatsNewService = TestBed.inject(WhatsNewService);
component = fixture.componentInstance;

// TODO: investigate in detail
component.triggerAutoUpdateMechanism = jest.fn();
component.modals = [];
fixture.detectChanges();
});
Expand Down Expand Up @@ -107,7 +114,9 @@ describe('AppComponent', () => {
it('should remove all ipc listeners on destroy', () => {
jest.spyOn(electronService, 'removeAllListeners');
component.ngOnDestroy();
expect(electronService.removeAllListeners).toHaveBeenCalledTimes(4);
expect(electronService.removeAllListeners).toHaveBeenCalledTimes(
component.commandsList.length
);
});

it('should navigate to the provided route', inject(
Expand Down
85 changes: 58 additions & 27 deletions src/app/app.component.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,14 @@
import { Component, NgZone } from '@angular/core';
import { MatSnackBar } from '@angular/material/snack-bar';
import { Router } from '@angular/router';
import { Store } from '@ngrx/store';
import { TranslateService } from '@ngx-translate/core';
import { ModalWindow } from 'ngx-whats-new/lib/modal-window.interface';
import { firstValueFrom } from 'rxjs';
import * as semver from 'semver';
import { IpcCommand } from '../../shared/ipc-command.class';
import {
AUTO_UPDATE_PLAYLISTS,
EPG_ERROR,
EPG_FETCH_DONE,
ERROR,
Expand All @@ -16,13 +19,14 @@ import {
} from '../../shared/ipc-commands';
import { DataService } from './services/data.service';
import { EpgService } from './services/epg.service';
import { PlaylistsService } from './services/playlists.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 * as PlaylistActions from './state/actions';
/**
* AppComponent
*/
Expand All @@ -47,29 +51,23 @@ export class AppComponent {
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 }
);
}
new IpcCommand(ERROR, (response: { message: string; status: number }) =>
this.showErrorAsNotification(response)
),
];

/** Default language as fallback */
DEFAULT_LANG = Language.ENGLISH;

/**
* Creates an instance of AppComponent
*/
listeners = [];

constructor(
private electronService: DataService,
private epgService: EpgService,
private ngZone: NgZone,
private playlistService: PlaylistsService,
private router: Router,
private store: Store,
private snackBar: MatSnackBar,
private translate: TranslateService,
private settingsService: SettingsService,
Expand All @@ -96,28 +94,49 @@ export class AppComponent {
}
}

/**
* Starts all the functions to initialize the component
*/
ngOnInit(): void {
ngOnInit() {
this.store.dispatch(PlaylistActions.loadPlaylists());
this.translate.setDefaultLang(this.DEFAULT_LANG);

this.setRendererListeners();
this.initSettings();
this.handleWhatsNewDialog();

this.triggerAutoUpdateMechanism();
}

async triggerAutoUpdateMechanism() {
if (this.electronService.isElectron) {
const playlistForAutoUpdate = await firstValueFrom(
this.playlistService.getPlaylistsForAutoUpdate()
);
if (playlistForAutoUpdate && playlistForAutoUpdate.length > 0)
this.electronService.sendIpcEvent(
AUTO_UPDATE_PLAYLISTS,
playlistForAutoUpdate
);
}
}

/**
* Initializes all necessary listeners for the events from the renderer process
*/
setRendererListeners(): void {
if (this.electronService.isElectron) {
this.commandsList.forEach((command) =>
this.commandsList.forEach((command) => {
if (this.electronService.isElectron) {
this.electronService.listenOn(command.id, () =>
this.ngZone.run((data) => command.callback(data))
)
);
}
);
} else {
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 @@ -167,7 +186,7 @@ export class AppComponent {
const actualVersion = this.electronService.getAppVersion();
this.settingsService
.getValueFromLocalStorage(STORE_KEY.Version)
.subscribe((version) => {
.subscribe((version: string) => {
const isNewVersion = semver.gt(
actualVersion,
version || '0.0.0'
Expand Down Expand Up @@ -212,13 +231,25 @@ export class AppComponent {
this.setDialogVisibility(true);
}

showErrorAsNotification(response: { message: string; status: number }) {
this.snackBar.open(
`Error: ${response.message} (Status: ${response.status})`,
null,
{ duration: 4000 }
);
}
/**
* Removes all ipc command listeners on component destroy
*/
ngOnDestroy(): void {
this.electronService.removeAllListeners(EPG_FETCH_DONE);
this.electronService.removeAllListeners(EPG_ERROR);
this.electronService.removeAllListeners(VIEW_ADD_PLAYLIST);
this.electronService.removeAllListeners(VIEW_SETTINGS);
if (this.electronService.isElectron) {
this.commandsList.forEach((command) =>
this.electronService.removeAllListeners(command.id)
);
} else {
this.listeners.forEach((listener) =>
window.removeEventListener('message', listener)
);
}
}
}
16 changes: 14 additions & 2 deletions src/app/app.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,9 @@ import { BrowserModule } from '@angular/platform-browser';
import { BrowserAnimationsModule } from '@angular/platform-browser/animations';
import { ServiceWorkerModule } from '@angular/service-worker';
// NG Translate
import { EffectsModule } from '@ngrx/effects';
import { StoreModule } from '@ngrx/store';
import { StoreDevtoolsModule } from '@ngrx/store-devtools';
import { TranslateLoader, TranslateModule } from '@ngx-translate/core';
import { TranslateHttpLoader } from '@ngx-translate/http-loader';
import { NgxIndexedDBModule, NgxIndexedDBService } from 'ngx-indexed-db';
Expand All @@ -17,6 +20,8 @@ import { DataService } from './services/data.service';
import { ElectronService } from './services/electron.service';
import { PwaService } from './services/pwa.service';
import { SharedModule } from './shared/shared.module';
import { PlaylistEffects } from './state/effects';
import { playlistReducer } from './state/reducers';

// AoT requires an exported function for factories
export function HttpLoaderFactory(http: HttpClient): TranslateHttpLoader {
Expand All @@ -39,7 +44,7 @@ export function DataFactory(dbService: NgxIndexedDBService, http: HttpClient) {
if (isElectron()) {
return new ElectronService();
}
return new PwaService(dbService, http);
return new PwaService(http);
}

@NgModule({
Expand All @@ -62,9 +67,16 @@ export function DataFactory(dbService: NgxIndexedDBService, http: HttpClient) {
},
}),
ServiceWorkerModule.register('ngsw-worker.js', {
enabled: AppConfig.production && !isElectron,
enabled: AppConfig.production && !isElectron(),
registrationStrategy: 'registerWhenStable:30000',
}),
StoreModule.forRoot({}),
StoreModule.forFeature('playlistState', playlistReducer),
EffectsModule.forRoot([PlaylistEffects]),
StoreDevtoolsModule.instrument({
maxAge: 25,
logOnly: AppConfig.production,
}),
],
providers: [
{
Expand Down
46 changes: 14 additions & 32 deletions src/app/home/home.component.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,20 +5,20 @@ import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar';
import { MatTabsModule } from '@angular/material/tabs';
import { Router } from '@angular/router';
import { RouterTestingModule } from '@angular/router/testing';
import { Actions } from '@ngrx/effects';
import { provideMockActions } from '@ngrx/effects/testing';
import { MockStore, provideMockStore } from '@ngrx/store/testing';
import { TranslatePipe, TranslateService } from '@ngx-translate/core';
import { MockComponent, MockModule, MockPipe, MockProvider } from 'ng-mocks';
import { NgxIndexedDBModule, NgxIndexedDBService } from 'ngx-indexed-db';
import { of } from 'rxjs';
import { Observable, of } from 'rxjs';
import { FileUploadComponent } from '../home/file-upload/file-upload.component';
import { RecentPlaylistsComponent } from '../home/recent-playlists/recent-playlists.component';
import { UrlUploadComponent } from '../home/url-upload/url-upload.component';
import { DataService } from '../services/data.service';
import { ElectronServiceStub } from '../services/electron.service.stub';
import { HeaderComponent } from '../shared/components/header/header.component';
import {
PLAYLIST_PARSE,
PLAYLIST_PARSE_BY_URL,
} from './../../../shared/ipc-commands';
import { PLAYLIST_PARSE_BY_URL } from './../../../shared/ipc-commands';
import { HomeComponent } from './home.component';

class MatSnackBarStub {
Expand All @@ -36,6 +36,8 @@ describe('HomeComponent', () => {
let fixture: ComponentFixture<HomeComponent>;
let electronService: DataService;
let router: Router;
let mockStore: MockStore;
const actions$ = new Observable<Actions>();

beforeEach(() => {
TestBed.configureTestingModule({
Expand Down Expand Up @@ -63,6 +65,8 @@ describe('HomeComponent', () => {
provide: NgxIndexedDBService,
useClass: NgxIndexedDBServiceStub,
},
provideMockStore(),
provideMockActions(actions$),
],
}).compileComponents();
});
Expand All @@ -72,6 +76,9 @@ describe('HomeComponent', () => {
component = fixture.componentInstance;
electronService = TestBed.inject(DataService);

mockStore = TestBed.inject(MockStore);
mockStore.setState({});

router = TestBed.inject(Router);
TestBed.inject(NgxIndexedDBService);
fixture.detectChanges();
Expand All @@ -96,7 +103,7 @@ describe('HomeComponent', () => {
});

it('should send an event to the main process to parse a playlist', () => {
jest.spyOn(electronService, 'sendIpcEvent');
jest.spyOn(mockStore, 'dispatch');
const title = 'my-list.m3u';
const path = '/home/user/iptv/' + title;
const playlistContent = 'test';
Expand All @@ -108,22 +115,7 @@ describe('HomeComponent', () => {
target: { result: playlistContent },
} as unknown as Event;
component.handlePlaylist({ file, uploadEvent });
expect(electronService.sendIpcEvent).toHaveBeenCalledWith(
PLAYLIST_PARSE,
{ title, playlist: [playlistContent], path }
);
});

it('should return the last last segment from an url', () => {
expect(component.getLastUrlSegment('http://example.com')).toEqual(
'example.com'
);
expect(
component.getLastUrlSegment('http://example.com/playlist.m3u')
).toEqual('playlist.m3u');
expect(
component.getLastUrlSegment('http://example.com/playlist.m3u/')
).toEqual('');
expect(mockStore.dispatch).toHaveBeenCalledTimes(1);
});

it('should set IPC event listeners', () => {
Expand All @@ -140,16 +132,6 @@ describe('HomeComponent', () => {
expect(component.showNotification).toHaveBeenCalledTimes(1);
});

it('should navigate to the player view', () => {
jest.spyOn(router, 'navigateByUrl');
component.navigateToPlayer();
expect(router.navigateByUrl).toHaveBeenCalledTimes(1);
expect(router.navigateByUrl).toHaveBeenCalledWith(
'/iptv',
expect.anything()
);
});

it('should remove all ipc listeners on destroy', () => {
jest.spyOn(electronService, 'removeAllListeners');
component.ngOnDestroy();
Expand Down
110 changes: 48 additions & 62 deletions src/app/home/home.component.ts
Original file line number Diff line number Diff line change
@@ -1,26 +1,23 @@
import { Component, NgZone } from '@angular/core';
import { MatSnackBar } from '@angular/material/snack-bar';
import { Router } from '@angular/router';
import { Store } from '@ngrx/store';
import { TranslateService } from '@ngx-translate/core';
import {
PLAYLIST_PARSE,
ERROR,
PLAYLIST_PARSE_BY_URL,
PLAYLIST_PARSE_RESPONSE,
PLAYLIST_PARSE_TEXT,
} from '../../../shared/ipc-commands';
import { Playlist } from '../../../shared/playlist.interface';
import { getFilenameFromUrl } from '../../../shared/playlist.utils';
import { DataService } from '../services/data.service';
import { PlaylistMeta } from '../shared/playlist-meta.type';
import { ChannelStore } from '../state';
import { addPlaylist, parsePlaylist } from '../state/actions';

@Component({
selector: 'app-home',
templateUrl: './home.component.html',
styleUrls: ['./home.component.scss'],
})
export class HomeComponent {
/** Added playlists */
playlists: PlaylistMeta[] = [];

/** Loading spinner state */
isLoading = false;

Expand All @@ -29,28 +26,27 @@ export class HomeComponent {
{
id: PLAYLIST_PARSE_RESPONSE,
execute: (response: { payload: Playlist }): void => {
this.channelStore.setPlaylist(response.payload);
this.navigateToPlayer();
this.store.dispatch(
addPlaylist({
playlist: response.payload,
})
);
},
},
{
id: ERROR,
execute: () => (this.isLoading = false),
},
];

listeners = [];

/**
* Creates an instanceof HomeComponent
* @param channelStore channels store
* @param electronService electron service
* @param ngZone angular ngZone module
* @param router angular router
* @param snackBar snackbar for notification messages
*/
constructor(
private electronService: DataService,
private dataService: DataService,
private ngZone: NgZone,
private channelStore: ChannelStore,
private router: Router,
private snackBar: MatSnackBar
private snackBar: MatSnackBar,
private readonly store: Store,
private translateService: TranslateService
) {
this.setRendererListeners();
}
Expand All @@ -60,8 +56,8 @@ export class HomeComponent {
*/
setRendererListeners(): void {
this.commandsList.forEach((command) => {
if (this.electronService.isElectron) {
this.electronService.listenOn(command.id, (event, response) =>
if (this.dataService.isElectron) {
this.dataService.listenOn(command.id, (event, response) =>
this.ngZone.run(() => command.execute(response))
);
} else {
Expand All @@ -70,19 +66,21 @@ export class HomeComponent {
command.execute(response.data);
}
};
this.electronService.listenOn(command.id, cb);
this.dataService.listenOn(command.id, cb);
this.listeners.push(cb);
}
});
}

/**
* Shows the filename of rejected file
* @param fileName name of the uploaded file
* @param filename name of the uploaded file
*/
rejectFile(fileName: string): void {
rejectFile(filename: string): void {
this.showNotification(
`File was rejected, unsupported file format (${fileName}).`
this.translateService.instant('HOME.FILE_UPLOAD.REJECTED', {
filename,
})
);
this.isLoading = false;
}
Expand All @@ -93,21 +91,17 @@ export class HomeComponent {
*/
handlePlaylist(payload: { uploadEvent: Event; file: File }): void {
this.isLoading = true;
const result = (payload.uploadEvent.target as FileReader).result;
const array = (result as string).split('\n');
this.electronService.sendIpcEvent(PLAYLIST_PARSE, {
title: payload.file.name,
playlist: array,
path: payload.file.path,
});
}
const playlist = (payload.uploadEvent.target as FileReader)
.result as string;

/**
* Navigates to the video player route
*/
navigateToPlayer(): void {
this.isLoading = false;
this.router.navigateByUrl('/iptv', { skipLocationChange: true });
this.store.dispatch(
parsePlaylist({
uploadType: 'FILE',
playlist,
title: payload.file.name,
path: payload.file.path,
})
);
}

/**
Expand All @@ -116,8 +110,8 @@ export class HomeComponent {
*/
sendPlaylistsUrl(playlistUrl: string): void {
this.isLoading = true;
this.electronService.sendIpcEvent(PLAYLIST_PARSE_BY_URL, {
title: this.getLastUrlSegment(playlistUrl),
this.dataService.sendIpcEvent(PLAYLIST_PARSE_BY_URL, {
title: getFilenameFromUrl(playlistUrl),
url: playlistUrl,
});
}
Expand All @@ -126,23 +120,15 @@ export class HomeComponent {
* Sends IPC event to the renderer process to parse playlist
* @param text playlist as string
*/
uploadAsText(text: string): void {
uploadAsText(playlist: 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
*/
getLastUrlSegment(value: string): string {
if (value && value.length > 1) {
return value.substr(value.lastIndexOf('/') + 1);
} else {
return '';
}
this.store.dispatch(
parsePlaylist({
uploadType: 'TEXT',
playlist,
title: 'Imported as text',
})
);
}

/**
Expand All @@ -160,9 +146,9 @@ export class HomeComponent {
* Remove ipcRenderer listeners after component destroy
*/
ngOnDestroy(): void {
if (this.electronService.isElectron) {
if (this.dataService.isElectron) {
this.commandsList.forEach((command) =>
this.electronService.removeAllListeners(command.id)
this.dataService.removeAllListeners(command.id)
);
} else {
this.listeners.forEach((listener) => {
Expand Down
2 changes: 0 additions & 2 deletions src/app/home/home.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@ import { DragDropFileUploadDirective } from './file-upload/drag-drop-file-upload
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,7 +13,6 @@ import { UrlUploadComponent } from './url-upload/url-upload.component';
DragDropFileUploadDirective,
HomeComponent,
FileUploadComponent,
PlaylistInfoComponent,
TextImportComponent,
UrlUploadComponent,
],
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@ <h2 mat-dialog-title>
{{ 'HOME.PLAYLISTS.INFO_DIALOG.UPDATE_FAILED' | translate }}
</mat-hint>
</mat-form-field>
<ng-container *ngIf="playlist.url || playlist.filePath">
<ng-container *ngIf="isElectron && (playlist.url || playlist.filePath)">
<mat-checkbox class="full-width" formControlName="autoRefresh">
{{ 'HOME.PLAYLISTS.INFO_DIALOG.AUTO_UPDATE' | translate }}
</mat-checkbox>
Expand All @@ -67,6 +67,10 @@ <h2 mat-dialog-title>
</ng-container>
</mat-dialog-content>
<mat-dialog-actions align="end">
<button mat-flat-button color="accent" (click)="exportPlaylist()">
{{ 'HOME.PLAYLISTS.INFO_DIALOG.EXPORT_PLAYLIST' | translate }}
</button>
<div style="flex: 1 1 auto"></div>
<button
mat-flat-button
mat-dialog-close
Expand Down
Original file line number Diff line number Diff line change
@@ -1,22 +1,31 @@
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 { 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';
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, UntypedFormBuilder } from '@angular/forms';
import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing';
import {
FormsModule,
ReactiveFormsModule,
UntypedFormBuilder,
} from '@angular/forms';
import { MatDialogModule, MAT_DIALOG_DATA } from '@angular/material/dialog';
import { MatFormFieldModule } from '@angular/material/form-field';
import { Actions } from '@ngrx/effects';
import { provideMockActions } from '@ngrx/effects/testing';
import { MockStore, provideMockStore } from '@ngrx/store/testing';
import { TranslateModule } from '@ngx-translate/core';
import { MockModule, MockPipe, MockProvider } from 'ng-mocks';
import { NgxIndexedDBService } from 'ngx-indexed-db';
import { Observable } from 'rxjs';
import { DataService } from '../../../services/data.service';
import { ElectronServiceStub } from '../../../services/electron.service.stub';
import { Playlist } from './../../../../../shared/playlist.interface';
import { PlaylistInfoComponent } from './playlist-info.component';

describe('PlaylistInfoComponent', () => {
let component: PlaylistInfoComponent;
let fixture: ComponentFixture<PlaylistInfoComponent>;
let electronService: DataService;
let mockStore: MockStore;
const actions$ = new Observable<Actions>();

beforeEach(
waitForAsync(() => {
Expand All @@ -26,17 +35,17 @@ describe('PlaylistInfoComponent', () => {
MockModule(MatDialogModule),
MockModule(MatCheckboxModule),
MockModule(MatFormFieldModule),
MockModule(TranslateModule),
ReactiveFormsModule,
],
declarations: [
PlaylistInfoComponent,
MockPipe(TranslatePipe),
MockPipe(DatePipe),
],
declarations: [PlaylistInfoComponent, MockPipe(DatePipe)],
providers: [
{ provide: MAT_DIALOG_DATA, useValue: {} },
{ provide: DataService, useClass: ElectronServiceStub },
UntypedFormBuilder,
provideMockStore(),
provideMockActions(actions$),
MockProvider(NgxIndexedDBService),
],
}).compileComponents();
})
Expand All @@ -45,22 +54,18 @@ describe('PlaylistInfoComponent', () => {
beforeEach(() => {
fixture = TestBed.createComponent(PlaylistInfoComponent);
component = fixture.componentInstance;
electronService = TestBed.inject(DataService);
mockStore = TestBed.inject(MockStore);
fixture.detectChanges();
});

it('should create', () => {
expect(component).toBeTruthy();
});

it('should send an event to the main process after save', () => {
it('should dispatch an event to save changes in the store', () => {
const playlistToSave = { _id: 'a12345', title: 'Playlist' } as Playlist;
jest.spyOn(electronService, 'sendIpcEvent');
jest.spyOn(mockStore, 'dispatch');
component.saveChanges(playlistToSave);
expect(electronService.sendIpcEvent).toHaveBeenCalledTimes(1);
expect(electronService.sendIpcEvent).toHaveBeenCalledWith(
PLAYLIST_SAVE_DETAILS,
playlistToSave
);
expect(mockStore.dispatch).toHaveBeenCalledTimes(1);
});
});
Original file line number Diff line number Diff line change
@@ -1,44 +1,60 @@
/* eslint-disable @typescript-eslint/unbound-method */
import { DatePipe } from '@angular/common';
import { CommonModule, DatePipe } from '@angular/common';
import { Component, Inject } from '@angular/core';
import {
FormControl,
ReactiveFormsModule,
UntypedFormBuilder,
UntypedFormControl,
UntypedFormGroup,
Validators,
Validators
} from '@angular/forms';
import { MAT_DIALOG_DATA } from '@angular/material/dialog';
import { PLAYLIST_SAVE_DETAILS } from '../../../../../shared/ipc-commands';
import { MatButtonModule } from '@angular/material/button';
import { MatCheckboxModule } from '@angular/material/checkbox';
import { MatDialogModule, MAT_DIALOG_DATA } from '@angular/material/dialog';
import { MatIconModule } from '@angular/material/icon';
import { MatInputModule } from '@angular/material/input';
import { Store } from '@ngrx/store';
import { TranslateModule } from '@ngx-translate/core';
import { firstValueFrom } from 'rxjs';
import { Playlist } from '../../../../../shared/playlist.interface';
import { DataService } from '../../../services/data.service';
import { PlaylistsService } from '../../../services/playlists.service';
import { PlaylistMeta } from '../../../shared/playlist-meta.type';
import * as PlaylistActions from '../../../state/actions';

@Component({
selector: 'app-playlist-info',
templateUrl: './playlist-info.component.html',
providers: [DatePipe],
imports: [
TranslateModule,
MatButtonModule,
MatIconModule,
MatInputModule,
MatCheckboxModule,
CommonModule,
ReactiveFormsModule,
MatDialogModule,
],
standalone: true,
})
export class PlaylistInfoComponent {
/** Flag that returns true if application runs in electron-based environment */
isElectron = this.electronService.isElectron;
isElectron = this.dataService.isElectron;

/** Playlist object */
playlist: Playlist;

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

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

/**
* Saves updated playlist information
* @param data updated form data
*/
saveChanges(
data: Pick<Playlist, '_id' | 'title' | 'userAgent' | 'autoRefresh'>
): void {
this.electronService.sendIpcEvent(PLAYLIST_SAVE_DETAILS, data);
saveChanges(playlist: PlaylistMeta): void {
this.store.dispatch(PlaylistActions.updatePlaylistMeta({ playlist }));
}

async exportPlaylist() {
const playlistAsString = await firstValueFrom(
this.playlistService.getRawPlaylistById(this.playlist._id)
);
const element = document.createElement('a');
element.setAttribute(
'href',
'data:text/plain;charset=utf-8,' +
encodeURIComponent(playlistAsString)
);
element.setAttribute('download', this.playlist.title || 'exported.m3u');
element.style.display = 'none';
document.body.appendChild(element);
element.click();
document.body.removeChild(element);
}
}
111 changes: 83 additions & 28 deletions src/app/home/recent-playlists/recent-playlists.component.html
Original file line number Diff line number Diff line change
@@ -1,31 +1,86 @@
<mat-nav-list cdkDropList (cdkDropListDropped)="drop($event)">
<mat-list-item *ngIf="playlists.length === 0">
<mat-icon class="favorites-icon" mat-list-icon>search</mat-icon>
<div mat-line>{{ 'HOME.PLAYLISTS.NO_PLAYLISTS' | translate }}</div>
<div mat-line class="meta">
{{ 'HOME.PLAYLISTS.ADD_FIRST' | translate }}
</div>
</mat-list-item>
<mat-list-item
*ngIf="playlists.length > 0"
(click)="getPlaylist('GLOBAL_FAVORITES')"
<ng-container *ngIf="allPlaylistsLoaded$ | async; else skeletonView">
<mat-nav-list
cdkDropList
(cdkDropListDropped)="drop($event, playlists)"
*ngIf="playlists$ | async as playlists"
>
<mat-icon class="favorites-icon" mat-list-icon>star</mat-icon>
<div mat-line>
{{ 'HOME.PLAYLISTS.GLOBAL_FAVORITES' | translate }}
</div>
<div mat-line class="meta">
{{ 'HOME.PLAYLISTS.GLOBAL_FAVORITES_DESCRIPTION' | translate }}
<mat-list-item *ngIf="playlists.length === 0">
<mat-icon class="favorites-icon" mat-list-icon>search</mat-icon>
<div mat-line>{{ 'HOME.PLAYLISTS.NO_PLAYLISTS' | translate }}</div>
<div mat-line class="meta">
{{ 'HOME.PLAYLISTS.ADD_FIRST' | translate }}
</div>
</mat-list-item>
<mat-list-item
*ngIf="playlists.length > 0"
(click)="getPlaylist('GLOBAL_FAVORITES')"
>
<mat-icon class="favorites-icon" mat-list-icon>star</mat-icon>
<div mat-line>
{{ 'HOME.PLAYLISTS.GLOBAL_FAVORITES' | translate }}
</div>
<div mat-line class="meta">
{{ 'HOME.PLAYLISTS.GLOBAL_FAVORITES_DESCRIPTION' | translate }}
</div>
<mat-divider></mat-divider>
</mat-list-item>

<app-playlist-item
*ngFor="let item of playlists; last as last"
[item]="item"
(editPlaylistClicked)="openInfoDialog($event)"
(playlistClicked)="getPlaylist($event)"
(refreshClicked)="refreshPlaylist($event)"
(removeClicked)="removeClicked($event)"
></app-playlist-item>

<div *ngIf="isMigrationPossible" id="migration-container">
<p>{{ migrationMessage }}</p>
<button
mat-button
(click)="migratePlaylists()"
class="migration-btn"
color="accent"
>
<mat-icon>playlist_add</mat-icon>
Migrate playlists</button
>&nbsp;
<button
mat-button
(click)="deleteMigratedPlaylists()"
class="migration-btn"
color="accent"
>
<mat-icon>delete</mat-icon>
Delete all playlists
</button>
</div>
<mat-divider></mat-divider>
</mat-list-item>
</mat-nav-list>
</ng-container>

<app-playlist-item
*ngFor="let item of playlists; last as last"
[item]="item"
(editPlaylistClicked)="openInfoDialog($event)"
(playlistClicked)="getPlaylist($event)"
(refreshClicked)="refreshPlaylist($event)"
(removeClicked)="removeClicked($event)"
></app-playlist-item>
</mat-nav-list>
<ng-template #skeletonView>
<div id="skeleton-container">
<div class="skeleton-item" *ngFor="let a of [].constructor(10)">
<ngx-skeleton-loader
count="1"
appearance="circle"
></ngx-skeleton-loader>
<div class="text">
<ngx-skeleton-loader
count="1"
[theme]="{ width: '30%', height: '20px' }"
></ngx-skeleton-loader>
<ngx-skeleton-loader
count="1"
[theme]="{ width: '50%', height: '10px' }"
></ngx-skeleton-loader>
</div>
<div class="actions">
<ngx-skeleton-loader
count="2"
appearance="circle"
></ngx-skeleton-loader>
</div>
</div>
</div>
</ng-template>
33 changes: 33 additions & 0 deletions src/app/home/recent-playlists/recent-playlists.component.scss
Original file line number Diff line number Diff line change
Expand Up @@ -32,3 +32,36 @@
.favorites-icon {
margin-left: 16px;
}

#skeleton-container {
padding-top: 10px;

.skeleton-item {
display: flex;
padding-left: 25px;
height: 72px;

.text {
flex: 1;
align-self: center;
padding-left: 6px;
flex-direction: column;
display: flex;
}
}
}

#migration-container {
padding: 10px;
border: 1px solid #999;
border-radius: 5px;
margin: 5px;

p {
margin-left: 15px;
}

.migration-btn {
text-transform: uppercase;
}
}
55 changes: 31 additions & 24 deletions src/app/home/recent-playlists/recent-playlists.component.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,17 +4,19 @@ import { MatIconModule } from '@angular/material/icon';
import { MatListModule } from '@angular/material/list';
import { MatSnackBar } from '@angular/material/snack-bar';
import { MatTooltipModule } from '@angular/material/tooltip';
import { Actions } from '@ngrx/effects';
import { provideMockActions } from '@ngrx/effects/testing';
import { MockStore, provideMockStore } from '@ngrx/store/testing';
import { TranslatePipe, TranslateService } from '@ngx-translate/core';
import { MockModule, MockPipe, MockProvider } from 'ng-mocks';
import {
PLAYLIST_GET_BY_ID,
PLAYLIST_REMOVE_BY_ID,
PLAYLIST_UPDATE,
} from '../../../../shared/ipc-commands';
import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader';
import { Observable } from 'rxjs';
import { PLAYLIST_UPDATE } from '../../../../shared/ipc-commands';
import { DataService } from '../../services/data.service';
import { DialogService } from '../../services/dialog.service';
import { ElectronServiceStub } from '../../services/electron.service.stub';
import { PlaylistMeta } from '../../shared/playlist-meta.type';
import { initialPlaylistMetaState } from '../../state/playlists.state';
import { RecentPlaylistsComponent } from './recent-playlists.component';

describe('RecentPlaylistsComponent', () => {
Expand All @@ -23,6 +25,8 @@ describe('RecentPlaylistsComponent', () => {
let electronService: DataService;
let dialog: MatDialog;
let dialogService: DialogService;
let mockStore: MockStore;
const actions$ = new Observable<Actions>();

beforeEach(
waitForAsync(() => {
Expand All @@ -36,12 +40,15 @@ describe('RecentPlaylistsComponent', () => {
MockModule(MatListModule),
MockModule(MatIconModule),
MockModule(MatTooltipModule),
MockModule(NgxSkeletonLoaderModule),
],
providers: [
{ provide: DataService, useClass: ElectronServiceStub },
MockProvider(TranslateService),
MockProvider(DialogService),
MatSnackBar,
provideMockStore(),
provideMockActions(actions$),
],
}).compileComponents();
})
Expand All @@ -50,10 +57,13 @@ describe('RecentPlaylistsComponent', () => {
beforeEach(() => {
fixture = TestBed.createComponent(RecentPlaylistsComponent);
component = fixture.componentInstance;
component.playlists = [];
dialog = TestBed.inject(MatDialog);
electronService = TestBed.inject(DataService);
dialogService = TestBed.inject(DialogService);
mockStore = TestBed.inject(MockStore);
mockStore.setState({
playlistState: { playlists: initialPlaylistMetaState },
});
fixture.detectChanges();
});

Expand All @@ -78,9 +88,9 @@ describe('RecentPlaylistsComponent', () => {
distance: { x: 0, y: 0 },
dropPoint: { x: 0, y: 0 },
} as any;
jest.spyOn(electronService, 'sendIpcEvent');
component.drop(event);
expect(electronService.sendIpcEvent).toHaveBeenCalledTimes(1);
jest.spyOn(mockStore, 'dispatch');
component.drop(event, []);
expect(mockStore.dispatch).toHaveBeenCalledTimes(1);
});

it('should open the confirmation dialog on remove icon click', () => {
Expand All @@ -92,36 +102,33 @@ describe('RecentPlaylistsComponent', () => {

it('should send an event to the main process to remove a playlist', () => {
const playlistId = '12345';
jest.spyOn(electronService, 'sendIpcEvent');
jest.spyOn(mockStore, 'dispatch');
component.removePlaylist(playlistId);
expect(electronService.sendIpcEvent).toHaveBeenCalledWith(
PLAYLIST_REMOVE_BY_ID,
{ id: playlistId }
);
expect(mockStore.dispatch).toHaveBeenCalledTimes(1);
});

it('should send an event to the main process to refresh a playlist', () => {
const playlistMeta: PlaylistMeta = {
_id: 'iptv1',
id: 'iptv1',
title: 'iptv',
filePath: '/home/user/lists/iptv.m3u',
} as PlaylistMeta;
} as unknown as PlaylistMeta;
jest.spyOn(electronService, 'sendIpcEvent');
component.refreshPlaylist(playlistMeta);
expect(electronService.sendIpcEvent).toHaveBeenCalledWith(
PLAYLIST_UPDATE,
{ id: playlistMeta._id, filePath: playlistMeta.filePath }
{
id: playlistMeta._id,
filePath: playlistMeta.filePath,
title: playlistMeta.title,
}
);
});

it('should send an event to the main process to get a playlist', () => {
const playlistId = '6789';
jest.spyOn(electronService, 'sendIpcEvent');
jest.spyOn(component.playlistClicked, 'emit');
component.getPlaylist(playlistId);
expect(electronService.sendIpcEvent).toHaveBeenCalledWith(
PLAYLIST_GET_BY_ID,
{
id: playlistId,
}
);
expect(component.playlistClicked.emit).toHaveBeenCalledTimes(1);
});
});
159 changes: 96 additions & 63 deletions src/app/home/recent-playlists/recent-playlists.component.ts
Original file line number Diff line number Diff line change
@@ -1,18 +1,33 @@
import { CdkDragDrop, moveItemInArray } from '@angular/cdk/drag-drop';
import { Component, NgZone, OnDestroy } from '@angular/core';
import {
Component,
EventEmitter,
NgZone,
OnDestroy,
Output,
} from '@angular/core';
import { MatDialog } from '@angular/material/dialog';
import { MatSnackBar } from '@angular/material/snack-bar';
import { Router } from '@angular/router';
import { Store } from '@ngrx/store';
import { TranslateService } from '@ngx-translate/core';
import { map } from 'rxjs';
import { IpcCommand } from '../../../../shared/ipc-command.class';
import { Playlist } from '../../../../shared/playlist.interface';
import { DataService } from '../../services/data.service';
import * as PlaylistActions from '../../state/actions';
import {
selectAllPlaylistsMeta,
selectPlaylistsLoadingFlag,
} from '../../state/selectors';
import {
PLAYLIST_GET_ALL,
PLAYLIST_GET_ALL_RESPONSE,
PLAYLIST_GET_BY_ID,
PLAYLIST_REMOVE_BY_ID,
PLAYLIST_REMOVE_BY_ID_RESPONSE,
AUTO_UPDATE_PLAYLISTS_RESPONSE,
DELETE_ALL_PLAYLISTS,
IS_PLAYLISTS_MIGRATION_POSSIBLE,
IS_PLAYLISTS_MIGRATION_POSSIBLE_RESPONSE,
MIGRATE_PLAYLISTS,
MIGRATE_PLAYLISTS_RESPONSE,
PLAYLIST_UPDATE,
PLAYLIST_UPDATE_POSITIONS,
PLAYLIST_UPDATE_RESPONSE,
} from './../../../../shared/ipc-commands';
import { DialogService } from './../../services/dialog.service';
Expand All @@ -25,54 +40,82 @@ import { PlaylistInfoComponent } from './playlist-info/playlist-info.component';
styleUrls: ['./recent-playlists.component.scss'],
})
export class RecentPlaylistsComponent implements OnDestroy {
/** All available playlists */
playlists: PlaylistMeta[] = [];
playlists$ = this.store.select(selectAllPlaylistsMeta).pipe(
// eslint-disable-next-line @ngrx/avoid-mapping-selectors
map((playlists) => playlists.sort((a, b) => a.position - b.position))
);

allPlaylistsLoaded$ = this.store.select(selectPlaylistsLoadingFlag);

/** IPC Renderer commands list with callbacks */
commandsList = [
new IpcCommand(
PLAYLIST_GET_ALL_RESPONSE,
(response: { payload: Partial<PlaylistMeta[]> }) => {
this.playlists = response.payload;
PLAYLIST_UPDATE_RESPONSE,
(response: { message: string; playlist: Playlist }) => {
this.snackBar.open(response.message, null, { duration: 2000 });
this.store.dispatch(
PlaylistActions.updatePlaylist({
playlistId: response.playlist._id,
playlist: response.playlist,
})
);
}
),
new IpcCommand(PLAYLIST_REMOVE_BY_ID_RESPONSE, (): void => {
this.snackBar.open('Done! Playlist was removed.', null, {
duration: 2000,
});
this.electronService.sendIpcEvent(PLAYLIST_GET_ALL);
}),
new IpcCommand(
PLAYLIST_UPDATE_RESPONSE,
(response: { message: string }) => {
this.snackBar.open(response.message, null, { duration: 2000 });
IS_PLAYLISTS_MIGRATION_POSSIBLE_RESPONSE,
(response: { result: boolean; message: string }) => {
this.isMigrationPossible = response.result;
this.migrationMessage = response.message || '';
}
),
new IpcCommand(
MIGRATE_PLAYLISTS_RESPONSE,
(response: { payload: Playlist[] }) => {
this.store.dispatch(
PlaylistActions.addManyPlaylists({
playlists: response.payload,
})
);
this.snackBar.open(
`${response.payload.length} playlists were successfully migrated`,
null,
{ duration: 2000 }
);
}
),
new IpcCommand(
AUTO_UPDATE_PLAYLISTS_RESPONSE,
(playlists: Playlist[]) => {
this.store.dispatch(
PlaylistActions.updateManyPlaylists({
playlists,
})
);
}
),
];

listeners = [];
isMigrationPossible = false;
migrationMessage = '';

@Output() playlistClicked = new EventEmitter<string>();

/**
* Creates an instance of the component
* @param dialog angular material dialog reference
* @param dialogService dialog service
* @param electronService electron service
* @param snackBar angular material snackbar reference
* @param translate translate service
*/
constructor(
private dialog: MatDialog,
private dialogService: DialogService,
private electronService: DataService,
private ngZone: NgZone,
private router: Router,
private snackBar: MatSnackBar,
private readonly store: Store,
private translate: TranslateService
) {}

ngOnInit(): void {
// get all playlists
this.electronService.sendIpcEvent(PLAYLIST_GET_ALL);
this.setRendererListeners();
if (this.electronService.isElectron) {
this.electronService.sendIpcEvent(IS_PLAYLISTS_MIGRATION_POSSIBLE);
}
}

/**
Expand All @@ -84,14 +127,6 @@ export class RecentPlaylistsComponent implements OnDestroy {
this.electronService.listenOn(command.id, (event, response) =>
this.ngZone.run(() => command.callback(response))
);
} else {
const cb = (response) => {
if (response.data.type === command.id) {
command.callback(response.data);
}
};
this.electronService.listenOn(command.id, cb);
this.listeners.push(cb);
}
});
}
Expand All @@ -110,26 +145,21 @@ export class RecentPlaylistsComponent implements OnDestroy {
* Drop event handler - applies the new sort order to the playlists array
* @param event drop event
*/
drop(event: CdkDragDrop<PlaylistMeta[]>): void {
moveItemInArray(
this.playlists,
event.previousIndex,
event.currentIndex
);
this.electronService.sendIpcEvent(
PLAYLIST_UPDATE_POSITIONS,
this.playlists
drop(event: CdkDragDrop<PlaylistMeta[]>, playlists: PlaylistMeta[]): void {
moveItemInArray(playlists, event.previousIndex, event.currentIndex);
this.store.dispatch(
PlaylistActions.updatePlaylistPositions({
positionUpdates: playlists.map((item, index) => ({
id: item._id,
changes: { position: index },
})),
})
);
}

/**
* Requests playlist by id
* @param playlistId playlist id
*/
getPlaylist(playlistId: string): void {
this.electronService.sendIpcEvent(PLAYLIST_GET_BY_ID, {
id: playlistId,
});
this.router.navigate(['playlists', playlistId]);
this.playlistClicked.emit(playlistId);
}

/**
Expand All @@ -151,9 +181,7 @@ export class RecentPlaylistsComponent implements OnDestroy {
* @param playlistId playlist id to remove
*/
removePlaylist(playlistId: string): void {
this.electronService.sendIpcEvent(PLAYLIST_REMOVE_BY_ID, {
id: playlistId,
});
this.store.dispatch(PlaylistActions.removePlaylist({ playlistId }));
}

/**
Expand All @@ -163,10 +191,19 @@ export class RecentPlaylistsComponent implements OnDestroy {
refreshPlaylist(item: PlaylistMeta): void {
this.electronService.sendIpcEvent(PLAYLIST_UPDATE, {
id: item._id,
title: item.title,
...(item.url ? { url: item.url } : { filePath: item.filePath }),
});
}

migratePlaylists() {
this.electronService.sendIpcEvent(MIGRATE_PLAYLISTS);
}

deleteMigratedPlaylists() {
this.electronService.sendIpcEvent(DELETE_ALL_PLAYLISTS);
}

/**
* Removes command listeners on component destroy
*/
Expand All @@ -175,10 +212,6 @@ export class RecentPlaylistsComponent implements OnDestroy {
this.commandsList.forEach((command) =>
this.electronService.removeAllListeners(command.id)
);
} else {
this.listeners.forEach((listener) => {
window.removeEventListener('message', listener);
});
}
}
}
2 changes: 1 addition & 1 deletion src/app/home/url-upload/url-upload.component.html
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
/>
</mat-form-field>
<mat-card *ngIf="!isElectron">
Note: In order to avoid <a href="https://developer.mozilla.org/en-US/docs/Web/HTTP/CORS" target="_blank">CORS</a> issues at this point the application uses a public CORS Proxy service. If you want to upload a playlist with sensitive data, it is better to import it as a file in the adjacent tab.
{{ 'HOME.URL_UPLOAD.CORS_NOTE' | translate }}
</mat-card>
<button
class="add-button"
Expand Down
Loading