diff --git a/CLAUDE.md b/CLAUDE.md index 1063b0072..68129166a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -264,6 +264,24 @@ See `docs/architecture/m3u-playlist-module.md` for complete documentation. - Same schema structure but implemented in IndexedDB - 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**: This project uses modern Angular signal-based APIs and patterns. **ALWAYS** use the following: diff --git a/apps/web/src/app/app.routes.ts b/apps/web/src/app/app.routes.ts index 0df0fe084..0e3395f8f 100644 --- a/apps/web/src/app/app.routes.ts +++ b/apps/web/src/app/app.routes.ts @@ -49,6 +49,21 @@ export const routes: Routes = [ }, { 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: { layout: 'workspace', }, @@ -90,6 +105,11 @@ export const routes: Routes = [ }, { path: 'playlists/:id', + pathMatch: 'full', + redirectTo: 'playlists/:id/all', + }, + { + path: 'playlists/:id/:view', loadComponent: () => import('./home/video-player/video-player.component').then( (c) => c.VideoPlayerComponent diff --git a/apps/web/src/app/home/video-player/video-player.component.html b/apps/web/src/app/home/video-player/video-player.component.html index a04d0b27e..b44f2af66 100644 --- a/apps/web/src/app/home/video-player/video-player.component.html +++ b/apps/web/src/app/home/video-player/video-player.component.html @@ -21,6 +21,7 @@ } @loading {

Loading...

@@ -29,7 +30,7 @@ } - @if (activeChannel$ | async; as activeChannel) { + @if (activeChannel(); as activeChannel) { @@ -129,6 +130,20 @@ } } + } @else { + + +
+ live_tv +

+ {{ 'CHANNELS.SELECT_CHANNEL_PLAYBACK' | translate }} +

+
}
diff --git a/apps/web/src/app/home/video-player/video-player.component.scss b/apps/web/src/app/home/video-player/video-player.component.scss index 01e92b02c..3730e74ed 100644 --- a/apps/web/src/app/home/video-player/video-player.component.scss +++ b/apps/web/src/app/home/video-player/video-player.component.scss @@ -126,3 +126,29 @@ 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; + } +} diff --git a/apps/web/src/app/home/video-player/video-player.component.ts b/apps/web/src/app/home/video-player/video-player.component.ts index b4942a4fe..1cc8d2b53 100644 --- a/apps/web/src/app/home/video-player/video-player.component.ts +++ b/apps/web/src/app/home/video-player/video-player.component.ts @@ -11,11 +11,14 @@ import { inject, viewChild, } from '@angular/core'; +import { toSignal } from '@angular/core/rxjs-interop'; import { MatDialog } from '@angular/material/dialog'; +import { MatIcon } from '@angular/material/icon'; import { MatSidenavModule } from '@angular/material/sidenav'; import { ActivatedRoute, Router, RouterLink } from '@angular/router'; import { Store } from '@ngrx/store'; import { StorageMap } from '@ngx-pwa/local-storage'; +import { TranslatePipe } from '@ngx-translate/core'; import { ArtPlayerComponent, AudioPlayerComponent, @@ -29,8 +32,10 @@ import { ToolbarComponent, VjsPlayerComponent, } from 'components'; -import { PlaylistActions, ChannelActions, FavoritesActions } from 'm3u-state'; import { + ChannelActions, + FavoritesActions, + PlaylistActions, selectActive, selectChannels, selectCurrentEpgProgram, @@ -40,6 +45,7 @@ import { Subscription, combineLatest, combineLatestWith, + distinctUntilChanged, filter, map, startWith, @@ -56,13 +62,14 @@ import { VideoPlayer, } from 'shared-interfaces'; import { SettingsStore } from '../../services/settings-store.service'; +import { SettingsComponent } from '../../settings/settings.component'; import { getAdjacentChannelItem, getChannelItemByNumber, } from '../../shared/services/remote-channel-navigation.util'; -import { SettingsComponent } from '../../settings/settings.component'; @Component({ + selector: 'app-video-player', imports: [ ArtPlayerComponent, AsyncPipe, @@ -71,11 +78,13 @@ import { SettingsComponent } from '../../settings/settings.component'; EpgListComponent, HtmlVideoPlayerComponent, InfoOverlayComponent, + MatIcon, MatSidenavModule, ResizableDirective, RouterLink, SidebarComponent, ToolbarComponent, + TranslatePipe, VjsPlayerComponent, ], templateUrl: './video-player.component.html', @@ -93,13 +102,34 @@ export class VideoPlayerComponent implements OnInit, OnDestroy { private readonly store = inject(Store); /** Active selected channel */ - readonly activeChannel$ = this.store.select(selectActive); + readonly activeChannel = this.store.selectSignal(selectActive); /** Channels list */ - channels$!: Observable; + readonly channels$: Observable = this.store.select( + selectChannels + ) as Observable; /** 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 */ playerSettings: Partial = { @@ -118,6 +148,7 @@ export class VideoPlayerComponent implements OnInit, OnDestroy { private unsubscribeRemoteChannelChange?: () => void; private unsubscribeRemoteCommand?: () => void; private statusSubscription?: Subscription; + private routeSubscription?: Subscription; private lastKnownVolume = 1; /** 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) if (this.isDesktop && window.electron?.onChannelChange) { - const unsubscribe = window.electron.onChannelChange((data: { - direction: 'up' | 'down'; - }) => { - this.handleRemoteChannelChange(data.direction); - }); + const unsubscribe = window.electron.onChannelChange( + (data: { direction: 'up' | 'down' }) => { + this.handleRemoteChannelChange(data.direction); + } + ); if (typeof unsubscribe === 'function') { this.unsubscribeRemoteChannelChange = unsubscribe; } @@ -175,66 +206,109 @@ export class VideoPlayerComponent implements OnInit, OnDestroy { } } - this.channels$ = this.activatedRoute.params.pipe( - combineLatestWith(this.activatedRoute.queryParams), - switchMap(([params, queryParams]) => { - if (params['id']) { - this.store.dispatch(ChannelActions.resetActiveChannel()); - this.store.dispatch( - PlaylistActions.setActivePlaylist({ - playlistId: params['id'], - }) + this.routeSubscription = this.activatedRoute.params + .pipe( + distinctUntilChanged((prev, curr) => prev['id'] === curr['id']), + combineLatestWith(this.activatedRoute.queryParams), + switchMap(([params, queryParams]) => { + const isGlobalFavorites = this.router.url.includes( + '/workspace/global-favorites' ); - 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( - ChannelActions.setChannels({ - channels: playlist.playlist.items, + if (isGlobalFavorites) { + this.store.dispatch( + 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 - if ( - playlist.favorites && - 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: [], - }) - ); - } + this.store.dispatch( + ChannelActions.setChannels({ + channels: playlist.playlist.items, + }) + ); - return playlist.playlist.items as Channel[]; - }) - ); - } else if (queryParams['url']) { - return this.store.select(selectChannels) as Observable< - Channel[] - >; - } - }) - ); + // Load favorites from the playlist + if ( + playlist.favorites && + 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 []; + }) + ) + .subscribe(); this.statusSubscription = combineLatest([ this.channels$, - this.activeChannel$, - this.epgProgram$.pipe(startWith(null)), + this.store.select(selectActive), + this.store.select(selectCurrentEpgProgram).pipe(startWith(null)), ]).subscribe(([channels, activeChannel, epgProgram]) => { if (!window.electron?.updateRemoteControlStatus || !activeChannel) { return; @@ -247,9 +321,7 @@ export class VideoPlayerComponent implements OnInit, OnDestroy { window.electron.updateRemoteControlStatus({ portal: 'm3u', isLiveView: true, - channelName: - activeChannel.name ?? - activeChannel.tvg?.name, + channelName: activeChannel.name ?? activeChannel.tvg?.name, channelNumber: currentIndex >= 0 ? currentIndex + 1 : undefined, epgTitle: (epgProgram as any)?.title, epgStart: (epgProgram as any)?.start, @@ -268,7 +340,7 @@ export class VideoPlayerComponent implements OnInit, OnDestroy { console.log(`Remote control: changing channel ${direction}`); // 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( filter(([channels, activeChannel]) => { return channels.length > 0 && !!activeChannel; @@ -310,6 +382,7 @@ export class VideoPlayerComponent implements OnInit, OnDestroy { this.unsubscribeRemoteChannelChange?.(); this.unsubscribeRemoteCommand?.(); this.statusSubscription?.unsubscribe(); + this.routeSubscription?.unsubscribe(); } /** @@ -382,11 +455,11 @@ export class VideoPlayerComponent implements OnInit, OnDestroy { ) as Observable; // Pass the active channel's tvg.id for highlighting - this.activeChannel$.pipe(take(1)).subscribe((channel) => { - if (channel) { - componentRef.instance.activeChannelId = (channel as Channel).tvg?.id || null; - } - }); + const currentChannel = this.activeChannel(); + if (currentChannel) { + componentRef.instance.activeChannelId = + currentChannel.tvg?.id || null; + } this.overlayRef.backdropClick().subscribe(() => { this.overlayRef.dispose(); @@ -479,7 +552,9 @@ export class VideoPlayerComponent implements OnInit, OnDestroy { this.channels$ .pipe( take(1), - map((channels) => getChannelItemByNumber(channels, channelNumber)) + map((channels) => + getChannelItemByNumber(channels, channelNumber) + ) ) .subscribe((channel) => { if (channel) { diff --git a/apps/web/src/app/shared/components/header/header.component.ts b/apps/web/src/app/shared/components/header/header.component.ts index 67b1c34de..f8b5a508b 100644 --- a/apps/web/src/app/shared/components/header/header.component.ts +++ b/apps/web/src/app/shared/components/header/header.component.ts @@ -18,7 +18,6 @@ import { MatTooltipModule } from '@angular/material/tooltip'; import { ActivatedRoute, Router } from '@angular/router'; import { TranslateModule } from '@ngx-translate/core'; import { DataService } from 'services'; -import { GLOBAL_FAVORITES_PLAYLIST_ID } from 'shared-interfaces'; //import { shell } from 'electron'; import { AddPlaylistMenuComponent, PlaylistType } from 'components'; import { HomeComponent } from '../../../home/home.component'; @@ -85,7 +84,7 @@ export class HeaderComponent implements OnInit { * Navigates to the global favorites view */ navigateToGlobalFavorites(): void { - this.router.navigate(['playlists', GLOBAL_FAVORITES_PLAYLIST_ID]); + this.router.navigate(['/workspace', 'global-favorites']); } /** diff --git a/apps/web/src/app/shared/navigation/portal-rail-links.ts b/apps/web/src/app/shared/navigation/portal-rail-links.ts index 626532561..4c8bb5b98 100644 --- a/apps/web/src/app/shared/navigation/portal-rail-links.ts +++ b/apps/web/src/app/shared/navigation/portal-rail-links.ts @@ -1,5 +1,3 @@ -import { GLOBAL_FAVORITES_PLAYLIST_ID } from 'shared-interfaces'; - export type PortalProvider = 'xtreams' | 'stalker' | 'playlists'; export interface PortalRailLink { @@ -160,29 +158,36 @@ export function buildPortalRailLinks( return { primary, secondary }; } - return { - primary: [ + if (provider === 'playlists') { + const primary: PortalRailLink[] = [ { - icon: 'play_circle', - tooltip: 'Player', - path: root, + icon: 'list', + tooltip: 'All Channels', + path: [...root, 'all'], exact: true, - section: 'player', + section: 'all', }, - ], - secondary: workspace - ? [ - { - icon: 'favorite', - tooltip: 'Global favorites', - path: [ - '/workspace', - 'playlists', - GLOBAL_FAVORITES_PLAYLIST_ID, - ], - section: 'favorites', - }, - ] - : [], - }; + { + icon: 'folder', + tooltip: 'Groups', + path: [...root, 'groups'], + exact: true, + section: 'groups', + }, + { + icon: 'star', + tooltip: 'Favorites', + path: [...root, 'favorites'], + exact: true, + section: 'favorites', + }, + ]; + + return { + primary, + secondary: [], + }; + } + + return { primary: [], secondary: [] }; } diff --git a/apps/web/src/app/workspace/workspace-shell.component.html b/apps/web/src/app/workspace/workspace-shell.component.html index 0857b569b..0801c3e59 100644 --- a/apps/web/src/app/workspace/workspace-shell.component.html +++ b/apps/web/src/app/workspace/workspace-shell.component.html @@ -131,18 +131,16 @@ {{ action.icon }} } - + star + @if (canSort()) { - @if ((playlistId$ | async) !== 'GLOBAL_FAVORITES') { + + -->