diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 41cd94774..7d693d23c 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -9,5 +9,9 @@ ], "deny": [], "ask": [] - } + }, + "enabledMcpjsonServers": [ + "nx-mcp" + ], + "enableAllProjectMcpServers": true } diff --git a/.github/workflows/build-and-make.yaml b/.github/workflows/build-and-make.yaml index acda077b8..fff436794 100644 --- a/.github/workflows/build-and-make.yaml +++ b/.github/workflows/build-and-make.yaml @@ -41,7 +41,7 @@ jobs: run: pnpm install --no-frozen-lockfile - name: Build frontend - run: npm run build:frontend + run: npx nx build web --skip-nx-cache - name: Build backend run: npm run build:backend diff --git a/.mcp.json b/.mcp.json new file mode 100644 index 000000000..ae5a82ef5 --- /dev/null +++ b/.mcp.json @@ -0,0 +1,9 @@ +{ + "mcpServers": { + "nx-mcp": { + "type": "stdio", + "command": "npx", + "args": ["nx", "mcp"] + } + } +} diff --git a/.vscode/extensions.json b/.vscode/extensions.json new file mode 100644 index 000000000..3583c4314 --- /dev/null +++ b/.vscode/extensions.json @@ -0,0 +1,3 @@ +{ + "recommendations": ["ms-playwright.playwright", "esbenp.prettier-vscode"] +} diff --git a/CLAUDE.md b/CLAUDE.md index c42097d00..9420b3598 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -94,23 +94,25 @@ This is an Nx monorepo with the following structure: - **apps/electron-backend** - Electron main process - **apps/web-e2e** - Playwright end-to-end tests - **libs/** - Shared libraries: - - **m3u-state** - NgRx state management for playlists - - **services** - Abstract DataService and implementations - - **shared/interfaces** - TypeScript interfaces and types - - **shared/m3u-utils** - M3U playlist utilities - - **ui/components** - Reusable UI components - - **ui/pipes** - Angular pipes - - **ui/shared-portals** - Portal-related UI components + - **m3u-state** - NgRx state management for playlists + - **services** - Abstract DataService and implementations + - **shared/interfaces** - TypeScript interfaces and types + - **shared/m3u-utils** - M3U playlist utilities + - **ui/components** - Reusable UI components + - **ui/pipes** - Angular pipes + - **ui/shared-portals** - Portal-related UI components ### Frontend Architecture (Angular) **State Management**: Uses NgRx for playlist state management: + - Store configuration in `apps/web/src/app/app.config.ts` - Playlist state, actions, effects, and reducers in `libs/m3u-state/` - Entity adapter pattern for managing playlists collection - Router store integration for route-based state **Routing**: Lazy-loaded routes in `apps/web/src/app/app.routes.ts` + - Home/playlists overview: `/` - Video player: `/playlists/:id` or `/iptv` - Xtream Codes: `/xtreams/:id` (different routes for Electron vs web) @@ -118,85 +120,96 @@ This is an Nx monorepo with the following structure: - Settings: `/settings` **Service Architecture** (Factory Pattern): + - Abstract `DataService` class in `libs/services/src/lib/data.service.ts` defines the contract - Two environment-specific implementations: - - `ElectronService` (`apps/web/src/app/services/electron.service.ts`) - Uses IPC to communicate with Electron backend - - `PwaService` (`apps/web/src/app/services/pwa.service.ts`) - Uses HTTP API and IndexedDB for standalone web version + - `ElectronService` (`apps/web/src/app/services/electron.service.ts`) - Uses IPC to communicate with Electron backend + - `PwaService` (`apps/web/src/app/services/pwa.service.ts`) - Uses HTTP API and IndexedDB for standalone web version - Factory function `DataFactory()` in `apps/web/src/app/app.config.ts` determines which implementation to inject: - ```typescript - if (window.electron) { - return new ElectronService(); - } - return new PwaService(); - ``` + ```typescript + if (window.electron) { + return new ElectronService(); + } + return new PwaService(); + ``` **Data Storage (Environment-Specific)**: + - **Electron**: libSQL/SQLite database via Drizzle ORM - - Location: `~/.iptvnator/databases/iptvnator.db` - - Full-featured relational database with foreign keys and indexes - - Supports local file or remote Turso instance via env vars + - Location: `~/.iptvnator/databases/iptvnator.db` + - Full-featured relational database with foreign keys and indexes + - Supports local file or remote Turso instance via env vars - **PWA (Web)**: IndexedDB via `ngx-indexed-db` - - Browser-based NoSQL storage - - Same schema structure but implemented in IndexedDB - - Limited by browser storage quotas + - Browser-based NoSQL storage + - Same schema structure but implemented in IndexedDB + - Limited by browser storage quotas ### Backend Architecture (Electron) **Main Entry**: `apps/electron-backend/src/main.ts` + - Bootstraps Electron app and initializes database - Registers event handlers for IPC communication **Database**: + - **ORM**: Drizzle ORM with libSQL (local SQLite file or remote Turso) - **Location**: `~/.iptvnator/databases/iptvnator.db` (avoids spaces in path) - **Schema** (`apps/electron-backend/src/app/database/schema.ts`): - - `playlists` - Playlist metadata (M3U, Xtream, Stalker) - - `categories` - Content categories (live, movies, series) - - `content` - Streams/VOD/series items - - `favorites` - User favorites - - `recentlyViewed` - Watch history + - `playlists` - Playlist metadata (M3U, Xtream, Stalker) + - `categories` - Content categories (live, movies, series) + - `content` - Streams/VOD/series items + - `favorites` - User favorites + - `recentlyViewed` - Watch history - **Connection**: `apps/electron-backend/src/app/database/connection.ts` - - Auto-creates tables on init - - Supports local file or remote via env vars (`LIBSQL_URL`, `LIBSQL_AUTH_TOKEN`) + - Auto-creates tables on init + - Supports local file or remote via env vars (`LIBSQL_URL`, `LIBSQL_AUTH_TOKEN`) **IPC Communication**: + - **Preload script**: `apps/electron-backend/src/app/api/main.preload.ts` - - Exposes `window.electron` API via `contextBridge` - - All IPC channels defined here (playlist operations, EPG, database CRUD, external players, etc.) + - Exposes `window.electron` API via `contextBridge` + - All IPC channels defined here (playlist operations, EPG, database CRUD, external players, etc.) - **Event handlers**: `apps/electron-backend/src/app/events/` - - `database.events.ts` - Database CRUD operations - - `playlist.events.ts` - Playlist import/update - - `epg.events.ts` - EPG fetch and parsing (uses worker) - - `xtream.events.ts` - Xtream Codes API - - `stalker.events.ts` - Stalker portal API - - `player.events.ts` - External player (MPV, VLC) integration - - `settings.events.ts` - App settings - - `electron.events.ts` - App version, etc. + - `database.events.ts` - Database CRUD operations + - `playlist.events.ts` - Playlist import/update + - `epg.events.ts` - EPG fetch and parsing (uses worker) + - `xtream.events.ts` - Xtream Codes API + - `stalker.events.ts` - Stalker portal API + - `player.events.ts` - External player (MPV, VLC) integration + - `settings.events.ts` - App settings + - `electron.events.ts` - App version, etc. **Workers**: + - EPG parsing runs in worker thread: `apps/electron-backend/src/app/workers/epg-parser.worker.ts` ### Key Features **Playlist Support**: + - M3U/M3U8 files (local or URL) - Xtream Codes API (`username`, `password`, `serverUrl`) - Stalker portal (`macAddress`, `url`) **Video Players**: + - Built-in HTML5 player with HLS.js or Video.js - External players: MPV, VLC (via IPC to Electron backend) **EPG (Electronic Program Guide)**: + - XMLTV format support - Background parsing in worker thread - Stored in database for quick lookup **Favorites and Recently Viewed**: + - Per-playlist favorites and global favorites - Recently viewed tracks watch history **Internationalization**: + - Uses `@ngx-translate` with 16 language files in `apps/web/src/assets/i18n/` ## Development Notes @@ -204,34 +217,39 @@ This is an Nx monorepo with the following structure: ### Environment Detection and Dual-Mode Architecture The app determines whether it's running in Electron or as a PWA by checking: + ```typescript -window.electron // truthy in Electron, undefined in browser +window.electron; // truthy in Electron, undefined in browser ``` **Why Dual Mode?** IPTVnator supports both Electron (desktop app) and PWA (web browser) to provide flexibility: + - **Electron**: Full-featured desktop experience with local database, external player support (MPV/VLC), and native file system access - **PWA**: Lightweight web version that runs in any browser without installation **Environment-Specific Behavior**: + - `app.config.ts` - `DataFactory()` selects DataService implementation based on environment - `app.routes.ts` - Different routes for Xtream portals (Electron uses Tauri-based routes, PWA uses standard routes) - Storage layer switches automatically: - - Electron → libSQL/Drizzle ORM → `~/.iptvnator/databases/iptvnator.db` - - PWA → IndexedDB → Browser storage + - Electron → libSQL/Drizzle ORM → `~/.iptvnator/databases/iptvnator.db` + - PWA → IndexedDB → Browser storage - External player support (MPV/VLC) only available in Electron - File system operations only available in Electron (uploading playlists from disk) **Base Href Configuration**: The app uses different base href values depending on the build target: + - **Development & PWA**: `baseHref="/"` (from `index.html`) - - Used by: `npm run serve:frontend`, `npm run build:frontend:pwa` - - For web servers with proper routing + - Used by: `npm run serve:frontend`, `npm run build:frontend:pwa` + - For web servers with proper routing - **Electron Production**: `baseHref="./"` (overridden in build config) - - Used by: `npm run build:backend`, `npm run make:app` - - Required for `file://` protocol in Electron + - Used by: `npm run build:backend`, `npm run make:app` + - Required for `file://` protocol in Electron Build configurations in `apps/web/project.json`: + - `production`: Electron build with `baseHref="./"` - `pwa`: Web deployment with `baseHref="/"` - `development`: Dev mode with `baseHref="/"` from index.html @@ -248,6 +266,7 @@ The factory pattern ensures a single codebase works in both environments without ### Nx Commands Use `nx` CLI for better performance: + ```bash nx run : # Example: nx run web:build @@ -255,6 +274,7 @@ nx run : ``` To run multiple projects: + ```bash nx run-many --target=test --all ``` @@ -262,6 +282,7 @@ nx run-many --target=test --all ### Electron Build Process The Electron backend depends on the web app being built first: + - `electron-backend:build` depends on `web:build` - Output goes to `dist/apps/electron-backend` (backend) and `dist/apps/web` (frontend) - Packaging combines both into distributable @@ -273,18 +294,35 @@ No formal migration system yet. Schema changes are applied via raw SQL in `conne ### Common Patterns **IPC Communication**: + 1. Define handler in appropriate events file (e.g., `database.events.ts`) 2. Register with `ipcMain.handle()` in the event bootstrap function 3. Expose in preload script via `contextBridge.exposeInMainWorld()` 4. Call from Angular via `window.electron.()` **Adding New Playlist Source**: + 1. Add type to `libs/shared/interfaces/src/lib/playlist.interface.ts` 2. Create event handler in `apps/electron-backend/src/app/events/` 3. Add UI in `apps/web/src/app/home/` 4. Update database schema if needed **State Management**: + - Use NgRx for global application state (playlists) - Use component stores (`@ngrx/component-store`) for feature-specific state - Use NgRx signals for reactive data streams + + + + +# General Guidelines for working with Nx + +- When running tasks (for example build, lint, test, e2e, etc.), always prefer running the task through `nx` (i.e. `nx run`, `nx run-many`, `nx affected`) instead of using the underlying tooling directly +- You have access to the Nx MCP server and its tools, use them to help the user +- When answering questions about the repository, use the `nx_workspace` tool first to gain an understanding of the workspace architecture where applicable. +- When working in individual projects, use the `nx_project_details` mcp tool to analyze and understand the specific project structure and dependencies +- For questions around nx configuration, best practices or if you're unsure, use the `nx_docs` tool to get relevant, up-to-date docs. Always use this instead of assuming things about nx configuration +- If the user needs help with an Nx configuration or project graph error, use the `nx_workspace` tool to get any errors + + diff --git a/apps/electron-backend/src/app/app.ts b/apps/electron-backend/src/app/app.ts index 6bd03b797..e262c3ed2 100644 --- a/apps/electron-backend/src/app/app.ts +++ b/apps/electron-backend/src/app/app.ts @@ -1,6 +1,5 @@ import { BrowserWindow, screen, shell } from 'electron'; import { join } from 'path'; -import { format } from 'url'; import { environment } from '../environments/environment'; import { rendererAppName, rendererAppPort } from './constants'; import { store, WINDOW_BOUNDS } from './services/store.service'; @@ -118,17 +117,8 @@ export default class App { App.mainWindow.loadURL(`http://localhost:${rendererAppPort}`); App.mainWindow.webContents.openDevTools(); } else { - App.mainWindow.loadURL( - format({ - pathname: join( - __dirname, - '..', - rendererAppName, - 'index.html' - ), - protocol: 'file:', - slashes: true, - }) + App.mainWindow.loadFile( + join(__dirname, '..', rendererAppName, 'index.html') ); } } diff --git a/apps/electron-backend/src/app/events/playlist.events.ts b/apps/electron-backend/src/app/events/playlist.events.ts index 99169e6b8..d858f2cf2 100644 --- a/apps/electron-backend/src/app/events/playlist.events.ts +++ b/apps/electron-backend/src/app/events/playlist.events.ts @@ -151,11 +151,13 @@ ipcMain.handle(AUTO_UPDATE_PLAYLISTS, async (event, playlists) => { continue; } - // Preserve the original _id and autoRefresh setting + // Preserve user data when updating playlist updatedPlaylists.push({ ...playlistObject, _id: playlist._id, autoRefresh: playlist.autoRefresh, + favorites: playlist.favorites || [], // Preserve favorites + userAgent: playlist.userAgent, // Preserve custom user agent }); console.log(`Successfully updated playlist "${playlist.title}"`); 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 a6880df8b..5926a7050 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 @@ -16,7 +16,9 @@ (toggleLeftDrawerClicked)="drawer.toggle()" (toggleRightDrawerClicked)="drawerRight.toggle()" (multiEpgClicked)="openMultiEpgView()" + (infoOverlayClicked)="toggleInfoOverlay()" [activeChannel]="activeChannel" + [isLeftDrawerOpened]="drawer.opened" (settingsClicked)="openSettings()" /> @if (activeChannel.radio === 'true') { @@ -98,6 +100,15 @@ [channel]="activeChannel" [epgProgram]="(epgProgram$ | async)!" /> + + + @if (showChannelNumberOverlay) { +
+
+ {{ channelNumberInput }} +
+
+ } } } 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 1a2ffa0c7..c876706e4 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 @@ -54,3 +54,39 @@ } } } + +.channel-number-overlay { + position: fixed; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + z-index: 1000; + pointer-events: none; +} + +.channel-number-display { + background: rgba(0, 0, 0, 0.85); + color: #fff; + font-size: 6rem; + font-weight: 700; + font-family: 'DM Sans', sans-serif; + padding: 2rem 3rem; + border-radius: 16px; + box-shadow: 0 8px 32px rgba(0, 0, 0, 0.5); + border: 2px solid rgba(255, 255, 255, 0.1); + min-width: 120px; + text-align: center; + letter-spacing: 0.05em; + animation: scaleIn 0.15s ease-out; +} + +@keyframes scaleIn { + from { + opacity: 0; + transform: scale(0.8); + } + to { + opacity: 1; + transform: scale(1); + } +} 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 e58d41192..5ce337d7e 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 @@ -3,10 +3,12 @@ import { ComponentPortal } from '@angular/cdk/portal'; import { AsyncPipe, CommonModule } from '@angular/common'; import { Component, + HostListener, Injector, OnInit, effect, inject, + viewChild, } from '@angular/core'; import { MatDialog } from '@angular/material/dialog'; import { MatSidenavModule } from '@angular/material/sidenav'; @@ -31,7 +33,15 @@ import { selectChannels, selectCurrentEpgProgram, } from 'm3u-state'; -import { Observable, combineLatest, combineLatestWith, filter, map, switchMap, take } from 'rxjs'; +import { + Observable, + combineLatest, + combineLatestWith, + filter, + map, + switchMap, + take, +} from 'rxjs'; import { DataService, PlaylistsService } from 'services'; import { Channel, @@ -97,6 +107,14 @@ export class VideoPlayerComponent implements OnInit { /** EPG overlay reference */ private overlayRef!: OverlayRef; + /** Info overlay component reference for manual triggering */ + readonly infoOverlay = viewChild(InfoOverlayComponent); + + /** Channel number input state */ + channelNumberInput = ''; + showChannelNumberOverlay = false; + private channelNumberTimeout?: number; + volume = 1; constructor() { @@ -124,9 +142,11 @@ export class VideoPlayerComponent implements OnInit { // Setup remote control channel change listener (Electron only) if (this.isDesktop && window.electron?.onChannelChange) { - window.electron.onChannelChange((data: { direction: 'up' | 'down' }) => { - this.handleRemoteChannelChange(data.direction); - }); + window.electron.onChannelChange( + (data: { direction: 'up' | 'down' }) => { + this.handleRemoteChannelChange(data.direction); + } + ); } this.channels$ = this.activatedRoute.params.pipe( @@ -153,6 +173,26 @@ export class VideoPlayerComponent implements OnInit { channels: playlist.playlist.items, }) ); + + // Load favorites from the playlist + if ( + playlist.favorites && + playlist.favorites.length > 0 + ) { + this.store.dispatch( + PlaylistActions.setFavorites({ + channelIds: playlist.favorites, + }) + ); + } else { + // Clear favorites if playlist has none + this.store.dispatch( + PlaylistActions.setFavorites({ + channelIds: [], + }) + ); + } + return playlist.playlist.items as Channel[]; }) ); @@ -179,18 +219,20 @@ export class VideoPlayerComponent implements OnInit { }), take(1), map(([channels, activeChannel]) => { - return { channels, activeChannel: activeChannel as Channel }; + return { + channels, + activeChannel: activeChannel as Channel, + }; }) ) .subscribe({ next: ({ channels, activeChannel }) => { // Find current channel index const currentIndex = channels.findIndex( - ch => ch.url === activeChannel.url + (ch) => ch.url === activeChannel.url ); if (currentIndex === -1) { - console.warn('Current channel not found in channel list'); return; } @@ -198,20 +240,29 @@ export class VideoPlayerComponent implements OnInit { let nextIndex: number; if (direction === 'up') { // Up = previous channel (decrease index) - nextIndex = currentIndex - 1 < 0 ? channels.length - 1 : currentIndex - 1; + nextIndex = + currentIndex - 1 < 0 + ? channels.length - 1 + : currentIndex - 1; } else { // Down = next channel (increase index) - nextIndex = currentIndex + 1 >= channels.length ? 0 : currentIndex + 1; + nextIndex = + currentIndex + 1 >= channels.length + ? 0 + : currentIndex + 1; } // Dispatch action to change channel const nextChannel = channels[nextIndex]; - console.log(`Switching to channel: ${nextChannel.name}`); - this.store.dispatch(PlaylistActions.setActiveChannel({ channel: nextChannel })); + this.store.dispatch( + PlaylistActions.setActiveChannel({ + channel: nextChannel, + }) + ); }, error: (err) => { console.error('Error changing channel:', err); - } + }, }); } @@ -284,6 +335,13 @@ export class VideoPlayerComponent implements OnInit { selectChannels ) 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; + } + }); + this.overlayRef.backdropClick().subscribe(() => { this.overlayRef.dispose(); }); @@ -304,4 +362,95 @@ export class VideoPlayerComponent implements OnInit { data: { isDialog: true }, }); } + + /** + * Keyboard shortcut: Press 'I' to toggle EPG info overlay + */ + @HostListener('document:keydown.i', ['$event']) + handleInfoKeyPress(event: Event): void { + // Prevent default behavior and show info overlay + event.preventDefault(); + this.toggleInfoOverlay(); + } + + /** + * Handle digit key presses for channel number input + */ + @HostListener('document:keydown', ['$event']) + handleKeyPress(event: KeyboardEvent): void { + // Only handle digit keys (0-9) + if (event.key >= '0' && event.key <= '9') { + event.preventDefault(); + this.handleChannelNumberInput(event.key); + } + } + + /** + * Toggles the EPG info overlay visibility + * Called by 'I' keyboard shortcut or info button + */ + toggleInfoOverlay(): void { + const overlay = this.infoOverlay(); + if (overlay) { + overlay.showOverlay(); + } + } + + /** + * Handle channel number input from keyboard + * Debounces input to allow multi-digit channel numbers + */ + handleChannelNumberInput(digit: string): void { + // Clear existing timeout + if (this.channelNumberTimeout) { + clearTimeout(this.channelNumberTimeout); + } + + // Add digit to current input + this.channelNumberInput += digit; + this.showChannelNumberOverlay = true; + + // Set timeout to switch channel after 2 seconds of no input + this.channelNumberTimeout = window.setTimeout(() => { + this.switchToChannelByNumber(parseInt(this.channelNumberInput, 10)); + this.clearChannelNumberInput(); + }, 2000); + } + + /** + * Switch to channel by number (1-based index) + */ + switchToChannelByNumber(channelNumber: number): void { + this.channels$ + .pipe( + take(1), + map((channels) => { + // Channel numbers are 1-based, array is 0-based + const channelIndex = channelNumber - 1; + if (channelIndex >= 0 && channelIndex < channels.length) { + return channels[channelIndex]; + } + return null; + }) + ) + .subscribe((channel) => { + if (channel) { + this.store.dispatch( + PlaylistActions.setActiveChannel({ channel }) + ); + } + }); + } + + /** + * Clear channel number input and hide overlay + */ + clearChannelNumberInput(): void { + this.channelNumberInput = ''; + this.showChannelNumberOverlay = false; + if (this.channelNumberTimeout) { + clearTimeout(this.channelNumberTimeout); + this.channelNumberTimeout = undefined; + } + } } diff --git a/apps/web/src/assets/i18n/en.json b/apps/web/src/assets/i18n/en.json index e50568ceb..061a5513b 100644 --- a/apps/web/src/assets/i18n/en.json +++ b/apps/web/src/assets/i18n/en.json @@ -198,7 +198,8 @@ "OPEN_CHANNELS_LIST": "Open channel list", "TOGGLE_FAVORITE_FLAG": "Toggle favorite flag", "OPEN_EPG_LIST": "Open EPG list", - "OPEN_MULTI_EPG": "Open Multi-EPG view" + "OPEN_MULTI_EPG": "Open Multi-EPG view", + "SHOW_INFO": "Show program info (Press I)" }, "EPG": { "TITLE": "Electronic Program Guide", @@ -213,6 +214,7 @@ "FETCH_EPG": "Fetching EPG data...", "ERROR": "Ooops, EPG could not be loaded.", "DOWNLOAD_SUCCESS": "EPG was successfully downloaded.", + "NO_PROGRAM_INFO": "No program information available", "PROGRAM_DIALOG": { "PROGRAM_DETAILS": "Details about the TV program", "TITLE": "Title", diff --git a/apps/web/src/index.html b/apps/web/src/index.html index d2475cdec..1d564b59e 100644 --- a/apps/web/src/index.html +++ b/apps/web/src/index.html @@ -20,13 +20,9 @@ - + -