mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-08 09:01:03 -08:00
feat: general development, update core application components, and enhance build scripts.
This commit is contained in:
1 parent
9a57eaff92
commit
992b996ecd
37 files changed
+500
-341
No files matched your search
@@ -264,6 +264,24 @@ See `docs/architecture/m3u-playlist-module.md` for complete documentation.
|
|||||||
- Same schema structure but implemented in IndexedDB
|
- Same schema structure but implemented in IndexedDB
|
||||||
- Limited by browser storage quotas
|
- Limited by browser storage quotas
|
||||||
|
|
||||||
|
**TypeScript File Size Rule**:
|
||||||
|
|
||||||
|
Keep TypeScript files under **300 lines**. Hard maximum is **350–400 lines**.
|
||||||
|
|
||||||
|
- When creating new files, design them to stay within this limit from the start.
|
||||||
|
- When adding a feature to an existing file that would push it past 350 lines, **refactor first**: extract helpers, sub-services, or feature modules before adding the new code.
|
||||||
|
- When you notice a file already exceeds 350 lines, **proactively suggest a refactoring** (or perform it if the change is straightforward) — even if the immediate task is small.
|
||||||
|
|
||||||
|
Typical split strategies:
|
||||||
|
- Angular components: extract child components, move logic to a dedicated service or store feature
|
||||||
|
- Signal store features: split into smaller `with*` feature functions in separate files
|
||||||
|
- Services: split by responsibility (e.g. separate API, transformation, and state concerns)
|
||||||
|
- Utility files: group by domain and export from a barrel `index.ts`
|
||||||
|
|
||||||
|
This rule exists to keep the codebase navigable and reviewable. A 150-line file is always preferable to a 500-line file.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
**Angular Coding Standards**:
|
**Angular Coding Standards**:
|
||||||
|
|
||||||
This project uses modern Angular signal-based APIs and patterns. **ALWAYS** use the following:
|
This project uses modern Angular signal-based APIs and patterns. **ALWAYS** use the following:
|
||||||
|
|||||||
@@ -49,6 +49,21 @@ export const routes: Routes = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: 'playlists/:id',
|
path: 'playlists/:id',
|
||||||
|
pathMatch: 'full',
|
||||||
|
redirectTo: 'playlists/:id/all',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'playlists/:id/:view',
|
||||||
|
data: {
|
||||||
|
layout: 'workspace',
|
||||||
|
},
|
||||||
|
loadComponent: () =>
|
||||||
|
import('./home/video-player/video-player.component').then(
|
||||||
|
(c) => c.VideoPlayerComponent
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'global-favorites',
|
||||||
data: {
|
data: {
|
||||||
layout: 'workspace',
|
layout: 'workspace',
|
||||||
},
|
},
|
||||||
@@ -90,6 +105,11 @@ export const routes: Routes = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: 'playlists/:id',
|
path: 'playlists/:id',
|
||||||
|
pathMatch: 'full',
|
||||||
|
redirectTo: 'playlists/:id/all',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'playlists/:id/:view',
|
||||||
loadComponent: () =>
|
loadComponent: () =>
|
||||||
import('./home/video-player/video-player.component').then(
|
import('./home/video-player/video-player.component').then(
|
||||||
(c) => c.VideoPlayerComponent
|
(c) => c.VideoPlayerComponent
|
||||||
|
|||||||
@@ -21,6 +21,7 @@
|
|||||||
<app-sidebar
|
<app-sidebar
|
||||||
[channels]="(channels$ | async)!"
|
[channels]="(channels$ | async)!"
|
||||||
[showPlaylistHeader]="!isWorkspaceLayout"
|
[showPlaylistHeader]="!isWorkspaceLayout"
|
||||||
|
[activeView]="activeView()"
|
||||||
/>
|
/>
|
||||||
} @loading {
|
} @loading {
|
||||||
<p>Loading...</p>
|
<p>Loading...</p>
|
||||||
@@ -29,7 +30,7 @@
|
|||||||
}
|
}
|
||||||
</mat-drawer>
|
</mat-drawer>
|
||||||
<mat-drawer-content class="drawer-content">
|
<mat-drawer-content class="drawer-content">
|
||||||
@if (activeChannel$ | async; as activeChannel) {
|
@if (activeChannel(); as activeChannel) {
|
||||||
<!-- toolbar with drawer icon -->
|
<!-- toolbar with drawer icon -->
|
||||||
<app-toolbar
|
<app-toolbar
|
||||||
(toggleLeftDrawerClicked)="drawer.toggle()"
|
(toggleLeftDrawerClicked)="drawer.toggle()"
|
||||||
@@ -117,7 +118,7 @@
|
|||||||
<!-- channel overlay -->
|
<!-- channel overlay -->
|
||||||
<app-info-overlay
|
<app-info-overlay
|
||||||
[channel]="activeChannel"
|
[channel]="activeChannel"
|
||||||
[epgProgram]="(epgProgram$ | async)!"
|
[epgProgram]="epgProgram()"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<!-- channel number overlay -->
|
<!-- channel number overlay -->
|
||||||
@@ -129,6 +130,20 @@
|
|||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
} @else {
|
||||||
|
<!-- Minimal toolbar when no channel is active so drawer works -->
|
||||||
|
<app-toolbar
|
||||||
|
(toggleLeftDrawerClicked)="drawer.toggle()"
|
||||||
|
(toggleRightDrawerClicked)="drawerRight.toggle()"
|
||||||
|
[isLeftDrawerOpened]="drawer.opened"
|
||||||
|
(settingsClicked)="openSettings()"
|
||||||
|
/>
|
||||||
|
<div class="empty-state-container">
|
||||||
|
<mat-icon class="empty-state-icon">live_tv</mat-icon>
|
||||||
|
<h2 class="empty-state-title">
|
||||||
|
{{ 'CHANNELS.SELECT_CHANNEL_PLAYBACK' | translate }}
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
}
|
}
|
||||||
</mat-drawer-content>
|
</mat-drawer-content>
|
||||||
<!-- right sidebar content -->
|
<!-- right sidebar content -->
|
||||||
|
|||||||
@@ -126,3 +126,29 @@
|
|||||||
transform: scale(1);
|
transform: scale(1);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.empty-state-container {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
height: calc(100% - 64px);
|
||||||
|
text-align: center;
|
||||||
|
color: rgba(255, 255, 255, 0.5);
|
||||||
|
|
||||||
|
.empty-state-icon {
|
||||||
|
font-size: 72px;
|
||||||
|
width: 72px;
|
||||||
|
height: 72px;
|
||||||
|
margin-bottom: 24px;
|
||||||
|
opacity: 0.2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-state-title {
|
||||||
|
font-family: 'DM Sans', sans-serif;
|
||||||
|
font-weight: 500;
|
||||||
|
font-size: 1.25rem;
|
||||||
|
margin: 0;
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -11,11 +11,14 @@ import {
|
|||||||
inject,
|
inject,
|
||||||
viewChild,
|
viewChild,
|
||||||
} from '@angular/core';
|
} from '@angular/core';
|
||||||
|
import { toSignal } from '@angular/core/rxjs-interop';
|
||||||
import { MatDialog } from '@angular/material/dialog';
|
import { MatDialog } from '@angular/material/dialog';
|
||||||
|
import { MatIcon } from '@angular/material/icon';
|
||||||
import { MatSidenavModule } from '@angular/material/sidenav';
|
import { MatSidenavModule } from '@angular/material/sidenav';
|
||||||
import { ActivatedRoute, Router, RouterLink } from '@angular/router';
|
import { ActivatedRoute, Router, RouterLink } from '@angular/router';
|
||||||
import { Store } from '@ngrx/store';
|
import { Store } from '@ngrx/store';
|
||||||
import { StorageMap } from '@ngx-pwa/local-storage';
|
import { StorageMap } from '@ngx-pwa/local-storage';
|
||||||
|
import { TranslatePipe } from '@ngx-translate/core';
|
||||||
import {
|
import {
|
||||||
ArtPlayerComponent,
|
ArtPlayerComponent,
|
||||||
AudioPlayerComponent,
|
AudioPlayerComponent,
|
||||||
@@ -29,8 +32,10 @@ import {
|
|||||||
ToolbarComponent,
|
ToolbarComponent,
|
||||||
VjsPlayerComponent,
|
VjsPlayerComponent,
|
||||||
} from 'components';
|
} from 'components';
|
||||||
import { PlaylistActions, ChannelActions, FavoritesActions } from 'm3u-state';
|
|
||||||
import {
|
import {
|
||||||
|
ChannelActions,
|
||||||
|
FavoritesActions,
|
||||||
|
PlaylistActions,
|
||||||
selectActive,
|
selectActive,
|
||||||
selectChannels,
|
selectChannels,
|
||||||
selectCurrentEpgProgram,
|
selectCurrentEpgProgram,
|
||||||
@@ -40,6 +45,7 @@ import {
|
|||||||
Subscription,
|
Subscription,
|
||||||
combineLatest,
|
combineLatest,
|
||||||
combineLatestWith,
|
combineLatestWith,
|
||||||
|
distinctUntilChanged,
|
||||||
filter,
|
filter,
|
||||||
map,
|
map,
|
||||||
startWith,
|
startWith,
|
||||||
@@ -56,13 +62,14 @@ import {
|
|||||||
VideoPlayer,
|
VideoPlayer,
|
||||||
} from 'shared-interfaces';
|
} from 'shared-interfaces';
|
||||||
import { SettingsStore } from '../../services/settings-store.service';
|
import { SettingsStore } from '../../services/settings-store.service';
|
||||||
|
import { SettingsComponent } from '../../settings/settings.component';
|
||||||
import {
|
import {
|
||||||
getAdjacentChannelItem,
|
getAdjacentChannelItem,
|
||||||
getChannelItemByNumber,
|
getChannelItemByNumber,
|
||||||
} from '../../shared/services/remote-channel-navigation.util';
|
} from '../../shared/services/remote-channel-navigation.util';
|
||||||
import { SettingsComponent } from '../../settings/settings.component';
|
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
|
selector: 'app-video-player',
|
||||||
imports: [
|
imports: [
|
||||||
ArtPlayerComponent,
|
ArtPlayerComponent,
|
||||||
AsyncPipe,
|
AsyncPipe,
|
||||||
@@ -71,11 +78,13 @@ import { SettingsComponent } from '../../settings/settings.component';
|
|||||||
EpgListComponent,
|
EpgListComponent,
|
||||||
HtmlVideoPlayerComponent,
|
HtmlVideoPlayerComponent,
|
||||||
InfoOverlayComponent,
|
InfoOverlayComponent,
|
||||||
|
MatIcon,
|
||||||
MatSidenavModule,
|
MatSidenavModule,
|
||||||
ResizableDirective,
|
ResizableDirective,
|
||||||
RouterLink,
|
RouterLink,
|
||||||
SidebarComponent,
|
SidebarComponent,
|
||||||
ToolbarComponent,
|
ToolbarComponent,
|
||||||
|
TranslatePipe,
|
||||||
VjsPlayerComponent,
|
VjsPlayerComponent,
|
||||||
],
|
],
|
||||||
templateUrl: './video-player.component.html',
|
templateUrl: './video-player.component.html',
|
||||||
@@ -93,13 +102,34 @@ export class VideoPlayerComponent implements OnInit, OnDestroy {
|
|||||||
private readonly store = inject(Store);
|
private readonly store = inject(Store);
|
||||||
|
|
||||||
/** Active selected channel */
|
/** Active selected channel */
|
||||||
readonly activeChannel$ = this.store.select(selectActive);
|
readonly activeChannel = this.store.selectSignal(selectActive);
|
||||||
|
|
||||||
/** Channels list */
|
/** Channels list */
|
||||||
channels$!: Observable<Channel[]>;
|
readonly channels$: Observable<Channel[]> = this.store.select(
|
||||||
|
selectChannels
|
||||||
|
) as Observable<Channel[]>;
|
||||||
|
|
||||||
/** Current epg program */
|
/** Current epg program */
|
||||||
readonly epgProgram$ = this.store.select(selectCurrentEpgProgram);
|
readonly epgProgram = this.store.selectSignal(selectCurrentEpgProgram);
|
||||||
|
|
||||||
|
/** Active M3U view (all, groups, favorites) */
|
||||||
|
readonly activeView = toSignal(
|
||||||
|
this.activatedRoute.params.pipe(
|
||||||
|
map((params) => {
|
||||||
|
if (this.router.url.includes('/workspace/global-favorites')) {
|
||||||
|
return 'favorites';
|
||||||
|
}
|
||||||
|
return params['view'] || 'all';
|
||||||
|
})
|
||||||
|
),
|
||||||
|
{
|
||||||
|
initialValue: this.router.url.includes(
|
||||||
|
'/workspace/global-favorites'
|
||||||
|
)
|
||||||
|
? 'favorites'
|
||||||
|
: 'all',
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
/** Selected video player options */
|
/** Selected video player options */
|
||||||
playerSettings: Partial<Settings> = {
|
playerSettings: Partial<Settings> = {
|
||||||
@@ -118,6 +148,7 @@ export class VideoPlayerComponent implements OnInit, OnDestroy {
|
|||||||
private unsubscribeRemoteChannelChange?: () => void;
|
private unsubscribeRemoteChannelChange?: () => void;
|
||||||
private unsubscribeRemoteCommand?: () => void;
|
private unsubscribeRemoteCommand?: () => void;
|
||||||
private statusSubscription?: Subscription;
|
private statusSubscription?: Subscription;
|
||||||
|
private routeSubscription?: Subscription;
|
||||||
private lastKnownVolume = 1;
|
private lastKnownVolume = 1;
|
||||||
|
|
||||||
/** Info overlay component reference for manual triggering */
|
/** Info overlay component reference for manual triggering */
|
||||||
@@ -155,11 +186,11 @@ export class VideoPlayerComponent implements OnInit, OnDestroy {
|
|||||||
|
|
||||||
// Setup remote control channel change listener (Electron only)
|
// Setup remote control channel change listener (Electron only)
|
||||||
if (this.isDesktop && window.electron?.onChannelChange) {
|
if (this.isDesktop && window.electron?.onChannelChange) {
|
||||||
const unsubscribe = window.electron.onChannelChange((data: {
|
const unsubscribe = window.electron.onChannelChange(
|
||||||
direction: 'up' | 'down';
|
(data: { direction: 'up' | 'down' }) => {
|
||||||
}) => {
|
this.handleRemoteChannelChange(data.direction);
|
||||||
this.handleRemoteChannelChange(data.direction);
|
}
|
||||||
});
|
);
|
||||||
if (typeof unsubscribe === 'function') {
|
if (typeof unsubscribe === 'function') {
|
||||||
this.unsubscribeRemoteChannelChange = unsubscribe;
|
this.unsubscribeRemoteChannelChange = unsubscribe;
|
||||||
}
|
}
|
||||||
@@ -175,66 +206,109 @@ export class VideoPlayerComponent implements OnInit, OnDestroy {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
this.channels$ = this.activatedRoute.params.pipe(
|
this.routeSubscription = this.activatedRoute.params
|
||||||
combineLatestWith(this.activatedRoute.queryParams),
|
.pipe(
|
||||||
switchMap(([params, queryParams]) => {
|
distinctUntilChanged((prev, curr) => prev['id'] === curr['id']),
|
||||||
if (params['id']) {
|
combineLatestWith(this.activatedRoute.queryParams),
|
||||||
this.store.dispatch(ChannelActions.resetActiveChannel());
|
switchMap(([params, queryParams]) => {
|
||||||
this.store.dispatch(
|
const isGlobalFavorites = this.router.url.includes(
|
||||||
PlaylistActions.setActivePlaylist({
|
'/workspace/global-favorites'
|
||||||
playlistId: params['id'],
|
|
||||||
})
|
|
||||||
);
|
);
|
||||||
return this.playlistsService.getPlaylist(params['id']).pipe(
|
|
||||||
map((playlist) => {
|
|
||||||
// Set user agent if specified on playlist level
|
|
||||||
if (playlist.userAgent) {
|
|
||||||
window.electron?.setUserAgent(
|
|
||||||
playlist.userAgent,
|
|
||||||
'localhost'
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
this.store.dispatch(
|
if (isGlobalFavorites) {
|
||||||
ChannelActions.setChannels({
|
this.store.dispatch(
|
||||||
channels: playlist.playlist.items,
|
ChannelActions.resetActiveChannel()
|
||||||
|
);
|
||||||
|
this.store.dispatch(
|
||||||
|
PlaylistActions.setActivePlaylist({
|
||||||
|
playlistId: '',
|
||||||
|
})
|
||||||
|
);
|
||||||
|
return this.playlistsService
|
||||||
|
.getPlaylistWithGlobalFavorites()
|
||||||
|
.pipe(
|
||||||
|
map((playlist) => {
|
||||||
|
this.store.dispatch(
|
||||||
|
ChannelActions.setChannels({
|
||||||
|
channels: playlist.playlist.items,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
if (
|
||||||
|
playlist.favorites &&
|
||||||
|
playlist.favorites.length > 0
|
||||||
|
) {
|
||||||
|
this.store.dispatch(
|
||||||
|
FavoritesActions.setFavorites({
|
||||||
|
channelIds: playlist.favorites,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
this.store.dispatch(
|
||||||
|
FavoritesActions.setFavorites({
|
||||||
|
channelIds: [],
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
} else if (params['id']) {
|
||||||
|
this.store.dispatch(
|
||||||
|
ChannelActions.resetActiveChannel()
|
||||||
|
);
|
||||||
|
this.store.dispatch(
|
||||||
|
PlaylistActions.setActivePlaylist({
|
||||||
|
playlistId: params['id'],
|
||||||
|
})
|
||||||
|
);
|
||||||
|
return this.playlistsService
|
||||||
|
.getPlaylist(params['id'])
|
||||||
|
.pipe(
|
||||||
|
map((playlist) => {
|
||||||
|
// Set user agent if specified on playlist level
|
||||||
|
if (playlist.userAgent) {
|
||||||
|
window.electron?.setUserAgent(
|
||||||
|
playlist.userAgent,
|
||||||
|
'localhost'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// Load favorites from the playlist
|
this.store.dispatch(
|
||||||
if (
|
ChannelActions.setChannels({
|
||||||
playlist.favorites &&
|
channels: playlist.playlist.items,
|
||||||
playlist.favorites.length > 0
|
})
|
||||||
) {
|
);
|
||||||
this.store.dispatch(
|
|
||||||
FavoritesActions.setFavorites({
|
|
||||||
channelIds: playlist.favorites,
|
|
||||||
})
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
// Clear favorites if playlist has none
|
|
||||||
this.store.dispatch(
|
|
||||||
FavoritesActions.setFavorites({
|
|
||||||
channelIds: [],
|
|
||||||
})
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return playlist.playlist.items as Channel[];
|
// Load favorites from the playlist
|
||||||
})
|
if (
|
||||||
);
|
playlist.favorites &&
|
||||||
} else if (queryParams['url']) {
|
playlist.favorites.length > 0
|
||||||
return this.store.select(selectChannels) as Observable<
|
) {
|
||||||
Channel[]
|
this.store.dispatch(
|
||||||
>;
|
FavoritesActions.setFavorites({
|
||||||
}
|
channelIds: playlist.favorites,
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
} else {
|
||||||
|
// Clear favorites if playlist has none
|
||||||
|
this.store.dispatch(
|
||||||
|
FavoritesActions.setFavorites({
|
||||||
|
channelIds: [],
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return [];
|
||||||
|
})
|
||||||
|
)
|
||||||
|
.subscribe();
|
||||||
|
|
||||||
this.statusSubscription = combineLatest([
|
this.statusSubscription = combineLatest([
|
||||||
this.channels$,
|
this.channels$,
|
||||||
this.activeChannel$,
|
this.store.select(selectActive),
|
||||||
this.epgProgram$.pipe(startWith(null)),
|
this.store.select(selectCurrentEpgProgram).pipe(startWith(null)),
|
||||||
]).subscribe(([channels, activeChannel, epgProgram]) => {
|
]).subscribe(([channels, activeChannel, epgProgram]) => {
|
||||||
if (!window.electron?.updateRemoteControlStatus || !activeChannel) {
|
if (!window.electron?.updateRemoteControlStatus || !activeChannel) {
|
||||||
return;
|
return;
|
||||||
@@ -247,9 +321,7 @@ export class VideoPlayerComponent implements OnInit, OnDestroy {
|
|||||||
window.electron.updateRemoteControlStatus({
|
window.electron.updateRemoteControlStatus({
|
||||||
portal: 'm3u',
|
portal: 'm3u',
|
||||||
isLiveView: true,
|
isLiveView: true,
|
||||||
channelName:
|
channelName: activeChannel.name ?? activeChannel.tvg?.name,
|
||||||
activeChannel.name ??
|
|
||||||
activeChannel.tvg?.name,
|
|
||||||
channelNumber: currentIndex >= 0 ? currentIndex + 1 : undefined,
|
channelNumber: currentIndex >= 0 ? currentIndex + 1 : undefined,
|
||||||
epgTitle: (epgProgram as any)?.title,
|
epgTitle: (epgProgram as any)?.title,
|
||||||
epgStart: (epgProgram as any)?.start,
|
epgStart: (epgProgram as any)?.start,
|
||||||
@@ -268,7 +340,7 @@ export class VideoPlayerComponent implements OnInit, OnDestroy {
|
|||||||
console.log(`Remote control: changing channel ${direction}`);
|
console.log(`Remote control: changing channel ${direction}`);
|
||||||
|
|
||||||
// Use combineLatest to get both values and take only the first emission
|
// Use combineLatest to get both values and take only the first emission
|
||||||
combineLatest([this.channels$, this.activeChannel$])
|
combineLatest([this.channels$, this.store.select(selectActive)])
|
||||||
.pipe(
|
.pipe(
|
||||||
filter(([channels, activeChannel]) => {
|
filter(([channels, activeChannel]) => {
|
||||||
return channels.length > 0 && !!activeChannel;
|
return channels.length > 0 && !!activeChannel;
|
||||||
@@ -310,6 +382,7 @@ export class VideoPlayerComponent implements OnInit, OnDestroy {
|
|||||||
this.unsubscribeRemoteChannelChange?.();
|
this.unsubscribeRemoteChannelChange?.();
|
||||||
this.unsubscribeRemoteCommand?.();
|
this.unsubscribeRemoteCommand?.();
|
||||||
this.statusSubscription?.unsubscribe();
|
this.statusSubscription?.unsubscribe();
|
||||||
|
this.routeSubscription?.unsubscribe();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -382,11 +455,11 @@ export class VideoPlayerComponent implements OnInit, OnDestroy {
|
|||||||
) as Observable<Channel[]>;
|
) as Observable<Channel[]>;
|
||||||
|
|
||||||
// Pass the active channel's tvg.id for highlighting
|
// Pass the active channel's tvg.id for highlighting
|
||||||
this.activeChannel$.pipe(take(1)).subscribe((channel) => {
|
const currentChannel = this.activeChannel();
|
||||||
if (channel) {
|
if (currentChannel) {
|
||||||
componentRef.instance.activeChannelId = (channel as Channel).tvg?.id || null;
|
componentRef.instance.activeChannelId =
|
||||||
}
|
currentChannel.tvg?.id || null;
|
||||||
});
|
}
|
||||||
|
|
||||||
this.overlayRef.backdropClick().subscribe(() => {
|
this.overlayRef.backdropClick().subscribe(() => {
|
||||||
this.overlayRef.dispose();
|
this.overlayRef.dispose();
|
||||||
@@ -479,7 +552,9 @@ export class VideoPlayerComponent implements OnInit, OnDestroy {
|
|||||||
this.channels$
|
this.channels$
|
||||||
.pipe(
|
.pipe(
|
||||||
take(1),
|
take(1),
|
||||||
map((channels) => getChannelItemByNumber(channels, channelNumber))
|
map((channels) =>
|
||||||
|
getChannelItemByNumber(channels, channelNumber)
|
||||||
|
)
|
||||||
)
|
)
|
||||||
.subscribe((channel) => {
|
.subscribe((channel) => {
|
||||||
if (channel) {
|
if (channel) {
|
||||||
|
|||||||
@@ -18,7 +18,6 @@ import { MatTooltipModule } from '@angular/material/tooltip';
|
|||||||
import { ActivatedRoute, Router } from '@angular/router';
|
import { ActivatedRoute, Router } from '@angular/router';
|
||||||
import { TranslateModule } from '@ngx-translate/core';
|
import { TranslateModule } from '@ngx-translate/core';
|
||||||
import { DataService } from 'services';
|
import { DataService } from 'services';
|
||||||
import { GLOBAL_FAVORITES_PLAYLIST_ID } from 'shared-interfaces';
|
|
||||||
//import { shell } from 'electron';
|
//import { shell } from 'electron';
|
||||||
import { AddPlaylistMenuComponent, PlaylistType } from 'components';
|
import { AddPlaylistMenuComponent, PlaylistType } from 'components';
|
||||||
import { HomeComponent } from '../../../home/home.component';
|
import { HomeComponent } from '../../../home/home.component';
|
||||||
@@ -85,7 +84,7 @@ export class HeaderComponent implements OnInit {
|
|||||||
* Navigates to the global favorites view
|
* Navigates to the global favorites view
|
||||||
*/
|
*/
|
||||||
navigateToGlobalFavorites(): void {
|
navigateToGlobalFavorites(): void {
|
||||||
this.router.navigate(['playlists', GLOBAL_FAVORITES_PLAYLIST_ID]);
|
this.router.navigate(['/workspace', 'global-favorites']);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -1,5 +1,3 @@
|
|||||||
import { GLOBAL_FAVORITES_PLAYLIST_ID } from 'shared-interfaces';
|
|
||||||
|
|
||||||
export type PortalProvider = 'xtreams' | 'stalker' | 'playlists';
|
export type PortalProvider = 'xtreams' | 'stalker' | 'playlists';
|
||||||
|
|
||||||
export interface PortalRailLink {
|
export interface PortalRailLink {
|
||||||
@@ -160,29 +158,36 @@ export function buildPortalRailLinks(
|
|||||||
return { primary, secondary };
|
return { primary, secondary };
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
if (provider === 'playlists') {
|
||||||
primary: [
|
const primary: PortalRailLink[] = [
|
||||||
{
|
{
|
||||||
icon: 'play_circle',
|
icon: 'list',
|
||||||
tooltip: 'Player',
|
tooltip: 'All Channels',
|
||||||
path: root,
|
path: [...root, 'all'],
|
||||||
exact: true,
|
exact: true,
|
||||||
section: 'player',
|
section: 'all',
|
||||||
},
|
},
|
||||||
],
|
{
|
||||||
secondary: workspace
|
icon: 'folder',
|
||||||
? [
|
tooltip: 'Groups',
|
||||||
{
|
path: [...root, 'groups'],
|
||||||
icon: 'favorite',
|
exact: true,
|
||||||
tooltip: 'Global favorites',
|
section: 'groups',
|
||||||
path: [
|
},
|
||||||
'/workspace',
|
{
|
||||||
'playlists',
|
icon: 'star',
|
||||||
GLOBAL_FAVORITES_PLAYLIST_ID,
|
tooltip: 'Favorites',
|
||||||
],
|
path: [...root, 'favorites'],
|
||||||
section: 'favorites',
|
exact: true,
|
||||||
},
|
section: 'favorites',
|
||||||
]
|
},
|
||||||
: [],
|
];
|
||||||
};
|
|
||||||
|
return {
|
||||||
|
primary,
|
||||||
|
secondary: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return { primary: [], secondary: [] };
|
||||||
}
|
}
|
||||||
@@ -131,18 +131,16 @@
|
|||||||
<mat-icon>{{ action.icon }}</mat-icon>
|
<mat-icon>{{ action.icon }}</mat-icon>
|
||||||
</button>
|
</button>
|
||||||
}
|
}
|
||||||
<!-- <button
|
<a
|
||||||
type="button"
|
|
||||||
class="header-shortcut"
|
class="header-shortcut"
|
||||||
mat-icon-button
|
mat-icon-button
|
||||||
[class.is-active]="isFavoritesView()"
|
routerLink="/workspace/global-favorites"
|
||||||
(click)="toggleFavoritesShortcut()"
|
[class.is-active]="isGlobalFavoritesRoute()"
|
||||||
[matTooltip]="'WORKSPACE.FAVORITES_ONLY' | translate"
|
[matTooltip]="'HOME.PLAYLISTS.GLOBAL_FAVORITES' | translate"
|
||||||
[disabled]="!currentContext()"
|
aria-label="Open global favorites"
|
||||||
aria-label="Toggle favorites only"
|
|
||||||
>
|
>
|
||||||
<mat-icon>favorite</mat-icon>
|
<mat-icon>star</mat-icon>
|
||||||
</button> -->
|
</a>
|
||||||
@if (canSort()) {
|
@if (canSort()) {
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
|||||||
@@ -234,7 +234,8 @@
|
|||||||
gap: 2px;
|
gap: 2px;
|
||||||
app-region: no-drag;
|
app-region: no-drag;
|
||||||
|
|
||||||
button {
|
button,
|
||||||
|
a {
|
||||||
color: var(--mat-sys-on-surface-variant);
|
color: var(--mat-sys-on-surface-variant);
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
transition:
|
transition:
|
||||||
|
|||||||
@@ -36,7 +36,6 @@ import {
|
|||||||
} from 'm3u-state';
|
} from 'm3u-state';
|
||||||
import { filter, firstValueFrom } from 'rxjs';
|
import { filter, firstValueFrom } from 'rxjs';
|
||||||
import { PlaylistsService } from 'services';
|
import { PlaylistsService } from 'services';
|
||||||
import { GLOBAL_FAVORITES_PLAYLIST_ID } from 'shared-interfaces';
|
|
||||||
import { DownloadsService } from '../services/downloads.service';
|
import { DownloadsService } from '../services/downloads.service';
|
||||||
import { AddPlaylistDialogComponent } from '../shared/components/add-playlist/add-playlist-dialog.component';
|
import { AddPlaylistDialogComponent } from '../shared/components/add-playlist/add-playlist-dialog.component';
|
||||||
import {
|
import {
|
||||||
@@ -146,12 +145,13 @@ export class WorkspaceShellComponent {
|
|||||||
return routeContext;
|
return routeContext;
|
||||||
}
|
}
|
||||||
|
|
||||||
// On dashboard, sources, and settings we still want provider-specific rail links
|
// On dashboard, sources, settings, and global favorites we still want provider-specific rail links
|
||||||
// for the currently active playlist selected in the switcher.
|
// for the currently active playlist selected in the switcher.
|
||||||
if (
|
if (
|
||||||
!this.isDashboardRoute() &&
|
!this.isDashboardRoute() &&
|
||||||
!this.isSourcesRoute() &&
|
!this.isSourcesRoute() &&
|
||||||
!this.isSettingsRoute()
|
!this.isSettingsRoute() &&
|
||||||
|
!this.isGlobalFavoritesRoute()
|
||||||
) {
|
) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -175,6 +175,11 @@ export class WorkspaceShellComponent {
|
|||||||
readonly isSettingsRoute = computed(() =>
|
readonly isSettingsRoute = computed(() =>
|
||||||
/^\/workspace\/settings(?:\/)?(?:\?.*)?$/.test(this.currentUrl())
|
/^\/workspace\/settings(?:\/)?(?:\?.*)?$/.test(this.currentUrl())
|
||||||
);
|
);
|
||||||
|
readonly isGlobalFavoritesRoute = computed(() =>
|
||||||
|
/^\/workspace\/global-favorites(?:\/)?(?:\?.*)?$/.test(
|
||||||
|
this.currentUrl()
|
||||||
|
)
|
||||||
|
);
|
||||||
readonly dashboardXtreamContext = computed<WorkspaceContext | null>(() => {
|
readonly dashboardXtreamContext = computed<WorkspaceContext | null>(() => {
|
||||||
if (!this.isDashboardRoute()) {
|
if (!this.isDashboardRoute()) {
|
||||||
return null;
|
return null;
|
||||||
@@ -221,17 +226,6 @@ export class WorkspaceShellComponent {
|
|||||||
this.isCategoryContextRoute() ||
|
this.isCategoryContextRoute() ||
|
||||||
this.isFavoritesContextRoute()
|
this.isFavoritesContextRoute()
|
||||||
);
|
);
|
||||||
readonly isFavoritesView = computed(() => {
|
|
||||||
const context = this.currentContext();
|
|
||||||
if (
|
|
||||||
context?.provider === 'playlists' &&
|
|
||||||
context.playlistId === GLOBAL_FAVORITES_PLAYLIST_ID
|
|
||||||
) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
return this.currentSection() === 'favorites';
|
|
||||||
});
|
|
||||||
readonly canUseSearch = computed(() => {
|
readonly canUseSearch = computed(() => {
|
||||||
if (this.isSettingsRoute()) {
|
if (this.isSettingsRoute()) {
|
||||||
return false;
|
return false;
|
||||||
@@ -504,9 +498,7 @@ export class WorkspaceShellComponent {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (context.playlistId !== GLOBAL_FAVORITES_PLAYLIST_ID) {
|
this.lastM3uPlaylistId.set(context.playlistId);
|
||||||
this.lastM3uPlaylistId.set(context.playlistId);
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -600,55 +592,6 @@ export class WorkspaceShellComponent {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
toggleFavoritesShortcut(): void {
|
|
||||||
const context = this.currentContext();
|
|
||||||
if (!context) return;
|
|
||||||
|
|
||||||
if (context.provider === 'xtreams') {
|
|
||||||
if (!this.isElectron) return;
|
|
||||||
|
|
||||||
const base = ['/workspace', 'xtreams', context.playlistId];
|
|
||||||
this.router.navigate(
|
|
||||||
this.isFavoritesView()
|
|
||||||
? [...base, 'vod']
|
|
||||||
: [...base, 'favorites']
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (context.provider === 'stalker') {
|
|
||||||
const base = ['/workspace', 'stalker', context.playlistId];
|
|
||||||
this.router.navigate(
|
|
||||||
this.isFavoritesView()
|
|
||||||
? [...base, 'vod']
|
|
||||||
: [...base, 'favorites']
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (context.provider === 'playlists') {
|
|
||||||
if (context.playlistId === GLOBAL_FAVORITES_PLAYLIST_ID) {
|
|
||||||
const fallbackPlaylist = this.lastM3uPlaylistId();
|
|
||||||
if (fallbackPlaylist) {
|
|
||||||
this.router.navigate([
|
|
||||||
'/workspace',
|
|
||||||
'playlists',
|
|
||||||
fallbackPlaylist,
|
|
||||||
]);
|
|
||||||
} else {
|
|
||||||
this.router.navigate(['/workspace', 'dashboard']);
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
this.router.navigate([
|
|
||||||
'/workspace',
|
|
||||||
'playlists',
|
|
||||||
GLOBAL_FAVORITES_PLAYLIST_ID,
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
openDownloadsShortcut(): void {
|
openDownloadsShortcut(): void {
|
||||||
const context = this.downloadsContext();
|
const context = this.downloadsContext();
|
||||||
if (!context) {
|
if (!context) {
|
||||||
|
|||||||
@@ -13,6 +13,7 @@
|
|||||||
"UPLOAD_OR_SELECT_OTHER_PLAYLIST": "Neue Playlist hochladen oder eine andere aussuchen",
|
"UPLOAD_OR_SELECT_OTHER_PLAYLIST": "Neue Playlist hochladen oder eine andere aussuchen",
|
||||||
"USE_STAR_TO_FAVORITE": "Nutzen Sie das Star-Icon um Sender als Favoriten zu markieren",
|
"USE_STAR_TO_FAVORITE": "Nutzen Sie das Star-Icon um Sender als Favoriten zu markieren",
|
||||||
"FAVORITES_UPDATED": "Favoriten wurden aktualisiert!",
|
"FAVORITES_UPDATED": "Favoriten wurden aktualisiert!",
|
||||||
|
"SELECT_CHANNEL_PLAYBACK": "Bitte wählen Sie einen Sender aus, um die Wiedergabe zu starten",
|
||||||
"SORT_BY": "Sortieren nach",
|
"SORT_BY": "Sortieren nach",
|
||||||
"SORT_BY_DATE": "Nach Datum sortieren (Neueste zuerst)",
|
"SORT_BY_DATE": "Nach Datum sortieren (Neueste zuerst)",
|
||||||
"SORT_BY_RATING": "Nach Bewertung sortieren",
|
"SORT_BY_RATING": "Nach Bewertung sortieren",
|
||||||
|
|||||||
@@ -227,6 +227,7 @@
|
|||||||
"REMOVE_FAVORITE": "Remove from favorites",
|
"REMOVE_FAVORITE": "Remove from favorites",
|
||||||
"ADD_FAVORITE": "Add to favorites",
|
"ADD_FAVORITE": "Add to favorites",
|
||||||
"FAVORITES_UPDATED": "Favorites were updated!",
|
"FAVORITES_UPDATED": "Favorites were updated!",
|
||||||
|
"SELECT_CHANNEL_PLAYBACK": "Please select a channel to start playback",
|
||||||
"SORT_BY": "Sort by",
|
"SORT_BY": "Sort by",
|
||||||
"SORT_BY_DATE": "Sort by Date Added (Newest First)",
|
"SORT_BY_DATE": "Sort by Date Added (Newest First)",
|
||||||
"SORT_BY_RATING": "Sort by Rating",
|
"SORT_BY_RATING": "Sort by Rating",
|
||||||
|
|||||||
@@ -203,6 +203,7 @@
|
|||||||
"USE_STAR_TO_FAVORITE": "Используйте иконку звезды, чтобы добавлять каналы в избранное",
|
"USE_STAR_TO_FAVORITE": "Используйте иконку звезды, чтобы добавлять каналы в избранное",
|
||||||
"REMOVE_FAVORITE": "Удалить из списка фаворитов",
|
"REMOVE_FAVORITE": "Удалить из списка фаворитов",
|
||||||
"FAVORITES_UPDATED": "Список фаворитов был обновлен!",
|
"FAVORITES_UPDATED": "Список фаворитов был обновлен!",
|
||||||
|
"SELECT_CHANNEL_PLAYBACK": "Пожалуйста, выберите канал для начала воспроизведения",
|
||||||
"SORT_BY": "Сортировать по",
|
"SORT_BY": "Сортировать по",
|
||||||
"SORT_BY_DATE": "Сортировать по дате добавления (сначала новые)",
|
"SORT_BY_DATE": "Сортировать по дате добавления (сначала новые)",
|
||||||
"SORT_BY_RATING": "Сортировать по рейтингу",
|
"SORT_BY_RATING": "Сортировать по рейтингу",
|
||||||
|
|||||||
@@ -17,7 +17,6 @@ import {
|
|||||||
} from 'rxjs';
|
} from 'rxjs';
|
||||||
import { DataService, EpgService, PlaylistsService } from 'services';
|
import { DataService, EpgService, PlaylistsService } from 'services';
|
||||||
import {
|
import {
|
||||||
GLOBAL_FAVORITES_PLAYLIST_ID,
|
|
||||||
OPEN_MPV_PLAYER,
|
OPEN_MPV_PLAYER,
|
||||||
OPEN_VLC_PLAYER,
|
OPEN_VLC_PLAYER,
|
||||||
Playlist,
|
Playlist,
|
||||||
@@ -25,10 +24,10 @@ import {
|
|||||||
VideoPlayer,
|
VideoPlayer,
|
||||||
} from 'shared-interfaces';
|
} from 'shared-interfaces';
|
||||||
import {
|
import {
|
||||||
PlaylistActions,
|
|
||||||
ChannelActions,
|
ChannelActions,
|
||||||
EpgActions,
|
EpgActions,
|
||||||
FavoritesActions,
|
FavoritesActions,
|
||||||
|
PlaylistActions,
|
||||||
} from './actions';
|
} from './actions';
|
||||||
import {
|
import {
|
||||||
selectActive,
|
selectActive,
|
||||||
@@ -57,10 +56,7 @@ export class PlaylistEffects {
|
|||||||
this.store.select(selectFavorites),
|
this.store.select(selectFavorites),
|
||||||
this.store.select(selectActivePlaylistId)
|
this.store.select(selectActivePlaylistId)
|
||||||
),
|
),
|
||||||
filter(
|
filter(([, , playlistId]) => !!playlistId),
|
||||||
([, , playlistId]) =>
|
|
||||||
playlistId !== GLOBAL_FAVORITES_PLAYLIST_ID
|
|
||||||
),
|
|
||||||
switchMap(([, favorites, playlistId]) =>
|
switchMap(([, favorites, playlistId]) =>
|
||||||
this.playlistsService.updateFavorites(playlistId, favorites)
|
this.playlistsService.updateFavorites(playlistId, favorites)
|
||||||
),
|
),
|
||||||
@@ -81,10 +77,7 @@ export class PlaylistEffects {
|
|||||||
return this.actions$.pipe(
|
return this.actions$.pipe(
|
||||||
ofType(FavoritesActions.setFavorites),
|
ofType(FavoritesActions.setFavorites),
|
||||||
combineLatestWith(this.store.select(selectActivePlaylistId)),
|
combineLatestWith(this.store.select(selectActivePlaylistId)),
|
||||||
filter(
|
filter(([, playlistId]) => !!playlistId),
|
||||||
([, playlistId]) =>
|
|
||||||
playlistId !== GLOBAL_FAVORITES_PLAYLIST_ID
|
|
||||||
),
|
|
||||||
switchMap(([action, playlistId]) =>
|
switchMap(([action, playlistId]) =>
|
||||||
this.playlistsService.setFavorites(
|
this.playlistsService.setFavorites(
|
||||||
playlistId,
|
playlistId,
|
||||||
@@ -116,7 +109,8 @@ export class PlaylistEffects {
|
|||||||
activeChannel?.url +
|
activeChannel?.url +
|
||||||
(activeChannel?.epgParams ?? ''),
|
(activeChannel?.epgParams ?? ''),
|
||||||
title: activeChannel?.name ?? '',
|
title: activeChannel?.name ?? '',
|
||||||
'user-agent': activeChannel?.http?.['user-agent'],
|
'user-agent':
|
||||||
|
activeChannel?.http?.['user-agent'],
|
||||||
referer: activeChannel?.http?.referrer,
|
referer: activeChannel?.http?.referrer,
|
||||||
origin: activeChannel?.http?.origin,
|
origin: activeChannel?.http?.origin,
|
||||||
});
|
});
|
||||||
@@ -130,7 +124,8 @@ export class PlaylistEffects {
|
|||||||
activeChannel?.url +
|
activeChannel?.url +
|
||||||
(activeChannel?.epgParams ?? ''),
|
(activeChannel?.epgParams ?? ''),
|
||||||
title: activeChannel?.name ?? '',
|
title: activeChannel?.name ?? '',
|
||||||
'user-agent': activeChannel?.http?.['user-agent'],
|
'user-agent':
|
||||||
|
activeChannel?.http?.['user-agent'],
|
||||||
referer: activeChannel?.http?.referrer,
|
referer: activeChannel?.http?.referrer,
|
||||||
origin: activeChannel?.http?.origin,
|
origin: activeChannel?.http?.origin,
|
||||||
});
|
});
|
||||||
@@ -314,7 +309,10 @@ export class PlaylistEffects {
|
|||||||
type: 'xtream',
|
type: 'xtream',
|
||||||
});
|
});
|
||||||
this.router.navigate(['/xtreams/', playlist._id]);
|
this.router.navigate(['/xtreams/', playlist._id]);
|
||||||
} else if (playlist.macAddress && this.dataService.isElectron) {
|
} else if (
|
||||||
|
playlist.macAddress &&
|
||||||
|
this.dataService.isElectron
|
||||||
|
) {
|
||||||
// Create Stalker playlist in SQLite
|
// Create Stalker playlist in SQLite
|
||||||
await window.electron.dbCreatePlaylist({
|
await window.electron.dbCreatePlaylist({
|
||||||
id: playlist._id.toString(),
|
id: playlist._id.toString(),
|
||||||
@@ -394,7 +392,9 @@ export class PlaylistEffects {
|
|||||||
// Also delete from SQLite if running in Electron
|
// Also delete from SQLite if running in Electron
|
||||||
if ((window as any).electron?.dbDeleteAllPlaylists) {
|
if ((window as any).electron?.dbDeleteAllPlaylists) {
|
||||||
try {
|
try {
|
||||||
await (window as any).electron.dbDeleteAllPlaylists();
|
await (
|
||||||
|
window as any
|
||||||
|
).electron.dbDeleteAllPlaylists();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(
|
console.error(
|
||||||
'Error deleting playlists from SQLite:',
|
'Error deleting playlists from SQLite:',
|
||||||
|
|||||||
@@ -12,14 +12,18 @@ export const epgReducers = [
|
|||||||
const epgParams = `?utc=${from}&lutc=${now}`;
|
const epgParams = `?utc=${from}&lutc=${now}`;
|
||||||
return {
|
return {
|
||||||
...state,
|
...state,
|
||||||
active: { ...state.active, epgParams } as Channel,
|
active: state.active
|
||||||
|
? ({ ...state.active, epgParams } as Channel)
|
||||||
|
: undefined,
|
||||||
};
|
};
|
||||||
}),
|
}),
|
||||||
on(
|
on(
|
||||||
EpgActions.resetActiveEpgProgram,
|
EpgActions.resetActiveEpgProgram,
|
||||||
(state): PlaylistState => ({
|
(state): PlaylistState => ({
|
||||||
...state,
|
...state,
|
||||||
active: { ...state.active, epgParams: '' } as Channel,
|
active: state.active
|
||||||
|
? ({ ...state.active, epgParams: '' } as Channel)
|
||||||
|
: undefined,
|
||||||
})
|
})
|
||||||
),
|
),
|
||||||
on(
|
on(
|
||||||
|
|||||||
@@ -1,10 +1,6 @@
|
|||||||
import { EntityState } from '@ngrx/entity';
|
import { EntityState } from '@ngrx/entity';
|
||||||
import { createFeatureSelector, createSelector, Selector } from '@ngrx/store';
|
import { createFeatureSelector, createSelector, Selector } from '@ngrx/store';
|
||||||
import {
|
import { Playlist, PlaylistMeta } from 'shared-interfaces';
|
||||||
GLOBAL_FAVORITES_PLAYLIST_ID,
|
|
||||||
Playlist,
|
|
||||||
PlaylistMeta,
|
|
||||||
} from 'shared-interfaces';
|
|
||||||
import * as fromPlaylistMetaState from './playlists.state';
|
import * as fromPlaylistMetaState from './playlists.state';
|
||||||
import * as fromPlaylistState from './reducers';
|
import * as fromPlaylistState from './reducers';
|
||||||
import { selectRouteParam } from './router.selectors';
|
import { selectRouteParam } from './router.selectors';
|
||||||
@@ -80,9 +76,7 @@ export const selectPlaylistTitle = createSelector(
|
|||||||
fromPlaylistMetaState.getPlaylistMetaEntities,
|
fromPlaylistMetaState.getPlaylistMetaEntities,
|
||||||
selectCurrentPlaylistId,
|
selectCurrentPlaylistId,
|
||||||
(data) => {
|
(data) => {
|
||||||
if (data.selectedId === GLOBAL_FAVORITES_PLAYLIST_ID) {
|
if (
|
||||||
return 'Global favorites';
|
|
||||||
} else if (
|
|
||||||
data.entities &&
|
data.entities &&
|
||||||
data.selectedId &&
|
data.selectedId &&
|
||||||
data.entities[data.selectedId]
|
data.entities[data.selectedId]
|
||||||
|
|||||||
@@ -13,7 +13,6 @@ import { combineLatest, map, switchMap } from 'rxjs';
|
|||||||
import {
|
import {
|
||||||
Channel,
|
Channel,
|
||||||
DbStores,
|
DbStores,
|
||||||
GLOBAL_FAVORITES_PLAYLIST_ID,
|
|
||||||
Playlist,
|
Playlist,
|
||||||
PlaylistMeta,
|
PlaylistMeta,
|
||||||
PlaylistUpdateState,
|
PlaylistUpdateState,
|
||||||
@@ -44,7 +43,7 @@ export class PlaylistsService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
getPlaylist(id: string) {
|
getPlaylist(id: string) {
|
||||||
if (id === GLOBAL_FAVORITES_PLAYLIST_ID) {
|
if (id === 'global-favorites') {
|
||||||
return this.getPlaylistWithGlobalFavorites();
|
return this.getPlaylistWithGlobalFavorites();
|
||||||
} else {
|
} else {
|
||||||
return this.dbService.getByID<Playlist>(DbStores.Playlists, id);
|
return this.dbService.getByID<Playlist>(DbStores.Playlists, id);
|
||||||
@@ -102,7 +101,10 @@ export class PlaylistsService {
|
|||||||
: {}),
|
: {}),
|
||||||
// Stalker portal optional fields
|
// Stalker portal optional fields
|
||||||
...(updatedPlaylist.stalkerSerialNumber !== undefined
|
...(updatedPlaylist.stalkerSerialNumber !== undefined
|
||||||
? { stalkerSerialNumber: updatedPlaylist.stalkerSerialNumber }
|
? {
|
||||||
|
stalkerSerialNumber:
|
||||||
|
updatedPlaylist.stalkerSerialNumber,
|
||||||
|
}
|
||||||
: {}),
|
: {}),
|
||||||
...(updatedPlaylist.stalkerDeviceId1 !== undefined
|
...(updatedPlaylist.stalkerDeviceId1 !== undefined
|
||||||
? { stalkerDeviceId1: updatedPlaylist.stalkerDeviceId1 }
|
? { stalkerDeviceId1: updatedPlaylist.stalkerDeviceId1 }
|
||||||
@@ -111,10 +113,16 @@ export class PlaylistsService {
|
|||||||
? { stalkerDeviceId2: updatedPlaylist.stalkerDeviceId2 }
|
? { stalkerDeviceId2: updatedPlaylist.stalkerDeviceId2 }
|
||||||
: {}),
|
: {}),
|
||||||
...(updatedPlaylist.stalkerSignature1 !== undefined
|
...(updatedPlaylist.stalkerSignature1 !== undefined
|
||||||
? { stalkerSignature1: updatedPlaylist.stalkerSignature1 }
|
? {
|
||||||
|
stalkerSignature1:
|
||||||
|
updatedPlaylist.stalkerSignature1,
|
||||||
|
}
|
||||||
: {}),
|
: {}),
|
||||||
...(updatedPlaylist.stalkerSignature2 !== undefined
|
...(updatedPlaylist.stalkerSignature2 !== undefined
|
||||||
? { stalkerSignature2: updatedPlaylist.stalkerSignature2 }
|
? {
|
||||||
|
stalkerSignature2:
|
||||||
|
updatedPlaylist.stalkerSignature2,
|
||||||
|
}
|
||||||
: {}),
|
: {}),
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
@@ -220,29 +228,26 @@ export class PlaylistsService {
|
|||||||
switchMap((portal) =>
|
switchMap((portal) =>
|
||||||
this.dbService.update(DbStores.Playlists, {
|
this.dbService.update(DbStores.Playlists, {
|
||||||
...portal,
|
...portal,
|
||||||
favorites: portal.favorites?.filter(
|
favorites: portal.favorites?.filter((i) => {
|
||||||
(i) => {
|
const expectedId = String(favoriteId);
|
||||||
const expectedId = String(favoriteId);
|
const streamId = String(
|
||||||
const streamId = String(
|
(i as Partial<XtreamItem>).stream_id ?? ''
|
||||||
(i as Partial<XtreamItem>).stream_id ?? ''
|
);
|
||||||
);
|
const seriesId = String(
|
||||||
const seriesId = String(
|
(i as Partial<XtreamSerieItem>).series_id ?? ''
|
||||||
(i as Partial<XtreamSerieItem>).series_id ?? ''
|
);
|
||||||
);
|
const movieId = String(
|
||||||
const movieId = String(
|
(i as Partial<{ movie_id: string }>).movie_id ?? ''
|
||||||
(i as Partial<{ movie_id: string }>).movie_id ??
|
);
|
||||||
''
|
const itemId = String((i as any).id ?? '');
|
||||||
);
|
|
||||||
const itemId = String((i as any).id ?? '');
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
streamId !== expectedId &&
|
streamId !== expectedId &&
|
||||||
seriesId !== expectedId &&
|
seriesId !== expectedId &&
|
||||||
movieId !== expectedId &&
|
movieId !== expectedId &&
|
||||||
itemId !== expectedId
|
itemId !== expectedId
|
||||||
);
|
);
|
||||||
}
|
}),
|
||||||
),
|
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
export * from './lib/channel.interface';
|
export * from './lib/channel.interface';
|
||||||
export * from './lib/channel.model';
|
export * from './lib/channel.model';
|
||||||
export * from './lib/constants';
|
|
||||||
export * from './lib/epg-channel-with-programs.interface';
|
export * from './lib/epg-channel-with-programs.interface';
|
||||||
export * from './lib/epg-channel.model';
|
export * from './lib/epg-channel.model';
|
||||||
export * from './lib/epg-item.interface';
|
export * from './lib/epg-item.interface';
|
||||||
@@ -29,9 +28,9 @@ export * from './lib/xtream-vod-details.interface';
|
|||||||
export * from './lib/xtream-vod-stream.interface';
|
export * from './lib/xtream-vod-stream.interface';
|
||||||
|
|
||||||
// Stalker interfaces
|
// Stalker interfaces
|
||||||
export * from './lib/stalker-vod-details.interface';
|
|
||||||
export * from './lib/stalker-serial-details.interface';
|
export * from './lib/stalker-serial-details.interface';
|
||||||
|
export * from './lib/stalker-vod-details.interface';
|
||||||
|
|
||||||
// Unified VOD details (discriminated union + adapters)
|
// Unified VOD details (discriminated union + adapters)
|
||||||
export * from './lib/vod-details-item.interface';
|
|
||||||
export * from './lib/vod-details-adapters';
|
export * from './lib/vod-details-adapters';
|
||||||
|
export * from './lib/vod-details-item.interface';
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
/**
|
|
||||||
* Id of the channel with favorite channels aggregated from all added playlists
|
|
||||||
*/
|
|
||||||
export const GLOBAL_FAVORITES_PLAYLIST_ID = 'GLOBAL_FAVORITES';
|
|
||||||
@@ -1,6 +1,5 @@
|
|||||||
import {
|
import {
|
||||||
Channel,
|
Channel,
|
||||||
GLOBAL_FAVORITES_PLAYLIST_ID,
|
|
||||||
ParsedPlaylist,
|
ParsedPlaylist,
|
||||||
ParsedPlaylistItem,
|
ParsedPlaylistItem,
|
||||||
Playlist,
|
Playlist,
|
||||||
@@ -39,7 +38,7 @@ export function createFavoritesPlaylist(
|
|||||||
channels: Channel[]
|
channels: Channel[]
|
||||||
): Partial<Playlist> {
|
): Partial<Playlist> {
|
||||||
return {
|
return {
|
||||||
_id: GLOBAL_FAVORITES_PLAYLIST_ID,
|
_id: 'global-favorites',
|
||||||
count: channels.length,
|
count: channels.length,
|
||||||
playlist: {
|
playlist: {
|
||||||
items: channels,
|
items: channels,
|
||||||
|
|||||||
File renamed without changes.
+11
-6
@@ -1,7 +1,14 @@
|
|||||||
@use '@angular/material' as mat;
|
@use '@angular/material' as mat;
|
||||||
|
|
||||||
|
:host {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
height: 100%;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
.scroll-viewport {
|
.scroll-viewport {
|
||||||
min-height: calc(100vh - 190px);
|
height: 100%;
|
||||||
overflow-x: hidden;
|
overflow-x: hidden;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
|
||||||
@@ -26,9 +33,9 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
#all-channels {
|
#all-channels {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
overflow-x: hidden;
|
|
||||||
max-height: calc(100vh - 167px);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.channel-list {
|
.channel-list {
|
||||||
@@ -89,7 +96,5 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
@media only screen and (max-width: 599px) {
|
@media only screen and (max-width: 599px) {
|
||||||
.scroll-viewport {
|
/* Mobile responsive tweaks if needed */
|
||||||
min-height: calc(50vh - 159px);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
+11
-11
@@ -26,20 +26,20 @@ export interface EnrichedChannel extends Channel {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-all-channels-tab',
|
selector: 'app-all-channels-view',
|
||||||
templateUrl: './all-channels-tab.component.html',
|
templateUrl: './all-channels-view.component.html',
|
||||||
styleUrls: ['./all-channels-tab.component.scss'],
|
styleUrls: ['./all-channels-view.component.scss'],
|
||||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||||
imports: [
|
imports: [
|
||||||
ChannelListItemComponent,
|
ChannelListItemComponent,
|
||||||
MatFormFieldModule,
|
MatFormFieldModule,
|
||||||
MatIconModule,
|
MatIconModule,
|
||||||
MatInputModule,
|
MatInputModule,
|
||||||
ScrollingModule,
|
ScrollingModule,
|
||||||
TranslatePipe
|
TranslatePipe,
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
export class AllChannelsTabComponent implements OnDestroy {
|
export class AllChannelsViewComponent implements OnDestroy {
|
||||||
/** All channels (will be filtered by search) */
|
/** All channels (will be filtered by search) */
|
||||||
readonly channels = input.required<Channel[]>();
|
readonly channels = input.required<Channel[]>();
|
||||||
|
|
||||||
+31
-48
@@ -1,14 +1,16 @@
|
|||||||
@if (displayedChannels().length) {
|
@if (displayedChannels().length) {
|
||||||
<mat-tab-group color="primary" backgroundColor="primary">
|
@if (viewTitle()) {
|
||||||
<!-- All Channels Tab -->
|
<header class="context-header">
|
||||||
<mat-tab>
|
<div class="context-header__top">
|
||||||
<ng-template mat-tab-label>
|
<h2>{{ viewTitle() | translate }}</h2>
|
||||||
<mat-icon class="tab-icon">list</mat-icon>
|
</div>
|
||||||
<div class="tab-title">
|
</header>
|
||||||
{{ 'CHANNELS.ALL_CHANNELS' | translate }}
|
<div class="context-divider"></div>
|
||||||
</div>
|
}
|
||||||
</ng-template>
|
|
||||||
<app-all-channels-tab
|
@switch (activeView()) {
|
||||||
|
@case ('all') {
|
||||||
|
<app-all-channels-view
|
||||||
[channels]="displayedChannels()"
|
[channels]="displayedChannels()"
|
||||||
[channelEpgMap]="channelEpgMap()"
|
[channelEpgMap]="channelEpgMap()"
|
||||||
[progressTick]="progressTick()"
|
[progressTick]="progressTick()"
|
||||||
@@ -19,17 +21,9 @@
|
|||||||
(channelSelected)="onChannelSelected($event)"
|
(channelSelected)="onChannelSelected($event)"
|
||||||
(favoriteToggled)="onFavoriteToggled($event)"
|
(favoriteToggled)="onFavoriteToggled($event)"
|
||||||
/>
|
/>
|
||||||
</mat-tab>
|
}
|
||||||
|
@case ('groups') {
|
||||||
<!-- Groups Tab -->
|
<app-groups-view
|
||||||
<mat-tab>
|
|
||||||
<ng-template mat-tab-label>
|
|
||||||
<mat-icon class="tab-icon">category</mat-icon>
|
|
||||||
<div class="tab-title">
|
|
||||||
{{ 'CHANNELS.GROUPS' | translate }}
|
|
||||||
</div>
|
|
||||||
</ng-template>
|
|
||||||
<app-groups-tab
|
|
||||||
[groupedChannels]="groupedChannels()"
|
[groupedChannels]="groupedChannels()"
|
||||||
[channelEpgMap]="channelEpgMap()"
|
[channelEpgMap]="channelEpgMap()"
|
||||||
[progressTick]="progressTick()"
|
[progressTick]="progressTick()"
|
||||||
@@ -39,34 +33,23 @@
|
|||||||
(channelSelected)="onChannelSelected($event)"
|
(channelSelected)="onChannelSelected($event)"
|
||||||
(favoriteToggled)="onFavoriteToggled($event)"
|
(favoriteToggled)="onFavoriteToggled($event)"
|
||||||
/>
|
/>
|
||||||
</mat-tab>
|
|
||||||
|
|
||||||
<!-- Favorites Tab -->
|
|
||||||
@let showFavorites = (playlistId$ | async) !== 'GLOBAL_FAVORITES';
|
|
||||||
@if (showFavorites) {
|
|
||||||
<mat-tab>
|
|
||||||
<ng-template mat-tab-label>
|
|
||||||
<mat-icon class="tab-icon">star</mat-icon>
|
|
||||||
<div class="tab-title">
|
|
||||||
{{ 'CHANNELS.FAVORITES' | translate }}
|
|
||||||
</div>
|
|
||||||
</ng-template>
|
|
||||||
@let favorites = favorites$ | async;
|
|
||||||
@if (favorites) {
|
|
||||||
<app-favorites-tab
|
|
||||||
[favorites]="favorites"
|
|
||||||
[channelEpgMap]="channelEpgMap()"
|
|
||||||
[progressTick]="progressTick()"
|
|
||||||
[shouldShowEpg]="shouldShowEpg()"
|
|
||||||
[activeChannelUrl]="activeChannelUrl()"
|
|
||||||
(channelSelected)="onChannelSelected($event)"
|
|
||||||
(favoriteToggled)="onFavoriteToggled($event)"
|
|
||||||
(favoritesReordered)="onFavoritesReordered($event)"
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
</mat-tab>
|
|
||||||
}
|
}
|
||||||
</mat-tab-group>
|
@case ('favorites') {
|
||||||
|
@let favorites = favorites$ | async;
|
||||||
|
@if (favorites) {
|
||||||
|
<app-favorites-view
|
||||||
|
[favorites]="favorites"
|
||||||
|
[channelEpgMap]="channelEpgMap()"
|
||||||
|
[progressTick]="progressTick()"
|
||||||
|
[shouldShowEpg]="shouldShowEpg()"
|
||||||
|
[activeChannelUrl]="activeChannelUrl()"
|
||||||
|
(channelSelected)="onChannelSelected($event)"
|
||||||
|
(favoriteToggled)="onFavoriteToggled($event)"
|
||||||
|
(favoritesReordered)="onFavoritesReordered($event)"
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
} @else {
|
} @else {
|
||||||
<div class="loading-container">
|
<div class="loading-container">
|
||||||
<mat-icon class="loading-spinner">sync</mat-icon>
|
<mat-icon class="loading-spinner">sync</mat-icon>
|
||||||
|
|||||||
+32
-1
@@ -1,5 +1,6 @@
|
|||||||
:host {
|
:host {
|
||||||
display: block;
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
container-type: inline-size;
|
container-type: inline-size;
|
||||||
@@ -52,3 +53,33 @@
|
|||||||
display: block;
|
display: block;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── Header ───────────────────────────────────────────────────────────────────
|
||||||
|
.context-header {
|
||||||
|
padding: 12px 16px 8px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.context-header__top {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 8px;
|
||||||
|
|
||||||
|
h2 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 0.94rem;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: -0.01em;
|
||||||
|
color: var(--mat-sys-on-surface);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Divider ──────────────────────────────────────────────────────────────────
|
||||||
|
.context-divider {
|
||||||
|
height: 1px;
|
||||||
|
background: var(--mat-sys-outline-variant);
|
||||||
|
margin: 0 0 6px;
|
||||||
|
opacity: 0.6;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
+37
-21
@@ -5,12 +5,14 @@ import {
|
|||||||
computed,
|
computed,
|
||||||
inject,
|
inject,
|
||||||
Input,
|
Input,
|
||||||
|
input,
|
||||||
OnDestroy,
|
OnDestroy,
|
||||||
OnInit,
|
OnInit,
|
||||||
signal,
|
signal,
|
||||||
} from '@angular/core';
|
} from '@angular/core';
|
||||||
|
import { toSignal } from '@angular/core/rxjs-interop';
|
||||||
import { MatIconModule } from '@angular/material/icon';
|
import { MatIconModule } from '@angular/material/icon';
|
||||||
import { MatTabsModule } from '@angular/material/tabs';
|
import { NavigationEnd, Router } from '@angular/router';
|
||||||
import { Store } from '@ngrx/store';
|
import { Store } from '@ngrx/store';
|
||||||
import { StorageMap } from '@ngx-pwa/local-storage';
|
import { StorageMap } from '@ngx-pwa/local-storage';
|
||||||
import { TranslatePipe } from '@ngx-translate/core';
|
import { TranslatePipe } from '@ngx-translate/core';
|
||||||
@@ -22,18 +24,12 @@ import {
|
|||||||
selectActivePlaylistId,
|
selectActivePlaylistId,
|
||||||
selectFavorites,
|
selectFavorites,
|
||||||
} from 'm3u-state';
|
} from 'm3u-state';
|
||||||
import { BehaviorSubject, combineLatest, map, skipWhile } from 'rxjs';
|
import { BehaviorSubject, combineLatest, filter, map, skipWhile } from 'rxjs';
|
||||||
import { EpgService } from 'services';
|
import { EpgService } from 'services';
|
||||||
import {
|
import { Channel, EpgProgram, Settings, STORE_KEY } from 'shared-interfaces';
|
||||||
Channel,
|
import { AllChannelsViewComponent } from './all-channels-view/all-channels-view.component';
|
||||||
EpgProgram,
|
import { FavoritesViewComponent } from './favorites-view/favorites-view.component';
|
||||||
GLOBAL_FAVORITES_PLAYLIST_ID,
|
import { GroupsViewComponent } from './groups-view/groups-view.component';
|
||||||
Settings,
|
|
||||||
STORE_KEY,
|
|
||||||
} from 'shared-interfaces';
|
|
||||||
import { AllChannelsTabComponent } from './all-channels-tab/all-channels-tab.component';
|
|
||||||
import { FavoritesTabComponent } from './favorites-tab/favorites-tab.component';
|
|
||||||
import { GroupsTabComponent } from './groups-tab/groups-tab.component';
|
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-channel-list-container',
|
selector: 'app-channel-list-container',
|
||||||
@@ -41,12 +37,11 @@ import { GroupsTabComponent } from './groups-tab/groups-tab.component';
|
|||||||
styleUrls: ['./channel-list-container.component.scss'],
|
styleUrls: ['./channel-list-container.component.scss'],
|
||||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||||
imports: [
|
imports: [
|
||||||
AllChannelsTabComponent,
|
AllChannelsViewComponent,
|
||||||
CommonModule,
|
CommonModule,
|
||||||
FavoritesTabComponent,
|
FavoritesViewComponent,
|
||||||
GroupsTabComponent,
|
GroupsViewComponent,
|
||||||
MatIconModule,
|
MatIconModule,
|
||||||
MatTabsModule,
|
|
||||||
TranslatePipe,
|
TranslatePipe,
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
@@ -54,6 +49,7 @@ export class ChannelListContainerComponent implements OnInit, OnDestroy {
|
|||||||
private readonly epgService = inject(EpgService);
|
private readonly epgService = inject(EpgService);
|
||||||
private readonly storage = inject(StorageMap);
|
private readonly storage = inject(StorageMap);
|
||||||
private readonly store = inject(Store);
|
private readonly store = inject(Store);
|
||||||
|
private readonly router = inject(Router);
|
||||||
|
|
||||||
/** Map of channel ID to current EPG program */
|
/** Map of channel ID to current EPG program */
|
||||||
readonly channelEpgMap = signal(new Map<string, EpgProgram | null>());
|
readonly channelEpgMap = signal(new Map<string, EpgProgram | null>());
|
||||||
@@ -73,6 +69,30 @@ export class ChannelListContainerComponent implements OnInit, OnDestroy {
|
|||||||
/** Item size for virtual scroll - compact when no EPG */
|
/** Item size for virtual scroll - compact when no EPG */
|
||||||
readonly itemSize = computed(() => (this.shouldShowEpg() ? 68 : 48));
|
readonly itemSize = computed(() => (this.shouldShowEpg() ? 68 : 48));
|
||||||
|
|
||||||
|
/** Active view (all, groups, favorites) */
|
||||||
|
readonly activeView = input<string>('all');
|
||||||
|
|
||||||
|
readonly currentUrl = toSignal(
|
||||||
|
this.router.events.pipe(
|
||||||
|
filter((event) => event instanceof NavigationEnd),
|
||||||
|
map((event) => (event as NavigationEnd).urlAfterRedirects)
|
||||||
|
),
|
||||||
|
{ initialValue: this.router.url }
|
||||||
|
);
|
||||||
|
|
||||||
|
readonly viewTitle = computed(() => {
|
||||||
|
const view = this.activeView();
|
||||||
|
const url = this.currentUrl();
|
||||||
|
if (view === 'all') return 'CHANNELS.ALL_CHANNELS';
|
||||||
|
if (view === 'groups') return 'CHANNELS.GROUPS';
|
||||||
|
if (view === 'favorites') {
|
||||||
|
return url.includes('/workspace/global-favorites')
|
||||||
|
? 'HOME.PLAYLISTS.GLOBAL_FAVORITES'
|
||||||
|
: 'CHANNELS.FAVORITES';
|
||||||
|
}
|
||||||
|
return '';
|
||||||
|
});
|
||||||
|
|
||||||
/** Channels array */
|
/** Channels array */
|
||||||
_channelList: Channel[] = [];
|
_channelList: Channel[] = [];
|
||||||
private readonly channelListSignal = signal<Channel[]>([]);
|
private readonly channelListSignal = signal<Channel[]>([]);
|
||||||
@@ -98,11 +118,7 @@ export class ChannelListContainerComponent implements OnInit, OnDestroy {
|
|||||||
|
|
||||||
/** Displayed channels - filters out unfavorited channels in global favorites view */
|
/** Displayed channels - filters out unfavorited channels in global favorites view */
|
||||||
readonly displayedChannels = computed(() => {
|
readonly displayedChannels = computed(() => {
|
||||||
const channels = this.channelListSignal();
|
return this.channelListSignal();
|
||||||
if (this.activePlaylistIdSignal() === GLOBAL_FAVORITES_PLAYLIST_ID) {
|
|
||||||
return channels.filter((ch) => this.favoriteIds().has(ch.url));
|
|
||||||
}
|
|
||||||
return channels;
|
|
||||||
});
|
});
|
||||||
|
|
||||||
/** Object with channels sorted by groups */
|
/** Object with channels sorted by groups */
|
||||||
|
|||||||
File renamed without changes.
+10
-1
@@ -1,6 +1,15 @@
|
|||||||
|
:host {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
height: 100%;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
#favorites-list {
|
#favorites-list {
|
||||||
height: calc(100vh - 105px);
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
overflow-x: hidden;
|
overflow-x: hidden;
|
||||||
|
overflow-y: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.channel-list {
|
.channel-list {
|
||||||
+5
-5
@@ -12,17 +12,17 @@ import {
|
|||||||
} from '@angular/core';
|
} from '@angular/core';
|
||||||
import { TranslatePipe } from '@ngx-translate/core';
|
import { TranslatePipe } from '@ngx-translate/core';
|
||||||
import { Channel, EpgProgram } from 'shared-interfaces';
|
import { Channel, EpgProgram } from 'shared-interfaces';
|
||||||
import { EnrichedChannel } from '../all-channels-tab/all-channels-tab.component';
|
import { EnrichedChannel } from '../all-channels-view/all-channels-view.component';
|
||||||
import { ChannelListItemComponent } from '../channel-list-item/channel-list-item.component';
|
import { ChannelListItemComponent } from '../channel-list-item/channel-list-item.component';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-favorites-tab',
|
selector: 'app-favorites-view',
|
||||||
templateUrl: './favorites-tab.component.html',
|
templateUrl: './favorites-view.component.html',
|
||||||
styleUrls: ['./favorites-tab.component.scss'],
|
styleUrls: ['./favorites-view.component.scss'],
|
||||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||||
imports: [ChannelListItemComponent, DragDropModule, TranslatePipe],
|
imports: [ChannelListItemComponent, DragDropModule, TranslatePipe],
|
||||||
})
|
})
|
||||||
export class FavoritesTabComponent {
|
export class FavoritesViewComponent {
|
||||||
/** Favorite channels */
|
/** Favorite channels */
|
||||||
readonly favorites = input.required<Channel[]>();
|
readonly favorites = input.required<Channel[]>();
|
||||||
|
|
||||||
File renamed without changes.
+10
-1
@@ -1,6 +1,15 @@
|
|||||||
|
:host {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
height: 100%;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
#groups-list {
|
#groups-list {
|
||||||
height: calc(100vh - 105px);
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
overflow-x: hidden;
|
overflow-x: hidden;
|
||||||
|
overflow-y: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.channel-list {
|
.channel-list {
|
||||||
+5
-5
@@ -16,13 +16,13 @@ import { MatExpansionModule } from '@angular/material/expansion';
|
|||||||
import { MatIconModule } from '@angular/material/icon';
|
import { MatIconModule } from '@angular/material/icon';
|
||||||
import { TranslatePipe } from '@ngx-translate/core';
|
import { TranslatePipe } from '@ngx-translate/core';
|
||||||
import { Channel, EpgProgram } from 'shared-interfaces';
|
import { Channel, EpgProgram } from 'shared-interfaces';
|
||||||
import { EnrichedChannel } from '../all-channels-tab/all-channels-tab.component';
|
import { EnrichedChannel } from '../all-channels-view/all-channels-view.component';
|
||||||
import { ChannelListItemComponent } from '../channel-list-item/channel-list-item.component';
|
import { ChannelListItemComponent } from '../channel-list-item/channel-list-item.component';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-groups-tab',
|
selector: 'app-groups-view',
|
||||||
templateUrl: './groups-tab.component.html',
|
templateUrl: './groups-view.component.html',
|
||||||
styleUrls: ['./groups-tab.component.scss'],
|
styleUrls: ['./groups-view.component.scss'],
|
||||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||||
imports: [
|
imports: [
|
||||||
ChannelListItemComponent,
|
ChannelListItemComponent,
|
||||||
@@ -33,7 +33,7 @@ import { ChannelListItemComponent } from '../channel-list-item/channel-list-item
|
|||||||
TranslatePipe,
|
TranslatePipe,
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
export class GroupsTabComponent implements AfterViewInit, OnDestroy {
|
export class GroupsViewComponent implements AfterViewInit, OnDestroy {
|
||||||
private readonly cdr = inject(ChangeDetectorRef);
|
private readonly cdr = inject(ChangeDetectorRef);
|
||||||
private readonly ngZone = inject(NgZone);
|
private readonly ngZone = inject(NgZone);
|
||||||
|
|
||||||
@@ -17,4 +17,7 @@
|
|||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
|
||||||
<app-channel-list-container [channelList]="channels()" />
|
<app-channel-list-container
|
||||||
|
[channelList]="channels()"
|
||||||
|
[activeView]="activeView()"
|
||||||
|
/>
|
||||||
@@ -27,6 +27,7 @@ import { ChannelListContainerComponent } from './../../channel-list-container/ch
|
|||||||
export class SidebarComponent {
|
export class SidebarComponent {
|
||||||
readonly channels = input<Channel[]>([]);
|
readonly channels = input<Channel[]>([]);
|
||||||
readonly showPlaylistHeader = input(true);
|
readonly showPlaylistHeader = input(true);
|
||||||
|
readonly activeView = input<string>('all');
|
||||||
|
|
||||||
private readonly store = inject(Store);
|
private readonly store = inject(Store);
|
||||||
private readonly translate = inject(TranslateService);
|
private readonly translate = inject(TranslateService);
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
>
|
>
|
||||||
<mat-icon>{{ isLeftDrawerOpened ? 'menu' : 'menu_open' }}</mat-icon>
|
<mat-icon>{{ isLeftDrawerOpened ? 'menu' : 'menu_open' }}</mat-icon>
|
||||||
</button>
|
</button>
|
||||||
@if ((playlistId$ | async) !== 'GLOBAL_FAVORITES') {
|
<!-- @if (playlistId$ | async) {
|
||||||
<button
|
<button
|
||||||
mat-icon-button
|
mat-icon-button
|
||||||
(click)="updateFavoriteStatus(activeChannel)"
|
(click)="updateFavoriteStatus(activeChannel)"
|
||||||
@@ -21,7 +21,7 @@
|
|||||||
}}</mat-icon
|
}}</mat-icon
|
||||||
>
|
>
|
||||||
</button>
|
</button>
|
||||||
}
|
} -->
|
||||||
<button
|
<button
|
||||||
mat-icon-button
|
mat-icon-button
|
||||||
(click)="infoOverlayClicked.emit()"
|
(click)="infoOverlayClicked.emit()"
|
||||||
@@ -31,13 +31,13 @@
|
|||||||
</button>
|
</button>
|
||||||
{{ activeChannel?.name }}
|
{{ activeChannel?.name }}
|
||||||
<div class="spacer"></div>
|
<div class="spacer"></div>
|
||||||
<button
|
<!-- <button
|
||||||
mat-icon-button
|
mat-icon-button
|
||||||
(click)="settingsClicked.emit()"
|
(click)="settingsClicked.emit()"
|
||||||
[matTooltip]="'MENU.SETTINGS' | translate"
|
[matTooltip]="'MENU.SETTINGS' | translate"
|
||||||
>
|
>
|
||||||
<mat-icon>settings</mat-icon>
|
<mat-icon>settings</mat-icon>
|
||||||
</button>
|
</button> -->
|
||||||
<button
|
<button
|
||||||
mat-icon-button
|
mat-icon-button
|
||||||
(click)="multiEpgClicked.emit()"
|
(click)="multiEpgClicked.emit()"
|
||||||
|
|||||||
@@ -8,10 +8,10 @@ import { MatTooltip } from '@angular/material/tooltip';
|
|||||||
import { Store } from '@ngrx/store';
|
import { Store } from '@ngrx/store';
|
||||||
import { TranslatePipe } from '@ngx-translate/core';
|
import { TranslatePipe } from '@ngx-translate/core';
|
||||||
import {
|
import {
|
||||||
|
FavoritesActions,
|
||||||
selectActivePlaylistId,
|
selectActivePlaylistId,
|
||||||
selectFavorites,
|
selectFavorites,
|
||||||
selectIsEpgAvailable,
|
selectIsEpgAvailable,
|
||||||
FavoritesActions,
|
|
||||||
} from 'm3u-state';
|
} from 'm3u-state';
|
||||||
import { Channel } from 'shared-interfaces';
|
import { Channel } from 'shared-interfaces';
|
||||||
|
|
||||||
|
|||||||
+3
-1
@@ -32,7 +32,9 @@
|
|||||||
"package:app": "nx run electron-backend:make --prepackageOnly",
|
"package:app": "nx run electron-backend:make --prepackageOnly",
|
||||||
"make:app": "nx run electron-backend:make",
|
"make:app": "nx run electron-backend:make",
|
||||||
"serve:website": "nx serve website",
|
"serve:website": "nx serve website",
|
||||||
"build:website": "nx build website"
|
"build:website": "nx build website",
|
||||||
|
"lint": "nx lint electron-backend",
|
||||||
|
"build": "nx build electron-backend"
|
||||||
},
|
},
|
||||||
"private": true,
|
"private": true,
|
||||||
"packageManager": "pnpm@10.17.1",
|
"packageManager": "pnpm@10.17.1",
|
||||||
|
|||||||
Reference in new issue
Block a user