feat: add favorites and tab based navigation

This commit is contained in:
4gray committed 2020-07-18 19:02:08 +02:00
1 parent 773e1edf63
commit c6e3e1f025
13 files changed
+244 -99

No files matched your search

@@ -1,29 +1,68 @@
<mat-list id="top-panel">
<mat-list-item>
<mat-form-field class="full-width">
<input matInput placeholder="Search channel ({{channelList?.length}})" [(ngModel)]="searchTerm.name">
</mat-form-field>
<button mat-icon-button [routerLink]="'/'" matTooltip="Upload or select other playlist">
<mat-icon>create_new_folder</mat-icon>
</button>
</mat-list-item>
</mat-list>
<mat-nav-list id="channels-list">
<mat-accordion class="example-headers-align" multi>
<ng-container *ngFor="let groups of channelList | keyvalue">
<mat-expansion-panel *ngIf="groups.value.length > 0">
<mat-expansion-panel-header>
{{groups.key || 'Ungrouped'}} ({{groups.value.length}})
</mat-expansion-panel-header>
<mat-list-item
*ngFor="let channel of groups.value | filterBy: searchTerm; index as i"
<mat-tab-group>
<mat-tab label="All channels">
<mat-list id="top-panel">
<mat-list-item>
<mat-form-field class="full-width">
<input matInput placeholder="Search channel ({{_channelList?.length}})"
[(ngModel)]="searchTerm.name">
</mat-form-field>
<button mat-icon-button [routerLink]="'/'" matTooltip="Upload or select other playlist">
<mat-icon>create_new_folder</mat-icon>
</button>
</mat-list-item>
</mat-list>
<mat-nav-list id="channels-list">
<mat-accordion multi>
<mat-list-item *ngFor="let channel of _channelList | filterBy: searchTerm; index as i"
[class.active]="selected?.id === channel.id" (click)="selectChannel(channel)">
{{i+1 + '. ' + channel?.name || 'Unnamed Channel'}}
<p matLine>
{{ i+1 + '. ' + channel?.name || 'Unnamed Channel' }}
</p>
<button mat-icon-button color="primary" (click)="favChannel(channel, $event)">
<mat-icon>star{{ channel.fav ? '' : '_outline' }}</mat-icon>
</button>
<mat-divider></mat-divider>
</mat-list-item>
</mat-expansion-panel>
</ng-container>
</mat-accordion>
</mat-nav-list>
</mat-accordion>
</mat-nav-list>
</mat-tab>
<mat-tab label="Groups">
<mat-nav-list id="channels-list">
<mat-accordion multi>
<ng-container *ngFor="let groups of groupedChannels | keyvalue">
<mat-expansion-panel>
<mat-expansion-panel-header *ngIf="groups.value.length > 0">
{{groups.key || 'Ungrouped'}} ({{groups.value.length}})
</mat-expansion-panel-header>
<mat-list-item *ngFor="let channel of groups.value; index as i"
[class.active]="selected?.id === channel.id" (click)="selectChannel(channel)">
<p matLine>
{{ i+1 + '. ' + channel?.name || 'Unnamed Channel' }}
</p>
<button mat-icon-button color="primary" (click)="favChannel(channel, $event)">
<mat-icon>star{{ channel.fav ? '' : '_outline' }}</mat-icon>
</button>
<mat-divider></mat-divider>
</mat-list-item>
</mat-expansion-panel>
</ng-container>
</mat-accordion>
</mat-nav-list>
</mat-tab>
<mat-tab label="Favorites">
<mat-nav-list>
<mat-list-item *ngFor="let channel of favs | filterBy: searchTerm; index as i"
[class.active]="selected?.id === channel.id" (click)="selectChannel(channel)">
<p matLine>
{{ i+1 + '. ' + channel?.name || 'Unnamed Channel' }}
</p>
<button mat-icon-button color="primary" (click)="favChannel(channel, $event)">
<mat-icon>star{{ channel.fav ? '' : '_outline' }}</mat-icon>
</button>
<mat-divider></mat-divider>
</mat-list-item>
</mat-nav-list>
</mat-tab>
</mat-tab-group>
@@ -6,11 +6,6 @@
background: #eee;
}
#channels-list {
overflow-y: auto;
height: calc(100% - 65px);
}
.active {
background: #ddd;
}
@@ -22,3 +17,10 @@
::ng-deep .mat-expansion-panel-body {
padding: 0 !important;
}
::ng-deep.mat-tab-label,
::ng-deep.mat-tab-label-active {
min-width: 0 !important;
padding: 3px 10px !important;
margin: 3px 10px !important;
}
@@ -1,29 +1,43 @@
import { Component, Input, Output, EventEmitter } from '@angular/core';
import { Channel } from 'src/app/state';
import { Channel, ChannelStore, ChannelQuery } from 'src/app/state';
import { NgxIndexedDBService } from 'ngx-indexed-db';
import { Playlist } from '../playlist-uploader/playlist-uploader.component';
import * as _ from 'lodash';
import { MatSnackBar } from '@angular/material/snack-bar';
@Component({
selector: 'app-channel-list-container',
templateUrl: './channel-list-container.component.html',
styleUrls: ['./channel-list-container.component.css'],
styleUrls: ['./channel-list-container.component.scss'],
})
export class ChannelListContainerComponent {
/**
* Channels array
* Create local copy of the store for local manipulations without updates in the store
*/
@Input() channelList: Channel[];
_channelList: Channel[];
get channelList(): Channel[] {
return this._channelList;
}
/**
* Selected channel
*/
@Input('channelList')
set channelList(value: Channel[]) {
// deep copy
this._channelList = JSON.parse(JSON.stringify(value));
this.groupedChannels = _.groupBy(this._channelList, 'group.title');
}
/** Object with channels sorted by grouped */
groupedChannels: { [key: string]: Channel[] };
/** Selected channel */
selected: Channel;
/**
* Emits on channel change
*/
@Output() changeChannel: EventEmitter<{
url: string;
name: string;
}> = new EventEmitter();
/** Emits on channel change */
@Output() changeChannel: EventEmitter<Channel> = new EventEmitter();
/** List with favorited channels */
favs: Channel[] = [];
/**
* Search term for channel filter
@@ -32,12 +46,65 @@ export class ChannelListContainerComponent {
name: '',
};
/**
* Creates an instance of ChannelListContainerComponent
* @param channelQuery akita's channel query
* @param channelStore akita's channel store
* @param dbService service to work with indexed db
* @param snackBar service to push snackbar notifications
*/
constructor(
private channelQuery: ChannelQuery,
private channelStore: ChannelStore,
private dbService: NgxIndexedDBService,
private snackBar: MatSnackBar
) {
this.channelQuery
.selectAll({
filterBy: (entity) => entity.fav === true,
})
.subscribe(
(favs) => (this.favs = JSON.parse(JSON.stringify(favs)))
);
}
/**
* Sets clicked channel as selected and emits them to the parent component
* @param channel selected channel
*/
selectChannel(channel: Channel): void {
this.selected = channel;
this.changeChannel.emit({ url: channel.url, name: channel.name });
this.changeChannel.emit(channel);
}
/**
* Toggles favorite flag for the given channel
* @param channel channel to update
* @param clickEvent mouse click event
*/
favChannel(channel: Channel, clickEvent: MouseEvent): void {
clickEvent.stopPropagation();
channel.fav = !channel.fav;
this.snackBar.open('Favorites were updated!');
this.channelStore.update(channel.id, { fav: channel.fav });
// update channels fav flag in the indexed db
this.channelStore.update((store) => {
const favorites = channel.fav
? [...store.favorites, channel.id]
: [...store.favorites.filter((favId) => channel.id !== favId)];
this.dbService
.getByID('playlists', store.playlistId)
.then((dataset: Playlist) => {
this.dbService.update('playlists', {
...dataset,
favorites,
});
});
return {
favorites,
};
});
}
}
@@ -6,23 +6,25 @@ import {
humanizeBytes,
UploaderOptions,
} from 'ngx-uploader';
import { ChannelStore, createChannel, Channel } from 'src/app/state';
import { ChannelStore, createChannel } from 'src/app/state';
import { M3uService } from 'src/app/services/m3u-service.service';
import { Router } from '@angular/router';
import { NgxIndexedDBService } from 'ngx-indexed-db';
import { PLAYLISTS_STORE } from 'src/app/db.config';
import { MatSnackBar } from '@angular/material/snack-bar';
import { guid, ID } from '@datorama/akita';
/**
* Describes playlist interface
*/
export interface Playlist {
id?: number;
id?: ID;
title: string;
filename: string;
playlist: any;
importDate: number;
lastUsage: number;
favorites: string[];
}
@Component({
@@ -46,6 +48,7 @@ export class PlaylistUploaderComponent {
* @param dbService indexeddb service
* @param m3uService m3u service
* @param router angulars router
* @param snackBar snackbars with notification messages
*/
constructor(
private channelStore: ChannelStore,
@@ -74,16 +77,8 @@ export class PlaylistUploaderComponent {
this.isLoading = true;
if (this.files.length > 0) {
const fileReader = new FileReader();
fileReader.onload = (fileLoadedEvent) => {
const result = (fileLoadedEvent.target as FileReader)
.result;
const array = (result as string).split('\n');
const playlist = this.m3uService.parsePlaylist(array);
this.savePlaylist(this.files[0].name, playlist);
this.setPlaylist(playlist);
};
fileReader.onload = (fileLoadedEvent) =>
this.handlePlaylist(fileLoadedEvent);
fileReader.readAsText(this.files[0].nativeFile);
}
} else if (
@@ -119,6 +114,18 @@ export class PlaylistUploaderComponent {
}
}
/**
* Parse and store uploaded playlist
* @param fileLoadedEvent
*/
handlePlaylist(fileLoadedEvent: any): void {
const result = (fileLoadedEvent.target as FileReader).result;
const array = (result as string).split('\n');
const playlist = this.m3uService.parsePlaylist(array);
const playlistObject = this.savePlaylist(this.files[0].name, playlist);
this.setPlaylist(playlistObject);
}
/**
* Navigates to the video player route
*/
@@ -132,18 +139,20 @@ export class PlaylistUploaderComponent {
* @param name name of the playlist
* @param playlist playlist to save
*/
savePlaylist(name: string, playlist: any): void {
this.dbService
.add<Playlist>('playlists', {
filename: name,
title: name,
playlist,
importDate: new Date().getMilliseconds(),
lastUsage: new Date().getMilliseconds(),
})
.then(() => {
console.log('playlist saved!');
});
savePlaylist(name: string, playlist: any): Playlist {
const playlistObject = {
id: guid(),
filename: name,
title: name,
playlist,
importDate: new Date().getMilliseconds(),
lastUsage: new Date().getMilliseconds(),
favorites: [],
};
this.dbService.add<Playlist>('playlists', playlistObject).then(() => {
console.log('playlist saved!');
});
return playlistObject;
}
/**
@@ -160,15 +169,19 @@ export class PlaylistUploaderComponent {
/**
* Sets the given playlist as active for the current session
* @param playlist m3u playlist
* // TODO: use typings from iptv-parser lib
* @param playlist playlist object
*/
setPlaylist(playlist: { header: any; items: Channel[] }): void {
setPlaylist(playlist: Playlist): void {
this.channelStore.reset();
const channels = playlist.items.map((element) =>
createChannel(element)
const favorites = playlist.favorites || [];
const channels = playlist.playlist.items.map((element) =>
createChannel(element, favorites)
);
this.channelStore.upsertMany(channels);
this.channelStore.update(() => ({
favorites,
playlistId: playlist.id,
}));
this.navigateToPlayer();
}
@@ -1,6 +1,6 @@
<mat-nav-list>
<div mat-subheader>Recently added playlists ({{playlists.length}})</div>
<a mat-list-item *ngFor="let item of playlists; last as last" (click)="playlistClicked.emit(item.playlist);">
<a mat-list-item *ngFor="let item of playlists; last as last" (click)="playlistClicked.emit(item)">
<mat-icon mat-list-icon>folder</mat-icon>
<div mat-line>{{ item.title || item.filename }}</div>
<div mat-line>Channels: {{ item.playlist?.items?.length }}</div>
@@ -11,7 +11,7 @@ export class RecentPlaylistsComponent {
@Input() playlists: Playlist[];
/** Emits on playlist selection */
@Output() playlistClicked: EventEmitter<any> = new EventEmitter();
@Output() playlistClicked: EventEmitter<Playlist> = new EventEmitter();
/** Emits on playlist remove click */
@Output() removeClicked: EventEmitter<Playlist> = new EventEmitter();
@@ -1,21 +1,21 @@
#video-player {
width: 100%;
height: calc(100vh - 70px);
width: 100%;
height: calc(100vh - 72px);
}
.main-container {
position: absolute;
top: 0;
bottom: 0;
left: 0;
right: 0;
position: absolute;
top: 0;
bottom: 0;
left: 0;
right: 0;
}
.mat-drawer-content {
background: #000;
background: #000;
}
.mat-drawer {
width: 400px;
min-width: 400px;
width: 400px;
min-width: 400px;
}
@@ -37,15 +37,17 @@ export class VideoPlayerComponent implements OnInit {
*/
@ViewChild('sidenav') sideNav: MatSidenav;
/**
* Creates an instance of VideoPlayerOmponent
* @param channelQuery akita's channel query
*/
constructor(private channelQuery: ChannelQuery) {}
/**
* Sets video player and subscribes to channel list from the store
*/
ngOnInit(): void {
this.channels$ = this.channelQuery
.selectAll()
.pipe(map((channels) => _.groupBy(channels, 'group.title')));
this.channels$ = this.channelQuery.selectAll();
this.videoPlayer = document.getElementById(
'video-player'
) as HTMLVideoElement;
+2
View File
@@ -7,6 +7,7 @@ import { MatInputModule } from '@angular/material/input';
import { MatListModule } from '@angular/material/list';
import { MatProgressBarModule } from '@angular/material/progress-bar';
import { MatSidenavModule } from '@angular/material/sidenav';
import { MatTabsModule } from '@angular/material/tabs';
import { MatToolbarModule } from '@angular/material/toolbar';
import { MatSnackBarModule } from '@angular/material/snack-bar';
@@ -21,6 +22,7 @@ import { MatSnackBarModule } from '@angular/material/snack-bar';
MatProgressBarModule,
MatSidenavModule,
MatSnackBarModule,
MatTabsModule,
MatToolbarModule,
],
})
+7 -2
View File
@@ -11,17 +11,22 @@ export interface Channel {
group: {
title: string;
};
fav: boolean;
}
/**
* Creates new channel object based on the given fields
* @param params partial channel object
*/
export function createChannel(params: Partial<Channel>) {
export function createChannel(
params: Partial<Channel>,
favoritesList: string[]
) {
return {
id: guid(),
id: params.url,
name: params.name,
group: params.group,
url: params.url,
fav: favoritesList.includes(params.url),
} as Channel;
}
+3 -5
View File
@@ -4,9 +4,7 @@ import { ChannelStore, ChannelState } from './channel.store';
@Injectable({ providedIn: 'root' })
export class ChannelQuery extends QueryEntity<ChannelState> {
constructor(protected store: ChannelStore) {
super(store);
}
constructor(protected store: ChannelStore) {
super(store);
}
}
+12 -2
View File
@@ -2,12 +2,22 @@ import { Injectable } from '@angular/core';
import { EntityState, EntityStore, StoreConfig } from '@datorama/akita';
import { Channel } from './channel.model';
export interface ChannelState extends EntityState<Channel> {}
export interface ChannelState extends EntityState<Channel> {
favorites: string[];
playlistId: string;
}
@Injectable({ providedIn: 'root' })
@StoreConfig({ name: 'channel', resettable: true })
export class ChannelStore extends EntityStore<ChannelState> {
constructor() {
super();
super({
favorites: [],
playlistId: '',
});
}
setChannels(channels: Channel[]): void {
this.upsertMany(channels);
}
}
+8 -1
View File
@@ -6,7 +6,14 @@
"version": "0.0.1",
"version_name": "0.0.1",
"icons": {
"144": "assets/icons/icon-144x144.png"
"72": "assets/icons/icon-72x72.png",
"96": "assets/icons/icon-96x96.png",
"128": "assets/icons/icon-128x128.png",
"144": "assets/icons/icon-144x144.png",
"152": "assets/icons/icon-152x152.png",
"192": "assets/icons/icon-192x192.png",
"384": "assets/icons/icon-384x384.png",
"512": "assets/icons/icon-512x512.png"
},
"app": {
"background": {