refactor: rework ipc events handling

This commit is contained in:
4gray committed 2025-11-08 23:02:15 +01:00
1 parent d3fc2a5af8
commit 656c86dc30
24 files changed
+218 -389

No files matched your search

+6 -72
View File
@@ -1,6 +1,5 @@
import { Component, inject, OnDestroy, OnInit } from '@angular/core';
import { Component, inject, OnInit } from '@angular/core';
import { MatDialog } from '@angular/material/dialog';
import { MatSnackBar } from '@angular/material/snack-bar';
import { Router, RouterOutlet } from '@angular/router';
import { Store } from '@ngrx/store';
import { TranslateService } from '@ngx-translate/core';
@@ -8,15 +7,11 @@ import { TranslateService } from '@ngx-translate/core';
import * as PlaylistActions from 'm3u-state';
import { DataService, EpgService } from 'services';
import {
ERROR,
IpcCommand,
Language,
OPEN_FILE,
Settings,
STORE_KEY,
Theme,
VIEW_ADD_PLAYLIST,
VIEW_SETTINGS,
} from 'shared-interfaces';
import { SettingsService } from './services/settings.service';
import { RecentlyViewedComponent } from './xtream-tauri/recently-viewed/recently-viewed.component';
@@ -27,30 +22,24 @@ import { SearchResultsComponent } from './xtream-tauri/search-results/search-res
templateUrl: './app.component.html',
imports: [RouterOutlet],
})
export class AppComponent implements OnInit, OnDestroy {
export class AppComponent implements OnInit {
private dataService = inject(DataService);
private dialog = inject(MatDialog);
private epgService = inject(EpgService);
private router = inject(Router);
private store = inject(Store);
private snackBar = inject(MatSnackBar);
private translate = inject(TranslateService);
private settingsService = inject(SettingsService);
/** List of ipc commands with function mapping */
private readonly commandsList = [
/* private readonly commandsList = [
new IpcCommand(VIEW_ADD_PLAYLIST, () => this.navigateToRoute('/')),
new IpcCommand(VIEW_SETTINGS, () => this.navigateToRoute('/settings')),
new IpcCommand(ERROR, (response: { message: string; status: number }) =>
this.showErrorAsNotification(response)
),
];
]; */
/** Default language as fallback */
private readonly DEFAULT_LANG = Language.ENGLISH;
private listeners = [];
constructor() {
if (
((this.dataService.isElectron &&
@@ -90,24 +79,7 @@ export class AppComponent implements OnInit, OnDestroy {
this.store.dispatch(PlaylistActions.loadPlaylists());
this.translate.setDefaultLang(this.DEFAULT_LANG);
this.setRendererListeners();
this.initSettings();
/* this.handleWhatsNewDialog(); */
}
/**
* Initializes all necessary listeners for the events from the renderer process
*/
setRendererListeners(): void {
this.commandsList.forEach((command) => {
const cb = (response) => {
if (response.data.type === command.id) {
command.callback(response.data);
}
};
this.dataService.listenOn(command.id, cb);
this.listeners.push(cb);
});
}
/**
@@ -134,6 +106,8 @@ export class AppComponent implements OnInit, OnDestroy {
this.epgService.fetchEpg(settings.epgUrl);
}
// TODO: trigger auto-refresh mechanism for playlists
if (settings.theme) {
this.settingsService.changeTheme(settings.theme);
} else {
@@ -160,21 +134,6 @@ export class AppComponent implements OnInit, OnDestroy {
}
}
/**
* Checks the actual version of the application and shows the "what is new" dialog if the updated version was detected
*/
/* handleWhatsNewDialog(): void {
const actualVersion = this.dataService.getAppVersion();
this.settingsService
.getValueFromLocalStorage(STORE_KEY.Version)
.subscribe(() => {
this.settingsService.setValueToLocalStorage(
STORE_KEY.Version,
actualVersion
);
});
} */
/**
* Navigate to the specified route
* @param route route to navigate to
@@ -183,16 +142,6 @@ export class AppComponent implements OnInit, OnDestroy {
this.router.navigateByUrl(route);
}
showErrorAsNotification(response: { message: string; status: number }) {
this.snackBar.open(
`Error: ${response?.message ?? 'Something went wrong'} (Status: ${
response?.status ?? 0
})`,
null,
{ duration: 4000 }
);
}
openGlobalSearch(): void {
this.dialog.open(SearchResultsComponent, {
width: '100%',
@@ -214,19 +163,4 @@ export class AppComponent implements OnInit, OnDestroy {
disableClose: false,
});
}
/**
* Removes all ipc command listeners on component destroy
*/
ngOnDestroy(): void {
if (this.dataService.isElectron) {
this.commandsList.forEach((command) =>
this.dataService.removeAllListeners(command.id)
);
} else {
this.listeners.forEach((listener) =>
window.removeEventListener('message', listener)
);
}
}
}
+3 -8
View File
@@ -1,10 +1,5 @@
<div class="grid-container">
<app-header
<app-header
[title]="'HOME.TITLE' | translate"
[subtitle]="'HOME.SUBTITLE' | translate"
/>
<app-recent-playlists class="recent-playlists" />
@if (isLoading) {
<mat-progress-bar mode="indeterminate" />
}
</div>
/>
<app-recent-playlists class="recent-playlists" />
-20
View File
@@ -1,24 +1,4 @@
::ng-deep {
.mat-tab-label-content {
text-transform: uppercase;
}
.mat-tab-group.mat-background-primary .mat-tab-header-pagination {
display: none;
}
}
.playlists-container {
grid-area: playlists;
}
.recent-playlists {
height: calc(100vh - 100px);
overflow: auto;
}
@media only screen and (max-width: 480px) {
.tab-title {
display: none;
}
}
+3 -94
View File
@@ -1,103 +1,12 @@
import { Component, inject, OnDestroy } from '@angular/core';
import { MatProgressBarModule } from '@angular/material/progress-bar';
import { MatSnackBar } from '@angular/material/snack-bar';
import { Store } from '@ngrx/store';
import { Component } from '@angular/core';
import { TranslatePipe } from '@ngx-translate/core';
import { RecentPlaylistsComponent } from 'components';
import { addPlaylist } from 'm3u-state';
import { DataService } from 'services';
import { ERROR, Playlist, PLAYLIST_PARSE_RESPONSE } from 'shared-interfaces';
import { HeaderComponent } from '../shared/components/header/header.component';
@Component({
selector: 'app-home',
templateUrl: './home.component.html',
styleUrls: ['./home.component.scss'],
imports: [
HeaderComponent,
MatProgressBarModule,
RecentPlaylistsComponent,
TranslatePipe,
],
imports: [HeaderComponent, RecentPlaylistsComponent, TranslatePipe],
})
export class HomeComponent implements OnDestroy {
private dataService = inject(DataService);
private snackBar = inject(MatSnackBar);
private readonly store = inject(Store);
/** Loading spinner state */
isLoading = false;
/** IPC Renderer commands list with callbacks */
commandsList = [
{
id: PLAYLIST_PARSE_RESPONSE,
execute: (response: { payload: Playlist }): void => {
this.store.dispatch(
addPlaylist({
playlist: response.payload,
})
);
},
},
{
id: ERROR,
execute: (error: { message: string; status: string }) => {
this.showNotification('Error: ' + error.message);
},
},
];
listeners = [];
constructor() {
this.setRendererListeners();
}
/**
* Set electrons main process listeners
*/
setRendererListeners(): void {
this.commandsList.forEach((command) => {
/* if (this.dataService.isElectron) {
this.dataService.listenOn(command.id, (event, response) =>
this.ngZone.run(() => command.execute(response))
);
} else { */
const cb = (response) => {
if (response.data.type === command.id) {
command.execute(response.data);
}
};
this.dataService.listenOn(command.id, cb);
this.listeners.push(cb);
/* } */
});
}
/**
* Shows snack bar notification with a given message
* @param message message to show
* @param duration visibility duration of the snackbar
*/
showNotification(message: string, duration = 2000): void {
this.snackBar.open(message, null, {
duration,
});
}
/**
* Remove ipcRenderer listeners after component destroy
*/
ngOnDestroy(): void {
if (this.dataService.isElectron) {
this.commandsList.forEach((command) =>
this.dataService.removeAllListeners(command.id)
);
} else {
this.listeners.forEach((listener) => {
window.removeEventListener('message', listener);
});
}
}
}
export class HomeComponent {}
@@ -96,7 +96,7 @@
<!-- channel overlay -->
<app-info-overlay
[channel]="activeChannel"
[epgProgram]="epgProgram$ | async"
[epgProgram]="(epgProgram$ | async)!"
/>
}
}
@@ -1,17 +1,9 @@
import { Overlay, OverlayRef } from '@angular/cdk/overlay';
import { ComponentPortal } from '@angular/cdk/portal';
import { AsyncPipe, CommonModule } from '@angular/common';
import {
Component,
Injector,
NgZone,
OnInit,
effect,
inject,
} from '@angular/core';
import { Component, Injector, OnInit, effect, inject } from '@angular/core';
import { MatDialog } from '@angular/material/dialog';
import { MatSidenavModule } from '@angular/material/sidenav';
import { MatSnackBar } from '@angular/material/snack-bar';
import { ActivatedRoute, Router, RouterLink } from '@angular/router';
import { Store } from '@ngrx/store';
import { StorageMap } from '@ngx-pwa/local-storage';
@@ -37,10 +29,7 @@ import { DataService, PlaylistsService } from 'services';
import {
COMPONENT_OVERLAY_REF,
Channel,
ERROR,
PLAYLIST_PARSE_BY_URL,
PLAYLIST_PARSE_RESPONSE,
Playlist,
STORE_KEY,
Settings,
SidebarView,
@@ -51,37 +40,35 @@ import { SettingsComponent } from '../../settings/settings.component';
@Component({
imports: [
ArtPlayerComponent,
AsyncPipe,
AudioPlayerComponent,
InfoOverlayComponent,
CommonModule,
EpgListComponent,
HtmlVideoPlayerComponent,
InfoOverlayComponent,
MatSidenavModule,
RouterLink,
SidebarComponent,
ToolbarComponent,
VjsPlayerComponent,
ArtPlayerComponent,
],
templateUrl: './video-player.component.html',
styleUrl: './video-player.component.scss',
})
export class VideoPlayerComponent implements OnInit {
private activatedRoute = inject(ActivatedRoute);
private dataService = inject(DataService);
private readonly activatedRoute = inject(ActivatedRoute);
private readonly dataService = inject(DataService);
private readonly dialog = inject(MatDialog);
private ngZone = inject(NgZone);
private overlay = inject(Overlay);
private playlistsService = inject(PlaylistsService);
private router = inject(Router);
private settingsStore = inject(SettingsStore);
private snackBar = inject(MatSnackBar);
private storage = inject(StorageMap);
private store = inject(Store);
private readonly overlay = inject(Overlay);
private readonly playlistsService = inject(PlaylistsService);
private readonly router = inject(Router);
private readonly settingsStore = inject(SettingsStore);
private readonly storage = inject(StorageMap);
private readonly store = inject(Store);
/** Active selected channel */
activeChannel$ = this.store
readonly activeChannel$ = this.store
.select(selectActive)
.pipe(filter((channel) => Boolean((channel as Channel)?.url)));
@@ -89,7 +76,7 @@ export class VideoPlayerComponent implements OnInit {
channels$!: Observable<Channel[]>;
/** Current epg program */
epgProgram$ = this.store.select(selectCurrentEpgProgram);
readonly epgProgram$ = this.store.select(selectCurrentEpgProgram);
/** Selected video player options */
playerSettings: Partial<Settings> = {
@@ -97,37 +84,6 @@ export class VideoPlayerComponent implements OnInit {
showCaptions: false,
};
/** IPC Renderer commands list with callbacks */
commandsList = [
{
id: ERROR,
execute: (response: { message: string }): void => {
this.snackBar.open(response.message, '', {
duration: 3100,
});
},
},
{
id: PLAYLIST_PARSE_RESPONSE,
execute: (response: { payload: Playlist }): void => {
if (response.payload.isTemporary) {
this.store.dispatch(
PlaylistActions.setChannels({
channels: response.payload.playlist.items,
})
);
} else {
this.store.dispatch(
PlaylistActions.addPlaylist({
playlist: response.payload,
})
);
}
this.sidebarView = 'CHANNELS';
},
},
];
readonly isDesktop = !!window['electron'];
sidebarView: SidebarView = 'CHANNELS';
@@ -158,7 +114,6 @@ export class VideoPlayerComponent implements OnInit {
*/
ngOnInit(): void {
this.applySettings();
this.setRendererListeners();
this.getPlaylistUrlAsParam();
this.channels$ = this.activatedRoute.params.pipe(
@@ -214,23 +169,6 @@ export class VideoPlayerComponent implements OnInit {
}
}
setRendererListeners(): void {
this.commandsList.forEach((command) => {
if (this.isDesktop) {
this.dataService.listenOn(command.id, (event, response) =>
this.ngZone.run(() => command.execute(response))
);
} else {
const cb = (response: any) => {
if (response.data.type === command.id) {
command.execute(response.data);
}
};
this.dataService.listenOn(command.id, cb);
}
});
}
/**
* Reads the app configuration from the browsers storage and applies the settings in the current component
*/
+55 -46
View File
@@ -1,16 +1,16 @@
import { inject, Injectable } from '@angular/core';
import { MatSnackBar } from '@angular/material/snack-bar';
import { Params } from '@angular/router';
import { Store } from '@ngrx/store';
import { TranslateService } from '@ngx-translate/core';
import * as PlaylistActions from 'm3u-state';
import { DataService } from 'services';
import {
AUTO_UPDATE_PLAYLISTS,
AUTO_UPDATE_PLAYLISTS_RESPONSE,
ERROR,
Playlist,
PLAYLIST_PARSE_BY_URL,
PLAYLIST_PARSE_RESPONSE,
PLAYLIST_UPDATE,
PLAYLIST_UPDATE_RESPONSE,
XTREAM_RESPONSE,
} from 'shared-interfaces';
import { AppConfig } from '../../environments/environment';
@@ -20,7 +20,9 @@ import { AppConfig } from '../../environments/environment';
})
export class ElectronService extends DataService {
private eventListeners: { [key: string]: () => void } = {};
private snackBar = inject(MatSnackBar);
private readonly snackBar = inject(MatSnackBar);
private readonly store = inject(Store);
private readonly translateService = inject(TranslateService);
constructor() {
super();
@@ -95,27 +97,18 @@ export class ElectronService extends DataService {
} else if (type === AUTO_UPDATE_PLAYLISTS) {
const data = payload as Playlist[];
const playlists = await window.electron.autoUpdatePlaylists(data);
/* for await (const item of data) {
if (item.filePath) {
const playlist = await readTextFile(item.filePath);
const parsedPlaylist = parse(playlist);
const playlistObject = createPlaylistObject(
item.title,
parsedPlaylist,
item.filePath,
'FILE'
);
playlists.push({ ...playlistObject, _id: item._id });
}
}
*/
window.postMessage({
type: AUTO_UPDATE_PLAYLISTS_RESPONSE,
payload: {
message: 'Success! The playlists were successfully updated',
this.store.dispatch(
PlaylistActions.updateManyPlaylists({
playlists,
},
});
})
);
this.snackBar.open(
this.translateService.instant(
'HOME.PLAYLISTS.AUTO_REFRESH_UPDATE_SUCCESS'
),
null,
{ duration: 2000 }
);
} else {
console.log('Unknown type', type);
}
@@ -130,25 +123,27 @@ export class ElectronService extends DataService {
// Use Electron IPC to make the Stalker request
const response = await window.electron.stalkerRequest(payload);
return response;
} catch (err: any) {
} catch (err) {
console.error('Stalker request error:', err);
window.postMessage({
type: ERROR,
message: err.message ?? 'Error: not found',
status: err.status ?? 404,
});
this.snackBar.open(
`Error: ${err.message ?? ' Not found'}, status: ${err.status ?? 404}`,
'Close',
{
duration: 5000,
}
);
throw err;
}
}
private async fetchM3uPlaylistFromUrl(payload: Partial<Playlist>) {
console.log(payload);
window.electron.fetchPlaylistByUrl(payload.url).then((result) => {
// TODO: call store and decide and store where to store based on the isTemporary flag etc
window.postMessage({
type: PLAYLIST_PARSE_RESPONSE,
payload: { ...result, isTemporary: payload.isTemporary },
});
this.store.dispatch(
PlaylistActions.handleAddingPlaylistByUrl({
isTemporary: !!payload?.isTemporary,
playlist: result,
})
);
});
}
@@ -179,16 +174,23 @@ export class ElectronService extends DataService {
}
methodToCall.then((playlistObject) => {
window.postMessage({
type: PLAYLIST_UPDATE_RESPONSE,
payload: {
message: 'Success! The playlist was successfully updated',
this.store.dispatch(
PlaylistActions.updatePlaylist({
playlist: {
...playlistObject,
_id: data.id,
},
},
});
playlistId: data.id,
})
);
this.snackBar.open(
this.translateService.instant(
'HOME.PLAYLISTS.PLAYLIST_UPDATE_SUCCESS'
),
null,
{ duration: 2000 }
);
});
}
@@ -224,14 +226,21 @@ export class ElectronService extends DataService {
window.postMessage(result);
return result;
} catch (error: any) {
console.error('Xtream request error:', error);
const result = {
console.error('Xtream request error:', error.message);
this.snackBar.open(
`Error: ${error.message ?? 'Failed to connect to Xtream server'}, status: ${
error.status ?? 500
}`,
'Close',
{
duration: 5000,
}
);
return {
type: ERROR,
status: error.status ?? 500,
message: error.message ?? 'Failed to connect to Xtream server',
};
window.postMessage(result);
return result;
}
}
+40 -24
View File
@@ -12,7 +12,6 @@ import {
ERROR,
Playlist,
PLAYLIST_PARSE_BY_URL,
PLAYLIST_PARSE_RESPONSE,
PLAYLIST_UPDATE,
STALKER_REQUEST,
XTREAM_REQUEST,
@@ -88,9 +87,15 @@ export class PwaService extends DataService {
this.getPlaylistFromUrl(payload.url)
.pipe(
catchError((error) => {
window.postMessage({
type: ERROR,
});
this.snackBar.open(
`Error: ${error.message ?? 'Unknown error'}, status: ${
error.status ?? 500
}`,
'Close',
{
duration: 5000,
}
);
return throwError(() => error);
})
)
@@ -120,19 +125,23 @@ export class PwaService extends DataService {
this.getPlaylistFromUrl(payload.url)
.pipe(
catchError((error) => {
window.postMessage({
type: ERROR,
message: this.getErrorMessageByStatusCode(error.status),
status: error.status,
});
this.snackBar.open(
this.getErrorMessageByStatusCode(error.status),
'Close',
{
duration: 5000,
}
);
return throwError(() => error);
})
)
.subscribe((response: any) => {
window.postMessage({
type: PLAYLIST_PARSE_RESPONSE,
payload: { ...response, isTemporary: payload.isTemporary },
});
.subscribe((response: Playlist) => {
this.store.dispatch(
PlaylistActions.handleAddingPlaylistByUrl({
isTemporary: !!payload?.isTemporary,
playlist: response,
})
);
});
}
@@ -196,11 +205,15 @@ export class PwaService extends DataService {
return result;
} catch (error: any) {
if (payload.params.action === 'get_account_info') return;
window.postMessage({
type: ERROR,
status: error.error?.status,
message: error.error?.message ?? 'Unknown error',
});
this.snackBar.open(
`Error: ${error.message ?? ' Unknown error'}, status: ${
error.status ?? 500
}`,
'Close',
{
duration: 5000,
}
);
}
}
@@ -232,11 +245,14 @@ export class PwaService extends DataService {
return (await response.json()).payload;
} catch (err) {
console.error('Stalker request error:', err);
window.postMessage({
type: ERROR,
message: err.message ?? 'Error: not found',
status: err.status ?? 404,
});
this.snackBar.open(
`Error: ${err.message ?? ' Not found'}, status: ${err.status ?? 404}`,
'Close',
{
duration: 5000,
}
);
throw err;
}
}
@@ -14,7 +14,6 @@
&.selected {
background-color: rgba(63, 81, 181, 0.1);
border-left: 4px solid #3f51b5;
margin-left: 4px;
}
}
@@ -1,20 +1,21 @@
<div class="expired-account">
@if (viewType === 'ERROR') {
<img src="./assets/images/broken-tv.svg" />
} @else if (viewType === 'EMPTY_CATEGORY') {
@if (viewType() === 'ERROR') {
<img alt="Something went wrong" src="./assets/images/broken-tv.svg" />
} @else if (viewType() === 'EMPTY_CATEGORY') {
<img
alt="empty category"
class="empty-category-img"
src="./assets/images/empty-category.svg"
/>
} @else if (viewType === 'NO_SEARCH_RESULTS') {
} @else if (viewType() === 'NO_SEARCH_RESULTS') {
<mat-icon class="icon">search</mat-icon>
}
<div>
<h3>{{ title }}</h3>
{{ description }}
<h3>{{ title() }}</h3>
{{ description() }}
</div>
@if (showActionButtons) {
@if (showActionButtons()) {
<div>
<button mat-stroked-button routerLink="/">
<mat-icon>home</mat-icon>
@@ -1,4 +1,4 @@
import { Component, Input, inject } from '@angular/core';
import { Component, inject, input } from '@angular/core';
import { MatButtonModule } from '@angular/material/button';
import { MatDialog } from '@angular/material/dialog';
import { MatIconModule } from '@angular/material/icon';
@@ -25,13 +25,16 @@ export class PlaylistErrorViewComponent {
private store = inject(Store);
private translate = inject(TranslateService);
currentPlaylist = this.store.selectSignal(selectCurrentPlaylist);
private readonly currentPlaylist = this.store.selectSignal(
selectCurrentPlaylist
);
@Input() description: string;
@Input() showActionButtons = true;
@Input() title: string;
@Input() viewType: 'ERROR' | 'EMPTY_CATEGORY' | 'NO_SEARCH_RESULTS' =
'ERROR';
readonly description = input<string>(undefined);
readonly showActionButtons = input(true);
readonly title = input<string>(undefined);
readonly viewType = input<'ERROR' | 'EMPTY_CATEGORY' | 'NO_SEARCH_RESULTS'>(
'ERROR'
);
openPlaylistDetails() {
this.dialog.open(PlaylistInfoComponent, {
@@ -7,7 +7,7 @@ mat-nav-list {
}
mat-list-item {
width: 280px !important;
width: 370px !important;
}
.scroll-viewport-portals {
@@ -33,7 +33,7 @@ mat-list-item {
transition: background-color 0.2s ease;
border-radius: 4px;
margin: 2px 8px;
::ng-deep .mdc-list-item__content {
overflow: hidden;
flex: 1;
@@ -50,7 +50,7 @@ mat-list-item {
}
&.mat-mdc-list-item-active {
background-color: rgba(29, 185, 84, 0.2);
background-color: rgba(29, 185, 84, 0.2);
.channel-info .title {
color: #1db954;
}
@@ -73,7 +73,7 @@ mat-list-item {
border-radius: 20px;
}
}
div[matformfieldlineripple] {
display: none;
}
@@ -93,7 +93,7 @@ mat-list-item {
flex-direction: column;
width: 100%;
gap: 4px;
.title {
font-weight: 600; // Slightly bolder
font-size: 14px;
@@ -108,7 +108,7 @@ mat-list-item {
animation: marquee 6s linear infinite;
}
}
.current-program {
font-size: 12px;
white-space: nowrap;
@@ -116,7 +116,7 @@ mat-list-item {
text-overflow: ellipsis;
line-height: 1.2;
margin-top: 2px;
&:hover {
overflow: visible;
text-overflow: clip;
@@ -135,17 +135,17 @@ mat-list-item {
align-items: center;
gap: 8px;
font-size: 11px;
opacity: .6;
opacity: 0.6;
padding: 0 2px;
.progress-bar {
flex: 1;
height: 3px;
background: rgba(255, 255, 255, 0.1);
opacity: .6;
opacity: 0.6;
border-radius: 2px;
overflow: hidden;
.progress {
height: 100%;
background: #1db954;
@@ -166,7 +166,7 @@ mat-list-item {
.progress-container {
opacity: 0.5;
.progress-bar {
background: rgba(255, 255, 255, 0.05);
}
@@ -22,7 +22,7 @@
.actions {
button {
margin: 5px 0;
margin: 5px;
}
}
@@ -10,6 +10,9 @@ import * as PlaylistActions from 'm3u-state';
import { selectCurrentPlaylist } from 'm3u-state';
import { Playlist } from 'shared-interfaces';
/**
* @deprecated: there is another one in xtream-tauri
*/
@Component({
selector: 'app-playlist-error-view',
templateUrl: './playlist-error-view.component.html',
+2 -1
View File
@@ -66,7 +66,8 @@
"MY_PLAYLISTS": "My playlists",
"MY_PLAYLISTS_SUBTITLE": "all available playlists",
"PLAYLIST_UPDATE_SUCCESS": "Success! The playlist was successfully updated.",
"PLAYLIST_UPDATE_ERROR": "Error updating playlist details."
"PLAYLIST_UPDATE_ERROR": "Error updating playlist details.",
"AUTO_REFRESH_UPDATE_SUCCESS": "Success! The playlists were successfully updated"
},
"FILE_UPLOAD": {
"DRAG_DROP": "Drag & drop files here",