mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-08 17:06:15 -08:00
feat: add favorites and tab based navigation
This commit is contained in:
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>
|
||||
+7
-5
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
],
|
||||
})
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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": {
|
||||
|
||||
Reference in new issue
Block a user