diff --git a/apps/electron-backend/project.json b/apps/electron-backend/project.json index 152ef13d8..d03e3a92c 100644 --- a/apps/electron-backend/project.json +++ b/apps/electron-backend/project.json @@ -7,7 +7,7 @@ "build": { "dependsOn": [ { - "projects": ["web"], + "projects": ["web", "remote-control-web"], "target": "build" } ], @@ -54,6 +54,7 @@ "nx run electron-backend:serve-electron" ] } + }, "serve-electron": { "executor": "nx-electron:execute", diff --git a/apps/electron-backend/src/app/api/main.preload.ts b/apps/electron-backend/src/app/api/main.preload.ts index de1012256..b91fafabb 100644 --- a/apps/electron-backend/src/app/api/main.preload.ts +++ b/apps/electron-backend/src/app/api/main.preload.ts @@ -1,6 +1,10 @@ import { contextBridge, ipcRenderer } from 'electron'; contextBridge.exposeInMainWorld('electron', { + // Remote control channel change listener + onChannelChange: (callback: (data: { direction: 'up' | 'down' }) => void) => { + ipcRenderer.on('CHANNEL_CHANGE', (_event, data) => callback(data)); + }, getAppVersion: () => ipcRenderer.invoke('get-app-version'), platform: process.platform, fetchPlaylistByUrl: (url: string, title?: string) => diff --git a/apps/electron-backend/src/app/events/remote-control.events.ts b/apps/electron-backend/src/app/events/remote-control.events.ts new file mode 100644 index 000000000..90c6e7a10 --- /dev/null +++ b/apps/electron-backend/src/app/events/remote-control.events.ts @@ -0,0 +1,107 @@ +import { BrowserWindow, ipcMain } from 'electron'; +import * as http from 'http'; +import { httpServer } from '../server/http-server'; +import { store } from '../services/store.service'; + +class RemoteControlEvents { + /** + * Bootstrap remote control events + */ + bootstrapRemoteControlEvents(): void { + // Register HTTP API endpoints + httpServer.registerRemoteControlHandler( + '/api/remote-control/channel/up', + this.handleChannelUp.bind(this) + ); + + httpServer.registerRemoteControlHandler( + '/api/remote-control/channel/down', + this.handleChannelDown.bind(this) + ); + + // Start HTTP server if remote control is enabled in settings + const remoteControlEnabled = store.get('remoteControl', false); + const remoteControlPort = store.get('remoteControlPort', 8765); + if (remoteControlEnabled) { + httpServer.start(remoteControlPort); + } + + // Register IPC handlers (for when the main app wants to use remote control) + ipcMain.handle('REMOTE_CONTROL_CHANNEL_UP', () => { + return this.changeChannelUp(); + }); + + ipcMain.handle('REMOTE_CONTROL_CHANNEL_DOWN', () => { + return this.changeChannelDown(); + }); + } + + /** + * HTTP handler for channel up + */ + private handleChannelUp( + req: http.IncomingMessage, + res: http.ServerResponse + ): void { + if (req.method !== 'POST') { + res.writeHead(405, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'Method not allowed' })); + return; + } + + this.changeChannelUp(); + + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ success: true })); + } + + /** + * HTTP handler for channel down + */ + private handleChannelDown( + req: http.IncomingMessage, + res: http.ServerResponse + ): void { + if (req.method !== 'POST') { + res.writeHead(405, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'Method not allowed' })); + return; + } + + this.changeChannelDown(); + + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ success: true })); + } + + /** + * Change to the next channel + */ + private changeChannelUp(): void { + console.log('Channel UP requested'); + this.sendChannelChangeToRenderer('up'); + } + + /** + * Change to the previous channel + */ + private changeChannelDown(): void { + console.log('Channel DOWN requested'); + this.sendChannelChangeToRenderer('down'); + } + + /** + * Send channel change message to the renderer process + */ + private sendChannelChangeToRenderer(direction: 'up' | 'down'): void { + const windows = BrowserWindow.getAllWindows(); + if (windows.length > 0) { + windows[0].webContents.send('CHANNEL_CHANGE', { direction }); + console.log(`Sent CHANNEL_CHANGE ${direction} to renderer`); + } else { + console.warn('No browser windows found to send channel change'); + } + } +} + +export default new RemoteControlEvents(); diff --git a/apps/electron-backend/src/app/events/settings.events.ts b/apps/electron-backend/src/app/events/settings.events.ts index 692b266d0..4d50233dc 100644 --- a/apps/electron-backend/src/app/events/settings.events.ts +++ b/apps/electron-backend/src/app/events/settings.events.ts @@ -3,6 +3,7 @@ import { MPV_REUSE_INSTANCE, store, } from '../services/store.service'; +import { httpServer } from '../server/http-server'; export default class SettingsEvents { static bootstrapSettingsEvents(): Electron.IpcMain { @@ -12,9 +13,26 @@ export default class SettingsEvents { ipcMain.handle('SETTINGS_UPDATE', (_event, arg) => { console.log('Received SETTINGS_UPDATE with data:', arg); - + // Only set values that are defined if (arg.mpvReuseInstance !== undefined) { store.set(MPV_REUSE_INSTANCE, arg.mpvReuseInstance); } + + // Handle remote control settings + if (arg.remoteControl !== undefined || arg.remoteControlPort !== undefined) { + const enabled = arg.remoteControl ?? store.get('remoteControl', false); + const port = arg.remoteControlPort ?? store.get('remoteControlPort', 8765); + + // Save to store + if (arg.remoteControl !== undefined) { + store.set('remoteControl', enabled); + } + if (arg.remoteControlPort !== undefined) { + store.set('remoteControlPort', port); + } + + // Update HTTP server + httpServer.updateSettings(enabled, port); + } }); diff --git a/apps/electron-backend/src/app/server/http-server.ts b/apps/electron-backend/src/app/server/http-server.ts new file mode 100644 index 000000000..c56b38d2b --- /dev/null +++ b/apps/electron-backend/src/app/server/http-server.ts @@ -0,0 +1,200 @@ +import { app } from 'electron'; +import * as fs from 'fs'; +import * as http from 'http'; +import * as path from 'path'; + +/** + * HTTP server for serving the remote control web app and providing REST API endpoints + */ +export class HttpServer { + private server: http.Server | null = null; + private port = 8765; + private isEnabled = false; + private distPath: string; + private remoteControlHandlers: Map< + string, + (req: http.IncomingMessage, res: http.ServerResponse) => void + > = new Map(); + + constructor() { + // Path to the built remote-control-web app + // In development: use workspace root + // In production: use app path + const appPath = app.getAppPath(); + const isDev = !app.isPackaged; + + if (isDev) { + // Development mode - use workspace root + this.distPath = path.join( + process.cwd(), + 'dist', + 'apps', + 'remote-control-web', + 'browser' + ); + } else { + // Production mode - files are bundled with the app + this.distPath = path.join( + appPath, + 'dist', + 'apps', + 'remote-control-web', + 'browser' + ); + } + + console.log('[HTTP Server] Serving from:', this.distPath); + } + + /** + * Start the HTTP server + */ + start(port?: number): void { + if (port) { + this.port = port; + } + + if (this.server) { + console.log('HTTP server is already running'); + return; + } + + this.server = http.createServer((req, res) => { + this.handleRequest(req, res); + }); + + this.server.listen(this.port, () => { + console.log(`HTTP server listening on port ${this.port}`); + console.log( + `Remote control available at: http://localhost:${this.port}` + ); + }); + + this.isEnabled = true; + } + + /** + * Stop the HTTP server + */ + stop(): void { + if (!this.server) { + return; + } + + this.server.close(() => { + console.log('HTTP server stopped'); + }); + + this.server = null; + this.isEnabled = false; + } + + /** + * Update server settings + */ + updateSettings(enabled: boolean, port: number): void { + const needsRestart = this.isEnabled && enabled && this.port !== port; + + if (!enabled && this.isEnabled) { + this.stop(); + } else if (enabled && !this.isEnabled) { + this.start(port); + } else if (needsRestart) { + this.stop(); + this.start(port); + } + } + + /** + * Register a handler for remote control API endpoints + */ + registerRemoteControlHandler( + path: string, + handler: (req: http.IncomingMessage, res: http.ServerResponse) => void + ): void { + this.remoteControlHandlers.set(path, handler); + } + + /** + * Handle incoming HTTP requests + */ + private handleRequest( + req: http.IncomingMessage, + res: http.ServerResponse + ): void { + const url = req.url || '/'; + + // Handle API requests + if (url.startsWith('/api/remote-control/')) { + const handler = this.remoteControlHandlers.get(url); + if (handler) { + handler(req, res); + return; + } + + res.writeHead(404, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'Endpoint not found' })); + return; + } + + // Serve static files from the remote-control-web app + this.serveStaticFile(url, res); + } + + /** + * Serve static files + */ + private serveStaticFile(url: string, res: http.ServerResponse): void { + // Default to index.html for root path + let filePath = url === '/' ? '/index.html' : url; + + // Security: prevent directory traversal + filePath = path.normalize(filePath).replace(/^(\.\.[/\\])+/, ''); + + const fullPath = path.join(this.distPath, filePath); + + fs.readFile(fullPath, (err, data) => { + if (err) { + // If file not found, try serving index.html (for Angular routing) + if (err.code === 'ENOENT' && filePath !== '/index.html') { + this.serveStaticFile('/', res); + return; + } + + res.writeHead(404, { 'Content-Type': 'text/plain' }); + res.end('404 Not Found'); + return; + } + + // Determine content type + const contentType = this.getContentType(fullPath); + res.writeHead(200, { 'Content-Type': contentType }); + res.end(data); + }); + } + + /** + * Get content type based on file extension + */ + private getContentType(filePath: string): string { + const ext = path.extname(filePath).toLowerCase(); + const contentTypes: Record = { + '.html': 'text/html', + '.js': 'application/javascript', + '.css': 'text/css', + '.json': 'application/json', + '.png': 'image/png', + '.jpg': 'image/jpeg', + '.gif': 'image/gif', + '.svg': 'image/svg+xml', + '.ico': 'image/x-icon', + '.woff': 'font/woff', + '.woff2': 'font/woff2', + '.ttf': 'font/ttf', + }; + + return contentTypes[ext] || 'application/octet-stream'; + } +} + +export const httpServer = new HttpServer(); diff --git a/apps/electron-backend/src/main.ts b/apps/electron-backend/src/main.ts index 4e1d08ed6..69d6f8e9b 100644 --- a/apps/electron-backend/src/main.ts +++ b/apps/electron-backend/src/main.ts @@ -7,6 +7,7 @@ import ElectronEvents from './app/events/electron.events'; import EpgEvents from './app/events/epg.events'; import PlayerEvents from './app/events/player.events'; import PlaylistEvents from './app/events/playlist.events'; +import RemoteControlEvents from './app/events/remote-control.events'; import SettingsEvents from './app/events/settings.events'; import SharedEvents from './app/events/shared.events'; import SquirrelEvents from './app/events/squirrel.events'; @@ -40,6 +41,7 @@ export default class Main { XtreamEvents.bootstrapXtreamEvents(); DatabaseEvents.bootstrapDatabaseEvents(); EpgEvents.bootstrapEpgEvents(); + RemoteControlEvents.bootstrapRemoteControlEvents(); // initialize auto updater service if (!App.isDevelopmentMode()) { diff --git a/apps/remote-control-web/README.md b/apps/remote-control-web/README.md new file mode 100644 index 000000000..9137bddae --- /dev/null +++ b/apps/remote-control-web/README.md @@ -0,0 +1,174 @@ +# Remote Control Web App + +A lightweight, mobile-optimized web application that provides remote control functionality for IPTVnator. Control channel switching from your phone or tablet on the same network. + +## Overview + +This is a standalone Angular application designed to be served by IPTVnator's Electron backend via an HTTP server. It provides a beautiful, Apple TV-inspired remote control interface that communicates with the main IPTVnator desktop application to change channels. + +## Features + +- **Apple TV-Inspired Design** - Premium dark theme with glass morphism effects +- **Mobile-Optimized** - Touch-friendly interface designed for phones and tablets +- **Real-time Control** - Instant channel switching via REST API +- **Minimal & Fast** - Lightweight app (~55 KB compressed) with no external dependencies +- **Responsive** - Adapts to different screen sizes (320px - 480px+) + +## How It Works + +### Architecture + +``` +┌─────────────────┐ HTTP ┌──────────────────┐ +│ Phone/Tablet │ ◄──────────────────► │ HTTP Server │ +│ (Browser App) │ REST API calls │ (Electron Main) │ +└─────────────────┘ └──────────────────┘ + │ IPC + ▼ + ┌──────────────────┐ + │ Main App │ + │ (Renderer) │ + └──────────────────┘ +``` + +1. **HTTP Server**: Electron backend starts an HTTP server (default port: 8765) +2. **Static Files**: Serves this Angular app's built files +3. **REST API**: Exposes `/api/remote-control/channel/up` and `/api/remote-control/channel/down` endpoints +4. **IPC Communication**: HTTP server sends events to the main Electron window +5. **Channel Switching**: Main app receives events and switches to next/previous channel + +### API Endpoints + +- `GET /` - Serves the remote control web interface +- `POST /api/remote-control/channel/up` - Switch to previous channel +- `POST /api/remote-control/channel/down` - Switch to next channel + +## Usage + +### Enable Remote Control + +1. Open IPTVnator desktop app +2. Go to **Settings** +3. Enable **"Remote Control"** checkbox +4. Optionally change the port (default: 8765) +5. The HTTP server starts automatically + +### Access from Mobile Device + +1. Ensure your phone/tablet is on the **same network** as your computer +2. Find your computer's IP address: + - **Windows**: `ipconfig` (look for IPv4) + - **macOS**: `ifconfig` or System Preferences → Network + - **Linux**: `ip addr` or `hostname -I` +3. Open browser on your mobile device +4. Navigate to: `http://:8765` + - Example: `http://192.168.1.100:8765` + +### Controls + +- **Top half of circle**: Previous channel (Channel Up) +- **Bottom half of circle**: Next channel (Channel Down) +- **Center blue dot**: Status indicator (glowing = ready) + +## Development + +### Build + +```bash +# Production build +nx build remote-control-web --configuration=production + +# Development build +nx build remote-control-web +``` + +Built files are output to `dist/apps/remote-control-web/browser/` + +### Serve Locally + +The app is designed to be served by the Electron backend's HTTP server. To test: + +```bash +# Start Electron backend (includes HTTP server) +nx serve electron-backend + +# Access at http://localhost:8765 +``` + +### Project Structure + +``` +apps/remote-control-web/ +├── src/ +│ ├── app/ +│ │ ├── app.component.ts # Root component +│ │ ├── app.config.ts # Angular configuration +│ │ └── app.routes.ts # Routes (single route) +│ ├── assets/ # Static assets (if any) +│ ├── index.html # HTML entry point +│ └── main.ts # Bootstrap Angular app +├── project.json # Nx project configuration +├── tsconfig.json # TypeScript config +└── README.md # This file +``` + +## Shared UI Library + +The remote control component is located in: +``` +libs/ui/remote-control/ +├── src/lib/remote-control/ +│ ├── remote-control.component.ts # Main component logic +│ ├── remote-control.component.html # Template +│ ├── remote-control.component.scss # Apple TV-inspired styles +│ └── remote-control.service.ts # HTTP service for API calls +``` + +This library is shared and can be reused in other applications if needed. + +## Technical Details + +- **Framework**: Angular 18+ (Standalone components) +- **HTTP Client**: Angular HttpClient +- **Styling**: SCSS with BEM methodology +- **Design System**: Apple TV-inspired with glass morphism +- **Animations**: CSS-only (no JavaScript animations) +- **Bundle Size**: ~55 KB gzipped (production build) +- **Browser Support**: Modern browsers (Chrome, Safari, Firefox, Edge) + +## Design Philosophy + +The interface is inspired by Apple TV's remote control: +- **Minimalism**: Clean, uncluttered design with essential controls only +- **Glass Morphism**: Frosted glass effects with subtle reflections +- **Touch-Optimized**: Large touch targets (50% of circle per button) +- **Feedback**: Visual feedback on hover/press with ripple effects +- **Accessibility**: Supports reduced motion preferences + +## Security Considerations + +- **Local Network Only**: The HTTP server should only be accessible on your local network +- **No Authentication**: Currently no authentication - ensure your network is trusted +- **CORS**: Server accepts requests from any origin on the local network + +## Future Enhancements + +Potential improvements: +- Volume control +- Play/pause functionality +- Channel search +- Favorites quick access +- Authentication/PIN protection +- WebSocket for real-time status updates +- Multi-device support with channel sync + +## Related Files + +- **HTTP Server**: `apps/electron-backend/src/app/server/http-server.ts` +- **Remote Control Events**: `apps/electron-backend/src/app/events/remote-control.events.ts` +- **Main App Handler**: `apps/web/src/app/home/video-player/video-player.component.ts` (handleRemoteChannelChange method) +- **Settings UI**: `apps/web/src/app/settings/settings.component.html` + +## License + +Same as IPTVnator main project. diff --git a/apps/remote-control-web/eslint.config.mjs b/apps/remote-control-web/eslint.config.mjs new file mode 100644 index 000000000..43bac6ec9 --- /dev/null +++ b/apps/remote-control-web/eslint.config.mjs @@ -0,0 +1,34 @@ +import nx from '@nx/eslint-plugin'; +import baseConfig from '../../eslint.config.mjs'; + +export default [ + ...baseConfig, + ...nx.configs['flat/angular'], + ...nx.configs['flat/angular-template'], + { + files: ['**/*.ts'], + rules: { + '@angular-eslint/directive-selector': [ + 'error', + { + type: 'attribute', + prefix: 'app', + style: 'camelCase', + }, + ], + '@angular-eslint/component-selector': [ + 'error', + { + type: 'element', + prefix: 'app', + style: 'kebab-case', + }, + ], + }, + }, + { + files: ['**/*.html'], + // Override or add rules here + rules: {}, + }, +]; diff --git a/apps/remote-control-web/jest.config.ts b/apps/remote-control-web/jest.config.ts new file mode 100644 index 000000000..98cc58074 --- /dev/null +++ b/apps/remote-control-web/jest.config.ts @@ -0,0 +1,21 @@ +export default { + displayName: 'remote-control-web', + preset: '../../jest.preset.js', + setupFilesAfterEnv: ['/src/test-setup.ts'], + coverageDirectory: '../../coverage/apps/remote-control-web', + transform: { + '^.+\\.(ts|mjs|js|html)$': [ + 'jest-preset-angular', + { + tsconfig: '/tsconfig.spec.json', + stringifyContentPathRegex: '\\.(html|svg)$', + }, + ], + }, + transformIgnorePatterns: ['node_modules/(?!.*\\.mjs$)'], + snapshotSerializers: [ + 'jest-preset-angular/build/serializers/no-ng-attributes', + 'jest-preset-angular/build/serializers/ng-snapshot', + 'jest-preset-angular/build/serializers/html-comment', + ], +}; diff --git a/apps/remote-control-web/project.json b/apps/remote-control-web/project.json new file mode 100644 index 000000000..9479337a7 --- /dev/null +++ b/apps/remote-control-web/project.json @@ -0,0 +1,96 @@ +{ + "name": "remote-control-web", + "$schema": "../../node_modules/nx/schemas/project-schema.json", + "projectType": "application", + "prefix": "app", + "sourceRoot": "apps/remote-control-web/src", + "tags": [], + "targets": { + "build": { + "executor": "@angular/build:application", + "outputs": ["{options.outputPath}"], + "options": { + "outputPath": "dist/apps/remote-control-web", + "browser": "apps/remote-control-web/src/main.ts", + "polyfills": ["zone.js"], + "tsConfig": "apps/remote-control-web/tsconfig.app.json", + "inlineStyleLanguage": "scss", + "assets": [ + { + "glob": "**/*", + "input": "apps/remote-control-web/public" + }, + { + "glob": "**/*", + "input": "apps/web/src/assets/i18n", + "output": "assets/i18n" + } + ], + "styles": ["apps/remote-control-web/src/styles.scss"] + }, + "configurations": { + "production": { + "budgets": [ + { + "type": "initial", + "maximumWarning": "500kb", + "maximumError": "1mb" + }, + { + "type": "anyComponentStyle", + "maximumWarning": "4kb", + "maximumError": "8kb" + } + ], + "outputHashing": "all" + }, + "development": { + "optimization": false, + "extractLicenses": false, + "sourceMap": true + } + }, + "defaultConfiguration": "production" + }, + "serve": { + "continuous": true, + "executor": "@angular/build:dev-server", + "configurations": { + "production": { + "buildTarget": "remote-control-web:build:production" + }, + "development": { + "buildTarget": "remote-control-web:build:development" + } + }, + "defaultConfiguration": "development" + }, + "extract-i18n": { + "executor": "@angular/build:extract-i18n", + "options": { + "buildTarget": "remote-control-web:build" + } + }, + "lint": { + "executor": "@nx/eslint:lint" + }, + "test": { + "executor": "@nx/jest:jest", + "outputs": ["{workspaceRoot}/coverage/{projectRoot}"], + "options": { + "jestConfig": "apps/remote-control-web/jest.config.ts", + "tsConfig": "apps/remote-control-web/tsconfig.spec.json" + } + }, + "serve-static": { + "continuous": true, + "executor": "@nx/web:file-server", + "options": { + "buildTarget": "remote-control-web:build", + "port": 4200, + "staticFilePath": "dist/apps/remote-control-web/browser", + "spa": true + } + } + } +} diff --git a/apps/remote-control-web/public/favicon.ico b/apps/remote-control-web/public/favicon.ico new file mode 100644 index 000000000..317ebcb23 Binary files /dev/null and b/apps/remote-control-web/public/favicon.ico differ diff --git a/apps/remote-control-web/src/app/app.config.ts b/apps/remote-control-web/src/app/app.config.ts new file mode 100644 index 000000000..ad40068af --- /dev/null +++ b/apps/remote-control-web/src/app/app.config.ts @@ -0,0 +1,32 @@ +import { + ApplicationConfig, + importProvidersFrom, + provideBrowserGlobalErrorListeners, + provideZoneChangeDetection, +} from '@angular/core'; +import { provideHttpClient } from '@angular/common/http'; +import { TranslateModule, TranslateLoader } from '@ngx-translate/core'; +import { TranslateHttpLoader } from '@ngx-translate/http-loader'; +import { HttpClient } from '@angular/common/http'; + +export function HttpLoaderFactory(http: HttpClient) { + return new TranslateHttpLoader(http, './assets/i18n/', '.json'); +} + +export const appConfig: ApplicationConfig = { + providers: [ + provideBrowserGlobalErrorListeners(), + provideZoneChangeDetection({ eventCoalescing: true }), + provideHttpClient(), + importProvidersFrom( + TranslateModule.forRoot({ + defaultLanguage: 'en', + loader: { + provide: TranslateLoader, + useFactory: HttpLoaderFactory, + deps: [HttpClient], + }, + }) + ), + ], +}; diff --git a/apps/remote-control-web/src/app/app.html b/apps/remote-control-web/src/app/app.html new file mode 100644 index 000000000..1051dd18d --- /dev/null +++ b/apps/remote-control-web/src/app/app.html @@ -0,0 +1 @@ + diff --git a/apps/remote-control-web/src/app/app.scss b/apps/remote-control-web/src/app/app.scss new file mode 100644 index 000000000..f85909345 --- /dev/null +++ b/apps/remote-control-web/src/app/app.scss @@ -0,0 +1,4 @@ +:host { + display: block; + height: 100vh; +} diff --git a/apps/remote-control-web/src/app/app.ts b/apps/remote-control-web/src/app/app.ts new file mode 100644 index 000000000..0ba58f221 --- /dev/null +++ b/apps/remote-control-web/src/app/app.ts @@ -0,0 +1,19 @@ +import { Component, OnInit, inject } from '@angular/core'; +import { RemoteControlComponent } from 'remote-control'; +import { TranslateService } from '@ngx-translate/core'; + +@Component({ + imports: [RemoteControlComponent], + selector: 'app-root', + templateUrl: './app.html', + styleUrl: './app.scss', +}) +export class App implements OnInit { + private translate = inject(TranslateService); + + ngOnInit() { + // Set default language + this.translate.setDefaultLang('en'); + this.translate.use('en'); + } +} diff --git a/apps/remote-control-web/src/index.html b/apps/remote-control-web/src/index.html new file mode 100644 index 000000000..69b4ac527 --- /dev/null +++ b/apps/remote-control-web/src/index.html @@ -0,0 +1,13 @@ + + + + + remote-control-web + + + + + + + + diff --git a/apps/remote-control-web/src/main.ts b/apps/remote-control-web/src/main.ts new file mode 100644 index 000000000..190f3418d --- /dev/null +++ b/apps/remote-control-web/src/main.ts @@ -0,0 +1,5 @@ +import { bootstrapApplication } from '@angular/platform-browser'; +import { appConfig } from './app/app.config'; +import { App } from './app/app'; + +bootstrapApplication(App, appConfig).catch((err) => console.error(err)); diff --git a/apps/remote-control-web/src/styles.scss b/apps/remote-control-web/src/styles.scss new file mode 100644 index 000000000..57526dcdc --- /dev/null +++ b/apps/remote-control-web/src/styles.scss @@ -0,0 +1,11 @@ +/* You can add global styles to this file, and also import other style files */ + +html, +body { + margin: 0; + padding: 0; + width: 100%; + height: 100%; + background-color: #1a1a1a; + overflow-x: hidden; +} diff --git a/apps/remote-control-web/src/test-setup.ts b/apps/remote-control-web/src/test-setup.ts new file mode 100644 index 000000000..0d88648e3 --- /dev/null +++ b/apps/remote-control-web/src/test-setup.ts @@ -0,0 +1,6 @@ +import { setupZoneTestEnv } from 'jest-preset-angular/setup-env/zone'; + +setupZoneTestEnv({ + errorOnUnknownElements: true, + errorOnUnknownProperties: true, +}); diff --git a/apps/remote-control-web/tsconfig.app.json b/apps/remote-control-web/tsconfig.app.json new file mode 100644 index 000000000..231df7c01 --- /dev/null +++ b/apps/remote-control-web/tsconfig.app.json @@ -0,0 +1,14 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "../../dist/out-tsc", + "types": [] + }, + "include": ["src/**/*.ts"], + "exclude": [ + "jest.config.ts", + "src/test-setup.ts", + "src/**/*.test.ts", + "src/**/*.spec.ts" + ] +} diff --git a/apps/remote-control-web/tsconfig.json b/apps/remote-control-web/tsconfig.json new file mode 100644 index 000000000..b81a1f7df --- /dev/null +++ b/apps/remote-control-web/tsconfig.json @@ -0,0 +1,32 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "strict": true, + "noImplicitOverride": true, + "noPropertyAccessFromIndexSignature": true, + "noImplicitReturns": true, + "noFallthroughCasesInSwitch": true, + "target": "es2022", + "moduleResolution": "bundler", + "isolatedModules": true, + "emitDecoratorMetadata": false, + "module": "preserve" + }, + "angularCompilerOptions": { + "enableI18nLegacyMessageIdFormat": false, + "strictInjectionParameters": true, + "strictInputAccessModifiers": true, + "typeCheckHostBindings": true, + "strictTemplates": true + }, + "files": [], + "include": [], + "references": [ + { + "path": "./tsconfig.app.json" + }, + { + "path": "./tsconfig.spec.json" + } + ] +} diff --git a/apps/remote-control-web/tsconfig.spec.json b/apps/remote-control-web/tsconfig.spec.json new file mode 100644 index 000000000..cc2617958 --- /dev/null +++ b/apps/remote-control-web/tsconfig.spec.json @@ -0,0 +1,17 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "../../dist/out-tsc", + "module": "commonjs", + "target": "es2016", + "types": ["jest", "node"], + "moduleResolution": "node10" + }, + "files": ["src/test-setup.ts"], + "include": [ + "jest.config.ts", + "src/**/*.test.ts", + "src/**/*.spec.ts", + "src/**/*.d.ts" + ] +} 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 19d2aaf7b..e58d41192 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,7 +3,6 @@ import { ComponentPortal } from '@angular/cdk/portal'; import { AsyncPipe, CommonModule } from '@angular/common'; import { Component, - InjectionToken, Injector, OnInit, effect, @@ -17,6 +16,7 @@ import { StorageMap } from '@ngx-pwa/local-storage'; import { ArtPlayerComponent, AudioPlayerComponent, + COMPONENT_OVERLAY_REF, EpgListComponent, HtmlVideoPlayerComponent, InfoOverlayComponent, @@ -31,7 +31,7 @@ import { selectChannels, selectCurrentEpgProgram, } from 'm3u-state'; -import { Observable, combineLatestWith, filter, map, switchMap } from 'rxjs'; +import { Observable, combineLatest, combineLatestWith, filter, map, switchMap, take } from 'rxjs'; import { DataService, PlaylistsService } from 'services'; import { Channel, @@ -73,10 +73,6 @@ export class VideoPlayerComponent implements OnInit { private readonly storage = inject(StorageMap); private readonly store = inject(Store); - private readonly COMPONENT_OVERLAY_REF = new InjectionToken( - 'COMPONENT_OVERLAY_REF' - ); - /** Active selected channel */ readonly activeChannel$ = this.store .select(selectActive) @@ -126,6 +122,13 @@ export class VideoPlayerComponent implements OnInit { this.applySettings(); this.getPlaylistUrlAsParam(); + // 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); + }); + } + this.channels$ = this.activatedRoute.params.pipe( combineLatestWith(this.activatedRoute.queryParams), switchMap(([params, queryParams]) => { @@ -162,6 +165,56 @@ export class VideoPlayerComponent implements OnInit { ); } + /** + * Handle remote control channel change + */ + handleRemoteChannelChange(direction: 'up' | 'down'): void { + console.log(`Remote control: changing channel ${direction}`); + + // Use combineLatest to get both values and take only the first emission + combineLatest([this.channels$, this.activeChannel$]) + .pipe( + filter(([channels, activeChannel]) => { + return channels.length > 0 && !!activeChannel; + }), + take(1), + map(([channels, activeChannel]) => { + return { channels, activeChannel: activeChannel as Channel }; + }) + ) + .subscribe({ + next: ({ channels, activeChannel }) => { + // Find current channel index + const currentIndex = channels.findIndex( + ch => ch.url === activeChannel.url + ); + + if (currentIndex === -1) { + console.warn('Current channel not found in channel list'); + return; + } + + // Calculate next/previous index with wraparound + let nextIndex: number; + if (direction === 'up') { + // Up = previous channel (decrease index) + nextIndex = currentIndex - 1 < 0 ? channels.length - 1 : currentIndex - 1; + } else { + // Down = next channel (increase index) + 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 })); + }, + error: (err) => { + console.error('Error changing channel:', err); + } + }); + } + /** * Opens a playlist provided as a url param * e.g. iptvnat.or?url=http://... @@ -214,7 +267,7 @@ export class VideoPlayerComponent implements OnInit { const injector = Injector.create({ providers: [ { - provide: this.COMPONENT_OVERLAY_REF, + provide: COMPONENT_OVERLAY_REF, useValue: this.overlayRef, }, ], diff --git a/apps/web/src/app/services/settings-store.service.ts b/apps/web/src/app/services/settings-store.service.ts index 6215f28dc..4aed192ab 100644 --- a/apps/web/src/app/services/settings-store.service.ts +++ b/apps/web/src/app/services/settings-store.service.ts @@ -27,7 +27,7 @@ const DEFAULT_SETTINGS: Settings = { mpvReuseInstance: false, vlcPlayerPath: '', remoteControl: false, - remoteControlPort: 3000, + remoteControlPort: 8765, epgUrl: [], }; diff --git a/apps/web/src/app/settings/settings.component.html b/apps/web/src/app/settings/settings.component.html index e483a4468..f882c0261 100644 --- a/apps/web/src/app/settings/settings.component.html +++ b/apps/web/src/app/settings/settings.component.html @@ -270,25 +270,23 @@ > - @if (settingsForm.value.remoteControl === true) {
@@ -302,13 +300,29 @@

- + + Port (1-65535) + @if (settingsForm.get('remoteControlPort')?.hasError('required')) { + {{ 'SETTINGS.PORT_REQUIRED' | translate }} + } + @if (settingsForm.get('remoteControlPort')?.hasError('min')) { + {{ 'SETTINGS.PORT_MIN' | translate }} + } + @if (settingsForm.get('remoteControlPort')?.hasError('max')) { + {{ 'SETTINGS.PORT_MAX' | translate }} + } + @if (settingsForm.get('remoteControlPort')?.hasError('pattern')) { + {{ 'SETTINGS.PORT_PATTERN' | translate }} + }
diff --git a/apps/web/src/app/settings/settings.component.ts b/apps/web/src/app/settings/settings.component.ts index 5bc12672f..0d3b86897 100644 --- a/apps/web/src/app/settings/settings.component.ts +++ b/apps/web/src/app/settings/settings.component.ts @@ -144,7 +144,15 @@ export class SettingsComponent implements OnInit { mpvReuseInstance: false, vlcPlayerPath: '', remoteControl: false, - remoteControlPort: 3000, + remoteControlPort: [ + 8765, + [ + Validators.required, + Validators.min(1), + Validators.max(65535), + Validators.pattern(/^\d+$/), + ], + ], }); /** Form array with epg sources */ diff --git a/apps/web/src/assets/i18n/de.json b/apps/web/src/assets/i18n/de.json index 961242124..6b283de03 100644 --- a/apps/web/src/assets/i18n/de.json +++ b/apps/web/src/assets/i18n/de.json @@ -1,377 +1,381 @@ { - "CLOSE": "Schließen", - "PLAYLISTS": "Playlisten", - "CHANNELS": { - "ALL_CHANNELS": "Alle Sender", - "FAVORITES": "Favoriten", - "GROUPS": "Gruppen", - "NO_FAVORITES": "Keine Favoriten", - "REMOVE_FAVORITE": "Aus Favoritenliste entfernen", - "SEARCH_CHANNEL": "Nach Sender suchen", - "UNGROUPED": "Ohne Gruppe", - "UNNAMED_CHANNEL": "Sender ohne Namen", - "UPLOAD_OR_SELECT_OTHER_PLAYLIST": "Neue Playlist hochladen oder eine andere aussuchen", - "USE_STAR_TO_FAVORITE": "Nutzen Sie das Star-Icon um Sender als Favoriten zu markieren", - "FAVORITES_UPDATED": "Favoriten wurden aktualisiert!", - "SORT_BY": "Sortieren nach", - "SORT_BY_DATE": "Nach Datum sortieren (Neueste zuerst)", - "SORT_BY_RATING": "Nach Bewertung sortieren", - "SORT_BY_NAME": "Nach Name sortieren (A-Z)" - }, - "EPG": { - "TITLE": "Elektronischer Programmführer", - "EPG_NOT_AVAILABLE_CHANNEL_DESCRIPTION": "Versuchen Sie bitte die TV-Guide Einstellungen zu ändern.", - "EPG_NOT_AVAILABLE_CHANNEL_TITLE": "Ups, leider ist das TV-Programm nicht verfügbar.", - "EPG_NOT_AVAILABLE_DATE": "Ups, leider ist das TV-Programm für das ausgewählte Datum nicht verfügbar", - "LIVE_NOW": "Live jetzt", - "LIVE_STREAM": "Live stream", - "NEXT_DAY": "Zum nächsten Tag", - "PREVIOUS_DAY": "Zum vorherigen Tag", - "TIMESHIFT_AVAILABLE": "Die Timeshift-Funktion (Archiv) ist verfügbar, klicken Sie an um abzuspielen", - "FETCH_EPG": "EPG-Daten werden geladen...", - "ERROR": "Ooops, EPG konnte nicht geladen werden", - "DOWNLOAD_SUCCESS": "EPG wurde erfolgreich geladen.", - "FETCH_SUCCESS": "EPG wurde erfolgreich abgerufen", - "PROGRAM_DIALOG": { - "PROGRAM_DETAILS": "Details zum TV-Programm", - "TITLE": "Titel", - "CATEGORY": "Kategorie", - "PARENTAL_RATING_SYSTEM": "Alterskennzeichnung (USK)", - "DESCRIPTION": "Beschreibung", - "LANGUAGE": "Sprache", - "SHOW_PROGRAM_DETAILS": "Details anzeigen" + "CLOSE": "Schließen", + "PLAYLISTS": "Playlisten", + "CHANNELS": { + "ALL_CHANNELS": "Alle Sender", + "FAVORITES": "Favoriten", + "GROUPS": "Gruppen", + "NO_FAVORITES": "Keine Favoriten", + "REMOVE_FAVORITE": "Aus Favoritenliste entfernen", + "SEARCH_CHANNEL": "Nach Sender suchen", + "UNGROUPED": "Ohne Gruppe", + "UNNAMED_CHANNEL": "Sender ohne Namen", + "UPLOAD_OR_SELECT_OTHER_PLAYLIST": "Neue Playlist hochladen oder eine andere aussuchen", + "USE_STAR_TO_FAVORITE": "Nutzen Sie das Star-Icon um Sender als Favoriten zu markieren", + "FAVORITES_UPDATED": "Favoriten wurden aktualisiert!", + "SORT_BY": "Sortieren nach", + "SORT_BY_DATE": "Nach Datum sortieren (Neueste zuerst)", + "SORT_BY_RATING": "Nach Bewertung sortieren", + "SORT_BY_NAME": "Nach Name sortieren (A-Z)" + }, + "EPG": { + "TITLE": "Elektronischer Programmführer", + "EPG_NOT_AVAILABLE_CHANNEL_DESCRIPTION": "Versuchen Sie bitte die TV-Guide Einstellungen zu ändern.", + "EPG_NOT_AVAILABLE_CHANNEL_TITLE": "Ups, leider ist das TV-Programm nicht verfügbar.", + "EPG_NOT_AVAILABLE_DATE": "Ups, leider ist das TV-Programm für das ausgewählte Datum nicht verfügbar", + "LIVE_NOW": "Live jetzt", + "LIVE_STREAM": "Live stream", + "NEXT_DAY": "Zum nächsten Tag", + "PREVIOUS_DAY": "Zum vorherigen Tag", + "TIMESHIFT_AVAILABLE": "Die Timeshift-Funktion (Archiv) ist verfügbar, klicken Sie an um abzuspielen", + "FETCH_EPG": "EPG-Daten werden geladen...", + "ERROR": "Ooops, EPG konnte nicht geladen werden", + "DOWNLOAD_SUCCESS": "EPG wurde erfolgreich geladen.", + "FETCH_SUCCESS": "EPG wurde erfolgreich abgerufen", + "PROGRAM_DIALOG": { + "PROGRAM_DETAILS": "Details zum TV-Programm", + "TITLE": "Titel", + "CATEGORY": "Kategorie", + "PARENTAL_RATING_SYSTEM": "Alterskennzeichnung (USK)", + "DESCRIPTION": "Beschreibung", + "LANGUAGE": "Sprache", + "SHOW_PROGRAM_DETAILS": "Details anzeigen" + } + }, + "HOME": { + "SUBTITLE": "Wählen Sie eine Playlist aus oder laden Sie eine neue Playlist hoch", + "TITLE": "IPTVnator", + "FILE_UPLOAD": { + "CHOOSE_PLAYLIST": "Playlist auswählen", + "DRAG_DROP": "Ziehen Sie Dateien per Drag & Drop", + "OR": "oder", + "REJECTED": "Datei wurde abgelehnt, nicht unterstütztes Dateiformat {{filename}}." + }, + "PLAYLISTS": { + "ADDED": "Hinzugefügt am", + "ADDED_VIA_FILE": "Diese Playlist wurde aus dem Dateisystem hochgeladen", + "ADDED_VIA_URL": "Diese Playlist wurde von der URL hinzugefügt", + "ADD_FIRST": "Bitte fügen Sie eine neue Playlist hinzu", + "CHANNELS": "TV-Sender", + "NO_PLAYLISTS": "Noch keine Playlisten hinzugefügt", + "REMOVE": "Playlist entfernen", + "SHOW_DETAILS": "Details anzeigen", + "SEARCH_PLAYLISTS": "Suche nach Playlisten", + "INFO_DIALOG": { + "CHANNELS": "Anzahl von Sender", + "CLOSE": "Schließen", + "FROM_URL": "Von der URL hinzugefügt", + "IMPORT_DATE": "Hinzugefügt am", + "PLAYLIST_DETAILS": "Infos über die Playlist", + "TITLE": "Titel", + "USER_AGENT": "User agent", + "SAVE": "Speichern", + "ORIGINAL_FILENAME": "Original-Dateinamen", + "FILE_PATH": "Dateipfad", + "UPDATE_FAILED": "Update fehlgeschlagen", + "AUTO_UPDATE": "Auto-update", + "AUTO_UPDATE_DESCRIPTION": "Wenn die Auto-Refresh-Funktion aktiviert ist, wird die Wiedergabeliste bei jedem Start der Anwendung automatisch aktualisiert.", + "CUSTOM_USER_AGENT": "Einige IPTV-Anbieter benötigen einen bestimmten 'User-Agent', um ihre Playlist abspielen zu können.", + "EXPORT_PLAYLIST": "Als m3u exportieren", + "PASSWORD": "Passwort", + "SERVER_URL": "Server-URL", + "USERNAME": "Nutzername", + "MAC_ADDRESS": "MAC-Adresse", + "PLAYLIST_EXPORT_SUCCESS": "Playlist wurde erfolgreich exportiert.", + "PLAYLIST_EXPORT_ERROR": "Fehler: Playlist-Export fehlgeschlagen." + }, + "UPDATED": "Aktualisiert am", + "REFRESH": "Aktualisieren", + "REFRESH_XTREAM": "Xtream-Playlist vom Server aktualisieren", + "REFRESH_XTREAM_DIALOG": { + "TITLE": "Xtream-Playlist aktualisieren", + "MESSAGE": "Dadurch werden lokal zwischengespeicherte Kategorien und Streams gelöscht und alles vom Remote-Server erneut importiert. Ihre Favoriten und kürzlich angesehenen Elemente bleiben erhalten. Möchten Sie wirklich fortfahren?", + "STARTED": "Aktualisierung gestartet. Navigation zur Playlist...", + "ERROR": "Playlist-Aktualisierung fehlgeschlagen. Bitte versuchen Sie es erneut." + }, + "REMOVE_DIALOG": { + "MESSAGE": "Sind Sie sicher, dass Sie diese Wiedergabeliste vollständig löschen möchten", + "TITLE": "Wiedergabeliste entfernen", + "SUCCESS": "Playlist wurde erfolgreich entfernt" + }, + "GLOBAL_FAVORITES": "Globale Favoriten", + "GLOBAL_FAVORITES_DESCRIPTION": "Automatisch generierte Playlist mit aggregierten Favoriten aus allen Playlists", + "MY_PLAYLISTS": "Meine Playlisten", + "MY_PLAYLISTS_SUBTITLE": "Alle verfügbaren Playlisten", + "PLAYLIST_UPDATE_SUCCESS": "Erfolg! Die Playlist wurde erfolgreich aktualisiert.", + "PLAYLIST_UPDATE_ERROR": "Fehler beim Aktualisieren der Playlist-Details.", + "AUTO_REFRESH_UPDATE_SUCCESS": "Erfolg! Die Playlists wurden erfolgreich aktualisiert", + "AUTO_REFRESH_ENABLED": "Auto-Refresh aktiviert", + "STALKER_PORTAL": "Stalker-Portal", + "XTREAM_PLAYLIST": "Xtream-Playlist" + }, + "TABS": { + "FILE_UPLOAD": "Aus Dateisystem hochladen", + "RECENTLY_ADDED": "Zuletzt hinzugefügt", + "URL_UPLOAD": "Via URL hinzufügen", + "TEXT_IMPORT": "Als Text importieren", + "XTREME_IMPORT": "Xtreme-Code hinzufügen", + "STALKER_PORTAL_IMPORT": "Stalker-Portal hinzufügen" + }, + "URL_UPLOAD": { + "ADD_PLAYLIST": "Playlist hinzufügen", + "PLAYLIST_URL": "URL der Playlist", + "CORS_NOTE": "Hinweis: Um CORS-Probleme zu vermeiden, verwendet die Anwendung an dieser Stelle einen öffentlichen CORS-Proxy-Dienst. Wenn Sie eine Playlist mit sensiblen Daten hochladen möchten, ist es sicherer, sie aus einer Datei zu importieren." + }, + "TEXT_IMPORT": { + "LABEL": "Füge m3u(8)-Wiedergabeliste hier als Text ein", + "BUTTON_LABEL": "Importieren" + }, + "XTREAM_PLAYLIST": { + "TITLE": "Playlist Titel", + "SERVER_URL": "Server URL", + "USERNAME": "Nutzername", + "PASSWORD": "Passwort", + "ADD": "Hinzufügen", + "TEST_CREDENTIALS": "Verbindung testen", + "URL_VALIDATION_ERROR": "Sollte eine gültige URL mit Protokoll sein (z. B. http://example.com oder https://example.com:4324)" + }, + "STALKER_PORTAL": { + "ADD": "Hinzufügen", + "TITLE": "Titel", + "SERVER_URL": "Server-URL", + "MAC_ADDRESS": "Mac-Adresse", + "URL_VALIDATION_ERROR": "Sollte eine gültige URL mit Protokoll sein (z. B. http://example.com/c oder https://example.com/stalker_portal/c)" + }, + "FILTER_BY_TYPE": "Nach Typ filtern", + "FILTER_BY_NAME": "Nach Namen filtern", + "SORT_PLAYLISTS": "Playlisten sortieren", + "SORT_BY": "Sortieren nach", + "SORT_OPTIONS": { + "NAME_ASC": "Name (A-Z)", + "NAME_DESC": "Name (Z-A)", + "NEWEST": "Hinzugefügt (Neueste zuerst)", + "OLDEST": "Hinzugefügt (Älteste zuerst)" + }, + "IMPORTED_AS_TEXT": "Als Text importiert", + "PARSING_ERROR": "Fehler: Parsen fehlgeschlagen, keine gültige Playlist:" + }, + "LANGUAGES": { + "ARABIC": "العربية", + "MOROCCAN_ARABIC": "العربية (اللهجة المغربية)", + "ENGLISH": "English", + "KOREAN": "한국어", + "RUSSIAN": "Русский", + "GERMAN": "Deutsch", + "SPANISH": "Español", + "CHINESE": "简体中文", + "TRADITIONAL_CHINESE": "繁體中文", + "FRENCH": "Français", + "ITALIAN": "Italiano", + "TURKISH": "Türkçe", + "JAPANESE": "日本語", + "DUTCH": "Nederlands", + "BELARUSIAN": "Беларуская", + "POLISH": "Polski", + "PORTUGUESE": "Português" + }, + "SETTINGS": { + "BACK_TO_HOME": "Zurück", + "DESCRIPTION": "Bitte vergessen Sie nicht die Änderungen mit dem Speicher-Button zu bestätigen", + "EPG_URL_LABEL": "TV-Programm (EPG) URL", + "EPG_URL_PLACEHOLDER": "URL eingeben", + "LANGUAGE": "Sprache", + "LATEST_VERSION": "Sie nutzen die neueste Version der Anwendung", + "NEW_VERSION_AVAILABLE": "EIne neue Version der Anwendung ist verfügbar", + "REFRESH_EPG": "TV-Programm aktualisieren", + "SAVE_CHANGES": "Änderungen speichern", + "SUBTITLE": "Die App-Einstellungen ändern", + "TITLE": "Einstellungen / IPTVnator", + "VERSION": "Version", + "VIDEO_PLAYER_LABEL": "Video player", + "VIDEO_PLAYER_PLACEHOLDER": "Option auswählen", + "STREAM_FORMAT": "Stream-Format", + "SETTINGS_SAVED": "Einstellungen wurden erfolgreich gespeichert!", + "THEME": "Farbdesign", + "SHOW_CAPTIONS": "Untertitel anzeigen", + "STREAM_FORMAT_DESCRIPTION": "Standard-Streamformat auswählen (Xtream)", + "REMOVE_ALL": "Playlist entfernen", + "REMOVE_ALL_BUTTON": "Entfernen", + "REMOVE_DIALOG": { + "TITLE": "Аlle Wiedergabelisten löschen", + "MESSAGE": "Sind Sie sicher, dass Sie alle Wiedergabelisten aus der Anwendung löschen möchten?" + }, + "LANGUAGE_DESCRIPTION": "Wählen Sie die Sprache der Anwendung aus", + "PLAYLISTS_REMOVED": "Alle Playlists wurden entfernt.", + "VERSION_DESCRIPTION": "Aktuelle Version der Anwendung", + "REMOVE_ALL_DESCRIPTION": "Dadurch werden alle vorhandenen Wiedergabelisten aus der Anwendung entfernt.", + "MPV_PLAYER_PATH": "MPV-Player-Pfad", + "SHOW_CAPTIONS_DESCRIPTION": "Untertitel standardmäßig ein- oder ausblenden", + "IMPORT_DATA": "Import", + "ADD_EPG_SOURCE": "EPG-Quelle hinzufügen", + "IMPORT_ERROR": "Importfehler. Bitte versuchen Sie es mit einer anderen Datei.", + "EPG_URL_ERROR": "Der Wert ist keine gültige URL", + "EXPORT_DATA": "Export", + "VIDEO_PLAYER_DESCRIPTION": "Wählen Sie den Standard-Videoplayer aus", + "IMPORT_EXPORT_DATA": "Wiedergabelisten importieren/exportieren", + "VLC_PLAYER_PATH_LABEL": "VLC-Pfad", + "VLC_PLAYER_PATH_DESCRIPTION": "Legen Sie den Pfad zum VLC-Player in Ihrem System fest. \nEs wird der bereitgestellte Pfad zu einer VLC-Binärdatei anstelle des Standardpfads für unser System verwendet.", + "THEME_DESCRIPTION": "Wählen Sie das visuelle Thema der Anwendung aus", + "EPG_NOTE": "Derzeit ist EPG nur in der Electron-basierten Version von IPTVnator verfügbar.", + "VLC_PLAYER_PATH": "VLC-Playerpfad", + "IMPORT_EXPORT_DATA_DESCRIPTION": "Exportieren oder importieren Sie Wiedergabelisten im JSON-Format, das in der Anwendung verwendet wird.", + "EPG_NOTE_URL_TEXT": "Überprüfen Sie es hier", + "MPV_PLAYER_PATH_LABEL": "MPV-Pfad", + "MPV_PLAYER_PATH_DESCRIPTION": "Legen Sie den Pfad zum MPV-Player in Ihrem System fest. Es wird der bereitgestellte Pfad zu einer MPV-Binärdatei anstelle des in $PATH gefundenen verwendet.", + "MPV_REUSE_INSTANCE_LABEL": "MPV-Instanz wiederverwenden", + "MPV_REUSE_INSTANCE_DESCRIPTION": "Wenn aktiviert, wird dasselbe MPV-Fenster zum Abspielen verschiedener Streams wiederverwendet. Wenn deaktiviert, wird jeder Stream in einem neuen MPV-Fenster geöffnet.", + "REMOTE_CONTROL": "Fernsteuerung", + "REMOTE_CONTROL_DESCRIPTION": "Fernsteuerung der Anwendung aktivieren.", + "REMOTE_CONTROL_PORT": "Fernsteuerungsport", + "REMOTE_CONTROL_PORT_DESCRIPTION": "Port für die Fernsteuerung der Anwendung. Standardmäßig wird Port 8765 verwendet.", + "PORT_REQUIRED": "Port ist erforderlich", + "PORT_MIN": "Port muss mindestens 1 sein", + "PORT_MAX": "Port darf maximal 65535 sein", + "PORT_PATTERN": "Nur Ziffern sind erlaubt" + }, + "THEMES": { + "DARK_THEME": "Dunkel", + "LIGHT_THEME": "Hell" + }, + "TOP_MENU": { + "OPEN_CHANNELS_LIST": "Senderliste öffnen", + "OPEN_EPG_LIST": "TV-Guide (EPG) öffnen", + "TOGGLE_FAVORITE_FLAG": "Als Favoriten hinzufügen/entfernen", + "OPEN_MULTI_EPG": "Open Multi-EPG view" + }, + "NO": "Nein", + "YES": "Ja", + "MENU": { + "OPEN": "Menü öffnen", + "SUPPORT_ARIA": "IPTVnator auf GitHub unterstützen", + "SETTINGS": "Einstellungen", + "SETTINGS_ARIA": "Einstellungen öffnen", + "BUG_REPORT": "Fehler melden", + "WHAT_IS_NEW": "Was ist neu", + "ABOUT": "Über", + "ABOUT_ARIA": "Über die Anwendung", + "SUPPORT_PROJECT": "Projekt unterstützen" + }, + "XTREAM": { + "RELEASE_DATE": "Veröffentlichungsdatum", + "ACTORS": "Schauspieler", + "PLAY": "Abspielen", + "OPENING_PLAYER": "Player wird geöffnet...", + "GENRE": "Genre", + "DURATION": "Dauer", + "COUNTRY": "Land", + "NAME": "Name", + "DIRECTOR": "Regisseur", + "CAST": "Gießen", + "RATING": "Bewertung", + "AGE": "Alter", + "EPISODE": "Folge", + "YEAR": "Jahr", + "YOUTUBE_TRAILER": "YouTube-Trailer", + "DESCRIPTION": "Beschreibung", + "KINOPOISK_RATING": "Bewertung Kinopoisk", + "IMDB_RATING": "IMDB-Bewertung", + "EPISODE_RUN_TIME": "Laufzeit der Episode", + "ACCOUNT_INFO": { + "TITLE": "Kontoinformationen", + "USER_INFO": "Benutzerinformationen", + "STATUS": "Status", + "USERNAME": "Benutzername", + "ACTIVE_CONNECTIONS": "Aktive Verbindungen", + "CREATED": "Erstellt", + "EXPIRES": "Läuft ab", + "TRIAL_ACCOUNT": "Testkonto", + "YES": "Ja", + "NO": "Nein", + "ALLOWED_FORMATS": "Zulässige Formate", + "SERVER_INFO": "Serverinformationen", + "LIVE_TV": "Live-TV", + "MOVIES": "Filme", + "TV_SERIES": "Serien", + "URL": "URL", + "PROTOCOL": "Protokoll", + "TIMEZONE": "Zeitzone", + "SERVER_TIME": "Serverzeit", + "PORTS": "Ports", + "HTTP_PORT": "HTTP", + "HTTPS_PORT": "HTTPS", + "RTMP_PORT": "RTMP" + } + }, + "PORTALS": { + "SIDEBAR": { + "MAIN": "Allgemein", + "MOVIES": "Filme", + "LIVE_TV": "Live TV", + "SERIES": "Serien", + "LIBRARY": "Bibliothek", + "SEARCH": "Suche", + "RECENT": "Zuletzt angesehen", + "FAVORITES": "Favoriten", + "HOME": "Startseite", + "PLAYLIST_INFO": "Playlist-Info", + "ACCOUNT_INFO": "Account-Info" + }, + "ALL_CATEGORIES": "Alle Kategorien", + "ADD_TO_FAVORITES": "Zu den Favoriten hinzufügen", + "REMOVE_FROM_FAVORITES": "Von Favoriten entfernen", + "MY_FAVORITES": "Meine Favoriten", + "COPY_STREAM_URL": "Stream-URL kopieren", + "ERROR_VIEW": { + "DELETE": "Löschen", + "ACCOUNT_EXPIRED": { + "TITLE": "Konto abgelaufen", + "DESCRIPTION": "Dieses Konto ist abgelaufen. \nBitte überprüfen Sie die Anmeldeinformationen in den Playlist-Einstellungen oder entfernen Sie sie." + }, + "HOME": "Startseite", + "EMPTY_CATEGORY": { + "TITLE": "Leere Kategorie", + "DESCRIPTION": "Diese Kategorie hat keinen Inhalt. Bitte wählen Sie eine andere Kategorie aus oder ändern Sie den Inhaltstyp" + }, + "NO_CATEGORY_SELECTED": { + "TITLE": "Keine Kategorie ausgewählt", + "DESCRIPTION": "Bitte wählen Sie eine Kategorie aus, um den Inhalt anzuzeigen" + }, + "UNAUTHORIZED": { + "TITLE": "Nicht autorisiert", + "DESCRIPTION": "Bitte überprüfen Sie die Anmeldeinformationen in den Playlist-Einstellungen oder entfernen Sie sie." + }, + "NOT_FOUND": { + "TITLE": "Portal nicht gefunden", + "DESCRIPTION": "Bitte überprüfen Sie die Anmeldeinformationen und die Portal-URL in den Playlist-Einstellungen oder entfernen Sie sie." + }, + "UNKNOWN_ERROR": { + "TITLE": "Etwas ist schief gelaufen", + "DESCRIPTION": "Unbekannter Fehler, bitte versuchen Sie es später noch einmal" + }, + "PLAYLIST_SETTINGS": "Playlist-Einstellungen" + }, + "STREAM_URL_COPIED": "Stream-URL in die Zwischenablage kopiert", + "EMPTY_LIST_VIEW": { + "TITLE": "Keine Ergebnisse gefunden", + "NO_SEARCH_RESULTS": "Nichts gefunden. Versuchen Sie, Ihre Suchanfrage zu ändern" + }, + "REMOVED_FROM_FAVORITES": "Aus den Favoriten entfernt", + "ADDED_TO_FAVORITES": "Zu den Favoriten hinzugefügt", + "SELECT_CATEGORY": "Wählen Sie eine Kategorie aus", + "PAGE": "Seite" + }, + "ABOUT": { + "TITLE": "Über IPTVnator", + "DESCRIPTION": "Plattformübergreifende IPTV-Player-Anwendung mit mehreren Funktionen wie Unterstützung von M3U- und M3U8-Wiedergabelisten, Favoriten, TV-Guide, TV-Archiv/Catchup und mehr.", + "VERSION": "Version", + "TWITTER_TOOLTIP": "Auf Twitter teilen", + "GITHUB_TOOLTIP": "ITPVnator-Repository auf GitHub", + "BUY_ME_A_COFFEE_TOOLTIP": "Buy me a coffee" + }, + "REFRESH": "Aktualisieren", + "UPDATE_AVAILABLE": "Update verfügbar", + "INFORMATION": "Information", + "REMOTE_CONTROL": { + "TITLE": "Fernbedienung", + "UP_CHANNEL": "Sender aufwärts", + "DOWN_CHANNEL": "Sender abwärts", + "HEADER": "IPTVNator Fernbedienung", + "FOOTER": "IPTVNator Fernbedienung" } - }, - "HOME": { - "SUBTITLE": "Wählen Sie eine Playlist aus oder laden Sie eine neue Playlist hoch", - "TITLE": "IPTVnator", - "FILE_UPLOAD": { - "CHOOSE_PLAYLIST": "Playlist auswählen", - "DRAG_DROP": "Ziehen Sie Dateien per Drag & Drop", - "OR": "oder", - "REJECTED": "Datei wurde abgelehnt, nicht unterstütztes Dateiformat {{filename}}." - }, - "PLAYLISTS": { - "ADDED": "Hinzugefügt am", - "ADDED_VIA_FILE": "Diese Playlist wurde aus dem Dateisystem hochgeladen", - "ADDED_VIA_URL": "Diese Playlist wurde von der URL hinzugefügt", - "ADD_FIRST": "Bitte fügen Sie eine neue Playlist hinzu", - "CHANNELS": "TV-Sender", - "NO_PLAYLISTS": "Noch keine Playlisten hinzugefügt", - "REMOVE": "Playlist entfernen", - "SHOW_DETAILS": "Details anzeigen", - "SEARCH_PLAYLISTS": "Suche nach Playlisten", - "INFO_DIALOG": { - "CHANNELS": "Anzahl von Sender", - "CLOSE": "Schließen", - "FROM_URL": "Von der URL hinzugefügt", - "IMPORT_DATE": "Hinzugefügt am", - "PLAYLIST_DETAILS": "Infos über die Playlist", - "TITLE": "Titel", - "USER_AGENT": "User agent", - "SAVE": "Speichern", - "ORIGINAL_FILENAME": "Original-Dateinamen", - "FILE_PATH": "Dateipfad", - "UPDATE_FAILED": "Update fehlgeschlagen", - "AUTO_UPDATE": "Auto-update", - "AUTO_UPDATE_DESCRIPTION": "Wenn die Auto-Refresh-Funktion aktiviert ist, wird die Wiedergabeliste bei jedem Start der Anwendung automatisch aktualisiert.", - "CUSTOM_USER_AGENT": "Einige IPTV-Anbieter benötigen einen bestimmten 'User-Agent', um ihre Playlist abspielen zu können.", - "EXPORT_PLAYLIST": "Als m3u exportieren", - "PASSWORD": "Passwort", - "SERVER_URL": "Server-URL", - "USERNAME": "Nutzername", - "MAC_ADDRESS": "MAC-Adresse", - "PLAYLIST_EXPORT_SUCCESS": "Playlist wurde erfolgreich exportiert.", - "PLAYLIST_EXPORT_ERROR": "Fehler: Playlist-Export fehlgeschlagen." - }, - "UPDATED": "Aktualisiert am", - "REFRESH": "Aktualisieren", - "REFRESH_XTREAM": "Xtream-Playlist vom Server aktualisieren", - "REFRESH_XTREAM_DIALOG": { - "TITLE": "Xtream-Playlist aktualisieren", - "MESSAGE": "Dadurch werden lokal zwischengespeicherte Kategorien und Streams gelöscht und alles vom Remote-Server erneut importiert. Ihre Favoriten und kürzlich angesehenen Elemente bleiben erhalten. Möchten Sie wirklich fortfahren?", - "STARTED": "Aktualisierung gestartet. Navigation zur Playlist...", - "ERROR": "Playlist-Aktualisierung fehlgeschlagen. Bitte versuchen Sie es erneut." - }, - "REMOVE_DIALOG": { - "MESSAGE": "Sind Sie sicher, dass Sie diese Wiedergabeliste vollständig löschen möchten", - "TITLE": "Wiedergabeliste entfernen", - "SUCCESS": "Playlist wurde erfolgreich entfernt" - }, - "GLOBAL_FAVORITES": "Globale Favoriten", - "GLOBAL_FAVORITES_DESCRIPTION": "Automatisch generierte Playlist mit aggregierten Favoriten aus allen Playlists", - "MY_PLAYLISTS": "Meine Playlisten", - "MY_PLAYLISTS_SUBTITLE": "Alle verfügbaren Playlisten", - "PLAYLIST_UPDATE_SUCCESS": "Erfolg! Die Playlist wurde erfolgreich aktualisiert.", - "PLAYLIST_UPDATE_ERROR": "Fehler beim Aktualisieren der Playlist-Details.", - "AUTO_REFRESH_UPDATE_SUCCESS": "Erfolg! Die Playlists wurden erfolgreich aktualisiert", - "AUTO_REFRESH_ENABLED": "Auto-Refresh aktiviert", - "STALKER_PORTAL": "Stalker-Portal", - "XTREAM_PLAYLIST": "Xtream-Playlist" - }, - "TABS": { - "FILE_UPLOAD": "Aus Dateisystem hochladen", - "RECENTLY_ADDED": "Zuletzt hinzugefügt", - "URL_UPLOAD": "Via URL hinzufügen", - "TEXT_IMPORT": "Als Text importieren", - "XTREME_IMPORT": "Xtreme-Code hinzufügen", - "STALKER_PORTAL_IMPORT": "Stalker-Portal hinzufügen" - }, - "URL_UPLOAD": { - "ADD_PLAYLIST": "Playlist hinzufügen", - "PLAYLIST_URL": "URL der Playlist", - "CORS_NOTE": "Hinweis: Um CORS-Probleme zu vermeiden, verwendet die Anwendung an dieser Stelle einen öffentlichen CORS-Proxy-Dienst. Wenn Sie eine Playlist mit sensiblen Daten hochladen möchten, ist es sicherer, sie aus einer Datei zu importieren." - }, - "TEXT_IMPORT": { - "LABEL": "Füge m3u(8)-Wiedergabeliste hier als Text ein", - "BUTTON_LABEL": "Importieren" - }, - "XTREAM_PLAYLIST": { - "TITLE": "Playlist Titel", - "SERVER_URL": "Server URL", - "USERNAME": "Nutzername", - "PASSWORD": "Passwort", - "ADD": "Hinzufügen", - "TEST_CREDENTIALS": "Verbindung testen", - "URL_VALIDATION_ERROR": "Sollte eine gültige URL mit Protokoll sein (z. B. http://example.com oder https://example.com:4324)" - }, - "STALKER_PORTAL": { - "ADD": "Hinzufügen", - "TITLE": "Titel", - "SERVER_URL": "Server-URL", - "MAC_ADDRESS": "Mac-Adresse", - "URL_VALIDATION_ERROR": "Sollte eine gültige URL mit Protokoll sein (z. B. http://example.com/c oder https://example.com/stalker_portal/c)" - }, - "FILTER_BY_TYPE": "Nach Typ filtern", - "FILTER_BY_NAME": "Nach Namen filtern", - "SORT_PLAYLISTS": "Playlisten sortieren", - "SORT_BY": "Sortieren nach", - "SORT_OPTIONS": { - "NAME_ASC": "Name (A-Z)", - "NAME_DESC": "Name (Z-A)", - "NEWEST": "Hinzugefügt (Neueste zuerst)", - "OLDEST": "Hinzugefügt (Älteste zuerst)" - }, - "IMPORTED_AS_TEXT": "Als Text importiert", - "PARSING_ERROR": "Fehler: Parsen fehlgeschlagen, keine gültige Playlist:" - }, - "LANGUAGES": { - "ARABIC": "العربية", - "MOROCCAN_ARABIC": "العربية (اللهجة المغربية)", - "ENGLISH": "English", - "KOREAN": "한국어", - "RUSSIAN": "Русский", - "GERMAN": "Deutsch", - "SPANISH": "Español", - "CHINESE": "简体中文", - "TRADITIONAL_CHINESE": "繁體中文", - "FRENCH": "Français", - "ITALIAN": "Italiano", - "TURKISH": "Türkçe", - "JAPANESE": "日本語", - "DUTCH": "Nederlands", - "BELARUSIAN": "Беларуская", - "POLISH": "Polski", - "PORTUGUESE": "Português" - }, - "SETTINGS": { - "BACK_TO_HOME": "Zurück", - "DESCRIPTION": "Bitte vergessen Sie nicht die Änderungen mit dem Speicher-Button zu bestätigen", - "EPG_URL_LABEL": "TV-Programm (EPG) URL", - "EPG_URL_PLACEHOLDER": "URL eingeben", - "LANGUAGE": "Sprache", - "LATEST_VERSION": "Sie nutzen die neueste Version der Anwendung", - "NEW_VERSION_AVAILABLE": "EIne neue Version der Anwendung ist verfügbar", - "REFRESH_EPG": "TV-Programm aktualisieren", - "SAVE_CHANGES": "Änderungen speichern", - "SUBTITLE": "Die App-Einstellungen ändern", - "TITLE": "Einstellungen / IPTVnator", - "VERSION": "Version", - "VIDEO_PLAYER_LABEL": "Video player", - "VIDEO_PLAYER_PLACEHOLDER": "Option auswählen", - "STREAM_FORMAT": "Stream-Format", - "SETTINGS_SAVED": "Einstellungen wurden erfolgreich gespeichert!", - "THEME": "Farbdesign", - "SHOW_CAPTIONS": "Untertitel anzeigen", - "STREAM_FORMAT_DESCRIPTION": "Standard-Streamformat auswählen (Xtream)", - "REMOVE_ALL": "Playlist entfernen", - "REMOVE_ALL_BUTTON": "Entfernen", - "REMOVE_DIALOG": { - "TITLE": "Аlle Wiedergabelisten löschen", - "MESSAGE": "Sind Sie sicher, dass Sie alle Wiedergabelisten aus der Anwendung löschen möchten?" - }, - "LANGUAGE_DESCRIPTION": "Wählen Sie die Sprache der Anwendung aus", - "PLAYLISTS_REMOVED": "Alle Playlists wurden entfernt.", - "VERSION_DESCRIPTION": "Aktuelle Version der Anwendung", - "REMOVE_ALL_DESCRIPTION": "Dadurch werden alle vorhandenen Wiedergabelisten aus der Anwendung entfernt.", - "MPV_PLAYER_PATH": "MPV-Player-Pfad", - "SHOW_CAPTIONS_DESCRIPTION": "Untertitel standardmäßig ein- oder ausblenden", - "IMPORT_DATA": "Import", - "ADD_EPG_SOURCE": "EPG-Quelle hinzufügen", - "IMPORT_ERROR": "Importfehler. Bitte versuchen Sie es mit einer anderen Datei.", - "EPG_URL_ERROR": "Der Wert ist keine gültige URL", - "EXPORT_DATA": "Export", - "VIDEO_PLAYER_DESCRIPTION": "Wählen Sie den Standard-Videoplayer aus", - "IMPORT_EXPORT_DATA": "Wiedergabelisten importieren/exportieren", - "VLC_PLAYER_PATH_LABEL": "VLC-Pfad", - "VLC_PLAYER_PATH_DESCRIPTION": "Legen Sie den Pfad zum VLC-Player in Ihrem System fest. \nEs wird der bereitgestellte Pfad zu einer VLC-Binärdatei anstelle des Standardpfads für unser System verwendet.", - "THEME_DESCRIPTION": "Wählen Sie das visuelle Thema der Anwendung aus", - "EPG_NOTE": "Derzeit ist EPG nur in der Electron-basierten Version von IPTVnator verfügbar.", - "VLC_PLAYER_PATH": "VLC-Playerpfad", - "IMPORT_EXPORT_DATA_DESCRIPTION": "Exportieren oder importieren Sie Wiedergabelisten im JSON-Format, das in der Anwendung verwendet wird.", - "EPG_NOTE_URL_TEXT": "Überprüfen Sie es hier", - "MPV_PLAYER_PATH_LABEL": "MPV-Pfad", - "MPV_PLAYER_PATH_DESCRIPTION": "Legen Sie den Pfad zum MPV-Player in Ihrem System fest. Es wird der bereitgestellte Pfad zu einer MPV-Binärdatei anstelle des in $PATH gefundenen verwendet.", - "MPV_REUSE_INSTANCE_LABEL": "MPV-Instanz wiederverwenden", - "MPV_REUSE_INSTANCE_DESCRIPTION": "Wenn aktiviert, wird dasselbe MPV-Fenster zum Abspielen verschiedener Streams wiederverwendet. Wenn deaktiviert, wird jeder Stream in einem neuen MPV-Fenster geöffnet.", - "REMOTE_CONTROL": "Fernbedienung", - "REMOTE_CONTROL_DESCRIPTION": "Fernsteuerung der Anwendung aktivieren.", - "REMOTE_CONTROL_PORT": "Fernsteuerungs-Port", - "REMOTE_CONTROL_PORT_DESCRIPTION": "Port für die Fernsteuerung der Anwendung. Standardmäßig wird Port 3000 verwendet." - }, - "THEMES": { - "DARK_THEME": "Dunkel", - "LIGHT_THEME": "Hell" - }, - "TOP_MENU": { - "OPEN_CHANNELS_LIST": "Senderliste öffnen", - "OPEN_EPG_LIST": "TV-Guide (EPG) öffnen", - "TOGGLE_FAVORITE_FLAG": "Als Favoriten hinzufügen/entfernen", - "OPEN_MULTI_EPG": "Open Multi-EPG view" - }, - "NO": "Nein", - "YES": "Ja", - "MENU": { - "OPEN": "Menü öffnen", - "SUPPORT_ARIA": "IPTVnator auf GitHub unterstützen", - "SETTINGS": "Einstellungen", - "SETTINGS_ARIA": "Einstellungen öffnen", - "BUG_REPORT": "Fehler melden", - "WHAT_IS_NEW": "Was ist neu", - "ABOUT": "Über", - "ABOUT_ARIA": "Über die Anwendung", - "SUPPORT_PROJECT": "Projekt unterstützen" - }, - "XTREAM": { - "RELEASE_DATE": "Veröffentlichungsdatum", - "ACTORS": "Schauspieler", - "PLAY": "Abspielen", - "OPENING_PLAYER": "Player wird geöffnet...", - "GENRE": "Genre", - "DURATION": "Dauer", - "COUNTRY": "Land", - "NAME": "Name", - "DIRECTOR": "Regisseur", - "CAST": "Gießen", - "RATING": "Bewertung", - "AGE": "Alter", - "EPISODE": "Folge", - "YEAR": "Jahr", - "YOUTUBE_TRAILER": "YouTube-Trailer", - "DESCRIPTION": "Beschreibung", - "KINOPOISK_RATING": "Bewertung Kinopoisk", - "IMDB_RATING": "IMDB-Bewertung", - "EPISODE_RUN_TIME": "Laufzeit der Episode", - "ACCOUNT_INFO": { - "TITLE": "Kontoinformationen", - "USER_INFO": "Benutzerinformationen", - "STATUS": "Status", - "USERNAME": "Benutzername", - "ACTIVE_CONNECTIONS": "Aktive Verbindungen", - "CREATED": "Erstellt", - "EXPIRES": "Läuft ab", - "TRIAL_ACCOUNT": "Testkonto", - "YES": "Ja", - "NO": "Nein", - "ALLOWED_FORMATS": "Zulässige Formate", - "SERVER_INFO": "Serverinformationen", - "LIVE_TV": "Live-TV", - "MOVIES": "Filme", - "TV_SERIES": "Serien", - "URL": "URL", - "PROTOCOL": "Protokoll", - "TIMEZONE": "Zeitzone", - "SERVER_TIME": "Serverzeit", - "PORTS": "Ports", - "HTTP_PORT": "HTTP", - "HTTPS_PORT": "HTTPS", - "RTMP_PORT": "RTMP" - } - }, - "PORTALS": { - "SIDEBAR": { - "MAIN": "Allgemein", - "MOVIES": "Filme", - "LIVE_TV": "Live TV", - "SERIES": "Serien", - "LIBRARY": "Bibliothek", - "SEARCH": "Suche", - "RECENT": "Zuletzt angesehen", - "FAVORITES": "Favoriten", - "HOME": "Startseite", - "PLAYLIST_INFO": "Playlist-Info", - "ACCOUNT_INFO": "Account-Info" - }, - "ALL_CATEGORIES": "Alle Kategorien", - "ADD_TO_FAVORITES": "Zu den Favoriten hinzufügen", - "REMOVE_FROM_FAVORITES": "Von Favoriten entfernen", - "MY_FAVORITES": "Meine Favoriten", - "COPY_STREAM_URL": "Stream-URL kopieren", - "ERROR_VIEW": { - "DELETE": "Löschen", - "ACCOUNT_EXPIRED": { - "TITLE": "Konto abgelaufen", - "DESCRIPTION": "Dieses Konto ist abgelaufen. \nBitte überprüfen Sie die Anmeldeinformationen in den Playlist-Einstellungen oder entfernen Sie sie." - }, - "HOME": "Startseite", - "EMPTY_CATEGORY": { - "TITLE": "Leere Kategorie", - "DESCRIPTION": "Diese Kategorie hat keinen Inhalt. Bitte wählen Sie eine andere Kategorie aus oder ändern Sie den Inhaltstyp" - }, - "NO_CATEGORY_SELECTED": { - "TITLE": "Keine Kategorie ausgewählt", - "DESCRIPTION": "Bitte wählen Sie eine Kategorie aus, um den Inhalt anzuzeigen" - }, - "UNAUTHORIZED": { - "TITLE": "Nicht autorisiert", - "DESCRIPTION": "Bitte überprüfen Sie die Anmeldeinformationen in den Playlist-Einstellungen oder entfernen Sie sie." - }, - "NOT_FOUND": { - "TITLE": "Portal nicht gefunden", - "DESCRIPTION": "Bitte überprüfen Sie die Anmeldeinformationen und die Portal-URL in den Playlist-Einstellungen oder entfernen Sie sie." - }, - "UNKNOWN_ERROR": { - "TITLE": "Etwas ist schief gelaufen", - "DESCRIPTION": "Unbekannter Fehler, bitte versuchen Sie es später noch einmal" - }, - "PLAYLIST_SETTINGS": "Playlist-Einstellungen" - }, - "STREAM_URL_COPIED": "Stream-URL in die Zwischenablage kopiert", - "EMPTY_LIST_VIEW": { - "TITLE": "Keine Ergebnisse gefunden", - "NO_SEARCH_RESULTS": "Nichts gefunden. Versuchen Sie, Ihre Suchanfrage zu ändern" - }, - "REMOVED_FROM_FAVORITES": "Aus den Favoriten entfernt", - "ADDED_TO_FAVORITES": "Zu den Favoriten hinzugefügt", - "SELECT_CATEGORY": "Wählen Sie eine Kategorie aus", - "PAGE": "Seite" - }, - "ABOUT": { - "TITLE": "Über IPTVnator", - "DESCRIPTION": "Plattformübergreifende IPTV-Player-Anwendung mit mehreren Funktionen wie Unterstützung von M3U- und M3U8-Wiedergabelisten, Favoriten, TV-Guide, TV-Archiv/Catchup und mehr.", - "VERSION": "Version", - "TWITTER_TOOLTIP": "Auf Twitter teilen", - "GITHUB_TOOLTIP": "ITPVnator-Repository auf GitHub", - "BUY_ME_A_COFFEE_TOOLTIP": "Buy me a coffee" - }, - "REFRESH": "Aktualisieren", - "UPDATE_AVAILABLE": "Update verfügbar", - "INFORMATION": "Information", - "REMOTE_CONTROL": { - "TITLE": "Fernbedienung", - "UP_CHANNEL": "Sender aufwärts", - "DOWN_CHANNEL": "Sender abwärts", - "HEADER": "IPTVNator Fernbedienung", - "FOOTER": "IPTVNator Fernbedienung" - } -} \ No newline at end of file +} diff --git a/apps/web/src/assets/i18n/en.json b/apps/web/src/assets/i18n/en.json index b587a74a5..e50568ceb 100644 --- a/apps/web/src/assets/i18n/en.json +++ b/apps/web/src/assets/i18n/en.json @@ -167,7 +167,11 @@ "REMOTE_CONTROL": "Remote control", "REMOTE_CONTROL_DESCRIPTION": "Enable remote control of the application.", "REMOTE_CONTROL_PORT": "Remote control port", - "REMOTE_CONTROL_PORT_DESCRIPTION": "Port used for remote control of the application. Port 3000 is used by default." + "REMOTE_CONTROL_PORT_DESCRIPTION": "Port used for remote control of the application. Port 8765 is used by default.", + "PORT_REQUIRED": "Port is required", + "PORT_MIN": "Port must be at least 1", + "PORT_MAX": "Port must be at most 65535", + "PORT_PATTERN": "Only digits are allowed" }, "THEMES": { "DARK_THEME": "Dark theme", @@ -369,8 +373,9 @@ "INFORMATION": "Information", "REMOTE_CONTROL": { "TITLE": "Remote control", - "UP_CHANNEL": "Up channel", - "DOWN_CHANNEL": "Down channel", + "SUBTITLE": "Control your IPTV player remotely", + "UP_CHANNEL": "Channel Up", + "DOWN_CHANNEL": "Channel Down", "HEADER": "IPTVNator Remote Control", "FOOTER": "IPTVNator Remote Control" } diff --git a/apps/web/src/assets/i18n/ru.json b/apps/web/src/assets/i18n/ru.json index a8a8a8a14..453258db7 100644 --- a/apps/web/src/assets/i18n/ru.json +++ b/apps/web/src/assets/i18n/ru.json @@ -1,377 +1,381 @@ { - "CLOSE": "Закрыть", - "PLAYLISTS": "Плейлисты", - "HOME": { - "TITLE": "IPTVnator", - "SUBTITLE": "Выберите плейлист из списка, добавьте новый с компьютера или загрузите по ссылке", - "TABS": { - "RECENTLY_ADDED": "Недавно добавленные плейлисты", - "FILE_UPLOAD": "Загрузить с компьютера", - "URL_UPLOAD": "Загрузить по ссылке", - "TEXT_IMPORT": "Импортировать из текста", - "STALKER_PORTAL_IMPORT": "Добавить Stalker портал", - "XTREME_IMPORT": "Добавить Xtreme Code " + "CLOSE": "Закрыть", + "PLAYLISTS": "Плейлисты", + "HOME": { + "TITLE": "IPTVnator", + "SUBTITLE": "Выберите плейлист из списка, добавьте новый с компьютера или загрузите по ссылке", + "TABS": { + "RECENTLY_ADDED": "Недавно добавленные плейлисты", + "FILE_UPLOAD": "Загрузить с компьютера", + "URL_UPLOAD": "Загрузить по ссылке", + "TEXT_IMPORT": "Импортировать из текста", + "STALKER_PORTAL_IMPORT": "Добавить Stalker портал", + "XTREME_IMPORT": "Добавить Xtreme Code " + }, + "PLAYLISTS": { + "NO_PLAYLISTS": "Пока еще не было добавлено ни одного плейлиста", + "ADD_FIRST": "Вы можете добавить плейлисты из соседних вкладок", + "ADDED_VIA_URL": "Был добавлен по ссылке:", + "ADDED_VIA_FILE": "Был загружен с компьютера", + "CHANNELS": "Каналов", + "ADDED": "Добавлен", + "SHOW_DETAILS": "Показать информацию о плейлисте", + "REMOVE": "Удалить плейлист", + "SEARCH_PLAYLISTS": "Поиск по названию плейлиста", + "INFO_DIALOG": { + "PLAYLIST_DETAILS": "Подробная информация о плейлисте", + "TITLE": "Название", + "FROM_URL": "Добавлен по ссылке", + "IMPORT_DATE": "Дата импорта", + "CHANNELS": "Количество каналов", + "CLOSE": "Закрыть", + "SAVE": "Сохранить", + "USER_AGENT": "User agent", + "ORIGINAL_FILENAME": "Имя файла при загрузке", + "FILE_PATH": "Путь к файлу", + "UPDATE_FAILED": "Не удалось обновить плейлист", + "AUTO_UPDATE": "Автообновление", + "AUTO_UPDATE_DESCRIPTION": "Если активирована функция автообновления, список воспроизведения будет обновляться автоматически при каждом запуске приложения.", + "CUSTOM_USER_AGENT": "Некоторые IPTV провайдеры требуют указание определенного пользовательского агента (User Agent) для воспроизведения своего плейлиста.", + "EXPORT_PLAYLIST": "Экспортировать плейлист (m3u)", + "PASSWORD": "Пароль", + "SERVER_URL": "URL-адрес сервера", + "USERNAME": "Имя пользователя", + "MAC_ADDRESS": "MAC-адрес", + "PLAYLIST_EXPORT_SUCCESS": "Плейлист был успешно экспортирован.", + "PLAYLIST_EXPORT_ERROR": "Ошибка: не удалось экспортировать плейлист." + }, + "UPDATED": "Обновлен", + "REFRESH": "Обновить плейлист", + "REFRESH_XTREAM": "Обновить Xtream плейлист с сервера", + "REFRESH_XTREAM_DIALOG": { + "TITLE": "Обновить Xtream плейлист", + "MESSAGE": "Это удалит локально кэшированные категории и потоки и повторно импортирует все с удаленного сервера. Ваши избранные и недавно просмотренные элементы будут сохранены. Вы уверены, что хотите продолжить?", + "STARTED": "Обновление начато. Переход к плейлисту...", + "ERROR": "Не удалось обновить плейлист. Пожалуйста, попробуйте снова." + }, + "REMOVE_DIALOG": { + "MESSAGE": "Вы уверены, что хотите удалить этот плейлист?", + "TITLE": "Удалить плейлист", + "SUCCESS": "Плейлист был успешно удален" + }, + "GLOBAL_FAVORITES": "Все фавориты", + "GLOBAL_FAVORITES_DESCRIPTION": "Автоматически сгенерированный плейлист, включающий в себя фавориты из всех плейлистов", + "MY_PLAYLISTS": "Мои плейлисты", + "MY_PLAYLISTS_SUBTITLE": "Все имеющиеся плейлисты", + "PLAYLIST_UPDATE_SUCCESS": "Плейлист был успешно обновлен!", + "PLAYLIST_UPDATE_ERROR": "Ошибка при обновлении деталей плейлиста.", + "AUTO_REFRESH_UPDATE_SUCCESS": "Успешно! Плейлисты были успешно обновлены", + "AUTO_REFRESH_ENABLED": "Автообновление включено", + "STALKER_PORTAL": "Stalker портал", + "XTREAM_PLAYLIST": "Xtream плейлист" + }, + "FILE_UPLOAD": { + "DRAG_DROP": "Перетащите файл в эту область", + "OR": "или", + "CHOOSE_PLAYLIST": "выберите плейлист", + "REJECTED": "К сожалению, данный формат файлов не поддерживается {{filename}}." + }, + "URL_UPLOAD": { + "PLAYLIST_URL": "Ссылка на плейлист (*.m3u или *.m3u8)", + "ADD_PLAYLIST": "Добавить плейлист", + "CORS_NOTE": "Примечание: Чтобы избежать проблем с CORS, приложение использует публичный CORS-прокси сервис. Если вы хотите загрузить плейлист с конфиденциальными данными, безопаснее импортировать его из файла." + }, + "TEXT_IMPORT": { + "LABEL": "Вставьте плейлист формата m3u(8) в данное поле", + "BUTTON_LABEL": "Импортировать" + }, + "XTREAM_PLAYLIST": { + "TITLE": "Название плейлиста", + "SERVER_URL": "Сервер URL", + "USERNAME": "Пользователь", + "PASSWORD": "Пароль", + "ADD": "Добавить", + "TEST_CREDENTIALS": "Проверить", + "URL_VALIDATION_ERROR": "Должен быть действительный URL-адрес с протоколом (например, http://example.com или https://example.com:4324)." + }, + "STALKER_PORTAL": { + "ADD": "Добавить", + "TITLE": "Название", + "MAC_ADDRESS": "MAC-адрес", + "SERVER_URL": "URL сервера", + "URL_VALIDATION_ERROR": "Должен быть действительный URL-адрес с протоколом (например, http://example.com/c или https://example.com/stalker_portal/c)." + }, + "FILTER_BY_TYPE": "Фильтровать по типу", + "FILTER_BY_NAME": "Фильтровать по имени", + "SORT_PLAYLISTS": "Сортировать плейлисты", + "SORT_BY": "Сортировать по", + "SORT_OPTIONS": { + "NAME_ASC": "Имени (А-Я)", + "NAME_DESC": "Имени (Я-А)", + "NEWEST": "Дате добавления (сначала новые)", + "OLDEST": "Дате добавления (сначала старые)" + }, + "IMPORTED_AS_TEXT": "Импортировано как текст", + "PARSING_ERROR": "Ошибка парсинга, недействительный плейлист:" }, - "PLAYLISTS": { - "NO_PLAYLISTS": "Пока еще не было добавлено ни одного плейлиста", - "ADD_FIRST": "Вы можете добавить плейлисты из соседних вкладок", - "ADDED_VIA_URL": "Был добавлен по ссылке:", - "ADDED_VIA_FILE": "Был загружен с компьютера", - "CHANNELS": "Каналов", - "ADDED": "Добавлен", - "SHOW_DETAILS": "Показать информацию о плейлисте", - "REMOVE": "Удалить плейлист", - "SEARCH_PLAYLISTS": "Поиск по названию плейлиста", - "INFO_DIALOG": { - "PLAYLIST_DETAILS": "Подробная информация о плейлисте", - "TITLE": "Название", - "FROM_URL": "Добавлен по ссылке", - "IMPORT_DATE": "Дата импорта", - "CHANNELS": "Количество каналов", - "CLOSE": "Закрыть", - "SAVE": "Сохранить", - "USER_AGENT": "User agent", - "ORIGINAL_FILENAME": "Имя файла при загрузке", - "FILE_PATH": "Путь к файлу", - "UPDATE_FAILED": "Не удалось обновить плейлист", - "AUTO_UPDATE": "Автообновление", - "AUTO_UPDATE_DESCRIPTION": "Если активирована функция автообновления, список воспроизведения будет обновляться автоматически при каждом запуске приложения.", - "CUSTOM_USER_AGENT": "Некоторые IPTV провайдеры требуют указание определенного пользовательского агента (User Agent) для воспроизведения своего плейлиста.", - "EXPORT_PLAYLIST": "Экспортировать плейлист (m3u)", - "PASSWORD": "Пароль", - "SERVER_URL": "URL-адрес сервера", - "USERNAME": "Имя пользователя", - "MAC_ADDRESS": "MAC-адрес", - "PLAYLIST_EXPORT_SUCCESS": "Плейлист был успешно экспортирован.", - "PLAYLIST_EXPORT_ERROR": "Ошибка: не удалось экспортировать плейлист." - }, - "UPDATED": "Обновлен", - "REFRESH": "Обновить плейлист", - "REFRESH_XTREAM": "Обновить Xtream плейлист с сервера", - "REFRESH_XTREAM_DIALOG": { - "TITLE": "Обновить Xtream плейлист", - "MESSAGE": "Это удалит локально кэшированные категории и потоки и повторно импортирует все с удаленного сервера. Ваши избранные и недавно просмотренные элементы будут сохранены. Вы уверены, что хотите продолжить?", - "STARTED": "Обновление начато. Переход к плейлисту...", - "ERROR": "Не удалось обновить плейлист. Пожалуйста, попробуйте снова." - }, - "REMOVE_DIALOG": { - "MESSAGE": "Вы уверены, что хотите удалить этот плейлист?", - "TITLE": "Удалить плейлист", - "SUCCESS": "Плейлист был успешно удален" - }, - "GLOBAL_FAVORITES": "Все фавориты", - "GLOBAL_FAVORITES_DESCRIPTION": "Автоматически сгенерированный плейлист, включающий в себя фавориты из всех плейлистов", - "MY_PLAYLISTS": "Мои плейлисты", - "MY_PLAYLISTS_SUBTITLE": "Все имеющиеся плейлисты", - "PLAYLIST_UPDATE_SUCCESS": "Плейлист был успешно обновлен!", - "PLAYLIST_UPDATE_ERROR": "Ошибка при обновлении деталей плейлиста.", - "AUTO_REFRESH_UPDATE_SUCCESS": "Успешно! Плейлисты были успешно обновлены", - "AUTO_REFRESH_ENABLED": "Автообновление включено", - "STALKER_PORTAL": "Stalker портал", - "XTREAM_PLAYLIST": "Xtream плейлист" + "SETTINGS": { + "TITLE": "Настройки / IPTVnator", + "SUBTITLE": "Здесь можно изменить общие настройки приложения", + "DESCRIPTION": "Закончив настройку, не забудьте подтвердить новые настройки кнопкой 'Сохранить'", + "EPG_URL_LABEL": "Ссылка на программу каналов (EPG) (*.xml или *.xml.gz)", + "EPG_URL_PLACEHOLDER": "Введите ссылку", + "REFRESH_EPG": "Обновить программу каналов (EPG)", + "VIDEO_PLAYER_LABEL": "Видеоплеер", + "VIDEO_PLAYER_PLACEHOLDER": "Выберите из списка", + "VERSION": "Версия приложения", + "SAVE_CHANGES": "Сохранить", + "BACK_TO_HOME": "Назад", + "NEW_VERSION_AVAILABLE": "Доступна новая версия приложения", + "LATEST_VERSION": "Вы используете последнюю версию приложения", + "STREAM_FORMAT": "Формат потока", + "LANGUAGE": "Язык", + "SETTINGS_SAVED": "Новые настройки были успешно сохранены!", + "THEME": "Тема оформления", + "SHOW_CAPTIONS": "Отображать субтитры", + "ADD_EPG_SOURCE": "Добавить источник EPG", + "EPG_URL_ERROR": "Введенное значение не является URL-адресом.", + "VIDEO_PLAYER_DESCRIPTION": "Выбрать стандартный видео плеер", + "STREAM_FORMAT_DESCRIPTION": "Выбрать формат потока по умолчанию (Xtream)", + "LANGUAGE_DESCRIPTION": "Выбрать язык приложения", + "THEME_DESCRIPTION": "Выбрать визуальную тему оформления", + "SHOW_CAPTIONS_DESCRIPTION": "Показать или скрыть субтитры по умолчанию.", + "VERSION_DESCRIPTION": "Текущая версия приложения", + "EPG_NOTE": "На данный момент EPG доступен только в нативной версии IPTVnator.", + "EPG_NOTE_URL_TEXT": "Подробнее здесь", + "IMPORT_EXPORT_DATA": "Импорт/экспорт плейлистов", + "IMPORT_EXPORT_DATA_DESCRIPTION": "Экспорт или импорт плейлистов в формате JSON, используемом в приложении.", + "IMPORT_DATA": "Импорт", + "EXPORT_DATA": "Экспорт", + "REMOVE_ALL": "Удалить плейлист", + "REMOVE_ALL_BUTTON": "Удалить", + "REMOVE_ALL_DESCRIPTION": " Это действие удалит все существующие плейлисты из приложения.", + "REMOVE_DIALOG": { + "TITLE": "Удалить все плейлисты", + "MESSAGE": "Вы уверены что хотите удалить все плейлисты из приложения?" + }, + "IMPORT_ERROR": "Ошибка импорта, пожалуйста попробуйте импортировать другой файл.", + "PLAYLISTS_REMOVED": "Все плейлисты были удалены.", + "VLC_PLAYER_PATH_LABEL": "Путь к VLC плееру", + "VLC_PLAYER_PATH": "Путь к VLC плееру", + "VLC_PLAYER_PATH_DESCRIPTION": "Установите путь к проигрывателю VLC в вашей системе. \nОн будет использовать предоставленный путь к binary файлу VLC вместо пути по умолчанию.", + "MPV_PLAYER_PATH_LABEL": "Путь к mpv плееру", + "MPV_PLAYER_PATH": "Путь к mpv плееру", + "MPV_PLAYER_PATH_DESCRIPTION": "Установите путь к проигрывателю MPV в вашей системе. Он будет использовать предоставленный путь к бинарному файлу MPV вместо найденного в $PATH.", + "MPV_REUSE_INSTANCE_LABEL": "Переиспользовать экземпляр MPV", + "MPV_REUSE_INSTANCE_DESCRIPTION": "Когда включено, одно и то же окно MPV будет использоваться для воспроизведения разных потоков. Когда отключено, каждый поток открывается в новом окне MPV.", + "REMOTE_CONTROL": "Удалённое управление", + "REMOTE_CONTROL_DESCRIPTION": "Включить удалённое управление приложением.", + "REMOTE_CONTROL_PORT": "Порт удалённого управления", + "REMOTE_CONTROL_PORT_DESCRIPTION": "Порт для удалённого управления приложением. По умолчанию используется порт 8765.", + "PORT_REQUIRED": "Порт обязателен", + "PORT_MIN": "Порт должен быть не менее 1", + "PORT_MAX": "Порт должен быть не более 65535", + "PORT_PATTERN": "Разрешены только цифры" }, - "FILE_UPLOAD": { - "DRAG_DROP": "Перетащите файл в эту область", - "OR": "или", - "CHOOSE_PLAYLIST": "выберите плейлист", - "REJECTED": "К сожалению, данный формат файлов не поддерживается {{filename}}." + "THEMES": { + "DARK_THEME": "Тёмная тема", + "LIGHT_THEME": "Светлая тема" }, - "URL_UPLOAD": { - "PLAYLIST_URL": "Ссылка на плейлист (*.m3u или *.m3u8)", - "ADD_PLAYLIST": "Добавить плейлист", - "CORS_NOTE": "Примечание: Чтобы избежать проблем с CORS, приложение использует публичный CORS-прокси сервис. Если вы хотите загрузить плейлист с конфиденциальными данными, безопаснее импортировать его из файла." + "CHANNELS": { + "SEARCH_CHANNEL": "Поиск каналов", + "UPLOAD_OR_SELECT_OTHER_PLAYLIST": "Загрузить или выбрать другой плейлист", + "ALL_CHANNELS": "Все каналы", + "GROUPS": "Группы", + "UNGROUPED": "Негруппированные", + "UNNAMED_CHANNEL": "Канал без названия", + "FAVORITES": "Избранные", + "NO_FAVORITES": "Избранных каналов пока нет", + "USE_STAR_TO_FAVORITE": "Используйте иконку звезды, чтобы добавлять каналы в избранное", + "REMOVE_FAVORITE": "Удалить из списка фаворитов", + "FAVORITES_UPDATED": "Список фаворитов был обновлен!", + "SORT_BY": "Сортировать по", + "SORT_BY_DATE": "Сортировать по дате добавления (сначала новые)", + "SORT_BY_RATING": "Сортировать по рейтингу", + "SORT_BY_NAME": "Сортировать по имени (А-Я)" }, - "TEXT_IMPORT": { - "LABEL": "Вставьте плейлист формата m3u(8) в данное поле", - "BUTTON_LABEL": "Импортировать" + "TOP_MENU": { + "OPEN_CHANNELS_LIST": "Открыть список каналов", + "TOGGLE_FAVORITE_FLAG": "Избранный", + "OPEN_EPG_LIST": "Открыть программу (EPG)", + "OPEN_MULTI_EPG": "Открыть Multi-EPG вид" }, - "XTREAM_PLAYLIST": { - "TITLE": "Название плейлиста", - "SERVER_URL": "Сервер URL", - "USERNAME": "Пользователь", - "PASSWORD": "Пароль", - "ADD": "Добавить", - "TEST_CREDENTIALS": "Проверить", - "URL_VALIDATION_ERROR": "Должен быть действительный URL-адрес с протоколом (например, http://example.com или https://example.com:4324)." + "EPG": { + "TITLE": "Электронный программный гид", + "NEXT_DAY": "Следующий день", + "PREVIOUS_DAY": "Предыдущий день", + "LIVE_NOW": "Сейчас", + "LIVE_STREAM": "Прямая трансляция", + "TIMESHIFT_AVAILABLE": "Доступна функция архива (timeshift). Нажмите на передачу для воспроизведения.", + "EPG_NOT_AVAILABLE_DATE": "Ой, программа на этот день, к сожалению, отсутствует.", + "EPG_NOT_AVAILABLE_CHANNEL_TITLE": "Программа для этого канала недоступна.", + "EPG_NOT_AVAILABLE_CHANNEL_DESCRIPTION": "Попробуйте изменить или обновить ссылку на EPG в настройках приложения.", + "FETCH_EPG": "Загружаю программу ТВ-передач (EPG)...", + "ERROR": "Ой, не удалось загрузить программу каналов (EPG)", + "DOWNLOAD_SUCCESS": "Программа каналов (EPG) была успешно загружена.", + "FETCH_SUCCESS": "Программа каналов (EPG) была успешно получена", + "PROGRAM_DIALOG": { + "PROGRAM_DETAILS": "Подробности передачи", + "TITLE": "Название", + "CATEGORY": "Категория", + "PARENTAL_RATING_SYSTEM": "Возрастное ограничение", + "DESCRIPTION": "Описание", + "LANGUAGE": "Язык", + "SHOW_PROGRAM_DETAILS": "Показать подробности" + } }, - "STALKER_PORTAL": { - "ADD": "Добавить", - "TITLE": "Название", - "MAC_ADDRESS": "MAC-адрес", - "SERVER_URL": "URL сервера", - "URL_VALIDATION_ERROR": "Должен быть действительный URL-адрес с протоколом (например, http://example.com/c или https://example.com/stalker_portal/c)." + "LANGUAGES": { + "ARABIC": "العربية", + "MOROCCAN_ARABIC": "العربية (اللهجة المغربية)", + "ENGLISH": "English", + "KOREAN": "한국어", + "RUSSIAN": "Русский", + "GERMAN": "Deutsch", + "SPANISH": "Español", + "CHINESE": "简体中文", + "TRADITIONAL_CHINESE": "繁體中文", + "FRENCH": "Français", + "ITALIAN": "Italiano", + "TURKISH": "Türkçe", + "JAPANESE": "日本語", + "DUTCH": "Nederlands", + "BELARUSIAN": "Беларуская", + "POLISH": "Polski", + "PORTUGUESE": "Português" }, - "FILTER_BY_TYPE": "Фильтровать по типу", - "FILTER_BY_NAME": "Фильтровать по имени", - "SORT_PLAYLISTS": "Сортировать плейлисты", - "SORT_BY": "Сортировать по", - "SORT_OPTIONS": { - "NAME_ASC": "Имени (А-Я)", - "NAME_DESC": "Имени (Я-А)", - "NEWEST": "Дате добавления (сначала новые)", - "OLDEST": "Дате добавления (сначала старые)" + "NO": "Нет", + "YES": "Да", + "MENU": { + "OPEN": "Открыть меню", + "SUPPORT_ARIA": "Поддержать IPTVnator на GitHub", + "SETTINGS": "Настройки", + "SETTINGS_ARIA": "Открыть настройки", + "BUG_REPORT": "Сообщить об ошибке", + "WHAT_IS_NEW": "Что нового?", + "ABOUT": "О приложении", + "ABOUT_ARIA": "О приложении", + "SUPPORT_PROJECT": "Поддержать проект" }, - "IMPORTED_AS_TEXT": "Импортировано как текст", - "PARSING_ERROR": "Ошибка парсинга, недействительный плейлист:" - }, - "SETTINGS": { - "TITLE": "Настройки / IPTVnator", - "SUBTITLE": "Здесь можно изменить общие настройки приложения", - "DESCRIPTION": "Закончив настройку, не забудьте подтвердить новые настройки кнопкой 'Сохранить'", - "EPG_URL_LABEL": "Ссылка на программу каналов (EPG) (*.xml или *.xml.gz)", - "EPG_URL_PLACEHOLDER": "Введите ссылку", - "REFRESH_EPG": "Обновить программу каналов (EPG)", - "VIDEO_PLAYER_LABEL": "Видеоплеер", - "VIDEO_PLAYER_PLACEHOLDER": "Выберите из списка", - "VERSION": "Версия приложения", - "SAVE_CHANGES": "Сохранить", - "BACK_TO_HOME": "Назад", - "NEW_VERSION_AVAILABLE": "Доступна новая версия приложения", - "LATEST_VERSION": "Вы используете последнюю версию приложения", - "STREAM_FORMAT": "Формат потока", - "LANGUAGE": "Язык", - "SETTINGS_SAVED": "Новые настройки были успешно сохранены!", - "THEME": "Тема оформления", - "SHOW_CAPTIONS": "Отображать субтитры", - "ADD_EPG_SOURCE": "Добавить источник EPG", - "EPG_URL_ERROR": "Введенное значение не является URL-адресом.", - "VIDEO_PLAYER_DESCRIPTION": "Выбрать стандартный видео плеер", - "STREAM_FORMAT_DESCRIPTION": "Выбрать формат потока по умолчанию (Xtream)", - "LANGUAGE_DESCRIPTION": "Выбрать язык приложения", - "THEME_DESCRIPTION": "Выбрать визуальную тему оформления", - "SHOW_CAPTIONS_DESCRIPTION": "Показать или скрыть субтитры по умолчанию.", - "VERSION_DESCRIPTION": "Текущая версия приложения", - "EPG_NOTE": "На данный момент EPG доступен только в нативной версии IPTVnator.", - "EPG_NOTE_URL_TEXT": "Подробнее здесь", - "IMPORT_EXPORT_DATA": "Импорт/экспорт плейлистов", - "IMPORT_EXPORT_DATA_DESCRIPTION": "Экспорт или импорт плейлистов в формате JSON, используемом в приложении.", - "IMPORT_DATA": "Импорт", - "EXPORT_DATA": "Экспорт", - "REMOVE_ALL": "Удалить плейлист", - "REMOVE_ALL_BUTTON": "Удалить", - "REMOVE_ALL_DESCRIPTION": " Это действие удалит все существующие плейлисты из приложения.", - "REMOVE_DIALOG": { - "TITLE": "Удалить все плейлисты", - "MESSAGE": "Вы уверены что хотите удалить все плейлисты из приложения?" + "ABOUT": { + "TITLE": "О приложении", + "DESCRIPTION": "Кроссплатформенное приложение IPTV-плеера с множеством функций, таких как поддержка плейлистов m3u и m3u8, избранное, телегид, ТВ-архив/catchup и многое другое.", + "VERSION": "Версия", + "GITHUB_TOOLTIP": "Репозиторий приложения на GitHub", + "BUY_ME_A_COFFEE_TOOLTIP": "Поддержать разработчика", + "TWITTER_TOOLTIP": "Поделиться на Twitter" }, - "IMPORT_ERROR": "Ошибка импорта, пожалуйста попробуйте импортировать другой файл.", - "PLAYLISTS_REMOVED": "Все плейлисты были удалены.", - "VLC_PLAYER_PATH_LABEL": "Путь к VLC плееру", - "VLC_PLAYER_PATH": "Путь к VLC плееру", - "VLC_PLAYER_PATH_DESCRIPTION": "Установите путь к проигрывателю VLC в вашей системе. \nОн будет использовать предоставленный путь к binary файлу VLC вместо пути по умолчанию.", - "MPV_PLAYER_PATH_LABEL": "Путь к mpv плееру", - "MPV_PLAYER_PATH": "Путь к mpv плееру", - "MPV_PLAYER_PATH_DESCRIPTION": "Установите путь к проигрывателю MPV в вашей системе. Он будет использовать предоставленный путь к бинарному файлу MPV вместо найденного в $PATH.", - "MPV_REUSE_INSTANCE_LABEL": "Переиспользовать экземпляр MPV", - "MPV_REUSE_INSTANCE_DESCRIPTION": "Когда включено, одно и то же окно MPV будет использоваться для воспроизведения разных потоков. Когда отключено, каждый поток открывается в новом окне MPV.", - "REMOTE_CONTROL": "Дистанционное управление", - "REMOTE_CONTROL_DESCRIPTION": "Включить дистанционное управление приложением.", - "REMOTE_CONTROL_PORT": "Порт дистанционного управления", - "REMOTE_CONTROL_PORT_DESCRIPTION": "Порт, используемый для дистанционного управления приложением. По умолчанию используется порт 3000." - }, - "THEMES": { - "DARK_THEME": "Тёмная тема", - "LIGHT_THEME": "Светлая тема" - }, - "CHANNELS": { - "SEARCH_CHANNEL": "Поиск каналов", - "UPLOAD_OR_SELECT_OTHER_PLAYLIST": "Загрузить или выбрать другой плейлист", - "ALL_CHANNELS": "Все каналы", - "GROUPS": "Группы", - "UNGROUPED": "Негруппированные", - "UNNAMED_CHANNEL": "Канал без названия", - "FAVORITES": "Избранные", - "NO_FAVORITES": "Избранных каналов пока нет", - "USE_STAR_TO_FAVORITE": "Используйте иконку звезды, чтобы добавлять каналы в избранное", - "REMOVE_FAVORITE": "Удалить из списка фаворитов", - "FAVORITES_UPDATED": "Список фаворитов был обновлен!", - "SORT_BY": "Сортировать по", - "SORT_BY_DATE": "Сортировать по дате добавления (сначала новые)", - "SORT_BY_RATING": "Сортировать по рейтингу", - "SORT_BY_NAME": "Сортировать по имени (А-Я)" - }, - "TOP_MENU": { - "OPEN_CHANNELS_LIST": "Открыть список каналов", - "TOGGLE_FAVORITE_FLAG": "Избранный", - "OPEN_EPG_LIST": "Открыть программу (EPG)", - "OPEN_MULTI_EPG": "Открыть Multi-EPG вид" - }, - "EPG": { - "TITLE": "Электронный программный гид", - "NEXT_DAY": "Следующий день", - "PREVIOUS_DAY": "Предыдущий день", - "LIVE_NOW": "Сейчас", - "LIVE_STREAM": "Прямая трансляция", - "TIMESHIFT_AVAILABLE": "Доступна функция архива (timeshift). Нажмите на передачу для воспроизведения.", - "EPG_NOT_AVAILABLE_DATE": "Ой, программа на этот день, к сожалению, отсутствует.", - "EPG_NOT_AVAILABLE_CHANNEL_TITLE": "Программа для этого канала недоступна.", - "EPG_NOT_AVAILABLE_CHANNEL_DESCRIPTION": "Попробуйте изменить или обновить ссылку на EPG в настройках приложения.", - "FETCH_EPG": "Загружаю программу ТВ-передач (EPG)...", - "ERROR": "Ой, не удалось загрузить программу каналов (EPG)", - "DOWNLOAD_SUCCESS": "Программа каналов (EPG) была успешно загружена.", - "FETCH_SUCCESS": "Программа каналов (EPG) была успешно получена", - "PROGRAM_DIALOG": { - "PROGRAM_DETAILS": "Подробности передачи", - "TITLE": "Название", - "CATEGORY": "Категория", - "PARENTAL_RATING_SYSTEM": "Возрастное ограничение", - "DESCRIPTION": "Описание", - "LANGUAGE": "Язык", - "SHOW_PROGRAM_DETAILS": "Показать подробности" + "UPDATE_AVAILABLE": "Доступно обновление", + "REFRESH": "Обновить", + "XTREAM": { + "RATING": "Рейтинг", + "RELEASE_DATE": "Дата выпуска", + "ACTORS": "Актеры", + "PLAY": "Воспроизвести", + "OPENING_PLAYER": "Открытие плеера...", + "GENRE": "Жанр", + "DURATION": "Продолжительность", + "COUNTRY": "Страна", + "NAME": "Название", + "DIRECTOR": "Режиссёр", + "CAST": "Бросать", + "EPISODE_RUN_TIME": "Длительность эпизода", + "AGE": "Возраст", + "EPISODE": "Эпизод", + "YEAR": "Год", + "YOUTUBE_TRAILER": "YouTube-трейлер", + "DESCRIPTION": "Описание", + "KINOPOISK_RATING": "Рейтинг на Кинопоиск", + "IMDB_RATING": "Рейтинг на IMDB", + "ACCOUNT_INFO": { + "TITLE": "Информация об аккаунте", + "USER_INFO": "Информация о пользователе", + "STATUS": "Статус", + "USERNAME": "Имя пользователя", + "ACTIVE_CONNECTIONS": "Активные подключения", + "CREATED": "Создано", + "EXPIRES": "Истекает", + "TRIAL_ACCOUNT": "Пробный аккаунт", + "YES": "Да", + "NO": "Нет", + "ALLOWED_FORMATS": "Разрешенные форматы", + "SERVER_INFO": "Информация о сервере", + "LIVE_TV": "Прямой эфир", + "MOVIES": "Фильмы", + "TV_SERIES": "Сериалы", + "URL": "URL", + "PROTOCOL": "Протокол", + "TIMEZONE": "Часовой пояс", + "SERVER_TIME": "Время сервера", + "PORTS": "Порты", + "HTTP_PORT": "HTTP", + "HTTPS_PORT": "HTTPS", + "RTMP_PORT": "RTMP" + } + }, + "PORTALS": { + "ALL_CATEGORIES": "Все категории", + "ADD_TO_FAVORITES": "Добавить в избранное", + "REMOVE_FROM_FAVORITES": "Удалить из избранного", + "MY_FAVORITES": "Избранное", + "COPY_STREAM_URL": "Копировать URL-адрес потока", + "ERROR_VIEW": { + "DELETE": "Удалить", + "ACCOUNT_EXPIRED": { + "TITLE": "Срок действия учетной записи истек", + "DESCRIPTION": "Срок действия этой учетной записи истек. \nПожалуйста, проверьте учетные данные в настройках плейлиста или удалите его." + }, + "HOME": "На главную", + "EMPTY_CATEGORY": { + "TITLE": "Пустая категория", + "DESCRIPTION": "В этой категории нет контента. Выберите другую категорию или измените тип контента." + }, + "UNAUTHORIZED": { + "TITLE": "Несанкционированный", + "DESCRIPTION": "Пожалуйста, проверьте учетные данные в настройках плейлиста или удалите их." + }, + "NOT_FOUND": { + "TITLE": "Портал не найден", + "DESCRIPTION": "Пожалуйста, проверьте учетные данные и URL портала в настройках плейлиста или удалите его." + }, + "NO_CATEGORY_SELECTED": { + "TITLE": "Категория не выбрана", + "DESCRIPTION": "Пожалуйста, выберите категорию для просмотра содержимого" + }, + "UNKNOWN_ERROR": { + "TITLE": "Что-то пошло не так", + "DESCRIPTION": "Неизвестная ошибка. Повторите попытку позже." + }, + "PLAYLIST_SETTINGS": "Настройки плейлиста" + }, + "STREAM_URL_COPIED": "URL-адрес потока был скопирован в буфер обмена.", + "EMPTY_LIST_VIEW": { + "TITLE": "Результаты не найдены", + "NO_SEARCH_RESULTS": "Ничего не найдено, попробуйте изменить поисковый запрос" + }, + "SIDEBAR": { + "MAIN": "Главная", + "MOVIES": "Фильмы", + "LIVE_TV": "Прямой эфир", + "SERIES": "Сериалы", + "LIBRARY": "Библиотека", + "SEARCH": "Поиск", + "RECENT": "Недавние", + "FAVORITES": "Избранное", + "HOME": "Домой", + "PLAYLIST_INFO": "Информация о плейлисте", + "ACCOUNT_INFO": "Информация об аккаунте" + }, + "REMOVED_FROM_FAVORITES": "Удалено из избранного", + "ADDED_TO_FAVORITES": "Добавлено в избранное", + "SELECT_CATEGORY": "Выберите категорию", + "PAGE": "Страница" + }, + "INFORMATION": "Инфо", + "REMOTE_CONTROL": { + "TITLE": "Дистанционное управление", + "UP_CHANNEL": "Канал вверх", + "DOWN_CHANNEL": "Канал вниз", + "HEADER": "IPTVNator Дистанционное управление", + "FOOTER": "IPTVNator Дистанционное управление" } - }, - "LANGUAGES": { - "ARABIC": "العربية", - "MOROCCAN_ARABIC": "العربية (اللهجة المغربية)", - "ENGLISH": "English", - "KOREAN": "한국어", - "RUSSIAN": "Русский", - "GERMAN": "Deutsch", - "SPANISH": "Español", - "CHINESE": "简体中文", - "TRADITIONAL_CHINESE": "繁體中文", - "FRENCH": "Français", - "ITALIAN": "Italiano", - "TURKISH": "Türkçe", - "JAPANESE": "日本語", - "DUTCH": "Nederlands", - "BELARUSIAN": "Беларуская", - "POLISH": "Polski", - "PORTUGUESE": "Português" - }, - "NO": "Нет", - "YES": "Да", - "MENU": { - "OPEN": "Открыть меню", - "SUPPORT_ARIA": "Поддержать IPTVnator на GitHub", - "SETTINGS": "Настройки", - "SETTINGS_ARIA": "Открыть настройки", - "BUG_REPORT": "Сообщить об ошибке", - "WHAT_IS_NEW": "Что нового?", - "ABOUT": "О приложении", - "ABOUT_ARIA": "О приложении", - "SUPPORT_PROJECT": "Поддержать проект" - }, - "ABOUT": { - "TITLE": "О приложении", - "DESCRIPTION": "Кроссплатформенное приложение IPTV-плеера с множеством функций, таких как поддержка плейлистов m3u и m3u8, избранное, телегид, ТВ-архив/catchup и многое другое.", - "VERSION": "Версия", - "GITHUB_TOOLTIP": "Репозиторий приложения на GitHub", - "BUY_ME_A_COFFEE_TOOLTIP": "Поддержать разработчика", - "TWITTER_TOOLTIP": "Поделиться на Twitter" - }, - "UPDATE_AVAILABLE": "Доступно обновление", - "REFRESH": "Обновить", - "XTREAM": { - "RATING": "Рейтинг", - "RELEASE_DATE": "Дата выпуска", - "ACTORS": "Актеры", - "PLAY": "Воспроизвести", - "OPENING_PLAYER": "Открытие плеера...", - "GENRE": "Жанр", - "DURATION": "Продолжительность", - "COUNTRY": "Страна", - "NAME": "Название", - "DIRECTOR": "Режиссёр", - "CAST": "Бросать", - "EPISODE_RUN_TIME": "Длительность эпизода", - "AGE": "Возраст", - "EPISODE": "Эпизод", - "YEAR": "Год", - "YOUTUBE_TRAILER": "YouTube-трейлер", - "DESCRIPTION": "Описание", - "KINOPOISK_RATING": "Рейтинг на Кинопоиск", - "IMDB_RATING": "Рейтинг на IMDB", - "ACCOUNT_INFO": { - "TITLE": "Информация об аккаунте", - "USER_INFO": "Информация о пользователе", - "STATUS": "Статус", - "USERNAME": "Имя пользователя", - "ACTIVE_CONNECTIONS": "Активные подключения", - "CREATED": "Создано", - "EXPIRES": "Истекает", - "TRIAL_ACCOUNT": "Пробный аккаунт", - "YES": "Да", - "NO": "Нет", - "ALLOWED_FORMATS": "Разрешенные форматы", - "SERVER_INFO": "Информация о сервере", - "LIVE_TV": "Прямой эфир", - "MOVIES": "Фильмы", - "TV_SERIES": "Сериалы", - "URL": "URL", - "PROTOCOL": "Протокол", - "TIMEZONE": "Часовой пояс", - "SERVER_TIME": "Время сервера", - "PORTS": "Порты", - "HTTP_PORT": "HTTP", - "HTTPS_PORT": "HTTPS", - "RTMP_PORT": "RTMP" - } - }, - "PORTALS": { - "ALL_CATEGORIES": "Все категории", - "ADD_TO_FAVORITES": "Добавить в избранное", - "REMOVE_FROM_FAVORITES": "Удалить из избранного", - "MY_FAVORITES": "Избранное", - "COPY_STREAM_URL": "Копировать URL-адрес потока", - "ERROR_VIEW": { - "DELETE": "Удалить", - "ACCOUNT_EXPIRED": { - "TITLE": "Срок действия учетной записи истек", - "DESCRIPTION": "Срок действия этой учетной записи истек. \nПожалуйста, проверьте учетные данные в настройках плейлиста или удалите его." - }, - "HOME": "На главную", - "EMPTY_CATEGORY": { - "TITLE": "Пустая категория", - "DESCRIPTION": "В этой категории нет контента. Выберите другую категорию или измените тип контента." - }, - "UNAUTHORIZED": { - "TITLE": "Несанкционированный", - "DESCRIPTION": "Пожалуйста, проверьте учетные данные в настройках плейлиста или удалите их." - }, - "NOT_FOUND": { - "TITLE": "Портал не найден", - "DESCRIPTION": "Пожалуйста, проверьте учетные данные и URL портала в настройках плейлиста или удалите его." - }, - "NO_CATEGORY_SELECTED": { - "TITLE": "Категория не выбрана", - "DESCRIPTION": "Пожалуйста, выберите категорию для просмотра содержимого" - }, - "UNKNOWN_ERROR": { - "TITLE": "Что-то пошло не так", - "DESCRIPTION": "Неизвестная ошибка. Повторите попытку позже." - }, - "PLAYLIST_SETTINGS": "Настройки плейлиста" - }, - "STREAM_URL_COPIED": "URL-адрес потока был скопирован в буфер обмена.", - "EMPTY_LIST_VIEW": { - "TITLE": "Результаты не найдены", - "NO_SEARCH_RESULTS": "Ничего не найдено, попробуйте изменить поисковый запрос" - }, - "SIDEBAR": { - "MAIN": "Главная", - "MOVIES": "Фильмы", - "LIVE_TV": "Прямой эфир", - "SERIES": "Сериалы", - "LIBRARY": "Библиотека", - "SEARCH": "Поиск", - "RECENT": "Недавние", - "FAVORITES": "Избранное", - "HOME": "Домой", - "PLAYLIST_INFO": "Информация о плейлисте", - "ACCOUNT_INFO": "Информация об аккаунте" - }, - "REMOVED_FROM_FAVORITES": "Удалено из избранного", - "ADDED_TO_FAVORITES": "Добавлено в избранное", - "SELECT_CATEGORY": "Выберите категорию", - "PAGE": "Страница" - }, - "INFORMATION": "Инфо", - "REMOTE_CONTROL": { - "TITLE": "Дистанционное управление", - "UP_CHANNEL": "Канал вверх", - "DOWN_CHANNEL": "Канал вниз", - "HEADER": "IPTVNator Дистанционное управление", - "FOOTER": "IPTVNator Дистанционное управление" - } -} \ No newline at end of file +} diff --git a/global.d.ts b/global.d.ts index bacf1bde3..199d2292e 100644 --- a/global.d.ts +++ b/global.d.ts @@ -156,6 +156,10 @@ declare global { xtreamId: number, playlistId: string ) => Promise; + // Remote control + onChannelChange?: ( + callback: (data: { direction: 'up' | 'down' }) => void + ) => void; }; process: NodeJS.Process; require: NodeRequire; diff --git a/libs/ui/components/src/index.ts b/libs/ui/components/src/index.ts index d5c46c97a..2c1817d76 100644 --- a/libs/ui/components/src/index.ts +++ b/libs/ui/components/src/index.ts @@ -9,6 +9,7 @@ export * from './lib/epg-list/epg-list.component'; export * from './lib/html-video-player/html-video-player.component'; export * from './lib/info-overlay/info-overlay.component'; export * from './lib/multi-epg/multi-epg-container.component'; +export * from './lib/multi-epg/overlay-ref.token'; export * from './lib/recent-playlists/playlist-info/playlist-info.component'; export * from './lib/recent-playlists/playlist-item/playlist-item.component'; export * from './lib/recent-playlists/recent-playlists.component'; diff --git a/libs/ui/components/src/lib/channel-list-container/channel-list-container.component.html b/libs/ui/components/src/lib/channel-list-container/channel-list-container.component.html index 1c0a2e4bc..8414827f7 100644 --- a/libs/ui/components/src/lib/channel-list-container/channel-list-container.component.html +++ b/libs/ui/components/src/lib/channel-list-container/channel-list-container.component.html @@ -42,7 +42,7 @@ " [logo]="channel?.tvg?.logo" (clicked)="selectChannel(channel)" - [selected]="selected?.id === channel?.id" + [selected]="activeChannel()?.url === channel?.url" /> @@ -90,7 +90,7 @@ [logo]="channel?.tvg?.logo" (clicked)="selectChannel(channel)" [selected]=" - selected?.id === channel.id + activeChannel()?.url === channel.url " > } @@ -136,7 +136,7 @@ [isDraggable]="true" [logo]="channel?.tvg?.logo" (clicked)="selectChannel(channel)" - [selected]="selected?.id === channel?.id" + [selected]="activeChannel()?.url === channel?.url" [showFavoriteButton]="true" (favoriteToggled)=" toggleFavoriteChannel(channel, $event) diff --git a/libs/ui/components/src/lib/channel-list-container/channel-list-container.component.ts b/libs/ui/components/src/lib/channel-list-container/channel-list-container.component.ts index 079d9d8cd..2c74f3747 100644 --- a/libs/ui/components/src/lib/channel-list-container/channel-list-container.component.ts +++ b/libs/ui/components/src/lib/channel-list-container/channel-list-container.component.ts @@ -28,7 +28,11 @@ import { Store } from '@ngrx/store'; import { TranslatePipe, TranslateService } from '@ngx-translate/core'; import * as _ from 'lodash'; import * as PlaylistActions from 'm3u-state'; -import { selectActivePlaylistId, selectFavorites } from 'm3u-state'; +import { + selectActive, + selectActivePlaylistId, + selectFavorites, +} from 'm3u-state'; import { map, skipWhile } from 'rxjs'; import { EpgService } from 'services'; import { Channel } from 'shared-interfaces'; @@ -81,7 +85,7 @@ export class ChannelListContainerComponent implements OnDestroy { groupedChannels!: { [key: string]: Channel[] }; /** Selected channel */ - selected!: Channel; + readonly activeChannel = this.store.selectSignal(selectActive); /** Search term for channel filter */ searchTerm: { name: string } = { @@ -123,11 +127,10 @@ export class ChannelListContainerComponent implements OnDestroy { ); /** - * Sets clicked channel as selected and emits them to the parent component + * Sets clicked channel as active and dispatches to store * @param channel selected channel */ selectChannel(channel: Channel): void { - this.selected = channel; this.store.dispatch(PlaylistActions.setActiveChannel({ channel })); // Use tvg-id for EPG matching, fallback to channel name if not available diff --git a/libs/ui/components/src/lib/multi-epg/multi-epg-container.component.ts b/libs/ui/components/src/lib/multi-epg/multi-epg-container.component.ts index 2bfe0b177..3dfcc68d1 100644 --- a/libs/ui/components/src/lib/multi-epg/multi-epg-container.component.ts +++ b/libs/ui/components/src/lib/multi-epg/multi-epg-container.component.ts @@ -7,7 +7,6 @@ import { Component, ElementRef, inject, - InjectionToken, Input, OnDestroy, OnInit, @@ -23,6 +22,7 @@ import { addDays, differenceInMinutes, format, parse, subDays } from 'date-fns'; import { BehaviorSubject, Observable, Subscription } from 'rxjs'; import { Channel, EpgChannel, EpgProgram } from 'shared-interfaces'; import { EpgItemDescriptionComponent } from '../epg-list/epg-item-description/epg-item-description.component'; +import { COMPONENT_OVERLAY_REF } from './overlay-ref.token'; interface EnrichedProgram extends EpgProgram { startDate: Date; @@ -89,13 +89,7 @@ export class MultiEpgContainerComponent private readonly dialog = inject(MatDialog); private readonly cdr = inject(ChangeDetectorRef); - private readonly COMPONENT_OVERLAY_REF = new InjectionToken( - 'COMPONENT_OVERLAY_REF' - ); - - private readonly overlayRef = inject( - this.COMPONENT_OVERLAY_REF - ); + private readonly overlayRef = inject(COMPONENT_OVERLAY_REF); ngOnInit() { this.calculateCurrentTimeBar(); diff --git a/libs/ui/components/src/lib/multi-epg/overlay-ref.token.ts b/libs/ui/components/src/lib/multi-epg/overlay-ref.token.ts new file mode 100644 index 000000000..db768fed1 --- /dev/null +++ b/libs/ui/components/src/lib/multi-epg/overlay-ref.token.ts @@ -0,0 +1,9 @@ +import { OverlayRef } from '@angular/cdk/overlay'; +import { InjectionToken } from '@angular/core'; + +/** + * Injection token for providing OverlayRef to dynamically created overlay components + */ +export const COMPONENT_OVERLAY_REF = new InjectionToken( + 'COMPONENT_OVERLAY_REF' +); diff --git a/libs/ui/remote-control/README.md b/libs/ui/remote-control/README.md new file mode 100644 index 000000000..0630db4e9 --- /dev/null +++ b/libs/ui/remote-control/README.md @@ -0,0 +1,7 @@ +# remote-control + +This library was generated with [Nx](https://nx.dev). + +## Running unit tests + +Run `nx test remote-control` to execute the unit tests. diff --git a/libs/ui/remote-control/eslint.config.mjs b/libs/ui/remote-control/eslint.config.mjs new file mode 100644 index 000000000..937c5f406 --- /dev/null +++ b/libs/ui/remote-control/eslint.config.mjs @@ -0,0 +1,34 @@ +import nx from '@nx/eslint-plugin'; +import baseConfig from '../../../eslint.config.mjs'; + +export default [ + ...baseConfig, + ...nx.configs['flat/angular'], + ...nx.configs['flat/angular-template'], + { + files: ['**/*.ts'], + rules: { + '@angular-eslint/directive-selector': [ + 'error', + { + type: 'attribute', + prefix: 'lib', + style: 'camelCase', + }, + ], + '@angular-eslint/component-selector': [ + 'error', + { + type: 'element', + prefix: 'lib', + style: 'kebab-case', + }, + ], + }, + }, + { + files: ['**/*.html'], + // Override or add rules here + rules: {}, + }, +]; diff --git a/libs/ui/remote-control/jest.config.ts b/libs/ui/remote-control/jest.config.ts new file mode 100644 index 000000000..395a66fa9 --- /dev/null +++ b/libs/ui/remote-control/jest.config.ts @@ -0,0 +1,21 @@ +export default { + displayName: 'remote-control', + preset: '../../../jest.preset.js', + setupFilesAfterEnv: ['/src/test-setup.ts'], + coverageDirectory: '../../../coverage/libs/ui/remote-control', + transform: { + '^.+\\.(ts|mjs|js|html)$': [ + 'jest-preset-angular', + { + tsconfig: '/tsconfig.spec.json', + stringifyContentPathRegex: '\\.(html|svg)$', + }, + ], + }, + transformIgnorePatterns: ['node_modules/(?!.*\\.mjs$)'], + snapshotSerializers: [ + 'jest-preset-angular/build/serializers/no-ng-attributes', + 'jest-preset-angular/build/serializers/ng-snapshot', + 'jest-preset-angular/build/serializers/html-comment', + ], +}; diff --git a/libs/ui/remote-control/project.json b/libs/ui/remote-control/project.json new file mode 100644 index 000000000..d5257af8e --- /dev/null +++ b/libs/ui/remote-control/project.json @@ -0,0 +1,21 @@ +{ + "name": "remote-control", + "$schema": "../../../node_modules/nx/schemas/project-schema.json", + "sourceRoot": "libs/ui/remote-control/src", + "prefix": "lib", + "projectType": "library", + "tags": [], + "targets": { + "test": { + "executor": "@nx/jest:jest", + "outputs": ["{workspaceRoot}/coverage/{projectRoot}"], + "options": { + "jestConfig": "libs/ui/remote-control/jest.config.ts", + "tsConfig": "libs/ui/remote-control/tsconfig.spec.json" + } + }, + "lint": { + "executor": "@nx/eslint:lint" + } + } +} diff --git a/libs/ui/remote-control/src/index.ts b/libs/ui/remote-control/src/index.ts new file mode 100644 index 000000000..a2f5fa64c --- /dev/null +++ b/libs/ui/remote-control/src/index.ts @@ -0,0 +1,2 @@ +export * from './lib/remote-control/remote-control.component'; +export * from './lib/remote-control/remote-control.service'; diff --git a/libs/ui/remote-control/src/lib/remote-control/remote-control.component.html b/libs/ui/remote-control/src/lib/remote-control/remote-control.component.html new file mode 100644 index 000000000..bcfc57fb2 --- /dev/null +++ b/libs/ui/remote-control/src/lib/remote-control/remote-control.component.html @@ -0,0 +1,77 @@ +
+
+ +
+ +
+ + + @if (error) { +
+ {{ error }} +
+ } + + +
+
+ + + + + + + +
+
+
+
+ + +
+
+ + +
+
+ {{ + isLoading ? 'Switching' : 'Ready' + }} +
+
+
diff --git a/libs/ui/remote-control/src/lib/remote-control/remote-control.component.scss b/libs/ui/remote-control/src/lib/remote-control/remote-control.component.scss new file mode 100644 index 000000000..4f73c25aa --- /dev/null +++ b/libs/ui/remote-control/src/lib/remote-control/remote-control.component.scss @@ -0,0 +1,398 @@ +* { + margin: 0; + padding: 0; + box-sizing: border-box; + -webkit-tap-highlight-color: transparent; + user-select: none; +} + +// Main container +.remote { + min-height: 100vh; + width: 100%; + display: flex; + align-items: center; + justify-content: center; + background-color: #1a1a1a; + font-family: + -apple-system, BlinkMacSystemFont, 'SF Pro Display', 'Helvetica Neue', + sans-serif; + padding: 1rem; + background-image: + radial-gradient( + circle at 50% 20%, + rgba(10, 132, 255, 0.15) 0%, + transparent 50% + ), + radial-gradient( + circle at 80% 80%, + rgba(120, 120, 255, 0.08) 0%, + transparent 50% + ); +} + +.remote__body { + width: 100%; + max-width: 420px; + display: flex; + flex-direction: column; + gap: 2.5rem; + animation: fadeInUp 0.8s cubic-bezier(0.16, 1, 0.3, 1) backwards; +} + +// Header +.remote__header { + text-align: center; +} + +.remote__logo { + font-size: 1.5rem; + font-weight: 600; + letter-spacing: -0.02em; + color: #ffffff; + opacity: 0; + animation: fadeIn 1s ease-out 0.3s forwards; +} + +// Error message +.remote__error { + background: rgba(255, 69, 58, 0.15); + border: 1px solid rgba(255, 69, 58, 0.4); + border-radius: 1rem; + padding: 1rem 1.25rem; + color: #ffb3ab; + font-size: 0.875rem; + font-weight: 500; + text-align: center; + animation: slideDown 0.4s cubic-bezier(0.16, 1, 0.3, 1); +} + +// Touchpad - Main control +.touchpad { + position: relative; + width: 100%; + aspect-ratio: 1; + max-width: 360px; + margin: 0 auto; + animation: scaleIn 0.9s cubic-bezier(0.16, 1, 0.3, 1) 0.2s backwards; +} + +.touchpad__glass { + position: relative; + width: 100%; + height: 100%; + border-radius: 50%; + background: rgba(255, 255, 255, 0.2); + backdrop-filter: blur(20px); + box-shadow: + 0 16px 48px rgba(0, 0, 0, 0.6), + 0 0 0 1px rgba(255, 255, 255, 0.15), + inset 0 1px 1px rgba(255, 255, 255, 0.1), + inset 0 -1px 1px rgba(0, 0, 0, 0.2); + border: 1px solid rgba(255, 255, 255, 0.3); + overflow: hidden; + + // Top reflection + &::before { + content: ''; + position: absolute; + top: 0; + left: 0; + right: 0; + height: 45%; + background: linear-gradient( + 180deg, + rgba(255, 255, 255, 0.25) 0%, + rgba(255, 255, 255, 0.1) 50%, + transparent 100% + ); + border-radius: 50% 50% 0 0; + pointer-events: none; + } + + // Bottom shadow + &::after { + content: ''; + position: absolute; + bottom: 0; + left: 0; + right: 0; + height: 35%; + background: linear-gradient( + 0deg, + rgba(0, 0, 0, 0.4) 0%, + rgba(0, 0, 0, 0.1) 50%, + transparent 100% + ); + border-radius: 0 0 50% 50%; + pointer-events: none; + } +} + +.touchpad__edge { + position: absolute; + inset: -2px; + border-radius: 50%; + background: radial-gradient( + circle at 50% 0%, + rgba(255, 255, 255, 0.15), + transparent 50% + ); + opacity: 0.6; + pointer-events: none; + filter: blur(1px); +} + +// Touch buttons +.touchpad__button { + position: absolute; + left: 0; + width: 100%; + height: 50%; + background: transparent; + border: none; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + padding: 0; + transition: all 0.3s cubic-bezier(0.16, 1, 0.3, 1); + + svg { + width: 56px; + height: 56px; + color: #ffffff; + stroke: #ffffff; + opacity: 0.5; + transition: all 0.4s cubic-bezier(0.16, 1, 0.3, 1); + filter: drop-shadow(0 2px 8px rgba(0, 0, 0, 0.3)); + } + + &:hover:not(:disabled) svg { + opacity: 0.9; + transform: scale(1.05); + } + + &:active:not(:disabled) { + svg { + opacity: 1; + transform: scale(0.92); + } + + &::after { + opacity: 1; + } + } + + // Ripple effect + &::after { + content: ''; + position: absolute; + inset: 0; + background: radial-gradient( + circle at center, + rgba(10, 132, 255, 0.5) 0%, + transparent 70% + ); + opacity: 0; + transition: opacity 0.2s ease; + } + + &:disabled { + cursor: not-allowed; + opacity: 0.4; + } + + &.is-loading svg { + opacity: 0.5; + animation: pulse 1.5s ease-in-out infinite; + } +} + +.touchpad__button--up { + top: 0; + border-radius: 50% 50% 0 0; + + svg { + margin-bottom: 20%; + } +} + +.touchpad__button--down { + bottom: 0; + border-radius: 0 0 50% 50%; + + svg { + margin-top: 20%; + } +} + +// Center indicator +.touchpad__center { + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + width: 48px; + height: 48px; + border-radius: 50%; + background: rgba(0, 0, 0, 0.2); + border: 1px solid rgba(255, 255, 255, 0.08); + display: flex; + align-items: center; + justify-content: center; + pointer-events: none; + box-shadow: inset 0 2px 8px rgba(0, 0, 0, 0.3); +} + +.touchpad__pulse { + width: 16px; + height: 16px; + border-radius: 50%; + background: rgba(255, 255, 255, 0.2); + transition: all 0.4s ease; + + &.is-active { + background: #0a84ff; + box-shadow: + 0 0 12px rgba(10, 132, 255, 0.5), + 0 0 24px rgba(10, 132, 255, 0.5); + animation: breathe 3s ease-in-out infinite; + } +} + +// Status bar +.remote__status { + display: flex; + align-items: center; + justify-content: center; + gap: 0.625rem; + padding: 0.875rem 1.25rem; + background: rgba(255, 255, 255, 0.1); + border-radius: 1.25rem; + border: 1px solid rgba(255, 255, 255, 0.06); + animation: fadeIn 1s ease-out 0.5s backwards; +} + +.status-dot { + width: 6px; + height: 6px; + border-radius: 50%; + background: rgba(255, 255, 255, 0.25); + transition: all 0.4s ease; + + &.is-active { + background: #30d158; + box-shadow: + 0 0 8px rgba(48, 209, 88, 0.5), + 0 0 16px rgba(48, 209, 88, 0.3); + animation: breathe 2.5s ease-in-out infinite; + } +} + +.status-text { + font-size: 0.8125rem; + font-weight: 500; + color: rgba(255, 255, 255, 0.7); + letter-spacing: 0.01em; +} + +// Animations +@keyframes fadeIn { + to { + opacity: 1; + } +} + +@keyframes fadeInUp { + from { + opacity: 0; + transform: translateY(20px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +@keyframes slideDown { + from { + opacity: 0; + transform: translateY(-8px); + } +} + +@keyframes scaleIn { + from { + opacity: 0; + transform: scale(0.9); + } +} + +@keyframes breathe { + 0%, + 100% { + opacity: 1; + transform: scale(1); + } + 50% { + opacity: 0.7; + transform: scale(0.9); + } +} + +@keyframes pulse { + 0%, + 100% { + opacity: 0.5; + } + 50% { + opacity: 0.8; + } +} + +// Responsive +@media (max-width: 480px) { + .remote__logo { + font-size: 1.25rem; + } + + .touchpad { + max-width: 320px; + } + + .touchpad__button svg { + width: 48px; + height: 48px; + } +} + +@media (max-width: 360px) { + .touchpad { + max-width: 280px; + } + + .touchpad__button svg { + width: 42px; + height: 42px; + } + + .touchpad__center { + width: 40px; + height: 40px; + } + + .touchpad__pulse { + width: 14px; + height: 14px; + } +} + +// Accessibility +@media (prefers-reduced-motion: reduce) { + * { + animation-duration: 0.01ms !important; + animation-iteration-count: 1 !important; + transition-duration: 0.01ms !important; + } +} diff --git a/libs/ui/remote-control/src/lib/remote-control/remote-control.component.ts b/libs/ui/remote-control/src/lib/remote-control/remote-control.component.ts new file mode 100644 index 000000000..eb390caf7 --- /dev/null +++ b/libs/ui/remote-control/src/lib/remote-control/remote-control.component.ts @@ -0,0 +1,42 @@ +import { CommonModule } from '@angular/common'; +import { Component, inject } from '@angular/core'; +import { RemoteControlService } from './remote-control.service'; + +@Component({ + selector: 'lib-remote-control', + imports: [CommonModule], + templateUrl: './remote-control.component.html', + styleUrls: ['./remote-control.component.scss'], +}) +export class RemoteControlComponent { + private remoteControlService = inject(RemoteControlService); + + isLoading = false; + error: string | null = null; + + async changeChannelUp(): Promise { + this.isLoading = true; + this.error = null; + try { + await this.remoteControlService.channelUp(); + } catch (err) { + this.error = 'Failed to change channel up'; + console.error(err); + } finally { + this.isLoading = false; + } + } + + async changeChannelDown(): Promise { + this.isLoading = true; + this.error = null; + try { + await this.remoteControlService.channelDown(); + } catch (err) { + this.error = 'Failed to change channel down'; + console.error(err); + } finally { + this.isLoading = false; + } + } +} diff --git a/libs/ui/remote-control/src/lib/remote-control/remote-control.service.ts b/libs/ui/remote-control/src/lib/remote-control/remote-control.service.ts new file mode 100644 index 000000000..8151ae401 --- /dev/null +++ b/libs/ui/remote-control/src/lib/remote-control/remote-control.service.ts @@ -0,0 +1,34 @@ +import { Injectable } from '@angular/core'; +import { HttpClient } from '@angular/common/http'; +import { firstValueFrom } from 'rxjs'; + +/** + * Service for remote control operations. + * This service communicates with the Electron backend via HTTP API. + */ +@Injectable({ + providedIn: 'root', +}) +export class RemoteControlService { + private readonly apiBaseUrl = '/api/remote-control'; + + constructor(private http: HttpClient) {} + + /** + * Changes to the next channel + */ + async channelUp(): Promise { + await firstValueFrom( + this.http.post(`${this.apiBaseUrl}/channel/up`, {}) + ); + } + + /** + * Changes to the previous channel + */ + async channelDown(): Promise { + await firstValueFrom( + this.http.post(`${this.apiBaseUrl}/channel/down`, {}) + ); + } +} diff --git a/libs/ui/remote-control/src/test-setup.ts b/libs/ui/remote-control/src/test-setup.ts new file mode 100644 index 000000000..0d88648e3 --- /dev/null +++ b/libs/ui/remote-control/src/test-setup.ts @@ -0,0 +1,6 @@ +import { setupZoneTestEnv } from 'jest-preset-angular/setup-env/zone'; + +setupZoneTestEnv({ + errorOnUnknownElements: true, + errorOnUnknownProperties: true, +}); diff --git a/libs/ui/remote-control/tsconfig.json b/libs/ui/remote-control/tsconfig.json new file mode 100644 index 000000000..e05028a82 --- /dev/null +++ b/libs/ui/remote-control/tsconfig.json @@ -0,0 +1,30 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "target": "es2022", + "moduleResolution": "bundler", + "strict": true, + "noImplicitOverride": true, + "noPropertyAccessFromIndexSignature": true, + "noImplicitReturns": true, + "noFallthroughCasesInSwitch": true, + "module": "preserve" + }, + "angularCompilerOptions": { + "enableI18nLegacyMessageIdFormat": false, + "strictInjectionParameters": true, + "strictInputAccessModifiers": true, + "typeCheckHostBindings": true, + "strictTemplates": true + }, + "files": [], + "include": [], + "references": [ + { + "path": "./tsconfig.lib.json" + }, + { + "path": "./tsconfig.spec.json" + } + ] +} diff --git a/libs/ui/remote-control/tsconfig.lib.json b/libs/ui/remote-control/tsconfig.lib.json new file mode 100644 index 000000000..8eb88c679 --- /dev/null +++ b/libs/ui/remote-control/tsconfig.lib.json @@ -0,0 +1,17 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "../../../dist/out-tsc", + "declaration": true, + "declarationMap": true, + "inlineSources": true, + "types": [] + }, + "exclude": [ + "src/**/*.spec.ts", + "src/test-setup.ts", + "jest.config.ts", + "src/**/*.test.ts" + ], + "include": ["src/**/*.ts"] +} diff --git a/libs/ui/remote-control/tsconfig.spec.json b/libs/ui/remote-control/tsconfig.spec.json new file mode 100644 index 000000000..7b3e56bde --- /dev/null +++ b/libs/ui/remote-control/tsconfig.spec.json @@ -0,0 +1,17 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "../../../dist/out-tsc", + "module": "commonjs", + "target": "es2016", + "types": ["jest", "node"], + "moduleResolution": "node10" + }, + "files": ["src/test-setup.ts"], + "include": [ + "jest.config.ts", + "src/**/*.test.ts", + "src/**/*.spec.ts", + "src/**/*.d.ts" + ] +} diff --git a/tsconfig.base.json b/tsconfig.base.json index 44bf52130..2219622e4 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -22,7 +22,8 @@ "m3u-utils": ["libs/shared/m3u-utils/src/index.ts"], "services": ["libs/services/src/index.ts"], "shared-interfaces": ["libs/shared/interfaces/src/index.ts"], - "shared-portals": ["libs/ui/shared-portals/src/index.ts"] + "shared-portals": ["libs/ui/shared-portals/src/index.ts"], + "remote-control": ["libs/ui/remote-control/src/index.ts"] } }, "exclude": ["node_modules", "tmp"],