From 5a98d0a416d4d80b2fd7d77bb0d313cc220582ea Mon Sep 17 00:00:00 2001 From: 4gray <4gray@users.noreply.github.com> Date: Wed, 22 Feb 2023 14:08:27 +0100 Subject: [PATCH 01/26] chore: set scss as default format for style files --- angular.json | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/angular.json b/angular.json index 582efe231..4ad1b8370 100644 --- a/angular.json +++ b/angular.json @@ -14,7 +14,8 @@ "projectType": "application", "schematics": { "@schematics/angular:application": { - "strict": true + "strict": true, + "style": "scss" } }, "architect": { From 96e13d0afb1818a7a6fc82cfe31aaf86d10149f8 Mon Sep 17 00:00:00 2001 From: 4gray <4gray@users.noreply.github.com> Date: Wed, 22 Feb 2023 14:08:50 +0100 Subject: [PATCH 02/26] chore: update language files --- src/assets/i18n/en.json | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/assets/i18n/en.json b/src/assets/i18n/en.json index da593a328..65ed79146 100644 --- a/src/assets/i18n/en.json +++ b/src/assets/i18n/en.json @@ -101,7 +101,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", From c4e69adef21b8e236112a0530770d9f92b05be55 Mon Sep 17 00:00:00 2001 From: 4gray <4gray@users.noreply.github.com> Date: Wed, 22 Feb 2023 14:09:26 +0100 Subject: [PATCH 03/26] refactor: refactor state management --- src/app/state/actions.ts | 60 +++++++++++++- src/app/state/effects.ts | 136 ++++++++++++++++++++++++++++--- src/app/state/playlists.state.ts | 24 ++++++ src/app/state/reducers.ts | 126 ++++++++++++++++++++++++---- src/app/state/selectors.ts | 80 ++++++++++++++---- src/app/state/state.ts | 10 +-- 6 files changed, 388 insertions(+), 48 deletions(-) create mode 100644 src/app/state/playlists.state.ts diff --git a/src/app/state/actions.ts b/src/app/state/actions.ts index b933c92b7..17974ec53 100644 --- a/src/app/state/actions.ts +++ b/src/app/state/actions.ts @@ -2,8 +2,59 @@ 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 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`, @@ -43,3 +94,10 @@ export const setPlaylist = createAction( `${STORE_KEY} Set playlist as active`, props<{ playlist: Playlist }>() ); + +export const updatePlaylistPositions = createAction( + `${STORE_KEY} Update playlist positions`, + props<{ + positionUpdates: { id: string; changes: { position: number } }[]; + }>() +); diff --git a/src/app/state/effects.ts b/src/app/state/effects.ts index a15513d33..c046c65e5 100644 --- a/src/app/state/effects.ts +++ b/src/app/state/effects.ts @@ -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,125 @@ 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 } + ); + constructor( private actions$: Actions, + private playlistsService: PlaylistsService, private dataService: DataService, - private store: Store + private store: Store, + private router: Router ) {} } diff --git a/src/app/state/playlists.state.ts b/src/app/state/playlists.state.ts new file mode 100644 index 000000000..630b47045 --- /dev/null +++ b/src/app/state/playlists.state.ts @@ -0,0 +1,24 @@ +import { createEntityAdapter, EntityAdapter, EntityState } from '@ngrx/entity'; +import { Playlist } from '../../../shared/playlist.interface'; +import { PlaylistMeta } from '../shared/playlist-meta.type'; + +export const playlistsAdapter: EntityAdapter = + createEntityAdapter({ + selectId: (item: Playlist) => item._id, + }); + +export interface PlaylistMetaState extends EntityState { + selectedId: string; +} + +export const initialPlaylistMetaState: PlaylistMetaState = + playlistsAdapter.getInitialState({ + selectedId: '', + }); + +export const { + selectIds, + selectEntities: getPlaylistMetaEntities, + selectAll: getAllPlaylistsMeta, + selectTotal, +} = playlistsAdapter.getSelectors(); diff --git a/src/app/state/reducers.ts b/src/app/state/reducers.ts index db592ea0a..aceb06ce1 100644 --- a/src/app/state/reducers.ts +++ b/src/app/state/reducers.ts @@ -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( @@ -47,39 +47,135 @@ export const playlistReducer = createReducer( epgAvailable: action.value, }) ), - on(PlaylistActions.setPlaylist, (state, action): PlaylistState => { - const { playlist } = action; + /* 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.upsertMany( + action.playlists, + state.playlists + ), + }; + }), + 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 }, + }, + 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 + ), + }; }) ); -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; diff --git a/src/app/state/selectors.ts b/src/app/state/selectors.ts index f9280915d..5f7806766 100644 --- a/src/app/state/selectors.ts +++ b/src/app/state/selectors.ts @@ -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'); -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,67 @@ export const selectChannels = createSelector( selectPlaylistState, fromPlaylistState.selectChannels ); + +/** 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 []; + } +); diff --git a/src/app/state/state.ts b/src/app/state/state.ts index 4e06b6bf9..cbca3d34c 100644 --- a/src/app/state/state.ts +++ b/src/app/state/state.ts @@ -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, }; From 78a0fcf7e24f70cc1da1f66c49e190302841e06f Mon Sep 17 00:00:00 2001 From: 4gray <4gray@users.noreply.github.com> Date: Wed, 22 Feb 2023 14:09:50 +0100 Subject: [PATCH 04/26] refactor: update playlist-meta interface --- src/app/shared/playlist-meta.type.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/app/shared/playlist-meta.type.ts b/src/app/shared/playlist-meta.type.ts index 0cacd9189..33d178a9a 100644 --- a/src/app/shared/playlist-meta.type.ts +++ b/src/app/shared/playlist-meta.type.ts @@ -13,4 +13,6 @@ export type PlaylistMeta = Pick< | 'updateDate' | 'updateState' | 'position' + | 'autoRefresh' + | 'favorites' >; From 743dc62aeb41947960fbcf848509099e961800a7 Mon Sep 17 00:00:00 2001 From: 4gray <4gray@users.noreply.github.com> Date: Wed, 22 Feb 2023 14:10:35 +0100 Subject: [PATCH 05/26] refactor: create own component for video player toolbar --- .../toolbar/toolbar.component.html | 41 +++++++++++++++++ .../toolbar/toolbar.component.scss | 3 ++ .../video-player/toolbar/toolbar.component.ts | 45 +++++++++++++++++++ 3 files changed, 89 insertions(+) create mode 100644 src/app/player/components/video-player/toolbar/toolbar.component.html create mode 100644 src/app/player/components/video-player/toolbar/toolbar.component.scss create mode 100644 src/app/player/components/video-player/toolbar/toolbar.component.ts diff --git a/src/app/player/components/video-player/toolbar/toolbar.component.html b/src/app/player/components/video-player/toolbar/toolbar.component.html new file mode 100644 index 000000000..89fdb0bb5 --- /dev/null +++ b/src/app/player/components/video-player/toolbar/toolbar.component.html @@ -0,0 +1,41 @@ + + + + {{ activeChannel?.name }} +
+ + +
diff --git a/src/app/player/components/video-player/toolbar/toolbar.component.scss b/src/app/player/components/video-player/toolbar/toolbar.component.scss new file mode 100644 index 000000000..4de53fb16 --- /dev/null +++ b/src/app/player/components/video-player/toolbar/toolbar.component.scss @@ -0,0 +1,3 @@ +.spacer { + flex: 1 1 auto; +} \ No newline at end of file diff --git a/src/app/player/components/video-player/toolbar/toolbar.component.ts b/src/app/player/components/video-player/toolbar/toolbar.component.ts new file mode 100644 index 000000000..5b83bfdee --- /dev/null +++ b/src/app/player/components/video-player/toolbar/toolbar.component.ts @@ -0,0 +1,45 @@ +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() toggleLeftDrawerClicked = new EventEmitter(); + @Output() toggleRightDrawerClicked = new EventEmitter(); + + 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 })); + } +} From b3a64b70f4dcb0ec8e1f51088dbdd2116e76cad4 Mon Sep 17 00:00:00 2001 From: 4gray <4gray@users.noreply.github.com> Date: Wed, 22 Feb 2023 14:11:08 +0100 Subject: [PATCH 06/26] refactor: create own component for video player sidebar --- .../sidebar/sidebar.component.html | 50 +++++++++++++++++++ .../sidebar/sidebar.component.scss | 47 +++++++++++++++++ .../video-player/sidebar/sidebar.component.ts | 40 +++++++++++++++ 3 files changed, 137 insertions(+) create mode 100644 src/app/player/components/video-player/sidebar/sidebar.component.html create mode 100644 src/app/player/components/video-player/sidebar/sidebar.component.scss create mode 100644 src/app/player/components/video-player/sidebar/sidebar.component.ts diff --git a/src/app/player/components/video-player/sidebar/sidebar.component.html b/src/app/player/components/video-player/sidebar/sidebar.component.html new file mode 100644 index 000000000..bcbf20963 --- /dev/null +++ b/src/app/player/components/video-player/sidebar/sidebar.component.html @@ -0,0 +1,50 @@ +
+
+ +
+
+ + {{ playlistTitle$ | async }} + + + {{ 'HOME.PLAYLISTS.MY_PLAYLISTS' | translate }} + +
+
+ {{ + sidebarView === 'CHANNELS' + ? channels.length + ' channels' + : 'all available playlists' + }} +
+
+ + +
+
+ + + + + + diff --git a/src/app/player/components/video-player/sidebar/sidebar.component.scss b/src/app/player/components/video-player/sidebar/sidebar.component.scss new file mode 100644 index 000000000..429fa55c6 --- /dev/null +++ b/src/app/player/components/video-player/sidebar/sidebar.component.scss @@ -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; + } + } +} + +.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; + } + } +} diff --git a/src/app/player/components/video-player/sidebar/sidebar.component.ts b/src/app/player/components/video-player/sidebar/sidebar.component.ts new file mode 100644 index 000000000..3b8801d28 --- /dev/null +++ b/src/app/player/components/video-player/sidebar/sidebar.component.ts @@ -0,0 +1,40 @@ +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 { 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( + private dataService: DataService, + private router: Router, + private store: Store + ) {} + + goBack(): void { + if (this.sidebarView === 'PLAYLISTS') { + this.router.navigate(['/']); + } else { + this.sidebarView = 'PLAYLISTS'; + } + } + + selectPlaylist() { + this.sidebarView = 'CHANNELS'; + } +} From 7bfbcf10981508a93a6d6ef40e2b386be1072fb0 Mon Sep 17 00:00:00 2001 From: 4gray <4gray@users.noreply.github.com> Date: Wed, 22 Feb 2023 14:11:41 +0100 Subject: [PATCH 07/26] refactor: update playlist interface --- shared/playlist.interface.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/shared/playlist.interface.ts b/shared/playlist.interface.ts index aef31d0b5..02e2c5b9e 100644 --- a/shared/playlist.interface.ts +++ b/shared/playlist.interface.ts @@ -11,7 +11,6 @@ export enum PlaylistUpdateState { * Describes playlist interface */ export interface Playlist { - id: string; _id: string; title: string; filename: string; From 38493bcfddc7981899d22a0435c50b697fe245c3 Mon Sep 17 00:00:00 2001 From: 4gray <4gray@users.noreply.github.com> Date: Wed, 22 Feb 2023 14:12:00 +0100 Subject: [PATCH 08/26] refactor: update routing --- src/app/app-routing.module.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/app/app-routing.module.ts b/src/app/app-routing.module.ts index 7eeb9bb5c..aea5ac266 100644 --- a/src/app/app-routing.module.ts +++ b/src/app/app-routing.module.ts @@ -8,7 +8,7 @@ const routes: Routes = [ import('./home/home.module').then((m) => m.HomeModule), }, { - path: 'iptv', + path: 'playlists/:id', loadChildren: () => import('./player/player.module').then((m) => m.PlayerModule), }, From 6f3fa14133a4d8cfb8693b6fdea26e49a7990918 Mon Sep 17 00:00:00 2001 From: 4gray <4gray@users.noreply.github.com> Date: Wed, 22 Feb 2023 14:12:46 +0100 Subject: [PATCH 09/26] refactor: register extracted components in video player module --- src/app/player/player.module.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/app/player/player.module.ts b/src/app/player/player.module.ts index f47a1c0d9..69be27214 100644 --- a/src/app/player/player.module.ts +++ b/src/app/player/player.module.ts @@ -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, ], From 585d0914d9b2429d1f08d3d9af33f2566a0cbd61 Mon Sep 17 00:00:00 2001 From: 4gray <4gray@users.noreply.github.com> Date: Wed, 22 Feb 2023 14:14:10 +0100 Subject: [PATCH 10/26] refactor: use indexed-db as playlist storage --- electron/api.ts | 566 ++++++------------ electron/epg-worker.ts | 5 +- electron/main.ts | 2 +- shared/ipc-commands.ts | 17 +- shared/playlist.utils.ts | 47 +- src/app/app.component.ts | 5 +- src/app/home/home.component.ts | 52 +- .../playlist-info/playlist-info.component.ts | 32 +- .../recent-playlists.component.html | 78 ++- .../recent-playlists.component.scss | 16 + .../recent-playlists.component.spec.ts | 2 +- .../recent-playlists.component.ts | 143 +++-- .../channel-list-container.component.ts | 44 +- .../video-player/video-player.component.html | 123 +--- .../video-player/video-player.component.scss | 49 -- .../video-player/video-player.component.ts | 123 +--- src/app/services/playlists.service.ts | 170 ++++++ src/app/services/pwa.service.ts | 322 +--------- 18 files changed, 681 insertions(+), 1115 deletions(-) create mode 100644 src/app/services/playlists.service.ts diff --git a/electron/api.ts b/electron/api.ts index 098f2d04e..7a488c2a0 100644 --- a/electron/api.ts +++ b/electron/api.ts @@ -1,9 +1,9 @@ import axios from 'axios'; import { app, BrowserWindow, ipcMain, session } from 'electron'; import { parse } from 'iptv-playlist-parser'; -import { GLOBAL_FAVORITES_PLAYLIST_ID } from '../shared/constants'; import { CHANNEL_SET_USER_AGENT, + DELETE_ALL_PLAYLISTS, EPG_ERROR, EPG_FETCH, EPG_FETCH_DONE, @@ -15,39 +15,35 @@ 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 { 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 +51,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 +65,113 @@ 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(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,111 +212,14 @@ 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) => - 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 - */ - sendPlaylistWithGlobalFavorites(event: Electron.IpcMainEvent) { - db.find({ type: { $exists: false } }).then((playlists: Playlist[]) => { - const favoriteChannels = aggregateFavoriteChannels(playlists); - const favPlaylist = createFavoritesPlaylist(favoriteChannels); - - event.sender.send(PLAYLIST_PARSE_RESPONSE, { - type: PLAYLIST_PARSE_RESPONSE, - payload: favPlaylist, - }); - }); - } - - /** - * Set default listeners for custom-titlebar - */ - setTitleBarListeners() { - ipcMain.on('window-minimize', function (event) { - BrowserWindow.fromWebContents(event.sender).minimize(); - }); - - ipcMain.on('window-maximize', function (event) { - const window = BrowserWindow.fromWebContents(event.sender); - window.isMaximized() ? window.unmaximize() : window.maximize(); - }); - - ipcMain.on('window-close', function (event) { - BrowserWindow.fromWebContents(event.sender).close(); - }); - - ipcMain.on('window-is-maximized', function (event) { - event.returnValue = BrowserWindow.fromWebContents( - event.sender - ).isMaximized(); - }); + // this.refreshPlaylists(); } /** * Starts the update process for all the playlists with the enabled auto-refresh flag */ - refreshPlaylists(): void { + // TODO: implement the same mechanism for self-hosted PWAs (ignore vercel instance) + /* refreshPlaylists(): void { this.getAllPlaylistsMeta().then((playlists) => { playlists.forEach((playlist) => { if (playlist.autoRefresh && playlist.autoRefresh === true) { @@ -358,46 +236,7 @@ export class Api { } }); }); - } - - /** - * 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 @@ -412,6 +251,7 @@ export class Api { 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 }); } @@ -438,92 +278,33 @@ export class Api { 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 - ): 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, + handlePlaylistRefresh( + args: { id: string; title: string; filePath?: string; url?: string }, playlistString: string, event?: Electron.IpcMainEvent - ): Promise { - 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) { + const parsedPlaylist: ParsedPlaylist = + this.parsePlaylist(playlistString); + const playlist = createPlaylistObject( + args.title, + parsedPlaylist, + args.url ? args.url : args.filePath, + args.url ? 'URL' : 'FILE' + ); event.sender.send(PLAYLIST_UPDATE_RESPONSE, { - message: `Success! The playlist was successfully updated (${playlist.items.length} channels)`, + message: `Success! The playlist was successfully updated (${parsedPlaylist.items.length} channels)`, + playlist: { + ...playlist, + _id: args.id, + }, }); - - // send all playlists back to the renderer process - this.sendAllPlaylists(event); } } @@ -534,24 +315,21 @@ 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 { + if (!args.url) return; try { await axios - .get(url, { httpsAgent: agent }) + .get(args.url, { httpsAgent: agent }) .then((result) => - this.handlePlaylistRefresh(id, result.data, event) + this.handlePlaylistRefresh(args, result.data, event) ); } 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, }); } } @@ -563,30 +341,25 @@ export class Api { * @param event ipc event to send the response back to the renderer */ fetchPlaylistByFilePath( - id: string, - path: string, + args: { id: string; title: string; filePath?: string }, event?: Electron.IpcMainEvent ): void { + if (!args.filePath) return; try { - fs.readFile(path, 'utf-8', (err, data) => { + fs.readFile(args.filePath, 'utf-8', (err, data) => { if (err) { - this.handleFileNotFoundError(err, id, event); + this.handleFileNotFoundError(err, event); return; } - this.handlePlaylistRefresh(id, data, event); + this.handlePlaylistRefresh(args, data, event); }); } catch (err) { - this.handleFileNotFoundError(err, id, event); + this.handleFileNotFoundError(err, event); } } - /** - * 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 +367,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 +378,35 @@ 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 */ + removeAllPlaylists(event: Electron.IpcMainEvent) { + fs.unlink(dbPath, (err) => { + if (err && err.code == 'ENOENT') { + // file doesn't exist + console.info("File doesn't exist, won't remove it."); + } else if (err) { + console.error('Error occurred while trying to remove file'); + } else { + console.log(`${dbPath} was deleted`); + event.sender.send(IS_PLAYLISTS_MIGRATION_POSSIBLE_RESPONSE, { + result: false, + }); + } }); } } diff --git a/electron/epg-worker.ts b/electron/epg-worker.ts index 83922f7e3..da6b17545 100644 --- a/electron/epg-worker.ts +++ b/electron/epg-worker.ts @@ -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); }); diff --git a/electron/main.ts b/electron/main.ts index ea8ac697a..d91b7d552 100644 --- a/electron/main.ts +++ b/electron/main.ts @@ -99,7 +99,7 @@ function registerGlobalShortcuts() { */ function createEpgWorkerWindow() { const window = new BrowserWindow({ - show: false, + show: true, webPreferences: { nodeIntegration: true, contextIsolation: false, diff --git a/shared/ipc-commands.ts b/shared/ipc-commands.ts index 612ecf4f5..7663372f2 100644 --- a/shared/ipc-commands.ts +++ b/shared/ipc-commands.ts @@ -12,20 +12,11 @@ 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 +29,11 @@ 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'; diff --git a/shared/playlist.utils.ts b/shared/playlist.utils.ts index 078450444..207d4d46f 100644 --- a/shared/playlist.utils.ts +++ b/shared/playlist.utils.ts @@ -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 { 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 } : {}), + }; +}; diff --git a/src/app/app.component.ts b/src/app/app.component.ts index 26b06c1c7..30f888777 100644 --- a/src/app/app.component.ts +++ b/src/app/app.component.ts @@ -1,6 +1,7 @@ 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 * as semver from 'semver'; @@ -22,7 +23,7 @@ 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 */ @@ -65,6 +66,7 @@ export class AppComponent { private epgService: EpgService, private ngZone: NgZone, private router: Router, + private store: Store, private snackBar: MatSnackBar, private translate: TranslateService, private settingsService: SettingsService, @@ -95,6 +97,7 @@ export class AppComponent { * Starts all the functions to initialize the component */ ngOnInit(): void { + this.store.dispatch(PlaylistActions.loadPlaylists()); this.translate.setDefaultLang(this.DEFAULT_LANG); this.setRendererListeners(); diff --git a/src/app/home/home.component.ts b/src/app/home/home.component.ts index ee2fec3b1..1936556eb 100644 --- a/src/app/home/home.component.ts +++ b/src/app/home/home.component.ts @@ -5,15 +5,13 @@ 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 +28,11 @@ 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(); + this.navigateToPlayer(response.payload._id); }, }, { @@ -43,15 +43,6 @@ 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, @@ -103,22 +94,25 @@ 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, - }); + + this.store.dispatch( + parsePlaylist({ + uploadType: 'FILE', + playlist, + title: payload.file.name, + path: payload.file.path, + }) + ); } /** * Navigates to the video player route */ - navigateToPlayer(): void { + navigateToPlayer(playlistId: string): void { this.isLoading = false; - this.router.navigateByUrl('/iptv', { skipLocationChange: true }); + this.router.navigateByUrl(`/playlists/${playlistId}`); } /** @@ -137,11 +131,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', + }) + ); } /** diff --git a/src/app/home/recent-playlists/playlist-info/playlist-info.component.ts b/src/app/home/recent-playlists/playlist-info/playlist-info.component.ts index 64eca239d..ea830c359 100644 --- a/src/app/home/recent-playlists/playlist-info/playlist-info.component.ts +++ b/src/app/home/recent-playlists/playlist-info/playlist-info.component.ts @@ -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', @@ -53,7 +55,8 @@ export class PlaylistInfoComponent { private datePipe: DatePipe, private formBuilder: UntypedFormBuilder, private electronService: DataService, - @Inject(MAT_DIALOG_DATA) playlist: Playlist + @Inject(MAT_DIALOG_DATA) playlist: Playlist, + private store: Store ) { this.playlist = playlist; } @@ -64,32 +67,29 @@ 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), }); } @@ -97,9 +97,7 @@ export class PlaylistInfoComponent { * Saves updated playlist information * @param data updated form data */ - saveChanges( - data: Pick - ): void { - this.electronService.sendIpcEvent(PLAYLIST_SAVE_DETAILS, data); + saveChanges(playlist: PlaylistMeta): void { + this.store.dispatch(PlaylistActions.updatePlaylistMeta({ playlist })); } } diff --git a/src/app/home/recent-playlists/recent-playlists.component.html b/src/app/home/recent-playlists/recent-playlists.component.html index d7128bd2c..830d64748 100644 --- a/src/app/home/recent-playlists/recent-playlists.component.html +++ b/src/app/home/recent-playlists/recent-playlists.component.html @@ -1,30 +1,5 @@ -
-
- -
- - -
-
- -
-
-
- - - + + search
{{ 'HOME.PLAYLISTS.NO_PLAYLISTS' | translate }}
@@ -54,5 +29,54 @@ (refreshClicked)="refreshPlaylist($event)" (removeClicked)="removeClicked($event)" > + +
+

{{ migrationMessage }}

+   + +
+
+ + +
+
+ +
+ + +
+
+ +
+
+
diff --git a/src/app/home/recent-playlists/recent-playlists.component.scss b/src/app/home/recent-playlists/recent-playlists.component.scss index 92a7ac2c8..5eebdf0b5 100644 --- a/src/app/home/recent-playlists/recent-playlists.component.scss +++ b/src/app/home/recent-playlists/recent-playlists.component.scss @@ -50,3 +50,19 @@ } } } + +#migration-container { + padding: 10px; + border: 1px solid #999; + border-radius: 5px; + margin: 5px; + + p { + margin-left: 15px; + } + + .migration-btn { + text-transform: uppercase; + } + +} \ No newline at end of file diff --git a/src/app/home/recent-playlists/recent-playlists.component.spec.ts b/src/app/home/recent-playlists/recent-playlists.component.spec.ts index 1fe5f197d..7b156b423 100644 --- a/src/app/home/recent-playlists/recent-playlists.component.spec.ts +++ b/src/app/home/recent-playlists/recent-playlists.component.spec.ts @@ -102,7 +102,7 @@ describe('RecentPlaylistsComponent', () => { it('should send an event to the main process to refresh a playlist', () => { const playlistMeta: PlaylistMeta = { - _id: 'iptv1', + id: 'iptv1', filePath: '/home/user/lists/iptv.m3u', } as PlaylistMeta; jest.spyOn(electronService, 'sendIpcEvent'); diff --git a/src/app/home/recent-playlists/recent-playlists.component.ts b/src/app/home/recent-playlists/recent-playlists.component.ts index 19495e9ab..2a64bd592 100644 --- a/src/app/home/recent-playlists/recent-playlists.component.ts +++ b/src/app/home/recent-playlists/recent-playlists.component.ts @@ -1,18 +1,29 @@ import { CdkDragDrop, moveItemInArray } from '@angular/cdk/drag-drop'; -import { Component, NgZone, OnDestroy } from '@angular/core'; +import { + Component, + EventEmitter, + NgZone, + OnDestroy, + Output, +} from '@angular/core'; import { MatDialog } from '@angular/material/dialog'; import { MatSnackBar } from '@angular/material/snack-bar'; +import { Router } from '@angular/router'; +import { Store } from '@ngrx/store'; import { TranslateService } from '@ngx-translate/core'; +import { map } from 'rxjs'; import { IpcCommand } from '../../../../shared/ipc-command.class'; +import { Playlist } from '../../../../shared/playlist.interface'; import { DataService } from '../../services/data.service'; +import * as PlaylistActions from '../../state/actions'; +import { selectAllPlaylistsMeta } from '../../state/selectors'; import { - PLAYLIST_GET_ALL, - PLAYLIST_GET_ALL_RESPONSE, - PLAYLIST_GET_BY_ID, - PLAYLIST_REMOVE_BY_ID, - PLAYLIST_REMOVE_BY_ID_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 +36,71 @@ import { PlaylistInfoComponent } from './playlist-info/playlist-info.component'; styleUrls: ['./recent-playlists.component.scss'], }) export class RecentPlaylistsComponent implements OnDestroy { - /** All available playlists */ - playlists: PlaylistMeta[] = []; - - loading = true; + playlists$ = this.store.select(selectAllPlaylistsMeta).pipe( + // eslint-disable-next-line @ngrx/avoid-mapping-selectors + map((playlists) => playlists.sort((a, b) => a.position - b.position)) + ); /** IPC Renderer commands list with callbacks */ commandsList = [ new IpcCommand( - PLAYLIST_GET_ALL_RESPONSE, - (response: { payload: Partial }) => { - this.playlists = response.payload; - this.loading = false; + PLAYLIST_UPDATE_RESPONSE, + (response: { message: string; playlist: Playlist }) => { + console.log(response.playlist); + this.snackBar.open(response.message, null, { duration: 2000 }); + this.store.dispatch( + PlaylistActions.updatePlaylist({ + playlistId: '', + 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 } + ); } ), ]; - listeners = []; + isMigrationPossible = false; + migrationMessage = ''; + + @Output() playlistClicked = new EventEmitter(); - /** - * 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 +112,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,15 +130,15 @@ export class RecentPlaylistsComponent implements OnDestroy { * Drop event handler - applies the new sort order to the playlists array * @param event drop event */ - drop(event: CdkDragDrop): void { - moveItemInArray( - this.playlists, - event.previousIndex, - event.currentIndex - ); - this.electronService.sendIpcEvent( - PLAYLIST_UPDATE_POSITIONS, - this.playlists + drop(event: CdkDragDrop, 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 }, + })), + }) ); } @@ -130,9 +147,8 @@ export class RecentPlaylistsComponent implements OnDestroy { * @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 +170,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 +180,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 +201,6 @@ export class RecentPlaylistsComponent implements OnDestroy { this.commandsList.forEach((command) => this.electronService.removeAllListeners(command.id) ); - } else { - this.listeners.forEach((listener) => { - window.removeEventListener('message', listener); - }); } } } diff --git a/src/app/player/components/channel-list-container/channel-list-container.component.ts b/src/app/player/components/channel-list-container/channel-list-container.component.ts index 82801f40f..f63255943 100644 --- a/src/app/player/components/channel-list-container/channel-list-container.component.ts +++ b/src/app/player/components/channel-list-container/channel-list-container.component.ts @@ -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 })); } diff --git a/src/app/player/components/video-player/video-player.component.html b/src/app/player/components/video-player/video-player.component.html index 6cd802ba6..34fb32b89 100644 --- a/src/app/player/components/video-player/video-player.component.html +++ b/src/app/player/components/video-player/video-player.component.html @@ -1,117 +1,14 @@ - + - - -
-
- -
-
- - {{ playlistTitle$ | async }} - - - {{ 'HOME.PLAYLISTS.MY_PLAYLISTS' | translate }} - -
-
- {{ - sidebarView === 'CHANNELS' - ? channels.length + ' channels' - : 'all available playlists' - }} -
-
- - -
-
- - - - - - -
+ + - - - - - {{ activeChannel?.name }} -
- - -
+
- +
diff --git a/src/app/player/components/video-player/video-player.component.scss b/src/app/player/components/video-player/video-player.component.scss index 15070cf6b..1a5469a15 100644 --- a/src/app/player/components/video-player/video-player.component.scss +++ b/src/app/player/components/video-player/video-player.component.scss @@ -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; - } - } } diff --git a/src/app/player/components/video-player/video-player.component.ts b/src/app/player/components/video-player/video-player.component.ts index 627befba0..88038dc66 100644 --- a/src/app/player/components/video-player/video-player.component.ts +++ b/src/app/player/components/video-player/video-player.component.ts @@ -7,45 +7,37 @@ 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 { 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'], }) @@ -56,81 +48,55 @@ export class VideoPlayerComponent implements OnInit, OnDestroy { .pipe(filter((channel) => Boolean(channel))); /** Channels list */ - channels$ = this.store.select(selectChannels); - - /** EPG availability flag */ - epgAvailable$ = this.store.select(selectIsEpgAvailable); + channels$!: Observable; /** Current epg program */ epgProgram$ = this.store.select(selectCurrentEpgProgram); - /** Favorites list */ - favorites$ = this.store.select(selectFavorites); - /** Selected video player options */ playerSettings: Partial = { 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 }) + PlaylistActions.addPlaylist({ + playlist: response.payload, + }) ); - this.setSidebarView('CHANNELS'); + 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 +105,20 @@ export class VideoPlayerComponent implements OnInit, OnDestroy { this.applySettings(); this.setRendererListeners(); this.getPlaylistUrlAsParam(); + + this.channels$ = this.activatedRoute.params.pipe( + switchMap((params) => { + this.store.dispatch( + PlaylistActions.setActivePlaylist({ playlistId: params.id }) + ); + return this.playlistsService.getPlaylistChannels(params.id); + }) + ); } /** * Opens a playlist provided as a url param + * e.g. iptvnat.or?url=http://... */ getPlaylistUrlAsParam() { const URL_REGEX = /^(http|https|file):\/\/[^ "]+$/; @@ -161,7 +137,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 +167,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) => diff --git a/src/app/services/playlists.service.ts b/src/app/services/playlists.service.ts new file mode 100644 index 000000000..621e96367 --- /dev/null +++ b/src/app/services/playlists.service.ts @@ -0,0 +1,170 @@ +/* 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(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>; + if (id === GLOBAL_FAVORITES_PLAYLIST_ID) { + playlist$ = this.getPlaylistWithGlobalFavorites(); + } else { + playlist$ = this.dbService.getByID( + 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(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, + }) + ) + ); + } + + // TODO: for-loop + updatePlaylistPosition(id: string, position: number) { + return this.getPlaylistById(id).pipe( + switchMap((playlist) => + this.dbService.update(DbStores.Playlists, { + ...playlist, + position, + }) + ) + ); + } + + getFavoriteChannels(playlistId: string) { + return this.dbService + .getByID(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 + } +} diff --git a/src/app/services/pwa.service.ts b/src/app/services/pwa.service.ts index 9f0b1482f..62883c81c 100644 --- a/src/app/services/pwa.service.ts +++ b/src/app/services/pwa.service.ts @@ -1,62 +1,39 @@ +/* eslint-disable @typescript-eslint/no-unused-vars */ 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, of, switchMap, 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); + /** Proxy URL to avoid CORS issues */ + corsProxyUrl = AppConfig.BACKEND_URL; + /** * Creates an instance of PwaService. * @param dbService database service @@ -94,228 +71,39 @@ export class PwaService extends DataService { 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) { + 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', - }) + this.getPlaylistFromUrl(payload.url) .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, - }); + .subscribe((playlist: Playlist) => { + this.store.dispatch( + PlaylistActions.updatePlaylist({ + playlist, + playlistId: payload.id, }) ); - }); + console.log('playlist was updated...'); - combineLatest(requests).subscribe(() => { - console.log('playlist positions were updated...'); - }); + // TODO: show notification + /* window.postMessage({ + type: PLAYLIST_UPDATE_RESPONSE, + message: `Success! The playlist was successfully updated.`, + }); */ + }); } } @@ -324,8 +112,7 @@ export class PwaService extends DataService { * @param payload playlist payload */ fetchFromUrl(payload: Partial): void { - this.http - .get(`${this.corsProxyUrl}/parse?url=${payload.url}`) + this.getPlaylistFromUrl(payload.url) .pipe( switchMap((response) => payload.isTemporary @@ -365,57 +152,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 { From b9e75a16d2ffc4b92fc99b3b122b0195909addf0 Mon Sep 17 00:00:00 2001 From: 4gray <4gray@users.noreply.github.com> Date: Wed, 22 Feb 2023 15:17:33 +0100 Subject: [PATCH 11/26] refactor: resolve some issues after refactoring --- .../components/video-player/sidebar/sidebar.component.ts | 2 +- .../components/video-player/toolbar/toolbar.component.html | 2 +- .../components/video-player/toolbar/toolbar.component.ts | 1 + .../player/components/video-player/video-player.component.html | 3 ++- 4 files changed, 5 insertions(+), 3 deletions(-) diff --git a/src/app/player/components/video-player/sidebar/sidebar.component.ts b/src/app/player/components/video-player/sidebar/sidebar.component.ts index 3b8801d28..e56e4aef5 100644 --- a/src/app/player/components/video-player/sidebar/sidebar.component.ts +++ b/src/app/player/components/video-player/sidebar/sidebar.component.ts @@ -21,7 +21,7 @@ export class SidebarComponent { sidebarView: SidebarView = 'CHANNELS'; constructor( - private dataService: DataService, + public dataService: DataService, private router: Router, private store: Store ) {} diff --git a/src/app/player/components/video-player/toolbar/toolbar.component.html b/src/app/player/components/video-player/toolbar/toolbar.component.html index 89fdb0bb5..fd49bf124 100644 --- a/src/app/player/components/video-player/toolbar/toolbar.component.html +++ b/src/app/player/components/video-player/toolbar/toolbar.component.html @@ -25,7 +25,7 @@ {{ activeChannel?.name }}
- - + + + + From 2130fe309dc80549f06c3a9ac90f609d3d720bc6 Mon Sep 17 00:00:00 2001 From: 4gray <4gray@users.noreply.github.com> Date: Fri, 24 Feb 2023 13:38:29 +0100 Subject: [PATCH 20/26] refactor: introduce loading flag in playlists store --- .../recent-playlists/recent-playlists.component.html | 8 ++++++-- .../recent-playlists/recent-playlists.component.ts | 7 ++++++- src/app/state/playlists.state.ts | 2 ++ src/app/state/reducers.ts | 10 ++++++---- src/app/state/selectors.ts | 5 +++++ 5 files changed, 25 insertions(+), 7 deletions(-) diff --git a/src/app/home/recent-playlists/recent-playlists.component.html b/src/app/home/recent-playlists/recent-playlists.component.html index 830d64748..cee4aa9d3 100644 --- a/src/app/home/recent-playlists/recent-playlists.component.html +++ b/src/app/home/recent-playlists/recent-playlists.component.html @@ -1,5 +1,9 @@ - - + + search
{{ 'HOME.PLAYLISTS.NO_PLAYLISTS' | translate }}
diff --git a/src/app/home/recent-playlists/recent-playlists.component.ts b/src/app/home/recent-playlists/recent-playlists.component.ts index 02031db3b..e53d41946 100644 --- a/src/app/home/recent-playlists/recent-playlists.component.ts +++ b/src/app/home/recent-playlists/recent-playlists.component.ts @@ -16,7 +16,10 @@ import { IpcCommand } from '../../../../shared/ipc-command.class'; import { Playlist } from '../../../../shared/playlist.interface'; import { DataService } from '../../services/data.service'; import * as PlaylistActions from '../../state/actions'; -import { selectAllPlaylistsMeta } from '../../state/selectors'; +import { + selectAllPlaylistsMeta, + selectPlaylistsLoadingFlag, +} from '../../state/selectors'; import { AUTO_UPDATE_PLAYLISTS_RESPONSE, DELETE_ALL_PLAYLISTS, @@ -42,6 +45,8 @@ export class RecentPlaylistsComponent implements OnDestroy { map((playlists) => playlists.sort((a, b) => a.position - b.position)) ); + allPlaylistsLoaded$ = this.store.select(selectPlaylistsLoadingFlag); + /** IPC Renderer commands list with callbacks */ commandsList = [ new IpcCommand( diff --git a/src/app/state/playlists.state.ts b/src/app/state/playlists.state.ts index 630b47045..bc5c80a9c 100644 --- a/src/app/state/playlists.state.ts +++ b/src/app/state/playlists.state.ts @@ -9,11 +9,13 @@ export const playlistsAdapter: EntityAdapter = export interface PlaylistMetaState extends EntityState { selectedId: string; + allPlaylistsLoaded: boolean; } export const initialPlaylistMetaState: PlaylistMetaState = playlistsAdapter.getInitialState({ selectedId: '', + allPlaylistsLoaded: false, }); export const { diff --git a/src/app/state/reducers.ts b/src/app/state/reducers.ts index a8298d604..b4c76a26f 100644 --- a/src/app/state/reducers.ts +++ b/src/app/state/reducers.ts @@ -82,10 +82,10 @@ export const playlistReducer = createReducer( on(PlaylistActions.loadPlaylistsSuccess, (state, action): PlaylistState => { return { ...state, - playlists: playlistsAdapter.upsertMany( - action.playlists, - state.playlists - ), + playlists: playlistsAdapter.addMany(action.playlists, { + ...state.playlists, + allPlaylistsLoaded: true, + }), }; }), on(PlaylistActions.removePlaylist, (state, action): PlaylistState => { @@ -201,3 +201,5 @@ 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; diff --git a/src/app/state/selectors.ts b/src/app/state/selectors.ts index 5f7806766..ac7d12db4 100644 --- a/src/app/state/selectors.ts +++ b/src/app/state/selectors.ts @@ -26,6 +26,11 @@ export const selectChannels = createSelector( fromPlaylistState.selectChannels ); +export const selectPlaylistsLoadingFlag = createSelector( + selectPlaylistState, + fromPlaylistState.selectPlaylistsLoadingFlag +); + /** Playlist entity store selectors */ export const selectPlaylistsMetaState = createSelector( selectPlaylistState, From 96cd35be4d14c922e749c8f6a2aad1d5aa9ed609 Mon Sep 17 00:00:00 2001 From: 4gray <4gray@users.noreply.github.com> Date: Fri, 24 Feb 2023 13:45:57 +0100 Subject: [PATCH 21/26] refactor: improve error handling --- .../html-video-player.component.ts | 20 ++++++++++--------- .../video-player/video-player.component.ts | 2 +- 2 files changed, 12 insertions(+), 10 deletions(-) diff --git a/src/app/player/components/html-video-player/html-video-player.component.ts b/src/app/player/components/html-video-player/html-video-player.component.ts index 199ca965b..cef1bdc45 100644 --- a/src/app/player/components/html-video-player/html-video-player.component.ts +++ b/src/app/player/components/html-video-player/html-video-player.component.ts @@ -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...'); + } } } diff --git a/src/app/player/components/video-player/video-player.component.ts b/src/app/player/components/video-player/video-player.component.ts index 3a9b35c63..140b6b781 100644 --- a/src/app/player/components/video-player/video-player.component.ts +++ b/src/app/player/components/video-player/video-player.component.ts @@ -52,7 +52,7 @@ 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$!: Observable; From 03ab774c54b02e0f0e806ac6a7e7a1228e836557 Mon Sep 17 00:00:00 2001 From: 4gray <4gray@users.noreply.github.com> Date: Fri, 24 Feb 2023 13:58:58 +0100 Subject: [PATCH 22/26] refactor: code style improvements --- src/app/app.module.ts | 2 +- src/app/services/pwa.service.ts | 73 +++++++++++++++------------------ src/assets/i18n/en.json | 3 +- 3 files changed, 35 insertions(+), 43 deletions(-) diff --git a/src/app/app.module.ts b/src/app/app.module.ts index 9a37714a5..6533f1df1 100644 --- a/src/app/app.module.ts +++ b/src/app/app.module.ts @@ -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({ diff --git a/src/app/services/pwa.service.ts b/src/app/services/pwa.service.ts index 4014c8471..3877283d9 100644 --- a/src/app/services/pwa.service.ts +++ b/src/app/services/pwa.service.ts @@ -1,11 +1,9 @@ -/* eslint-disable @typescript-eslint/no-unused-vars */ 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 { NgxIndexedDBService } from 'ngx-indexed-db'; import { catchError, throwError } from 'rxjs'; import { ERROR, @@ -33,22 +31,12 @@ export class PwaService extends DataService { /** Proxy URL to avoid CORS issues */ corsProxyUrl = AppConfig.BACKEND_URL; - /** - * Creates an instance of PwaService. - * @param dbService database service - * @param http angular http client - */ - constructor( - private dbService: NgxIndexedDBService, - private http: HttpClient - ) { + 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 @@ -63,9 +51,6 @@ export class PwaService extends DataService { }); } - /** - * Returns the current version of the app - */ getAppVersion(): string { return AppConfig.version; } @@ -75,36 +60,42 @@ export class PwaService extends DataService { * @param type ipc command type * @param payload payload */ - sendIpcEvent(type: string, payload?: any): void { + sendIpcEvent(type: string, payload?: unknown): void { if (type === PLAYLIST_PARSE_BY_URL) { this.fetchFromUrl(payload); } else if (type === PLAYLIST_UPDATE) { - 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( - `Success! The playlist was successfully updated.`, - null, - { duration: 2000 } - ); - }); + this.refreshPlaylist(payload); } } + refreshPlaylist(payload: Partial) { + 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 diff --git a/src/assets/i18n/en.json b/src/assets/i18n/en.json index 2dcf2ad33..937f36405 100644 --- a/src/assets/i18n/en.json +++ b/src/assets/i18n/en.json @@ -43,7 +43,8 @@ "GLOBAL_FAVORITES": "Global favorites", "GLOBAL_FAVORITES_DESCRIPTION": "Auto-generated playlist with aggregated favorites from all playlists", "MY_PLAYLISTS": "My playlists", - "MY_PLAYLISTS_SUBTITLE": "all available playlists" + "MY_PLAYLISTS_SUBTITLE": "all available playlists", + "PLAYLIST_UPDATE_SUCCESS": "Success! The playlist was successfully updated." }, "FILE_UPLOAD": { "DRAG_DROP": "Drag & drop files here", From 42079b4b84507ea3afa839cf872dfae30a4cd280 Mon Sep 17 00:00:00 2001 From: 4gray Date: Fri, 24 Feb 2023 15:36:24 +0100 Subject: [PATCH 23/26] refactor: revert some changes related to custom-titlebar lib --- electron/api.ts | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/electron/api.ts b/electron/api.ts index 554420611..502d0511f 100644 --- a/electron/api.ts +++ b/electron/api.ts @@ -245,6 +245,8 @@ export class Api { .on(EPG_FORCE_FETCH, (event, arg) => this.workerWindow.webContents.send(EPG_FORCE_FETCH, arg) ); + + this.setTitleBarListeners(); } /** @@ -326,6 +328,30 @@ export class Api { }); } + /** + * Set default listeners for custom-titlebar + */ + setTitleBarListeners() { + ipcMain.on('window-minimize', function (event) { + BrowserWindow.fromWebContents(event.sender).minimize(); + }); + + ipcMain.on('window-maximize', function (event) { + const window = BrowserWindow.fromWebContents(event.sender); + window.isMaximized() ? window.unmaximize() : window.maximize(); + }); + + ipcMain.on('window-close', function (event) { + BrowserWindow.fromWebContents(event.sender).close(); + }); + + ipcMain.on('window-is-maximized', function (event) { + event.returnValue = BrowserWindow.fromWebContents( + event.sender + ).isMaximized(); + }); + } + /** * Fetches the playlist from the given url and triggers the update operation * @param id id of the playlist to update From a5bc2818f3960b605cf5f440e8a5c94afc840d4f Mon Sep 17 00:00:00 2001 From: 4gray <4gray@users.noreply.github.com> Date: Fri, 24 Feb 2023 16:21:39 +0100 Subject: [PATCH 24/26] Update playwright.config.ts --- playwright.config.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/playwright.config.ts b/playwright.config.ts index 9e1f34377..ee835f20d 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -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', }, From 03ec2d1814206e630ea7444f1359b52f2b3d1e96 Mon Sep 17 00:00:00 2001 From: 4gray <4gray@users.noreply.github.com> Date: Fri, 24 Feb 2023 17:21:37 +0100 Subject: [PATCH 25/26] chore: update playwright --- package-lock.json | 54 +++++++++++++++++++++++++---------------------- package.json | 8 +++---- 2 files changed, 33 insertions(+), 29 deletions(-) diff --git a/package-lock.json b/package-lock.json index df0bfde97..16045dc81 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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": { diff --git a/package.json b/package.json index 147172e37..a70d0f6c2 100644 --- a/package.json +++ b/package.json @@ -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", From f53a9362acdc576fc0563708711350c858d042ff Mon Sep 17 00:00:00 2001 From: 4gray <4gray@users.noreply.github.com> Date: Fri, 24 Feb 2023 19:04:22 +0100 Subject: [PATCH 26/26] ci: exclude e2e under windows-latest --- .github/workflows/ci.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 5a55f74b8..875431a0f 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -32,7 +32,7 @@ jobs: strategy: matrix: - os: [macos-latest, windows-latest] + os: [macos-latest] steps: - uses: actions/checkout@v2