Original file line number Diff line number Diff line change
Expand Up @@ -20,30 +20,27 @@
<mat-divider></mat-divider>
<mat-nav-list>
<cdk-virtual-scroll-viewport itemSize="50" class="scroll-viewport">
<mat-list-item
<ng-container
*cdkVirtualFor="
let channel of _channelList | filterBy: searchTerm;
let channel of _channelList
| filterBy: searchTerm.name:'name';
index as i;
trackBy: trackByFn
"
[class.active]="selected?.id === channel.id"
(click)="selectChannel(channel)"
>
<div class="channel-logo" *ngIf="channel.tvg.logo">
<img
[src]="channel.tvg.logo"
width="48"
onerror="this.style.display='none'"
/>
</div>
<p matLine class="channel-name">
{{
i + 1 + '. ' + channel?.name ||
('CHANNELS.UNNAMED_CHANNEL' | translate)
}}
</p>
<mat-divider></mat-divider>
</mat-list-item>
<app-channel-list-item
[name]="
i +
1 +
'. ' +
(channel?.name || 'CHANNELS.UNNAMED_CHANNEL'
| translate)
"
[logo]="channel?.tvg?.logo"
(clicked)="selectChannel(channel)"
[selected]="selected?.id === channel.id"
></app-channel-list-item>
</ng-container>
</cdk-virtual-scroll-viewport>
</mat-nav-list>
</mat-tab>
Expand All @@ -69,30 +66,23 @@
({{ groups.value.length }})
</mat-expansion-panel-header>
<ng-template matExpansionPanelContent>
<mat-list-item
<ng-container
*ngFor="let channel of groups.value; index as i"
[class.active]="selected?.id === channel.id"
(click)="selectChannel(channel)"
>
<div
class="channel-logo"
*ngIf="channel.tvg.logo"
>
<img
[src]="channel.tvg.logo"
width="48"
onerror="this.style.display='none'"
/>
</div>
<p matLine class="channel-name">
{{
i + 1 + '. ' + channel?.name ||
('CHANNELS.UNNAMED_CHANNEL'
| translate)
}}
</p>
<mat-divider></mat-divider>
</mat-list-item>
<app-channel-list-item
[name]="
i +
1 +
'. ' +
(channel?.name ||
'CHANNELS.UNNAMED_CHANNEL'
| translate)
"
[logo]="channel?.tvg?.logo"
(clicked)="selectChannel(channel)"
[selected]="selected?.id === channel.id"
></app-channel-list-item>
</ng-container>
</ng-template>
</mat-expansion-panel>
</ng-container>
Expand All @@ -108,38 +98,28 @@
</div>
</ng-template>
<mat-nav-list
cdkDropList
*ngIf="favorites$ | async as favorites"
(cdkDropListDropped)="drop($event, favorites)"
id="favorites-list"
>
<ng-container *ngIf="favorites.length > 0; else noFavorites">
<mat-list-item
<app-channel-list-item
*ngFor="let channel of favorites; index as i"
[class.active]="selected?.id === channel.id"
(click)="selectChannel(channel)"
>
<div class="channel-logo" *ngIf="channel.tvg.logo">
<img
[src]="channel.tvg.logo"
width="48"
onerror="this.style.display='none'"
/>
</div>
<p matLine class="channel-name">
{{
i + 1 + '. ' + channel?.name ||
('CHANNELS.UNNAMED_CHANNEL' | translate)
}}
</p>
<button
mat-icon-button
color="primary"
[matTooltip]="'CHANNELS.REMOVE_FAVORITE' | translate"
(click)="toggleFavoriteChannel(channel, $event)"
>
<mat-icon color="accent">star</mat-icon>
</button>
<mat-divider></mat-divider>
</mat-list-item>
[name]="
i +
1 +
'. ' +
(channel?.name || 'CHANNELS.UNNAMED_CHANNEL'
| translate)
"
[isDraggable]="true"
[logo]="channel?.tvg?.logo"
(clicked)="selectChannel(channel)"
[selected]="selected?.id === channel.id"
[showFavoriteButton]="true"
(favoriteToggled)="toggleFavoriteChannel(channel, $event)"
></app-channel-list-item>
</ng-container>
<ng-template #noFavorites>
<mat-list-item
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,3 @@
.active {
background: #ddd;
}

.mat-list-base {
padding-top: 0;
}
Expand All @@ -17,11 +13,6 @@
border-radius: 4px;
}

.channel-logo {
max-height: 48px;
overflow: hidden;
}

::ng-deep {
.mat-expansion-panel-body {
padding: 0 !important;
Expand All @@ -36,10 +27,6 @@
.search-bar::placeholder {
color: #ccc;
}

.active {
background: #000;
}
}

.mat-tab-label-content {
Expand All @@ -57,16 +44,6 @@
}
}

.channel-name {
overflow: hidden;
text-overflow: ellipsis;
display: -webkit-box;
-webkit-line-clamp: 1;
-webkit-box-orient: vertical;
font-size: 0.9em;
max-width: 290px;
}

[type='search']::-webkit-search-cancel-button {
-webkit-appearance: none;
cursor: pointer;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,15 +10,18 @@ import { MatTabsModule } from '@angular/material/tabs';
import { MatTooltipModule } from '@angular/material/tooltip';
import { By } from '@angular/platform-browser';
import { RouterTestingModule } from '@angular/router/testing';
import { TranslatePipe } from '@ngx-translate/core';
import { MockModule, MockPipes } from 'ng-mocks';
import { FilterPipe } from 'ngx-filter-pipe';
import { Actions } from '@ngrx/effects';
import { provideMockActions } from '@ngrx/effects/testing';
import { MockStore, provideMockStore } from '@ngrx/store/testing';
import { TranslatePipe, TranslateService } from '@ngx-translate/core';
import { MockModule, MockPipes, MockProviders } from 'ng-mocks';
import { NgxIndexedDBService } from 'ngx-indexed-db';
import { Observable } from 'rxjs';
import * as MOCKED_PLAYLIST from '../../../../mocks/playlist.json';
import { DataService } from '../../../services/data.service';
import { ElectronServiceStub } from '../../../services/electron.service.stub';
import { createChannel } from '../../../state';
import { ChannelQuery } from '../../../state/channel.query';
import { ChannelStore } from '../../../state/channel.store';
import { createChannel } from '../../../shared/channel.model';
import { FilterPipe } from '../../../shared/pipes/filter.pipe';
import { ChannelListContainerComponent } from './channel-list-container.component';

class MatSnackBarStub {
Expand All @@ -28,7 +31,8 @@ class MatSnackBarStub {
describe('ChannelListContainerComponent', () => {
let component: ChannelListContainerComponent;
let fixture: ComponentFixture<ChannelListContainerComponent>;
let store: ChannelStore;
let mockStore: MockStore;
const actions$ = new Observable<Actions>();

beforeEach(() => {
TestBed.configureTestingModule({
Expand All @@ -37,9 +41,11 @@ describe('ChannelListContainerComponent', () => {
MockPipes(TranslatePipe, FilterPipe),
],
providers: [
ChannelQuery,
{ provide: MatSnackBar, useClass: MatSnackBarStub },
{ provide: DataService, useClass: ElectronServiceStub },
provideMockStore(),
provideMockActions(actions$),
MockProviders(NgxIndexedDBService, TranslateService),
],
imports: [
MockModule(MatSnackBarModule),
Expand All @@ -59,25 +65,20 @@ describe('ChannelListContainerComponent', () => {
beforeEach(() => {
fixture = TestBed.createComponent(ChannelListContainerComponent);
component = fixture.componentInstance;
TestBed.inject(ChannelQuery);
store = TestBed.inject(ChannelStore);
store.update({
favorites: [],
playlistId: '',
active: undefined,
});
mockStore = TestBed.inject(MockStore);

// set channels
const channels = MOCKED_PLAYLIST.playlist.items.map((element) =>
createChannel(element)
);
store.upsertMany(channels);
component.channelList = channels;

// set favorites
store.update({
favorites: [MOCKED_PLAYLIST.playlist.items[0].url],
mockStore.setState({
playlistState: {
channels,
active: undefined,
},
});
component.channelList = channels;
fixture.detectChanges();
});

Expand Down Expand Up @@ -144,22 +145,23 @@ describe('ChannelListContainerComponent', () => {
});

it('should update store after channel was selected', () => {
jest.spyOn(store, 'update');
jest.spyOn(mockStore, 'dispatch');
component.selectChannel(component._channelList[0]);
fixture.detectChanges();
expect(store.update).toHaveBeenCalledTimes(1);
expect(mockStore.dispatch).toHaveBeenCalledTimes(1);
});

it('should update store after channel was favorited', () => {
jest.spyOn(store, 'updateFavorite');
jest.spyOn(mockStore, 'dispatch');
component.toggleFavoriteChannel(
component._channelList[0],
new MouseEvent('click')
);
fixture.detectChanges();
expect(store.updateFavorite).toHaveBeenCalledWith(
component._channelList[0]
);
expect(store.updateFavorite).toHaveBeenCalledTimes(1);
expect(mockStore.dispatch).toHaveBeenCalledWith({
channel: component._channelList[0],
type: expect.stringContaining('favorites'),
});
expect(mockStore.dispatch).toHaveBeenCalledTimes(1);
});
});
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { CdkDragDrop, moveItemInArray } from '@angular/cdk/drag-drop';
import {
Component,
ElementRef,
Expand All @@ -6,11 +7,16 @@ import {
ViewChild,
} from '@angular/core';
import { MatSnackBar } from '@angular/material/snack-bar';
import { Store } from '@ngrx/store';
import { TranslateService } from '@ngx-translate/core';
import * as _ from 'lodash';
import { map, Observable, skipWhile } from 'rxjs';
import { map, skipWhile } from 'rxjs';
import { Channel } from '../../../../../shared/channel.interface';
import { ChannelQuery, ChannelStore } from '../../../state';

import * as PlaylistActions from '../../../state/actions';
import {
selectActivePlaylistId,
selectFavorites,
} from '../../../state/selectors';
@Component({
selector: 'app-channel-list-container',
templateUrl: './channel-list-container.component.html',
Expand Down Expand Up @@ -38,13 +44,6 @@ export class ChannelListContainerComponent {
/** Selected channel */
selected!: Channel;

/** List with favorited channels */
favorites$: Observable<Channel[]> = this.channelQuery.select((store) =>
this.channelQuery
.getAll()
.filter((channel) => store.favorites.includes(channel.id))
);

/** Search term for channel filter */
searchTerm: any = {
name: '',
Expand All @@ -62,20 +61,31 @@ export class ChannelListContainerComponent {
}

/** ID of the current playlist */
playlistId$ = this.channelQuery.select().pipe(
skipWhile(
(store) => store.playlistId === '' || store.playlistId === undefined
),
map((data) => data.playlistId)
);
playlistId$ = this.store
.select(selectActivePlaylistId)
.pipe(
skipWhile(
(playlistId) => playlistId === '' || playlistId === undefined
)
);

/** List with favorites */
favorites$ = this.store
.select(selectFavorites)
.pipe(
map((favoriteChannelIds) =>
favoriteChannelIds.map((favoriteChannelId) =>
this.channelList.find(
(channel) => channel.id === favoriteChannelId
)
)
)
);

/**
* Creates an instance of ChannelListContainerComponent
*/
constructor(
private channelQuery: ChannelQuery,
private channelStore: ChannelStore,
private snackBar: MatSnackBar
private readonly store: Store,
private snackBar: MatSnackBar,
private translateService: TranslateService
) {}

/**
Expand All @@ -84,7 +94,7 @@ export class ChannelListContainerComponent {
*/
selectChannel(channel: Channel): void {
this.selected = channel;
this.channelStore.setActiveChannel(channel);
this.store.dispatch(PlaylistActions.setActiveChannel({ channel }));
}

/**
Expand All @@ -94,8 +104,12 @@ export class ChannelListContainerComponent {
*/
toggleFavoriteChannel(channel: Channel, clickEvent: MouseEvent): void {
clickEvent.stopPropagation();
this.snackBar.open('Favorites were updated!', null, { duration: 2000 });
this.channelStore.updateFavorite(channel);
this.snackBar.open(
this.translateService.instant('CHANNELS.FAVORITES_UPDATED'),
null,
{ duration: 2000 }
);
this.store.dispatch(PlaylistActions.updateFavorites({ channel }));
}

/**
Expand All @@ -106,4 +120,14 @@ export class ChannelListContainerComponent {
trackByFn(index: number, channel: Channel): string {
return channel.id;
}

drop(event: CdkDragDrop<Channel[]>, favorites: Channel[]) {
moveItemInArray(favorites, event.previousIndex, event.currentIndex);
console.log(favorites);
this.store.dispatch(
PlaylistActions.setFavorites({
channelIds: favorites.map((item) => item.id),
})
);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
.channel-logo {
max-height: 48px;
overflow: hidden;
}

.channel-name {
overflow: hidden;
text-overflow: ellipsis;
display: -webkit-box;
-webkit-line-clamp: 1;
-webkit-box-orient: vertical;
font-size: 0.9em;
max-width: 290px;
}

.active {
background: #ddd;
}

::ng-deep {
.dark-theme {
.active {
background: #000;
}
}
}

.drag-icon {
cursor: move;
margin-left: -20px;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import { Component, EventEmitter, Input, Output } from '@angular/core';

@Component({
selector: 'app-channel-list-item',
styleUrls: ['./channel-list-item.component.scss'],
template: `<mat-list-item
cdkDrag
[cdkDragDisabled]="!isDraggable"
cdkDragPreviewContainer="parent"
[class.active]="selected"
(click)="clicked.emit()"
data-test-id="channel-item"
>
<mat-icon
*ngIf="isDraggable"
mat-list-icon
cdkDragHandle
class="drag-icon"
>drag_indicator</mat-icon
>
<div class="channel-logo" *ngIf="logo">
<img [src]="logo" width="48" onerror="this.style.display='none'" />
</div>
<p matLine class="channel-name">
{{ name }}
</p>
<button
*ngIf="showFavoriteButton"
mat-icon-button
color="primary"
[matTooltip]="'CHANNELS.REMOVE_FAVORITE' | translate"
(click)="favoriteToggled.emit($event)"
>
<mat-icon color="accent">star</mat-icon>
</button>
<mat-divider></mat-divider
></mat-list-item>`,
})
export class ChannelListItemComponent {
@Input() isDraggable = false;
@Input() logo!: string;
@Input() name = '';
@Input() showFavoriteButton = false;
@Input() selected = false;

@Output() clicked = new EventEmitter<void>();
@Output() favoriteToggled = new EventEmitter();
}
28 changes: 10 additions & 18 deletions src/app/player/components/epg-list/epg-list.component.html
Original file line number Diff line number Diff line change
@@ -1,15 +1,11 @@
<ng-container *ngIf="channel; else noEpg">
<!-- channel info panel -->
<div fxFlex="120px" id="channel-info" color="primary" fxLayout="column">
<div fxFlex="79px" fxLayout="row" fxLayoutGap="10px">
<div
fxFlex="60px"
class="channel-icon"
fxLayoutAlign="center center"
>
<div id="channel-header" color="primary">
<div class="channel-info">
<div class="channel-icon">
<img *ngIf="channel?.icon" [src]="channel.icon" width="48" />
</div>
<div fxFlex="100" fxFlexAlign="center">
<div class="channel-details">
<div class="channel-name">
{{ channel?.name[0]?.value }}
</div>
Expand All @@ -23,24 +19,20 @@
</div>
</div>
<mat-divider></mat-divider>
<div fxFlex="40px" fxLayout="row" id="date-navigator">
<div id="date-navigator">
<button
fxFlex="40px"
class="previous-day"
mat-icon-button
(click)="changeDate('prev')"
[matTooltip]="'EPG.PREVIOUS_DAY' | translate"
>
<mat-icon>chevron_left</mat-icon>
</button>
<span
class="selected-date"
fxFlex="100"
fxLayoutAlign="center center"
>
<span class="selected-date">
{{ dateToday | momentDate: 'YYYYMMDD':'MMMM Do, dddd' }}</span
>
<button
fxFlex="40px"
class="next-day"
mat-icon-button
(click)="changeDate('next')"
[matTooltip]="'EPG.NEXT_DAY' | translate"
Expand All @@ -53,7 +45,7 @@

<!-- program list -->
<mat-selection-list
fxFlex
id="program-list"
[multiple]="false"
*ngIf="timeshiftUntil$ | async as timeshiftUntil"
>
Expand Down Expand Up @@ -95,7 +87,7 @@
[item]="item"
></app-epg-list-item>
</div>
<p matLine>{{ item?.title[0]?.value }}</p>
<p matLine [innerHTML]="item?.title[0]?.value"></p>
</mat-list-option>
</ng-template>
</ng-container>
Expand Down
69 changes: 68 additions & 1 deletion src/app/player/components/epg-list/epg-list.component.scss
Original file line number Diff line number Diff line change
Expand Up @@ -13,9 +13,43 @@
border-bottom: 1px dashed #666;
}

#channel-info {
#channel-header {
flex-direction: column;
box-sizing: border-box;
display: flex;
flex: 1 1 120px;
max-height: 120px;
min-height: 120px;

.channel-info {
flex-direction: row;
box-sizing: border-box;
display: flex;
flex: 1 1 79px;
max-height: 79px;
min-height: 79px;
}

.channel-icon {
overflow: hidden;
margin-right: 10px;
place-content: center;
align-items: center;
flex-direction: row;
box-sizing: border-box;
display: flex;
flex: 1 1 60px;
max-width: 60px;
min-width: 60px;
}

.channel-details {
flex: 1 1 100%;
box-sizing: border-box;
max-width: 100%;
align-self: center;
display: flex;
flex-direction: column;
}

.channel-name {
Expand All @@ -32,12 +66,45 @@
}
}

#program-list {
flex: 1 1 1e-9px;
box-sizing: border-box;
}

mat-selection-list {
overflow: auto;
}

#date-navigator {
flex-direction: row;
box-sizing: border-box;
display: flex;
flex: 1 1 40px;
max-height: 40px;
min-height: 40px;

.selected-date {
font-size: 0.9em;
place-content: center;
align-items: center;
flex-direction: row;
box-sizing: border-box;
display: flex;
flex: 1 1 100%;
max-width: 100%;
}

.previous-day {
flex: 1 1 40px;
box-sizing: border-box;
max-width: 40px;
min-width: 40px;
}

.next-day {
flex: 1 1 40px;
box-sizing: border-box;
max-width: 40px;
min-width: 40px;
}
}
47 changes: 29 additions & 18 deletions src/app/player/components/epg-list/epg-list.component.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,23 +4,27 @@ import { MatDialog, MatDialogModule } from '@angular/material/dialog';
import { MatIconModule } from '@angular/material/icon';
import { MatListModule } from '@angular/material/list';
import { MatTooltipModule } from '@angular/material/tooltip';
import { Actions } from '@ngrx/effects';
import { provideMockActions } from '@ngrx/effects/testing';
import { MockStore, provideMockStore } from '@ngrx/store/testing';
import { TranslatePipe } from '@ngx-translate/core';
import * as moment from 'moment';
import { MockComponent, MockModule, MockPipe, MockProvider } from 'ng-mocks';
import { Observable } from 'rxjs';
import { Channel } from '../../../../../shared/channel.interface';
import { EPG_GET_PROGRAM_DONE } from '../../../../../shared/ipc-commands';
import { DataService } from '../../../services/data.service';
import { ElectronServiceStub } from '../../../services/electron.service.stub';
import { MomentDatePipe } from '../../../shared/pipes/moment-date.pipe';
import { ChannelStore } from '../../../state';
import { EpgListItemComponent } from './epg-list-item/epg-list-item.component';
import { EpgData, EpgListComponent } from './epg-list.component';

describe('EpgListComponent', () => {
let component: EpgListComponent;
let fixture: ComponentFixture<EpgListComponent>;
let electronService: DataService;
let channelStore: ChannelStore;
let mockStore: MockStore;
const actions$ = new Observable<Actions>();

const MOCKED_PROGRAMS = {
channel: {
Expand Down Expand Up @@ -103,6 +107,8 @@ describe('EpgListComponent', () => {
providers: [
{ provide: DataService, useClass: ElectronServiceStub },
MockProvider(MatDialog),
provideMockStore(),
provideMockActions(actions$),
],
}).compileComponents();
})
Expand All @@ -112,16 +118,21 @@ describe('EpgListComponent', () => {
fixture = TestBed.createComponent(EpgListComponent);
component = fixture.componentInstance;
electronService = TestBed.inject(DataService);
channelStore = TestBed.inject(ChannelStore);
channelStore.setActiveChannel({
id: '',
url: '',
name: '',
group: { title: '' },
tvg: {
rec: '3',

mockStore = TestBed.inject(MockStore);
mockStore.setState({
playlistState: {
active: {
id: '',
url: '',
name: '',
group: { title: '' },
tvg: {
rec: '3',
},
} as unknown as Channel,
},
} as unknown as Channel);
});
fixture.detectChanges();
});

Expand Down Expand Up @@ -158,19 +169,19 @@ describe('EpgListComponent', () => {
});

it('should set epg program as active', () => {
jest.spyOn(channelStore, 'setActiveEpgProgram');
jest.spyOn(mockStore, 'dispatch');
component.setEpgProgram(MOCKED_PROGRAMS.items[0], false, true);
expect(channelStore.setActiveEpgProgram).toHaveBeenCalledTimes(1);
expect(channelStore.setActiveEpgProgram).toHaveBeenCalledWith(
MOCKED_PROGRAMS.items[0]
expect(mockStore.dispatch).toHaveBeenCalledTimes(1);
expect(mockStore.dispatch).toHaveBeenCalledWith(
{program: MOCKED_PROGRAMS.items[0], type: expect.stringContaining('epg program')}
);
});

it('should reset active epg program', () => {
jest.spyOn(channelStore, 'resetActiveEpgProgram');
jest.spyOn(mockStore, 'dispatch');
component.setEpgProgram(MOCKED_PROGRAMS.items[0], true);
expect(channelStore.resetActiveEpgProgram).toHaveBeenCalledTimes(1);
expect(mockStore.dispatch).toHaveBeenCalledTimes(1);
component.setEpgProgram(MOCKED_PROGRAMS.items[0], true, true);
expect(channelStore.resetActiveEpgProgram).toHaveBeenCalledTimes(2);
expect(mockStore.dispatch).toHaveBeenCalledTimes(2);
});
});
49 changes: 27 additions & 22 deletions src/app/player/components/epg-list/epg-list.component.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,16 @@
import { Component, NgZone } from '@angular/core';
import { Store } from '@ngrx/store';
import * as moment from 'moment';
import { Observable } from 'rxjs';
import { map } from 'rxjs/operators';
import { EPG_GET_PROGRAM_DONE } from '../../../../../shared/ipc-commands';
import { DataService } from '../../../services/data.service';
import { ChannelQuery, ChannelStore } from '../../../state';
import {
resetActiveEpgProgram,
setActiveEpgProgram,
setCurrentEpgProgram,
} from '../../../state/actions';
import { selectActive } from '../../../state/selectors';
import { EpgChannel } from '../../models/epg-channel.model';
import { EpgProgram } from '../../models/epg-program.model';

Expand Down Expand Up @@ -50,14 +56,12 @@ export class EpgListComponent {

/**
* Creates an instance of EpgListComponent
* @param channelQuery
* @param channelStore
* @param store
* @param electronService
* @param ngZone
*/
constructor(
private channelQuery: ChannelQuery,
private channelStore: ChannelStore,
private readonly store: Store,
private electronService: DataService,
private ngZone: NgZone
) {
Expand All @@ -73,20 +77,21 @@ export class EpgListComponent {
* Subscribe for values from the store on component init
*/
ngOnInit(): void {
this.timeshiftUntil$ = this.channelQuery
.select(
(store) =>
store.active?.tvg?.rec ||
store.active?.timeshift ||
store.active?.catchup?.days
this.timeshiftUntil$ = this.store.select(selectActive).pipe(
// eslint-disable-next-line @ngrx/avoid-mapping-selectors
map((active) => {
return (
active?.tvg?.rec ||
active?.timeshift ||
active?.catchup?.days
);
}),
map((value) =>
moment(Date.now())
.subtract(value, 'days')
.format(DATE_TIME_FORMAT)
)
.pipe(
map((value) =>
moment(Date.now())
.subtract(value, 'days')
.format(DATE_TIME_FORMAT)
)
);
);
}

/**
Expand All @@ -105,7 +110,7 @@ export class EpgListComponent {
} else {
this.items = [];
this.channel = null;
this.channelStore.setCurrentEpgProgram(undefined);
this.store.dispatch(setCurrentEpgProgram(undefined));
}
}

Expand Down Expand Up @@ -156,7 +161,7 @@ export class EpgListComponent {
this.playingNow = this.items.find(
(item) => this.timeNow >= item.start && this.timeNow <= item.stop
);
this.channelStore.setCurrentEpgProgram(this.playingNow);
this.store.dispatch(setCurrentEpgProgram({ program: this.playingNow }));
}

/**
Expand All @@ -171,10 +176,10 @@ export class EpgListComponent {
timeshift?: boolean
): void {
if (isLive) {
this.channelStore.resetActiveEpgProgram();
this.store.dispatch(resetActiveEpgProgram());
} else {
if (!timeshift) return;
this.channelStore.setActiveEpgProgram(program);
this.store.dispatch(setActiveEpgProgram({ program }));
}
this.playingNow = program;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -48,15 +48,17 @@ export class HtmlVideoPlayerComponent implements OnChanges, OnDestroy {
*/
playChannel(channel: Channel): void {
if (this.hls) this.hls.destroy();
const url = channel.url + channel.epgParams;
if (Hls && Hls.isSupported()) {
console.log('... switching channel to ', channel.name, url);
this.hls = new Hls();
this.hls.attachMedia(this.videoPlayer.nativeElement);
this.hls.loadSource(url);
this.handlePlayOperation();
} else {
console.error('something wrong with hls.js init...');
if (channel.url) {
const url = channel.url + channel.epgParams;
if (Hls && Hls.isSupported()) {
console.log('... switching channel to ', channel.name, url);
this.hls = new Hls();
this.hls.attachMedia(this.videoPlayer.nativeElement);
this.hls.loadSource(url);
this.handlePlayOperation();
} else {
console.error('something wrong with hls.js init...');
}
}
}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
<div id="playlists-navigation">
<div class="current-playlist">
<button mat-icon-button matTooltip="Back" (click)="goBack()">
<mat-icon>arrow_back_ios</mat-icon>
</button>
<div class="playlist-info">
<div class="name">
<ng-container
*ngIf="sidebarView === 'CHANNELS'; else allPlaylists"
>
{{ playlistTitle$ | async }}
</ng-container>
<ng-template #allPlaylists>
{{ 'HOME.PLAYLISTS.MY_PLAYLISTS' | translate }}
</ng-template>
</div>
<div class="channels-count">
{{
sidebarView === 'CHANNELS'
? channels.length +
' ' +
('HOME.PLAYLISTS.CHANNELS' | translate)
: ('HOME.PLAYLISTS.MY_PLAYLISTS_SUBTITLE' | translate)
}}
</div>
</div>

<button
mat-icon-button
*ngIf="sidebarView === 'CHANNELS'"
routerLink="/"
[matTooltip]="
'CHANNELS.UPLOAD_OR_SELECT_OTHER_PLAYLIST' | translate
"
>
<mat-icon>playlist_add</mat-icon>
</button>
</div>
</div>
<mat-divider></mat-divider>
<app-channel-list-container
*ngIf="sidebarView === 'CHANNELS'; else playlistsTemplate"
[channelList]="channels"
>
</app-channel-list-container>
<ng-template #playlistsTemplate>
<app-recent-playlists
class="recent-playlists"
[class.electron]="dataService.isElectron"
(playlistClicked)="selectPlaylist()"
></app-recent-playlists>
</ng-template>
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
#playlists-navigation {
background-color: #000;
color: #fff;

.current-playlist {
display: flex;
align-items: center;
padding: 8px;
}

.playlist-info {
flex: 1;
max-width: 300px;

.name {
font-size: 1.1em;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}

.channels-count {
font-size: 0.8em;
color: #ccc;
text-transform: lowercase;
}
}
}

.recent-playlists {
height: calc(100vh - 60px);
overflow: auto;
width: 100%;
display: block;
}

.electron {
height: calc(100vh - 85px) !important;
}

@media only screen and (max-width: 480px) {
.playlist-info {
.name {
max-width: 105px;
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import { Component, Input } from '@angular/core';
import { Router } from '@angular/router';
import { Store } from '@ngrx/store';
import { Channel } from '../../../../../../shared/channel.interface';
import { DataService } from '../../../../services/data.service';
import * as PlaylistActions from '../../../../state/actions';
import { selectPlaylistTitle } from '../../../../state/selectors';
import { SidebarView } from '../video-player.component';

@Component({
selector: 'app-sidebar',
templateUrl: './sidebar.component.html',
styleUrls: ['./sidebar.component.scss'],
})
export class SidebarComponent {
@Input() channels: Channel[] = [];

isElectron = this.dataService.isElectron;

playlistTitle$ = this.store.select(selectPlaylistTitle);

sidebarView: SidebarView = 'CHANNELS';

constructor(
public dataService: DataService,
private router: Router,
private store: Store
) {}

goBack(): void {
if (this.sidebarView === 'PLAYLISTS') {
this.store.dispatch(PlaylistActions.resetActiveChannel());
this.router.navigate(['/']);
} else {
this.sidebarView = 'PLAYLISTS';
}
}

selectPlaylist() {
this.sidebarView = 'CHANNELS';
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
<mat-toolbar color="primary">
<button
mat-icon-button
(click)="toggleLeftDrawerClicked.emit()"
[matTooltip]="'TOP_MENU.OPEN_CHANNELS_LIST' | translate"
>
<mat-icon>menu</mat-icon>
</button>
<button
*ngIf="(playlistId$ | async) !== 'GLOBAL_FAVORITES'"
mat-icon-button
(click)="addToFavorites(activeChannel)"
[matTooltip]="'TOP_MENU.TOGGLE_FAVORITE_FLAG' | translate"
>
<mat-icon>
{{
(favorites$ | async).includes(activeChannel?.id)
? 'star'
: 'star_outline'
}}</mat-icon
>
</button>
{{ activeChannel?.name }}
<div class="spacer"></div>
<ng-container *ngIf="isEpgAvailable$ | async">
<button
mat-icon-button
(click)="multiEpgClicked.emit()"
[matTooltip]="'TOP_MENU.OPEN_MULTI_EPG' | translate"
>
<mat-icon>view_list</mat-icon>
</button>
<button
mat-button
(click)="toggleRightDrawerClicked.emit()"
[matTooltip]="'TOP_MENU.OPEN_EPG_LIST' | translate"
>
EPG
</button>
</ng-container>
</mat-toolbar>
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
.spacer {
flex: 1 1 auto;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import { Component, EventEmitter, Input, Output } from '@angular/core';
import { MatSnackBar } from '@angular/material/snack-bar';
import { Store } from '@ngrx/store';
import { TranslateService } from '@ngx-translate/core';
import { Channel } from '../../../../../../shared/channel.interface';
import { updateFavorites } from '../../../../state/actions';
import {
selectActivePlaylistId,
selectFavorites,
selectIsEpgAvailable,
} from '../../../../state/selectors';

@Component({
selector: 'app-toolbar',
templateUrl: './toolbar.component.html',
styleUrls: ['./toolbar.component.scss'],
})
export class ToolbarComponent {
@Input() activeChannel!: Channel;
@Output() multiEpgClicked = new EventEmitter<void>();
@Output() toggleLeftDrawerClicked = new EventEmitter<void>();
@Output() toggleRightDrawerClicked = new EventEmitter<void>();

favorites$ = this.store.select(selectFavorites);
isEpgAvailable$ = this.store.select(selectIsEpgAvailable);
playlistId$ = this.store.select(selectActivePlaylistId);

constructor(
private snackBar: MatSnackBar,
private store: Store,
private translateService: TranslateService
) {}

/**
* Adds/removes a given channel to the favorites list
* @param channel channel to add
*/
addToFavorites(channel: Channel): void {
this.snackBar.open(
this.translateService.instant('CHANNELS.FAVORITES_UPDATED'),
null,
{ duration: 2000 }
);
this.store.dispatch(updateFavorites({ channel }));
}
}
124 changes: 8 additions & 116 deletions src/app/player/components/video-player/video-player.component.html
Original file line number Diff line number Diff line change
@@ -1,117 +1,15 @@
<mat-drawer-container class="main-container" (backdropClick)="close()">
<mat-drawer-container class="main-container">
<!-- sidebar content -->
<mat-drawer
#drawer
mode="side"
opened
(keydown.escape)="close()"
disableClose
>
<ng-container *ngIf="channels$ | async as channels">
<div id="playlists-navigation">
<div class="current-playlist">
<button
mat-icon-button
matTooltip="Back"
(click)="goBack()"
>
<mat-icon>arrow_back_ios</mat-icon>
</button>
<div class="playlist-info">
<div class="name">
<ng-container
*ngIf="
sidebarView === 'CHANNELS';
else allPlaylists
"
>
{{ playlistTitle$ | async }}
</ng-container>
<ng-template #allPlaylists>
{{ 'HOME.PLAYLISTS.MY_PLAYLISTS' | translate }}
</ng-template>
</div>
<div class="channels-count">
{{
sidebarView === 'CHANNELS'
? channels.length + ' channels'
: 'all available playlists'
}}
</div>
</div>

<button
mat-icon-button
*ngIf="sidebarView === 'CHANNELS'"
routerLink="/"
[matTooltip]="
'CHANNELS.UPLOAD_OR_SELECT_OTHER_PLAYLIST'
| translate
"
>
<mat-icon>playlist_add</mat-icon>
</button>
</div>
</div>
<mat-divider></mat-divider>
<app-channel-list-container
*ngIf="sidebarView === 'CHANNELS'; else playlistsTemplate"
[channelList]="channels"
>
</app-channel-list-container>
<ng-template #playlistsTemplate>
<app-recent-playlists
class="recent-playlists"
[class.electron]="dataService.isElectron"
></app-recent-playlists>
</ng-template>
</ng-container>
<mat-drawer #drawer mode="side" opened disableClose>
<app-sidebar
*ngIf="channels$ | async as channels"
[channels]="channels" />
</mat-drawer>

<mat-drawer-content>
<ng-container *ngIf="activeChannel$ | async as activeChannel">
<!-- toolbar with drawer icon -->
<mat-toolbar color="primary">
<button
mat-icon-button
(click)="drawer.toggle()"
[matTooltip]="'TOP_MENU.OPEN_CHANNELS_LIST' | translate"
>
<mat-icon>menu</mat-icon>
</button>
<button
*ngIf="(playlistId$ | async) !== 'GLOBAL_FAVORITES'"
mat-icon-button
(click)="addToFavorites(activeChannel)"
[matTooltip]="'TOP_MENU.TOGGLE_FAVORITE_FLAG' | translate"
>
<mat-icon>
{{
(favorites$ | async).includes(activeChannel?.id)
? 'star'
: 'star_outline'
}}</mat-icon
>
</button>
{{ activeChannel?.name }}
<div class="spacer"></div>
<button
*ngIf="epgAvailable$ | async"
mat-icon-button
(click)="openMultiEpgView()"
[matTooltip]="'TOP_MENU.OPEN_MULTI_EPG' | translate"
>
<mat-icon>view_list</mat-icon>
</button>
<button
*ngIf="epgAvailable$ | async"
mat-button
(click)="drawerRight.toggle()"
[matTooltip]="'TOP_MENU.OPEN_EPG_LIST' | translate"
>
EPG
</button>
</mat-toolbar>
<app-toolbar (toggleLeftDrawerClicked)="drawer.toggle()" (toggleRightDrawerClicked)="drawerRight.toggle()"
(multiEpgClicked)="openMultiEpgView()" [activeChannel]="activeChannel" />

<!-- video.js player -->
<app-vjs-player
Expand Down Expand Up @@ -142,13 +40,7 @@
></app-info-overlay>
</mat-drawer-content>
<!-- right sidebar content -->
<mat-drawer
position="end"
#drawerRight
mode="side"
(keydown.escape)="close()"
disableClose
>
<mat-drawer position="end" #drawerRight mode="side" disableClose>
<app-epg-list *ngIf="isElectron"></app-epg-list>
</mat-drawer>
</mat-drawer-container>
49 changes: 0 additions & 49 deletions src/app/player/components/video-player/video-player.component.scss
Original file line number Diff line number Diff line change
Expand Up @@ -16,58 +16,9 @@
min-width: 400px;
}

.spacer {
flex: 1 1 auto;
}

#playlists-navigation {
background-color: #000;
color: #fff;

.current-playlist {
display: flex;
align-items: center;
padding: 8px;
}

.playlist-info {
flex: 1;
max-width: 300px;

.name {
font-size: 1.1em;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}

.channels-count {
font-size: 0.8em;
color: #ccc;
}
}
}

.recent-playlists {
height: calc(100vh - 60px);
overflow: auto;
width: 100%;
display: block;
}

.electron {
height: calc(100vh - 85px) !important;
}

@media only screen and (max-width: 480px) {
.mat-drawer {
width: 200px;
min-width: 200px;
}

.playlist-info {
.name {
max-width: 105px;
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -5,31 +5,38 @@ import { MatSidenavModule } from '@angular/material/sidenav';
import { MatSnackBar } from '@angular/material/snack-bar';
import { MatToolbarModule } from '@angular/material/toolbar';
import { MatTooltipModule } from '@angular/material/tooltip';
import { ActivatedRoute } from '@angular/router';
import { RouterTestingModule } from '@angular/router/testing';
import { MockStore, provideMockStore } from '@ngrx/store/testing';
import { TranslatePipe } from '@ngx-translate/core';
import { MockComponent, MockModule, MockPipe } from 'ng-mocks';
import * as MOCKED_PLAYLIST from '../../../../mocks/playlist.json';
import { MockComponent, MockModule, MockPipe, MockProviders } from 'ng-mocks';
import { DataService } from '../../../services/data.service';
import { ElectronServiceStub } from '../../../services/electron.service.stub';
import { VideoPlayer } from '../../../settings/settings.interface';
import { createChannel } from '../../../state';
import { ChannelStore } from '../../../state/channel.store';
import { ChannelListContainerComponent } from '../channel-list-container/channel-list-container.component';
import { EpgListComponent } from '../epg-list/epg-list.component';
import { HtmlVideoPlayerComponent } from '../html-video-player/html-video-player.component';
import { VjsPlayerComponent } from '../vjs-player/vjs-player.component';
import { InfoOverlayComponent } from './../info-overlay/info-overlay.component';
import { VideoPlayerComponent } from './video-player.component';

import { Actions } from '@ngrx/effects';
import { provideMockActions } from '@ngrx/effects/testing';
import { NgxIndexedDBService } from 'ngx-indexed-db';
import { Observable, of } from 'rxjs';
import { PlaylistsService } from '../../../services/playlists.service';
import { initialState } from '../../../state/state';

class MatSnackBarStub {
open(): void {}
}

describe('VideoPlayerComponent', () => {
let component: VideoPlayerComponent;
let fixture: ComponentFixture<VideoPlayerComponent>;
let store: ChannelStore;
let channels;
let mockStore: MockStore;
const actions$ = new Observable<Actions>();

beforeEach(
waitForAsync(() => {
Expand All @@ -47,6 +54,20 @@ describe('VideoPlayerComponent', () => {
providers: [
{ provide: MatSnackBar, useClass: MatSnackBarStub },
{ provide: DataService, useClass: ElectronServiceStub },
{
provide: ActivatedRoute,
useValue: {
params: of({ id: '1' }),
snapshot: {
queryParams: {
url: 'https://iptvnator/list.m3u',
},
},
},
},
provideMockStore(),
provideMockActions(actions$),
MockProviders(NgxIndexedDBService, PlaylistsService),
],
imports: [
MockModule(MatSidenavModule),
Expand All @@ -63,35 +84,18 @@ describe('VideoPlayerComponent', () => {
beforeEach(() => {
fixture = TestBed.createComponent(VideoPlayerComponent);
component = fixture.componentInstance;
store = TestBed.inject(ChannelStore);
// set channels
channels = MOCKED_PLAYLIST.playlist.items.map((element) =>
createChannel(element)
);
store.upsertMany(channels);
});
mockStore = TestBed.inject(MockStore);

it('should create and init component', () => {
expect(component).toBeTruthy();
jest.spyOn(component, 'applySettings');
mockStore.setState({
playlistState: initialState,
});
fixture.detectChanges();
expect(component.applySettings).toHaveBeenCalledTimes(1);
});

it('should check default component settings', () => {
fixture.detectChanges();
expect(component.playerSettings).toEqual({
player: VideoPlayer.VideoJs,
showCaptions: false,
});
});

it('should update store after channel was faved', () => {
jest.spyOn(store, 'updateFavorite');
const [firstChannel] = channels;
component.addToFavorites(firstChannel);
//fixture.detectChanges();
expect(store.updateFavorite).toHaveBeenCalledTimes(1);
expect(store.updateFavorite).toHaveBeenCalledWith(firstChannel);
});
});
185 changes: 86 additions & 99 deletions src/app/player/components/video-player/video-player.component.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,139 +7,159 @@ import {
NgZone,
OnDestroy,
OnInit,
ViewChild,
} from '@angular/core';
import { MatSidenav } from '@angular/material/sidenav';
import { MatSnackBar } from '@angular/material/snack-bar';
import { Router } from '@angular/router';
import { ActivatedRoute } from '@angular/router';
import { Store } from '@ngrx/store';
import { StorageMap } from '@ngx-pwa/local-storage';
import { filter, map, Observable, skipWhile } from 'rxjs';
import {
combineLatestWith,
filter,
Observable,
skipWhile,
switchMap,
} from 'rxjs';
import { Channel } from '../../../../../shared/channel.interface';
import {
PLAYLIST_GET_ALL,
PLAYLIST_PARSE_BY_URL,
PLAYLIST_PARSE_RESPONSE,
} from '../../../../../shared/ipc-commands';
import { Playlist } from '../../../../../shared/playlist.interface';
import { DataService } from '../../../services/data.service';
import { PlaylistsService } from '../../../services/playlists.service';
import { Settings, VideoPlayer } from '../../../settings/settings.interface';
import { STORE_KEY } from '../../../shared/enums/store-keys.enum';
import { ChannelQuery, ChannelStore } from '../../../state';
import * as PlaylistActions from '../../../state/actions';
import {
selectActive,
selectChannels,
selectCurrentEpgProgram,
selectPlaylistTitle,
} from '../../../state/selectors';
import { MultiEpgContainerComponent } from '../multi-epg/multi-epg-container.component';
import { EpgProgram } from './../../models/epg-program.model';

/** Possible sidebar view options */
type SidebarView = 'CHANNELS' | 'PLAYLISTS';
export type SidebarView = 'CHANNELS' | 'PLAYLISTS';

export const COMPONENT_OVERLAY_REF = new InjectionToken(
'COMPONENT_OVERLAY_REF'
);

@Component({
selector: 'app-video-player',
templateUrl: './video-player.component.html',
styleUrls: ['./video-player.component.scss'],
})
export class VideoPlayerComponent implements OnInit, OnDestroy {
/** Active selected channel */
activeChannel$: Observable<Channel> = this.channelQuery
.select((state) => state.active)
.pipe(filter((channel) => Boolean(channel)));
activeChannel$ = this.store
.select(selectActive)
.pipe(filter((channel) => Boolean(channel?.url)));

/** Channels list */
channels$: Observable<Channel[]> = this.channelQuery.selectAll();

/** EPG availability flag */
epgAvailable$: Observable<boolean> = this.channelQuery.select(
(store) => store.epgAvailable
);
channels$!: Observable<Channel[]>;

/** Current epg program */
epgProgram$: Observable<EpgProgram> = this.channelQuery.select(
(store) => store.currentEpgProgram
);

/** Favorites list */
favorites$: Observable<string[]> = this.channelQuery.select(
(store) => store.favorites
);
epgProgram$ = this.store.select(selectCurrentEpgProgram);

/** Selected video player options */
playerSettings: Partial<Settings> = {
player: VideoPlayer.VideoJs,
showCaptions: false,
};

/** Playlists array */
playlists = [];

/** Sidebar object */
@ViewChild('sidenav') sideNav: MatSidenav;

/** ID of the current playlist */
playlistId$ = this.channelQuery.select().pipe(
skipWhile(
(store) => store.playlistId === '' || store.playlistId === undefined
),
map((data) => data.playlistId)
);

/** Title of the current playlist */
playlistTitle$ = this.channelQuery.select().pipe(
skipWhile((store) => store.playlistFilename === ''),
map((store) => store.playlistFilename)
);

isElectron = this.dataService.isElectron;
playlistTitle$ = this.store
.select(selectPlaylistTitle)
.pipe(skipWhile((playlistFilename) => playlistFilename === ''));

/** IPC Renderer commands list with callbacks */
commandsList = [
{
id: PLAYLIST_PARSE_RESPONSE,
execute: (response: { payload: Playlist }): void => {
this.channelStore.setPlaylist(response.payload);
this.setSidebarView('CHANNELS');
if (response.payload.isTemporary) {
this.store.dispatch(
PlaylistActions.setChannels({
channels: response.payload.playlist.items,
})
);
} else {
this.store.dispatch(
PlaylistActions.addPlaylist({
playlist: response.payload,
})
);
}
this.sidebarView = 'CHANNELS';
},
},
];

/** Current sidebar view */
sidebarView: SidebarView = 'CHANNELS';

listeners = [];

isElectron = this.dataService.isElectron;

sidebarView: SidebarView = 'CHANNELS';

/** EPG overlay reference */
overlayRef: OverlayRef;

/**
* Creates an instance of VideoPlayerComponent
*/
constructor(
private channelQuery: ChannelQuery,
private channelStore: ChannelStore,
public dataService: DataService,
private activatedRoute: ActivatedRoute,
private dataService: DataService,
private ngZone: NgZone,
private overlay: Overlay,
private router: Router,
private snackBar: MatSnackBar,
private storage: StorageMap
) {
this.dataService.sendIpcEvent(PLAYLIST_GET_ALL);
}
private playlistsService: PlaylistsService,
private storage: StorageMap,
private store: Store
) {}

/**
* Sets video player and subscribes to channel list from the store
*/
ngOnInit(): void {
this.applySettings();
this.setRendererListeners();
this.getPlaylistUrlAsParam();

this.channels$ = this.activatedRoute.params.pipe(
combineLatestWith(this.activatedRoute.queryParams),
switchMap(([params, queryParams]) => {
if (params.id) {
this.store.dispatch(
PlaylistActions.setActivePlaylist({
playlistId: params.id,
})
);
return this.playlistsService.getPlaylistChannels(params.id);
} else if (queryParams.url) {
return this.store.select(selectChannels);
}
})
);
}

/**
* Opens a playlist provided as a url param
* e.g. iptvnat.or?url=http://...
*/
getPlaylistUrlAsParam() {
const URL_REGEX = /^(http|https|file):\/\/[^ "]+$/;
const playlistUrl = this.activatedRoute.snapshot.queryParams.url;

if (playlistUrl && playlistUrl.match(URL_REGEX)) {
this.dataService.sendIpcEvent(PLAYLIST_PARSE_BY_URL, {
url: playlistUrl,
isTemporary: true,
});
}
}

/**
* Set electrons main process listeners
*/
setRendererListeners(): void {
this.commandsList.forEach((command) => {
if (this.dataService.isElectron) {
if (this.isElectron) {
this.dataService.listenOn(command.id, (event, response) =>
this.ngZone.run(() => command.execute(response))
);
Expand Down Expand Up @@ -169,41 +189,8 @@ export class VideoPlayerComponent implements OnInit, OnDestroy {
});
}

/**
* Closes the channels sidebar
*/
close(): void {
this.sideNav.close();
}

/**
* Adds/removes a given channel to the favorites list
* @param channel channel to add
*/
addToFavorites(channel: Channel): void {
this.snackBar.open('Favorites were updated!', null, { duration: 2000 });
this.channelStore.updateFavorite(channel);
}

/**
* Switches the sidebar view to the specified value
* @param view view to change
*/
setSidebarView(view: SidebarView) {
this.sidebarView = view;
}

/** Navigates back */
goBack(): void {
if (this.sidebarView === 'PLAYLISTS') {
this.router.navigate(['/']);
} else {
this.sidebarView = 'PLAYLISTS';
}
}

ngOnDestroy() {
if (this.dataService.isElectron) {
if (this.isElectron) {
this.dataService.removeAllListeners(PLAYLIST_PARSE_RESPONSE);
} else {
this.listeners.forEach((listener) =>
Expand Down
6 changes: 6 additions & 0 deletions src/app/player/player.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,15 @@ import { NgModule } from '@angular/core';
import { RouterModule, Routes } from '@angular/router';
import { SharedModule } from '../shared/shared.module';
import { ChannelListContainerComponent } from './components/channel-list-container/channel-list-container.component';
import { ChannelListItemComponent } from './components/channel-list-container/channel-list-item/channel-list-item.component';
import { EpgItemDescriptionComponent } from './components/epg-list/epg-item-description/epg-item-description.component';
import { EpgListItemComponent } from './components/epg-list/epg-list-item/epg-list-item.component';
import { EpgListComponent } from './components/epg-list/epg-list.component';
import { HtmlVideoPlayerComponent } from './components/html-video-player/html-video-player.component';
import { InfoOverlayComponent } from './components/info-overlay/info-overlay.component';
import { MultiEpgContainerComponent } from './components/multi-epg/multi-epg-container.component';
import { SidebarComponent } from './components/video-player/sidebar/sidebar.component';
import { ToolbarComponent } from './components/video-player/toolbar/toolbar.component';
import { VideoPlayerComponent } from './components/video-player/video-player.component';
import { VjsPlayerComponent } from './components/vjs-player/vjs-player.component';

Expand All @@ -24,12 +27,15 @@ const routes: Routes = [{ path: '', component: VideoPlayerComponent }];
],
declarations: [
ChannelListContainerComponent,
ChannelListItemComponent,
EpgItemDescriptionComponent,
EpgListComponent,
EpgListItemComponent,
InfoOverlayComponent,
HtmlVideoPlayerComponent,
MultiEpgContainerComponent,
SidebarComponent,
ToolbarComponent,
VideoPlayerComponent,
VjsPlayerComponent,
],
Expand Down
16 changes: 9 additions & 7 deletions src/app/services/dialog.service.spec.ts
Original file line number Diff line number Diff line change
@@ -1,17 +1,19 @@
import { ConfirmDialogComponent } from './../shared/components/confirm-dialog/confirm-dialog.component';
import { MockModule, MockComponent } from 'ng-mocks';
import { inject, TestBed } from '@angular/core/testing';
import { MatDialog, MatDialogModule } from '@angular/material/dialog';
import { TestBed, inject } from '@angular/core/testing';
import { DialogService } from './dialog.service';
import { ConfirmDialogData } from '../shared/components/confirm-dialog/confirm-dialog.component';
import { MockComponent, MockModule } from 'ng-mocks';
import { EMPTY } from 'rxjs';
import { ConfirmDialogData } from '../shared/components/confirm-dialog/confirm-dialog-data.interface';
import { ConfirmDialogComponent } from './../shared/components/confirm-dialog/confirm-dialog.component';
import { DialogService } from './dialog.service';

describe('Service: Dialog', () => {
beforeEach(() => {
TestBed.configureTestingModule({
declarations: [MockComponent(ConfirmDialogComponent)],
providers: [DialogService, MatDialog],
imports: [MockModule(MatDialogModule)],
imports: [
MockModule(MatDialogModule),
MockComponent(ConfirmDialogComponent),
],
});
});

Expand Down
11 changes: 7 additions & 4 deletions src/app/services/dialog.service.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { ConfirmDialogComponent } from './../shared/components/confirm-dialog/confirm-dialog.component';
import { MatDialog } from '@angular/material/dialog';
import { Injectable } from '@angular/core';
import { ConfirmDialogData } from '../shared/components/confirm-dialog/confirm-dialog.component';
import { MatDialog } from '@angular/material/dialog';
import { ConfirmDialogData } from '../shared/components/confirm-dialog/confirm-dialog-data.interface';
import { ConfirmDialogComponent } from './../shared/components/confirm-dialog/confirm-dialog.component';

@Injectable({
providedIn: 'root',
Expand All @@ -14,7 +14,10 @@ export class DialogService {
* @param data dialog meta info
*/
openConfirmDialog(data: ConfirmDialogData): void {
const dialogRef = this.dialog.open(ConfirmDialogComponent, {
const dialogRef = this.dialog.open<
ConfirmDialogComponent,
ConfirmDialogData
>(ConfirmDialogComponent, {
data,
width: '300px',
});
Expand Down
11 changes: 6 additions & 5 deletions src/app/services/epg.service.spec.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import { inject, TestBed } from '@angular/core/testing';
import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar';
import { MockStore, provideMockStore } from '@ngrx/store/testing';
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';

Expand All @@ -12,6 +12,7 @@ describe('EpgService', () => {
providers: [
EpgService,
MockProviders(DataService, TranslateService, MatSnackBar),
provideMockStore()
],
imports: [MockModule(MatSnackBarModule)],
});
Expand All @@ -34,17 +35,17 @@ describe('EpgService', () => {
));

it('should handle epg download success', inject(
[MatSnackBar, ChannelStore, EpgService],
[MatSnackBar, MockStore, EpgService],
(
snackbar: MatSnackBar,
channelStore: ChannelStore,
channelStore: MockStore,
service: EpgService
) => {
jest.spyOn(snackbar, 'open');
jest.spyOn(channelStore, 'setEpgAvailableFlag');
jest.spyOn(channelStore, 'dispatch');
service.onEpgFetchDone();
expect(snackbar.open).toHaveBeenCalledTimes(1);
expect(channelStore.setEpgAvailableFlag).toHaveBeenCalledWith(true);
expect(channelStore.dispatch).toHaveBeenCalledWith({value: true, type: expect.stringContaining('active epg')});
}
));
});
11 changes: 8 additions & 3 deletions src/app/services/epg.service.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
import { Injectable } from '@angular/core';
import { MatSnackBar, MatSnackBarConfig } from '@angular/material/snack-bar';
import { Store } from '@ngrx/store';
import { TranslateService } from '@ngx-translate/core';
import { EPG_FETCH } from '../../../shared/ipc-commands';
import { ChannelStore } from '../state';
import { setEpgAvailableFlag } from '../state/actions';
import { DataService } from './data.service';

@Injectable({
Expand All @@ -16,7 +17,7 @@ export class EpgService {
};

constructor(
private channelStore: ChannelStore,
private readonly store: Store,
private electronService: DataService,
private snackBar: MatSnackBar,
private translate: TranslateService
Expand All @@ -35,6 +36,10 @@ export class EpgService {
url,
})
);
this.showFetchSnackbar();
}

showFetchSnackbar() {
this.snackBar.open(
this.translate.instant('EPG.FETCH_EPG'),
this.translate.instant('CLOSE'),
Expand All @@ -46,7 +51,7 @@ export class EpgService {
* Handles the event when the EPG fetching is done
*/
onEpgFetchDone(): void {
this.channelStore.setEpgAvailableFlag(true);
this.store.dispatch(setEpgAvailableFlag({ value: true }));
this.snackBar.open(
this.translate.instant('EPG.DOWNLOAD_SUCCESS'),
null,
Expand Down
215 changes: 215 additions & 0 deletions src/app/services/playlists.service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,215 @@
/* eslint-disable @typescript-eslint/no-unused-vars */
import { Injectable } from '@angular/core';
import { parse } from 'iptv-playlist-parser';
import { NgxIndexedDBService } from 'ngx-indexed-db';
import { combineLatest, map, Observable, switchMap } from 'rxjs';
import { Channel } from '../../../shared/channel.interface';
import { GLOBAL_FAVORITES_PLAYLIST_ID } from '../../../shared/constants';
import {
Playlist,
PlaylistUpdateState,
} from '../../../shared/playlist.interface';
import {
aggregateFavoriteChannels,
createFavoritesPlaylist,
createPlaylistObject,
} from '../../../shared/playlist.utils';
import { DbStores } from '../indexed-db.config';
import { PlaylistMeta } from '../shared/playlist-meta.type';

@Injectable({
providedIn: 'root',
})
export class PlaylistsService {
constructor(private dbService: NgxIndexedDBService) {}

getAllPlaylists() {
return this.dbService.getAll<Playlist>(DbStores.Playlists).pipe(
map((data) =>
data.map(({ playlist, items, header, ...rest }) => ({
...rest,
}))
),
map((playlists) =>
playlists.sort((a, b) => a.position - b.position)
)
);
}

addPlaylist(playlist) {
return this.dbService.add(DbStores.Playlists, playlist);
}

getPlaylistChannels(id: string) {
let playlist$: Observable<Partial<Playlist>>;
if (id === GLOBAL_FAVORITES_PLAYLIST_ID) {
playlist$ = this.getPlaylistWithGlobalFavorites();
} else {
playlist$ = this.dbService.getByID<Playlist>(
DbStores.Playlists,
id
);
}
return playlist$.pipe(
map((data: Playlist) => data.playlist.items as Channel[])
);
}

deletePlaylist(playlistId: string) {
return this.dbService.delete(DbStores.Playlists, playlistId);
}

updatePlaylist(playlistId: string, updatedPlaylist: Playlist) {
return this.getPlaylistById(playlistId).pipe(
switchMap((currentPlaylist: Playlist) =>
this.dbService.update(DbStores.Playlists, {
...currentPlaylist,
...updatedPlaylist,
count: updatedPlaylist.playlist.items.length,
updateDate: Date.now(),
updateState: PlaylistUpdateState.UPDATED,
})
)
);
}

getPlaylistById(id: string) {
return this.dbService.getByID<Playlist>(DbStores.Playlists, id);
}

updatePlaylistMeta(updatedPlaylist: PlaylistMeta) {
return this.getPlaylistById(updatedPlaylist._id).pipe(
switchMap((playlist) =>
this.dbService.update(DbStores.Playlists, {
...playlist,
title: updatedPlaylist.title,
autoRefresh: updatedPlaylist.autoRefresh,
})
)
);
}

updateFavorites(id: string, favorites: string[]) {
return this.getPlaylistById(id).pipe(
switchMap((playlist) =>
this.dbService.update(DbStores.Playlists, {
...playlist,
favorites,
})
)
);
}

updateManyPlaylists(playlists: Playlist[]) {
return combineLatest(
playlists.map((playlist) => {
return this.dbService.update(DbStores.Playlists, {
...playlist,
updateDate: Date.now(),
autoRefresh: true,
});
})
);
}

getFavoriteChannels(playlistId: string) {
return this.dbService
.getByID<Playlist>(DbStores.Playlists, playlistId)
.pipe(
map((data) =>
data.playlist.items.filter((channel) =>
data.favorites.includes((channel as Channel).id)
)
)
);
}

updatePlaylistPositions(
positionUpdates: {
id: string;
changes: { position: number };
}[]
) {
return combineLatest(
positionUpdates.map((item, index) => {
return this.dbService.getByID(DbStores.Playlists, item.id).pipe(
switchMap((playlist: Playlist) =>
this.dbService.update(DbStores.Playlists, {
...playlist,
position: item.changes.position,
})
)
);
})
);
}

handlePlaylistParsing(
uploadType: 'FILE' | 'URL' | 'TEXT',
playlist: string,
title: string,
path?: string
) {
const parsedPlaylist = parse(playlist);
return createPlaylistObject(title, parsedPlaylist, path, uploadType);
}

getPlaylistWithGlobalFavorites() {
return this.dbService.getAll(DbStores.Playlists).pipe(
map((playlists: Playlist[]) => {
const favoriteChannels = aggregateFavoriteChannels(playlists);
const favPlaylist = createFavoritesPlaylist(favoriteChannels);
return favPlaylist;
})
);
}

addManyPlaylists(playlists: Playlist[]) {
return this.dbService.bulkAdd(DbStores.Playlists, playlists as any); // TODO: update ngx-indexed-db
}

getPlaylistsForAutoUpdate() {
return this.dbService.getAll(DbStores.Playlists).pipe(
map((playlists: Playlist[]) => {
return playlists
.filter((item) => item.autoRefresh)
.map(
({ playlist, header, items, favorites, ...rest }) =>
rest
);
})
);
}

setFavorites(playlistId: string, favorites: string[]) {
return this.getPlaylistById(playlistId).pipe(
switchMap((playlist) =>
this.dbService.update(DbStores.Playlists, {
...playlist,
favorites,
})
)
);
}

getRawPlaylistById(id: string) {
return this.dbService.getByID<Playlist>(DbStores.Playlists, id).pipe(
map((playlist) => {
return (
// eslint-disable-next-line @typescript-eslint/restrict-plus-operands
playlist.playlist.header.raw +
'\n' +
playlist.playlist.items.map((item) => item.raw).join('\n')
);
})
);
}

getAllData() {
return this.dbService.getAll<Playlist>(DbStores.Playlists);
}

removeAll() {
return this.dbService.clear(DbStores.Playlists);
}
}
419 changes: 72 additions & 347 deletions src/app/services/pwa.service.ts

Large diffs are not rendered by default.

4 changes: 2 additions & 2 deletions src/app/services/settings.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,8 +59,8 @@ export class SettingsService {
* Returns the version of the released app
*/
getAppVersion() {
return this.http.get<string>(PACKAGE_JSON_URL).pipe(
map((response) => response['version']),
return this.http.get<{ version: string }>(PACKAGE_JSON_URL).pipe(
map((response) => response.version),
catchError((err) => {
console.error(err);
throw new Error(err);
Expand Down
1 change: 1 addition & 0 deletions src/app/settings/language.enum.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,4 +9,5 @@ export enum Language {
SPANISH = 'es',
CHINESE = 'zh',
FRENCH = 'fr',
ITALIAN = 'it',
}
428 changes: 233 additions & 195 deletions src/app/settings/settings.component.html

Large diffs are not rendered by default.

44 changes: 27 additions & 17 deletions src/app/settings/settings.component.scss
Original file line number Diff line number Diff line change
@@ -1,31 +1,41 @@
.card-header {
margin-top: 15px;
margin-bottom: 15px;
}

button {
cursor: pointer !important;
text-transform: uppercase;
}

.mat-card {
border-radius: 0 !important;
.settings-container {
overflow: auto;
height: calc(100vh - 140px);
}

.mat-list-item {
height: 70px !important;
.row {
display: flex;
padding: 10px;

:nth-child(2) {
text-align: right;

mat-form-field {
font-size: 14px;
}
}
}

.settings-container {
overflow: auto;
height: calc(100vh - 140px);
.column {
flex: 1;

.mat-card {
box-shadow: none;
background: none;
p {
font-size: 0.9em;
opacity: 0.6;
padding: 2px 0;
margin: 0;
}
}

.mat-form-field {
width: 100%;
.action-buttons {
margin-top: 10px;

button {
margin: 0 10px;
}
}
51 changes: 36 additions & 15 deletions src/app/settings/settings.component.spec.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
import { HttpClientTestingModule } from '@angular/common/http/testing';
import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing';
import { UntypedFormBuilder, FormsModule, ReactiveFormsModule } from '@angular/forms';
import {
FormsModule,
ReactiveFormsModule,
UntypedFormBuilder,
} from '@angular/forms';
import { MatCardModule } from '@angular/material/card';
import { MatCheckboxModule } from '@angular/material/checkbox';
import { MatDividerModule } from '@angular/material/divider';
Expand All @@ -12,21 +16,34 @@ import { MatSnackBar } from '@angular/material/snack-bar';
import { MatTooltipModule } from '@angular/material/tooltip';
import { Router } from '@angular/router';
import { RouterTestingModule } from '@angular/router/testing';
import { provideMockStore } from '@ngrx/store/testing';
import { StorageMap } from '@ngx-pwa/local-storage';
import { TranslatePipe, TranslateService } from '@ngx-translate/core';
import { MockComponent, MockModule, MockPipe } from 'ng-mocks';
import {
TranslateModule,
TranslatePipe,
TranslateService,
} from '@ngx-translate/core';
import {
MockComponent,
MockModule,
MockPipe,
MockProvider,
MockProviders,
} from 'ng-mocks';
import { of } from 'rxjs';
import { EPG_FETCH } from '../../../shared/ipc-commands';
/* eslint-disable @typescript-eslint/unbound-method */
import { EPG_FORCE_FETCH } from '../../../shared/ipc-commands';
import { DataService } from '../services/data.service';
import { ElectronServiceStub } from '../services/electron.service.stub';
import { TranslateServiceStub } from './../../testing/translate.stub';
import { HeaderComponent } from './../shared/components/header/header.component';
import { HeaderComponent } from '../shared/components';
import { SharedModule } from '../shared/shared.module';
import { Language } from './language.enum';
import { SettingsComponent } from './settings.component';
import { VideoPlayer } from './settings.interface';
import { Theme } from './theme.enum';

import { NgxIndexedDBService } from 'ngx-indexed-db';
import { PlaylistsService } from '../services/playlists.service';

class MatSnackBarStub {
open(): void {}
}
Expand Down Expand Up @@ -63,17 +80,16 @@ describe('SettingsComponent', () => {
],
providers: [
UntypedFormBuilder,
MockProvider(TranslateService),
{ provide: MatSnackBar, useClass: MatSnackBarStub },
{
provide: TranslateService,
useClass: TranslateServiceStub,
},
{ provide: DataService, useClass: ElectronServiceStub },
{
provide: Router,
useClass: MockRouter,
},
StorageMap,
provideMockStore(),
MockProviders(NgxIndexedDBService, PlaylistsService),
],
imports: [
HttpClientTestingModule,
Expand All @@ -88,6 +104,8 @@ describe('SettingsComponent', () => {
MockModule(MatFormFieldModule),
MockModule(MatCheckboxModule),
MockModule(MatDividerModule),
MockModule(TranslateModule),
MockModule(SharedModule),
],
}).compileComponents();
})
Expand All @@ -99,6 +117,7 @@ describe('SettingsComponent', () => {
storage = TestBed.inject(StorageMap);
router = TestBed.inject(Router);
translate = TestBed.inject(TranslateService);

component = fixture.componentInstance;
fixture.detectChanges();
});
Expand Down Expand Up @@ -168,10 +187,12 @@ describe('SettingsComponent', () => {

it('should send epg fetch command', () => {
jest.spyOn(electronService, 'sendIpcEvent');
component.fetchEpg(['']);
expect(electronService.sendIpcEvent).toHaveBeenCalledWith(EPG_FETCH, {
url: '',
});
const url = 'http://epg-url-here/data.xml';
component.refreshEpg(url);
expect(electronService.sendIpcEvent).toHaveBeenCalledWith(
EPG_FORCE_FETCH,
url
);
});

it('should navigate back to home page', () => {
Expand Down
136 changes: 116 additions & 20 deletions src/app/settings/settings.component.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { CommonModule } from '@angular/common';
import { Component, OnInit } from '@angular/core';
import {
UntypedFormArray,
Expand All @@ -7,22 +8,30 @@ import {
} from '@angular/forms';
import { MatSnackBar } from '@angular/material/snack-bar';
import { Router } from '@angular/router';
import { Store } from '@ngrx/store';
import { TranslateService } from '@ngx-translate/core';
import { Observable } from 'rxjs';
import { take } from 'rxjs';
import * as semver from 'semver';
import { EPG_FORCE_FETCH } from '../../../shared/ipc-commands';
import { Playlist } from '../../../shared/playlist.interface';
import { DataService } from '../services/data.service';
import { DialogService } from '../services/dialog.service';
import { EpgService } from '../services/epg.service';
import { PlaylistsService } from '../services/playlists.service';
import { STORE_KEY } from '../shared/enums/store-keys.enum';
import { ChannelQuery } from '../state';
import { SharedModule } from '../shared/shared.module';
import * as PlaylistActions from '../state/actions';
import { selectIsEpgAvailable } from '../state/selectors';
import { SettingsService } from './../services/settings.service';
import { Language } from './language.enum';
import { Settings, VideoPlayer } from './settings.interface';
import { Theme } from './theme.enum';

@Component({
selector: 'app-settings',
templateUrl: './settings.component.html',
styleUrls: ['./settings.component.scss'],
standalone: true,
imports: [CommonModule, SharedModule],
})
export class SettingsComponent implements OnInit {
/** List with available languages as enum */
Expand All @@ -47,9 +56,7 @@ export class SettingsComponent implements OnInit {
updateMessage: string;

/** EPG availability flag */
epgAvailable$: Observable<boolean> = this.channelQuery.select(
(store) => store.epgAvailable
);
epgAvailable$ = this.store.select(selectIsEpgAvailable);

/** Flag that indicates whether the app runs in electron environment */
isElectron = this.electronService.isElectron;
Expand All @@ -74,13 +81,15 @@ export class SettingsComponent implements OnInit {
* required dependencies into the component
*/
constructor(
private channelQuery: ChannelQuery,
private dialogService: DialogService,
private electronService: DataService,
private epgService: EpgService,
private formBuilder: UntypedFormBuilder,
private playlistsService: PlaylistsService,
private router: Router,
private settingsService: SettingsService,
private snackBar: MatSnackBar,
private store: Store,
private translate: TranslateService
) {}

Expand All @@ -105,7 +114,7 @@ export class SettingsComponent implements OnInit {
player: settings.player
? settings.player
: VideoPlayer.VideoJs,
...(this.isElectron ? { epgUrl: new Array() } : {}),
...(this.isElectron ? { epgUrl: [] } : {}),
language: settings.language
? settings.language
: Language.ENGLISH,
Expand Down Expand Up @@ -134,13 +143,11 @@ export class SettingsComponent implements OnInit {
if (!Array.isArray(epgUrls)) {
epgUrls = [epgUrls];
}
epgUrls = epgUrls.filter((url) => url !== '');

for (const url of epgUrls) {
this.epgUrl.push(
new UntypedFormControl(url, [
Validators.required,
Validators.pattern(URL_REGEX),
])
new UntypedFormControl(url, [Validators.pattern(URL_REGEX)])
);
}
}
Expand All @@ -151,9 +158,10 @@ export class SettingsComponent implements OnInit {
* settings UI
*/
checkAppVersion(): void {
this.settingsService.getAppVersion().subscribe((version) => {
this.showVersionInformation(version);
});
this.settingsService
.getAppVersion()
.pipe(take(1))
.subscribe((version) => this.showVersionInformation(version));
}

/**
Expand Down Expand Up @@ -199,6 +207,7 @@ export class SettingsComponent implements OnInit {
this.settingsForm.value,
true
)
.pipe(take(1))
.subscribe(() => {
this.applyChangedSettings();
});
Expand All @@ -211,8 +220,13 @@ export class SettingsComponent implements OnInit {
this.settingsForm.markAsPristine();
// check whether the epg url was changed or not
if (this.isElectron) {
if (this.settingsForm.value.epgUrl) {
this.fetchEpg(this.settingsForm.value.epgUrl);
let epgUrls = this.settingsForm.value.epgUrl;
if (epgUrls) {
if (!Array.isArray(epgUrls)) {
epgUrls = [epgUrls];
}
epgUrls = epgUrls.filter((url) => url !== '');
this.epgService.fetchEpg(epgUrls);
}
}
this.translate.use(this.settingsForm.value.language);
Expand All @@ -235,10 +249,11 @@ export class SettingsComponent implements OnInit {

/**
* Fetches and updates EPG from the given URL
* @param urls epg source urls
* @param url epg source url
*/
fetchEpg(urls: string | string[]): void {
this.epgService.fetchEpg(urls);
refreshEpg(url: string): void {
this.electronService.sendIpcEvent(EPG_FORCE_FETCH, url);
this.epgService.showFetchSnackbar();
}

/**
Expand All @@ -256,4 +271,85 @@ export class SettingsComponent implements OnInit {
this.epgUrl.removeAt(index);
this.settingsForm.markAsDirty();
}

exportData() {
this.playlistsService
.getAllData()
.pipe(take(1))
.subscribe((data) => {
const blob = new Blob([JSON.stringify(data)], {
type: 'text/plain',
});
const url = window.URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = 'playlists.json';
link.click();
window.URL.revokeObjectURL(url);
});
}

importData() {
const input = document.createElement('input');
input.type = 'file';
input.accept = 'application/json';

input.addEventListener('change', (event: Event) => {
const target = event.target as HTMLInputElement;
const file = target.files?.[0];

if (file) {
if (file) {
const reader = new FileReader();
reader.onload = () => {
const contents = reader.result;

try {
const parsedPlaylists: Playlist[] = JSON.parse(
contents.toString()
);

if (!Array.isArray(parsedPlaylists)) {
this.snackBar.open(
this.translate.instant(
'SETTINGS.IMPORT_ERROR'
),
null,
{
duration: 2000,
}
);
} else {
this.store.dispatch(
PlaylistActions.addManyPlaylists({
playlists: parsedPlaylists,
})
);
}
} catch (error) {
this.snackBar.open(
this.translate.instant('SETTINGS.IMPORT_ERROR'),
null,
{
duration: 2000,
}
);
}
};
reader.readAsText(file);
}
}
});

input.click();
}

removeAll() {
this.dialogService.openConfirmDialog({
title: this.translate.instant('SETTINGS.REMOVE_DIALOG.TITLE'),
message: this.translate.instant('SETTINGS.REMOVE_DIALOG.MESSAGE'),
onConfirm: (): void =>
this.store.dispatch(PlaylistActions.removeAllPlaylists()),
});
}
}
11 changes: 0 additions & 11 deletions src/app/settings/settings.module.ts

This file was deleted.

16 changes: 0 additions & 16 deletions src/app/settings/settings.routing.ts

This file was deleted.

File renamed without changes.
38 changes: 0 additions & 38 deletions src/app/shared/components/about-dialog/about-dialog.component.html

This file was deleted.

11 changes: 0 additions & 11 deletions src/app/shared/components/about-dialog/about-dialog.component.scss

This file was deleted.

This file was deleted.

70 changes: 60 additions & 10 deletions src/app/shared/components/about-dialog/about-dialog.component.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,65 @@
import { Component } from '@angular/core';
import { DataService } from '../../../services/data.service';
import { Component, Inject } from '@angular/core';
import { MAT_DIALOG_DATA } from '@angular/material/dialog';
import { MatIconModule } from '@angular/material/icon';
import { TranslateModule } from '@ngx-translate/core';

@Component({
selector: 'app-about-dialog',
templateUrl: './about-dialog.component.html',
styleUrls: ['./about-dialog.component.scss'],
standalone: true,
imports: [MatIconModule, TranslateModule],
template: `
<div mat-dialog-content>
<img src="./assets/icons/icon-tv-256.png" width="128" /><br />
<h2 mat-dialog-title>{{ 'ABOUT.TITLE' | translate }}</h2>
<p>{{ 'ABOUT.DESCRIPTION' | translate }}</p>
<p>{{ 'ABOUT.VERSION' | translate }}: {{ appVersion }}</p>
<p>
<a
href="https://github.com/4gray/iptvnator"
target="_blank"
[title]="'ABOUT.GITHUB_TOOLTIP' | translate"
[attr.aria-label]="'ABOUT.GITHUB_TOOLTIP' | translate"
><img
src="./assets/icons/github-light.png"
[title]="'ABOUT.GITHUB_TOOLTIP' | translate" /></a
>&nbsp;
<a
href="http://twitter.com/share?text=IPTVnator &mdash; free cross-platform IPTV player. Available as PWA and as native application.&url=https://github.com/4gray/iptvnator&hashtags=iptv,m3u,video-player"
[title]="'ABOUT.TWITTER_TOOLTIP' | translate"
>
<img
height="32"
src="./assets/icons/twitter-light.png"
[title]="'ABOUT.TWITTER_TOOLTIP' | translate"
/>
</a>
<a
href="https://www.buymeacoffee.com/4gray"
target="_blank"
[title]="'ABOUT.BUY_ME_A_COFFEE_TOOLTIP' | translate"
[attr.aria-label]="
'ABOUT.BUY_ME_A_COFFEE_TOOLTIP' | translate
"
><mat-icon>local_cafe</mat-icon></a
>
</p>
</div>
`,
styles: [
`
button {
text-transform: uppercase;
}
a {
color: #fff;
}
.mat-icon {
font-size: 32px;
}
`,
],
})
export class AboutDialogComponent {
/** Version of the application */
appVersion = this.dataService.getAppVersion();

/** Default constructor */
constructor(private dataService: DataService) {}
constructor(@Inject(MAT_DIALOG_DATA) public appVersion: string) {}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
export interface ConfirmDialogData {
title: string;
message: string;
confirmLabel?: string;
cancelLabel?: string;
onConfirm: () => void;
}

This file was deleted.

This file was deleted.

Original file line number Diff line number Diff line change
@@ -1,25 +1,32 @@
import { MAT_DIALOG_DATA } from '@angular/material/dialog';
import { Component, Inject } from '@angular/core';

export interface ConfirmDialogData {
title: string;
message: string;
confirmLabel?: string;
cancelLabel?: string;
onConfirm: () => void;
}

import { MatButtonModule } from '@angular/material/button';
import { MatDialogModule, MAT_DIALOG_DATA } from '@angular/material/dialog';
import { TranslateModule } from '@ngx-translate/core';
import { ConfirmDialogData } from './confirm-dialog-data.interface';
@Component({
selector: 'app-confirm-dialog',
templateUrl: './confirm-dialog.component.html',
imports: [MatButtonModule, MatDialogModule, TranslateModule],
standalone: true,
template: `
<h2 mat-dialog-title>
{{ dialogData.title }}
</h2>
<mat-dialog-content class="mat-typography">
{{ dialogData.message }}
</mat-dialog-content>
<mat-dialog-actions align="end">
<button mat-flat-button [mat-dialog-close]="true" color="accent">
{{ dialogData?.confirmLabel || 'YES' | translate }}
</button>
<button mat-button mat-dialog-close cdkFocusInitial color="accent">
{{ dialogData?.cancelLabel || 'NO' | translate }}
</button>
</mat-dialog-actions>
`,
})
export class ConfirmDialogComponent {
/** Contains meta information to show in the dialog */
dialogData;
dialogData!: ConfirmDialogData;

/** Creates an instance of ConfirmDialogComponent
* @param data dialog data
*/
constructor(@Inject(MAT_DIALOG_DATA) data: ConfirmDialogData) {
this.dialogData = data;
}
Expand Down
1 change: 1 addition & 0 deletions src/app/shared/components/header/header.component.html
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
*ngIf="isElectron; else pwaMenu"
mat-icon-button
(click)="openSettings()"
data-test-id="open-settings"
>
<mat-icon>settings</mat-icon>
</button>
Expand Down
3 changes: 2 additions & 1 deletion src/app/shared/components/header/header.component.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ export class HeaderComponent implements OnInit {

isHome = true;

/** Creates an instance of SettingsComponent */
/** Creates an instance of HeaderComponent */
constructor(
private activatedRoute: ActivatedRoute,
private dialog: MatDialog,
Expand Down Expand Up @@ -79,6 +79,7 @@ export class HeaderComponent implements OnInit {
this.dialog.open(AboutDialogComponent, {
panelClass: 'about-dialog-overlay',
width: '600px',
data: this.dataService.getAppVersion(),
});
}
}
16 changes: 16 additions & 0 deletions src/app/shared/pipes/filter.pipe.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import { Injectable, Pipe, PipeTransform } from '@angular/core';

@Pipe({
name: 'filterBy',
})
@Injectable()
export class FilterPipe implements PipeTransform {
transform(array: any[], filter: string, property: string): any {
if (!array || !filter) {
return array;
}
return array.filter((item) =>
item[property].toLowerCase().includes(filter.toLowerCase())
);
}
}
2 changes: 2 additions & 0 deletions src/app/shared/playlist-meta.type.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,4 +13,6 @@ export type PlaylistMeta = Pick<
| 'updateDate'
| 'updateState'
| 'position'
| 'autoRefresh'
| 'favorites'
>;
21 changes: 10 additions & 11 deletions src/app/shared/shared.module.ts
Original file line number Diff line number Diff line change
@@ -1,42 +1,41 @@
import { DragDropModule } from '@angular/cdk/drag-drop';
import { CommonModule } from '@angular/common';
import { NgModule } from '@angular/core';
import { FlexLayoutModule } from '@angular/flex-layout';
import { FormsModule, ReactiveFormsModule } from '@angular/forms';
import { TranslateModule } from '@ngx-translate/core';
import { FilterPipeModule } from 'ngx-filter-pipe';
import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader';
import { NgxWhatsNewModule } from 'ngx-whats-new';
import { PlaylistItemComponent } from '../home/recent-playlists/playlist-item/playlist-item.component';
import { RecentPlaylistsComponent } from '../home/recent-playlists/recent-playlists.component';
import { MaterialModule } from '../material.module';
import { HeaderComponent } from './components/';
import { AboutDialogComponent } from './components/about-dialog/about-dialog.component';
import { ConfirmDialogComponent } from './components/confirm-dialog/confirm-dialog.component';
import { FilterPipe } from './pipes/filter.pipe';
import { MomentDatePipe } from './pipes/moment-date.pipe';

@NgModule({
declarations: [
ConfirmDialogComponent,
FilterPipe,
HeaderComponent,
MomentDatePipe,
AboutDialogComponent,
RecentPlaylistsComponent,
PlaylistItemComponent,
],
imports: [
CommonModule,
FilterPipeModule,
FlexLayoutModule,
FormsModule,
MaterialModule,
NgxWhatsNewModule,
ReactiveFormsModule,
TranslateModule,
DragDropModule,
NgxSkeletonLoaderModule.forRoot({
animation: 'pulse',
loadingText: 'This item is actually loading...',
}),
],
exports: [
ConfirmDialogComponent,
FilterPipeModule,
FlexLayoutModule,
DragDropModule,
FilterPipe,
FormsModule,
HeaderComponent,
MaterialModule,
Expand Down
Loading