Merge pull request #644 from 4gray/refactor/ipc-events-handling

refactor/ipc-events-handling
This commit is contained in:
4gray authored and GitHub committed 2025-11-08 22:46:47 +00:00
commit b8650be90f
57 files changed
+7055 -5376

No files matched your search

+11
View File
@@ -0,0 +1,11 @@
{
"permissions": {
"allow": [
"Bash(tree:*)",
"WebSearch",
"WebFetch(domain:www.electronjs.org)"
],
"deny": [],
"ask": []
}
}
+12 -3
View File
@@ -110,11 +110,20 @@ jobs:
uses: softprops/action-gh-release@v2
with:
draft: true
prerelease: false
prerelease: ${{ github.event_name == 'pull_request' }}
name: Release v${{ steps.package-version.outputs.version }}
tag_name: ${{ github.ref_name }}
tag_name: ${{ startsWith(github.ref, 'refs/tags/') && github.ref_name || format('test-{0}', github.sha) }}
generate_release_notes: true
files: |
artifacts/**/*
artifacts/macos-artifacts/*.dmg
artifacts/macos-artifacts/*.zip
artifacts/linux-artifacts/*.AppImage
artifacts/linux-artifacts/*.deb
artifacts/linux-artifacts/*.rpm
artifacts/linux-artifacts/*.snap
artifacts/linux-artifacts/*.tar.gz
artifacts/windows-artifacts/*-setup.exe
artifacts/windows-artifacts/*.msi
artifacts/windows-artifacts/*.zip
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
-1
View File
@@ -1 +0,0 @@
_
-8
View File
@@ -1,8 +0,0 @@
#!/bin/sh
. "$(dirname "$0")/_/husky.sh"
[ -n "$CI" ] && exit 0
npx --no-install commitlint --edit
npm test
npx eslint src/**/*.ts
+290
View File
@@ -0,0 +1,290 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Project Overview
IPTVnator is a cross-platform IPTV player application built with Angular and Electron, supporting M3U/M3U8 playlists, Xtream Codes API, and Stalker portals.
**Dual Environment Support**: The application is designed to work in both Electron and as a Progressive Web App (PWA). The architecture uses a factory pattern to inject environment-specific services at runtime, ensuring the same codebase works in both contexts.
## Development Commands
### Building and Serving
```bash
# Serve the Angular web app only (development mode, baseHref="/")
npm run serve:frontend
# or
nx serve web
# Serve with PWA configuration (optimized, baseHref="/")
npm run serve:frontend:pwa
# or
nx serve web --configuration=pwa
# Serve the Electron app (starts both frontend and backend)
npm run serve:backend
# or
nx serve electron-backend
# Build frontend for Electron (baseHref="./")
npm run build:frontend
# or
nx build web
# Build frontend for PWA deployment (baseHref="/")
npm run build:frontend:pwa
# or
nx build web --configuration=pwa
# Build backend (Electron)
npm run build:backend
# or
nx build electron-backend
# Package the app (creates distributable without installers)
npm run package:app
# or
nx run electron-backend:package
# Create installers/executables
npm run make:app
# or
nx run electron-backend:make
```
### Testing
```bash
# Run frontend tests
npm run test:frontend
# or
nx test web
# Run backend tests
npm run test:backend
# or
nx test electron-backend
# Run e2e tests (Playwright)
nx e2e web-e2e
# Run tests with coverage
nx test web --configuration=ci
```
### Linting
```bash
# Lint frontend
nx lint web
# Lint backend
nx lint electron-backend
```
## Architecture
### Monorepo Structure (Nx Workspace)
This is an Nx monorepo with the following structure:
- **apps/web** - Angular application (frontend)
- **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
### 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)
- Stalker portal: `/portals/:id`
- 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
- 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();
```
**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
- **PWA (Web)**: IndexedDB via `ngx-indexed-db`
- 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
- **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`)
**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.)
- **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.
**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
### 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
```
**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
- 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
- **Electron Production**: `baseHref="./"` (overridden in build config)
- 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
**Factory Pattern Implementation**:
The factory pattern ensures a single codebase works in both environments without conditional checks scattered throughout the application. All environment-specific logic is encapsulated in the service implementations.
### Testing Strategy
- **Unit tests**: Jest with `jest-preset-angular` and `ng-mocks`
- **E2E tests**: Playwright testing the web app
- Backend tests use standard Jest
### Nx Commands
Use `nx` CLI for better performance:
```bash
nx run <project>:<target>
# Example: nx run web:build
# Example: nx run electron-backend:serve
```
To run multiple projects:
```bash
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
### Database Migrations
No formal migration system yet. Schema changes are applied via raw SQL in `connection.ts` `createTables()` function using `CREATE TABLE IF NOT EXISTS`.
### 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.<methodName>()`
**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
+5 -6
View File
@@ -13,7 +13,7 @@
**IPTVnator** is a video player application that provides support for IPTV playlist playback (m3u, m3u8). The application allows users to import playlists using remote URLs or by uploading files from the local file system. Additionally, it supports EPG information in XMLTV format which can be provided via URL.
The application is a cross-platform, open-source project built with ~~Electron~~ Tauri and Angular.
The application is a cross-platform, open-source project built with Electron and Angular.
⚠️ Note: IPTVnator does not provide any playlists or other digital content. The channels and pictures in the screenshots are for demonstration purposes only.
@@ -113,7 +113,6 @@ sudo emerge iptvnator-bin
Requirements:
- Node.js with npm
- Rust (required for tauri)
1. Clone this repository and install project dependencies:
@@ -123,15 +122,15 @@ Requirements:
2. Start the application:
```
$ npm run tauri dev
$ npm run serve:backend
```
This will open the Tauri version in a separate window, while the PWA version will be available at http://localhost:4200.
This will open the Electron app in a separate window, while the Angular dev server will run at http://localhost:4200.
To run only the Angular app without Tauri, use:
To run only the Angular app without Electron, use:
```
$ npm run serve
$ npm run serve:frontend
```
## Disclaimer
@@ -8,15 +8,16 @@ contextBridge.exposeInMainWorld('electron', {
updatePlaylistFromFilePath: (filePath: string, title: string) =>
ipcRenderer.invoke('update-playlist-from-file-path', filePath, title),
openPlaylistFromFile: () => ipcRenderer.invoke('open-playlist-from-file'),
saveFileDialog: (defaultPath: string, filters?: { name: string; extensions: string[] }[]) =>
ipcRenderer.invoke('save-file-dialog', defaultPath, filters),
saveFileDialog: (
defaultPath: string,
filters?: { name: string; extensions: string[] }[]
) => ipcRenderer.invoke('save-file-dialog', defaultPath, filters),
writeFile: (filePath: string, content: string) =>
ipcRenderer.invoke('write-file', filePath, content),
setUserAgent: (userAgent: string, referer?: string) =>
ipcRenderer.invoke('set-user-agent', userAgent, referer),
openInMpv: (
url: string,
path: string,
title: string,
userAgent: string,
referer?: string,
@@ -25,7 +26,6 @@ contextBridge.exposeInMainWorld('electron', {
ipcRenderer.invoke(
'OPEN_MPV_PLAYER',
url,
path,
title,
userAgent,
referer,
@@ -33,7 +33,6 @@ contextBridge.exposeInMainWorld('electron', {
),
openInVlc: (
url: string,
path: string,
title: string,
userAgent: string,
referer?: string,
@@ -42,7 +41,6 @@ contextBridge.exposeInMainWorld('electron', {
ipcRenderer.invoke(
'OPEN_VLC_PLAYER',
url,
path,
title,
userAgent,
referer,
+2
View File
@@ -78,6 +78,8 @@ export default class App {
preload: join(__dirname, 'main.preload.js'),
},
...savedWindowBounds,
minHeight: 600,
minWidth: 900,
});
App.mainWindow.setMenu(null);
if (!savedWindowBounds) {
@@ -53,87 +53,142 @@ function sendMpvCommand(command: string, args: string[]): Promise<void> {
});
}
ipcMain.handle('OPEN_MPV_PLAYER', async (event, url) => {
try {
const mpvPath = getMpvPath();
const reuseInstance = store.get(MPV_REUSE_INSTANCE, false);
ipcMain.handle(
'OPEN_MPV_PLAYER',
async (
event,
url: string,
title: string,
userAgent?: string,
referer?: string,
origin?: string
) => {
try {
const mpvPath = getMpvPath();
const reuseInstance = store.get(MPV_REUSE_INSTANCE, false);
console.log('Opening MPV player with path:', mpvPath);
console.log('Reuse instance:', reuseInstance);
console.log('URL:', url);
console.log('Opening MPV player with path:', mpvPath);
console.log('Reuse instance:', reuseInstance);
console.log('URL:', url);
console.log('User-Agent:', userAgent);
console.log('Referer:', referer);
console.log('Origin:', origin);
// If reuse is enabled and there's an existing process, try to use it
if (
reuseInstance &&
mpvProcess &&
!mpvProcess.killed &&
mpvSocketPath
) {
console.log('Reusing existing MPV instance');
try {
await sendMpvCommand('loadfile', [url, 'replace']);
console.log(
'Successfully loaded new URL in existing MPV instance'
);
return;
} catch (err) {
console.error('Failed to send command to existing MPV:', err);
// If it fails, clear the reference and create a new one
mpvProcess = null;
mpvSocketPath = null;
// If reuse is enabled and there's an existing process, try to use it
if (
reuseInstance &&
mpvProcess &&
!mpvProcess.killed &&
mpvSocketPath
) {
console.log('Reusing existing MPV instance');
try {
await sendMpvCommand('loadfile', [url, 'replace']);
console.log(
'Successfully loaded new URL in existing MPV instance'
);
return;
} catch (err) {
console.error(
'Failed to send command to existing MPV:',
err
);
// If it fails, clear the reference and create a new one
mpvProcess = null;
mpvSocketPath = null;
// Fall through to create new instance
}
}
}
// Create new MPV process
console.log('Creating new MPV instance');
// Create new MPV process
console.log('Creating new MPV instance');
// Generate unique socket path
const socketPath =
process.platform === 'win32'
? `\\\\.\\pipe\\mpv-${Date.now()}`
: `/tmp/mpvsocket-${Date.now()}`;
// Generate unique socket path
const socketPath =
process.platform === 'win32'
? `\\\\.\\pipe\\mpv-${Date.now()}`
: `/tmp/mpvsocket-${Date.now()}`;
const args = [`--input-ipc-server=${socketPath}`, '--idle=yes', url];
const args = [`--input-ipc-server=${socketPath}`, '--idle=yes'];
const proc = spawn(mpvPath, args, {
shell: false,
detached: !reuseInstance,
stdio: 'ignore',
});
// Add user agent if provided
if (userAgent) {
args.push(`--user-agent=${userAgent}`);
}
proc.on('error', (err) => {
console.error('Failed to start MPV player:', err);
// Add referer if provided
if (referer) {
args.push(`--referrer=${referer}`);
}
// Add origin as custom HTTP header if provided
// MPV doesn't have a direct --origin flag, so we use --http-header-fields
if (origin) {
args.push(`--http-header-fields=Origin: ${origin}`);
}
// Add title if provided
if (title) {
args.push(`--force-media-title=${title}`);
}
// Add URL last
args.push(url);
// Wrap spawn in a promise to catch startup errors
await new Promise<void>((resolve, reject) => {
const proc = spawn(mpvPath, args, {
shell: false,
detached: !reuseInstance,
stdio: 'ignore',
});
proc.on('error', (err) => {
console.error('Failed to start MPV player:', err);
mpvProcess = null;
mpvSocketPath = null;
reject(
new Error(
`Failed to start MPV player: ${err.message}. Make sure MPV is installed and the path '${mpvPath}' is correct.`
)
);
});
proc.on('exit', (code) => {
console.log(`MPV exited with code ${code}`);
mpvProcess = null;
mpvSocketPath = null;
});
// Store the process reference if reuse is enabled
if (reuseInstance) {
mpvProcess = proc;
mpvSocketPath = socketPath;
console.log(
'Stored MPV process for reuse with socket:',
socketPath
);
} else {
// Detach the process so it can continue running independently
proc.unref();
}
// Resolve immediately if spawn succeeds (error event would fire if it fails)
// Give a small window to catch immediate spawn errors
setTimeout(() => {
if (!proc.killed) {
resolve();
}
}, 100);
});
} catch (error) {
console.error('Error opening MPV player:', error);
mpvProcess = null;
mpvSocketPath = null;
/* event.sender.send(ERROR, {
message: `Error: Failed to start MPV player. Make sure that mpv player is installed on your system and the path is correct.`,
}); */
});
proc.on('exit', (code) => {
console.log(`MPV exited with code ${code}`);
mpvProcess = null;
mpvSocketPath = null;
});
// Store the process reference if reuse is enabled
if (reuseInstance) {
mpvProcess = proc;
mpvSocketPath = socketPath;
console.log(
'Stored MPV process for reuse with socket:',
socketPath
);
} else {
// Detach the process so it can continue running independently
proc.unref();
throw error;
}
} catch (error) {
console.log(error);
mpvProcess = null;
mpvSocketPath = null;
}
});
);
ipcMain.handle('SET_MPV_PLAYER_PATH', (_event, mpvPlayerPath) => {
console.log('... setting mpv player path', mpvPlayerPath);
@@ -153,28 +208,85 @@ ipcMain.handle('SET_MPV_REUSE_INSTANCE', (_event, reuseInstance: boolean) => {
}
});
ipcMain.handle('OPEN_VLC_PLAYER', (event, url) => {
const vlcPath = getVlcPath();
console.log('Opening VLC player with path:', vlcPath);
console.log('URL:', url);
ipcMain.handle(
'OPEN_VLC_PLAYER',
async (
event,
url: string,
title: string,
userAgent?: string,
referer?: string,
origin?: string
) => {
try {
const vlcPath = getVlcPath();
console.log('Opening VLC player with path:', vlcPath);
console.log('URL:', url);
console.log('User-Agent:', userAgent);
console.log('Referer:', referer);
console.log('Origin:', origin);
const proc = spawn(vlcPath, [url as string], {
shell: false,
detached: true,
stdio: 'ignore',
});
const args: string[] = [];
proc.on('error', (err) => {
console.error('Failed to start VLC player:', err);
});
// Add user agent if provided (VLC uses :http-user-agent= format)
if (userAgent) {
args.push(`:http-user-agent=${userAgent}`);
}
proc.on('exit', (code) => {
console.log(`VLC exited with code ${code}`);
});
// Add referer if provided (VLC uses :http-referrer= format)
if (referer) {
args.push(`:http-referrer=${referer}`);
}
// Detach the process so it can continue running independently
proc.unref();
});
// Note: VLC doesn't have a direct origin option, but origin is typically
// included in referer for most IPTV use cases
// Add title if provided
if (title) {
args.push(`--meta-title=${title}`);
}
// Add URL last
args.push(url);
// Wrap spawn in a promise to catch startup errors
await new Promise<void>((resolve, reject) => {
const proc = spawn(vlcPath, args, {
shell: false,
detached: true,
stdio: 'ignore',
});
proc.on('error', (err) => {
console.error('Failed to start VLC player:', err);
reject(
new Error(
`Failed to start VLC player: ${err.message}. Make sure VLC is installed and the path '${vlcPath}' is correct.`
)
);
});
proc.on('exit', (code) => {
console.log(`VLC exited with code ${code}`);
});
// Detach the process so it can continue running independently
proc.unref();
// Resolve immediately if spawn succeeds (error event would fire if it fails)
// Give a small window to catch immediate spawn errors
setTimeout(() => {
if (!proc.killed) {
resolve();
}
}, 100);
});
} catch (error) {
console.error('Error opening VLC player:', error);
throw error;
}
}
);
ipcMain.handle('SET_VLC_PLAYER_PATH', (_event, vlcPlayerPath) => {
console.log('... setting vlc player path', vlcPlayerPath);
@@ -5,7 +5,6 @@
import axios, { AxiosRequestConfig } from 'axios';
import { ipcMain } from 'electron';
import { XTREAM_REQUEST } from 'shared-interfaces';
export default class XtreamEvents {
static bootstrapXtreamEvents(): Electron.IpcMain {
@@ -17,7 +16,7 @@ export default class XtreamEvents {
* Handle Xtream Codes API requests
*/
ipcMain.handle(
XTREAM_REQUEST,
'XTREAM_REQUEST',
async (
event,
payload: {
@@ -77,7 +76,11 @@ ipcMain.handle(
status: error.response?.status || 500,
};
throw errorResponse;
} else if (error && typeof error === 'object' && 'message' in error) {
} else if (
error &&
typeof error === 'object' &&
'message' in error
) {
throw error;
} else {
throw {
+22
View File
@@ -45,6 +45,25 @@
],
"outputHashing": "all"
},
"pwa": {
"baseHref": "/",
"budgets": [
{
"type": "initial",
"maximumWarning": "500kb",
"maximumError": "5mb"
},
{
"type": "anyComponentStyle",
"maximumWarning": "4kb",
"maximumError": "8kb"
}
],
"outputHashing": "all",
"optimization": true,
"extractLicenses": true,
"sourceMap": false
},
"development": {
"optimization": false,
"extractLicenses": false,
@@ -60,6 +79,9 @@
"production": {
"buildTarget": "web:build:production"
},
"pwa": {
"buildTarget": "web:build:pwa"
},
"development": {
"buildTarget": "web:build:development"
}
+6 -72
View File
@@ -1,6 +1,5 @@
import { Component, inject, OnDestroy, OnInit } from '@angular/core';
import { Component, inject, OnInit } from '@angular/core';
import { MatDialog } from '@angular/material/dialog';
import { MatSnackBar } from '@angular/material/snack-bar';
import { Router, RouterOutlet } from '@angular/router';
import { Store } from '@ngrx/store';
import { TranslateService } from '@ngx-translate/core';
@@ -8,15 +7,11 @@ import { TranslateService } from '@ngx-translate/core';
import * as PlaylistActions from 'm3u-state';
import { DataService, EpgService } from 'services';
import {
ERROR,
IpcCommand,
Language,
OPEN_FILE,
Settings,
STORE_KEY,
Theme,
VIEW_ADD_PLAYLIST,
VIEW_SETTINGS,
} from 'shared-interfaces';
import { SettingsService } from './services/settings.service';
import { RecentlyViewedComponent } from './xtream-tauri/recently-viewed/recently-viewed.component';
@@ -27,30 +22,24 @@ import { SearchResultsComponent } from './xtream-tauri/search-results/search-res
templateUrl: './app.component.html',
imports: [RouterOutlet],
})
export class AppComponent implements OnInit, OnDestroy {
export class AppComponent implements OnInit {
private dataService = inject(DataService);
private dialog = inject(MatDialog);
private epgService = inject(EpgService);
private router = inject(Router);
private store = inject(Store);
private snackBar = inject(MatSnackBar);
private translate = inject(TranslateService);
private settingsService = inject(SettingsService);
/** List of ipc commands with function mapping */
private readonly commandsList = [
/* private readonly commandsList = [
new IpcCommand(VIEW_ADD_PLAYLIST, () => this.navigateToRoute('/')),
new IpcCommand(VIEW_SETTINGS, () => this.navigateToRoute('/settings')),
new IpcCommand(ERROR, (response: { message: string; status: number }) =>
this.showErrorAsNotification(response)
),
];
]; */
/** Default language as fallback */
private readonly DEFAULT_LANG = Language.ENGLISH;
private listeners = [];
constructor() {
if (
((this.dataService.isElectron &&
@@ -90,24 +79,7 @@ export class AppComponent implements OnInit, OnDestroy {
this.store.dispatch(PlaylistActions.loadPlaylists());
this.translate.setDefaultLang(this.DEFAULT_LANG);
this.setRendererListeners();
this.initSettings();
/* this.handleWhatsNewDialog(); */
}
/**
* Initializes all necessary listeners for the events from the renderer process
*/
setRendererListeners(): void {
this.commandsList.forEach((command) => {
const cb = (response) => {
if (response.data.type === command.id) {
command.callback(response.data);
}
};
this.dataService.listenOn(command.id, cb);
this.listeners.push(cb);
});
}
/**
@@ -134,6 +106,8 @@ export class AppComponent implements OnInit, OnDestroy {
this.epgService.fetchEpg(settings.epgUrl);
}
// TODO: trigger auto-refresh mechanism for playlists
if (settings.theme) {
this.settingsService.changeTheme(settings.theme);
} else {
@@ -160,21 +134,6 @@ export class AppComponent implements OnInit, OnDestroy {
}
}
/**
* Checks the actual version of the application and shows the "what is new" dialog if the updated version was detected
*/
/* handleWhatsNewDialog(): void {
const actualVersion = this.dataService.getAppVersion();
this.settingsService
.getValueFromLocalStorage(STORE_KEY.Version)
.subscribe(() => {
this.settingsService.setValueToLocalStorage(
STORE_KEY.Version,
actualVersion
);
});
} */
/**
* Navigate to the specified route
* @param route route to navigate to
@@ -183,16 +142,6 @@ export class AppComponent implements OnInit, OnDestroy {
this.router.navigateByUrl(route);
}
showErrorAsNotification(response: { message: string; status: number }) {
this.snackBar.open(
`Error: ${response?.message ?? 'Something went wrong'} (Status: ${
response?.status ?? 0
})`,
null,
{ duration: 4000 }
);
}
openGlobalSearch(): void {
this.dialog.open(SearchResultsComponent, {
width: '100%',
@@ -214,19 +163,4 @@ export class AppComponent implements OnInit, OnDestroy {
disableClose: false,
});
}
/**
* Removes all ipc command listeners on component destroy
*/
ngOnDestroy(): void {
if (this.dataService.isElectron) {
this.commandsList.forEach((command) =>
this.dataService.removeAllListeners(command.id)
);
} else {
this.listeners.forEach((listener) =>
window.removeEventListener('message', listener)
);
}
}
}
+3 -8
View File
@@ -1,10 +1,5 @@
<div class="grid-container">
<app-header
<app-header
[title]="'HOME.TITLE' | translate"
[subtitle]="'HOME.SUBTITLE' | translate"
/>
<app-recent-playlists class="recent-playlists" />
@if (isLoading) {
<mat-progress-bar mode="indeterminate" />
}
</div>
/>
<app-recent-playlists class="recent-playlists" />
-20
View File
@@ -1,24 +1,4 @@
::ng-deep {
.mat-tab-label-content {
text-transform: uppercase;
}
.mat-tab-group.mat-background-primary .mat-tab-header-pagination {
display: none;
}
}
.playlists-container {
grid-area: playlists;
}
.recent-playlists {
height: calc(100vh - 100px);
overflow: auto;
}
@media only screen and (max-width: 480px) {
.tab-title {
display: none;
}
}
+3 -94
View File
@@ -1,103 +1,12 @@
import { Component, inject, OnDestroy } from '@angular/core';
import { MatProgressBarModule } from '@angular/material/progress-bar';
import { MatSnackBar } from '@angular/material/snack-bar';
import { Store } from '@ngrx/store';
import { Component } from '@angular/core';
import { TranslatePipe } from '@ngx-translate/core';
import { RecentPlaylistsComponent } from 'components';
import { addPlaylist } from 'm3u-state';
import { DataService } from 'services';
import { ERROR, Playlist, PLAYLIST_PARSE_RESPONSE } from 'shared-interfaces';
import { HeaderComponent } from '../shared/components/header/header.component';
@Component({
selector: 'app-home',
templateUrl: './home.component.html',
styleUrls: ['./home.component.scss'],
imports: [
HeaderComponent,
MatProgressBarModule,
RecentPlaylistsComponent,
TranslatePipe,
],
imports: [HeaderComponent, RecentPlaylistsComponent, TranslatePipe],
})
export class HomeComponent implements OnDestroy {
private dataService = inject(DataService);
private snackBar = inject(MatSnackBar);
private readonly store = inject(Store);
/** Loading spinner state */
isLoading = false;
/** IPC Renderer commands list with callbacks */
commandsList = [
{
id: PLAYLIST_PARSE_RESPONSE,
execute: (response: { payload: Playlist }): void => {
this.store.dispatch(
addPlaylist({
playlist: response.payload,
})
);
},
},
{
id: ERROR,
execute: (error: { message: string; status: string }) => {
this.showNotification('Error: ' + error.message);
},
},
];
listeners = [];
constructor() {
this.setRendererListeners();
}
/**
* Set electrons main process listeners
*/
setRendererListeners(): void {
this.commandsList.forEach((command) => {
/* if (this.dataService.isElectron) {
this.dataService.listenOn(command.id, (event, response) =>
this.ngZone.run(() => command.execute(response))
);
} else { */
const cb = (response) => {
if (response.data.type === command.id) {
command.execute(response.data);
}
};
this.dataService.listenOn(command.id, cb);
this.listeners.push(cb);
/* } */
});
}
/**
* Shows snack bar notification with a given message
* @param message message to show
* @param duration visibility duration of the snackbar
*/
showNotification(message: string, duration = 2000): void {
this.snackBar.open(message, null, {
duration,
});
}
/**
* Remove ipcRenderer listeners after component destroy
*/
ngOnDestroy(): void {
if (this.dataService.isElectron) {
this.commandsList.forEach((command) =>
this.dataService.removeAllListeners(command.id)
);
} else {
this.listeners.forEach((listener) => {
window.removeEventListener('message', listener);
});
}
}
}
export class HomeComponent {}
@@ -96,7 +96,7 @@
<!-- channel overlay -->
<app-info-overlay
[channel]="activeChannel"
[epgProgram]="epgProgram$ | async"
[epgProgram]="(epgProgram$ | async)!"
/>
}
}
@@ -1,17 +1,9 @@
import { Overlay, OverlayRef } from '@angular/cdk/overlay';
import { ComponentPortal } from '@angular/cdk/portal';
import { AsyncPipe, CommonModule } from '@angular/common';
import {
Component,
Injector,
NgZone,
OnInit,
effect,
inject,
} from '@angular/core';
import { Component, Injector, OnInit, effect, inject } from '@angular/core';
import { MatDialog } from '@angular/material/dialog';
import { MatSidenavModule } from '@angular/material/sidenav';
import { MatSnackBar } from '@angular/material/snack-bar';
import { ActivatedRoute, Router, RouterLink } from '@angular/router';
import { Store } from '@ngrx/store';
import { StorageMap } from '@ngx-pwa/local-storage';
@@ -37,10 +29,7 @@ import { DataService, PlaylistsService } from 'services';
import {
COMPONENT_OVERLAY_REF,
Channel,
ERROR,
PLAYLIST_PARSE_BY_URL,
PLAYLIST_PARSE_RESPONSE,
Playlist,
STORE_KEY,
Settings,
SidebarView,
@@ -51,37 +40,35 @@ import { SettingsComponent } from '../../settings/settings.component';
@Component({
imports: [
ArtPlayerComponent,
AsyncPipe,
AudioPlayerComponent,
InfoOverlayComponent,
CommonModule,
EpgListComponent,
HtmlVideoPlayerComponent,
InfoOverlayComponent,
MatSidenavModule,
RouterLink,
SidebarComponent,
ToolbarComponent,
VjsPlayerComponent,
ArtPlayerComponent,
],
templateUrl: './video-player.component.html',
styleUrl: './video-player.component.scss',
})
export class VideoPlayerComponent implements OnInit {
private activatedRoute = inject(ActivatedRoute);
private dataService = inject(DataService);
private readonly activatedRoute = inject(ActivatedRoute);
private readonly dataService = inject(DataService);
private readonly dialog = inject(MatDialog);
private ngZone = inject(NgZone);
private overlay = inject(Overlay);
private playlistsService = inject(PlaylistsService);
private router = inject(Router);
private settingsStore = inject(SettingsStore);
private snackBar = inject(MatSnackBar);
private storage = inject(StorageMap);
private store = inject(Store);
private readonly overlay = inject(Overlay);
private readonly playlistsService = inject(PlaylistsService);
private readonly router = inject(Router);
private readonly settingsStore = inject(SettingsStore);
private readonly storage = inject(StorageMap);
private readonly store = inject(Store);
/** Active selected channel */
activeChannel$ = this.store
readonly activeChannel$ = this.store
.select(selectActive)
.pipe(filter((channel) => Boolean((channel as Channel)?.url)));
@@ -89,7 +76,7 @@ export class VideoPlayerComponent implements OnInit {
channels$!: Observable<Channel[]>;
/** Current epg program */
epgProgram$ = this.store.select(selectCurrentEpgProgram);
readonly epgProgram$ = this.store.select(selectCurrentEpgProgram);
/** Selected video player options */
playerSettings: Partial<Settings> = {
@@ -97,37 +84,6 @@ export class VideoPlayerComponent implements OnInit {
showCaptions: false,
};
/** IPC Renderer commands list with callbacks */
commandsList = [
{
id: ERROR,
execute: (response: { message: string }): void => {
this.snackBar.open(response.message, '', {
duration: 3100,
});
},
},
{
id: PLAYLIST_PARSE_RESPONSE,
execute: (response: { payload: Playlist }): void => {
if (response.payload.isTemporary) {
this.store.dispatch(
PlaylistActions.setChannels({
channels: response.payload.playlist.items,
})
);
} else {
this.store.dispatch(
PlaylistActions.addPlaylist({
playlist: response.payload,
})
);
}
this.sidebarView = 'CHANNELS';
},
},
];
readonly isDesktop = !!window['electron'];
sidebarView: SidebarView = 'CHANNELS';
@@ -158,7 +114,6 @@ export class VideoPlayerComponent implements OnInit {
*/
ngOnInit(): void {
this.applySettings();
this.setRendererListeners();
this.getPlaylistUrlAsParam();
this.channels$ = this.activatedRoute.params.pipe(
@@ -214,23 +169,6 @@ export class VideoPlayerComponent implements OnInit {
}
}
setRendererListeners(): void {
this.commandsList.forEach((command) => {
if (this.isDesktop) {
this.dataService.listenOn(command.id, (event, response) =>
this.ngZone.run(() => command.execute(response))
);
} else {
const cb = (response: any) => {
if (response.data.type === command.id) {
command.execute(response.data);
}
};
this.dataService.listenOn(command.id, cb);
}
});
}
/**
* Reads the app configuration from the browsers storage and applies the settings in the current component
*/
+62 -52
View File
@@ -1,16 +1,16 @@
import { inject, Injectable } from '@angular/core';
import { MatSnackBar } from '@angular/material/snack-bar';
import { Params } from '@angular/router';
import { Store } from '@ngrx/store';
import { TranslateService } from '@ngx-translate/core';
import * as PlaylistActions from 'm3u-state';
import { DataService } from 'services';
import {
AUTO_UPDATE_PLAYLISTS,
AUTO_UPDATE_PLAYLISTS_RESPONSE,
ERROR,
Playlist,
PLAYLIST_PARSE_BY_URL,
PLAYLIST_PARSE_RESPONSE,
PLAYLIST_UPDATE,
PLAYLIST_UPDATE_RESPONSE,
XTREAM_RESPONSE,
} from 'shared-interfaces';
import { AppConfig } from '../../environments/environment';
@@ -20,7 +20,9 @@ import { AppConfig } from '../../environments/environment';
})
export class ElectronService extends DataService {
private eventListeners: { [key: string]: () => void } = {};
private snackBar = inject(MatSnackBar);
private readonly snackBar = inject(MatSnackBar);
private readonly store = inject(Store);
private readonly translateService = inject(TranslateService);
constructor() {
super();
@@ -60,15 +62,15 @@ export class ElectronService extends DataService {
try {
return await window.electron.openInMpv(
data.url,
data.mpvPlayerPath || '',
data.title ?? '',
data['user-agent'] ?? undefined,
data.referer ?? undefined,
data.origin ?? undefined
);
/* thumbnail: data.thumbnail ?? '', */
} catch (error) {
this.snackBar.open(`Error launching MPV: ${error}`, 'Close', {
} catch (error: any) {
const errorMessage = error?.message || String(error);
this.snackBar.open(`Error launching MPV: ${errorMessage}`, 'Close', {
duration: 5000,
});
console.error('MPV launch error:', error);
@@ -79,13 +81,14 @@ export class ElectronService extends DataService {
try {
return await window.electron.openInVlc(
data.url,
data.vlcPlayerPath || '',
data.title ?? '',
data['user-agent'] ?? undefined,
data.referer ?? undefined,
data.origin ?? undefined
);
} catch (error) {
this.snackBar.open(`Error launching VLC: ${error}`, 'Close', {
} catch (error: any) {
const errorMessage = error?.message || String(error);
this.snackBar.open(`Error launching VLC: ${errorMessage}`, 'Close', {
duration: 5000,
});
console.error('VLC launch error:', error);
@@ -94,27 +97,18 @@ export class ElectronService extends DataService {
} else if (type === AUTO_UPDATE_PLAYLISTS) {
const data = payload as Playlist[];
const playlists = await window.electron.autoUpdatePlaylists(data);
/* for await (const item of data) {
if (item.filePath) {
const playlist = await readTextFile(item.filePath);
const parsedPlaylist = parse(playlist);
const playlistObject = createPlaylistObject(
item.title,
parsedPlaylist,
item.filePath,
'FILE'
);
playlists.push({ ...playlistObject, _id: item._id });
}
}
*/
window.postMessage({
type: AUTO_UPDATE_PLAYLISTS_RESPONSE,
payload: {
message: 'Success! The playlists were successfully updated',
this.store.dispatch(
PlaylistActions.updateManyPlaylists({
playlists,
},
});
})
);
this.snackBar.open(
this.translateService.instant(
'HOME.PLAYLISTS.AUTO_REFRESH_UPDATE_SUCCESS'
),
null,
{ duration: 2000 }
);
} else {
console.log('Unknown type', type);
}
@@ -129,25 +123,27 @@ export class ElectronService extends DataService {
// Use Electron IPC to make the Stalker request
const response = await window.electron.stalkerRequest(payload);
return response;
} catch (err: any) {
} catch (err) {
console.error('Stalker request error:', err);
window.postMessage({
type: ERROR,
message: err.message ?? 'Error: not found',
status: err.status ?? 404,
});
this.snackBar.open(
`Error: ${err.message ?? ' Not found'}, status: ${err.status ?? 404}`,
'Close',
{
duration: 5000,
}
);
throw err;
}
}
private async fetchM3uPlaylistFromUrl(payload: Partial<Playlist>) {
console.log(payload);
window.electron.fetchPlaylistByUrl(payload.url).then((result) => {
// TODO: call store and decide and store where to store based on the isTemporary flag etc
window.postMessage({
type: PLAYLIST_PARSE_RESPONSE,
payload: { ...result, isTemporary: payload.isTemporary },
});
this.store.dispatch(
PlaylistActions.handleAddingPlaylistByUrl({
isTemporary: !!payload?.isTemporary,
playlist: result,
})
);
});
}
@@ -178,16 +174,23 @@ export class ElectronService extends DataService {
}
methodToCall.then((playlistObject) => {
window.postMessage({
type: PLAYLIST_UPDATE_RESPONSE,
payload: {
message: 'Success! The playlist was successfully updated',
this.store.dispatch(
PlaylistActions.updatePlaylist({
playlist: {
...playlistObject,
_id: data.id,
},
},
});
playlistId: data.id,
})
);
this.snackBar.open(
this.translateService.instant(
'HOME.PLAYLISTS.PLAYLIST_UPDATE_SUCCESS'
),
null,
{ duration: 2000 }
);
});
}
@@ -223,14 +226,21 @@ export class ElectronService extends DataService {
window.postMessage(result);
return result;
} catch (error: any) {
console.error('Xtream request error:', error);
const result = {
console.error('Xtream request error:', error.message);
this.snackBar.open(
`Error: ${error.message ?? 'Failed to connect to Xtream server'}, status: ${
error.status ?? 500
}`,
'Close',
{
duration: 5000,
}
);
return {
type: ERROR,
status: error.status ?? 500,
message: error.message ?? 'Failed to connect to Xtream server',
};
window.postMessage(result);
return result;
}
}
+10 -3
View File
@@ -26,7 +26,10 @@ export class PlayerService {
title: string,
thumbnail?: string,
hideExternalInfoDialog = true,
isLiveContent = false
isLiveContent = false,
userAgent?: string,
referer?: string,
origin?: string
) {
const player = this.settingsStore.player() ?? VideoPlayer.VideoJs;
@@ -36,9 +39,11 @@ export class PlayerService {
}
this.dataService.sendIpcEvent(OPEN_MPV_PLAYER, {
url: streamUrl,
mpvPlayerPath: this.settingsStore.mpvPlayerPath(),
title,
thumbnail,
'user-agent': userAgent,
referer: referer,
origin: origin,
});
} else if (player === VideoPlayer.VLC) {
if (!hideExternalInfoDialog) {
@@ -46,9 +51,11 @@ export class PlayerService {
}
this.dataService.sendIpcEvent(OPEN_VLC_PLAYER, {
url: streamUrl,
vlcPlayerPath: this.settingsStore.vlcPlayerPath(),
title,
thumbnail,
'user-agent': userAgent,
referer: referer,
origin: origin,
});
} else if (!isLiveContent) {
this.dialog.open<PlayerDialogComponent, PlayerDialogData>(
+40 -24
View File
@@ -12,7 +12,6 @@ import {
ERROR,
Playlist,
PLAYLIST_PARSE_BY_URL,
PLAYLIST_PARSE_RESPONSE,
PLAYLIST_UPDATE,
STALKER_REQUEST,
XTREAM_REQUEST,
@@ -88,9 +87,15 @@ export class PwaService extends DataService {
this.getPlaylistFromUrl(payload.url)
.pipe(
catchError((error) => {
window.postMessage({
type: ERROR,
});
this.snackBar.open(
`Error: ${error.message ?? 'Unknown error'}, status: ${
error.status ?? 500
}`,
'Close',
{
duration: 5000,
}
);
return throwError(() => error);
})
)
@@ -120,19 +125,23 @@ export class PwaService extends DataService {
this.getPlaylistFromUrl(payload.url)
.pipe(
catchError((error) => {
window.postMessage({
type: ERROR,
message: this.getErrorMessageByStatusCode(error.status),
status: error.status,
});
this.snackBar.open(
this.getErrorMessageByStatusCode(error.status),
'Close',
{
duration: 5000,
}
);
return throwError(() => error);
})
)
.subscribe((response: any) => {
window.postMessage({
type: PLAYLIST_PARSE_RESPONSE,
payload: { ...response, isTemporary: payload.isTemporary },
});
.subscribe((response: Playlist) => {
this.store.dispatch(
PlaylistActions.handleAddingPlaylistByUrl({
isTemporary: !!payload?.isTemporary,
playlist: response,
})
);
});
}
@@ -196,11 +205,15 @@ export class PwaService extends DataService {
return result;
} catch (error: any) {
if (payload.params.action === 'get_account_info') return;
window.postMessage({
type: ERROR,
status: error.error?.status,
message: error.error?.message ?? 'Unknown error',
});
this.snackBar.open(
`Error: ${error.message ?? ' Unknown error'}, status: ${
error.status ?? 500
}`,
'Close',
{
duration: 5000,
}
);
}
}
@@ -232,11 +245,14 @@ export class PwaService extends DataService {
return (await response.json()).payload;
} catch (err) {
console.error('Stalker request error:', err);
window.postMessage({
type: ERROR,
message: err.message ?? 'Error: not found',
status: err.status ?? 404,
});
this.snackBar.open(
`Error: ${err.message ?? ' Not found'}, status: ${err.status ?? 404}`,
'Close',
{
duration: 5000,
}
);
throw err;
}
}
@@ -3,8 +3,8 @@
class="navigation-sidebar"
[navigationItems]="mainNavigationItems"
(categoryClick)="setContentType($any($event))"
[selectedContentType]="stalkerStore.selectedContentType()"
(pageClicked)="handlePageClick()"
[selectedContentType]="stalkerStore.selectedContentType()"
/>
<router-outlet />
</div>
@@ -13,6 +13,6 @@
}
.navigation-sidebar {
width: 200px;
width: 60px;
border-right: 1px solid #333;
}
+11 -1
View File
@@ -479,7 +479,17 @@ export const StalkerStore = signalStore(
cover: thumbnail,
title,
});
playerService.openPlayer(url, title, thumbnail);
const playlist = this.currentPlaylist();
playerService.openPlayer(
url,
title,
thumbnail,
true,
false,
playlist?.userAgent,
playlist?.referrer,
playlist?.origin
);
},
addToRecentlyViewed(item: any) {
console.log('Adding to recently viewed', item);
@@ -14,7 +14,6 @@
&.selected {
background-color: rgba(63, 81, 181, 0.1);
border-left: 4px solid #3f51b5;
margin-left: 4px;
}
}
@@ -1,11 +1,16 @@
<div class="portal-status-container" [class]="getStatusColor()">
<div class="portal-status-wrapper">
<div class="portal-header">
<div class="portal-icon" [class]="getStatusColor()">
<div
class="portal-icon"
[class]="getStatusColor()"
[matTooltip]="xtreamStore.portalStatus()"
matTooltipPosition="right"
>
<mat-icon>{{ getStatusIcon() }}</mat-icon>
</div>
</div>
<div class="portal-info">
<!-- <div class="portal-info">
<span class="portal-name">{{
currentPlaylist()?.title || 'Unknown Portal'
}}</span>
@@ -14,102 +19,111 @@
xtreamStore.portalStatus()
}}</span>
}
</div>
<div class="portal-actions">
<button
mat-icon-button
class="action-button"
routerLink="/"
routerLinkActive="active"
[routerLinkActiveOptions]="{ exact: true }"
[matTooltip]="'PORTALS.SIDEBAR.HOME' | translate"
>
<mat-icon>home</mat-icon>
</button>
@if (!isStalkerPlaylist()) {
<button
mat-icon-button
class="action-button"
(click)="openAccountInfo()"
[matTooltip]="'PORTALS.SIDEBAR.ACCOUNT_INFO' | translate"
>
<mat-icon>person</mat-icon>
</button>
}
<button
mat-icon-button
class="action-button"
(click)="openPlaylistInfo()"
[matTooltip]="'PORTALS.SIDEBAR.PLAYLIST_INFO' | translate"
>
<mat-icon>playlist_play</mat-icon>
</button>
</div>
</div> -->
</div>
</div>
<div class="nav-section">
<div class="nav-group-label">{{ 'PORTALS.SIDEBAR.MAIN' | translate }}</div>
<!-- <div class="nav-group-label">{{ 'PORTALS.SIDEBAR.MAIN' | translate }}</div> -->
<mat-nav-list>
<a
mat-list-item
routerLink="/"
routerLinkActive="active"
[routerLinkActiveOptions]="{ exact: true }"
[matTooltip]="'PORTALS.SIDEBAR.HOME' | translate"
matTooltipPosition="right"
>
<mat-icon matListItemIcon>home</mat-icon>
</a>
</mat-nav-list>
</div>
<mat-divider />
<div class="nav-section">
<!-- <div class="nav-group-label">{{ 'PORTALS.SIDEBAR.MAIN' | translate }}</div> -->
<mat-nav-list>
@for (item of navigationItems(); track item.id) {
<a
mat-list-item
[class.active]="isContentTypeActive(item.id)"
(click)="categoryClick.emit(item.id)"
[matTooltip]="item.labelKey | translate"
matTooltipPosition="right"
>
<mat-icon matListItemIcon>{{ item.icon }}</mat-icon>
<span matListItemTitle>{{ item.labelKey | translate }}</span>
</a>
}
</mat-nav-list>
</div>
<mat-divider />
<div class="nav-section">
<div class="nav-group-label">
<!-- <div class="nav-group-label">
{{ 'PORTALS.SIDEBAR.LIBRARY' | translate }}
</div>
</div> -->
<mat-nav-list>
<a
mat-list-item
routerLink="./search"
routerLinkActive="active"
[matTooltip]="'PORTALS.SIDEBAR.SEARCH' | translate"
matTooltipPosition="right"
(click)="pageSwitch('search')"
>
<mat-icon matListItemIcon>search</mat-icon>
<span matListItemTitle>{{
'PORTALS.SIDEBAR.SEARCH' | translate
}}</span>
</a>
<a
mat-list-item
routerLink="./recent"
routerLinkActive="active"
[matTooltip]="'PORTALS.SIDEBAR.RECENT' | translate"
matTooltipPosition="right"
(click)="pageSwitch('recent')"
>
<mat-icon matListItemIcon>history</mat-icon>
<span matListItemTitle>{{
'PORTALS.SIDEBAR.RECENT' | translate
}}</span>
</a>
<a
mat-list-item
routerLink="./favorites"
routerLinkActive="active"
[matTooltip]="'PORTALS.SIDEBAR.FAVORITES' | translate"
matTooltipPosition="right"
(click)="pageSwitch('favorites')"
>
<mat-icon matListItemIcon>favorite</mat-icon>
<span matListItemTitle>{{
'PORTALS.SIDEBAR.FAVORITES' | translate
}}</span>
</a>
</mat-nav-list>
</div>
<div class="navigation-bottom">
<mat-divider />
<mat-nav-list>
<a mat-list-item (click)="openSettings()">
@if (!isStalkerPlaylist()) {
<a
mat-list-item
class="action-button"
(click)="openAccountInfo()"
[matTooltip]="'PORTALS.SIDEBAR.ACCOUNT_INFO' | translate"
matTooltipPosition="right"
>
<mat-icon>person</mat-icon>
</a>
}
<a
mat-list-item
(click)="openPlaylistInfo()"
[matTooltip]="'PORTALS.SIDEBAR.PLAYLIST_INFO' | translate"
matTooltipPosition="right"
>
<mat-icon matListItemIcon>playlist_play</mat-icon>
</a>
<mat-divider />
<a
mat-list-item
(click)="openSettings()"
[matTooltip]="'MENU.SETTINGS' | translate"
matTooltipPosition="right"
>
<mat-icon matListItemIcon>settings</mat-icon>
<span matListItemTitle>{{ 'MENU.SETTINGS' | translate }}</span>
</a>
</mat-nav-list>
</div>
@@ -10,7 +10,6 @@
}
.navigation-bottom {
border-top: 1px solid #333;
margin-top: auto;
padding-bottom: 16px;
}
@@ -19,7 +18,6 @@
transition: all 0.2s ease;
background: rgba(63, 81, 181, 0.12);
color: #3f51b5;
border-left: 4px solid #3f51b5;
mat-icon {
color: #3f51b5;
@@ -39,10 +37,14 @@ mat-icon {
padding: 16px;
position: relative;
overflow: hidden;
background: linear-gradient(to bottom, rgba(18, 18, 18, 0.8) 0%, transparent 100%);
background: linear-gradient(
to bottom,
rgba(18, 18, 18, 0.8) 0%,
transparent 100%
);
backdrop-filter: blur(20px);
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
min-height: 120px;
min-height: 50px;
&::before {
content: '';
@@ -57,13 +59,26 @@ mat-icon {
}
&.status-active::before {
background: linear-gradient(135deg, rgba(76, 175, 80, 0.3) 0%, transparent 100%);
background: linear-gradient(
135deg,
rgba(76, 175, 80, 0.3) 0%,
transparent 100%
);
}
&.status-inactive::before, &.status-unavailable::before {
background: linear-gradient(135deg, rgba(244, 67, 54, 0.3) 0%, transparent 100%);
&.status-inactive::before,
&.status-unavailable::before {
background: linear-gradient(
135deg,
rgba(244, 67, 54, 0.3) 0%,
transparent 100%
);
}
&.status-expired::before {
background: linear-gradient(135deg, rgba(255, 152, 0, 0.3) 0%, transparent 100%);
background: linear-gradient(
135deg,
rgba(255, 152, 0, 0.3) 0%,
transparent 100%
);
}
.portal-status-wrapper {
@@ -99,14 +114,21 @@ mat-icon {
margin: 0;
}
&.status-active { color: #4caf50; }
&.status-inactive, &.status-unavailable { color: #f44336; }
&.status-expired { color: #ff9800; }
&.status-active {
color: #4caf50;
}
&.status-inactive,
&.status-unavailable {
color: #f44336;
}
&.status-expired {
color: #ff9800;
}
}
.portal-info {
text-align: center;
.portal-name {
font-size: 16px;
font-weight: 600;
@@ -129,40 +151,6 @@ mat-icon {
}
}
.portal-actions {
display: flex;
justify-content: center;
gap: 8px;
margin-top: 4px;
.action-button {
width: 36px;
height: 36px;
padding: 0;
display: flex;
align-items: center;
justify-content: center;
background: rgba(255, 255, 255, 0.1);
border-radius: 8px;
mat-icon {
font-size: 20px;
width: 20px;
height: 20px;
margin: 0;
transition: all 0.2s ease;
}
&:hover {
background: rgba(255, 255, 255, 0.15);
mat-icon {
transform: scale(1.1);
}
}
}
}
.info-button {
opacity: 0.7;
margin-left: 8px;
@@ -172,18 +160,14 @@ mat-icon {
}
}
.nav-section {
margin: 10px 0;
padding: 5px 0;
.nav-group-label {
padding: 0 16px 0;
font-size: 12px;
text-transform: uppercase;
letter-spacing: 0.5px;
font-weight: 500;
opacity: .7;
opacity: 0.7;
}
.mat-nav-list {
@@ -1,5 +1,4 @@
import { Component, computed, inject, input, output } from '@angular/core';
import { MatIconButton } from '@angular/material/button';
import { MatDialog } from '@angular/material/dialog';
import { MatIcon } from '@angular/material/icon';
import { MatListModule } from '@angular/material/list';
@@ -19,7 +18,6 @@ import { NavigationItem } from './navigation.interface';
selector: 'app-navigation',
imports: [
MatIcon,
MatIconButton,
MatListModule,
MatTooltip,
RouterLink,
@@ -1,20 +1,21 @@
<div class="expired-account">
@if (viewType === 'ERROR') {
<img src="./assets/images/broken-tv.svg" />
} @else if (viewType === 'EMPTY_CATEGORY') {
@if (viewType() === 'ERROR') {
<img alt="Something went wrong" src="./assets/images/broken-tv.svg" />
} @else if (viewType() === 'EMPTY_CATEGORY') {
<img
alt="empty category"
class="empty-category-img"
src="./assets/images/empty-category.svg"
/>
} @else if (viewType === 'NO_SEARCH_RESULTS') {
} @else if (viewType() === 'NO_SEARCH_RESULTS') {
<mat-icon class="icon">search</mat-icon>
}
<div>
<h3>{{ title }}</h3>
{{ description }}
<h3>{{ title() }}</h3>
{{ description() }}
</div>
@if (showActionButtons) {
@if (showActionButtons()) {
<div>
<button mat-stroked-button routerLink="/">
<mat-icon>home</mat-icon>
@@ -1,4 +1,4 @@
import { Component, Input, inject } from '@angular/core';
import { Component, inject, input } from '@angular/core';
import { MatButtonModule } from '@angular/material/button';
import { MatDialog } from '@angular/material/dialog';
import { MatIconModule } from '@angular/material/icon';
@@ -25,13 +25,16 @@ export class PlaylistErrorViewComponent {
private store = inject(Store);
private translate = inject(TranslateService);
currentPlaylist = this.store.selectSignal(selectCurrentPlaylist);
private readonly currentPlaylist = this.store.selectSignal(
selectCurrentPlaylist
);
@Input() description: string;
@Input() showActionButtons = true;
@Input() title: string;
@Input() viewType: 'ERROR' | 'EMPTY_CATEGORY' | 'NO_SEARCH_RESULTS' =
'ERROR';
readonly description = input<string>(undefined);
readonly showActionButtons = input(true);
readonly title = input<string>(undefined);
readonly viewType = input<'ERROR' | 'EMPTY_CATEGORY' | 'NO_SEARCH_RESULTS'>(
'ERROR'
);
openPlaylistDetails() {
this.dialog.open(PlaylistInfoComponent, {
@@ -7,7 +7,7 @@ mat-nav-list {
}
mat-list-item {
width: 280px !important;
width: 370px !important;
}
.scroll-viewport-portals {
@@ -33,7 +33,7 @@ mat-list-item {
transition: background-color 0.2s ease;
border-radius: 4px;
margin: 2px 8px;
::ng-deep .mdc-list-item__content {
overflow: hidden;
flex: 1;
@@ -50,7 +50,7 @@ mat-list-item {
}
&.mat-mdc-list-item-active {
background-color: rgba(29, 185, 84, 0.2);
background-color: rgba(29, 185, 84, 0.2);
.channel-info .title {
color: #1db954;
}
@@ -73,7 +73,7 @@ mat-list-item {
border-radius: 20px;
}
}
div[matformfieldlineripple] {
display: none;
}
@@ -93,7 +93,7 @@ mat-list-item {
flex-direction: column;
width: 100%;
gap: 4px;
.title {
font-weight: 600; // Slightly bolder
font-size: 14px;
@@ -108,7 +108,7 @@ mat-list-item {
animation: marquee 6s linear infinite;
}
}
.current-program {
font-size: 12px;
white-space: nowrap;
@@ -116,7 +116,7 @@ mat-list-item {
text-overflow: ellipsis;
line-height: 1.2;
margin-top: 2px;
&:hover {
overflow: visible;
text-overflow: clip;
@@ -135,17 +135,17 @@ mat-list-item {
align-items: center;
gap: 8px;
font-size: 11px;
opacity: .6;
opacity: 0.6;
padding: 0 2px;
.progress-bar {
flex: 1;
height: 3px;
background: rgba(255, 255, 255, 0.1);
opacity: .6;
opacity: 0.6;
border-radius: 2px;
overflow: hidden;
.progress {
height: 100%;
background: #1db954;
@@ -166,7 +166,7 @@ mat-list-item {
.progress-container {
opacity: 0.5;
.progress-bar {
background: rgba(255, 255, 255, 0.05);
}
@@ -186,4 +186,4 @@ mat-list-item {
// Override Material styles to allow animation
::ng-deep .mdc-list-item__primary-text {
overflow: visible !important;
}
}
+4 -4
View File
@@ -1,17 +1,17 @@
.sidebar {
width: 300px;
width: 400px;
border-right: 1px solid #333;
display: flex;
flex-direction: column;
.sidebar-header {
padding: 16px;
border-bottom: 1px solid rgba(0, 0, 0, 0.12);
h2 {
margin: 0;
font-size: 1.2rem;
font-weight: 500;
}
}
}
}
@@ -13,6 +13,6 @@
}
.navigation-sidebar {
width: 200px;
width: 60px;
border-right: 1px solid #333;
}
@@ -844,13 +844,17 @@ export const XtreamStore = signalStore(
title: string,
thumbnail: string = null
) {
const playlist = store.currentPlaylist();
playerService.openPlayer(
streamUrl,
title,
thumbnail,
localStorage.getItem('hideExternalInfoDialog') ===
'true',
store.selectedContentType() === 'live'
store.selectedContentType() === 'live',
playlist?.userAgent,
playlist?.referrer,
playlist?.origin
);
},
addToFavorites(item: any) {
@@ -22,7 +22,7 @@
.actions {
button {
margin: 5px 0;
margin: 5px;
}
}
@@ -10,6 +10,9 @@ import * as PlaylistActions from 'm3u-state';
import { selectCurrentPlaylist } from 'm3u-state';
import { Playlist } from 'shared-interfaces';
/**
* @deprecated: there is another one in xtream-tauri
*/
@Component({
selector: 'app-playlist-error-view',
templateUrl: './playlist-error-view.component.html',
@@ -345,12 +345,18 @@ export class XtreamMainContainerComponent implements OnInit, OnDestroy {
openPlayer(streamUrl: string, title: string) {
this.streamUrl = streamUrl;
this.player = this.settingsStore.player() ?? VideoPlayer.VideoJs;
const playlist = this.currentPlaylist() as Playlist;
if (this.player === VideoPlayer.MPV) {
if (!this.hideExternalInfoDialog())
this.dialog.open(ExternalPlayerInfoDialogComponent);
this.dataService.sendIpcEvent(OPEN_MPV_PLAYER, {
url: streamUrl,
mpvPlayerPath: this.settingsStore.mpvPlayerPath(),
title,
'user-agent': playlist?.userAgent,
referer: playlist?.referrer,
origin: playlist?.origin,
});
} else if (this.player === VideoPlayer.VLC) {
if (!this.hideExternalInfoDialog())
@@ -358,6 +364,10 @@ export class XtreamMainContainerComponent implements OnInit, OnDestroy {
this.dataService.sendIpcEvent(OPEN_VLC_PLAYER, {
url: streamUrl,
vlcPlayerPath: this.settingsStore.vlcPlayerPath(),
title,
'user-agent': playlist?.userAgent,
referer: playlist?.referrer,
origin: playlist?.origin,
});
} else {
if (this.selectedContentType !== ContentType.ITV) {
@@ -384,14 +394,26 @@ export class XtreamMainContainerComponent implements OnInit, OnDestroy {
}
playEpisode(episode: XtreamSerieEpisode) {
const { serverUrl, username, password } =
this.currentPlaylist() as Playlist;
const playlist = this.currentPlaylist() as Playlist;
const { serverUrl, username, password, userAgent, referrer, origin } = playlist;
const player = this.settingsStore.player();
const streamUrl = `${serverUrl}/series/${username}/${password}/${episode.id}.${episode.container_extension}`;
if (player === VideoPlayer.MPV) {
this.dataService.sendIpcEvent(OPEN_MPV_PLAYER, { url: streamUrl });
this.dataService.sendIpcEvent(OPEN_MPV_PLAYER, {
url: streamUrl,
title: episode.title,
'user-agent': userAgent,
referer: referrer,
origin: origin,
});
} else if (player === VideoPlayer.VLC) {
this.dataService.sendIpcEvent(OPEN_VLC_PLAYER, { url: streamUrl });
this.dataService.sendIpcEvent(OPEN_VLC_PLAYER, {
url: streamUrl,
title: episode.title,
'user-agent': userAgent,
referer: referrer,
origin: origin,
});
} else {
this.dialog.open(PlayerDialogComponent, {
data: { streamUrl, player, title: episode.title },
+2 -1
View File
@@ -66,7 +66,8 @@
"MY_PLAYLISTS": "My playlists",
"MY_PLAYLISTS_SUBTITLE": "all available playlists",
"PLAYLIST_UPDATE_SUCCESS": "Success! The playlist was successfully updated.",
"PLAYLIST_UPDATE_ERROR": "Error updating playlist details."
"PLAYLIST_UPDATE_ERROR": "Error updating playlist details.",
"AUTO_REFRESH_UPDATE_SUCCESS": "Success! The playlists were successfully updated"
},
"FILE_UPLOAD": {
"DRAG_DROP": "Drag & drop files here",
Vendored
-2
View File
@@ -32,7 +32,6 @@ declare global {
setUserAgent: (userAgent: string, referer?: string) => void;
openInMpv: (
url: string,
path: string,
title: string,
userAgent: string,
referer?: string,
@@ -40,7 +39,6 @@ declare global {
) => void;
openInVlc: (
url: string,
path: string,
title: string,
userAgent: string,
referer?: string,
+5
View File
@@ -133,3 +133,8 @@ export const setCurrentPlaylistId = createAction(
`${STORE_KEY} Set current playlist id`,
props<{ playlistId: string | undefined }>()
);
export const handleAddingPlaylistByUrl = createAction(
`${PLAYLISTS_STORE_KEY} Handle adding playlist by URL`,
props<{ isTemporary: boolean; playlist: Playlist }>()
);
+24 -8
View File
@@ -7,6 +7,7 @@ import { StorageMap } from '@ngx-pwa/local-storage';
import { TranslateService } from '@ngx-translate/core';
import {
combineLatestWith,
EMPTY,
firstValueFrom,
map,
switchMap,
@@ -99,7 +100,10 @@ export class PlaylistEffects {
url:
activeChannel?.url +
(activeChannel?.epgParams ?? ''),
mpvPlayerPath: settings?.mpvPlayerPath,
title: activeChannel?.name ?? '',
'user-agent': activeChannel?.http?.['user-agent'],
referer: activeChannel?.http?.referrer,
origin: activeChannel?.http?.origin,
});
else if (
settings &&
@@ -110,7 +114,10 @@ export class PlaylistEffects {
url:
activeChannel?.url +
(activeChannel?.epgParams ?? ''),
vlcPlayerPath: settings?.vlcPlayerPath,
title: activeChannel?.name ?? '',
'user-agent': activeChannel?.http?.['user-agent'],
referer: activeChannel?.http?.referrer,
origin: activeChannel?.http?.origin,
});
}
);
@@ -150,9 +157,9 @@ export class PlaylistEffects {
) {
this.dataService.sendIpcEvent(OPEN_MPV_PLAYER, {
url: channel.url,
mpvPlayerPath: settings?.mpvPlayerPath,
title: channel.name ?? '',
'user-agent': channel.http['user-agent'],
referer: channel.http.referrer,
userAgent: channel.http['user-agent'],
origin: channel.http.origin,
});
} else if (
@@ -163,7 +170,10 @@ export class PlaylistEffects {
)
this.dataService.sendIpcEvent(OPEN_VLC_PLAYER, {
url: channel.url,
vlcPlayerPath: settings?.vlcPlayerPath,
title: channel.name ?? '',
'user-agent': channel.http['user-agent'],
referer: channel.http.referrer,
origin: channel.http.origin,
});
}
);
@@ -239,10 +249,16 @@ export class PlaylistEffects {
addPlaylist$ = createEffect(
() => {
return this.actions$.pipe(
ofType(PlaylistActions.addPlaylist),
switchMap((action) =>
this.playlistsService.addPlaylist(action.playlist)
ofType(
PlaylistActions.addPlaylist,
PlaylistActions.handleAddingPlaylistByUrl
),
switchMap((action) => {
if ('isTemporary' in action && action.isTemporary) {
return EMPTY;
}
return this.playlistsService.addPlaylist(action.playlist);
}),
map((playlist: Playlist) => {
if (playlist.serverUrl && !this.dataService.isElectron) {
this.router.navigate(['/xtreams/', playlist._id]);
+19
View File
@@ -254,6 +254,25 @@ export const playlistReducer = createReducer(
currentPlaylistId: playlistId,
};
}
),
on(
PlaylistActions.handleAddingPlaylistByUrl,
(state, action): PlaylistState => {
if (action.isTemporary) {
return {
...state,
channels: action.playlist.playlist.items as Channel[],
};
} else {
return {
...state,
playlists: playlistsAdapter.addOne(
action.playlist,
state.playlists
),
};
}
}
)
);
+1
View File
@@ -7,6 +7,7 @@
"inlineSources": true,
"types": []
},
"files": ["../../global.d.ts"],
"exclude": [
"src/**/*.spec.ts",
"src/test-setup.ts",
+11 -9
View File
@@ -1,5 +1,4 @@
import { Injectable, inject } from '@angular/core';
import { XTREAM_REQUEST } from 'shared-interfaces';
import { DataService } from './data.service';
export type PortalStatus = 'active' | 'inactive' | 'expired' | 'unavailable';
@@ -29,14 +28,17 @@ export class PortalStatusService {
normalizedUrl = serverUrl;
}
let response = await this.dataService.sendIpcEvent(XTREAM_REQUEST, {
url: normalizedUrl,
params: {
password,
username,
action: 'get_account_info',
},
});
let response = await this.dataService.sendIpcEvent(
'XTREAM_REQUEST',
{
url: normalizedUrl,
params: {
password,
username,
action: 'get_account_info',
},
}
);
response = response?.payload;
if (!response?.user_info?.status) {
+2 -1
View File
@@ -19,7 +19,8 @@
"executor": "@nx/jest:jest",
"outputs": ["{workspaceRoot}/coverage/{projectRoot}"],
"options": {
"jestConfig": "libs/shared/interfaces/jest.config.ts"
"jestConfig": "libs/shared/interfaces/jest.config.ts",
"tsConfig": "libs/shared/interfaces/tsconfig.spec.json"
}
}
}
@@ -11,9 +11,7 @@ export const EPG_FORCE_FETCH = 'EPG:EPG_FORCE_FETCH';
// Playlist related commands
export const PLAYLIST_PARSE_BY_URL = 'PLAYLIST:PARSE_PLAYLIST_BY_URL';
export const PLAYLIST_PARSE_RESPONSE = 'PLAYLIST:PARSE_PLAYLIST_RESPONSE';
export const PLAYLIST_UPDATE = 'PLAYLIST:UPDATE';
export const PLAYLIST_UPDATE_RESPONSE = 'PLAYLIST:UPDATE_RESPONSE';
// General
export const ERROR = 'ERROR';
@@ -23,6 +23,8 @@ export interface Playlist {
count: number;
url?: string;
userAgent?: string;
referrer?: string;
origin?: string;
filePath?: string;
autoRefresh: boolean;
updateDate?: number;
+4 -1
View File
@@ -29,6 +29,9 @@ export default [
{
files: ['**/*.html'],
// Override or add rules here
rules: {},
rules: {
'@angular-eslint/template/click-events-have-key-events': 'off',
'@angular-eslint/template/interactive-supports-focus': 'off',
},
},
];
@@ -17,7 +17,7 @@
>
}
@if (item.desc?.length > 0) {
@if (item?.desc?.length > 0) {
<span
(click)="$event.stopPropagation(); showDescription(item)"
matListItemMeta
@@ -1,11 +1,11 @@
import { AsyncPipe } from '@angular/common';
import { Component, inject, OnInit } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { MatButtonModule } from '@angular/material/button';
import { MatDividerModule } from '@angular/material/divider';
import { MatIconModule } from '@angular/material/icon';
import { MatIconButton } from '@angular/material/button';
import { MatDivider } from '@angular/material/divider';
import { MatIcon } from '@angular/material/icon';
import { MatListModule } from '@angular/material/list';
import { MatTooltipModule } from '@angular/material/tooltip';
import { MatTooltip } from '@angular/material/tooltip';
import { MomentDatePipe } from '@iptvnator/pipes';
import { Store } from '@ngrx/store';
import { TranslatePipe } from '@ngx-translate/core';
@@ -17,9 +17,8 @@ import {
setEpgAvailableFlag,
} from 'm3u-state';
import moment from 'moment';
import { BehaviorSubject, combineLatest, Observable } from 'rxjs';
import { map } from 'rxjs/operators';
import { DataService, EpgService } from 'services';
import { BehaviorSubject, combineLatest, map } from 'rxjs';
import { EpgService } from 'services';
import { EpgChannel, EpgProgram } from 'shared-interfaces';
import { EpgListItemComponent } from './epg-list-item/epg-list-item.component';
@@ -35,11 +34,11 @@ const DATE_FORMAT = 'YYYY-MM-DD';
AsyncPipe,
EpgListItemComponent,
FormsModule,
MatButtonModule,
MatDividerModule,
MatIconModule,
MatDivider,
MatIcon,
MatIconButton,
MatListModule,
MatTooltipModule,
MatTooltip,
MomentDatePipe,
TranslatePipe,
],
@@ -49,7 +48,6 @@ const DATE_FORMAT = 'YYYY-MM-DD';
})
export class EpgListComponent implements OnInit {
private readonly store = inject(Store);
private dataService = inject(DataService);
private readonly epgService = inject(EpgService);
/** Channel info in EPG format */
@@ -76,12 +74,46 @@ export class EpgListComponent implements OnInit {
timeNow!: string;
/** Timeshift availability date, based on tvg-rec value from the channel */
timeshiftUntil$!: Observable<string>;
readonly timeshiftUntil$ = this.store.select(selectActive).pipe(
map((active) => {
// Create EpgChannel with proper structure
const displayNames = active?.name
? [{ lang: '', value: active.name }]
: [];
const icons = active?.tvg?.logo ? [{ src: active.tvg.logo }] : [];
this.channel = {
id: active?.tvg?.id || '',
displayName: displayNames,
url: active?.url ? [active.url] : [],
icon: icons,
};
return (
active?.tvg?.rec || active?.timeshift || active?.catchup?.days
);
}),
map((value) => {
const days = Number(value ?? 0) || 0;
return moment().subtract(days, 'days').toISOString();
})
);
private readonly selectedDate$ = new BehaviorSubject<string>(
moment().format(DATE_FORMAT)
);
/** Filtered EPG programs based on selected date */
filteredItems$ = combineLatest([this.items$, this.selectedDate$]).pipe(
map(([items, selectedDate]) =>
items
.filter(
(item) =>
moment(item.start).format('YYYY-MM-DD') === selectedDate
)
.sort((a, b) => moment(a.start).diff(moment(b.start)))
)
);
/**
* Helper function to get channel display name
*/
@@ -108,47 +140,10 @@ export class EpgListComponent implements OnInit {
return channel.icon[0]?.src || '';
}
/** Filtered EPG programs based on selected date */
filteredItems$ = combineLatest([this.items$, this.selectedDate$]).pipe(
map(([items, selectedDate]) =>
items
.filter(
(item) =>
moment(item.start).format('YYYY-MM-DD') === selectedDate
)
.sort((a, b) => moment(a.start).diff(moment(b.start)))
)
);
/**
* Subscribe for values from the store on component init
*/
ngOnInit(): void {
this.timeshiftUntil$ = this.store.select(selectActive).pipe(
map((active) => {
// Create EpgChannel with proper structure
const displayNames = active?.name
? [{ lang: '', value: active.name }]
: [];
const icons = active?.tvg?.logo
? [{ src: active.tvg.logo }]
: [];
this.channel = {
id: active?.tvg?.id || '',
displayName: displayNames,
url: active?.url ? [active.url] : [],
icon: icons,
};
return (
active?.tvg?.rec ||
active?.timeshift ||
active?.catchup?.days
);
}),
map((value) => moment().subtract(value, 'days').toISOString())
);
this.items$.subscribe((programs) => this.handleEpgData(programs));
this.dateToday = moment().format(DATE_FORMAT);
this.selectedDate$.next(this.dateToday);
@@ -1,12 +1,13 @@
import { SimpleChange } from '@angular/core';
import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing';
import { TranslateModule } from '@ngx-translate/core';
import { DataService } from '../../../../../services/src/lib/data.service';
import { DataService } from 'services';
import { HtmlVideoPlayerComponent } from './html-video-player.component';
describe('HtmlVideoPlayerComponent', () => {
let component: HtmlVideoPlayerComponent;
let fixture: ComponentFixture<HtmlVideoPlayerComponent>;
// eslint-disable-next-line @typescript-eslint/no-unused-vars
let dataService: DataService;
const TEST_CHANNEL = {
@@ -45,7 +46,9 @@ describe('HtmlVideoPlayerComponent', () => {
it('should call play channel function after input changes', () => {
jest.spyOn(component, 'playChannel');
jest.spyOn(global.console, 'error').mockImplementation(() => {});
jest.spyOn(global.console, 'error').mockImplementation(() => {
/* empty */
});
component.ngOnChanges({
channel: new SimpleChange(null, TEST_CHANNEL, true),
});
@@ -1,27 +0,0 @@
import { MomentDatePipe } from '@iptvnator/pipes';
import { MockPipe } from 'ng-mocks';
/* tslint:disable:no-unused-variable */
import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing';
import { InfoOverlayComponent } from './info-overlay.component';
describe('InfoOverlayComponent', () => {
let component: InfoOverlayComponent;
let fixture: ComponentFixture<InfoOverlayComponent>;
beforeEach(waitForAsync(() => {
TestBed.configureTestingModule({
declarations: [InfoOverlayComponent, MockPipe(MomentDatePipe)],
}).compileComponents();
}));
beforeEach(() => {
fixture = TestBed.createComponent(InfoOverlayComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});
@@ -6,11 +6,11 @@ import {
ChangeDetectorRef,
Component,
ElementRef,
Inject,
inject,
Input,
OnDestroy,
OnInit,
ViewChild,
viewChild,
} from '@angular/core';
import { MatButtonModule } from '@angular/material/button';
import { MatDialog } from '@angular/material/dialog';
@@ -19,7 +19,7 @@ import { MatTooltip } from '@angular/material/tooltip';
import { MomentDatePipe } from '@iptvnator/pipes';
import { TranslatePipe } from '@ngx-translate/core';
import { addDays, differenceInMinutes, format, parse, subDays } from 'date-fns';
import { BehaviorSubject, Observable } from 'rxjs';
import { BehaviorSubject, Observable, Subscription } from 'rxjs';
import {
Channel,
COMPONENT_OVERLAY_REF,
@@ -56,18 +56,21 @@ interface EnrichedChannel extends EpgChannel {
export class MultiEpgContainerComponent
implements OnInit, AfterViewInit, OnDestroy
{
@ViewChild('epgContainer') epgContainer!: ElementRef;
readonly epgContainer = viewChild.required<ElementRef>('epgContainer');
@Input() set playlistChannels(value: Observable<Channel[]>) {
// Unsubscribe from previous subscription if it exists
if (this.playlistChannelsSubscription) {
this.playlistChannelsSubscription.unsubscribe();
}
if (value) {
value.subscribe((channels) => {
this._playlistChannels = channels;
this.playlistChannelsSubscription = value.subscribe(() => {
this.initializeVisibleChannels();
this.requestPrograms();
});
}
}
private _playlistChannels: Channel[] = [];
readonly timeHeader = Array.from({ length: 24 }, (_, i) => i);
readonly hourWidth$ = new BehaviorSubject<number>(150);
@@ -82,16 +85,15 @@ export class MultiEpgContainerComponent
originalEpgData: any[] = [];
private dateCache = new Map<string, Date>();
private interval: any;
private interval!: number;
private playlistChannelsSubscription?: Subscription;
isLastPage = false;
totalChannels = 0;
constructor(
private dialog: MatDialog,
private cdr: ChangeDetectorRef,
@Inject(COMPONENT_OVERLAY_REF) private overlayRef: OverlayRef
) {}
private readonly dialog = inject(MatDialog);
private readonly cdr = inject(ChangeDetectorRef);
private readonly overlayRef = inject<OverlayRef>(COMPONENT_OVERLAY_REF);
ngOnInit() {
this.calculateCurrentTimeBar();
@@ -123,9 +125,9 @@ export class MultiEpgContainerComponent
}
private initializeVisibleChannels(): void {
if (this.epgContainer) {
const containerHeight =
this.epgContainer.nativeElement.offsetHeight;
const epgContainer = this.epgContainer();
if (epgContainer) {
const containerHeight = epgContainer.nativeElement.offsetHeight;
const calculatedVisibleChannels = Math.floor(
(containerHeight - this.barHeight) / this.barHeight
);
@@ -142,6 +144,11 @@ export class MultiEpgContainerComponent
this.hourWidth$.complete();
this.channels$.complete();
clearInterval(this.interval);
// Clean up playlist channels subscription
if (this.playlistChannelsSubscription) {
this.playlistChannelsSubscription.unsubscribe();
}
}
trackByIndex(index: number): number {
@@ -25,7 +25,7 @@
</div>
</mat-list-item>
} @else {
<mat-list-item (click)="getGlobalFavorites()">
<mat-list-item (click)="navigateToGlobalFavorites()">
<mat-icon class="favorites-icon" matListItemIcon
>star</mat-icon
>
@@ -42,7 +42,7 @@
@for (item of playlists; track item._id; let last = $last) {
<app-playlist-item
[item]="item"
[showActions]="!sidebarMode"
[showActions]="!sidebarMode()"
(editPlaylistClicked)="openInfoDialog($event)"
(playlistClicked)="getPlaylist(item)"
(refreshClicked)="refreshPlaylist($event)"
@@ -3,11 +3,9 @@ import { AsyncPipe } from '@angular/common';
import {
Component,
ElementRef,
EventEmitter,
inject,
Input,
OnInit,
Output,
input,
output,
viewChild,
} from '@angular/core';
import { MatDialog } from '@angular/material/dialog';
@@ -28,11 +26,8 @@ import { NgxSkeletonLoaderComponent } from 'ngx-skeleton-loader';
import { BehaviorSubject, combineLatest, map } from 'rxjs';
import { DatabaseService, DataService, SortService } from 'services';
import {
AUTO_UPDATE_PLAYLISTS_RESPONSE,
GLOBAL_FAVORITES_PLAYLIST_ID,
Playlist,
PLAYLIST_UPDATE,
PLAYLIST_UPDATE_RESPONSE,
PlaylistMeta,
} from 'shared-interfaces';
import { DialogService } from '../confirm-dialog/dialog.service';
@@ -53,7 +48,7 @@ import { PlaylistItemComponent } from './playlist-item/playlist-item.component';
TranslatePipe,
],
})
export class RecentPlaylistsComponent implements OnInit {
export class RecentPlaylistsComponent {
private readonly databaseService = inject(DatabaseService);
private readonly dialog = inject(MatDialog);
private readonly dialogService = inject(DialogService);
@@ -64,6 +59,13 @@ export class RecentPlaylistsComponent implements OnInit {
private readonly store = inject(Store);
private readonly translate = inject(TranslateService);
readonly sidebarMode = input(false);
readonly playlistClicked = output<string>();
readonly allPlaylistsLoaded = this.store.selectSignal(
selectPlaylistsLoadingFlag
);
readonly searchQueryInput =
viewChild<ElementRef<HTMLInputElement>>('searchQuery');
@@ -112,58 +114,6 @@ export class RecentPlaylistsComponent implements OnInit {
})
);
allPlaylistsLoaded = this.store.selectSignal(selectPlaylistsLoadingFlag);
@Input() sidebarMode = false;
@Output() playlistClicked = new EventEmitter<string>();
/** IPC Renderer commands list with callbacks */
commandsList = [
{
id: PLAYLIST_UPDATE_RESPONSE,
execute: (response: {
payload: { message: string; playlist: Playlist };
}) => {
this.snackBar.open(response.payload.message, undefined, {
duration: 2000,
});
this.store.dispatch(
PlaylistActions.updatePlaylist({
playlistId: response.payload.playlist._id,
playlist: response.payload.playlist,
})
);
},
},
{
id: AUTO_UPDATE_PLAYLISTS_RESPONSE,
execute: (response: {
payload: { message: string; playlists: Playlist[] };
}) => {
this.store.dispatch(
PlaylistActions.updateManyPlaylists({
playlists: response.payload.playlists,
})
);
},
},
];
ngOnInit(): void {
this.setRendererListeners();
}
setRendererListeners(): void {
this.commandsList.forEach((command) => {
const cb = (response: any) => {
if (response.data.type === command.id) {
command.execute(response.data);
}
};
this.dataService.listenOn(command.id, cb);
});
}
/**
* Opens the details dialog with the information about the provided playlist
* @param data selected playlist
@@ -190,7 +140,7 @@ export class RecentPlaylistsComponent implements OnInit {
);
}
getGlobalFavorites() {
navigateToGlobalFavorites() {
this.router.navigate(['playlists', GLOBAL_FAVORITES_PLAYLIST_ID]);
this.playlistClicked.emit(GLOBAL_FAVORITES_PLAYLIST_ID);
}
+4654 -2877
View File
File diff suppressed because it is too large. Load diff
+53 -48
View File
@@ -22,6 +22,7 @@
"build": {
"appId": "com.fourgray.iptvnator",
"productName": "IPTVnator",
"artifactName": "${name}-${version}-${os}-${arch}.${ext}",
"mac": {
"target": {
"target": "dmg",
@@ -44,7 +45,8 @@
"Snap",
"deb",
"rpm"
]
],
"artifactName": "${name}-${version}-${os}-${arch}.${ext}"
},
"win": {
"compression": "maximum",
@@ -56,14 +58,20 @@
"x64"
]
}
]
],
"artifactName": "${name}-${version}-windows-${arch}-setup.${ext}"
},
"nsis": {
"artifactName": "${name}-${version}-windows-${arch}-setup.${ext}"
}
},
"scripts": {
"postinstall": "electron-builder install-app-deps",
"build:frontend": "nx build web",
"build:frontend:pwa": "nx build web --configuration=pwa",
"build:backend": "nx build electron-backend",
"serve:frontend": "nx serve web --no-tui",
"serve:frontend:pwa": "nx serve web --configuration=pwa --no-tui",
"serve:backend": "nx serve electron-backend",
"test:frontend": "nx test web",
"test:backend": "nx test electron-backend",
@@ -72,16 +80,16 @@
},
"private": true,
"dependencies": {
"@angular/animations": "^20.3.3",
"@angular/cdk": "^20.2.7",
"@angular/common": "~20.1.0",
"@angular/compiler": "~20.1.0",
"@angular/core": "~20.1.0",
"@angular/forms": "~20.1.0",
"@angular/material": "^20.2.7",
"@angular/platform-browser": "~20.1.0",
"@angular/platform-browser-dynamic": "~20.1.0",
"@angular/router": "~20.1.0",
"@angular/animations": "^20.3.10",
"@angular/cdk": "^20.2.12",
"@angular/common": "~20.3.10",
"@angular/compiler": "~20.3.10",
"@angular/core": "~20.3.10",
"@angular/forms": "~20.3.10",
"@angular/material": "^20.2.12",
"@angular/platform-browser": "~20.3.10",
"@angular/platform-browser-dynamic": "~20.3.10",
"@angular/router": "~20.3.10",
"@libsql/client": "0.15.15",
"@ngrx/component-store": "20.0.0",
"@ngrx/effects": "20.0.0",
@@ -118,41 +126,39 @@
"zone.js": "~0.15.1"
},
"devDependencies": {
"@angular-devkit/core": "~20.1.0",
"@angular-devkit/schematics": "~20.1.0",
"@angular-eslint/builder": "20.3.0",
"@angular-eslint/eslint-plugin": "20.3.0",
"@angular-eslint/eslint-plugin-template": "20.3.0",
"@angular-eslint/schematics": "20.3.0",
"@angular-eslint/template-parser": "20.3.0",
"@angular/build": "~20.1.0",
"@angular/cli": "~20.1.0",
"@angular/common": "20.3.1",
"@angular/compiler": "20.3.1",
"@angular/compiler-cli": "~20.1.0",
"@angular/core": "20.3.1",
"@angular/forms": "20.3.1",
"@angular/language-service": "~20.1.0",
"@angular/platform-browser": "20.3.1",
"@angular/platform-browser-dynamic": "20.3.1",
"@angular/router": "20.3.1",
"@angular/service-worker": "20.3.1",
"@commitlint/cli": "18.4.3",
"@commitlint/config-conventional": "18.4.3",
"@angular-devkit/core": "~20.3.9",
"@angular-devkit/schematics": "~20.3.9",
"@angular-eslint/builder": "20.5.2",
"@angular-eslint/eslint-plugin": "20.5.2",
"@angular-eslint/eslint-plugin-template": "20.5.2",
"@angular-eslint/schematics": "20.5.2",
"@angular-eslint/template-parser": "20.5.2",
"@angular/build": "~20.3.9",
"@angular/cli": "~20.3.9",
"@angular/common": "20.3.10",
"@angular/compiler": "20.3.10",
"@angular/compiler-cli": "~20.3.10",
"@angular/core": "20.3.10",
"@angular/forms": "20.3.10",
"@angular/language-service": "~20.3.10",
"@angular/platform-browser": "20.3.10",
"@angular/platform-browser-dynamic": "20.3.10",
"@angular/router": "20.3.10",
"@angular/service-worker": "20.3.10",
"@eslint/eslintrc": "3.3.1",
"@eslint/js": "^9.38.0",
"@ngrx/eslint-plugin": "^20.0.0",
"@ngx-translate/core": "16.0.4",
"@ngx-translate/http-loader": "16.0.1",
"@nx/angular": "21.4.1",
"@nx/devkit": "21.4.1",
"@nx/eslint": "21.4.1",
"@nx/eslint-plugin": "21.4.1",
"@nx/jest": "21.4.1",
"@nx/js": "21.4.1",
"@nx/playwright": "21.4.1",
"@nx/web": "21.4.1",
"@nx/workspace": "21.4.1",
"@nx/angular": "22.0.2",
"@nx/devkit": "22.0.2",
"@nx/eslint": "22.0.2",
"@nx/eslint-plugin": "22.0.2",
"@nx/jest": "22.0.2",
"@nx/js": "22.0.2",
"@nx/playwright": "22.0.2",
"@nx/web": "22.0.2",
"@nx/workspace": "22.0.2",
"@playwright/test": "^1.36.0",
"@schematics/angular": "~20.1.0",
"@semantic-release/changelog": "6.0.3",
@@ -171,17 +177,16 @@
"@typescript-eslint/eslint-plugin": "^8.46.2",
"@typescript-eslint/parser": "^8.46.2",
"@typescript-eslint/utils": "^8.46.2",
"angular-eslint": "^20.0.0",
"angular-eslint": "20.5.2",
"conventional-changelog-cli": "5.0.0",
"drizzle-kit": "0.31.5",
"electron": "^34.5.6",
"electron-builder": "26.0.12",
"electron": "^39.0.0",
"electron-builder": "^26.0.12",
"eslint": "^9.8.0",
"eslint-config-prettier": "^10.1.8",
"eslint-plugin-import": "2.32.0",
"eslint-plugin-playwright": "^1.6.2",
"globals": "15.9.0",
"husky": "5.0.9",
"jest": "^30.0.2",
"jest-environment-jsdom": "^30.0.2",
"jest-environment-node": "^30.0.2",
@@ -189,7 +194,7 @@
"jest-util": "^30.0.2",
"jsonc-eslint-parser": "^2.1.0",
"ng-mocks": "14.14.0",
"nx": "21.4.1",
"nx": "22.0.2",
"nx-electron": "^21.0.1",
"playwright": "1.56.1",
"playwright-core": "1.56.1",
@@ -198,7 +203,7 @@
"ts-jest": "^29.4.5",
"ts-node": "10.9.2",
"tslib": "^2.8.1",
"typescript": "~5.8.2",
"typescript": "5.9.3",
"typescript-eslint": "^8.46.2"
}
}
+1320 -1661
View File
File diff suppressed because it is too large. Load diff