feat: integrate epg feature

This commit is contained in:
4gray committed 2021-01-13 22:25:26 +01:00
1 parent 1665a44590
commit e896af0373
16 files changed
+380 -14

No files matched your search

+13 -2
View File
@@ -4,6 +4,8 @@ import { AppComponent } from './app.component';
import { TranslateModule } from '@ngx-translate/core';
import { ElectronService } from './services/electron.service';
import { ElectronServiceStub } from './home/home.component.spec';
import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar';
import { MockModule } from 'ng-mocks';
jest.mock('custom-electron-titlebar', () => {
return {
@@ -16,14 +18,23 @@ jest.mock('custom-electron-titlebar', () => {
};
});
describe('AppComponen t', () => {
class MatSnackBarStub {
open(): void {}
}
describe('AppComponent', () => {
beforeEach(async(() => {
TestBed.configureTestingModule({
declarations: [AppComponent],
providers: [
{ provide: ElectronService, useClass: ElectronServiceStub },
{ provide: MatSnackBar, useClass: MatSnackBarStub },
],
imports: [
MockModule(MatSnackBarModule),
RouterTestingModule,
TranslateModule.forRoot(),
],
imports: [RouterTestingModule, TranslateModule.forRoot()],
}).compileComponents();
}));
+67 -1
View File
@@ -5,6 +5,11 @@ import { akitaDevtools } from '@datorama/akita';
import { Titlebar, Color } from 'custom-electron-titlebar';
import { ElectronService } from './services/electron.service';
import { Router } from '@angular/router';
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 './shared/ipc-commands';
// create custom title bar
new Titlebar({
@@ -20,10 +25,13 @@ new Titlebar({
})
export class AppComponent {
constructor(
private channelStore: ChannelStore,
private electronService: ElectronService,
private ngZone: NgZone,
private router: Router,
private translate: TranslateService
private translate: TranslateService,
private snackBar: MatSnackBar,
private storage: StorageMap
) {
if (!AppConfig.production) {
akitaDevtools(ngZone);
@@ -51,6 +59,32 @@ export class AppComponent {
});
});
this.electronService.ipcRenderer
.on(EPG_FETCH_DONE, () => {
this.ngZone.run(() => {
this.channelStore.update((store) => ({
...store,
epgAvailable: true,
}));
this.snackBar.open(
'EPG was successfully downloaded!',
null,
{
duration: 2000,
verticalPosition: 'bottom',
horizontalPosition: 'right',
}
);
});
})
.on(EPG_ERROR, () => {
this.snackBar.open('EPG Error: something went wrong...', null, {
duration: 2000,
verticalPosition: 'bottom',
horizontalPosition: 'right',
});
});
if (
(this.electronService.remote.process.platform === 'linux' ||
this.electronService.remote.process.platform === 'win32') &&
@@ -70,4 +104,36 @@ export class AppComponent {
}
}
}
/**
* Subscribes for app settings on component init
*/
ngOnInit(): void {
this.storage.get('settings').subscribe((settings: Settings) => {
if (
settings &&
Object.keys(settings).length > 0 &&
settings.epgUrl
) {
this.electronService.ipcRenderer.send(EPG_FETCH, {
url: settings.epgUrl,
});
this.snackBar.open('Fetch EPG data...', 'Close', {
verticalPosition: 'bottom',
horizontalPosition: 'right',
});
}
});
}
/**
* Removes all ipc command listeners on component destroy
*/
ngOnDestroy(): void {
this.electronService.ipcRenderer.removeAllListeners(EPG_FETCH_DONE);
this.electronService.ipcRenderer.removeAllListeners(EPG_ERROR);
this.electronService.ipcRenderer.removeAllListeners(
'add-playlist-view'
);
}
}
@@ -16,6 +16,8 @@ import { FilterPipeModule } from 'ngx-filter-pipe';
import { By } from '@angular/platform-browser';
import { createChannel } from '../../../state';
import * as MOCKED_PLAYLIST from '../../../../mocks/playlist.json';
import { ElectronService } from '../../../services/electron.service';
import { ElectronServiceStub } from '../../../home/home.component.spec';
class MatSnackBarStub {
open(): void {}
@@ -32,6 +34,7 @@ describe('ChannelListContainerComponent', () => {
providers: [
ChannelQuery,
{ provide: MatSnackBar, useClass: MatSnackBarStub },
{ provide: ElectronService, useClass: ElectronServiceStub },
],
imports: [
MockModule(MatSnackBarModule),
@@ -79,10 +79,7 @@ export class ChannelListContainerComponent {
*/
selectChannel(channel: Channel): void {
this.selected = channel;
this.channelStore.update((store) => ({
...store,
active: channel,
}));
this.channelStore.setActiveChannel(channel);
}
/**
@@ -0,0 +1,33 @@
<ng-container *ngIf="channel; else noEpg">
<div id="channel-info" fxLayoutAlign="row">
<img
fxFlex="48px"
*ngIf="channel?.icon"
[src]="channel.icon"
width="48"
/>
<div fxFlex="100" fxLayoutAlign="start center" class="channel-name">
{{ channel?.name[0]?.value }}
</div>
</div>
<mat-list>
<mat-list-item
*ngFor="let item of items"
[class.active]="timeNow >= item.start && timeNow <= item.stop"
>
<div matLine>
<span class="time">{{ item.start }} - {{ item.stop }}</span>
</div>
<p matLine>{{ item?.title[0]?.value }}</p>
<mat-divider></mat-divider>
</mat-list-item>
</mat-list>
</ng-container>
<ng-template #noEpg>
<mat-list>
<mat-list-item>
Ooops, EPG is not available for this channel.<br />
Please add/change the url of EPG list the settings of the app.
</mat-list-item>
</mat-list>
</ng-template>
@@ -0,0 +1,25 @@
.active {
background-color: #ddd;
}
.time {
color: #666;
border-bottom: 1px solid #ccc;
font-size: 0.8em;
display: inline-block;
margin-bottom: 5px;
}
#channel-info {
padding:10px;
border-bottom: 1px solid #ccc;
}
.channel-name {
padding-left: 10px
}
mat-list {
height: calc(100vh - 110px);
overflow: auto;
}
@@ -0,0 +1,31 @@
import { async, ComponentFixture, TestBed } from '@angular/core/testing';
import { EpgListComponent } from './epg-list.component';
import { MatListModule } from '@angular/material/list';
import { MockModule } from 'ng-mocks';
import { ElectronService } from 'app/services/electron.service';
import { ElectronServiceStub } from 'app/home/home.component.spec';
describe('EpgListComponent', () => {
let component: EpgListComponent;
let fixture: ComponentFixture<EpgListComponent>;
beforeEach(async(() => {
TestBed.configureTestingModule({
declarations: [EpgListComponent],
imports: [MockModule(MatListModule)],
providers: [
{ provide: ElectronService, useClass: ElectronServiceStub },
],
}).compileComponents();
}));
beforeEach(() => {
fixture = TestBed.createComponent(EpgListComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});
@@ -0,0 +1,92 @@
import { Component, NgZone } from '@angular/core';
import { EpgChannel } from '../../models/epg-channel.model';
import { EpgProgram } from '../../models/epg-program.model';
import * as moment from 'moment';
import { ElectronService } from '../../../services/electron.service';
import { EPG_GET_PROGRAM_DONE } from '../../../shared/ipc-commands';
interface EpgData {
channel: EpgChannel;
items: EpgProgram[];
}
@Component({
selector: 'app-epg-list',
templateUrl: './epg-list.component.html',
styleUrls: ['./epg-list.component.scss'],
})
export class EpgListComponent {
/** Channel info in EPG format */
channel: EpgChannel;
/** Today as formatted date string */
dateToday: string;
/** Array with EPG programs */
items: EpgProgram[] = [];
/** Object with epg programs for the active channel */
programs: {
payload: EpgData;
};
/** Current time as formatted string */
timeNow: string;
/**
* Creates an instance of EpgListComponent
* @param electronService
* @param ngZone
*/
constructor(
private electronService: ElectronService,
private ngZone: NgZone
) {
this.electronService.ipcRenderer.on(
EPG_GET_PROGRAM_DONE,
(event, response) => {
this.ngZone.run(() => this.handleEpgData(response));
}
);
}
/**
* Handles incoming epg programs for the active channel from the main process
* @param programs
*/
handleEpgData(programs: { payload: EpgData }): void {
if (programs.payload?.items?.length > 0) {
this.timeNow = moment(Date.now()).format('HH:mm');
this.dateToday = moment(Date.now()).format('YYYYMMDD');
this.channel = programs.payload?.channel;
this.items = programs.payload?.items
.filter((item) =>
item.start.includes(this.dateToday.toString())
)
.map((program) => ({
...program,
start: moment(program.start, 'YYYYMMDDHHmm ZZ').format(
'HH:mm'
),
stop: moment(program.stop, 'YYYYMMDDHHmm ZZ').format(
'HH:mm'
),
}))
.sort((a, b) => {
return a.start.localeCompare(b.start);
});
} else {
this.items = [];
this.channel = null;
}
}
/**
* Removes all ipc renderer listeners after destroy
*/
ngOnDestroy(): void {
this.electronService.ipcRenderer.removeAllListeners(
EPG_GET_PROGRAM_DONE
);
}
}
@@ -19,7 +19,6 @@
<mat-toolbar color="primary">
<button
mat-icon-button
mat-toolbar="Add to favorites"
(click)="drawer.toggle()"
matTooltip="Open channels list"
>
@@ -40,8 +39,16 @@
</button>
{{ activeChannel.name }}
<div class="spacer"></div>
<button mat-icon-button (click)="openAbout()">
<!-- <button mat-icon-button (click)="openAbout()">
<mat-icon>info</mat-icon>
</button> -->
<button
*ngIf="epgAvailable$ | async"
mat-button
(click)="drawerRight.toggle()"
matTooltip="Open EPG list"
>
EPG
</button>
</mat-toolbar>
@@ -65,4 +72,14 @@
>
</app-html-video-player>
</mat-drawer-content>
<!-- right sidebar content -->
<mat-drawer
position="end"
#drawerRight
mode="side"
(keydown.escape)="close()"
disableClose
>
<app-epg-list></app-epg-list>
</mat-drawer>
</mat-drawer-container>
@@ -12,6 +12,7 @@ import { ChannelStore } from '../../../state/channel.store';
import * as MOCKED_PLAYLIST from '../../../../mocks/playlist.json';
import { createChannel } from '../../../state';
import { HtmlVideoPlayerComponent } from '../html-video-player/html-video-player.component';
import { EpgListComponent } from '../epg-list/epg-list.component';
class MatSnackBarStub {
open(): void {}
@@ -26,6 +27,7 @@ describe('VideoPlayerComponent', () => {
beforeEach(async(() => {
TestBed.configureTestingModule({
declarations: [
MockComponent(EpgListComponent),
MockComponent(HtmlVideoPlayerComponent),
MockComponent(VjsPlayerComponent),
MockComponent(VideoPlayerComponent),
@@ -31,6 +31,11 @@ export class VideoPlayerComponent implements OnInit {
/** Name of the selected channel */
channelTitle: string;
/** EPG availability flag */
epgAvailable$: Observable<boolean> = this.channelQuery.select(
(store) => store.epgAvailable
);
/** Favorites list */
favorites$: Observable<string[]> = this.channelQuery.select(
(store) => store.favorites
@@ -42,6 +47,9 @@ export class VideoPlayerComponent implements OnInit {
/** Sidebar object */
@ViewChild('sidenav') sideNav: MatSidenav;
// DEV
epgPrograms = [];
/**
* Creates an instance of VideoPlayerComponent
* @param channelQuery akita's channel query
+2
View File
@@ -6,6 +6,7 @@ import { VideoPlayerComponent } from './components/video-player/video-player.com
import { VjsPlayerComponent } from './components/vjs-player/vjs-player.component';
import { RouterModule, Routes } from '@angular/router';
import { SharedModule } from 'app/shared/shared.module';
import { EpgListComponent } from './components/epg-list/epg-list.component';
const routes: Routes = [{ path: '', component: VideoPlayerComponent }];
@@ -13,6 +14,7 @@ const routes: Routes = [{ path: '', component: VideoPlayerComponent }];
imports: [CommonModule, RouterModule.forChild(routes), SharedModule],
declarations: [
ChannelListContainerComponent,
EpgListComponent,
HtmlVideoPlayerComponent,
VideoPlayerComponent,
VjsPlayerComponent,
+29
View File
@@ -12,6 +12,35 @@
</mat-card-header>
<mat-card-content>
<mat-list>
<mat-list-item>
<div matLine fxLayout="row">
<p fxFlex>EPG Url (*.xml or *.xml.gz)</p>
<p fxFlex fxLayoutAlign="start center">
<mat-form-field class="full-width">
<input
placeholder="Enter a URL"
matInput
type="url"
formControlName="epgUrl"
/>
</mat-form-field>
<button
*ngIf="
(epgAvailable$ | async) &&
settingsForm.value.epgUrl
"
mat-icon-button
color="primary"
matTooltip="Refresh EPG"
type="button"
(click)="fetchEpg()"
>
<mat-icon>refresh</mat-icon>
</button>
</p>
</div>
<p matLine></p>
</mat-list-item>
<mat-list-item>
<div matLine fxLayout="row">
<p fxFlex>Video player</p>
+34 -2
View File
@@ -3,11 +3,13 @@ import { FormGroup, FormBuilder } from '@angular/forms';
import { StorageMap } from '@ngx-pwa/local-storage';
import { Router } from '@angular/router';
import { MatSnackBar } from '@angular/material/snack-bar';
import { Subscription } from 'rxjs';
import { Observable, Subscription } from 'rxjs';
import { Settings } from './settings.interface';
import { HttpClient } from '@angular/common/http';
import * as semver from 'semver';
import { ElectronService } from 'app/services/electron.service';
import { ChannelQuery } from 'app/state';
import { EPG_FETCH } from 'app/shared/ipc-commands';
@Component({
selector: 'app-settings',
@@ -43,14 +45,23 @@ export class SettingsComponent implements OnInit, OnDestroy {
/** Update message to show */
updateMessage: string;
/** EPG availability flag */
epgAvailable$: Observable<boolean> = this.channelQuery.select(
(store) => store.epgAvailable
);
/**
* Creates an instance of SettingsComponent and injects some dependencies into the component
* @param channelQuery
* @param electronService
* @param formBuilder
* @param http
* @param router
* @param snackBar
* @param storage
*/
constructor(
private channelQuery: ChannelQuery,
private electronService: ElectronService,
private formBuilder: FormBuilder,
private http: HttpClient,
@@ -60,6 +71,7 @@ export class SettingsComponent implements OnInit, OnDestroy {
) {
this.settingsForm = this.formBuilder.group({
player: ['html5'], // default value
epgUrl: '',
});
this.subscription.add(
@@ -88,7 +100,10 @@ export class SettingsComponent implements OnInit, OnDestroy {
this.subscription.add(
this.storage.get('settings').subscribe((settings: Settings) => {
if (settings) {
this.settingsForm.setValue(settings);
this.settingsForm.setValue({
player: settings.player ? settings.player : 'html5',
epgUrl: settings.epgUrl ? settings.epgUrl : '',
});
}
})
);
@@ -110,6 +125,10 @@ export class SettingsComponent implements OnInit, OnDestroy {
duration: 2000,
}
);
// check whether the epg url was changed or not
if (this.settingsForm.value.epgUrl) {
this.fetchEpg();
}
})
);
}
@@ -127,4 +146,17 @@ export class SettingsComponent implements OnInit, OnDestroy {
ngOnDestroy(): void {
this.subscription.unsubscribe();
}
/**
* Fetches and updates EPG from the given URL
*/
fetchEpg(): void {
this.electronService.ipcRenderer.send(EPG_FETCH, {
url: this.settingsForm.value.epgUrl,
});
this.snackBar.open('Fetch EPG data...', 'Close', {
verticalPosition: 'bottom',
horizontalPosition: 'right',
});
}
}
+1
View File
@@ -5,4 +5,5 @@ export type VideoPlayerType = 'html5' | 'videojs';
*/
export interface Settings {
player: VideoPlayerType;
epgUrl: string;
}
+20 -3
View File
@@ -1,12 +1,14 @@
import { Injectable } from '@angular/core';
import { EntityState, EntityStore, StoreConfig } from '@datorama/akita';
import { EPG_GET_PROGRAM } from '../shared/ipc-commands';
import { ElectronService } from '../services/electron.service';
import { Channel } from './channel.model';
export interface ChannelState extends EntityState<Channel> {
active: Channel;
epgAvailable: boolean;
favorites: string[];
playlistId: string;
active: Channel;
}
@Injectable({ providedIn: 'root' })
@@ -14,13 +16,14 @@ export interface ChannelState extends EntityState<Channel> {
export class ChannelStore extends EntityStore<ChannelState> {
/**
* Creates an instance of ChannelStore
* @param channelStore channels store
* @param electronService electron service
*/
constructor(private electronService: ElectronService) {
super({
active: undefined,
epgAvailable: false,
favorites: [],
playlistId: '',
active: undefined,
});
}
@@ -45,4 +48,18 @@ export class ChannelStore extends EntityStore<ChannelState> {
return { favorites };
});
}
/**
* Updates selected/active channel in store
* @param channel selected channel
*/
setActiveChannel(channel: Channel): void {
this.update((store) => ({
...store,
active: channel,
}));
this.electronService.ipcRenderer.send(EPG_GET_PROGRAM, {
channelName: channel.name,
});
}
}