Merge pull request #34 from 4gray/electron

v0.6.0
This commit is contained in:
4gray authored and GitHub committed 2021-04-10 23:56:38 +02:00
commit dfef6605cc
52 files changed
+43280 -348

No files matched your search

+1 -1
View File
@@ -1,2 +1,2 @@
save=true
save-exact=true
save-exact=true
+54 -9
View File
@@ -11,6 +11,7 @@ import {
EPG_FETCH_DONE,
EPG_GET_PROGRAM,
EPG_GET_PROGRAM_DONE,
PLAYLIST_SAVE_DETAILS,
} from './ipc-commands';
const fs = require('fs');
@@ -24,6 +25,9 @@ export class Api {
/** Instance of the main application window */
mainWindow: BrowserWindow;
/** Default user agent stored as a fallback value */
defaultUserAgent: string;
/** Instance of the epg browser window */
workerWindow: BrowserWindow;
@@ -61,18 +65,11 @@ export class Api {
event.sender.send('parse-response', { payload: playlistObject });
});
ipcMain.on('playlists-all', async (event) => {
const playlists = await db.find(
{ type: { $exists: false } },
{ count: 1, title: 1, _id: 1, url: 1, importDate: 1 }
);
event.sender.send('playlist-all-result', {
payload: playlists,
});
});
ipcMain.on('playlists-all', (event) => this.sendAllPlaylists(event));
ipcMain.on('playlist-by-id', async (event, args) => {
const playlist = await db.findOne({ _id: args.id });
this.setUserAgent(playlist.userAgent);
event.sender.send('parse-response', {
payload: playlist,
});
@@ -137,6 +134,51 @@ export class Api {
.on(EPG_ERROR, (event, arg) =>
this.mainWindow.webContents.send(EPG_ERROR, arg)
);
ipcMain.on(PLAYLIST_SAVE_DETAILS, async (event, args) => {
const updated = await db.update(
{ _id: args._id },
{ $set: { title: args.title, userAgent: args.userAgent } }
);
if (!updated.numAffected || updated.numAffected === 0) {
console.error('Error: Playlist details were not updated');
}
this.sendAllPlaylists(event);
});
}
/**
* Sends list with all playlists which are stored in the database
* @param event main event
*/
async sendAllPlaylists(event: Electron.IpcMainEvent): Promise<void> {
const playlists = await db.find(
{ type: { $exists: false } },
{
count: 1,
title: 1,
_id: 1,
url: 1,
importDate: 1,
userAgent: 1,
filename: 1,
}
);
event.sender.send('playlist-all-result', {
payload: playlists,
});
}
/**
* Sets the user agent header for all http requests
* @param userAgent user agent to use
*/
setUserAgent(userAgent: string): void {
if (userAgent === undefined || userAgent === null || userAgent === '') {
userAgent = this.defaultUserAgent;
}
this.mainWindow.webContents.setUserAgent(userAgent);
console.log(`Success: Set "${userAgent}" as user agent header`);
}
/**
@@ -145,6 +187,9 @@ export class Api {
*/
setEpgWorkerWindow(workerWindow: BrowserWindow): void {
this.workerWindow = workerWindow;
// store default user agent as fallback
this.defaultUserAgent = this.workerWindow.webContents.getUserAgent();
}
/**
+7
View File
@@ -1,5 +1,12 @@
// EPG related commands
export const EPG_FETCH = 'EPG:FETCH';
export const EPG_FETCH_DONE = 'EPG:FETCH_DONE';
export const EPG_ERROR = 'EPG:ERROR';
export const EPG_GET_PROGRAM = 'EPG:GET_PROGRAM';
export const EPG_GET_PROGRAM_DONE = 'EPG:GET_PROGRAM_DONE';
// Playlist related commands
export const PLAYLIST_SAVE_DETAILS = 'PLAYLIST:SAVE_DETAILS';
// General
export const SHOW_WHATS_NEW = 'SHOW_WHATS_NEW';
+4
View File
@@ -52,6 +52,10 @@ function createWindow(): BrowserWindow {
// in an array if your app supports multi windows, this is the time
// when you should delete the corresponding element.
win = null;
if (process.platform !== 'darwin') {
app.quit();
}
});
return win;
+5
View File
@@ -1,5 +1,6 @@
import { app, BrowserWindow, Menu, MenuItem, shell } from 'electron';
import * as path from 'path';
import { SHOW_WHATS_NEW } from './ipc-commands';
const openAboutWindow = require('about-window').default;
export class AppMenu {
@@ -75,6 +76,10 @@ export class AppMenu {
return new MenuItem({
label: 'Help',
submenu: [
{
label: 'What is new',
click: () => this.window.webContents.send(SHOW_WHATS_NEW),
},
{
label: 'Report a bug',
click: () =>
+42053 -71
View File
File diff suppressed because it is too large. Load diff
+1
View File
@@ -128,6 +128,7 @@
"nedb-promises-ts": "0.0.1-4",
"ngx-filter-pipe": "2.1.2",
"ngx-uploader": "11.0.0",
"ngx-whats-new": "0.0.1",
"semver": "7.3.4",
"uuid": "8.3.0",
"video-js": "0.7.0"
+8
View File
@@ -1 +1,9 @@
<router-outlet></router-outlet>
<!-- whats new dialog -->
<ngx-whats-new
*ngIf="isDialogVisible$ | async"
(closeModal)="setDialogVisibility(false)"
[items]="modals"
[options]="options"
>
</ngx-whats-new>
+2
View File
@@ -1,3 +1,4 @@
import { NgxWhatsNewModule } from 'ngx-whats-new';
import { TestBed, waitForAsync } from '@angular/core/testing';
import { RouterTestingModule } from '@angular/router/testing';
import { AppComponent } from './app.component';
@@ -33,6 +34,7 @@ describe('AppComponent', () => {
],
imports: [
MockModule(MatSnackBarModule),
MockModule(NgxWhatsNewModule),
RouterTestingModule,
TranslateModule.forRoot(),
],
+96 -36
View File
@@ -7,7 +7,16 @@ import { MatSnackBar } from '@angular/material/snack-bar';
import { ChannelStore } from './state';
import { StorageMap } from '@ngx-pwa/local-storage';
import { Settings } from './settings/settings.interface';
import { EPG_ERROR, EPG_FETCH, EPG_FETCH_DONE } from '../../ipc-commands';
import {
EPG_ERROR,
EPG_FETCH,
EPG_FETCH_DONE,
SHOW_WHATS_NEW,
} from '../../ipc-commands';
import { SettingsService } from './services/settings.service';
import { WhatsNewService } from './services/whats-new.service';
import * as semver from 'semver';
import { ModalWindow } from 'ngx-whats-new/lib/modal-window.interface';
// create custom title bar
new Titlebar({
@@ -22,31 +31,55 @@ new Titlebar({
styleUrls: ['./app.component.scss'],
})
export class AppComponent {
/** Visibility flag of the "what is new" modal dialog */
isDialogVisible$ = this.whatsNewService.dialogState$;
/** Dialog options */
options = this.whatsNewService.options;
/** Modals to show for the updated version of the application */
modals: ModalWindow[];
/**
* Creates an instance of AppComponent
*/
constructor(
private channelStore: ChannelStore,
private electronService: ElectronService,
private ngZone: NgZone,
private router: Router,
private translate: TranslateService,
private settingsService: SettingsService,
private snackBar: MatSnackBar,
private storage: StorageMap
private storage: StorageMap,
private whatsNewService: WhatsNewService
) {
this.translate.setDefaultLang('en');
if (electronService.isElectron) {
console.log(process.env);
console.log('Run in electron');
console.log(
'Electron ipcRenderer',
this.electronService.ipcRenderer
this.setRendererListeners();
if (
(this.electronService.remote.process.platform === 'linux' ||
this.electronService.remote.process.platform === 'win32') &&
this.electronService.remote.process.argv.length > 2
) {
const filePath = this.electronService.remote.process.argv.find(
(filepath) =>
filepath.endsWith('.m3u') || filepath.endsWith('.m3u8')
);
console.log(
'NodeJS childProcess',
this.electronService.childProcess
);
} else {
console.log('Run in browser');
if (filePath) {
const filePathsArray = filePath.split('/');
const fileName = filePathsArray[filePathsArray.length - 1];
this.electronService.ipcRenderer.send('open-file', {
filePath,
fileName,
});
}
}
}
/**
* Initializes all necessary listeners for the events from the renderer process
*/
setRendererListeners(): void {
this.electronService.ipcRenderer.on('add-playlist-view', () => {
this.ngZone.run(() => {
this.router.navigateByUrl('/', { skipLocationChange: true });
@@ -71,6 +104,7 @@ export class AppComponent {
});
});
});
this.electronService.ipcRenderer.on(EPG_ERROR, () => {
this.snackBar.open('EPG Error: something went wrong...', null, {
duration: 2000,
@@ -79,24 +113,14 @@ export class AppComponent {
});
});
if (
(this.electronService.remote.process.platform === 'linux' ||
this.electronService.remote.process.platform === 'win32') &&
this.electronService.remote.process.argv.length > 2
) {
const filePath = this.electronService.remote.process.argv.find(
(filepath) =>
filepath.endsWith('.m3u') || filepath.endsWith('.m3u8')
);
if (filePath) {
const filePathsArray = filePath.split('/');
const fileName = filePathsArray[filePathsArray.length - 1];
this.electronService.ipcRenderer.send('open-file', {
filePath,
fileName,
});
}
}
this.electronService.ipcRenderer.on(SHOW_WHATS_NEW, () => {
this.ngZone.run(() => {
this.modals = this.whatsNewService.getModalsByVersion(
this.electronService.getAppVersion()
);
this.setDialogVisibility(true);
});
});
}
/**
@@ -115,13 +139,49 @@ export class AppComponent {
horizontalPosition: 'right',
});
}
if (settings.theme) {
this.settingsService.changeTheme(settings.theme);
}
}
});
// store current app version (TODO: "what is new" dialog in v0.5)
this.storage
.set('version', this.electronService.getAppVersion())
.subscribe(() => {});
this.handleWhatsNewDialog();
}
/**
* 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.electronService.getAppVersion();
this.storage.get('version').subscribe((version) => {
const isNewVersion = semver.gt(actualVersion, version || '0.0.0');
if (!version || isNewVersion) {
this.modals = this.whatsNewService.getModalsByVersion(
actualVersion
);
this.setDialogVisibility(true);
}
this.setVersion(actualVersion);
});
}
/**
* Updates the version of the application in localstorage
* @param version actual version of the application
*/
setVersion(version: string): void {
this.storage.set('version', version).subscribe(() => {});
}
/**
* Sets the visibility flag of the modal window
* @param visible show/hide window flag
*/
setDialogVisibility(visible: boolean): void {
if (this.modals.length > 0) {
this.whatsNewService.changeDialogVisibleState(visible);
}
}
/**
+2 -2
View File
@@ -4,9 +4,9 @@ import '../polyfills';
import { AppRoutingModule } from './app-routing.module';
import { BrowserAnimationsModule } from '@angular/platform-browser/animations';
import { BrowserModule } from '@angular/platform-browser';
import { CommonModule } from '@angular/common';
import { HttpClientModule, HttpClient } from '@angular/common/http';
import { NgModule } from '@angular/core';
import { NgxWhatsNewModule } from 'ngx-whats-new';
import { SharedModule } from './shared/shared.module';
// NG Translate
@@ -26,8 +26,8 @@ export function HttpLoaderFactory(http: HttpClient): TranslateHttpLoader {
AppRoutingModule,
BrowserAnimationsModule,
BrowserModule,
CommonModule,
HttpClientModule,
NgxWhatsNewModule,
SharedModule,
TranslateModule.forRoot({
loader: {
+2 -6
View File
@@ -3,11 +3,7 @@
text-transform: uppercase;
}
.mat-tab-group.mat-background-primary .mat-tab-header {
background: #000 !important;
}
.mat-tab-group.mat-background-primary .mat-tab-header-pagination {
display:none;
display: none;
}
}
}
+4 -1
View File
@@ -8,7 +8,10 @@ import { Playlist } from './playlist.interface';
import { ElectronService } from '../services/electron.service';
/** Type to describe meta data of a playlist */
export type PlaylistMeta = Pick<Playlist, 'count' | 'title' | 'filename' | '_id' | 'url' | 'importDate'>;
export type PlaylistMeta = Pick<
Playlist,
'count' | 'title' | 'filename' | '_id' | 'url' | 'importDate' | 'userAgent'
>;
@Component({
selector: 'app-home',
+1
View File
@@ -14,4 +14,5 @@ export interface Playlist {
favorites: string[];
count: number;
url?: string;
userAgent?: string;
}
@@ -1,34 +1,60 @@
<h2 mat-dialog-title>
{{ 'HOME.PLAYLISTS.INFO_DIALOG.PLAYLIST_DETAILS' | translate }}
</h2>
<mat-dialog-content class="mat-typography">
<mat-form-field class="full-width">
<mat-label>{{
'HOME.PLAYLISTS.INFO_DIALOG.TITLE' | translate
}}</mat-label>
<input matInput [value]="playlist.title" disabled="true" />
</mat-form-field>
<mat-form-field class="full-width" *ngIf="playlist.url">
<mat-label>{{
'HOME.PLAYLISTS.INFO_DIALOG.FROM_URL' | translate
}}</mat-label>
<input matInput [value]="playlist.url" disabled="true" />
</mat-form-field>
<mat-form-field class="full-width">
<mat-label>{{
'HOME.PLAYLISTS.INFO_DIALOG.IMPORT_DATE' | translate
}}</mat-label>
<input matInput [value]="playlist.importDate | date" disabled="true" />
</mat-form-field>
<mat-form-field class="full-width">
<mat-label>{{
'HOME.PLAYLISTS.INFO_DIALOG.CHANNELS' | translate
}}</mat-label>
<input matInput [value]="playlist.count" disabled="true" />
</mat-form-field>
</mat-dialog-content>
<mat-dialog-actions align="end">
<button mat-button mat-dialog-close cdkFocusInitial color="primary">
{{ 'HOME.PLAYLISTS.INFO_DIALOG.CLOSE' | translate }}
</button>
</mat-dialog-actions>
<form
[formGroup]="playlistDetails"
(ngSubmit)="saveChanges(playlistDetails.value)"
>
<h2 mat-dialog-title>
{{ 'HOME.PLAYLISTS.INFO_DIALOG.PLAYLIST_DETAILS' | translate }}
</h2>
<mat-dialog-content class="mat-typography">
<mat-form-field class="full-width">
<mat-label>{{
'HOME.PLAYLISTS.INFO_DIALOG.TITLE' | translate
}}</mat-label>
<input formControlName="title" matInput />
</mat-form-field>
<mat-form-field class="full-width" *ngIf="playlist.url">
<mat-label>{{
'HOME.PLAYLISTS.INFO_DIALOG.FROM_URL' | translate
}}</mat-label>
<input matInput formControlName="url" />
</mat-form-field>
<mat-form-field class="full-width">
<mat-label>{{
'HOME.PLAYLISTS.INFO_DIALOG.ORIGINAL_FILENAME' | translate
}}</mat-label>
<input matInput formControlName="filename" />
</mat-form-field>
<mat-form-field class="full-width">
<mat-label>{{
'HOME.PLAYLISTS.INFO_DIALOG.IMPORT_DATE' | translate
}}</mat-label>
<input matInput formControlName="importDate" />
</mat-form-field>
<mat-form-field class="full-width">
<mat-label>{{
'HOME.PLAYLISTS.INFO_DIALOG.CHANNELS' | translate
}}</mat-label>
<input matInput formControlName="count" />
</mat-form-field>
<mat-form-field class="full-width">
<mat-label>{{
'HOME.PLAYLISTS.INFO_DIALOG.USER_AGENT' | translate
}}</mat-label>
<input formControlName="userAgent" matInput />
</mat-form-field>
</mat-dialog-content>
<mat-dialog-actions align="end">
<button
mat-flat-button
mat-dialog-close
color="accent"
type="submit"
[disabled]="!playlistDetails.valid || playlistDetails.pristine"
>
{{ 'HOME.PLAYLISTS.INFO_DIALOG.SAVE' | translate }}
</button>
<button mat-button mat-dialog-close cdkFocusInitial color="accent">
{{ 'HOME.PLAYLISTS.INFO_DIALOG.CLOSE' | translate }}
</button>
</mat-dialog-actions>
</form>
@@ -1,24 +1,40 @@
import { PLAYLIST_SAVE_DETAILS } from './../../../../../ipc-commands';
/* eslint-disable @typescript-eslint/unbound-method */
import { ElectronServiceStub } from './../../home.component.spec';
import { ElectronService } from './../../../services/electron.service';
import { TranslatePipe } from '@ngx-translate/core';
import { MockModule, MockPipe } from 'ng-mocks';
import { MAT_DIALOG_DATA, MatDialogModule } from '@angular/material/dialog';
import { MatFormFieldModule } from '@angular/material/form-field';
import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing';
import { DatePipe } from '@angular/common';
import { PlaylistInfoComponent } from './playlist-info.component';
import { FormsModule, ReactiveFormsModule, FormBuilder } from '@angular/forms';
describe('PlaylistInfoComponent', () => {
let component: PlaylistInfoComponent;
let fixture: ComponentFixture<PlaylistInfoComponent>;
let electronService: ElectronService;
beforeEach(
waitForAsync(() => {
TestBed.configureTestingModule({
imports: [
FormsModule,
MockModule(MatDialogModule),
MockModule(MatFormFieldModule),
ReactiveFormsModule,
],
declarations: [
PlaylistInfoComponent,
MockPipe(TranslatePipe),
MockPipe(DatePipe),
],
providers: [
{ provide: MAT_DIALOG_DATA, useValue: {} },
{ provide: ElectronService, useClass: ElectronServiceStub },
FormBuilder,
],
declarations: [PlaylistInfoComponent, MockPipe(TranslatePipe)],
providers: [{ provide: MAT_DIALOG_DATA, useValue: {} }],
}).compileComponents();
})
);
@@ -26,10 +42,22 @@ describe('PlaylistInfoComponent', () => {
beforeEach(() => {
fixture = TestBed.createComponent(PlaylistInfoComponent);
component = fixture.componentInstance;
electronService = TestBed.inject(ElectronService);
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
it('should send an event to the main process after save', () => {
const playlistToSave = { _id: 'a12345', title: 'Playlist' };
spyOn(electronService.ipcRenderer, 'send');
component.saveChanges(playlistToSave);
expect(electronService.ipcRenderer.send).toHaveBeenCalledTimes(1);
expect(electronService.ipcRenderer.send).toHaveBeenCalledWith(
PLAYLIST_SAVE_DETAILS,
playlistToSave
);
});
});
@@ -1,21 +1,75 @@
/* eslint-disable @typescript-eslint/unbound-method */
import { DatePipe } from '@angular/common';
import { Component, Inject } from '@angular/core';
import {
FormBuilder,
FormControl,
FormGroup,
Validators,
} from '@angular/forms';
import { MAT_DIALOG_DATA } from '@angular/material/dialog';
import { PLAYLIST_SAVE_DETAILS } from '../../../../../ipc-commands';
import { ElectronService } from '../../../services/electron.service';
import { Playlist } from '../../playlist.interface';
@Component({
selector: 'app-playlist-info',
templateUrl: './playlist-info.component.html',
styleUrls: ['./playlist-info.component.scss'],
providers: [DatePipe],
})
export class PlaylistInfoComponent {
/** Playlist object */
playlist: Playlist;
/** Form group with playlist details */
playlistDetails: FormGroup;
/**
* Creates an instance of the component and injects the selected playlist from the parent component
* @param data playlist object to show
* @param datePipe
* @param formBuilder
* @param electronService
* @param playlist playlist object to show
*/
constructor(@Inject(MAT_DIALOG_DATA) playlist: Playlist) {
constructor(
private datePipe: DatePipe,
private formBuilder: FormBuilder,
private electronService: ElectronService,
@Inject(MAT_DIALOG_DATA) playlist: Playlist
) {
this.playlist = playlist;
}
/**
* Create the form and set initial data on component init
*/
ngOnInit(): void {
this.playlistDetails = this.formBuilder.group({
_id: this.playlist._id,
title: new FormControl(this.playlist.title, Validators.required),
userAgent: this.playlist.userAgent || '',
filename: new FormControl({
value: this.playlist.filename || '',
disabled: true,
}),
count: new FormControl({
value: this.playlist.count,
disabled: true,
}),
importDate: new FormControl({
value: this.datePipe.transform(this.playlist.importDate),
disabled: true,
}),
url: new FormControl({ value: this.playlist.url, disabled: true }),
});
}
/**
* Saves updated playlist information
* @param data updated form data
*/
saveChanges(data: Pick<Playlist, '_id' | 'title' | 'userAgent'>): void {
this.electronService.ipcRenderer.send(PLAYLIST_SAVE_DETAILS, data);
}
}
@@ -35,15 +35,15 @@
</div>
<button
mat-icon-button
color="primary"
color="accent"
(click)="$event.stopPropagation(); openInfoDialog(item)"
[matTooltip]="'HOME.PLAYLISTS.SHOW_DETAILS' | translate"
>
<mat-icon>info</mat-icon>
<mat-icon>edit</mat-icon>
</button>
<button
mat-icon-button
color="primary"
color="accent"
(click)="$event.stopPropagation(); removeClicked.emit(item._id)"
[matTooltip]="'HOME.PLAYLISTS.REMOVE' | translate"
>
@@ -1,6 +1,6 @@
<mat-tab-group>
<mat-tab [label]="'CHANNELS.ALL_CHANNELS' | translate">
<mat-list id="top-panel">
<mat-list>
<mat-list-item>
<input
class="full-width search-bar"
@@ -21,6 +21,7 @@
</button>
</mat-list-item>
</mat-list>
<mat-divider></mat-divider>
<mat-nav-list>
<cdk-virtual-scroll-viewport itemSize="50" class="scroll-viewport">
<mat-list-item
@@ -32,7 +33,13 @@
[class.active]="selected?.id === channel.id"
(click)="selectChannel(channel)"
>
<p matLine>
<img
[src]="channel.tvg.logo"
*ngIf="channel.tvg.logo"
width="48"
onerror="this.style.display='none'"
/>
<p matLine class="channel-name">
{{
i + 1 + '. ' + channel?.name ||
('CHANNELS.UNNAMED_CHANNEL' | translate)
@@ -64,7 +71,13 @@
[class.active]="selected?.id === channel.id"
(click)="selectChannel(channel)"
>
<p matLine>
<img
[src]="channel.tvg.logo"
*ngIf="channel.tvg.logo"
width="48"
onerror="this.style.display='none'"
/>
<p matLine class="channel-name">
{{
i + 1 + '. ' + channel?.name ||
('CHANNELS.UNNAMED_CHANNEL'
@@ -94,7 +107,13 @@
[class.active]="selected?.id === channel.id"
(click)="selectChannel(channel)"
>
<p matLine>
<img
[src]="channel.tvg.logo"
*ngIf="channel.tvg.logo"
width="48"
onerror="this.style.display='none'"
/>
<p matLine class="channel-name">
{{
i + 1 + '. ' + channel?.name ||
('CHANNELS.UNNAMED_CHANNEL' | translate)
@@ -106,7 +125,7 @@
[matTooltip]="'CHANNELS.REMOVE_FAVORITE' | translate"
(click)="toggleFavoriteChannel(channel, $event)"
>
<mat-icon>star</mat-icon>
<mat-icon color="accent">star</mat-icon>
</button>
<mat-divider></mat-divider>
</mat-list-item>
@@ -1,11 +1,13 @@
#top-panel {
background: #eee;
}
.active {
background: #ddd;
}
::ng-deep {
.dark-theme .active {
background: #000;
}
}
.mat-list-base {
padding-top: 0;
}
@@ -20,6 +22,20 @@
.search-bar {
padding: 8px;
background: #ddd;
border: none;
border-radius: 4px;
}
::ng-deep .dark-theme {
.search-bar {
background: #666;
color: #fff;
}
.search-bar::placeholder {
color: #ccc;
}
}
::ng-deep {
@@ -30,9 +46,20 @@
border-radius: 0 !important;
}
.mat-tab-label, .mat-tab-label-active {
min-width: 0!important;
padding: 15px!important;
margin: 5px!important;
.mat-tab-label,
.mat-tab-label-active {
min-width: 0 !important;
padding: 15px !important;
margin: 5px !important;
}
}
}
.channel-name {
overflow: hidden;
text-overflow: ellipsis;
display: -webkit-box;
-webkit-line-clamp: 1;
-webkit-box-orient: vertical;
font-size: 0.9em;
width: 290px;
}
@@ -0,0 +1,40 @@
<h2 mat-dialog-title>
{{ 'EPG.PROGRAM_DIALOG.PROGRAM_DETAILS' | translate }}
</h2>
<mat-dialog-content class="mat-typography">
<ng-container *ngIf="epgProgram?.title?.length > 0">
<div class="subheading-2">
{{ 'EPG.PROGRAM_DIALOG.TITLE' | translate }}
</div>
<p data-test="title">{{ epgProgram.title[0].value }}</p>
<div class="subheading-2">
{{ 'EPG.PROGRAM_DIALOG.LANGUAGE' | translate }}
</div>
<p data-test="lang">{{ epgProgram.title[0].lang }}</p>
</ng-container>
<ng-container *ngIf="epgProgram?.category?.length > 0">
<div class="subheading-2">
{{ 'EPG.PROGRAM_DIALOG.CATEGORY' | translate }}
</div>
<p data-test="category">{{ epgProgram.category[0].value }}</p>
</ng-container>
<ng-container *ngIf="epgProgram?.desc?.length > 0">
<div class="subheading-2">
{{ 'EPG.PROGRAM_DIALOG.DESCRIPTION' | translate }}
</div>
<p data-test="desc">{{ epgProgram.desc[0].value }}</p>
</ng-container>
<ng-container *ngIf="epgProgram?.rating?.length > 0">
<div class="subheading-2">
{{ 'EPG.PROGRAM_DIALOG.PARENTAL_RATING_SYSTEM' | translate }}
</div>
<p data-test="rating">
{{ epgProgram.rating[0].value }} [{{ epgProgram.rating[0].system }}]
</p>
</ng-container>
</mat-dialog-content>
<mat-dialog-actions align="end">
<button mat-button mat-dialog-close cdkFocusInitial color="accent">
{{ 'HOME.PLAYLISTS.INFO_DIALOG.CLOSE' | translate }}
</button>
</mat-dialog-actions>
@@ -0,0 +1,13 @@
.subheading-2 {
color: #666;
text-decoration: underline;
text-transform: uppercase;
font-weight: 900;
font-size: 0.9em;
}
::ng-deep {
.dark-theme .subheading-2 {
color: #bdbdbd;
}
}
@@ -0,0 +1,59 @@
import { By } from '@angular/platform-browser';
import { MockPipe, MockModule } from 'ng-mocks';
import { TranslatePipe } from '@ngx-translate/core';
import { MatDialogModule, MAT_DIALOG_DATA } from '@angular/material/dialog';
import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing';
import { EpgItemDescriptionComponent } from './epg-item-description.component';
import { EpgProgram } from '../../../models/epg-program.model';
describe('EpgItemDescriptionComponent', () => {
let component: EpgItemDescriptionComponent;
let fixture: ComponentFixture<EpgItemDescriptionComponent>;
beforeEach(
waitForAsync(() => {
TestBed.configureTestingModule({
declarations: [
EpgItemDescriptionComponent,
MockPipe(TranslatePipe),
],
imports: [MockModule(MatDialogModule)],
providers: [{ provide: MAT_DIALOG_DATA, useValue: {} }],
}).compileComponents();
})
);
beforeEach(() => {
fixture = TestBed.createComponent(EpgItemDescriptionComponent);
component = fixture.componentInstance;
component.epgProgram = {} as EpgProgram;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
it('should render epg details in the dialog', () => {
component.epgProgram = {
title: [{ value: 'TV Show 1', lang: 'ru' }],
desc: [{ value: 'Highly interesting show about pets' }],
category: [{ value: 'Fun' }],
} as EpgProgram;
fixture.detectChanges();
const title = fixture.debugElement.query(By.css('[data-test="title"]'));
expect(title.nativeNode.innerHTML).toContain(
component.epgProgram.title[0].value
);
const category = fixture.debugElement.query(
By.css('[data-test="category"]')
);
expect(category.nativeNode.innerHTML).toContain(
component.epgProgram.category[0].value
);
const desc = fixture.debugElement.query(By.css('[data-test="desc"]'));
expect(desc.nativeNode.innerHTML).toContain(
component.epgProgram.desc[0].value
);
});
});
@@ -0,0 +1,20 @@
import { MAT_DIALOG_DATA } from '@angular/material/dialog';
import { EpgProgram } from './../../../models/epg-program.model';
import { Component, Inject } from '@angular/core';
@Component({
selector: 'app-epg-item-description',
templateUrl: './epg-item-description.component.html',
styleUrls: ['./epg-item-description.component.scss'],
})
export class EpgItemDescriptionComponent {
/** EPG program object */
epgProgram: EpgProgram;
/**
* Creates an instance of the component and injects the program of the clicked epg program
* @param epgProgram epg program
*/
constructor(@Inject(MAT_DIALOG_DATA) epgProgram: EpgProgram) {
this.epgProgram = epgProgram;
}
}
@@ -0,0 +1,29 @@
<span class="time"
>{{ item.start | momentDate: 'YYYYMMDDHHmm ZZ':'HH:mm' }}
-
{{ item.stop | momentDate: 'YYYYMMDDHHmm ZZ':'HH:mm' }}</span
>
<span
*ngIf="timeNow >= item.start && timeNow <= item.stop"
class="live badge"
[matTooltip]="'EPG.LIVE_NOW' | translate"
>live</span
>
<span
*ngIf="
item.start < timeNow &&
item.stop < timeNow &&
item.start > timeshiftUntil
"
class="rec badge"
[matTooltip]="'EPG.TIMESHIFT_AVAILABLE' | translate"
>r</span
>
<span
(click)="$event.stopPropagation(); showDescription(item)"
*ngIf="item.desc.length > 0"
class="description-icon"
[matTooltip]="'EPG.PROGRAM_DIALOG.SHOW_PROGRAM_DETAILS' | translate"
><mat-icon>info</mat-icon></span
>
<mat-divider></mat-divider>
@@ -0,0 +1,29 @@
.time {
color: #666;
border-bottom: 1px solid #ccc;
font-size: 0.8em;
display: inline-block;
margin-bottom: 5px;
}
.badge {
border-radius: 2px;
padding: 2px 6px;
color: #fff;
text-transform: uppercase;
margin-left: 6px;
font-size: 0.8em;
}
.live {
background-color: red;
}
.rec {
background-color: grey;
}
.description-icon {
float: right;
cursor: pointer;
}
@@ -0,0 +1,103 @@
/* eslint-disable @typescript-eslint/unbound-method */
import { MatTooltipModule } from '@angular/material/tooltip';
import { MatIconModule } from '@angular/material/icon';
import { TranslatePipe } from '@ngx-translate/core';
import { MatListModule } from '@angular/material/list';
import { MomentDatePipe } from './../../../../shared/pipes/moment-date.pipe';
import { EpgProgram } from './../../../models/epg-program.model';
import { MockModule, MockPipe } from 'ng-mocks';
import { MatDialog, MatDialogModule } from '@angular/material/dialog';
import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing';
import { EpgListItemComponent } from './epg-list-item.component';
import { EpgItemDescriptionComponent } from './../epg-item-description/epg-item-description.component';
import * as moment from 'moment';
const EPG_PROGRAM_ITEM = {
start: moment(Date.now()).format('YYYYMMDD'),
stop: moment(Date.now()).format('YYYYMMDD'),
channel: '12345',
title: [{ lang: 'en', value: 'NOW on PBS' }],
desc: [
{
lang: 'en',
value:
"Jordan's Queen Rania has made job creation a priority to help curb the staggering unemployment rates among youths in the Middle East.",
},
],
date: ['20080711'],
category: [
{ lang: 'en', value: 'Newsmagazine' },
{ lang: 'en', value: 'Interview' },
],
episodeNum: [
{ system: 'dd_progid', value: 'EP01006886.0028' },
{ system: 'onscreen', value: '427' },
],
previouslyShown: [{ start: '20080711000000' }],
subtitles: [{ type: 'teletext' }],
rating: [
{
system: 'VCHIP',
value: 'TV-G',
},
],
credits: [
{
role: 'actor',
name: 'Peter Bergman',
},
],
icon: [
'http://imageswoapi.whatsonindia.com/WhatsOnTV/images/ProgramImages/xlarge/38B4DE4E9A7132257749051B6C8B4F699DB264F4V.jpg',
],
audio: [],
_attributes: {
start: moment(Date.now()).format('YYYYMMDD'),
stop: moment(Date.now()).format('YYYYMMDD'),
},
};
describe('EpgListItemComponent', () => {
let component: EpgListItemComponent;
let fixture: ComponentFixture<EpgListItemComponent>;
let dialog: MatDialog;
beforeEach(
waitForAsync(() => {
TestBed.configureTestingModule({
declarations: [
EpgListItemComponent,
MockPipe(MomentDatePipe),
MockPipe(TranslatePipe),
],
imports: [
MockModule(MatDialogModule),
MockModule(MatListModule),
MockModule(MatIconModule),
MockModule(MatTooltipModule),
],
}).compileComponents();
})
);
beforeEach(() => {
fixture = TestBed.createComponent(EpgListItemComponent);
dialog = TestBed.inject(MatDialog);
component = fixture.componentInstance;
component.item = EPG_PROGRAM_ITEM;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
it('should trigger the function to open the details dialog', () => {
spyOn(dialog, 'open');
component.showDescription({} as EpgProgram);
expect(dialog.open).toHaveBeenCalledTimes(1);
expect(dialog.open).toHaveBeenCalledWith(EpgItemDescriptionComponent, {
data: {},
});
});
});
@@ -0,0 +1,36 @@
import { EpgItemDescriptionComponent } from './../epg-item-description/epg-item-description.component';
import { MatDialog } from '@angular/material/dialog';
import { Component, Input } from '@angular/core';
import { EpgProgram } from '../../../models/epg-program.model';
@Component({
selector: 'app-epg-list-item',
templateUrl: './epg-list-item.component.html',
styleUrls: ['./epg-list-item.component.scss'],
})
export class EpgListItemComponent {
/** EPG Program to render */
@Input() item: EpgProgram;
/** Actual time */
@Input() timeNow: string;
/** Aviability of the timeshift function until date */
@Input() timeshiftUntil: string;
/**
* Creates an instance of EpgListItemComponent
* @param dialog angular material dialog
*/
constructor(private dialog: MatDialog) {}
/**
* Opens the dialog with details about the selected program
* @param program selected epg program
*/
showDescription(program: EpgProgram): void {
this.dialog.open(EpgItemDescriptionComponent, {
data: program,
});
}
}
@@ -1,6 +1,6 @@
<ng-container *ngIf="channel; else noEpg">
<!-- channel info panel -->
<div fxFlex="120px" id="channel-info" fxLayout="column">
<div fxFlex="120px" id="channel-info" color="primary" fxLayout="column">
<div fxFlex="79px" fxLayout="row" fxLayoutGap="10px">
<div
fxFlex="60px"
@@ -22,6 +22,7 @@
</div>
</div>
</div>
<mat-divider></mat-divider>
<div fxFlex="40px" fxLayout="row" id="date-navigator">
<button
fxFlex="40px"
@@ -48,6 +49,7 @@
</button>
</div>
</div>
<mat-divider></mat-divider>
<!-- program list -->
<mat-selection-list
@@ -56,51 +58,47 @@
*ngIf="timeshiftUntil$ | async as timeshiftUntil"
>
<ng-container *ngIf="items?.length > 0; else epgNotAvailable">
<mat-list-option
*ngFor="let item of items"
[value]="item"
[class.active]="timeNow >= item.start && timeNow <= item.stop"
(click)="
setEpgProgram(
item,
timeNow >= item.start && timeNow <= item.stop,
item.start > timeshiftUntil
)
"
[disabled]="
item.start < timeshiftUntil || item.start >= timeNow
"
>
<div matLine>
<span class="time"
>{{
item.start | momentDate: 'YYYYMMDDHHmm ZZ':'HH:mm'
}}
-
{{
item.stop | momentDate: 'YYYYMMDDHHmm ZZ':'HH:mm'
}}</span
>
<span
*ngIf="timeNow >= item.start && timeNow <= item.stop"
class="live badge"
[matTooltip]="'EPG.LIVE_NOW' | translate"
>live</span
>
<span
*ngIf="
item.start < timeNow &&
item.stop < timeNow &&
item.start > timeshiftUntil
<ng-container *ngFor="let item of items">
<mat-list-item
*ngIf="
item.start < timeshiftUntil || item.start >= timeNow;
else timeshiftItem
"
>
<div matLine>
<app-epg-list-item
[timeshiftUntil]="timeshiftUntil"
[timeNow]="timeNow"
[item]="item"
></app-epg-list-item>
</div>
<p matLine>{{ item?.title[0]?.value }}</p>
</mat-list-item>
<ng-template #timeshiftItem>
<mat-list-option
[value]="item"
[class.active]="
timeNow >= item.start && timeNow <= item.stop
"
(click)="
setEpgProgram(
item,
timeNow >= item.start && timeNow <= item.stop,
item.start > timeshiftUntil
)
"
class="rec badge"
[matTooltip]="'EPG.TIMESHIFT_AVAILABLE' | translate"
>r</span
>
</div>
<p matLine>{{ item?.title[0]?.value }}</p>
<mat-divider></mat-divider>
</mat-list-option>
<div matLine>
<app-epg-list-item
[timeshiftUntil]="timeshiftUntil"
[timeNow]="timeNow"
[item]="item"
></app-epg-list-item>
</div>
<p matLine>{{ item?.title[0]?.value }}</p>
</mat-list-option>
</ng-template>
</ng-container>
</ng-container>
<ng-template #epgNotAvailable>
<mat-list-option>{{
@@ -13,19 +13,7 @@
border-bottom: 1px dashed #666;
}
.time {
color: #666;
border-bottom: 1px solid #ccc;
font-size: 0.8em;
display: inline-block;
margin-bottom: 5px;
}
#channel-info {
border-bottom: 1px solid #ccc;
box-shadow: 0 3px 6px rgba(0,0,0,0.16), 0 3px 6px rgba(0,0,0,0.23);
background-color: #ccc;
.channel-icon {
overflow: hidden;
}
@@ -48,28 +36,8 @@ mat-selection-list {
overflow: auto;
}
.badge {
border-radius: 2px;
padding: 2px 6px;
color: #fff;
text-transform: uppercase;
margin-left: 6px;
font-size: 0.8em;
}
.live {
background-color: red;
}
.rec {
background-color: grey;
}
#date-navigator {
border-top: 1px solid #999;
.selected-date {
font-size: 0.9em;
color: #333;
}
}
}
@@ -1,6 +1,8 @@
import { MockComponent } from 'ng-mocks';
/* eslint-disable @typescript-eslint/unbound-method */
import { MatDialog, MatDialogModule } from '@angular/material/dialog';
import { TranslatePipe } from '@ngx-translate/core';
import { MatTooltipModule } from '@angular/material/tooltip';
/* eslint-disable @typescript-eslint/unbound-method */
import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing';
import { EpgListComponent, EpgData } from './epg-list.component';
import { MatListModule } from '@angular/material/list';
@@ -12,12 +14,14 @@ import { EPG_GET_PROGRAM_DONE } from '../../../../../ipc-commands';
import { Channel, ChannelStore } from '../../../state';
import { MomentDatePipe } from '../../../shared/pipes/moment-date.pipe';
import { MatIconModule } from '@angular/material/icon';
import { EpgListItemComponent } from './epg-list-item/epg-list-item.component';
describe('EpgListComponent', () => {
let component: EpgListComponent;
let fixture: ComponentFixture<EpgListComponent>;
let electronService: ElectronService;
let channelStore: ChannelStore;
let dialog: MatDialog;
const MOCKED_PROGRAMS = {
channel: {
@@ -90,11 +94,13 @@ describe('EpgListComponent', () => {
EpgListComponent,
MockPipe(MomentDatePipe),
MockPipe(TranslatePipe),
MockComponent(EpgListItemComponent),
],
imports: [
MockModule(MatIconModule),
MockModule(MatTooltipModule),
MockModule(MatListModule),
MockModule(MatDialogModule),
],
providers: [
{ provide: ElectronService, useClass: ElectronServiceStub },
@@ -107,6 +113,7 @@ describe('EpgListComponent', () => {
fixture = TestBed.createComponent(EpgListComponent);
component = fixture.componentInstance;
electronService = TestBed.inject(ElectronService);
dialog = TestBed.inject(MatDialog);
channelStore = TestBed.inject(ChannelStore);
channelStore.setActiveChannel(({
id: '',
+2 -2
View File
@@ -3,8 +3,8 @@ export interface EpgProgram {
stop: string;
channel: string;
title: { lang: string; value: string }[];
desc: any[];
category: any[];
desc: { lang: string; value: string }[];
category: { lang: string; value: string }[];
date: any[];
episodeNum: any[];
previouslyShown: any[];
+4
View File
@@ -1,3 +1,4 @@
import { EpgListItemComponent } from './components/epg-list/epg-list-item/epg-list-item.component';
import { NgModule } from '@angular/core';
import { CommonModule } from '@angular/common';
import { ChannelListContainerComponent } from './components/channel-list-container/channel-list-container.component';
@@ -7,6 +8,7 @@ import { VjsPlayerComponent } from './components/vjs-player/vjs-player.component
import { RouterModule, Routes } from '@angular/router';
import { SharedModule } from '../shared/shared.module';
import { EpgListComponent } from './components/epg-list/epg-list.component';
import { EpgItemDescriptionComponent } from './components/epg-list/epg-item-description/epg-item-description.component';
const routes: Routes = [{ path: '', component: VideoPlayerComponent }];
@@ -14,7 +16,9 @@ const routes: Routes = [{ path: '', component: VideoPlayerComponent }];
imports: [CommonModule, RouterModule.forChild(routes), SharedModule],
declarations: [
ChannelListContainerComponent,
EpgItemDescriptionComponent,
EpgListComponent,
EpgListItemComponent,
HtmlVideoPlayerComponent,
VideoPlayerComponent,
VjsPlayerComponent,
+19
View File
@@ -0,0 +1,19 @@
import { Theme } from './../settings/theme.enum';
import { Injectable } from '@angular/core';
@Injectable({
providedIn: 'root',
})
export class SettingsService {
/**
* Changes the visual theme of the application
* @param selectedTheme theme to set
*/
changeTheme(selectedTheme: Theme): void {
if (selectedTheme === Theme.LightTheme) {
document.body.classList.remove('dark-theme');
} else if (selectedTheme === Theme.DarkTheme) {
document.body.classList.add('dark-theme');
}
}
}
@@ -0,0 +1,38 @@
/* eslint-disable @typescript-eslint/unbound-method */
import { TestBed, inject } from '@angular/core/testing';
import { WhatsNewService } from './whats-new.service';
describe('Service: WhatsNew', () => {
beforeEach(() => {
TestBed.configureTestingModule({
providers: [WhatsNewService],
});
});
it('should ...', inject([WhatsNewService], (service: WhatsNewService) => {
expect(service).toBeTruthy();
}));
it('should change the state', inject(
[WhatsNewService],
(service: WhatsNewService) => {
spyOn(service.dialogState$, 'next');
service.changeDialogVisibleState(true);
expect(service.dialogState$.next).toHaveBeenCalledTimes(1);
expect(service.dialogState$.next).toHaveBeenCalledWith(true);
service.changeDialogVisibleState(false);
expect(service.dialogState$.next).toHaveBeenCalledTimes(2);
expect(service.dialogState$.next).toHaveBeenCalledWith(false);
}
));
it('should return modal windows for the requested version ', inject(
[WhatsNewService],
(service: WhatsNewService) => {
const version = '0.6.0';
const modals = service.getModalsByVersion(version);
expect(modals).toBeDefined();
expect(modals).toHaveLength(service.modals[version].length);
}
));
});
+82
View File
@@ -0,0 +1,82 @@
import { ModalWindow } from 'ngx-whats-new/lib/modal-window.interface';
import { Injectable } from '@angular/core';
import { BehaviorSubject } from 'rxjs';
@Injectable({
providedIn: 'root',
})
export class WhatsNewService {
/** Visibility state of the modal */
dialogState$ = new BehaviorSubject<boolean>(false);
/** Array with all available modals */
modals = {
'0.6.0': [
{
imageHeight: 470,
imageBgColor: '#333',
imageSrc: './assets/updates/060/dark-theme.png',
title: 'New in v0.6.0 - Dark theme 🎉',
text:
'Finally, the dark theme is now available. Check the settings page of the application to change the theme.',
button: {
text: 'OKAY',
textColor: '#ccc',
bgColor: '#111',
},
},
{
imageHeight: 470,
imageBgColor: '#333',
imageSrc: './assets/updates/060/channel-logos.png',
title: 'Channel logos 📺',
text:
'The channel list was extended with the visualization of logotypes (only visible if they are defined in the playlist)',
button: {
text: 'OKAY',
textColor: '#ccc',
bgColor: '#111',
},
},
{
imageHeight: 470,
imageBgColor: '#333',
imageSrc: './assets/updates/060/custom-user-agent.png',
title: 'Custom user agent 🕵️‍♂️',
text:
'Some IPTV providers need a specific user-agent in order to play their playlist. Support of custom user agent is available now.',
button: {
text: 'GOT IT',
textColor: '#ccc',
bgColor: '#111',
},
},
],
};
/** Options for the "what is new" modal dialogs */
options = {
width: '500px',
customStyle: {
boxShadow: '0px 0px 10px 5px #111',
backgroundColor: '#333',
textColor: '#eee',
},
};
/**
* Changes the visibility state of the modal dialog
* @param value flag to set
*/
changeDialogVisibleState(value: boolean): void {
this.dialogState$.next(value);
}
/**
* Returns an array with modals for the provided version of the application
* @param version version string
*/
getModalsByVersion(version: string): ModalWindow[] {
return this.modals[version] || [];
}
}
+26 -2
View File
@@ -95,6 +95,30 @@
</p>
</div>
</mat-list-item>
<mat-list-item>
<div matLine fxLayout="row">
<p fxFlex>{{ 'SETTINGS.THEME' | translate }}</p>
<p fxFlex>
<mat-form-field appearance="fill">
<mat-label>{{
'SETTINGS.VIDEO_PLAYER_PLACEHOLDER'
| translate
}}</mat-label>
<mat-select formControlName="theme">
<mat-option
*ngFor="
let theme of themeEnum | keyvalue
"
[value]="theme.value"
>{{
'THEMES.' + theme.value | translate
}}</mat-option
>
</mat-select>
</mat-form-field>
</p>
</div>
</mat-list-item>
<mat-list-item>
<div matLine fxLayout="row">
<p fxFlex>{{ 'SETTINGS.VERSION' | translate }}</p>
@@ -109,7 +133,7 @@
<mat-card-actions>
<button
mat-button
color="primary"
color="accent"
type="submit"
[disabled]="settingsForm.pristine"
>
@@ -119,7 +143,7 @@
<div fxFlex></div>
<button
mat-button
color="primary"
color="accent"
type="reset"
(click)="backToHome()"
>
@@ -1,3 +1,8 @@
import { StorageMap } from '@ngx-pwa/local-storage';
import { FormBuilder } from '@angular/forms';
/* eslint-disable @typescript-eslint/unbound-method */
import { ElectronService } from './../services/electron.service';
import { ElectronServiceStub } from './../home/home.component.spec';
import { MatSelectModule } from '@angular/material/select';
import { MatFormFieldModule } from '@angular/material/form-field';
import { MatListModule } from '@angular/material/list';
@@ -14,14 +19,25 @@ import { MatIconModule } from '@angular/material/icon';
import { MatSnackBar } from '@angular/material/snack-bar';
import { FormsModule, ReactiveFormsModule } from '@angular/forms';
import { HttpClientTestingModule } from '@angular/common/http/testing';
import { EPG_FETCH } from '../../../ipc-commands';
import { Router } from '@angular/router';
class MatSnackBarStub {
open(): void {}
}
class MockRouter {
navigateByUrl(url: string) {
return url;
}
}
describe('SettingsComponent', () => {
let component: SettingsComponent;
let fixture: ComponentFixture<SettingsComponent>;
let electronService: ElectronService;
let storage: StorageMap;
let router: Router;
beforeEach(
waitForAsync(() => {
@@ -32,11 +48,18 @@ describe('SettingsComponent', () => {
MockPipe(TranslatePipe),
],
providers: [
FormBuilder,
{ provide: MatSnackBar, useClass: MatSnackBarStub },
{
provide: TranslateService,
useClass: TranslateServiceStub,
},
{ provide: ElectronService, useClass: ElectronServiceStub },
{
provide: Router,
useClass: MockRouter,
},
StorageMap,
],
imports: [
HttpClientTestingModule,
@@ -56,10 +79,29 @@ describe('SettingsComponent', () => {
beforeEach(() => {
fixture = TestBed.createComponent(SettingsComponent);
electronService = TestBed.inject(ElectronService);
storage = TestBed.inject(StorageMap);
router = TestBed.inject(Router);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create and init component', () => {
expect(component).toBeTruthy();
});
it('should send epg fetch command', () => {
spyOn(electronService.ipcRenderer, 'send');
component.fetchEpg();
expect(electronService.ipcRenderer.send).toHaveBeenCalledWith(
EPG_FETCH,
{ url: '' }
);
});
it('should navigate back to home page', () => {
spyOn(router, 'navigateByUrl');
component.backToHome();
expect(router.navigateByUrl).toHaveBeenCalledTimes(1);
});
});
+15
View File
@@ -12,6 +12,8 @@ import { ElectronService } from '../services/electron.service';
import { ChannelQuery } from '../state';
import { EPG_FETCH } from '../../../ipc-commands';
import { Language } from './language.enum';
import { Theme } from './theme.enum';
import { SettingsService } from './../services/settings.service';
/** Url of the package.json file in the app repository, required to get the version of the released app */
const PACKAGE_JSON_URL =
@@ -55,6 +57,9 @@ export class SettingsComponent implements OnInit, OnDestroy {
(store) => store.epgAvailable
);
/** All available visual themes */
themeEnum = Theme;
/**
* Creates an instance of SettingsComponent and injects some dependencies into the component
* @param channelQuery
@@ -62,8 +67,10 @@ export class SettingsComponent implements OnInit, OnDestroy {
* @param formBuilder
* @param http
* @param router
* @param settingsService
* @param snackBar
* @param storage
* @param translate
*/
constructor(
private channelQuery: ChannelQuery,
@@ -71,6 +78,7 @@ export class SettingsComponent implements OnInit, OnDestroy {
private formBuilder: FormBuilder,
private http: HttpClient,
private router: Router,
private settingsService: SettingsService,
private snackBar: MatSnackBar,
private storage: StorageMap,
private translate: TranslateService
@@ -79,6 +87,7 @@ export class SettingsComponent implements OnInit, OnDestroy {
player: ['videojs'],
epgUrl: '',
language: Language.ENGLISH,
theme: Theme.LightTheme,
});
this.subscription.add(
@@ -119,6 +128,9 @@ export class SettingsComponent implements OnInit, OnDestroy {
language: settings.language
? settings.language
: Language.ENGLISH,
theme: settings.theme
? settings.theme
: Theme.LightTheme,
});
}
})
@@ -139,6 +151,9 @@ export class SettingsComponent implements OnInit, OnDestroy {
this.fetchEpg();
}
this.translate.use(this.settingsForm.value.language);
this.settingsService.changeTheme(
this.settingsForm.value.theme
);
this.snackBar.open(
this.translate.instant('SETTINGS.SETTINGS_SAVED'),
null,
+2
View File
@@ -1,4 +1,5 @@
import { Language } from './language.enum';
import { Theme } from './theme.enum';
export type VideoPlayerType = 'html5' | 'videojs';
@@ -9,4 +10,5 @@ export interface Settings {
player: VideoPlayerType;
epgUrl: string;
language: Language;
theme: Theme;
}
+4
View File
@@ -0,0 +1,4 @@
export enum Theme {
DarkTheme = 'DARK_THEME',
LightTheme = 'LIGHT_THEME',
}
+30 -13
View File
@@ -8,7 +8,7 @@
"SEARCH_CHANNEL": "Nach Sender suchen",
"UNGROUPED": "Ohne Gruppe",
"UNNAMED_CHANNEL": "Sender ohne Namen",
"UPLOAD_OR_SELECT_OTHER_PLAYLIST": "Neue Playliste hochladen oder eine andere aussuchen",
"UPLOAD_OR_SELECT_OTHER_PLAYLIST": "Neue Playlist hochladen oder eine andere aussuchen",
"USE_STAR_TO_FAVORITE": "Nutzen Sie das Star-Icon um Sender als Favoriten zu markieren"
},
"EPG": {
@@ -20,32 +20,44 @@
"NEXT_DAY": "Zum nächsten Tag",
"PREVIOUS_DAY": "Zum vorherigen Tag",
"TIMESHIFT_AVAILABLE": "Die Timeshift-Funktion (Archiv) ist verfügbar, klicken Sie an um abzuspielen",
"FETCH_EPG": "EPG-Daten werden geladen..."
"FETCH_EPG": "EPG-Daten werden geladen...",
"PROGRAM_DIALOG": {
"PROGRAM_DETAILS": "Details zum TV-Programm",
"TITLE": "Titel",
"CATEGORY": "Ketegorie",
"PARENTAL_RATING_SYSTEM": "Alterskennzeichnung (USK)",
"DESCRIPTION": "Beschreibung",
"LANGUAGE": "Sprache",
"SHOW_PROGRAM_DETAILS": "Details anzeigen"
}
},
"HOME": {
"SUBTITLE": "Wählen Sie eine Playliste aus oder laden Sie eine neue Playliste hoch",
"SUBTITLE": "Wählen Sie eine Playlist aus oder laden Sie eine neue Playlist hoch",
"TITLE": "IPTVnator",
"FILE_UPLOAD": {
"CHOOSE_PLAYLIST": "Playliste auswählen",
"CHOOSE_PLAYLIST": "Playlist auswählen",
"DRAG_DROP": "Ziehen Sie Dateien per Drag & Drop",
"OR": "oder"
},
"PLAYLISTS": {
"ADDED": "Hinzugefügt am",
"ADDED_VIA_FILE": "Diese Playliste wurde aus dem Dateisystem hochgeladen",
"ADDED_VIA_URL": "Diese Playliste wurde von der URL hinzugefügt",
"ADD_FIRST": "Bitte fügen Sie eine neue Playliste hinzu",
"ADDED_VIA_FILE": "Diese Playlist wurde aus dem Dateisystem hochgeladen",
"ADDED_VIA_URL": "Diese Playlist wurde von der URL hinzugefügt",
"ADD_FIRST": "Bitte fügen Sie eine neue Playlist hinzu",
"CHANNELS": "TV-Sender",
"NO_PLAYLISTS": "Noch keine Playlisten hinzugefügt",
"REMOVE": "Playliste entfernen",
"REMOVE": "Playlist entfernen",
"SHOW_DETAILS": "Details anzeigen",
"INFO_DIALOG": {
"CHANNELS": "Anzahl von Sender",
"CLOSE": "Schließen",
"FROM_URL": "Von der URL hinzugefügt",
"IMPORT_DATE": "Hinzugefügt am",
"PLAYLIST_DETAILS": "Informationen über die Playliste",
"TITLE": "Titel"
"PLAYLIST_DETAILS": "Infos über die Playlist",
"TITLE": "Titel",
"USER_AGENT": "User agent",
"SAVE": "Speichern",
"ORIGINAL_FILENAME": "Original-Dateinamen"
}
},
"TABS": {
@@ -54,8 +66,8 @@
"URL_UPLOAD": "Via URL hinzufügen"
},
"URL_UPLOAD": {
"ADD_PLAYLIST": "Playliste hinzufügen",
"PLAYLIST_URL": "URL der Playliste"
"ADD_PLAYLIST": "Playlist hinzufügen",
"PLAYLIST_URL": "URL der Playlist"
}
},
"LANGUAGES": {
@@ -78,7 +90,12 @@
"VERSION": "Version",
"VIDEO_PLAYER_LABEL": "Video player",
"VIDEO_PLAYER_PLACEHOLDER": "Option auswählen",
"SETTINGS_SAVED": "Einstellungen wurden erfolgreich gespeichert!"
"SETTINGS_SAVED": "Einstellungen wurden erfolgreich gespeichert!",
"THEME": "Farbdesign"
},
"THEMES": {
"DARK_THEME": "Dunkel",
"LIGHT_THEME": "Hell"
},
"TOP_MENU": {
"OPEN_CHANNELS_LIST": "Senderliste öffnen",
+20 -3
View File
@@ -22,7 +22,10 @@
"FROM_URL": "Added from the URL",
"IMPORT_DATE": "Import date",
"CHANNELS": "Number of channels",
"CLOSE": "Close"
"CLOSE": "Close",
"SAVE": "Save",
"USER_AGENT": "User agent",
"ORIGINAL_FILENAME": "Original filename"
}
},
"FILE_UPLOAD": {
@@ -50,7 +53,12 @@
"NEW_VERSION_AVAILABLE": "There is a new version available",
"LATEST_VERSION": "You are using the latest version",
"LANGUAGE": "Language",
"SETTINGS_SAVED": "Success! Configuration was saved."
"SETTINGS_SAVED": "Success! Configuration was saved.",
"THEME": "Visual theme"
},
"THEMES": {
"DARK_THEME": "Dark theme",
"LIGHT_THEME": "Light theme"
},
"CHANNELS": {
"SEARCH_CHANNEL": "Search channel",
@@ -78,7 +86,16 @@
"EPG_NOT_AVAILABLE_DATE": "Ooops, EPG is not available for the selected date",
"EPG_NOT_AVAILABLE_CHANNEL_TITLE": "Ooops, EPG is not available for this channel.",
"EPG_NOT_AVAILABLE_CHANNEL_DESCRIPTION": "Please add/change the url of EPG list the settings of the app.",
"FETCH_EPG": "Fetch EPG data..."
"FETCH_EPG": "Fetch EPG data...",
"PROGRAM_DIALOG": {
"PROGRAM_DETAILS": "Details about the TV Program",
"TITLE": "Title",
"CATEGORY": "Category",
"PARENTAL_RATING_SYSTEM": "Television content rating system",
"DESCRIPTION": "Description",
"LANGUAGE": "Language",
"SHOW_PROGRAM_DETAILS": "Show details about this program"
}
},
"LANGUAGES": {
"ENGLISH": "English",
+21 -4
View File
@@ -22,7 +22,10 @@
"FROM_URL": "Добавлен по ссылке",
"IMPORT_DATE": "Дата импорта",
"CHANNELS": "Количество каналов",
"CLOSE": "Закрыть"
"CLOSE": "Закрыть",
"SAVE": "Сохранить",
"USER_AGENT": "User agent",
"ORIGINAL_FILENAME": "Имя файла при загрузке"
}
},
"FILE_UPLOAD": {
@@ -50,11 +53,16 @@
"NEW_VERSION_AVAILABLE": "Доступна новая версия приложения",
"LATEST_VERSION": "Вы используете последнюю версию приложения",
"LANGUAGE": "Язык",
"SETTINGS_SAVED": "Новые настройки были успешно сохранены."
"SETTINGS_SAVED": "Новые настройки были успешно сохранены.",
"THEME": "Тема оформления"
},
"THEMES": {
"DARK_THEME": "Тёмная тема",
"LIGHT_THEME": "Светлая тема"
},
"CHANNELS": {
"SEARCH_CHANNEL": "Поиск каналов",
"UPLOAD_OR_SELECT_OTHER_PLAYLIST": "Загрузить илди выбрать другой плейлист",
"UPLOAD_OR_SELECT_OTHER_PLAYLIST": "Загрузить или выбрать другой плейлист",
"ALL_CHANNELS": "Все каналы",
"GROUPS": "Группы",
"UNGROUPED": "Без групп",
@@ -78,7 +86,16 @@
"EPG_NOT_AVAILABLE_DATE": "Ой, к сожалению, программа отсутствует на этот день.",
"EPG_NOT_AVAILABLE_CHANNEL_TITLE": "Ой, к сожалению, программа не доступна для этого канала.",
"EPG_NOT_AVAILABLE_CHANNEL_DESCRIPTION": "Попробуйте изменить или обновить ссылку на EPG в настройках приложения.",
"FETCH_EPG": "Загружаю программу тв-передач (EPG)..."
"FETCH_EPG": "Загружаю программу тв-передач (EPG)...",
"PROGRAM_DIALOG": {
"PROGRAM_DETAILS": "Подробности передачи",
"TITLE": "Название",
"CATEGORY": "Категория",
"PARENTAL_RATING_SYSTEM": "Возрастное ограничение",
"DESCRIPTION": "Описание",
"LANGUAGE": "Язык",
"SHOW_PROGRAM_DETAILS": "Показать подробности"
}
},
"LANGUAGES": {
"ENGLISH": "English",
Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

+1
View File
@@ -0,0 +1 @@
import 'jest-extended';
+11 -11
View File
@@ -1,14 +1,14 @@
<!doctype html>
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>IPTVnator</title>
<base href="/">
<head>
<meta charset="utf-8" />
<title>IPTVnator</title>
<base href="/" />
<meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="icon" type="image/x-icon" href="assets/icons/favicon.ico">
</head>
<body>
<app-root></app-root>
</body>
<meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="icon" type="image/x-icon" href="assets/icons/favicon.ico" />
</head>
<body class="mat-app-background">
<app-root></app-root>
</body>
</html>
+69 -53
View File
@@ -1,82 +1,98 @@
$material-icons-font-path: '~material-icons/iconfont/';
/* You can add global styles to this file, and also import other style files */
@import '~@angular/material/prebuilt-themes/indigo-pink.css';
$material-icons-font-path: '~material-icons/iconfont/';
@import '~material-icons/iconfont/material-icons.scss';
@import '~@angular/material/theming';
@include mat-core();
@import 'themes/';
body,
html {
background: #fff;
font-family: caption;
user-select: none;
}
.mat-toolbar.mat-primary {
background: #000000;
}
html,
body {
margin: 0;
padding: 0;
html, body {
margin: 0;
padding: 0;
height: 100%;
font-family: Arial, Helvetica, sans-serif;
height: 100%;
font-family: Arial, Helvetica, sans-serif;
}
/* CAN (MUST) BE REMOVED ! Sample Global style */
.container {
height: 100%;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 100%;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
.title {
color: white;
margin: 0;
padding: 50px 20px;
}
a {
color: #fff !important;
text-transform: uppercase;
text-decoration: none;
background: #ed3330;
padding: 20px;
border-radius: 5px;
display: inline-block;
border: none;
transition: all 0.4s ease 0s;
&:hover {
background: #fff;
color: #ed3330 !important;
letter-spacing: 1px;
-webkit-box-shadow: 0px 5px 40px -10px rgba(0,0,0,0.57);
-moz-box-shadow: 0px 5px 40px -10px rgba(0,0,0,0.57);
box-shadow: 5px 40px -10px rgba(0,0,0,0.57);
transition: all 0.4s ease 0s;
.title {
color: white;
margin: 0;
padding: 50px 20px;
}
a {
color: #fff !important;
text-transform: uppercase;
text-decoration: none;
background: #ed3330;
padding: 20px;
border-radius: 5px;
display: inline-block;
border: none;
transition: all 0.4s ease 0s;
&:hover {
background: #fff;
color: #ed3330 !important;
letter-spacing: 1px;
-webkit-box-shadow: 0px 5px 40px -10px rgba(0, 0, 0, 0.57);
-moz-box-shadow: 0px 5px 40px -10px rgba(0, 0, 0, 0.57);
box-shadow: 5px 40px -10px rgba(0, 0, 0, 0.57);
transition: all 0.4s ease 0s;
}
}
}
}
.full-width {
width: 100%;
width: 100%;
}
.mat-toolbar-row, .mat-toolbar-single-row {
height: 54px;
.mat-toolbar-row,
.mat-toolbar-single-row {
height: 54px;
}
:not(input):not(textarea),
:not(input):not(textarea)::after,
:not(input):not(textarea)::before {
-webkit-user-select: none;
user-select: none;
cursor: default;
}
input, button, textarea, :focus {
input,
button,
textarea,
:focus {
outline: none;
}
}
.dark-theme .mat-expansion-panel,
.dark-theme .mat-drawer,
.dark-theme .mat-app-background,
.dark-theme.mat-app-background {
background-color: #1d1d1d;
}
.dark-theme {
::-webkit-scrollbar {
overflow-y: scroll;
background: rgb(100, 100, 100);
}
::-webkit-scrollbar-thumb {
background-color: rgb(60, 60, 60);
}
}
+48
View File
@@ -0,0 +1,48 @@
$black: (
50: #000000,
100: #000000,
200: #000000,
300: #000000,
400: #000000,
500: #000000,
600: #000000,
700: #000000,
800: #000000,
900: #000000,
A100: #000000,
A200: #000000,
A400: #000000,
A700: #000000,
contrast: (
50: $light-primary-text,
100: $light-primary-text,
200: $light-primary-text,
300: $light-primary-text,
400: $light-primary-text,
500: $light-primary-text,
600: $light-primary-text,
700: $light-primary-text,
800: $light-primary-text,
900: $light-primary-text,
A100: $light-primary-text,
A200: $light-primary-text,
A400: $light-primary-text,
A700: $light-primary-text,
),
);
$angular-primary: mat-palette($black);
$angular-accent: mat-palette($mat-indigo, 500, 600, 700);
$angular-warn: mat-palette($mat-red);
$angular-default-theme: mat-light-theme(
(
color: (
primary: $angular-primary,
accent: $angular-accent,
warn: $angular-warn,
),
)
);
@include angular-material-theme($angular-default-theme);
@import 'dark';
+16
View File
@@ -0,0 +1,16 @@
$dark-primary: mat-palette($black, A200, A800, A500);
$dark-accent: mat-palette($mat-teal, A400, A100, A400);
$dark-warn: mat-palette($mat-red);
$dark-theme: mat-dark-theme(
(
color: (
primary: $dark-primary,
accent: $dark-accent,
warn: $dark-warn,
),
)
);
.dark-theme {
@include angular-material-color($dark-theme);
}