From e896af037303990c95d95efac7296365e8c714ee Mon Sep 17 00:00:00 2001 From: 4gray Date: Wed, 13 Jan 2021 22:25:16 +0100 Subject: [PATCH] feat: integrate epg feature --- src/app/app.component.spec.ts | 15 ++- src/app/app.component.ts | 68 +++++++++++++- .../channel-list-container.component.spec.ts | 3 + .../channel-list-container.component.ts | 5 +- .../epg-list/epg-list.component.html | 33 +++++++ .../epg-list/epg-list.component.scss | 25 +++++ .../epg-list/epg-list.component.spec.ts | 31 +++++++ .../components/epg-list/epg-list.component.ts | 92 +++++++++++++++++++ .../video-player/video-player.component.html | 21 ++++- .../video-player.component.spec.ts | 2 + .../video-player/video-player.component.ts | 8 ++ src/app/player/player.module.ts | 2 + src/app/settings/settings.component.html | 29 ++++++ src/app/settings/settings.component.ts | 36 +++++++- src/app/settings/settings.interface.ts | 1 + src/app/state/channel.store.ts | 23 ++++- 16 files changed, 380 insertions(+), 14 deletions(-) create mode 100644 src/app/player/components/epg-list/epg-list.component.html create mode 100644 src/app/player/components/epg-list/epg-list.component.scss create mode 100644 src/app/player/components/epg-list/epg-list.component.spec.ts create mode 100644 src/app/player/components/epg-list/epg-list.component.ts diff --git a/src/app/app.component.spec.ts b/src/app/app.component.spec.ts index 0fd7606a0..e6a925ec0 100644 --- a/src/app/app.component.spec.ts +++ b/src/app/app.component.spec.ts @@ -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(); })); diff --git a/src/app/app.component.ts b/src/app/app.component.ts index d00afc0e0..3d75f3318 100644 --- a/src/app/app.component.ts +++ b/src/app/app.component.ts @@ -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' + ); + } } diff --git a/src/app/player/components/channel-list-container/channel-list-container.component.spec.ts b/src/app/player/components/channel-list-container/channel-list-container.component.spec.ts index 2418f2ece..083218a79 100644 --- a/src/app/player/components/channel-list-container/channel-list-container.component.spec.ts +++ b/src/app/player/components/channel-list-container/channel-list-container.component.spec.ts @@ -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), diff --git a/src/app/player/components/channel-list-container/channel-list-container.component.ts b/src/app/player/components/channel-list-container/channel-list-container.component.ts index d2853cb1f..693c59a33 100644 --- a/src/app/player/components/channel-list-container/channel-list-container.component.ts +++ b/src/app/player/components/channel-list-container/channel-list-container.component.ts @@ -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); } /** diff --git a/src/app/player/components/epg-list/epg-list.component.html b/src/app/player/components/epg-list/epg-list.component.html new file mode 100644 index 000000000..c4a3f4918 --- /dev/null +++ b/src/app/player/components/epg-list/epg-list.component.html @@ -0,0 +1,33 @@ + +
+ +
+ {{ channel?.name[0]?.value }} +
+
+ + +
+ {{ item.start }} - {{ item.stop }} +
+

{{ item?.title[0]?.value }}

+ +
+
+
+ + + + Ooops, EPG is not available for this channel.
+ Please add/change the url of EPG list the settings of the app. +
+
+
diff --git a/src/app/player/components/epg-list/epg-list.component.scss b/src/app/player/components/epg-list/epg-list.component.scss new file mode 100644 index 000000000..5e03280b4 --- /dev/null +++ b/src/app/player/components/epg-list/epg-list.component.scss @@ -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; +} \ No newline at end of file diff --git a/src/app/player/components/epg-list/epg-list.component.spec.ts b/src/app/player/components/epg-list/epg-list.component.spec.ts new file mode 100644 index 000000000..c1e974cd8 --- /dev/null +++ b/src/app/player/components/epg-list/epg-list.component.spec.ts @@ -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; + + 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(); + }); +}); diff --git a/src/app/player/components/epg-list/epg-list.component.ts b/src/app/player/components/epg-list/epg-list.component.ts new file mode 100644 index 000000000..63432dd5e --- /dev/null +++ b/src/app/player/components/epg-list/epg-list.component.ts @@ -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 + ); + } +} diff --git a/src/app/player/components/video-player/video-player.component.html b/src/app/player/components/video-player/video-player.component.html index b5caf3d7d..aaa37ca2d 100644 --- a/src/app/player/components/video-player/video-player.component.html +++ b/src/app/player/components/video-player/video-player.component.html @@ -19,7 +19,6 @@ {{ activeChannel.name }}
-
@@ -65,4 +72,14 @@ > + + + + diff --git a/src/app/player/components/video-player/video-player.component.spec.ts b/src/app/player/components/video-player/video-player.component.spec.ts index 5a4a61e78..2059c6a4a 100644 --- a/src/app/player/components/video-player/video-player.component.spec.ts +++ b/src/app/player/components/video-player/video-player.component.spec.ts @@ -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), diff --git a/src/app/player/components/video-player/video-player.component.ts b/src/app/player/components/video-player/video-player.component.ts index d0c19bfb5..4d6c4e259 100644 --- a/src/app/player/components/video-player/video-player.component.ts +++ b/src/app/player/components/video-player/video-player.component.ts @@ -31,6 +31,11 @@ export class VideoPlayerComponent implements OnInit { /** Name of the selected channel */ channelTitle: string; + /** EPG availability flag */ + epgAvailable$: Observable = this.channelQuery.select( + (store) => store.epgAvailable + ); + /** Favorites list */ favorites$: Observable = 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 diff --git a/src/app/player/player.module.ts b/src/app/player/player.module.ts index 07edb41a5..10b9595b1 100644 --- a/src/app/player/player.module.ts +++ b/src/app/player/player.module.ts @@ -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, diff --git a/src/app/settings/settings.component.html b/src/app/settings/settings.component.html index c033f0842..cdc33e37a 100644 --- a/src/app/settings/settings.component.html +++ b/src/app/settings/settings.component.html @@ -12,6 +12,35 @@ + +
+

EPG Url (*.xml or *.xml.gz)

+

+ + + + +

+
+

+

Video player

diff --git a/src/app/settings/settings.component.ts b/src/app/settings/settings.component.ts index 15ecbd018..23e597274 100644 --- a/src/app/settings/settings.component.ts +++ b/src/app/settings/settings.component.ts @@ -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 = 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', + }); + } } diff --git a/src/app/settings/settings.interface.ts b/src/app/settings/settings.interface.ts index 74a94b8c4..b954ce5a9 100644 --- a/src/app/settings/settings.interface.ts +++ b/src/app/settings/settings.interface.ts @@ -5,4 +5,5 @@ export type VideoPlayerType = 'html5' | 'videojs'; */ export interface Settings { player: VideoPlayerType; + epgUrl: string; } diff --git a/src/app/state/channel.store.ts b/src/app/state/channel.store.ts index aff0450e3..4e939ad2c 100644 --- a/src/app/state/channel.store.ts +++ b/src/app/state/channel.store.ts @@ -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 { + active: Channel; + epgAvailable: boolean; favorites: string[]; playlistId: string; - active: Channel; } @Injectable({ providedIn: 'root' }) @@ -14,13 +16,14 @@ export interface ChannelState extends EntityState { export class ChannelStore extends EntityStore { /** * 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 { 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, + }); + } }