| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,7 @@ | ||
| { | ||
| "name": "iptvnator-electron", | ||
| "version": "0.10.0", | ||
| "main": "main.js", | ||
| "private": true, | ||
| "dependencies": {} | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,9 +1,4 @@ | ||
| .drop-container { | ||
| text-align: center; | ||
| color: #666; | ||
| } | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -18,3 +18,9 @@ | |
| width: 100%; | ||
| display: block; | ||
| } | ||
|
|
||
| @media only screen and (max-width: 480px) { | ||
| .tab-title { | ||
| display: none; | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,18 @@ | ||
| <form (ngSubmit)="textAdded.emit(textForm.value.text)" [formGroup]="textForm"> | ||
| <mat-form-field class="full-width" appearance="fill"> | ||
| <mat-label>{{ 'HOME.TEXT_IMPORT.LABEL' | translate }}</mat-label> | ||
| <textarea | ||
| formControlName="text" | ||
| matInput | ||
| placeholder="#EXTM3U url-tvg=..." | ||
| ></textarea> | ||
| </mat-form-field> | ||
| <button | ||
| mat-flat-button | ||
| color="primary" | ||
| type="submit" | ||
| [disabled]="!textForm.valid" | ||
| > | ||
| {{ 'HOME.TEXT_IMPORT.BUTTON_LABEL' | translate }} | ||
| </button> | ||
| </form> |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,8 @@ | ||
| button { | ||
| margin: 0 15px; | ||
| text-transform: uppercase; | ||
| } | ||
|
|
||
| textarea { | ||
| height: calc(100vh - 300px); | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,35 @@ | ||
| import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing'; | ||
| import { FormsModule, ReactiveFormsModule } from '@angular/forms'; | ||
| import { MatInputModule } from '@angular/material/input'; | ||
| import { TranslatePipe } from '@ngx-translate/core'; | ||
|
|
||
| import { MockModule, MockPipe } from 'ng-mocks'; | ||
| import { TextImportComponent } from './text-import.component'; | ||
|
|
||
| describe('TextImportComponent', () => { | ||
| let component: TextImportComponent; | ||
| let fixture: ComponentFixture<TextImportComponent>; | ||
|
|
||
| beforeEach( | ||
| waitForAsync(() => { | ||
| TestBed.configureTestingModule({ | ||
| declarations: [TextImportComponent, MockPipe(TranslatePipe)], | ||
| imports: [ | ||
| MockModule(MatInputModule), | ||
| MockModule(FormsModule), | ||
| MockModule(ReactiveFormsModule), | ||
| ], | ||
| }).compileComponents(); | ||
| }) | ||
| ); | ||
|
|
||
| beforeEach(() => { | ||
| fixture = TestBed.createComponent(TextImportComponent); | ||
| component = fixture.componentInstance; | ||
| fixture.detectChanges(); | ||
| }); | ||
|
|
||
| it('should create', () => { | ||
| expect(component).toBeTruthy(); | ||
| }); | ||
| }); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,15 @@ | ||
| import { Component, EventEmitter, Output } from '@angular/core'; | ||
| import { FormControl, FormGroup, Validators } from '@angular/forms'; | ||
|
|
||
| @Component({ | ||
| selector: 'app-text-import', | ||
| templateUrl: './text-import.component.html', | ||
| styleUrls: ['./text-import.component.scss'], | ||
| }) | ||
| export class TextImportComponent { | ||
| @Output() textAdded = new EventEmitter<string>(); | ||
|
|
||
| textForm = new FormGroup({ | ||
| text: new FormControl('', Validators.required), | ||
| }); | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,142 @@ | ||
| <div id="epg-navigation"> | ||
| <button | ||
| [matTooltip]="'CLOSE' | translate" | ||
| mat-button | ||
| (click)="close()" | ||
| color="accent" | ||
| > | ||
| <mat-icon>close</mat-icon> {{ 'CLOSE' | translate }} | ||
| </button> | ||
| <button | ||
| [matTooltip]="'EPG.NEXT_DAY' | translate" | ||
| mat-icon-button | ||
| (click)="switchDay('prev')" | ||
| > | ||
| <mat-icon>navigate_before</mat-icon> | ||
| </button> | ||
| <button | ||
| [matTooltip]="'EPG.PREVIOUS_DAY' | translate" | ||
| mat-icon-button | ||
| (click)="switchDay('next')" | ||
| > | ||
| <mat-icon>navigate_next</mat-icon> | ||
| </button> | ||
| <div class="today-date"> | ||
| {{ today | momentDate: 'YYYYMMDD':'MMMM Do, dddd' }} | ||
| </div> | ||
| <button mat-icon-button (click)="zoomIn()" [disabled]="hourWidth >= 800"> | ||
| <mat-icon>zoom_in</mat-icon> | ||
| </button> | ||
| <button mat-icon-button [disabled]="hourWidth <= 50" (click)="zoomOut()"> | ||
| <mat-icon>zoom_out</mat-icon> | ||
| </button> | ||
| <button | ||
| mat-button | ||
| [disabled]="channelsLowerRange === 0" | ||
| (click)="previousChannels()" | ||
| > | ||
| Previous channels | ||
| </button> | ||
| <button mat-button (click)="nextChannels()">Next channels</button> | ||
| </div> | ||
| <div class="parent" #epgContainer> | ||
| <svg id="channels-column"> | ||
| <g *ngFor="let item of channels; let i = index"> | ||
| <!-- channel name --> | ||
| <g class="channel"> | ||
| <rect | ||
| width="100" | ||
| [attr.height]="barHeight" | ||
| [attr.y]="barHeight * i + barHeight" | ||
| stroke="black" | ||
| ></rect> | ||
| <image | ||
| *ngIf="item.icon[0]" | ||
| [attr.y]="barHeight * i + barHeight + 1" | ||
| [attr.href]="item.icon[0]" | ||
| [attr.height]="barHeight - 2" | ||
| width="100" | ||
| [matTooltip]="item.name[0].value" | ||
| ></image> | ||
| <foreignObject | ||
| *ngIf="!item.icon[0]" | ||
| [attr.y]="barHeight * i + barHeight" | ||
| width="100" | ||
| [attr.height]="barHeight" | ||
| > | ||
| <div class="channel-name"> | ||
| {{ item.name[0].value }} | ||
| </div> | ||
| </foreignObject> | ||
| </g> | ||
| </g> | ||
| </svg> | ||
| <div id="epg-container"> | ||
| <svg [attr.width]="24 * hourWidth" height="100%" id="epg-svg"> | ||
| <!-- time headline --> | ||
| <g *ngFor="let a of timeHeader; let i = index"> | ||
| <rect | ||
| [matTooltip]="i + ':00'" | ||
| [attr.width]="hourWidth" | ||
| [attr.height]="barHeight" | ||
| [attr.x]="i * hourWidth" | ||
| fill="#000" | ||
| ></rect> | ||
| <text | ||
| [attr.x]="i * hourWidth" | ||
| y="10" | ||
| font-size="14" | ||
| fill="white" | ||
| transform="translate(20,20)" | ||
| > | ||
| {{ i }}:00 | ||
| </text> | ||
| </g> | ||
|
|
||
| <!-- epg channels with programs --> | ||
| <g *ngFor="let item of channels; let i = index"> | ||
| <g | ||
| *ngFor="let program of item.programs; let a = index" | ||
| [matTooltip]="program.title[0].value" | ||
| class="program-item" | ||
| > | ||
| <!-- program item --> | ||
| <rect | ||
| [attr.width]="program.width" | ||
| [attr.height]="barHeight" | ||
| [attr.x]="program.startPosition" | ||
| [attr.y]="barHeight * i + barHeight" | ||
| fill="#000" | ||
| ></rect> | ||
| <foreignObject | ||
| (click)="showDescription(program)" | ||
| [attr.width]="program.width" | ||
| [attr.height]="barHeight" | ||
| [attr.y]="barHeight * i + barHeight" | ||
| [attr.x]="program.startPosition" | ||
| > | ||
| <div | ||
| [innerHtml]="program.title[0]?.value" | ||
| class="program-name" | ||
| ></div> | ||
| </foreignObject> | ||
| </g> | ||
| <line | ||
| [attr.x1]="0" | ||
| [attr.y1]="barHeight * i + barHeight" | ||
| [attr.x2]="24 * hourWidth" | ||
| [attr.y2]="barHeight * i + barHeight" | ||
| stroke="#676767" | ||
| /> | ||
| </g> | ||
| <line | ||
| [attr.x1]="currentTimeLine" | ||
| [attr.y1]="barHeight" | ||
| [attr.x2]="currentTimeLine" | ||
| id="current-time-line" | ||
| y2="1000" | ||
| stroke="black" | ||
| /> | ||
| </svg> | ||
| </div> | ||
| </div> |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,70 @@ | ||
| .program-item { | ||
| cursor: pointer; | ||
| } | ||
|
|
||
| rect { | ||
| stroke: #676767; | ||
| cursor: pointer; | ||
| } | ||
|
|
||
| .channel > rect { | ||
| stroke: #676767; | ||
| } | ||
|
|
||
| .program-item:hover > rect { | ||
| fill: #333; | ||
| } | ||
|
|
||
| .program-name, | ||
| .channel-name { | ||
| -webkit-line-clamp: 2; | ||
| height: 32px; | ||
| overflow: hidden; | ||
| text-overflow: ellipsis; | ||
| display: -webkit-box; | ||
| -webkit-box-orient: vertical; | ||
| padding: 6px 2px; | ||
| } | ||
|
|
||
| #epg-container { | ||
| width: calc(100vw - 100px); | ||
| height: calc(100vh - 73px); | ||
| overflow-x: scroll; | ||
| overflow-y: hidden; | ||
| } | ||
|
|
||
| .parent { | ||
| display: flex; | ||
| background-color: black; | ||
| } | ||
|
|
||
| #channels-column { | ||
| width: 100px; | ||
| flex: none; | ||
| border-right: 2px solid #ccc; | ||
| } | ||
|
|
||
| .channel-name { | ||
| margin: 0 5px; | ||
| text-align: center; | ||
| } | ||
|
|
||
| #epg-navigation { | ||
| display: flex; | ||
| background-color: black; | ||
| margin-top: 30px; | ||
| border-bottom: 2px solid #fff; | ||
| } | ||
|
|
||
| .today-date { | ||
| flex: 1; | ||
| align-items: center; | ||
| line-height: 40px; | ||
| height: 40px; | ||
| padding: 0 20px; | ||
| } | ||
|
|
||
| #current-time-line { | ||
| stroke-width: 2px; | ||
| stroke: white; | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,212 @@ | ||
| import { OverlayRef } from '@angular/cdk/overlay'; | ||
| import { | ||
| AfterViewInit, | ||
| Component, | ||
| ElementRef, | ||
| Inject, | ||
| NgZone, | ||
| OnInit, | ||
| ViewChild, | ||
| } from '@angular/core'; | ||
| import { MatDialog } from '@angular/material/dialog'; | ||
| import { addDays, differenceInMinutes, format, parse, subDays } from 'date-fns'; | ||
| import { | ||
| EPG_GET_CHANNELS_BY_RANGE, | ||
| EPG_GET_CHANNELS_BY_RANGE_RESPONSE, | ||
| } from '../../../../../shared/ipc-commands'; | ||
| import { DataService } from '../../../services/data.service'; | ||
| import { EpgChannel } from '../../models/epg-channel.model'; | ||
| import { EpgProgram } from '../../models/epg-program.model'; | ||
| import { EpgItemDescriptionComponent } from '../epg-list/epg-item-description/epg-item-description.component'; | ||
| import { COMPONENT_OVERLAY_REF } from '../video-player/video-player.component'; | ||
|
|
||
| @Component({ | ||
| selector: 'app-multi-epg-container', | ||
| templateUrl: './multi-epg-container.component.html', | ||
| styleUrls: ['./multi-epg-container.component.scss'], | ||
| }) | ||
| export class MultiEpgContainerComponent implements OnInit, AfterViewInit { | ||
| @ViewChild('epgContainer') epgContainer: ElementRef; | ||
| timeHeader = new Array(24); | ||
| hourWidth = 150; | ||
| barHeight = 50; | ||
| originalEpgData: (EpgChannel & { programs: EpgProgram[] })[] = []; | ||
| channels: (EpgChannel & { programs: EpgProgram[] })[] = []; | ||
| today = format(new Date(), 'yyyyMMdd'); | ||
| currentTimeLine = 0; | ||
| visibleChannels; | ||
| channelsLowerRange = 0; | ||
| channelsUpperRange; | ||
|
|
||
| constructor( | ||
| private dataService: DataService, | ||
| private dialog: MatDialog, | ||
| private ngZone: NgZone, | ||
| @Inject(COMPONENT_OVERLAY_REF) private overlayRef: OverlayRef | ||
| ) { | ||
| this.dataService.listenOn( | ||
| EPG_GET_CHANNELS_BY_RANGE_RESPONSE, | ||
| (event, response) => | ||
| this.ngZone.run(() => { | ||
| if (response) { | ||
| this.originalEpgData = response.payload; | ||
| this.channels = this.enrichProgramData(); | ||
| } | ||
| }) | ||
| ); | ||
| } | ||
|
|
||
| ngOnInit(): void { | ||
| this.calculateCurrentTimeBar(); | ||
| } | ||
|
|
||
| ngAfterViewInit(): void { | ||
| const timeNow = new Date(); | ||
| const scrollPosition = | ||
| (timeNow.getHours() + timeNow.getMinutes() / 60) * this.hourWidth; | ||
| document | ||
| .getElementById('epg-container')! | ||
| .scrollTo(scrollPosition < 1000 ? 0 : scrollPosition - 150, 0); | ||
|
|
||
| const borderInPx = | ||
| this.epgContainer.nativeElement.offsetHeight / this.barHeight; | ||
| this.visibleChannels = Math.floor( | ||
| (this.epgContainer.nativeElement.offsetHeight - borderInPx) / | ||
| this.barHeight - | ||
| 1 | ||
| ); | ||
| this.channelsUpperRange = this.visibleChannels; | ||
| this.requestPrograms(); | ||
| } | ||
|
|
||
| nextChannels(): void { | ||
| this.channelsLowerRange = this.channelsUpperRange; | ||
| this.channelsUpperRange = | ||
| this.channelsUpperRange + this.visibleChannels; | ||
| this.channels = []; | ||
| this.requestPrograms(); | ||
| } | ||
|
|
||
| previousChannels(): void { | ||
| this.channelsUpperRange = | ||
| this.channelsUpperRange - this.visibleChannels; | ||
| this.channelsLowerRange = | ||
| this.channelsUpperRange - this.visibleChannels; | ||
|
|
||
| this.requestPrograms(); | ||
| } | ||
|
|
||
| requestPrograms() { | ||
| this.dataService.sendIpcEvent(EPG_GET_CHANNELS_BY_RANGE, { | ||
| limit: this.channelsUpperRange, | ||
| skip: this.channelsLowerRange, | ||
| }); | ||
| } | ||
|
|
||
| enrichProgramData() { | ||
| return this.originalEpgData.map((channel) => { | ||
| return { | ||
| ...channel, | ||
| programs: channel.programs | ||
| .filter((item) => item.start.includes(this.today)) | ||
| .map((program) => { | ||
| const startDate = parse( | ||
| program.start, | ||
| 'yyyyMMddHHmmss XXXX', | ||
| addDays(new Date(), 1) | ||
| ); | ||
| const stopDate = parse( | ||
| program.stop, | ||
| 'yyyyMMddHHmmss XXXX', | ||
| addDays(new Date(), 1) | ||
| ); | ||
| return { | ||
| ...program, | ||
| startDate, | ||
| stopDate, | ||
| startPosition: this.positionToStartInPx(startDate), | ||
| width: this.programDurationInPx( | ||
| startDate, | ||
| stopDate | ||
| ), | ||
| }; | ||
| }), | ||
| }; | ||
| }); | ||
| } | ||
|
|
||
| positionToStartInPx(startDate: Date) { | ||
| return ( | ||
| (startDate.getHours() + startDate.getMinutes() / 60) * | ||
| this.hourWidth | ||
| ); | ||
| } | ||
|
|
||
| programDurationInPx(startDate: Date, stopDate: Date) { | ||
| const duration = differenceInMinutes(stopDate, startDate); | ||
| return (duration * this.hourWidth) / 60; | ||
| } | ||
|
|
||
| recalculate(): void { | ||
| this.channels.forEach((channel) => { | ||
| channel.programs = channel.programs.map((program: any) => { | ||
| return { | ||
| ...program, | ||
| startPosition: this.positionToStartInPx(program.startDate), | ||
| width: this.programDurationInPx( | ||
| program.startDate, | ||
| program.stopDate | ||
| ), | ||
| }; | ||
| }); | ||
| }); | ||
| } | ||
|
|
||
| zoomIn(): void { | ||
| this.hourWidth += 50; | ||
| this.recalculate(); | ||
| this.calculateCurrentTimeBar(); | ||
| } | ||
|
|
||
| zoomOut(): void { | ||
| if (this.hourWidth <= 50) return; | ||
| this.hourWidth -= 50; | ||
| this.recalculate(); | ||
| this.calculateCurrentTimeBar(); | ||
| } | ||
|
|
||
| calculateCurrentTimeBar(): void { | ||
| const timeNow = new Date(); | ||
| this.currentTimeLine = | ||
| (timeNow.getHours() + timeNow.getMinutes() / 60) * this.hourWidth; | ||
| } | ||
|
|
||
| switchDay(direction: 'prev' | 'next'): void { | ||
| this.today = | ||
| direction === 'prev' | ||
| ? format( | ||
| subDays(parse(this.today, 'yyyyMMdd', new Date()), 1), | ||
| 'yyyyMMdd' | ||
| ) | ||
| : format( | ||
| addDays(parse(this.today, 'yyyyMMdd', new Date()), 1), | ||
| 'yyyyMMdd' | ||
| ); | ||
| this.calculateCurrentTimeBar(); | ||
| this.channels = this.enrichProgramData(); | ||
| } | ||
|
|
||
| /** | ||
| * Opens the dialog with details about the selected program | ||
| * @param program selected epg program | ||
| */ | ||
| showDescription(program: EpgProgram): void { | ||
| this.dialog.open(EpgItemDescriptionComponent, { | ||
| data: program, | ||
| }); | ||
| } | ||
|
|
||
| close() { | ||
| this.overlayRef.detach(); | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,50 @@ | ||
| import { inject, TestBed } from '@angular/core/testing'; | ||
| import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar'; | ||
| import { TranslateService } from '@ngx-translate/core'; | ||
| import { MockModule, MockProviders } from 'ng-mocks'; | ||
| import { ChannelStore } from '../state'; | ||
| import { DataService } from './data.service'; | ||
| import { EpgService } from './epg.service'; | ||
|
|
||
| describe('EpgService', () => { | ||
| beforeEach(() => { | ||
| TestBed.configureTestingModule({ | ||
| providers: [ | ||
| EpgService, | ||
| MockProviders(DataService, TranslateService, MatSnackBar), | ||
| ], | ||
| imports: [MockModule(MatSnackBarModule)], | ||
| }); | ||
| }); | ||
|
|
||
| it('should create a service instance', inject( | ||
| [EpgService], | ||
| (service: EpgService) => { | ||
| expect(service).toBeTruthy(); | ||
| } | ||
| )); | ||
|
|
||
| it('should show a notification on epg error', inject( | ||
| [MatSnackBar, EpgService], | ||
| (snackbar: MatSnackBar, service: EpgService) => { | ||
| jest.spyOn(snackbar, 'open'); | ||
| service.onEpgError(); | ||
| expect(snackbar.open).toHaveBeenCalledTimes(1); | ||
| } | ||
| )); | ||
|
|
||
| it('should handle epg download success', inject( | ||
| [MatSnackBar, ChannelStore, EpgService], | ||
| ( | ||
| snackbar: MatSnackBar, | ||
| channelStore: ChannelStore, | ||
| service: EpgService | ||
| ) => { | ||
| jest.spyOn(snackbar, 'open'); | ||
| jest.spyOn(channelStore, 'setEpgAvailableFlag'); | ||
| service.onEpgFetchDone(); | ||
| expect(snackbar.open).toHaveBeenCalledTimes(1); | ||
| expect(channelStore.setEpgAvailableFlag).toHaveBeenCalledWith(true); | ||
| } | ||
| )); | ||
| }); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,69 @@ | ||
| import { Injectable } from '@angular/core'; | ||
| import { MatSnackBar, MatSnackBarConfig } from '@angular/material/snack-bar'; | ||
| import { TranslateService } from '@ngx-translate/core'; | ||
| import { EPG_FETCH } from '../../../shared/ipc-commands'; | ||
| import { ChannelStore } from '../state'; | ||
| import { DataService } from './data.service'; | ||
|
|
||
| @Injectable({ | ||
| providedIn: 'root', | ||
| }) | ||
| export class EpgService { | ||
| /** Default options for epg snackbar notifications */ | ||
| epgSnackBarOptions: MatSnackBarConfig = { | ||
| verticalPosition: 'bottom', | ||
| horizontalPosition: 'right', | ||
| }; | ||
|
|
||
| constructor( | ||
| private channelStore: ChannelStore, | ||
| private electronService: DataService, | ||
| private snackBar: MatSnackBar, | ||
| private translate: TranslateService | ||
| ) {} | ||
|
|
||
| /** | ||
| * Fetches and updates EPG from the given URL | ||
| * @param urls epg source urls | ||
| */ | ||
| fetchEpg(urls: string | string[]): void { | ||
| if (!Array.isArray(urls)) { | ||
| urls = [urls]; | ||
| } | ||
| urls.forEach((url) => | ||
| this.electronService.sendIpcEvent(EPG_FETCH, { | ||
| url, | ||
| }) | ||
| ); | ||
| this.snackBar.open( | ||
| this.translate.instant('EPG.FETCH_EPG'), | ||
| this.translate.instant('CLOSE'), | ||
| this.epgSnackBarOptions | ||
| ); | ||
| } | ||
|
|
||
| /** | ||
| * Handles the event when the EPG fetching is done | ||
| */ | ||
| onEpgFetchDone(): void { | ||
| this.channelStore.setEpgAvailableFlag(true); | ||
| this.snackBar.open( | ||
| this.translate.instant('EPG.DOWNLOAD_SUCCESS'), | ||
| null, | ||
| { | ||
| ...this.epgSnackBarOptions, | ||
| duration: 2000, | ||
| } | ||
| ); | ||
| } | ||
|
|
||
| /** | ||
| * Handles epg error | ||
| */ | ||
| onEpgError(): void { | ||
| this.snackBar.open(this.translate.instant('EPG.ERROR'), null, { | ||
| ...this.epgSnackBarOptions, | ||
| duration: 2000, | ||
| }); | ||
| } | ||
| } |