mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-09 17:36:15 -08:00
Merge pull request #230 from 4gray/refactor/indexed-db
refactoring: switch to indexedDb and ngrx
This commit is contained in:
47 files changed
+1717
-1426
No files matched your search
@@ -32,7 +32,7 @@ jobs:
|
||||
|
||||
strategy:
|
||||
matrix:
|
||||
os: [macos-latest, windows-latest]
|
||||
os: [macos-latest]
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
|
||||
+2
-1
@@ -14,7 +14,8 @@
|
||||
"projectType": "application",
|
||||
"schematics": {
|
||||
"@schematics/angular:application": {
|
||||
"strict": true
|
||||
"strict": true,
|
||||
"style": "scss"
|
||||
}
|
||||
},
|
||||
"architect": {
|
||||
|
||||
+284
-447
@@ -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,
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
Generated
+29
-25
@@ -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
@@ -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,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
@@ -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';
|
||||
@@ -11,7 +11,6 @@ export enum PlaylistUpdateState {
|
||||
* Describes playlist interface
|
||||
*/
|
||||
export interface Playlist {
|
||||
id: string;
|
||||
_id: string;
|
||||
title: string;
|
||||
filename: string;
|
||||
|
||||
+36
-11
@@ -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 } : {}),
|
||||
};
|
||||
};
|
||||
@@ -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: () =>
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
|
||||
>
|
||||
<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);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
+8
-7
@@ -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);
|
||||
});
|
||||
});
|
||||
+27
-17
@@ -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) =>
|
||||
|
||||
@@ -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,
|
||||
],
|
||||
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -13,4 +13,6 @@ export type PlaylistMeta = Pick<
|
||||
| 'updateDate'
|
||||
| 'updateState'
|
||||
| 'position'
|
||||
| 'autoRefresh'
|
||||
| 'favorites'
|
||||
>;
|
||||
@@ -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
@@ -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
|
||||
) {}
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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 [];
|
||||
}
|
||||
);
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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",
|
||||
|
||||
Reference in new issue
Block a user