From 8d333d95203ec4344f44df0678c70d6840ae1bb0 Mon Sep 17 00:00:00 2001 From: 4gray Date: Sat, 18 Jul 2020 19:02:08 +0200 Subject: [PATCH] feat: add favorites and tab based navigation --- .../channel-list-container.component.html | 91 +++++++++++++----- ... => channel-list-container.component.scss} | 12 ++- .../channel-list-container.component.ts | 95 ++++++++++++++++--- .../playlist-uploader.component.ts | 71 ++++++++------ .../recent-playlists.component.html | 2 +- .../recent-playlists.component.ts | 2 +- .../video-player/video-player.component.css | 20 ++-- .../video-player/video-player.component.ts | 8 +- src/app/material.module.ts | 2 + src/app/state/channel.model.ts | 9 +- src/app/state/channel.query.ts | 8 +- src/app/state/channel.store.ts | 14 ++- src/manifest.json | 9 +- 13 files changed, 244 insertions(+), 99 deletions(-) rename src/app/components/channel-list-container/{channel-list-container.component.css => channel-list-container.component.scss} (58%) diff --git a/src/app/components/channel-list-container/channel-list-container.component.html b/src/app/components/channel-list-container/channel-list-container.component.html index b401b8d31..c9805c1b5 100644 --- a/src/app/components/channel-list-container/channel-list-container.component.html +++ b/src/app/components/channel-list-container/channel-list-container.component.html @@ -1,29 +1,68 @@ - - - - - - - - - - - - - - {{groups.key || 'Ungrouped'}} ({{groups.value.length}}) - - - + + + + + + + + + + + + - {{i+1 + '. ' + channel?.name || 'Unnamed Channel'}} +

+ {{ i+1 + '. ' + channel?.name || 'Unnamed Channel' }} +

+
-
- -
-
-
+ + + + + + + + + + + {{groups.key || 'Ungrouped'}} ({{groups.value.length}}) + + +

+ {{ i+1 + '. ' + channel?.name || 'Unnamed Channel' }} +

+ + +
+
+
+
+
+
+ + + + +

+ {{ i+1 + '. ' + channel?.name || 'Unnamed Channel' }} +

+ + +
+
+
+ \ No newline at end of file diff --git a/src/app/components/channel-list-container/channel-list-container.component.css b/src/app/components/channel-list-container/channel-list-container.component.scss similarity index 58% rename from src/app/components/channel-list-container/channel-list-container.component.css rename to src/app/components/channel-list-container/channel-list-container.component.scss index 4185ade97..881882962 100644 --- a/src/app/components/channel-list-container/channel-list-container.component.css +++ b/src/app/components/channel-list-container/channel-list-container.component.scss @@ -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; +} diff --git a/src/app/components/channel-list-container/channel-list-container.component.ts b/src/app/components/channel-list-container/channel-list-container.component.ts index 4c8658645..5c6c0b7c9 100644 --- a/src/app/components/channel-list-container/channel-list-container.component.ts +++ b/src/app/components/channel-list-container/channel-list-container.component.ts @@ -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 = 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, + }; + }); } } diff --git a/src/app/components/playlist-uploader/playlist-uploader.component.ts b/src/app/components/playlist-uploader/playlist-uploader.component.ts index 399bef1f7..2e6fbd8b3 100644 --- a/src/app/components/playlist-uploader/playlist-uploader.component.ts +++ b/src/app/components/playlist-uploader/playlist-uploader.component.ts @@ -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('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('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(); } diff --git a/src/app/components/recent-playlists/recent-playlists.component.html b/src/app/components/recent-playlists/recent-playlists.component.html index 58ea921e4..acdb0c2bd 100644 --- a/src/app/components/recent-playlists/recent-playlists.component.html +++ b/src/app/components/recent-playlists/recent-playlists.component.html @@ -1,6 +1,6 @@
Recently added playlists ({{playlists.length}})
- + folder
{{ item.title || item.filename }}
Channels: {{ item.playlist?.items?.length }}
diff --git a/src/app/components/recent-playlists/recent-playlists.component.ts b/src/app/components/recent-playlists/recent-playlists.component.ts index 5bb584227..4e4b8e76a 100644 --- a/src/app/components/recent-playlists/recent-playlists.component.ts +++ b/src/app/components/recent-playlists/recent-playlists.component.ts @@ -11,7 +11,7 @@ export class RecentPlaylistsComponent { @Input() playlists: Playlist[]; /** Emits on playlist selection */ - @Output() playlistClicked: EventEmitter = new EventEmitter(); + @Output() playlistClicked: EventEmitter = new EventEmitter(); /** Emits on playlist remove click */ @Output() removeClicked: EventEmitter = new EventEmitter(); diff --git a/src/app/components/video-player/video-player.component.css b/src/app/components/video-player/video-player.component.css index 9962dd102..e470c9ff4 100644 --- a/src/app/components/video-player/video-player.component.css +++ b/src/app/components/video-player/video-player.component.css @@ -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; } diff --git a/src/app/components/video-player/video-player.component.ts b/src/app/components/video-player/video-player.component.ts index 28b941b6b..da04883ba 100644 --- a/src/app/components/video-player/video-player.component.ts +++ b/src/app/components/video-player/video-player.component.ts @@ -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; diff --git a/src/app/material.module.ts b/src/app/material.module.ts index 94ef566ba..d27ebc893 100644 --- a/src/app/material.module.ts +++ b/src/app/material.module.ts @@ -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, ], }) diff --git a/src/app/state/channel.model.ts b/src/app/state/channel.model.ts index 5b54c902e..fbf01b977 100755 --- a/src/app/state/channel.model.ts +++ b/src/app/state/channel.model.ts @@ -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) { +export function createChannel( + params: Partial, + favoritesList: string[] +) { return { - id: guid(), + id: params.url, name: params.name, group: params.group, url: params.url, + fav: favoritesList.includes(params.url), } as Channel; } diff --git a/src/app/state/channel.query.ts b/src/app/state/channel.query.ts index c70e66197..98ed902e3 100644 --- a/src/app/state/channel.query.ts +++ b/src/app/state/channel.query.ts @@ -4,9 +4,7 @@ import { ChannelStore, ChannelState } from './channel.store'; @Injectable({ providedIn: 'root' }) export class ChannelQuery extends QueryEntity { - - constructor(protected store: ChannelStore) { - super(store); - } - + constructor(protected store: ChannelStore) { + super(store); + } } diff --git a/src/app/state/channel.store.ts b/src/app/state/channel.store.ts index 835607d1b..6af5c49ab 100644 --- a/src/app/state/channel.store.ts +++ b/src/app/state/channel.store.ts @@ -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 {} +export interface ChannelState extends EntityState { + favorites: string[]; + playlistId: string; +} @Injectable({ providedIn: 'root' }) @StoreConfig({ name: 'channel', resettable: true }) export class ChannelStore extends EntityStore { constructor() { - super(); + super({ + favorites: [], + playlistId: '', + }); + } + + setChannels(channels: Channel[]): void { + this.upsertMany(channels); } } diff --git a/src/manifest.json b/src/manifest.json index 40dfb764d..24ad61ebd 100644 --- a/src/manifest.json +++ b/src/manifest.json @@ -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": {