Merge pull request #230 from 4gray/refactor/indexed-db

refactoring: switch to indexedDb and ngrx
This commit is contained in:
4gray authored and GitHub committed 2023-02-24 19:19:17 +01:00
commit de17686cdf
47 files changed
+1717 -1426

No files matched your search

+1 -1
View File
@@ -32,7 +32,7 @@ jobs:
strategy:
matrix:
os: [macos-latest, windows-latest]
os: [macos-latest]
steps:
- uses: actions/checkout@v2
+2 -1
View File
@@ -14,7 +14,8 @@
"projectType": "application",
"schematics": {
"@schematics/angular:application": {
"strict": true
"strict": true,
"style": "scss"
}
},
"architect": {
+284 -447
View File
@@ -1,9 +1,13 @@
import axios from 'axios';
import { app, BrowserWindow, ipcMain, session } from 'electron';
import { promises as fsPromises } from 'fs';
import { parse } from 'iptv-playlist-parser';
import { GLOBAL_FAVORITES_PLAYLIST_ID } from '../shared/constants';
import { Channel } from '../shared/channel.interface';
import {
AUTO_UPDATE_PLAYLISTS,
AUTO_UPDATE_PLAYLISTS_RESPONSE,
CHANNEL_SET_USER_AGENT,
DELETE_ALL_PLAYLISTS,
EPG_ERROR,
EPG_FETCH,
EPG_FETCH_DONE,
@@ -15,39 +19,36 @@ import {
EPG_GET_PROGRAM,
EPG_GET_PROGRAM_DONE,
ERROR,
IS_PLAYLISTS_MIGRATION_POSSIBLE,
IS_PLAYLISTS_MIGRATION_POSSIBLE_RESPONSE,
MIGRATE_PLAYLISTS,
MIGRATE_PLAYLISTS_RESPONSE,
OPEN_FILE,
PLAYLIST_GET_ALL,
PLAYLIST_GET_ALL_RESPONSE,
PLAYLIST_GET_BY_ID,
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';
import {
aggregateFavoriteChannels,
createFavoritesPlaylist,
} from '../shared/playlist.utils';
import { Playlist } from '../shared/playlist.interface';
import { createPlaylistObject } from '../shared/playlist.utils';
import { ParsedPlaylist } from '../src/typings.d';
const Nedb = require('nedb-promises');
const fs = require('fs');
const https = require('https');
/** @deprecated - used only for migration */
const Nedb = require('nedb-promises');
/** @deprecated - used only for migration */
const userData = process.env['e2e']
? process.cwd() + '/e2e'
: app.getPath('userData');
/** @deprecated - used only for migration */
const dbPath = `${userData}/db/data.db`;
/** @deprecated - used only for migration */
const db = new Nedb({
filename: `${userData}/db/data.db`,
filename: dbPath,
autoload: true,
});
@@ -55,10 +56,6 @@ const agent = new https.Agent({
rejectUnauthorized: false,
});
export function guid() {
return Math.random().toString(36).slice(2);
}
export class Api {
/** Instance of the main application window */
mainWindow: BrowserWindow;
@@ -73,130 +70,142 @@ export class Api {
workerWindow: BrowserWindow;
constructor() {
ipcMain.on(PLAYLIST_PARSE_BY_URL, (event, args) => {
try {
axios.get(args.url, { httpsAgent: agent }).then((result) => {
const parsedPlaylist = this.convertFileStringToPlaylist(
result.data
);
const playlistObject = this.createPlaylistObject(
args.title,
parsedPlaylist,
args.url,
'URL'
);
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_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(
args.title,
parsedPlaylist,
args.path,
'FILE'
);
this.insertToDb(playlistObject);
event.sender.send(PLAYLIST_PARSE_RESPONSE, {
payload: playlistObject,
});
});
ipcMain.on(PLAYLIST_GET_ALL, (event) => this.sendAllPlaylists(event));
ipcMain.on(PLAYLIST_GET_BY_ID, (event, args) => {
if (args.id === GLOBAL_FAVORITES_PLAYLIST_ID) {
this.sendPlaylistWithGlobalFavorites(event);
} else {
db.findOne({ _id: args.id }).then((playlist) => {
this.setUserAgent(playlist?.userAgent);
event.sender.send(PLAYLIST_PARSE_RESPONSE, {
payload: playlist,
});
});
}
});
ipcMain.on(PLAYLIST_REMOVE_BY_ID, (event, args) => {
db.remove({ _id: args.id }).then((removed) => {
if (removed) {
event.sender.send(PLAYLIST_REMOVE_BY_ID_RESPONSE, {
message: 'playlist was removed',
ipcMain
.on(PLAYLIST_PARSE_BY_URL, (event, args) => {
try {
axios
.get(args.url, { httpsAgent: agent })
.then((result) => {
const parsedPlaylist = this.parsePlaylist(
result.data
);
const playlistObject = createPlaylistObject(
args.title,
parsedPlaylist,
args.url,
'URL'
);
event.sender.send(PLAYLIST_PARSE_RESPONSE, {
payload: playlistObject,
});
});
} catch (err) {
event.sender.send(ERROR, {
message: err.response.statusText,
status: err.response.status,
});
}
});
});
})
.on(OPEN_FILE, (event, args) => {
fs.readFile(
args.filePath,
'utf-8',
(err: NodeJS.ErrnoException, data: string) => {
if (err) {
console.log(
'An error ocurred reading the file :' +
err.message
);
return;
}
// open playlist from file system
ipcMain.on(OPEN_FILE, (event, args) => {
fs.readFile(
args.filePath,
'utf-8',
(err: NodeJS.ErrnoException, data: string) => {
if (err) {
console.log(
'An error ocurred reading the file :' + err.message
const parsedPlaylist = this.parsePlaylist(data);
const playlistObject = createPlaylistObject(
args.fileName,
parsedPlaylist,
args.filePath,
'FILE'
);
return;
event.sender.send(PLAYLIST_PARSE_RESPONSE, {
payload: playlistObject,
});
}
const parsedPlaylist =
this.convertFileStringToPlaylist(data);
const playlistObject = this.createPlaylistObject(
args.fileName,
parsedPlaylist,
args.filePath,
'FILE'
);
})
.on(
PLAYLIST_UPDATE,
(
event,
args: {
id: string;
title: string;
filePath?: string;
url?: string;
}
) => {
if (args.filePath && args.id) {
this.fetchPlaylistByFilePath(args, event);
} else if (args.url && args.id) {
this.fetchPlaylistByUrl(args, event);
}
}
)
.on(
CHANNEL_SET_USER_AGENT,
(event, args: { userAgent: string; referer?: string }) => {
if (args.userAgent && args.referer) {
this.setUserAgent(args.userAgent, args.referer);
} else {
this.setUserAgent(this.defaultUserAgent, 'localhost');
}
}
)
.on(IS_PLAYLISTS_MIGRATION_POSSIBLE, (event) => {
db.count({
type: { $exists: false },
}).then((count: number) => {
event.sender.send(
IS_PLAYLISTS_MIGRATION_POSSIBLE_RESPONSE,
{
result: count > 0,
message:
count > 0
? `${count} playlists were found, which can be migrated from the database used in the last version of the application.`
: 'No playlists for migration',
}
);
this.insertToDb(playlistObject);
event.sender.send(PLAYLIST_PARSE_RESPONSE, {
payload: playlistObject,
});
})
.on(
AUTO_UPDATE_PLAYLISTS,
// eslint-disable-next-line @typescript-eslint/no-misused-promises
async (event, playlists: Partial<Playlist>[]) => {
const results: any[] = [];
let playlist: any;
for (const element of playlists) {
if (element.url && element._id) {
playlist = await this.fetchPlaylistByUrl({
id: element._id,
title: element.title || '',
url: element.url,
});
results.push(playlist);
} else if (element.filePath && element._id) {
playlist = await this.fetchPlaylistByFilePath({
id: element._id,
title: element.title || '',
filePath: element.filePath,
});
results.push(playlist);
}
}
event.sender.send(
AUTO_UPDATE_PLAYLISTS_RESPONSE,
results.filter((item) => item !== undefined)
);
}
)
.on(MIGRATE_PLAYLISTS, (event) => {
this.getAllPlaylists().then((playlists) => {
event.sender.send(MIGRATE_PLAYLISTS_RESPONSE, {
payload: playlists,
});
}
);
});
ipcMain.on(PLAYLIST_UPDATE_FAVORITES, (event, args) => {
db.update(
{ _id: args.id },
{ $set: { favorites: args.favorites } }
).then((updated) => {
if (!updated.numAffected || updated.numAffected === 0) {
console.error('Error: Favorites were not updated');
}
});
})
.on(DELETE_ALL_PLAYLISTS, (event) => {
this.removeAllPlaylists(event);
});
});
// listeners for EPG events
ipcMain
@@ -237,80 +246,85 @@ export class Api {
this.workerWindow.webContents.send(EPG_FORCE_FETCH, arg)
);
ipcMain.on(
PLAYLIST_SAVE_DETAILS,
(
event,
args: Pick<
Playlist,
'_id' | 'title' | 'userAgent' | 'autoRefresh'
>
) => {
this.updatePlaylistById(args._id, {
title: args.title,
userAgent: args.userAgent,
autoRefresh: args.autoRefresh,
}).then((updated) => {
if (!updated.numAffected || updated.numAffected === 0) {
console.error(
'Error: Playlist details were not updated'
);
}
this.sendAllPlaylists(event);
});
}
);
ipcMain.on(
PLAYLIST_UPDATE,
(event, args: { id: string; filePath?: string; url?: string }) => {
if (args.filePath && args.id) {
this.fetchPlaylistByFilePath(args.id, args.filePath, event);
} else if (args.url && args.id) {
this.fetchPlaylistByUrl(args.id, args.url, event);
}
}
);
ipcMain.on(
CHANNEL_SET_USER_AGENT,
(event, args: { userAgent: string; referer?: string }) => {
if (args.userAgent && args.referer) {
this.setUserAgent(args.userAgent, args.referer);
} else {
this.setUserAgent(this.defaultUserAgent, 'localhost');
}
}
);
ipcMain.on(
PLAYLIST_UPDATE_POSITIONS,
(event, playlists: Partial<Playlist[]>) =>
playlists.forEach((list, index) => {
this.updatePlaylistById((list as Playlist)._id, {
...list,
position: index,
});
})
);
this.setTitleBarListeners();
this.refreshPlaylists();
}
/**
* Sends a message with playlist that contains favorite channels from all available playlists
* @param event ipc main event
* Sets the user agent header for all http requests
* @param userAgent user agent to use
* @param referer referer to use
*/
sendPlaylistWithGlobalFavorites(event: Electron.IpcMainEvent) {
db.find({ type: { $exists: false } }).then((playlists: Playlist[]) => {
const favoriteChannels = aggregateFavoriteChannels(playlists);
const favPlaylist = createFavoritesPlaylist(favoriteChannels);
setUserAgent(userAgent: string, referer?: string): void {
if (userAgent === undefined || userAgent === null || userAgent === '') {
userAgent = this.defaultUserAgent;
}
event.sender.send(PLAYLIST_PARSE_RESPONSE, {
type: PLAYLIST_PARSE_RESPONSE,
payload: favPlaylist,
});
session.defaultSession.webRequest.onBeforeSendHeaders(
(details, callback) => {
details.requestHeaders['User-Agent'] = userAgent;
// eslint-disable-next-line @typescript-eslint/no-unnecessary-type-assertion
details.requestHeaders['Referer'] = referer as string;
callback({ requestHeaders: details.requestHeaders });
}
);
console.log(`Success: Set "${userAgent}" as user agent header`);
}
/**
* Sets epg browser window
* @param workerWindow
*/
setEpgWorkerWindow(workerWindow: BrowserWindow): void {
this.workerWindow = workerWindow;
// store default user agent as fallback
this.defaultUserAgent = this.workerWindow.webContents.getUserAgent();
}
/**
* Sets browser window of the main app window
* @param mainWindow
*/
setMainWindow(mainWindow: BrowserWindow): void {
this.mainWindow = mainWindow;
}
/**
* Converts the fetched playlist string to the playlist object, updates it in the database and sends the updated playlists array back to the renderer
* @param id id of the playlist to update
* @param playlistString updated playlist as string
*/
getRefreshedPlaylist(
args: { id: string; title: string; filePath?: string; url?: string },
playlistString: string
) {
const parsedPlaylist: ParsedPlaylist =
this.parsePlaylist(playlistString);
const playlist = createPlaylistObject(
args.title,
parsedPlaylist,
args.url ? args.url : args.filePath,
args.url ? 'URL' : 'FILE'
);
return {
...playlist,
_id: args.id,
};
}
sendPlaylistRefreshResponse(
playlistId: string,
playlist: Playlist,
event: Electron.IpcMainEvent
) {
event.sender.send(PLAYLIST_UPDATE_RESPONSE, {
message: `Success! The playlist was successfully updated (${
(playlist.playlist.items as Channel[]).length
} channels)`,
playlist: {
...playlist,
_id: playlistId,
},
});
}
@@ -338,195 +352,6 @@ export class Api {
});
}
/**
* Starts the update process for all the playlists with the enabled auto-refresh flag
*/
refreshPlaylists(): void {
this.getAllPlaylistsMeta().then((playlists) => {
playlists.forEach((playlist) => {
if (playlist.autoRefresh && playlist.autoRefresh === true) {
if (playlist.url) {
this.fetchPlaylistByUrl(playlist._id, playlist.url);
} else if (playlist.filePath) {
this.fetchPlaylistByFilePath(
playlist._id,
playlist.filePath
);
} else {
console.log('skip...');
}
}
});
});
}
/**
* Sends list with all playlists which are stored in the database
* @param event main event
*/
sendAllPlaylists(event: Electron.IpcMainEvent): void {
this.getAllPlaylistsMeta().then((playlists) => {
event.sender.send(PLAYLIST_GET_ALL_RESPONSE, {
payload: playlists,
});
});
}
/**
* Returns all existing playlists with meta information from the database
* @returns
*/
getAllPlaylistsMeta() {
return db
.find(
{ type: { $exists: false } },
{
count: 1,
title: 1,
_id: 1,
id: 1,
url: 1,
importDate: 1,
userAgent: 1,
filename: 1,
filePath: 1,
autoRefresh: 1,
updateDate: 1,
updateState: 1,
position: 1,
}
)
.sort({ position: 1, importDate: -1 });
}
/**
* Sets the user agent header for all http requests
* @param userAgent user agent to use
* @param referer referer to use
*/
setUserAgent(userAgent: string, referer?: string): void {
if (userAgent === undefined || userAgent === null || userAgent === '') {
userAgent = this.defaultUserAgent;
}
session.defaultSession.webRequest.onBeforeSendHeaders(
(details, callback) => {
details.requestHeaders['User-Agent'] = userAgent;
details.requestHeaders['Referer'] = referer as string;
callback({ requestHeaders: details.requestHeaders });
}
);
console.log(`Success: Set "${userAgent}" as user agent header`);
}
/**
* Sets epg browser window
* @param workerWindow
*/
setEpgWorkerWindow(workerWindow: BrowserWindow): void {
this.workerWindow = workerWindow;
// store default user agent as fallback
this.defaultUserAgent = this.workerWindow.webContents.getUserAgent();
}
/**
* Sets browser window of the main app window
* @param mainWindow
*/
setMainWindow(mainWindow: BrowserWindow): void {
this.mainWindow = mainWindow;
}
/**
* 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
*/
createPlaylistObject(
name: string,
playlist: ParsedPlaylist,
urlOrPath?: string,
uploadType?: 'URL' | 'FILE'
): Playlist {
return {
id: guid(),
_id: guid(),
filename: name,
title: name,
count: playlist.items.length,
playlist: {
...playlist,
items: playlist.items.map((item) => ({
...item,
id: guid(),
})),
},
importDate: new Date().toISOString(),
lastUsage: new Date().toISOString(),
favorites: [],
autoRefresh: false,
...(uploadType === 'URL' ? { url: urlOrPath } : {}),
...(uploadType === 'FILE' ? { filePath: urlOrPath } : {}),
};
}
/**
* Updates the provided playlist in the database
* @param id id of the playlist
* @param data playlist data to update
* @returns
*/
updatePlaylistById(
id: string,
data: Partial<Playlist>
): Promise<{
numAffected: number;
upsert: boolean;
}> {
return db.update(
{ _id: id },
{
$set: data,
}
);
}
/**
* Converts the fetched playlist string to the playlist object, updates it in the database and sends the updated playlists array back to the renderer
* @param id id of the playlist to update
* @param playlistString updated playlist as string
* @param event ipc event to send the response back to the renderer
*/
async handlePlaylistRefresh(
id: string,
playlistString: string,
event?: Electron.IpcMainEvent
): Promise<void> {
const playlist: ParsedPlaylist =
this.convertFileStringToPlaylist(playlistString);
const updated = await this.updatePlaylistById(id, {
playlist,
count: playlist.items.length,
updateDate: Date.now(),
updateState: PlaylistUpdateState.UPDATED,
});
if (!updated.numAffected || updated.numAffected === 0) {
console.error('Error: Playlist details were not updated');
}
if (event) {
event.sender.send(PLAYLIST_UPDATE_RESPONSE, {
message: `Success! The playlist was successfully updated (${playlist.items.length} channels)`,
});
// send all playlists back to the renderer process
this.sendAllPlaylists(event);
}
}
/**
* Fetches the playlist from the given url and triggers the update operation
* @param id id of the playlist to update
@@ -534,24 +359,31 @@ export class Api {
* @param event ipc event to send the response back to the renderer
*/
async fetchPlaylistByUrl(
id: string,
url: string,
args: { id: string; title: string; url?: string },
event?: Electron.IpcMainEvent
): Promise<void> {
) {
if (!args.url) return;
try {
await axios
.get(url, { httpsAgent: agent })
.then((result) =>
this.handlePlaylistRefresh(id, result.data, event)
const result = await axios.get(args.url, { httpsAgent: agent });
const refreshedPlaylist = this.getRefreshedPlaylist(
args,
result.data
);
if (event) {
this.sendPlaylistRefreshResponse(
refreshedPlaylist._id,
refreshedPlaylist,
event
);
} else {
return refreshedPlaylist;
}
} catch (err) {
this.updatePlaylistById(id, {
updateState: PlaylistUpdateState.NOT_UPDATED,
});
if (event)
event.sender.send(ERROR, {
message: `File not found. Please check the entered playlist URL again.`,
status: err.response.status,
status: err.response?.status,
});
}
}
@@ -562,31 +394,32 @@ export class Api {
* @param playlistString updated playlist as string
* @param event ipc event to send the response back to the renderer
*/
fetchPlaylistByFilePath(
id: string,
path: string,
async fetchPlaylistByFilePath(
args: { id: string; title: string; filePath?: string },
event?: Electron.IpcMainEvent
): void {
try {
fs.readFile(path, 'utf-8', (err, data) => {
if (err) {
this.handleFileNotFoundError(err, id, event);
return;
}
) {
if (!args.filePath) return;
let refreshedPlaylist: Playlist;
this.handlePlaylistRefresh(id, data, event);
});
try {
const playlist = await fsPromises.readFile(args.filePath, 'utf-8');
refreshedPlaylist = this.getRefreshedPlaylist(args, playlist);
if (event) {
this.sendPlaylistRefreshResponse(
refreshedPlaylist._id,
refreshedPlaylist,
event
);
} else {
return refreshedPlaylist;
}
} catch (err) {
this.handleFileNotFoundError(err, id, event);
return;
}
}
/**
* Sends an error message to the renderer process
* @param error
* @param id
* @param event
*/
/** Sends an error message to the renderer process */
handleFileNotFoundError(
error: {
errno: string;
@@ -594,16 +427,10 @@ export class Api {
syscall: string;
path: string;
},
id: string,
event?: Electron.IpcMainEvent
): void {
console.error(error);
this.updatePlaylistById(id, {
updateState: PlaylistUpdateState.NOT_UPDATED,
});
if (event) {
// send all playlists back to the renderer process
this.sendAllPlaylists(event);
event.sender.send(ERROR, {
message: `Sorry, playlist was not found (${error.path})`,
status: 'ENOENT',
@@ -611,26 +438,36 @@ export class Api {
}
}
convertFileStringToPlaylist(m3uString: string): ParsedPlaylist {
return this.parsePlaylist(m3uString.split('\n'));
}
/**
* Parses string based array to playlist object
* @param m3uArray m3u playlist as array with strings
* @param m3uString m3u playlist as string
*/
parsePlaylist(m3uArray: string[]): ParsedPlaylist {
const playlistAsString = m3uArray.join('\n');
return parse(playlistAsString);
parsePlaylist(m3uString: string): ParsedPlaylist {
return parse(m3uString);
}
/**
* Inserts new playlist to the database
* @param playlist playlist to add
*/
insertToDb(playlist: Playlist): void {
db.insert(playlist).then((response) => {
console.log('playlist was saved...', response._id);
/** @deprecated - used only for migration */
getAllPlaylists() {
return db
.find({ type: { $exists: false } })
.sort({ position: 1, importDate: -1 });
}
/** @deprecated - used only for migration */
async removeAllPlaylists(event: Electron.IpcMainEvent) {
const removeCount = await db.remove({}, { multi: true });
console.info(removeCount, ' playlists were removed');
fs.unlink(dbPath, (err) => {
if (err && err.code == 'ENOENT') {
console.info("File doesn't exist, won't remove it.");
} else if (err) {
console.error('Error occurred while trying to remove file');
} else {
console.info(`${dbPath} was deleted`);
event.sender.send(IS_PLAYLISTS_MIGRATION_POSSIBLE_RESPONSE, {
result: false,
});
}
});
}
}
+4 -1
View File
@@ -112,7 +112,10 @@ const convertEpgData = () => {
// fetches epg data from the provided URL
ipcRenderer.on(EPG_FETCH, (event, epgUrl: string) => {
console.log(loggerLabel, 'epg fetch command was triggered');
if (fetchedUrls.indexOf(epgUrl) > -1) return;
if (fetchedUrls.indexOf(epgUrl) > -1) {
ipcRenderer.send(EPG_FETCH_DONE);
return;
}
fetchedUrls.push(epgUrl);
fetchEpgDataFromUrl(epgUrl);
});
+29 -25
View File
@@ -68,7 +68,7 @@
"@ngrx/eslint-plugin": "^14.3.2",
"@ngx-translate/core": "14.0.0",
"@ngx-translate/http-loader": "7.0.0",
"@playwright/test": "1.29.1",
"@playwright/test": "1.31.1",
"@semantic-release/changelog": "6.0.1",
"@semantic-release/git": "10.0.1",
"@semantic-release/npm": "9.0.0",
@@ -91,8 +91,8 @@
"ng-mocks": "14.6.0",
"node-polyfill-webpack-plugin": "1.1.4",
"npm-run-all": "4.1.5",
"playwright": "1.29.1",
"playwright-core": "1.29.1",
"playwright": "1.31.1",
"playwright-core": "1.31.1",
"prettier": "2.5.1",
"semantic-release": "19.0.3",
"stylelint": "14.2.0",
@@ -5314,19 +5314,22 @@
}
},
"node_modules/@playwright/test": {
"version": "1.29.1",
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.29.1.tgz",
"integrity": "sha512-iQxk2DX5U9wOGV3+/Jh9OHPsw5H3mleUL2S4BgQuwtlAfK3PnKvn38m4Rg9zIViGHVW24opSm99HQm/UFLEy6w==",
"version": "1.31.1",
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.31.1.tgz",
"integrity": "sha512-IsytVZ+0QLDh1Hj83XatGp/GsI1CDJWbyDaBGbainsh0p2zC7F4toUocqowmjS6sQff2NGT3D9WbDj/3K2CJiA==",
"dev": true,
"dependencies": {
"@types/node": "*",
"playwright-core": "1.29.1"
"playwright-core": "1.31.1"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=14"
},
"optionalDependencies": {
"fsevents": "2.3.2"
}
},
"node_modules/@schematics/angular": {
@@ -22634,13 +22637,13 @@
}
},
"node_modules/playwright": {
"version": "1.29.1",
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.29.1.tgz",
"integrity": "sha512-lasC+pMqsQ2uWhNurt3YK3xo0gWlMjslYUylKbHcqF/NTjwp9KStRGO7S6wwz2f52GcSnop8XUK/GymJjdzrxw==",
"version": "1.31.1",
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.31.1.tgz",
"integrity": "sha512-zKJabsIA2rvOwJ12lGTqWv4HVJzlfw2JtUvO4hAr7J8UXQZ1qEPpX20E1vcz/9fotnTkwgqp3CVdIBwptBN3Fg==",
"dev": true,
"hasInstallScript": true,
"dependencies": {
"playwright-core": "1.29.1"
"playwright-core": "1.31.1"
},
"bin": {
"playwright": "cli.js"
@@ -22650,9 +22653,9 @@
}
},
"node_modules/playwright-core": {
"version": "1.29.1",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.29.1.tgz",
"integrity": "sha512-20Ai3d+lMkWpI9YZYlxk8gxatfgax5STW8GaMozAHwigLiyiKQrdkt7gaoT9UQR8FIVDg6qVXs9IoZUQrDjIIg==",
"version": "1.31.1",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.31.1.tgz",
"integrity": "sha512-JTyX4kV3/LXsvpHkLzL2I36aCdml4zeE35x+G5aPc4bkLsiRiQshU5lWeVpHFAuC8xAcbI6FDcw/8z3q2xtJSQ==",
"dev": true,
"bin": {
"playwright": "cli.js"
@@ -31347,13 +31350,14 @@
}
},
"@playwright/test": {
"version": "1.29.1",
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.29.1.tgz",
"integrity": "sha512-iQxk2DX5U9wOGV3+/Jh9OHPsw5H3mleUL2S4BgQuwtlAfK3PnKvn38m4Rg9zIViGHVW24opSm99HQm/UFLEy6w==",
"version": "1.31.1",
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.31.1.tgz",
"integrity": "sha512-IsytVZ+0QLDh1Hj83XatGp/GsI1CDJWbyDaBGbainsh0p2zC7F4toUocqowmjS6sQff2NGT3D9WbDj/3K2CJiA==",
"dev": true,
"requires": {
"@types/node": "*",
"playwright-core": "1.29.1"
"fsevents": "2.3.2",
"playwright-core": "1.31.1"
}
},
"@schematics/angular": {
@@ -44490,18 +44494,18 @@
}
},
"playwright": {
"version": "1.29.1",
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.29.1.tgz",
"integrity": "sha512-lasC+pMqsQ2uWhNurt3YK3xo0gWlMjslYUylKbHcqF/NTjwp9KStRGO7S6wwz2f52GcSnop8XUK/GymJjdzrxw==",
"version": "1.31.1",
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.31.1.tgz",
"integrity": "sha512-zKJabsIA2rvOwJ12lGTqWv4HVJzlfw2JtUvO4hAr7J8UXQZ1qEPpX20E1vcz/9fotnTkwgqp3CVdIBwptBN3Fg==",
"dev": true,
"requires": {
"playwright-core": "1.29.1"
"playwright-core": "1.31.1"
}
},
"playwright-core": {
"version": "1.29.1",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.29.1.tgz",
"integrity": "sha512-20Ai3d+lMkWpI9YZYlxk8gxatfgax5STW8GaMozAHwigLiyiKQrdkt7gaoT9UQR8FIVDg6qVXs9IoZUQrDjIIg==",
"version": "1.31.1",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.31.1.tgz",
"integrity": "sha512-JTyX4kV3/LXsvpHkLzL2I36aCdml4zeE35x+G5aPc4bkLsiRiQshU5lWeVpHFAuC8xAcbI6FDcw/8z3q2xtJSQ==",
"dev": true
},
"plist": {
+4 -4
View File
@@ -42,7 +42,7 @@
"test": "jest",
"test:watch": "jest --watch",
"e2e": "npm run build:electron && npx playwright test --retries 3",
"e2e:ci": "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",
@@ -110,7 +110,7 @@
"@ngrx/eslint-plugin": "^14.3.2",
"@ngx-translate/core": "14.0.0",
"@ngx-translate/http-loader": "7.0.0",
"@playwright/test": "1.29.1",
"@playwright/test": "1.31.1",
"@semantic-release/changelog": "6.0.1",
"@semantic-release/git": "10.0.1",
"@semantic-release/npm": "9.0.0",
@@ -133,8 +133,8 @@
"ng-mocks": "14.6.0",
"node-polyfill-webpack-plugin": "1.1.4",
"npm-run-all": "4.1.5",
"playwright": "1.29.1",
"playwright-core": "1.29.1",
"playwright": "1.31.1",
"playwright-core": "1.31.1",
"prettier": "2.5.1",
"semantic-release": "19.0.3",
"stylelint": "14.2.0",
+2
View File
@@ -2,6 +2,7 @@ import { devices, PlaywrightTestConfig } from '@playwright/test';
const config: PlaywrightTestConfig = {
testDir: './e2e',
timeout: 45000,
maxFailures: 2,
testMatch: /.*\.e2e\.ts/,
projects: [
@@ -28,6 +29,7 @@ const config: PlaywrightTestConfig = {
}, */
],
use: {
headless: false,
screenshot: 'only-on-failure',
testIdAttribute: 'data-test-id',
},
+12 -10
View File
@@ -12,20 +12,10 @@ export const 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';
@@ -38,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';
-1
View File
@@ -11,7 +11,6 @@ export enum PlaylistUpdateState {
* Describes playlist interface
*/
export interface Playlist {
id: string;
_id: string;
title: string;
filename: string;
+36 -11
View File
@@ -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';
@@ -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: {
@@ -43,16 +44,6 @@ export function createFavoritesPlaylist(
};
}
export const getUUID = () =>
// eslint-disable-next-line @typescript-eslint/restrict-plus-operands
(String(1e7) + -1e3 + -4e3 + -8e3 + -1e11).replace(/[018]/g, (c) =>
(
Number(c) ^
(crypto.getRandomValues(new Uint8Array(1))[0] &
(15 >> (Number(c) / 4)))
).toString(16)
);
/**
* Returns last segment (part after last slash "/") of the given URL
* @param value URL as string
@@ -63,3 +54,37 @@ export const getFilenameFromUrl = (value: string): string => {
}
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 } : {}),
};
};
+10
View File
@@ -7,11 +7,21 @@ const routes: Routes = [
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: 'playlists/:id',
loadChildren: () =>
import('./player/player.module').then((m) => m.PlayerModule),
},
{
path: 'settings',
loadComponent: () =>
+12 -7
View File
@@ -11,11 +11,13 @@ 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';
@@ -23,10 +25,6 @@ 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', () => {
@@ -43,10 +41,14 @@ 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,
@@ -70,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();
});
+24 -8
View File
@@ -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,
@@ -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
*/
@@ -57,14 +61,13 @@ export class AppComponent {
listeners = [];
/**
* Creates an instance of AppComponent
*/
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,
@@ -91,15 +94,28 @@ 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
);
}
}
/**
+1 -1
View File
@@ -44,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({
+3 -19
View File
@@ -18,10 +18,7 @@ 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 {
@@ -106,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';
@@ -118,10 +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 }
);
expect(mockStore.dispatch).toHaveBeenCalledTimes(1);
});
it('should set IPC event listeners', () => {
@@ -138,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();
+21 -34
View File
@@ -1,19 +1,16 @@
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 {
ERROR,
PLAYLIST_PARSE,
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 { setPlaylist } from '../state/actions';
import { addPlaylist, parsePlaylist } from '../state/actions';
@Component({
selector: 'app-home',
@@ -30,9 +27,10 @@ export class HomeComponent {
id: PLAYLIST_PARSE_RESPONSE,
execute: (response: { payload: Playlist }): void => {
this.store.dispatch(
setPlaylist({ playlist: response.payload })
addPlaylist({
playlist: response.payload,
})
);
this.navigateToPlayer();
},
},
{
@@ -43,19 +41,9 @@ export class HomeComponent {
listeners = [];
/**
* Creates an instance of HomeComponent
* @param dataService data service
* @param ngZone angular ngZone module
* @param router angular router
* @param snackBar snackbar for notification messages
* @param store channels store
* @param translateService ngx-translate service
*/
constructor(
private dataService: DataService,
private ngZone: NgZone,
private router: Router,
private snackBar: MatSnackBar,
private readonly store: Store,
private translateService: TranslateService
@@ -103,22 +91,17 @@ export class HomeComponent {
*/
handlePlaylist(payload: { uploadEvent: Event; file: File }): void {
this.isLoading = true;
const result = (payload.uploadEvent.target as FileReader)
const playlist = (payload.uploadEvent.target as FileReader)
.result as string;
const array = result.split('\n');
this.dataService.sendIpcEvent(PLAYLIST_PARSE, {
title: payload.file.name,
playlist: array,
path: payload.file.path,
});
}
/**
* 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,
})
);
}
/**
@@ -137,11 +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.dataService.sendIpcEvent(PLAYLIST_PARSE_TEXT, {
text,
});
this.store.dispatch(
parsePlaylist({
uploadType: 'TEXT',
playlist,
title: 'Imported as text',
})
);
}
/**
@@ -54,7 +54,7 @@
{{ '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>
@@ -1,5 +1,4 @@
import { MatCheckboxModule } from '@angular/material/checkbox';
import { PLAYLIST_SAVE_DETAILS } from './../../../../../shared/ipc-commands';
/* eslint-disable @typescript-eslint/unbound-method */
import { DatePipe } from '@angular/common';
import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing';
@@ -10,8 +9,12 @@ import {
} 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 } from 'ng-mocks';
import { Observable } from 'rxjs';
import { DataService } from '../../../services/data.service';
import { ElectronServiceStub } from '../../../services/electron.service.stub';
import { Playlist } from './../../../../../shared/playlist.interface';
@@ -21,6 +24,8 @@ describe('PlaylistInfoComponent', () => {
let component: PlaylistInfoComponent;
let fixture: ComponentFixture<PlaylistInfoComponent>;
let electronService: DataService;
let mockStore: MockStore;
const actions$ = new Observable<Actions>();
beforeEach(
waitForAsync(() => {
@@ -38,6 +43,8 @@ describe('PlaylistInfoComponent', () => {
{ provide: MAT_DIALOG_DATA, useValue: {} },
{ provide: DataService, useClass: ElectronServiceStub },
UntypedFormBuilder,
provideMockStore(),
provideMockActions(actions$),
],
}).compileComponents();
})
@@ -47,6 +54,7 @@ describe('PlaylistInfoComponent', () => {
fixture = TestBed.createComponent(PlaylistInfoComponent);
component = fixture.componentInstance;
electronService = TestBed.inject(DataService);
mockStore = TestBed.inject(MockStore);
fixture.detectChanges();
});
@@ -54,14 +62,10 @@ describe('PlaylistInfoComponent', () => {
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);
});
});
@@ -2,9 +2,9 @@
import { CommonModule, DatePipe } from '@angular/common';
import { Component, Inject } from '@angular/core';
import {
FormControl,
ReactiveFormsModule,
UntypedFormBuilder,
UntypedFormControl,
UntypedFormGroup,
Validators,
} from '@angular/forms';
@@ -12,10 +12,12 @@ import { MatButtonModule } from '@angular/material/button';
import { MatCheckboxModule } from '@angular/material/checkbox';
import { MatDialogModule, MAT_DIALOG_DATA } from '@angular/material/dialog';
import { MatInputModule } from '@angular/material/input';
import { Store } from '@ngrx/store';
import { TranslateModule } from '@ngx-translate/core';
import { PLAYLIST_SAVE_DETAILS } from '../../../../../shared/ipc-commands';
import { Playlist } from '../../../../../shared/playlist.interface';
import { DataService } from '../../../services/data.service';
import { PlaylistMeta } from '../../../shared/playlist-meta.type';
import * as PlaylistActions from '../../../state/actions';
@Component({
selector: 'app-playlist-info',
@@ -34,7 +36,7 @@ import { DataService } from '../../../services/data.service';
})
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;
@@ -42,18 +44,12 @@ export class PlaylistInfoComponent {
/** 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 store: Store
) {
this.playlist = playlist;
}
@@ -64,42 +60,33 @@ 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({
url: new FormControl({
value: this.playlist.url,
disabled: true,
}),
filePath: new UntypedFormControl({
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 }));
}
}
@@ -1,30 +1,9 @@
<div id="skeleton-container" *ngIf="loading; else playlistsView">
<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 #playlistsView>
<mat-nav-list cdkDropList (cdkDropListDropped)="drop($event)">
<ng-container *ngIf="allPlaylistsLoaded$ | async; else skeletonView">
<mat-nav-list
cdkDropList
(cdkDropListDropped)="drop($event, playlists)"
*ngIf="playlists$ | async as playlists"
>
<mat-list-item *ngIf="playlists.length === 0">
<mat-icon class="favorites-icon" mat-list-icon>search</mat-icon>
<div mat-line>{{ 'HOME.PLAYLISTS.NO_PLAYLISTS' | translate }}</div>
@@ -54,5 +33,54 @@
(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-nav-list>
</ng-container>
<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>
@@ -50,3 +50,18 @@
}
}
}
#migration-container {
padding: 10px;
border: 1px solid #999;
border-radius: 5px;
margin: 5px;
p {
margin-left: 15px;
}
.migration-btn {
text-transform: uppercase;
}
}
@@ -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', () => {
@@ -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(() => {
@@ -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();
})
@@ -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();
});
@@ -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', () => {
@@ -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);
});
});
@@ -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 {
PLAYLIST_GET_ALL,
PLAYLIST_GET_ALL_RESPONSE,
PLAYLIST_GET_BY_ID,
PLAYLIST_REMOVE_BY_ID,
PLAYLIST_REMOVE_BY_ID_RESPONSE,
selectAllPlaylistsMeta,
selectPlaylistsLoadingFlag,
} from '../../state/selectors';
import {
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';
@@ -25,57 +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))
);
loading = true;
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;
this.loading = false;
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);
}
}
/**
@@ -87,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);
}
});
}
@@ -113,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);
}
/**
@@ -154,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 }));
}
/**
@@ -166,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
*/
@@ -178,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);
});
}
}
}
@@ -13,8 +13,9 @@ 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 } from '@ngx-translate/core';
import { MockModule, MockPipes } from 'ng-mocks';
import { TranslatePipe, TranslateService } from '@ngx-translate/core';
import { MockModule, MockPipes, MockProviders } from 'ng-mocks';
import { NgxIndexedDBService } from 'ngx-indexed-db';
import { Observable } from 'rxjs';
import * as MOCKED_PLAYLIST from '../../../../mocks/playlist.json';
import { DataService } from '../../../services/data.service';
@@ -44,6 +45,7 @@ describe('ChannelListContainerComponent', () => {
{ provide: DataService, useClass: ElectronServiceStub },
provideMockStore(),
provideMockActions(actions$),
MockProviders(NgxIndexedDBService, TranslateService),
],
imports: [
MockModule(MatSnackBarModule),
@@ -73,8 +75,6 @@ describe('ChannelListContainerComponent', () => {
mockStore.setState({
playlistState: {
channels,
favorites: [MOCKED_PLAYLIST.playlist.items[0].url],
playlistId: '',
active: undefined,
},
});
@@ -158,9 +158,10 @@ describe('ChannelListContainerComponent', () => {
new MouseEvent('click')
);
fixture.detectChanges();
expect(mockStore.dispatch).toHaveBeenCalledWith(
{channel: component._channelList[0], type: expect.stringContaining('favorites')},
);
expect(mockStore.dispatch).toHaveBeenCalledWith({
channel: component._channelList[0],
type: expect.stringContaining('favorites'),
});
expect(mockStore.dispatch).toHaveBeenCalledTimes(1);
});
});
@@ -7,14 +7,15 @@ import {
} from '@angular/core';
import { MatSnackBar } from '@angular/material/snack-bar';
import { Store } from '@ngrx/store';
import { TranslateService } from '@ngx-translate/core';
import * as _ from 'lodash';
import { combineLatestWith, map, skipWhile } from 'rxjs';
import { map, skipWhile } from 'rxjs';
import { Channel } from '../../../../../shared/channel.interface';
import { PlaylistsService } from '../../../services/playlists.service';
import * as PlaylistActions from '../../../state/actions';
import {
selectChannels,
selectActivePlaylistId,
selectFavorites,
selectPlaylistId,
} from '../../../state/selectors';
@Component({
selector: 'app-channel-list-container',
@@ -43,14 +44,6 @@ export class ChannelListContainerComponent {
/** Selected channel */
selected!: Channel;
/** List with favorites */
favorites$ = this.store.select(selectChannels).pipe(
combineLatestWith(this.store.select(selectFavorites)),
map(([channels, favorites]) =>
channels.filter((channel) => favorites.includes(channel.id))
)
);
/** Search term for channel filter */
searchTerm: any = {
name: '',
@@ -69,17 +62,30 @@ export class ChannelListContainerComponent {
/** ID of the current playlist */
playlistId$ = this.store
.select(selectPlaylistId)
.select(selectActivePlaylistId)
.pipe(
skipWhile(
(playlistId) => playlistId === '' || playlistId === undefined
)
);
/**
* Creates an instance of ChannelListContainerComponent
*/
constructor(private readonly store: Store, private snackBar: MatSnackBar) {}
/** List with favorites */
favorites$ = this.store
.select(selectFavorites)
.pipe(
map((favorites) =>
this.channelList.filter((channel) =>
favorites.includes(channel.id)
)
)
);
constructor(
private playlistService: PlaylistsService,
private readonly store: Store,
private snackBar: MatSnackBar,
private translateService: TranslateService
) {}
/**
* Sets clicked channel as selected and emits them to the parent component
@@ -97,7 +103,11 @@ export class ChannelListContainerComponent {
*/
toggleFavoriteChannel(channel: Channel, clickEvent: MouseEvent): void {
clickEvent.stopPropagation();
this.snackBar.open('Favorites were updated!', null, { duration: 2000 });
this.snackBar.open(
this.translateService.instant('CHANNELS.FAVORITES_UPDATED'),
null,
{ duration: 2000 }
);
this.store.dispatch(PlaylistActions.updateFavorites({ channel }));
}
@@ -48,15 +48,17 @@ export class HtmlVideoPlayerComponent implements OnChanges, OnDestroy {
*/
playChannel(channel: Channel): void {
if (this.hls) this.hls.destroy();
const url = channel.url + channel.epgParams;
if (Hls && Hls.isSupported()) {
console.log('... switching channel to ', channel.name, url);
this.hls = new Hls();
this.hls.attachMedia(this.videoPlayer.nativeElement);
this.hls.loadSource(url);
this.handlePlayOperation();
} else {
console.error('something wrong with hls.js init...');
if (channel.url) {
const url = channel.url + channel.epgParams;
if (Hls && Hls.isSupported()) {
console.log('... switching channel to ', channel.name, url);
this.hls = new Hls();
this.hls.attachMedia(this.videoPlayer.nativeElement);
this.hls.loadSource(url);
this.handlePlayOperation();
} else {
console.error('something wrong with hls.js init...');
}
}
}
@@ -0,0 +1,52 @@
<div id="playlists-navigation">
<div class="current-playlist">
<button mat-icon-button matTooltip="Back" (click)="goBack()">
<mat-icon>arrow_back_ios</mat-icon>
</button>
<div class="playlist-info">
<div class="name">
<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">
{{
sidebarView === 'CHANNELS'
? channels.length +
' ' +
('HOME.PLAYLISTS.CHANNELS' | translate)
: ('HOME.PLAYLISTS.MY_PLAYLISTS_SUBTITLE' | translate)
}}
</div>
</div>
<button
mat-icon-button
*ngIf="sidebarView === 'CHANNELS'"
routerLink="/"
[matTooltip]="
'CHANNELS.UPLOAD_OR_SELECT_OTHER_PLAYLIST' | translate
"
>
<mat-icon>playlist_add</mat-icon>
</button>
</div>
</div>
<mat-divider></mat-divider>
<app-channel-list-container
*ngIf="sidebarView === 'CHANNELS'; else playlistsTemplate"
[channelList]="channels"
>
</app-channel-list-container>
<ng-template #playlistsTemplate>
<app-recent-playlists
class="recent-playlists"
[class.electron]="dataService.isElectron"
(playlistClicked)="selectPlaylist()"
></app-recent-playlists>
</ng-template>
@@ -0,0 +1,47 @@
#playlists-navigation {
background-color: #000;
color: #fff;
.current-playlist {
display: flex;
align-items: center;
padding: 8px;
}
.playlist-info {
flex: 1;
max-width: 300px;
.name {
font-size: 1.1em;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.channels-count {
font-size: 0.8em;
color: #ccc;
text-transform: lowercase;
}
}
}
.recent-playlists {
height: calc(100vh - 60px);
overflow: auto;
width: 100%;
display: block;
}
.electron {
height: calc(100vh - 85px) !important;
}
@media only screen and (max-width: 480px) {
.playlist-info {
.name {
max-width: 105px;
}
}
}
@@ -0,0 +1,42 @@
import { Component, Input } from '@angular/core';
import { Router } from '@angular/router';
import { Store } from '@ngrx/store';
import { Channel } from '../../../../../../shared/channel.interface';
import { DataService } from '../../../../services/data.service';
import * as PlaylistActions from '../../../../state/actions';
import { selectPlaylistTitle } from '../../../../state/selectors';
import { SidebarView } from '../video-player.component';
@Component({
selector: 'app-sidebar',
templateUrl: './sidebar.component.html',
styleUrls: ['./sidebar.component.scss'],
})
export class SidebarComponent {
@Input() channels: Channel[] = [];
isElectron = this.dataService.isElectron;
playlistTitle$ = this.store.select(selectPlaylistTitle);
sidebarView: SidebarView = 'CHANNELS';
constructor(
public dataService: DataService,
private router: Router,
private store: Store
) {}
goBack(): void {
if (this.sidebarView === 'PLAYLISTS') {
this.store.dispatch(PlaylistActions.resetActiveChannel());
this.router.navigate(['/']);
} else {
this.sidebarView = 'PLAYLISTS';
}
}
selectPlaylist() {
this.sidebarView = 'CHANNELS';
}
}
@@ -0,0 +1,41 @@
<mat-toolbar color="primary">
<button
mat-icon-button
(click)="toggleLeftDrawerClicked.emit()"
[matTooltip]="'TOP_MENU.OPEN_CHANNELS_LIST' | translate"
>
<mat-icon>menu</mat-icon>
</button>
<button
*ngIf="(playlistId$ | async) !== 'GLOBAL_FAVORITES'"
mat-icon-button
(click)="addToFavorites(activeChannel)"
[matTooltip]="'TOP_MENU.TOGGLE_FAVORITE_FLAG' | translate"
>
<mat-icon>
{{
(favorites$ | async).includes(activeChannel?.id)
? 'star'
: 'star_outline'
}}</mat-icon
>
</button>
{{ activeChannel?.name }}
<div class="spacer"></div>
<ng-container *ngIf="isEpgAvailable$ | async">
<button
mat-icon-button
(click)="multiEpgClicked.emit()"
[matTooltip]="'TOP_MENU.OPEN_MULTI_EPG' | translate"
>
<mat-icon>view_list</mat-icon>
</button>
<button
mat-button
(click)="toggleRightDrawerClicked.emit()"
[matTooltip]="'TOP_MENU.OPEN_EPG_LIST' | translate"
>
EPG
</button>
</ng-container>
</mat-toolbar>
@@ -0,0 +1,3 @@
.spacer {
flex: 1 1 auto;
}
@@ -0,0 +1,46 @@
import { Component, EventEmitter, Input, Output } from '@angular/core';
import { MatSnackBar } from '@angular/material/snack-bar';
import { Store } from '@ngrx/store';
import { TranslateService } from '@ngx-translate/core';
import { Channel } from '../../../../../../shared/channel.interface';
import { updateFavorites } from '../../../../state/actions';
import {
selectActivePlaylistId,
selectFavorites,
selectIsEpgAvailable,
} from '../../../../state/selectors';
@Component({
selector: 'app-toolbar',
templateUrl: './toolbar.component.html',
styleUrls: ['./toolbar.component.scss'],
})
export class ToolbarComponent {
@Input() activeChannel!: Channel;
@Output() multiEpgClicked = new EventEmitter<void>();
@Output() toggleLeftDrawerClicked = new EventEmitter<void>();
@Output() toggleRightDrawerClicked = new EventEmitter<void>();
favorites$ = this.store.select(selectFavorites);
isEpgAvailable$ = this.store.select(selectIsEpgAvailable);
playlistId$ = this.store.select(selectActivePlaylistId);
constructor(
private snackBar: MatSnackBar,
private store: Store,
private translateService: TranslateService
) {}
/**
* Adds/removes a given channel to the favorites list
* @param channel channel to add
*/
addToFavorites(channel: Channel): void {
this.snackBar.open(
this.translateService.instant('CHANNELS.FAVORITES_UPDATED'),
null,
{ duration: 2000 }
);
this.store.dispatch(updateFavorites({ channel }));
}
}
@@ -1,117 +1,15 @@
<mat-drawer-container class="main-container" (backdropClick)="close()">
<mat-drawer-container class="main-container">
<!-- sidebar content -->
<mat-drawer
#drawer
mode="side"
opened
(keydown.escape)="close()"
disableClose
>
<ng-container *ngIf="channels$ | async as channels">
<div id="playlists-navigation">
<div class="current-playlist">
<button
mat-icon-button
matTooltip="Back"
(click)="goBack()"
>
<mat-icon>arrow_back_ios</mat-icon>
</button>
<div class="playlist-info">
<div class="name">
<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">
{{
sidebarView === 'CHANNELS'
? channels.length + ' channels'
: 'all available playlists'
}}
</div>
</div>
<button
mat-icon-button
*ngIf="sidebarView === 'CHANNELS'"
routerLink="/"
[matTooltip]="
'CHANNELS.UPLOAD_OR_SELECT_OTHER_PLAYLIST'
| translate
"
>
<mat-icon>playlist_add</mat-icon>
</button>
</div>
</div>
<mat-divider></mat-divider>
<app-channel-list-container
*ngIf="sidebarView === 'CHANNELS'; else playlistsTemplate"
[channelList]="channels"
>
</app-channel-list-container>
<ng-template #playlistsTemplate>
<app-recent-playlists
class="recent-playlists"
[class.electron]="dataService.isElectron"
></app-recent-playlists>
</ng-template>
</ng-container>
<mat-drawer #drawer mode="side" opened disableClose>
<app-sidebar
*ngIf="channels$ | async as channels"
[channels]="channels" />
</mat-drawer>
<mat-drawer-content>
<ng-container *ngIf="activeChannel$ | async as activeChannel">
<!-- toolbar with drawer icon -->
<mat-toolbar color="primary">
<button
mat-icon-button
(click)="drawer.toggle()"
[matTooltip]="'TOP_MENU.OPEN_CHANNELS_LIST' | translate"
>
<mat-icon>menu</mat-icon>
</button>
<button
*ngIf="(playlistId$ | async) !== 'GLOBAL_FAVORITES'"
mat-icon-button
(click)="addToFavorites(activeChannel)"
[matTooltip]="'TOP_MENU.TOGGLE_FAVORITE_FLAG' | translate"
>
<mat-icon>
{{
(favorites$ | async).includes(activeChannel?.id)
? 'star'
: 'star_outline'
}}</mat-icon
>
</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
(click)="drawerRight.toggle()"
[matTooltip]="'TOP_MENU.OPEN_EPG_LIST' | translate"
>
EPG
</button>
</mat-toolbar>
<app-toolbar (toggleLeftDrawerClicked)="drawer.toggle()" (toggleRightDrawerClicked)="drawerRight.toggle()"
(multiEpgClicked)="openMultiEpgView()" [activeChannel]="activeChannel" />
<!-- video.js player -->
<app-vjs-player
@@ -142,13 +40,7 @@
></app-info-overlay>
</mat-drawer-content>
<!-- right sidebar content -->
<mat-drawer
position="end"
#drawerRight
mode="side"
(keydown.escape)="close()"
disableClose
>
<mat-drawer position="end" #drawerRight mode="side" disableClose>
<app-epg-list *ngIf="isElectron"></app-epg-list>
</mat-drawer>
</mat-drawer-container>
@@ -16,58 +16,9 @@
min-width: 400px;
}
.spacer {
flex: 1 1 auto;
}
#playlists-navigation {
background-color: #000;
color: #fff;
.current-playlist {
display: flex;
align-items: center;
padding: 8px;
}
.playlist-info {
flex: 1;
max-width: 300px;
.name {
font-size: 1.1em;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.channels-count {
font-size: 0.8em;
color: #ccc;
}
}
}
.recent-playlists {
height: calc(100vh - 60px);
overflow: auto;
width: 100%;
display: block;
}
.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;
}
}
}
@@ -9,12 +9,10 @@ import { ActivatedRoute } from '@angular/router';
import { RouterTestingModule } from '@angular/router/testing';
import { MockStore, provideMockStore } from '@ngrx/store/testing';
import { TranslatePipe } from '@ngx-translate/core';
import { MockComponent, MockModule, MockPipe } from 'ng-mocks';
import * as MOCKED_PLAYLIST from '../../../../mocks/playlist.json';
import { MockComponent, MockModule, MockPipe, MockProviders } from 'ng-mocks';
import { DataService } from '../../../services/data.service';
import { ElectronServiceStub } from '../../../services/electron.service.stub';
import { VideoPlayer } from '../../../settings/settings.interface';
import { createChannel } from '../../../shared/channel.model';
import { ChannelListContainerComponent } from '../channel-list-container/channel-list-container.component';
import { EpgListComponent } from '../epg-list/epg-list.component';
import { HtmlVideoPlayerComponent } from '../html-video-player/html-video-player.component';
@@ -24,7 +22,10 @@ import { VideoPlayerComponent } from './video-player.component';
import { Actions } from '@ngrx/effects';
import { provideMockActions } from '@ngrx/effects/testing';
import { Observable } from 'rxjs';
import { NgxIndexedDBService } from 'ngx-indexed-db';
import { Observable, of } from 'rxjs';
import { PlaylistsService } from '../../../services/playlists.service';
import { initialState } from '../../../state/state';
class MatSnackBarStub {
open(): void {}
@@ -56,6 +57,7 @@ describe('VideoPlayerComponent', () => {
{
provide: ActivatedRoute,
useValue: {
params: of({ id: '1' }),
snapshot: {
queryParams: {
url: 'https://iptvnator/list.m3u',
@@ -65,6 +67,7 @@ describe('VideoPlayerComponent', () => {
},
provideMockStore(),
provideMockActions(actions$),
MockProviders(NgxIndexedDBService, PlaylistsService),
],
imports: [
MockModule(MatSidenavModule),
@@ -83,34 +86,16 @@ describe('VideoPlayerComponent', () => {
component = fixture.componentInstance;
mockStore = TestBed.inject(MockStore);
// set channels
channels = MOCKED_PLAYLIST.playlist.items.map((element) =>
createChannel(element)
);
mockStore.setState({ playlistState: {channels} });
});
it('should create and init component', () => {
expect(component).toBeTruthy();
jest.spyOn(component, 'applySettings');
mockStore.setState({
playlistState: initialState,
});
fixture.detectChanges();
expect(component.applySettings).toHaveBeenCalledTimes(1);
});
it('should check default component settings', () => {
fixture.detectChanges();
expect(component.playerSettings).toEqual({
player: VideoPlayer.VideoJs,
showCaptions: false,
});
});
it('should update store after channel was faved', () => {
jest.spyOn(mockStore, 'dispatch');
const [firstChannel] = channels;
component.addToFavorites(firstChannel);
expect(mockStore.dispatch).toHaveBeenCalledTimes(1);
expect(mockStore.dispatch).toHaveBeenCalledWith({channel: firstChannel, type: expect.stringContaining('favorite')});
});
});
@@ -7,45 +7,44 @@ import {
NgZone,
OnDestroy,
OnInit,
ViewChild,
} from '@angular/core';
import { MatSidenav } from '@angular/material/sidenav';
import { MatSnackBar } from '@angular/material/snack-bar';
import { ActivatedRoute, Router } from '@angular/router';
import { ActivatedRoute } from '@angular/router';
import { Store } from '@ngrx/store';
import { StorageMap } from '@ngx-pwa/local-storage';
import { filter, skipWhile } from 'rxjs';
import {
combineLatestWith,
filter,
Observable,
skipWhile,
switchMap,
} from 'rxjs';
import { Channel } from '../../../../../shared/channel.interface';
import {
PLAYLIST_GET_ALL,
PLAYLIST_PARSE_BY_URL,
PLAYLIST_PARSE_RESPONSE,
} from '../../../../../shared/ipc-commands';
import { Playlist } from '../../../../../shared/playlist.interface';
import { DataService } from '../../../services/data.service';
import { PlaylistsService } from '../../../services/playlists.service';
import { Settings, VideoPlayer } from '../../../settings/settings.interface';
import { STORE_KEY } from '../../../shared/enums/store-keys.enum';
import { setPlaylist, updateFavorites } from '../../../state/actions';
import * as PlaylistActions from '../../../state/actions';
import {
selectActive,
selectChannels,
selectCurrentEpgProgram,
selectFavorites,
selectIsEpgAvailable,
selectPlaylistFilename,
selectPlaylistId,
selectPlaylistTitle,
} from '../../../state/selectors';
import { MultiEpgContainerComponent } from '../multi-epg/multi-epg-container.component';
/** Possible sidebar view options */
type SidebarView = 'CHANNELS' | 'PLAYLISTS';
export 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'],
})
@@ -53,84 +52,66 @@ export class VideoPlayerComponent implements OnInit, OnDestroy {
/** Active selected channel */
activeChannel$ = this.store
.select(selectActive)
.pipe(filter((channel) => Boolean(channel)));
.pipe(filter((channel) => Boolean(channel?.url)));
/** Channels list */
channels$ = this.store.select(selectChannels);
/** EPG availability flag */
epgAvailable$ = this.store.select(selectIsEpgAvailable);
channels$!: Observable<Channel[]>;
/** Current epg program */
epgProgram$ = this.store.select(selectCurrentEpgProgram);
/** Favorites list */
favorites$ = this.store.select(selectFavorites);
/** Selected video player options */
playerSettings: Partial<Settings> = {
player: VideoPlayer.VideoJs,
showCaptions: false,
};
/** Playlists array */
playlists = [];
/** Sidebar object */
@ViewChild('sidenav') sideNav: MatSidenav;
/** ID of the current playlist */
playlistId$ = this.store
.select(selectPlaylistId)
.pipe(
skipWhile(
(playlistId) => playlistId === '' || playlistId === undefined
)
);
/** Title of the current playlist */
playlistTitle$ = this.store
.select(selectPlaylistFilename)
.select(selectPlaylistTitle)
.pipe(skipWhile((playlistFilename) => playlistFilename === ''));
isElectron = this.dataService.isElectron;
/** IPC Renderer commands list with callbacks */
commandsList = [
{
id: PLAYLIST_PARSE_RESPONSE,
execute: (response: { payload: Playlist }): void => {
this.store.dispatch(
setPlaylist({ playlist: response.payload })
);
this.setSidebarView('CHANNELS');
if (response.payload.isTemporary) {
this.store.dispatch(
PlaylistActions.setChannels({
channels: response.payload.playlist.items,
})
);
} else {
this.store.dispatch(
PlaylistActions.addPlaylist({
playlist: response.payload,
})
);
}
this.sidebarView = 'CHANNELS';
},
},
];
/** Current sidebar view */
sidebarView: SidebarView = 'CHANNELS';
listeners = [];
isElectron = this.dataService.isElectron;
sidebarView: SidebarView = 'CHANNELS';
/** EPG overlay reference */
overlayRef: OverlayRef;
/**
* Creates an instance of VideoPlayerComponent
*/
constructor(
private activatedRoute: ActivatedRoute,
private store: Store,
public dataService: DataService,
private dataService: DataService,
private ngZone: NgZone,
private overlay: Overlay,
private router: Router,
private snackBar: MatSnackBar,
private storage: StorageMap
) {
this.dataService.sendIpcEvent(PLAYLIST_GET_ALL);
}
private playlistsService: PlaylistsService,
private storage: StorageMap,
private store: Store
) {}
/**
* Sets video player and subscribes to channel list from the store
@@ -139,10 +120,27 @@ export class VideoPlayerComponent implements OnInit, OnDestroy {
this.applySettings();
this.setRendererListeners();
this.getPlaylistUrlAsParam();
this.channels$ = this.activatedRoute.params.pipe(
combineLatestWith(this.activatedRoute.queryParams),
switchMap(([params, queryParams]) => {
if (params.id) {
this.store.dispatch(
PlaylistActions.setActivePlaylist({
playlistId: params.id,
})
);
return this.playlistsService.getPlaylistChannels(params.id);
} else if (queryParams.url) {
return this.store.select(selectChannels);
}
})
);
}
/**
* Opens a playlist provided as a url param
* e.g. iptvnat.or?url=http://...
*/
getPlaylistUrlAsParam() {
const URL_REGEX = /^(http|https|file):\/\/[^ "]+$/;
@@ -161,7 +159,7 @@ export class VideoPlayerComponent implements OnInit, OnDestroy {
*/
setRendererListeners(): void {
this.commandsList.forEach((command) => {
if (this.dataService.isElectron) {
if (this.isElectron) {
this.dataService.listenOn(command.id, (event, response) =>
this.ngZone.run(() => command.execute(response))
);
@@ -191,41 +189,8 @@ export class VideoPlayerComponent implements OnInit, OnDestroy {
});
}
/**
* Closes the channels sidebar
*/
close(): void {
this.sideNav.close();
}
/**
* Adds/removes a given channel to the favorites list
* @param channel channel to add
*/
addToFavorites(channel: Channel): void {
this.snackBar.open('Favorites were updated!', null, { duration: 2000 });
this.store.dispatch(updateFavorites({ channel }));
}
/**
* Switches the sidebar view to the specified value
* @param view view to change
*/
setSidebarView(view: SidebarView) {
this.sidebarView = view;
}
/** Navigates back */
goBack(): void {
if (this.sidebarView === 'PLAYLISTS') {
this.router.navigate(['/']);
} else {
this.sidebarView = 'PLAYLISTS';
}
}
ngOnDestroy() {
if (this.dataService.isElectron) {
if (this.isElectron) {
this.dataService.removeAllListeners(PLAYLIST_PARSE_RESPONSE);
} else {
this.listeners.forEach((listener) =>
+4
View File
@@ -11,6 +11,8 @@ 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 { SidebarComponent } from './components/video-player/sidebar/sidebar.component';
import { ToolbarComponent } from './components/video-player/toolbar/toolbar.component';
import { VideoPlayerComponent } from './components/video-player/video-player.component';
import { VjsPlayerComponent } from './components/vjs-player/vjs-player.component';
@@ -32,6 +34,8 @@ const routes: Routes = [{ path: '', component: VideoPlayerComponent }];
InfoOverlayComponent,
HtmlVideoPlayerComponent,
MultiEpgContainerComponent,
SidebarComponent,
ToolbarComponent,
VideoPlayerComponent,
VjsPlayerComponent,
],
+183
View File
@@ -0,0 +1,183 @@
/* eslint-disable @typescript-eslint/no-unused-vars */
import { Injectable } from '@angular/core';
import { parse } from 'iptv-playlist-parser';
import { NgxIndexedDBService } from 'ngx-indexed-db';
import { combineLatest, map, Observable, switchMap } from 'rxjs';
import { Channel } from '../../../shared/channel.interface';
import { GLOBAL_FAVORITES_PLAYLIST_ID } from '../../../shared/constants';
import {
Playlist,
PlaylistUpdateState,
} from '../../../shared/playlist.interface';
import {
aggregateFavoriteChannels,
createFavoritesPlaylist,
createPlaylistObject,
} from '../../../shared/playlist.utils';
import { DbStores } from '../indexed-db.config';
import { PlaylistMeta } from '../shared/playlist-meta.type';
@Injectable({
providedIn: 'root',
})
export class PlaylistsService {
constructor(private dbService: NgxIndexedDBService) {}
getAllPlaylists() {
return this.dbService.getAll<Playlist>(DbStores.Playlists).pipe(
map((data) =>
data.map(({ playlist, items, header, ...rest }) => ({
...rest,
}))
),
map((playlists) =>
playlists.sort((a, b) => a.position - b.position)
)
);
}
addPlaylist(playlist) {
return this.dbService.add(DbStores.Playlists, playlist);
}
getPlaylistChannels(id: string) {
let playlist$: Observable<Partial<Playlist>>;
if (id === GLOBAL_FAVORITES_PLAYLIST_ID) {
playlist$ = this.getPlaylistWithGlobalFavorites();
} else {
playlist$ = this.dbService.getByID<Playlist>(
DbStores.Playlists,
id
);
}
return playlist$.pipe(
map((data: Playlist) => data.playlist.items as Channel[])
);
}
deletePlaylist(playlistId: string) {
return this.dbService.delete(DbStores.Playlists, playlistId);
}
updatePlaylist(playlistId: string, updatedPlaylist: Playlist) {
return this.getPlaylistById(playlistId).pipe(
switchMap((currentPlaylist: Playlist) =>
this.dbService.update(DbStores.Playlists, {
...currentPlaylist,
...updatedPlaylist,
count: updatedPlaylist.playlist.items.length,
updateDate: Date.now(),
updateState: PlaylistUpdateState.UPDATED,
})
)
);
}
getPlaylistById(id: string) {
return this.dbService.getByID<Playlist>(DbStores.Playlists, id);
}
updatePlaylistMeta(updatedPlaylist: PlaylistMeta) {
return this.getPlaylistById(updatedPlaylist._id).pipe(
switchMap((playlist) =>
this.dbService.update(DbStores.Playlists, {
...playlist,
title: updatedPlaylist.title,
autoRefresh: updatedPlaylist.autoRefresh,
})
)
);
}
updateFavorites(id: string, favorites: string[]) {
return this.getPlaylistById(id).pipe(
switchMap((playlist) =>
this.dbService.update(DbStores.Playlists, {
...playlist,
favorites,
})
)
);
}
updateManyPlaylists(playlists: Playlist[]) {
return combineLatest(
playlists.map((playlist) => {
return this.dbService.update(DbStores.Playlists, {
...playlist,
updateDate: Date.now(),
autoRefresh: true,
});
})
);
}
getFavoriteChannels(playlistId: string) {
return this.dbService
.getByID<Playlist>(DbStores.Playlists, playlistId)
.pipe(
map((data) =>
data.playlist.items.filter((channel) =>
data.favorites.includes((channel as Channel).id)
)
)
);
}
updatePlaylistPositions(
positionUpdates: {
id: string;
changes: { position: number };
}[]
) {
return combineLatest(
positionUpdates.map((item, index) => {
return this.dbService.getByID(DbStores.Playlists, item.id).pipe(
switchMap((playlist: Playlist) =>
this.dbService.update(DbStores.Playlists, {
...playlist,
position: item.changes.position,
})
)
);
})
);
}
handlePlaylistParsing(
uploadType: 'FILE' | 'URL' | 'TEXT',
playlist: string,
title: string,
path?: string
) {
const parsedPlaylist = parse(playlist);
return createPlaylistObject(title, parsedPlaylist, path, uploadType);
}
getPlaylistWithGlobalFavorites() {
return this.dbService.getAll(DbStores.Playlists).pipe(
map((playlists: Playlist[]) => {
const favoriteChannels = aggregateFavoriteChannels(playlists);
const favPlaylist = createFavoritesPlaylist(favoriteChannels);
return favPlaylist;
})
);
}
addManyPlaylists(playlists: Playlist[]) {
return this.dbService.bulkAdd(DbStores.Playlists, playlists as any); // TODO: update ngx-indexed-db
}
getPlaylistsForAutoUpdate() {
return this.dbService.getAll(DbStores.Playlists).pipe(
map((playlists: Playlist[]) => {
return playlists
.filter((item) => item.autoRefresh)
.map(
({ playlist, header, items, favorites, ...rest }) =>
rest
);
})
);
}
}
+49 -325
View File
@@ -2,77 +2,41 @@ import { HttpClient } from '@angular/common/http';
import { ApplicationRef, inject, Injectable } from '@angular/core';
import { MatSnackBar } from '@angular/material/snack-bar';
import { SwUpdate } from '@angular/service-worker';
import { Store } from '@ngrx/store';
import { TranslateService } from '@ngx-translate/core';
import { parse } from 'iptv-playlist-parser';
import { NgxIndexedDBService } from 'ngx-indexed-db';
import {
catchError,
combineLatest,
map,
of,
switchMap,
throwError,
} from 'rxjs';
import { GLOBAL_FAVORITES_PLAYLIST_ID } from '../../../shared/constants';
import { catchError, throwError } from 'rxjs';
import {
ERROR,
PLAYLIST_GET_ALL,
PLAYLIST_GET_ALL_RESPONSE,
PLAYLIST_GET_BY_ID,
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';
import {
aggregateFavoriteChannels,
createFavoritesPlaylist,
getUUID,
} from '../../../shared/playlist.utils';
import { Playlist } from '../../../shared/playlist.interface';
import { AppConfig } from '../../environments/environment';
import { ParsedPlaylist } from '../../typings';
import { DbStores } from '../indexed-db.config';
import * as PlaylistActions from '../state/actions';
import { DataService } from './data.service';
import { PlaylistsService } from './playlists.service';
@Injectable({
providedIn: 'root',
})
export class PwaService extends DataService {
/** Proxy URL to avoid CORS issues */
corsProxyUrl = AppConfig.BACKEND_URL;
appRef = inject(ApplicationRef);
playlistService = inject(PlaylistsService);
snackBar = inject(MatSnackBar);
store = inject(Store);
swUpdate = inject(SwUpdate);
translateService = inject(TranslateService);
/**
* Creates an instance of PwaService.
* @param dbService database service
* @param http angular http client
*/
constructor(
private dbService: NgxIndexedDBService,
private http: HttpClient
) {
/** Proxy URL to avoid CORS issues */
corsProxyUrl = AppConfig.BACKEND_URL;
constructor(private http: HttpClient) {
super();
console.log('PWA service initialized...');
}
/**
* Uses service worker mechanism to check for available application updates
*/
/** Uses service worker mechanism to check for available application updates */
checkUpdates() {
this.swUpdate.versionUpdates.subscribe(() => {
this.snackBar
@@ -87,251 +51,58 @@ export class PwaService extends DataService {
});
}
/**
* Returns the current version of the app
*/
getAppVersion(): string {
return AppConfig.version;
}
/**
* Returns the count of favorite channels from all playlists
*/
getGlobalFavoritesCount() {
return this.dbService.getAll(DbStores.Playlists).pipe(
map((playlists: Playlist[]) => {
let count = 0;
playlists.forEach((playlist) => {
if (playlist.favorites.length > 0) {
count = count + playlist.favorites.length;
}
});
return count;
})
);
}
/**
* Sends a message with playlist that contains favorite channels from all available playlists
*/
sendPlaylistWithGlobalFavorites() {
this.dbService
.getAll(DbStores.Playlists)
.pipe(
map((playlists: Playlist[]) => {
const favoriteChannels =
aggregateFavoriteChannels(playlists);
const favPlaylist =
createFavoritesPlaylist(favoriteChannels);
return favPlaylist;
})
)
.subscribe((playlist) => {
window.postMessage({
type: PLAYLIST_PARSE_RESPONSE,
payload: playlist,
});
});
}
/**
* Handles incoming IPC commands
* @param type ipc command type
* @param payload payload
*/
sendIpcEvent(type: string, payload?: any): void {
if (type === PLAYLIST_PARSE) {
const parsedPlaylist = this.parsePlaylist(payload.playlist);
const playlistObject = this.createPlaylistObject(
payload.title,
parsedPlaylist,
payload.path,
'FILE'
);
// save to db
this.dbService
.add(DbStores.Playlists, playlistObject)
.subscribe(() => {
console.log('playlist was saved to db...');
});
window.postMessage({
type: PLAYLIST_PARSE_RESPONSE,
payload: playlistObject,
});
} else if (type === PLAYLIST_GET_BY_ID) {
if (payload.id === GLOBAL_FAVORITES_PLAYLIST_ID) {
this.sendPlaylistWithGlobalFavorites();
} else {
this.dbService
.getByIndex(DbStores.Playlists, '_id', payload.id)
.subscribe((playlist) => {
window.postMessage({
type: PLAYLIST_PARSE_RESPONSE,
payload: playlist,
});
});
}
} else if (type === PLAYLIST_REMOVE_BY_ID) {
this.dbService
.delete(DbStores.Playlists, payload.id)
.subscribe((playlist) => {
window.postMessage({
type: PLAYLIST_REMOVE_BY_ID_RESPONSE,
payload: playlist,
});
});
} else if (type === PLAYLIST_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)
.subscribe((playlists: Playlist[]) => {
window.postMessage({
type: PLAYLIST_GET_ALL_RESPONSE,
payload: playlists.sort(
(a, b) => a.position - b.position
),
});
});
} else if (type === PLAYLIST_SAVE_DETAILS) {
this.dbService
.getByID(DbStores.Playlists, payload._id)
.pipe(
switchMap((playlist: Playlist) => {
return this.dbService.update(DbStores.Playlists, {
...playlist,
title: payload.title,
});
})
)
.subscribe((playlists) => {
window.postMessage({
type: PLAYLIST_GET_ALL_RESPONSE,
payload: playlists,
});
});
} else if (type === PLAYLIST_UPDATE_FAVORITES) {
this.dbService
.getByID(DbStores.Playlists, payload.id)
.pipe(
switchMap((playlist: Playlist) => {
return this.dbService.update(DbStores.Playlists, {
...playlist,
favorites: payload.favorites,
});
})
)
.subscribe(() => {
console.log('favorites were updated...');
});
} else if (type === PLAYLIST_PARSE_BY_URL) {
sendIpcEvent(type: string, payload?: unknown): void {
if (type === PLAYLIST_PARSE_BY_URL) {
this.fetchFromUrl(payload);
} else if (type === PLAYLIST_UPDATE) {
this.http
// eslint-disable-next-line @typescript-eslint/restrict-template-expressions
.get(`${this.corsProxyUrl}/parse?url=${payload.url}`, {
responseType: 'text',
})
.pipe(
catchError((error) => {
window.postMessage({
type: ERROR,
});
return throwError(() => error);
}),
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,
});
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) => {
return this.dbService
.getByID(DbStores.Playlists, playlist._id)
.pipe(
switchMap((playlist: Playlist) => {
return this.dbService.update(DbStores.Playlists, {
...playlist,
position: index,
});
})
);
});
combineLatest(requests).subscribe(() => {
console.log('playlist positions were updated...');
});
this.refreshPlaylist(payload);
}
}
refreshPlaylist(payload: Partial<Playlist & { id: string }>) {
this.getPlaylistFromUrl(payload.url)
.pipe(
catchError((error) => {
window.postMessage({
type: ERROR,
});
return throwError(() => error);
})
)
.subscribe((playlist: Playlist) => {
this.store.dispatch(
PlaylistActions.updatePlaylist({
playlist,
playlistId: payload.id,
})
);
this.snackBar.open(
this.translateService.instant(
'HOME.PLAYLISTS.PLAYLIST_UPDATE_SUCCESS'
),
null,
{ duration: 2000 }
);
});
}
/**
* Fetches playlist from the specified url
* @param payload playlist payload
*/
fetchFromUrl(payload: Partial<Playlist>): void {
this.http
.get(`${this.corsProxyUrl}/parse?url=${payload.url}`)
this.getPlaylistFromUrl(payload.url)
.pipe(
switchMap((response) =>
payload.isTemporary
? of(response)
: this.dbService.add(DbStores.Playlists, response)
),
catchError((error) => {
window.postMessage({
type: ERROR,
@@ -344,7 +115,7 @@ export class PwaService extends DataService {
.subscribe((response: any) => {
window.postMessage({
type: PLAYLIST_PARSE_RESPONSE,
payload: response,
payload: { ...response, isTemporary: payload.isTemporary },
});
});
}
@@ -365,57 +136,10 @@ export class PwaService extends DataService {
return message;
}
/**
* Converts file string to playlist object
* @param m3uString
*/
convertFileStringToPlaylist(m3uString: string): ParsedPlaylist {
return this.parsePlaylist(m3uString.split('\n'));
}
/**
* Saves playlist to the localStorage
* @param name name of the playlist
* @param playlist playlist to save
* @param urlOrPath absolute fs path or url of the playlist
* @param uploadType upload type - by file or via an url
*/
createPlaylistObject(
name: string,
playlist: ParsedPlaylist,
urlOrPath?: string,
uploadType?: 'URL' | 'FILE'
): Playlist {
const id = getUUID();
return {
id,
_id: id,
filename: name,
title: name,
count: playlist.items.length,
playlist: {
...playlist,
items: playlist.items.map((item) => ({
id: getUUID(),
...item,
})),
},
importDate: new Date().toISOString(),
lastUsage: new Date().toISOString(),
favorites: [],
autoRefresh: false,
...(uploadType === 'URL' ? { url: urlOrPath } : {}),
...(uploadType === 'FILE' ? { filePath: urlOrPath } : {}),
};
}
/**
* Parses string based array to playlist object
* @param m3uArray m3u playlist as array with strings
*/
parsePlaylist(m3uArray: string[]): ParsedPlaylist {
const playlistAsString = m3uArray.join('\n');
return parse(playlistAsString);
getPlaylistFromUrl(url: string) {
return this.http.get(`${this.corsProxyUrl}/parse`, {
params: { url },
});
}
removeAllListeners(): void {
+2
View File
@@ -13,4 +13,6 @@ export type PlaylistMeta = Pick<
| 'updateDate'
| 'updateState'
| 'position'
| 'autoRefresh'
| 'favorites'
>;
+71 -4
View File
@@ -2,8 +2,64 @@ import { createAction, props } from '@ngrx/store';
import { Channel } from '../../../shared/channel.interface';
import { Playlist } from '../../../shared/playlist.interface';
import { EpgProgram } from '../player/models/epg-program.model';
import { PlaylistMeta } from '../shared/playlist-meta.type';
const STORE_KEY = '[PLAYLIST STORE]';
const STORE_KEY = '[GLOBAL STORE]';
const PLAYLISTS_STORE_KEY = '[PLAYLISTS STORE]';
export const loadPlaylists = createAction(
`${PLAYLISTS_STORE_KEY} Load playlists from db`
);
export const loadPlaylistsSuccess = createAction(
`${PLAYLISTS_STORE_KEY} Successfully loaded playlists from db`,
props<{ playlists: PlaylistMeta[] }>()
);
export const addPlaylist = createAction(
`${PLAYLISTS_STORE_KEY} Add new playlist`,
props<{ playlist: Playlist }>()
);
export const addManyPlaylists = createAction(
`${PLAYLISTS_STORE_KEY} Add many playlists`,
props<{ playlists: Playlist[] }>()
);
export const removePlaylist = createAction(
`${PLAYLISTS_STORE_KEY} Remove playlist by id`,
props<{ playlistId: string }>()
);
export const updatePlaylistMeta = createAction(
`${PLAYLISTS_STORE_KEY} update playlist meta`,
props<{ playlist: PlaylistMeta }>()
);
export const updatePlaylist = createAction(
`${PLAYLISTS_STORE_KEY} update playlist`,
props<{ playlist: Playlist; playlistId: string }>()
);
export const updateManyPlaylists = createAction(
`${PLAYLISTS_STORE_KEY} Update many playlists (auto-update mechanism)`,
props<{ playlists: Playlist[] }>()
);
export const parsePlaylist = createAction(
`${PLAYLISTS_STORE_KEY} parse playlist`,
props<{
uploadType: 'FILE' | 'URL' | 'TEXT';
playlist: string;
title: string;
path?: string;
}>()
);
export const setActivePlaylist = createAction(
`${PLAYLISTS_STORE_KEY} set active playlist`,
props<{ playlistId: string }>()
);
export const updateFavorites = createAction(
`${STORE_KEY} Add/remove provided channel to the favorites`,
@@ -15,6 +71,10 @@ export const setActiveChannel = createAction(
props<{ channel: Channel }>()
);
export const resetActiveChannel = createAction(
`${STORE_KEY} Reset active channel`
);
export const setActiveChannelSuccess = createAction(
`${STORE_KEY} Set active channel success`,
props<{ channel: Channel }>()
@@ -39,7 +99,14 @@ export const setEpgAvailableFlag = createAction(
props<{ value: boolean }>()
);
export const setPlaylist = createAction(
`${STORE_KEY} Set playlist as active`,
props<{ playlist: Playlist }>()
export const setChannels = createAction(
`${STORE_KEY} Set channels`,
props<{ channels: Channel[] }>()
);
export const updatePlaylistPositions = createAction(
`${STORE_KEY} Update playlist positions`,
props<{
positionUpdates: { id: string; changes: { position: number } }[];
}>()
);
+137 -11
View File
@@ -1,19 +1,20 @@
/* eslint-disable @typescript-eslint/no-unused-vars */
import { Injectable } from '@angular/core';
import { Router } from '@angular/router';
import { Actions, createEffect, ofType } from '@ngrx/effects';
import { Store } from '@ngrx/store';
import { combineLatestWith, map } from 'rxjs/operators';
import { combineLatestWith, map, switchMap, tap } from 'rxjs/operators';
import {
CHANNEL_SET_USER_AGENT,
EPG_GET_PROGRAM,
PLAYLIST_UPDATE_FAVORITES,
} from '../../../shared/ipc-commands';
import { DataService } from '../services/data.service';
import { PlaylistsService } from '../services/playlists.service';
import * as PlaylistActions from './actions';
import {
selectActivePlaylistId,
selectFavorites,
selectIsEpgAvailable,
selectPlaylistId,
} from './selectors';
@Injectable({ providedIn: 'any' })
@@ -24,14 +25,11 @@ export class PlaylistEffects {
ofType(PlaylistActions.updateFavorites),
combineLatestWith(
this.store.select(selectFavorites),
this.store.select(selectPlaylistId)
this.store.select(selectActivePlaylistId)
),
map(([action, favorites, playlistId]) => {
this.dataService.sendIpcEvent(PLAYLIST_UPDATE_FAVORITES, {
id: playlistId,
favorites,
});
})
switchMap(([action, favorites, playlistId]) =>
this.playlistsService.updateFavorites(playlistId, favorites)
)
);
},
{ dispatch: false }
@@ -61,9 +59,137 @@ export class PlaylistEffects {
);
});
loadPlaylists$ = createEffect(() => {
return this.actions$.pipe(
ofType(PlaylistActions.loadPlaylists),
switchMap(() =>
this.playlistsService.getAllPlaylists().pipe(
map((playlists) =>
PlaylistActions.loadPlaylistsSuccess({
playlists,
})
)
)
)
);
});
removePlaylist$ = createEffect(
() => {
return this.actions$.pipe(
ofType(PlaylistActions.removePlaylist),
switchMap((action) =>
this.playlistsService.deletePlaylist(action.playlistId)
)
);
},
{ dispatch: false }
);
updatePlaylist$ = createEffect(
() => {
return this.actions$.pipe(
ofType(PlaylistActions.updatePlaylist),
switchMap((action) =>
this.playlistsService.updatePlaylist(action.playlistId, {
...action.playlist,
_id: action.playlistId,
})
)
);
},
{ dispatch: false }
);
parsePlaylist$ = createEffect(() => {
return this.actions$.pipe(
ofType(PlaylistActions.parsePlaylist),
map((action) =>
this.playlistsService.handlePlaylistParsing(
action.uploadType,
action.playlist,
action.title,
action.path
)
),
map((playlist) =>
PlaylistActions.addPlaylist({
playlist,
})
)
);
});
addPlaylist$ = createEffect(
() => {
return this.actions$.pipe(
ofType(PlaylistActions.addPlaylist),
switchMap((action) =>
this.playlistsService.addPlaylist(action.playlist)
),
tap((playlist) => {
this.router.navigate(['/playlists/', playlist._id]);
})
);
},
{ dispatch: false }
);
updatePlaylistMeta$ = createEffect(
() => {
return this.actions$.pipe(
ofType(PlaylistActions.updatePlaylistMeta),
switchMap((action) =>
this.playlistsService.updatePlaylistMeta(action.playlist)
)
);
},
{ dispatch: false }
);
updatePlaylistPositions$ = createEffect(
() => {
return this.actions$.pipe(
ofType(PlaylistActions.updatePlaylistPositions),
switchMap((action) =>
this.playlistsService.updatePlaylistPositions(
action.positionUpdates
)
)
);
},
{ dispatch: false }
);
addManyPlaylists$ = createEffect(
() => {
return this.actions$.pipe(
ofType(PlaylistActions.addManyPlaylists),
switchMap((action) =>
this.playlistsService.addManyPlaylists(action.playlists)
)
);
},
{ dispatch: false }
);
updateManyPlaylists$ = createEffect(
() => {
return this.actions$.pipe(
ofType(PlaylistActions.updateManyPlaylists),
switchMap((action) =>
this.playlistsService.updateManyPlaylists(action.playlists)
)
);
},
{ dispatch: false }
);
constructor(
private actions$: Actions,
private playlistsService: PlaylistsService,
private dataService: DataService,
private store: Store
private store: Store,
private router: Router
) {}
}
+26
View File
@@ -0,0 +1,26 @@
import { createEntityAdapter, EntityAdapter, EntityState } from '@ngrx/entity';
import { Playlist } from '../../../shared/playlist.interface';
import { PlaylistMeta } from '../shared/playlist-meta.type';
export const playlistsAdapter: EntityAdapter<PlaylistMeta> =
createEntityAdapter<PlaylistMeta>({
selectId: (item: Playlist) => item._id,
});
export interface PlaylistMetaState extends EntityState<PlaylistMeta> {
selectedId: string;
allPlaylistsLoaded: boolean;
}
export const initialPlaylistMetaState: PlaylistMetaState =
playlistsAdapter.getInitialState({
selectedId: '',
allPlaylistsLoaded: false,
});
export const {
selectIds,
selectEntities: getPlaylistMetaEntities,
selectAll: getAllPlaylistsMeta,
selectTotal,
} = playlistsAdapter.getSelectors();
+143 -23
View File
@@ -1,7 +1,7 @@
import { createReducer, on } from '@ngrx/store';
import * as moment from 'moment';
import { createChannel } from '../shared/channel.model';
import * as PlaylistActions from './actions';
import { playlistsAdapter } from './playlists.state';
import { initialState, PlaylistState } from './state';
export const playlistReducer = createReducer(
@@ -33,6 +33,12 @@ export const playlistReducer = createReducer(
};
}
),
on(PlaylistActions.resetActiveChannel, (state): PlaylistState => {
return {
...state,
active: undefined,
};
}),
on(
PlaylistActions.setCurrentEpgProgram,
(state, action): PlaylistState => ({
@@ -47,39 +53,153 @@ export const playlistReducer = createReducer(
epgAvailable: action.value,
})
),
on(PlaylistActions.setPlaylist, (state, action): PlaylistState => {
const { playlist } = action;
return {
...state,
channels: playlist?.playlist.items.map((element) =>
createChannel(element)
),
favorites: playlist?.favorites || [],
active: undefined,
playlistId: playlist._id,
playlistFilename: playlist.title || playlist.filename,
};
}),
on(PlaylistActions.updateFavorites, (state, action): PlaylistState => {
let favorites;
const { channel } = action;
if (state.favorites.includes(channel.id)) {
favorites = [...state.favorites.filter((id) => id !== channel.id)];
const playlistFavorites =
state.playlists.entities[state.playlists.selectedId].favorites;
if (playlistFavorites.includes(channel.id)) {
favorites = [
...playlistFavorites.filter((id) => id !== channel.id),
];
} else {
favorites = [...state.favorites, channel.id];
favorites = [...playlistFavorites, channel.id];
}
return { ...state, favorites };
return {
...state,
playlists: {
...state.playlists,
entities: {
...state.playlists.entities,
[state.playlists.selectedId]: {
...state.playlists.entities[state.playlists.selectedId],
favorites,
},
},
},
};
}),
on(PlaylistActions.loadPlaylistsSuccess, (state, action): PlaylistState => {
return {
...state,
playlists: playlistsAdapter.addMany(action.playlists, {
...state.playlists,
allPlaylistsLoaded: true,
}),
};
}),
on(PlaylistActions.removePlaylist, (state, action): PlaylistState => {
return {
...state,
playlists: playlistsAdapter.removeOne(
action.playlistId,
state.playlists
),
};
}),
on(PlaylistActions.updatePlaylist, (state, action): PlaylistState => {
return {
...state,
playlists: playlistsAdapter.updateOne(
{
id: action.playlistId,
changes: {
...action.playlist,
_id: action.playlistId,
updateDate: Date.now(),
favorites: [],
count: action.playlist.playlist.items.length,
},
},
state.playlists
),
};
}),
on(PlaylistActions.addPlaylist, (state, action): PlaylistState => {
return {
...state,
playlists: playlistsAdapter.addOne(
action.playlist,
state.playlists
),
};
}),
on(PlaylistActions.addManyPlaylists, (state, action): PlaylistState => {
return {
...state,
playlists: playlistsAdapter.addMany(
action.playlists,
state.playlists
),
};
}),
on(PlaylistActions.setActivePlaylist, (state, action): PlaylistState => {
return {
...state,
playlists: {
...state.playlists,
selectedId: action.playlistId,
},
};
}),
on(
PlaylistActions.updatePlaylistPositions,
(state, action): PlaylistState => {
return {
...state,
playlists: playlistsAdapter.updateMany(
action.positionUpdates,
state.playlists
),
};
}
),
on(PlaylistActions.updatePlaylistMeta, (state, action): PlaylistState => {
return {
...state,
playlists: playlistsAdapter.updateOne(
{
id: action.playlist._id,
changes: {
title: action.playlist.title,
autoRefresh: action.playlist.autoRefresh || false,
},
},
state.playlists
),
};
}),
on(PlaylistActions.setChannels, (state, action): PlaylistState => {
return {
...state,
channels: action.channels,
};
}),
on(PlaylistActions.updateManyPlaylists, (state, action): PlaylistState => {
return {
...state,
playlists: playlistsAdapter.updateMany(
action.playlists.map((updatedPlaylist) => ({
id: updatedPlaylist._id,
changes: {
...updatedPlaylist,
updateDate: Date.now(),
},
})),
state.playlists
),
};
})
);
export const selectFavorites = (state: PlaylistState) => state.favorites;
export const selectPlaylistId = (state: PlaylistState) => state.playlistId;
export const selectPlaylistFilename = (state: PlaylistState) =>
state.playlistFilename;
export const selectIsEpgAvailable = (state: PlaylistState) =>
state.epgAvailable;
export const selectActive = (state: PlaylistState) => state.active;
export const selectCurrentEpgProgram = (state: PlaylistState) =>
state.currentEpgProgram;
export const selectChannels = (state: PlaylistState) => state.channels;
export const selectPlaylists = (state: PlaylistState) => state.playlists;
export const selectPlaylistId = (state: PlaylistState) =>
state.playlists?.selectedId;
export const selectPlaylistsLoadingFlag = (state: PlaylistState) =>
state.playlists?.allPlaylistsLoaded;
+70 -15
View File
@@ -1,30 +1,16 @@
import { createFeatureSelector, createSelector } from '@ngrx/store';
import * as fromPlaylistMetaState from './playlists.state';
import * as fromPlaylistState from './reducers';
import { PlaylistState } from './state';
export const selectPlaylistState =
createFeatureSelector<PlaylistState>('playlistState');
export const selectFavorites = createSelector(
selectPlaylistState,
fromPlaylistState.selectFavorites
);
export const selectPlaylistId = createSelector(
selectPlaylistState,
fromPlaylistState.selectPlaylistId
);
export const selectIsEpgAvailable = createSelector(
selectPlaylistState,
fromPlaylistState.selectIsEpgAvailable
);
export const selectPlaylistFilename = createSelector(
selectPlaylistState,
fromPlaylistState.selectPlaylistFilename
);
export const selectActive = createSelector(
selectPlaylistState,
fromPlaylistState.selectActive
@@ -39,3 +25,72 @@ export const selectChannels = createSelector(
selectPlaylistState,
fromPlaylistState.selectChannels
);
export const selectPlaylistsLoadingFlag = createSelector(
selectPlaylistState,
fromPlaylistState.selectPlaylistsLoadingFlag
);
/** Playlist entity store selectors */
export const selectPlaylistsMetaState = createSelector(
selectPlaylistState,
fromPlaylistState.selectPlaylists
);
export const selectAllPlaylistsMeta = createSelector(
selectPlaylistsMetaState,
fromPlaylistMetaState.getAllPlaylistsMeta
);
export const selectPlaylistEntity = (id: string) =>
createSelector(
selectPlaylistsMetaState,
fromPlaylistMetaState.getPlaylistMetaEntities,
(data) => {
return data.entities[id];
}
);
export const selectActivePlaylistId = createSelector(
selectPlaylistsMetaState,
fromPlaylistMetaState.getPlaylistMetaEntities,
(data) => data.selectedId
);
export const selectPlaylistTitle = createSelector(
selectPlaylistsMetaState,
fromPlaylistMetaState.getPlaylistMetaEntities,
fromPlaylistState.selectPlaylistId,
(data) => {
if (
data.entities &&
data.selectedId &&
data.entities[data.selectedId]
) {
return (
data.entities[data.selectedId].title ||
data.entities[data.selectedId].filename
);
} else return 'Untitled playlist';
}
);
export const selectPlaylistEntities = createSelector(
selectPlaylistsMetaState,
fromPlaylistMetaState.getPlaylistMetaEntities
);
export const selectFavorites = createSelector(
selectPlaylistsMetaState,
fromPlaylistMetaState.getPlaylistMetaEntities,
fromPlaylistState.selectPlaylistId,
(data) => {
if (
data.entities &&
data.selectedId &&
data.entities[data.selectedId]
) {
return data.entities[data.selectedId].favorites || [];
} else return [];
}
);
+4 -6
View File
@@ -1,22 +1,20 @@
import { Channel } from '../../../shared/channel.interface';
import { initialPlaylistMetaState, PlaylistMetaState } from './playlists.state';
import { EpgProgram } from '../player/models/epg-program.model';
export interface PlaylistState {
active: Channel | undefined;
currentEpgProgram: EpgProgram | undefined;
epgAvailable: boolean;
favorites: string[];
playlistId: string;
playlistFilename: string;
channels: Channel[]; // TODO: use entity store
playlists: PlaylistMetaState;
}
export const initialState: PlaylistState = {
active: undefined,
currentEpgProgram: undefined,
epgAvailable: false,
favorites: [],
playlistId: '',
playlistFilename: '',
channels: [],
playlists: initialPlaylistMetaState,
};
+5 -2
View File
@@ -42,7 +42,9 @@
"REFRESH": "Refresh playlist",
"GLOBAL_FAVORITES": "Global favorites",
"GLOBAL_FAVORITES_DESCRIPTION": "Auto-generated playlist with aggregated favorites from all playlists",
"MY_PLAYLISTS": "My playlists"
"MY_PLAYLISTS": "My playlists",
"MY_PLAYLISTS_SUBTITLE": "all available playlists",
"PLAYLIST_UPDATE_SUCCESS": "Success! The playlist was successfully updated."
},
"FILE_UPLOAD": {
"DRAG_DROP": "Drag & drop files here",
@@ -102,7 +104,8 @@
"FAVORITES": "Favorites",
"NO_FAVORITES": "No favorites yet",
"USE_STAR_TO_FAVORITE": "Please use the 'star' icon to add channels to this list.",
"REMOVE_FAVORITE": "Remove from favorites"
"REMOVE_FAVORITE": "Remove from favorites",
"FAVORITES_UPDATED": "Favorites were updated!"
},
"TOP_MENU": {
"OPEN_CHANNELS_LIST": "Open channel list",