feat(xtream): implement portal channels list and enhance layout for improved navigation

This commit is contained in:
4gray committed 2024-12-24 01:01:55 +01:00
1 parent e4569556a1
commit 89ec644d9f
6 files changed
+317 -253

No files matched your search

@@ -1,74 +1,24 @@
<div
[class]="
player === 'videojs' || player === 'html5'
? 'itv-container'
: 'itv-container-epg'
"
>
<div class="sidebar">
<div class="sidebar-content">
<div class="search-panel">
<mat-form-field class="search-bar">
<mat-icon matPrefix>search</mat-icon>
<input
matInput
spellcheck="false"
type="search"
[placeholder]="'CHANNELS.SEARCH_CHANNEL' | translate"
[(ngModel)]="searchString"
/>
</mat-form-field>
</div>
<mat-nav-list>
<cdk-virtual-scroll-viewport
[itemSize]="48"
class="scroll-viewport-portals"
>
<mat-list-item
*cdkVirtualFor="
let item of channels()
| filterBy: searchString() : 'title';
trackBy: trackBy;
templateCacheSize: 0
"
(click)="playLive(item)"
[activated]="
xtreamStore.selectedItem()?.xtream_id ===
item.xtream_id
"
>
<img
matListItemAvatar
class="stream-icon"
*ngIf="item.poster_url"
[src]="item.poster_url"
(error)="
$event.target.src =
'./assets/images/default-poster.png'
"
/>
<div class="title" matListItemTitle>
{{ item.title }}
</div>
<button
mat-icon-button
matListItemMeta
(click)="toggleFavorite($event, item)"
>
<mat-icon>
{{
favorites.get(item.xtream_id)
? 'star'
: 'star_outline'
}}
</mat-icon>
</button>
</mat-list-item>
</cdk-virtual-scroll-viewport>
</mat-nav-list>
</div>
@let isEmbeddedPlayer = player() === 'videojs' || player() === 'html5';
<div class="sidebar">
<div class="sidebar-header">
@if (selectedCategoryId()) {
<button mat-icon-button (click)="backToCategories()">
<mat-icon>chevron_left</mat-icon>
</button>
<h2>Channels</h2>
} @else {
<h2>Categories</h2>
}
</div>
@if (player === 'videojs' || player === 'html5') {
@if (selectedCategoryId()) {
<app-portal-channels-list (playClicked)="playLive($event)" />
} @else {
<app-category-view (categoryClicked)="selectCategory($event)" />
}
</div>
<div [class]="isEmbeddedPlayer ? 'itv-container' : 'itv-container-epg'">
@if (isEmbeddedPlayer) {
@if (streamUrl) {
<div class="video-player">
<app-web-player-view [streamUrl]="streamUrl" />
@@ -77,13 +27,13 @@
}
@if (streamUrl) {
<div class="epg">
<div class="epg-content">
<div [class]="isEmbeddedPlayer ? 'epg-content' : ''">
@if (epgItems().length > 0) {
<app-epg-view [epgItems]="epgItems()" />
}
</div>
</div>
} @else {
<div class="no-channel-selected">Please select a channel</div>
<div class="no-channel-selected">Select a category and channel</div>
}
</div>
@@ -1,52 +1,41 @@
@use '@angular/material' as mat;
:host {
width: 100%;
display: flex;
flex-direction: row;
overflow: hidden;
height: 100%;
width: 100%;
}
.itv-container {
display: grid;
grid-template-columns: 330px 1fr 1fr;
grid-template-columns: 1fr 1fr;
grid-template-rows: 50% 50%;
gap: 0 0;
grid-auto-flow: row;
grid-template-areas:
'sidebar video-player video-player'
'sidebar epg epg';
'video-player video-player'
'epg epg';
}
.itv-container-epg {
display: grid;
grid-template-columns: 330px 1fr 1fr;
grid-template-rows: 1fr 1fr 1fr;
grid-template-columns: 1fr 1fr;
grid-template-rows: 1fr 1fr;
gap: 0 0;
grid-auto-flow: row;
grid-template-areas:
'sidebar epg epg'
'sidebar epg epg'
'sidebar epg epg';
'epg epg'
'epg epg'
'epg epg';
height: 100%;
}
.sidebar { grid-area: sidebar; }
.video-player { grid-area: video-player; }
.epg { grid-area: epg; }
.search-panel {
margin-top: 10px;
display: flex;
justify-content: center;
margin-bottom: -25px;
}
.search-bar {
width: 90%;
@include mat.form-field-density(-5);
}
.epg-content {
height: calc(50vh - 20px);
overflow-y: auto;
@@ -54,56 +43,19 @@
padding-bottom: 20px;
}
.sidebar-content {
height: 100%;
overflow-y: auto;
border-right: #111 1px solid;
overflow-x: hidden;
mat-nav-list {
width: 100%;
overflow: hidden;
display: block;
.sidebar-header {
display: flex;
flex-direction: row;
align-items: center;
button {
@include mat.form-field-density(-5);
}
}
.no-channel-selected {
margin: 10px;
padding: 16px;
margin: 0;
font-size: 1.2rem;
font-weight: 500;
}
mat-list-option {
width: 320px;
}
::ng-deep {
.search-bar {
div {
border-radius: 20px;
}
}
}
.scroll-viewport-portals {
min-height: calc(100vh - 59px) !important;
width: 100%;
}
.title {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: calc(100%); // Account for avatar and favorite button
min-width: 0;
flex: 1;
}
mat-list-item {
width: 325px;
min-width: 0; // Prevents flex items from overflowing
box-sizing: border-box;
::ng-deep .mdc-list-item__content {
overflow: hidden;
flex: 1;
}
}
@@ -1,37 +1,24 @@
import { ScrollingModule } from '@angular/cdk/scrolling';
import { NgIf } from '@angular/common';
import {
ChangeDetectionStrategy,
Component,
inject,
OnInit,
signal,
} from '@angular/core';
import { FormsModule } from '@angular/forms';
import { MatIconButton } from '@angular/material/button';
import { MatDialog } from '@angular/material/dialog';
import { MatFormFieldModule } from '@angular/material/form-field';
import { MatIcon } from '@angular/material/icon';
import { MatInputModule } from '@angular/material/input';
import { MatListModule } from '@angular/material/list';
import { ActivatedRoute } from '@angular/router';
import { TranslateModule } from '@ngx-translate/core';
import {
OPEN_MPV_PLAYER,
OPEN_VLC_PLAYER,
} from '../../../../shared/ipc-commands';
import { XtreamItem } from '../../../../shared/xtream-item.interface';
import { XtreamCategory } from '../../../../shared/xtream-category.interface';
import { EpgViewComponent } from '../../portals/epg-view/epg-view.component';
import { WebPlayerViewComponent } from '../../portals/web-player-view/web-player-view.component';
import { DataService } from '../../services/data.service';
import { DatabaseService } from '../../services/database.service'; // Add this import
import { PlayerService } from '../../services/player.service';
import { SettingsStore } from '../../services/settings-store.service';
import { VideoPlayer } from '../../settings/settings.interface';
import { ExternalPlayerInfoDialogComponent } from '../../shared/components/external-player-info-dialog/external-player-info-dialog.component';
import { FilterPipe } from '../../shared/pipes/filter.pipe';
import {
PlayerDialogComponent,
PlayerDialogData,
} from '../player-dialog/player-dialog.component';
import { CategoryViewComponent } from '../category-view/category-view.component';
import { PortalChannelsListComponent } from '../portal-channels-list/portal-channels-list.component';
import { FavoritesService } from '../services/favorites.service';
import { XtreamStore } from '../xtream.store';
@@ -39,47 +26,38 @@ import { XtreamStore } from '../xtream.store';
standalone: true,
selector: 'app-live-stream-layout',
templateUrl: './live-stream-layout.component.html',
styleUrls: ['./live-stream-layout.component.scss'],
styleUrls: ['./live-stream-layout.component.scss', '../sidebar.scss'],
imports: [
CategoryViewComponent,
EpgViewComponent,
FilterPipe,
FormsModule,
MatIconButton,
MatListModule,
MatIcon,
MatInputModule,
MatFormFieldModule,
NgIf,
ScrollingModule,
WebPlayerViewComponent,
MatIcon,
MatIconButton,
MatInputModule,
MatListModule,
PortalChannelsListComponent,
TranslateModule,
WebPlayerViewComponent,
],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class LiveStreamLayoutComponent implements OnInit {
private settingsStore = inject(SettingsStore);
private dialog = inject(MatDialog);
private dataService = inject(DataService);
private favoritesService = inject(FavoritesService);
private dbService = inject(DatabaseService); // Add this injection
private readonly settings = this.settingsStore.getSettings();
readonly xtreamStore = inject(XtreamStore);
readonly channels = this.xtreamStore.liveStreams;
private playerService = inject(PlayerService);
private readonly xtreamStore = inject(XtreamStore);
private readonly settingsStore = inject(SettingsStore);
readonly epgItems = this.xtreamStore.epgItems;
readonly selectedCategoryId = this.xtreamStore.selectedCategoryId;
private readonly hideExternalInfoDialog =
this.xtreamStore.hideExternalInfoDialog;
private readonly selectedContentType = this.xtreamStore.selectedContentType;
private readonly route = inject(ActivatedRoute);
player: VideoPlayer = VideoPlayer.VideoJs;
player = this.settingsStore.player;
streamUrl: string;
searchString = signal<string>('');
favorites = new Map<number, boolean>();
trackBy(_index: number, item: XtreamItem) {
return item.xtream_id;
}
ngOnInit() {
const playlist = this.xtreamStore.currentPlaylist();
if (playlist) {
@@ -92,6 +70,10 @@ export class LiveStreamLayoutComponent implements OnInit {
});
});
}
const { categoryId } = this.route.firstChild.snapshot.params;
if (categoryId)
this.xtreamStore.setSelectedCategory(Number(categoryId));
}
playLive(item: any) {
@@ -106,76 +88,22 @@ export class LiveStreamLayoutComponent implements OnInit {
openPlayer(streamUrl: string, title: string, thumbnail: string) {
this.streamUrl = streamUrl;
this.player = this.settingsStore.player() ?? VideoPlayer.VideoJs;
if (this.player === VideoPlayer.MPV) {
if (!this.hideExternalInfoDialog())
this.dialog.open(ExternalPlayerInfoDialogComponent);
this.dataService.sendIpcEvent(OPEN_MPV_PLAYER, {
url: streamUrl,
mpvPlayerPath: this.settingsStore.mpvPlayerPath(),
title,
thumbnail,
});
} else if (this.player === VideoPlayer.VLC) {
if (!this.hideExternalInfoDialog())
this.dialog.open(ExternalPlayerInfoDialogComponent);
this.dataService.sendIpcEvent(OPEN_VLC_PLAYER, {
url: streamUrl,
vlcPlayerPath: this.settingsStore.vlcPlayerPath(),
});
} else {
if (this.selectedContentType() !== 'live') {
this.dialog.open<PlayerDialogComponent, PlayerDialogData>(
PlayerDialogComponent,
{
data: { streamUrl, title },
width: '80%',
}
);
}
}
this.playerService.openPlayer(
streamUrl,
title,
thumbnail,
this.hideExternalInfoDialog(),
this.selectedContentType() === 'live'
);
}
async toggleFavorite(event: Event, item: any): Promise<void> {
event.stopPropagation();
const playlist = this.xtreamStore.currentPlaylist();
selectCategory(category: XtreamCategory) {
const categoryId = (category as any).category_id ?? category.id;
console.log('Selected category:', categoryId);
this.xtreamStore.setSelectedCategory(categoryId);
}
// Update UI state immediately
const currentFavoriteState =
this.favorites.get(item.xtream_id) || false;
this.favorites.set(item.xtream_id, !currentFavoriteState);
try {
const db = await this.dbService.getConnection();
const content: any = await db.select(
'SELECT id FROM content WHERE xtream_id = ?',
[item.xtream_id]
);
if (!content || content.length === 0) {
console.error('Content not found in database');
// Revert UI state on error
this.favorites.set(item.xtream_id, currentFavoriteState);
return;
}
const contentId = content[0].id;
if (!currentFavoriteState) {
await this.favoritesService.addToFavorites({
content_id: contentId,
playlist_id: playlist.id,
});
} else {
await this.favoritesService.removeFromFavorites(
contentId,
playlist.id
);
}
} catch (error) {
console.error('Error toggling favorite:', error);
// Revert UI state on error
this.favorites.set(item.xtream_id, currentFavoriteState);
}
backToCategories() {
this.xtreamStore.setSelectedCategory(null);
}
}
@@ -0,0 +1,54 @@
<div class="search-panel">
<mat-form-field class="search-bar" subscriptSizing="dynamic">
<mat-icon matPrefix>search</mat-icon>
<input
matInput
spellcheck="false"
type="search"
[placeholder]="'CHANNELS.SEARCH_CHANNEL' | translate"
[(ngModel)]="searchString"
/>
</mat-form-field>
</div>
<mat-nav-list>
<cdk-virtual-scroll-viewport
[itemSize]="48"
class="scroll-viewport-portals"
>
<mat-list-item
*cdkVirtualFor="
let item of channels() | filterBy: searchString() : 'title';
trackBy: trackBy;
templateCacheSize: 0
"
(click)="playClicked.emit(item)"
[activated]="
xtreamStore.selectedItem()?.xtream_id === item.xtream_id
"
>
<img
matListItemAvatar
class="stream-icon"
[src]="item.poster_url"
(error)="
$event.target.src = './assets/images/default-poster.png'
"
/>
<div class="title" matListItemTitle>
{{ item.title }}
</div>
<button
mat-icon-button
matListItemMeta
(click)="toggleFavorite($event, item)"
>
<mat-icon>
{{
favorites.get(item.xtream_id) ? 'star' : 'star_outline'
}}
</mat-icon>
</button>
</mat-list-item>
</cdk-virtual-scroll-viewport>
</mat-nav-list>
@@ -0,0 +1,58 @@
@use '@angular/material' as mat;
mat-nav-list {
width: 100%;
overflow: hidden;
display: block;
}
mat-list-item {
width: 280px !important;
}
.scroll-viewport-portals {
min-height: calc(100vh - 114px) !important;
width: 100%;
}
.title {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: calc(100%); // Account for avatar and favorite button
min-width: 0;
flex: 1;
}
mat-list-item {
width: 325px;
min-width: 0; // Prevents flex items from overflowing
box-sizing: border-box;
::ng-deep .mdc-list-item__content {
overflow: hidden;
flex: 1;
}
}
.search-bar {
width: 90%;
@include mat.form-field-density(-5);
}
.search-panel {
display: flex;
justify-content: center;
}
::ng-deep {
.search-bar {
div {
border-radius: 20px;
}
}
div[matformfieldlineripple] {
display: none;
}
}
@@ -0,0 +1,122 @@
import { ScrollingModule } from '@angular/cdk/scrolling';
import { Component, EventEmitter, Output, inject, signal } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { MatIconButton } from '@angular/material/button';
import { MatCardModule } from '@angular/material/card';
import { MatFormFieldModule } from '@angular/material/form-field';
import { MatIcon } from '@angular/material/icon';
import { MatInputModule } from '@angular/material/input';
import { MatListModule } from '@angular/material/list';
import { MatTooltipModule } from '@angular/material/tooltip';
import { ActivatedRoute } from '@angular/router';
import { TranslateModule } from '@ngx-translate/core';
import { XtreamCategory } from '../../../../shared/xtream-category.interface';
import { XtreamItem } from '../../../../shared/xtream-item.interface';
import { DatabaseService } from '../../services/database.service';
import { FilterPipe } from '../../shared/pipes/filter.pipe';
import { FavoritesService } from '../services/favorites.service';
import { XtreamStore } from '../xtream.store';
@Component({
selector: 'app-portal-channels-list',
standalone: true,
templateUrl: './portal-channels-list.component.html',
styleUrls: ['./portal-channels-list.component.scss'],
imports: [
FilterPipe,
FormsModule,
MatFormFieldModule,
ScrollingModule,
MatCardModule,
MatIcon,
MatIconButton,
MatListModule,
MatInputModule,
TranslateModule,
MatTooltipModule,
],
})
export class PortalChannelsListComponent {
@Output() playClicked = new EventEmitter<any>();
readonly xtreamStore = inject(XtreamStore);
private readonly favoritesService = inject(FavoritesService);
private readonly dbService = inject(DatabaseService);
private readonly route = inject(ActivatedRoute);
readonly channels = this.xtreamStore.selectItemsFromSelectedCategory;
favorites = new Map<number, boolean>();
searchString = signal<string>('');
trackBy(_index: number, item: XtreamItem) {
return item.xtream_id;
}
ngOnInit(): void {
const { categoryId } = this.route.snapshot.params;
if (categoryId)
this.xtreamStore.setSelectedCategory(Number(categoryId));
const playlist = this.xtreamStore.currentPlaylist();
if (playlist) {
this.favoritesService
.getFavorites(playlist.id)
.subscribe((favorites) => {
// Map using content.id instead of xtream_id
favorites.forEach((fav: any) => {
this.favorites.set(fav.xtream_id, true);
});
console.log(this.favorites);
});
}
}
isSelected(item: XtreamCategory): boolean {
const selectedCategory = this.xtreamStore.selectedCategoryId();
const itemId = Number((item as any).category_id || item.id);
return selectedCategory !== null && selectedCategory === itemId;
}
async toggleFavorite(event: Event, item: any): Promise<void> {
event.stopPropagation();
const playlist = this.xtreamStore.currentPlaylist();
// Update UI state immediately
const currentFavoriteState =
this.favorites.get(item.xtream_id) || false;
this.favorites.set(item.xtream_id, !currentFavoriteState);
try {
const db = await this.dbService.getConnection();
const content: any = await db.select(
'SELECT id FROM content WHERE xtream_id = ?',
[item.xtream_id]
);
if (!content || content.length === 0) {
console.error('Content not found in database');
// Revert UI state on error
this.favorites.set(item.xtream_id, currentFavoriteState);
return;
}
const contentId = content[0].id;
if (!currentFavoriteState) {
await this.favoritesService.addToFavorites({
content_id: contentId,
playlist_id: playlist.id,
});
} else {
await this.favoritesService.removeFromFavorites(
contentId,
playlist.id
);
}
} catch (error) {
console.error('Error toggling favorite:', error);
// Revert UI state on error
this.favorites.set(item.xtream_id, currentFavoriteState);
}
}
}