mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-08 17:06:15 -08:00
Merge pull request #662 from 4gray/feat-store-backbutton-and-player-teardown
EPG Worker fix
This commit is contained in:
19 files changed
+2058
-317
No files matched your search
@@ -5,13 +5,16 @@
|
||||
"WebSearch",
|
||||
"WebFetch(domain:www.electronjs.org)",
|
||||
"Bash(npm run build:frontend:*)",
|
||||
"Bash(cat:*)"
|
||||
"Bash(cat:*)",
|
||||
"Bash(npx asar:*)",
|
||||
"Bash(npm run package:app:*)",
|
||||
"Bash(npm run build:backend:*)"
|
||||
],
|
||||
"deny": [],
|
||||
"ask": []
|
||||
},
|
||||
"enableAllProjectMcpServers": true,
|
||||
"enabledMcpjsonServers": [
|
||||
"nx-mcp"
|
||||
],
|
||||
"enableAllProjectMcpServers": true
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
const esbuild = require('esbuild');
|
||||
const path = require('path');
|
||||
|
||||
// Node.js built-in modules that should be externalized
|
||||
const nodeBuiltins = [
|
||||
'assert', 'async_hooks', 'buffer', 'child_process', 'cluster',
|
||||
'console', 'constants', 'crypto', 'dgram', 'dns', 'domain',
|
||||
'events', 'fs', 'http', 'http2', 'https', 'inspector', 'module',
|
||||
'net', 'os', 'path', 'perf_hooks', 'process', 'punycode',
|
||||
'querystring', 'readline', 'repl', 'stream', 'string_decoder',
|
||||
'sys', 'timers', 'tls', 'trace_events', 'tty', 'url', 'util',
|
||||
'v8', 'vm', 'worker_threads', 'zlib'
|
||||
];
|
||||
|
||||
const isProduction = process.env.NODE_ENV === 'production';
|
||||
|
||||
async function buildWorker() {
|
||||
try {
|
||||
console.log(`Building EPG parser worker with esbuild (${isProduction ? 'production' : 'development'})...`);
|
||||
|
||||
await esbuild.build({
|
||||
entryPoints: [path.join(__dirname, 'src/app/workers/epg-parser.worker.ts')],
|
||||
bundle: true,
|
||||
platform: 'node',
|
||||
target: 'node18',
|
||||
format: 'cjs',
|
||||
outfile: path.join(__dirname, '../../dist/apps/electron-backend/workers/epg-parser.worker.js'),
|
||||
external: nodeBuiltins.map(m => `node:${m}`).concat(nodeBuiltins),
|
||||
sourcemap: !isProduction,
|
||||
minify: isProduction,
|
||||
// Resolve workspace libraries from tsconfig paths
|
||||
alias: {
|
||||
'shared-interfaces': path.join(__dirname, '../../libs/shared/interfaces/src/index.ts'),
|
||||
},
|
||||
});
|
||||
|
||||
console.log('✅ Worker built successfully!');
|
||||
} catch (error) {
|
||||
console.error('❌ Worker build failed:', error);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
buildWorker();
|
||||
@@ -4,8 +4,16 @@
|
||||
"sourceRoot": "apps/electron-backend/src",
|
||||
"projectType": "application",
|
||||
"targets": {
|
||||
"build-worker": {
|
||||
"executor": "nx:run-commands",
|
||||
"outputs": ["{workspaceRoot}/dist/apps/electron-backend/workers"],
|
||||
"options": {
|
||||
"command": "node apps/electron-backend/build-worker.js"
|
||||
}
|
||||
},
|
||||
"build": {
|
||||
"dependsOn": [
|
||||
"electron-backend:build-worker",
|
||||
{
|
||||
"projects": ["web", "remote-control-web"],
|
||||
"target": "build"
|
||||
@@ -17,20 +25,7 @@
|
||||
"outputPath": "dist/apps/electron-backend",
|
||||
"main": "apps/electron-backend/src/main.ts",
|
||||
"tsConfig": "apps/electron-backend/tsconfig.app.json",
|
||||
"assets": [
|
||||
"apps/electron-backend/src/assets",
|
||||
{
|
||||
"input": "apps/electron-backend/src/app/workers",
|
||||
"glob": "*.worker.ts",
|
||||
"output": "workers"
|
||||
}
|
||||
],
|
||||
"additionalEntryPoints": [
|
||||
{
|
||||
"entryName": "workers/epg-parser.worker",
|
||||
"entryPath": "apps/electron-backend/src/app/workers/epg-parser.worker.ts"
|
||||
}
|
||||
]
|
||||
"assets": ["apps/electron-backend/src/assets"]
|
||||
},
|
||||
"configurations": {
|
||||
"production": {
|
||||
@@ -54,7 +49,6 @@
|
||||
"nx run electron-backend:serve-electron"
|
||||
]
|
||||
}
|
||||
|
||||
},
|
||||
"serve-electron": {
|
||||
"executor": "nx-electron:execute",
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { ipcMain } from 'electron';
|
||||
import { app, ipcMain } from 'electron';
|
||||
import * as path from 'path';
|
||||
import { EpgChannelWithPrograms, EpgData, EpgProgram } from 'shared-interfaces';
|
||||
import { Worker } from 'worker_threads';
|
||||
import { pathToFileURL } from 'url';
|
||||
|
||||
/**
|
||||
* EPG Events Handler
|
||||
@@ -23,8 +24,6 @@ export default class EpgEvents {
|
||||
* Bootstrap EPG events
|
||||
*/
|
||||
static bootstrapEpgEvents(): Electron.IpcMain {
|
||||
console.log('[EPG Events] Bootstrapping EPG events...');
|
||||
|
||||
// Fetch EPG from URLs
|
||||
ipcMain.handle('FETCH_EPG', async (event, args: { url: string[] }) => {
|
||||
return await this.handleFetchEpg(args.url);
|
||||
@@ -78,7 +77,7 @@ export default class EpgEvents {
|
||||
await Promise.all(promises);
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
console.error('[EPG Events] Error fetching EPG:', error);
|
||||
console.error(this.loggerLabel, 'Error fetching EPG:', error);
|
||||
return {
|
||||
success: false,
|
||||
message: error instanceof Error ? error.message : String(error),
|
||||
@@ -92,24 +91,33 @@ export default class EpgEvents {
|
||||
private static async fetchEpgFromUrl(url: string): Promise<void> {
|
||||
// Skip if already fetched
|
||||
if (this.fetchedUrls.has(url)) {
|
||||
console.log(this.loggerLabel, 'URL already fetched:', url);
|
||||
return;
|
||||
}
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const workerPath = path.join(
|
||||
__dirname,
|
||||
'workers',
|
||||
'epg-parser.worker.js'
|
||||
);
|
||||
let workerPath: string;
|
||||
|
||||
console.log(this.loggerLabel, 'Creating worker for:', url);
|
||||
console.log(this.loggerLabel, 'Worker path:', workerPath);
|
||||
if (app.isPackaged) {
|
||||
// In packaged app: Resources/dist/apps/electron-backend/workers/epg-parser.worker.js
|
||||
const resourcesPath = path.dirname(app.getAppPath());
|
||||
workerPath = path.join(
|
||||
resourcesPath,
|
||||
'dist',
|
||||
'apps',
|
||||
'electron-backend',
|
||||
'workers',
|
||||
'epg-parser.worker.js'
|
||||
);
|
||||
} else {
|
||||
// In development: dist/apps/electron-backend/workers/epg-parser.worker.js
|
||||
workerPath = path.join(__dirname, 'workers', 'epg-parser.worker.js');
|
||||
}
|
||||
|
||||
let worker: Worker;
|
||||
try {
|
||||
worker = new Worker(workerPath);
|
||||
console.log(this.loggerLabel, 'Worker created successfully');
|
||||
// Worker threads require file:// URLs wrapped in URL object for packaged apps
|
||||
const workerURL = pathToFileURL(workerPath);
|
||||
worker = new Worker(workerURL);
|
||||
} catch (error) {
|
||||
console.error(
|
||||
this.loggerLabel,
|
||||
@@ -131,16 +139,8 @@ export default class EpgEvents {
|
||||
url?: string;
|
||||
}) => {
|
||||
if (message.type === 'READY') {
|
||||
console.log(
|
||||
this.loggerLabel,
|
||||
'Worker ready, sending fetch command'
|
||||
);
|
||||
worker.postMessage({ type: 'FETCH_EPG', url });
|
||||
} else if (message.type === 'EPG_PARSED') {
|
||||
console.log(
|
||||
this.loggerLabel,
|
||||
'EPG parsed in worker, merging data...'
|
||||
);
|
||||
if (message.data) {
|
||||
this.mergeEpgData(message.data);
|
||||
this.fetchedUrls.add(url);
|
||||
@@ -183,11 +183,6 @@ export default class EpgEvents {
|
||||
* Merge EPG data (optimized for large datasets)
|
||||
*/
|
||||
private static mergeEpgData(newData: EpgData): void {
|
||||
console.log(
|
||||
this.loggerLabel,
|
||||
`Merging ${newData.channels.length} channels and ${newData.programs.length} programs...`
|
||||
);
|
||||
|
||||
// Merge channels (avoid duplicates)
|
||||
const existingChannelIds = new Set(
|
||||
this.epgData.channels.map((c) => c.id)
|
||||
@@ -208,11 +203,6 @@ export default class EpgEvents {
|
||||
|
||||
// Rebuild merged data structure
|
||||
this.rebuildMergedData();
|
||||
|
||||
console.log(
|
||||
this.loggerLabel,
|
||||
`Merged EPG data: ${this.epgData.channels.length} channels, ${this.epgData.programs.length} programs`
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -220,7 +210,6 @@ export default class EpgEvents {
|
||||
* Optimized for large datasets
|
||||
*/
|
||||
private static rebuildMergedData(): void {
|
||||
console.log(this.loggerLabel, 'Rebuilding merged data structure...');
|
||||
this.epgDataMerged.clear();
|
||||
|
||||
// Create channel lookup map for O(1) access
|
||||
@@ -247,31 +236,17 @@ export default class EpgEvents {
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
console.log(
|
||||
this.loggerLabel,
|
||||
`Rebuild complete. ${this.epgDataMerged.size} channels with programs`
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get programs for a specific channel
|
||||
*/
|
||||
private static handleGetChannelPrograms(channelId: string): EpgProgram[] {
|
||||
console.log('[EPG Events] Getting programs for channel:', channelId);
|
||||
console.log(
|
||||
'[EPG Events] Total merged channels:',
|
||||
this.epgDataMerged.size
|
||||
);
|
||||
|
||||
// First try exact ID match
|
||||
let channelData = this.epgDataMerged.get(channelId);
|
||||
|
||||
// If not found, try to find by display name
|
||||
if (!channelData) {
|
||||
console.log(
|
||||
'[EPG Events] No exact match, searching by display name...'
|
||||
);
|
||||
for (const [id, channel] of this.epgDataMerged.entries()) {
|
||||
const displayNames = channel.displayName.map((d) =>
|
||||
d.value.toLowerCase()
|
||||
@@ -284,22 +259,12 @@ export default class EpgEvents {
|
||||
channelId.toLowerCase().includes(name)
|
||||
)
|
||||
) {
|
||||
console.log(
|
||||
'[EPG Events] Found match by name:',
|
||||
id,
|
||||
'→',
|
||||
channel.displayName[0]?.value
|
||||
);
|
||||
channelData = channel;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
console.log(
|
||||
'[EPG Events] Found programs:',
|
||||
channelData?.programs?.length || 0
|
||||
);
|
||||
return channelData?.programs || [];
|
||||
}
|
||||
|
||||
@@ -325,7 +290,5 @@ export default class EpgEvents {
|
||||
// Terminate all workers
|
||||
this.workers.forEach((worker) => worker.terminate());
|
||||
this.workers.clear();
|
||||
|
||||
console.log(this.loggerLabel, 'EPG data cleared');
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,3 @@
|
||||
import axios from 'axios';
|
||||
import { parse as parseEpg } from 'epg-parser';
|
||||
import { EpgData, EpgProgram } from 'shared-interfaces';
|
||||
import { parentPort } from 'worker_threads';
|
||||
@@ -29,32 +28,28 @@ const loggerLabel = '[EPG Worker]';
|
||||
*/
|
||||
async function fetchAndParseEpg(url: string): Promise<EpgData> {
|
||||
try {
|
||||
console.log(loggerLabel, 'Fetching EPG from:', url);
|
||||
|
||||
const isGzipped = url.endsWith('.gz');
|
||||
const axiosConfig = isGzipped
|
||||
? { responseType: 'arraybuffer' as const }
|
||||
: {};
|
||||
|
||||
const response = await axios.get(url.trim(), axiosConfig);
|
||||
console.log(
|
||||
loggerLabel,
|
||||
'EPG data fetched, size:',
|
||||
response.data.length
|
||||
);
|
||||
// Use native fetch (available in Node.js 18+)
|
||||
const response = await fetch(url.trim());
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`);
|
||||
}
|
||||
|
||||
let xmlString: string;
|
||||
|
||||
if (isGzipped) {
|
||||
console.log(loggerLabel, 'Unzipping...');
|
||||
const buffer = Buffer.from(response.data);
|
||||
// For gzipped files, get as ArrayBuffer
|
||||
const arrayBuffer = await response.arrayBuffer();
|
||||
const buffer = Buffer.from(arrayBuffer);
|
||||
const decompressed = gunzipSync(buffer);
|
||||
xmlString = decompressed.toString('utf-8');
|
||||
} else {
|
||||
xmlString = response.data;
|
||||
// For regular files, get as text
|
||||
xmlString = await response.text();
|
||||
}
|
||||
|
||||
console.log(loggerLabel, 'Parsing EPG XML...');
|
||||
const parsed = parseEpg(xmlString);
|
||||
|
||||
// Normalize data to match our interface
|
||||
@@ -89,11 +84,6 @@ async function fetchAndParseEpg(url: string): Promise<EpgData> {
|
||||
) as EpgProgram[],
|
||||
};
|
||||
|
||||
console.log(
|
||||
loggerLabel,
|
||||
`Parsed ${normalized.channels.length} channels and ${normalized.programs.length} programs`
|
||||
);
|
||||
|
||||
return normalized;
|
||||
} catch (error) {
|
||||
console.error(
|
||||
@@ -109,20 +99,12 @@ async function fetchAndParseEpg(url: string): Promise<EpgData> {
|
||||
* Worker message handler
|
||||
*/
|
||||
if (parentPort) {
|
||||
console.log(loggerLabel, 'Worker initialized and ready');
|
||||
|
||||
parentPort.on('message', async (message: WorkerMessage) => {
|
||||
console.log(loggerLabel, 'Received message:', message.type);
|
||||
try {
|
||||
if (
|
||||
message.type === 'FETCH_EPG' ||
|
||||
message.type === 'FORCE_FETCH'
|
||||
) {
|
||||
console.log(
|
||||
loggerLabel,
|
||||
'Starting EPG fetch for:',
|
||||
message.url
|
||||
);
|
||||
const parsedData = await fetchAndParseEpg(message.url);
|
||||
|
||||
const response: WorkerResponse = {
|
||||
@@ -131,10 +113,6 @@ if (parentPort) {
|
||||
url: message.url,
|
||||
};
|
||||
|
||||
console.log(
|
||||
loggerLabel,
|
||||
'Sending parsed data back to main thread'
|
||||
);
|
||||
parentPort?.postMessage(response);
|
||||
}
|
||||
} catch (error) {
|
||||
@@ -150,7 +128,6 @@ if (parentPort) {
|
||||
});
|
||||
|
||||
// Notify parent that worker is ready
|
||||
console.log(loggerLabel, 'Sending READY message');
|
||||
parentPort.postMessage({ type: 'READY' });
|
||||
} else {
|
||||
console.error(loggerLabel, 'parentPort is not available!');
|
||||
|
||||
@@ -226,16 +226,28 @@ export class ElectronService extends DataService {
|
||||
window.postMessage(result);
|
||||
return result;
|
||||
} catch (error: any) {
|
||||
console.error('Xtream request error:', error.message);
|
||||
this.snackBar.open(
|
||||
`Error: ${error.message ?? 'Failed to connect to Xtream server'}, status: ${
|
||||
error.status ?? 500
|
||||
}`,
|
||||
'Close',
|
||||
{
|
||||
duration: 5000,
|
||||
}
|
||||
);
|
||||
const isStatusCheck = payload.params?.action === 'get_account_info';
|
||||
|
||||
// Log error to console
|
||||
if (isStatusCheck) {
|
||||
console.log('Portal status check failed - portal may be unavailable:', error.message || error);
|
||||
} else {
|
||||
console.error('Xtream request error:', error.message);
|
||||
}
|
||||
|
||||
// Only show snackbar for non-status-check requests
|
||||
if (!isStatusCheck) {
|
||||
this.snackBar.open(
|
||||
`Error: ${error.message ?? 'Failed to connect to Xtream server'}, status: ${
|
||||
error.status ?? 500
|
||||
}`,
|
||||
'Close',
|
||||
{
|
||||
duration: 5000,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
type: ERROR,
|
||||
status: error.status ?? 500,
|
||||
|
||||
@@ -186,7 +186,10 @@ export class PwaService extends DataService {
|
||||
);
|
||||
|
||||
if (!(response as any).payload) {
|
||||
if (payload.params.action === 'get_account_info') return;
|
||||
if (payload.params.action === 'get_account_info') {
|
||||
console.log('Portal status check failed - portal may be unavailable:', (response as any).message || 'No payload received');
|
||||
return;
|
||||
}
|
||||
|
||||
result = {
|
||||
type: ERROR,
|
||||
@@ -204,7 +207,15 @@ export class PwaService extends DataService {
|
||||
}
|
||||
return result;
|
||||
} catch (error: any) {
|
||||
if (payload.params.action === 'get_account_info') return;
|
||||
const isStatusCheck = payload.params.action === 'get_account_info';
|
||||
|
||||
// Log error to console
|
||||
if (isStatusCheck) {
|
||||
console.log('Portal status check failed - portal may be unavailable:', error.message || error);
|
||||
return;
|
||||
}
|
||||
|
||||
console.error('Xtream request error:', error.message);
|
||||
this.snackBar.open(
|
||||
`Error: ${error.message ?? ' Unknown error'}, status: ${
|
||||
error.status ?? 500
|
||||
|
||||
@@ -90,19 +90,17 @@
|
||||
<div class="main-content">
|
||||
<div class="content-header">
|
||||
<h2>
|
||||
@if (
|
||||
currentLayout === 'vod-details' ||
|
||||
currentLayout === 'serial-details'
|
||||
) {
|
||||
<button mat-icon-button (click)="historyBack()">
|
||||
@if (stalkerStore.selectedItem()) {
|
||||
<button mat-icon-button (click)="goBackToList()">
|
||||
<mat-icon>chevron_left</mat-icon>
|
||||
</button>
|
||||
} @else if (!isItvCategorySelected) {
|
||||
<button mat-icon-button disabled>
|
||||
<mat-icon>category</mat-icon>
|
||||
</button>
|
||||
}
|
||||
|
||||
@if (!isItvCategorySelected) {
|
||||
<button mat-icon-button disabled>
|
||||
<mat-icon>category</mat-icon>
|
||||
</button>
|
||||
{{ selectedCategoryTitle() }}
|
||||
}
|
||||
|
||||
|
||||
@@ -236,4 +236,8 @@ export class StalkerMainContainerComponent {
|
||||
backToCategories() {
|
||||
this.stalkerStore.setSelectedCategory(null);
|
||||
}
|
||||
|
||||
goBackToList() {
|
||||
this.stalkerStore.clearSelectedItem();
|
||||
}
|
||||
}
|
||||
@@ -332,6 +332,14 @@ export const StalkerStore = signalStore(
|
||||
selectedItem,
|
||||
});
|
||||
},
|
||||
clearSelectedItem() {
|
||||
patchState(store, {
|
||||
selectedVodId: undefined,
|
||||
selectedSerialId: undefined,
|
||||
selectedItvId: undefined,
|
||||
selectedItem: undefined,
|
||||
});
|
||||
},
|
||||
setCategories(type: 'vod' | 'series' | 'itv', categories: any[]) {
|
||||
if (type === 'vod') {
|
||||
patchState(store, { vodCategories: categories });
|
||||
|
||||
@@ -23,7 +23,6 @@
|
||||
max-width: 100%;
|
||||
box-sizing: border-box;
|
||||
height: auto;
|
||||
min-height: calc(100vh - 73px);
|
||||
|
||||
&::before {
|
||||
content: '';
|
||||
|
||||
@@ -2,10 +2,11 @@
|
||||
width: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.details {
|
||||
overflow: visible;
|
||||
|
||||
h2 {
|
||||
word-wrap: break-word;
|
||||
}
|
||||
@@ -16,6 +17,7 @@
|
||||
grid-gap: 10px;
|
||||
grid-template-columns: 320px 1fr;
|
||||
padding: 0 10px;
|
||||
overflow: visible;
|
||||
|
||||
&::before {
|
||||
content: '';
|
||||
|
||||
@@ -38,6 +38,7 @@ export class ArtPlayerComponent implements OnInit, OnDestroy, OnChanges {
|
||||
@Input() showCaptions = false;
|
||||
|
||||
private player!: Artplayer;
|
||||
private hls: Hls | null = null;
|
||||
|
||||
private readonly elementRef = inject(ElementRef);
|
||||
|
||||
@@ -46,20 +47,26 @@ export class ArtPlayerComponent implements OnInit, OnDestroy, OnChanges {
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
if (this.player) {
|
||||
this.player.destroy();
|
||||
}
|
||||
this.destroyPlayer();
|
||||
}
|
||||
|
||||
ngOnChanges(changes: SimpleChanges): void {
|
||||
if (changes['channel'] && !changes['channel'].firstChange) {
|
||||
if (this.player) {
|
||||
this.player.destroy();
|
||||
}
|
||||
this.destroyPlayer();
|
||||
this.initPlayer();
|
||||
}
|
||||
}
|
||||
|
||||
private destroyPlayer(): void {
|
||||
if (this.hls) {
|
||||
this.hls.destroy();
|
||||
this.hls = null;
|
||||
}
|
||||
if (this.player) {
|
||||
this.player.destroy();
|
||||
}
|
||||
}
|
||||
|
||||
private initPlayer(): void {
|
||||
const el = this.elementRef.nativeElement.querySelector(
|
||||
'.artplayer-container'
|
||||
@@ -89,11 +96,14 @@ export class ArtPlayerComponent implements OnInit, OnDestroy, OnChanges {
|
||||
mutex: true,
|
||||
theme: '#ff0000',
|
||||
customType: {
|
||||
m3u8: function (video: HTMLVideoElement, url: string) {
|
||||
m3u8: (video: HTMLVideoElement, url: string) => {
|
||||
if (Hls.isSupported()) {
|
||||
const hls = new Hls();
|
||||
hls.loadSource(url);
|
||||
hls.attachMedia(video);
|
||||
if (this.hls) {
|
||||
this.hls.destroy();
|
||||
}
|
||||
this.hls = new Hls();
|
||||
this.hls.loadSource(url);
|
||||
this.hls.attachMedia(video);
|
||||
} else if (
|
||||
video.canPlayType('application/vnd.apple.mpegurl')
|
||||
) {
|
||||
|
||||
@@ -1,30 +1,106 @@
|
||||
:host {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.time {
|
||||
color: #666;
|
||||
border-bottom: 1px solid #ccc;
|
||||
font-size: 0.8em;
|
||||
display: inline-block;
|
||||
margin-bottom: 5px;
|
||||
margin-left: 5px;
|
||||
font-family: 'DM Sans', sans-serif;
|
||||
font-size: 0.7rem;
|
||||
opacity: 0.6;
|
||||
letter-spacing: 0.02em;
|
||||
line-height: 1;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.badge {
|
||||
border-radius: 2px;
|
||||
padding: 2px 6px;
|
||||
color: #fff;
|
||||
border-radius: 4px;
|
||||
padding: 3px 8px;
|
||||
color: white;
|
||||
text-transform: uppercase;
|
||||
margin-left: 6px;
|
||||
font-size: 0.8em;
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.08em;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 40px;
|
||||
transition: all 0.2s ease;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
.live {
|
||||
background-color: red;
|
||||
background: linear-gradient(135deg, #ef4444 0%, #dc2626 100%);
|
||||
animation: livePulse 2s ease-in-out infinite;
|
||||
box-shadow: 0 0 12px rgba(239, 68, 68, 0.5), 0 2px 8px rgba(0, 0, 0, 0.3);
|
||||
|
||||
&::after {
|
||||
content: '●';
|
||||
margin-left: 4px;
|
||||
animation: blink 1.5s ease-in-out infinite;
|
||||
}
|
||||
}
|
||||
|
||||
.rec {
|
||||
background-color: grey;
|
||||
background: linear-gradient(135deg, #8b5cf6 0%, #7c3aed 100%);
|
||||
box-shadow: 0 0 12px rgba(139, 92, 246, 0.4), 0 2px 8px rgba(0, 0, 0, 0.3);
|
||||
|
||||
&::after {
|
||||
content: '◉';
|
||||
margin-left: 4px;
|
||||
}
|
||||
}
|
||||
|
||||
.description-icon {
|
||||
float: right;
|
||||
margin-left: auto;
|
||||
cursor: pointer;
|
||||
opacity: 0.6;
|
||||
transition: all 0.2s ease;
|
||||
padding: 4px;
|
||||
border-radius: 4px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
mat-icon {
|
||||
font-size: 18px;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
opacity: 1;
|
||||
background: rgba(0, 0, 0, 0.04);
|
||||
}
|
||||
|
||||
&:active {
|
||||
transform: scale(0.95);
|
||||
}
|
||||
}
|
||||
|
||||
// Dark theme support
|
||||
::ng-deep .dark-theme {
|
||||
.description-icon:hover {
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
}
|
||||
}
|
||||
|
||||
// Animations
|
||||
@keyframes livePulse {
|
||||
0%, 100% {
|
||||
box-shadow: 0 0 12px rgba(239, 68, 68, 0.5), 0 2px 8px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
50% {
|
||||
box-shadow: 0 0 20px rgba(239, 68, 68, 0.8), 0 2px 12px rgba(0, 0, 0, 0.4);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes blink {
|
||||
0%, 100% {
|
||||
opacity: 1;
|
||||
}
|
||||
50% {
|
||||
opacity: 0.3;
|
||||
}
|
||||
}
|
||||
@@ -23,7 +23,7 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<mat-divider />
|
||||
<div class="header-divider"></div>
|
||||
<div id="date-navigator">
|
||||
<button
|
||||
class="previous-day"
|
||||
@@ -46,35 +46,40 @@
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<mat-divider />
|
||||
|
||||
@if (timeshiftUntil) {
|
||||
<!-- program list -->
|
||||
<mat-selection-list
|
||||
id="program-list"
|
||||
[multiple]="false"
|
||||
hideSingleSelectionIndicator="true"
|
||||
>
|
||||
<div id="program-list">
|
||||
@if (items?.length > 0) {
|
||||
@for (program of items; track program.start + program.stop) {
|
||||
@for (program of items; track $index) {
|
||||
@if (
|
||||
program.start < timeshiftUntil || program.start >= timeNow
|
||||
) {
|
||||
<mat-list-option>
|
||||
<div matListItemLine>
|
||||
<div class="program-item">
|
||||
<div class="program-meta">
|
||||
<app-epg-list-item
|
||||
[timeshiftUntil]="timeshiftUntil"
|
||||
[timeNow]="timeNow"
|
||||
[item]="program"
|
||||
/>
|
||||
</div>
|
||||
<p matListItemTitle class="program-title">
|
||||
<div class="program-title">
|
||||
{{ program?.title?.[0]?.value }}
|
||||
</p>
|
||||
</mat-list-option>
|
||||
</div>
|
||||
@if (isProgramPlaying(program)) {
|
||||
<div class="program-progress">
|
||||
<div class="epg-progress-track">
|
||||
<div
|
||||
class="epg-progress-fill"
|
||||
[style.width.%]="calculateProgress(program)"
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
} @else {
|
||||
<mat-list-option
|
||||
[value]="program"
|
||||
<div
|
||||
class="program-item clickable"
|
||||
[class.active]="
|
||||
timeNow >= program.start && timeNow <= program.stop
|
||||
"
|
||||
@@ -87,25 +92,34 @@
|
||||
)
|
||||
"
|
||||
>
|
||||
<div matListItemLine>
|
||||
<div class="program-meta">
|
||||
<app-epg-list-item
|
||||
[timeshiftUntil]="timeshiftUntil"
|
||||
[timeNow]="timeNow"
|
||||
[item]="program"
|
||||
/>
|
||||
</div>
|
||||
<p
|
||||
matListItemLine
|
||||
<div
|
||||
class="program-title"
|
||||
[innerHTML]="program.title?.[0]?.value"
|
||||
></p>
|
||||
</mat-list-option>
|
||||
></div>
|
||||
@if (isProgramPlaying(program)) {
|
||||
<div class="program-progress">
|
||||
<div class="epg-progress-track">
|
||||
<div
|
||||
class="epg-progress-fill"
|
||||
[style.width.%]="calculateProgress(program)"
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
<mat-divider />
|
||||
}
|
||||
} @else {
|
||||
<p class="epg-not-available">
|
||||
<div class="epg-not-available">
|
||||
{{ 'EPG.EPG_NOT_AVAILABLE_DATE' | translate }}
|
||||
</p>
|
||||
</div>
|
||||
}
|
||||
</mat-selection-list>
|
||||
</div>
|
||||
}
|
||||
@@ -10,14 +10,20 @@
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
.active {
|
||||
border-bottom: 1px dashed #666;
|
||||
// Header divider
|
||||
.header-divider {
|
||||
height: 1px;
|
||||
background: rgba(0, 0, 0, 0.12);
|
||||
}
|
||||
|
||||
.program-title {
|
||||
font-size: 14px;
|
||||
// Dark theme support
|
||||
::ng-deep .dark-theme {
|
||||
.header-divider {
|
||||
background: rgba(255, 255, 255, 0.12);
|
||||
}
|
||||
}
|
||||
|
||||
// Channel header
|
||||
#channel-header {
|
||||
flex-direction: column;
|
||||
box-sizing: border-box;
|
||||
@@ -27,20 +33,27 @@
|
||||
flex-direction: row;
|
||||
box-sizing: border-box;
|
||||
display: flex;
|
||||
padding: 10px;
|
||||
padding: 12px;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.channel-icon {
|
||||
overflow: hidden;
|
||||
margin-right: 10px;
|
||||
place-content: center;
|
||||
align-items: center;
|
||||
flex-direction: row;
|
||||
box-sizing: border-box;
|
||||
display: flex;
|
||||
flex: 1 1 60px;
|
||||
max-width: 60px;
|
||||
min-width: 60px;
|
||||
flex: 0 0 48px;
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border-radius: 4px;
|
||||
|
||||
img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: contain;
|
||||
}
|
||||
}
|
||||
|
||||
.channel-details {
|
||||
@@ -50,41 +63,153 @@
|
||||
align-self: center;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.channel-name {
|
||||
font-weight: 800;
|
||||
font-family: 'DM Sans', sans-serif;
|
||||
font-weight: 500;
|
||||
font-size: 0.9rem;
|
||||
line-height: 1.3;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.program-name {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 500;
|
||||
opacity: 0.85;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 1;
|
||||
-webkit-box-orient: vertical;
|
||||
font-size: 0.9em;
|
||||
white-space: nowrap;
|
||||
line-height: 1.2;
|
||||
}
|
||||
}
|
||||
|
||||
#program-list {
|
||||
flex: 1 1 1e-9px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.epg-not-available {
|
||||
padding: 0 10px;
|
||||
}
|
||||
|
||||
mat-selection-list {
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
// Date navigator
|
||||
#date-navigator {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 8px;
|
||||
gap: 8px;
|
||||
|
||||
button {
|
||||
transition: all 0.2s ease;
|
||||
|
||||
&[disabled] {
|
||||
opacity: 0.3;
|
||||
}
|
||||
}
|
||||
|
||||
.selected-date {
|
||||
font-size: 0.9em;
|
||||
flex: 1;
|
||||
font-family: 'DM Sans', sans-serif;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 500;
|
||||
text-align: center;
|
||||
opacity: 0.8;
|
||||
}
|
||||
}
|
||||
|
||||
// Program list styling
|
||||
#program-list {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
// Program list items
|
||||
.program-item {
|
||||
border-bottom: 1px solid rgba(0, 0, 0, 0.08);
|
||||
transition: background-color 0.2s ease;
|
||||
position: relative;
|
||||
padding: 10px 12px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
cursor: default;
|
||||
|
||||
&.clickable {
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
background-color: rgba(0, 0, 0, 0.04);
|
||||
}
|
||||
}
|
||||
|
||||
&.active {
|
||||
background-color: rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
|
||||
.program-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.program-title {
|
||||
font-family: 'DM Sans', sans-serif;
|
||||
font-size: 0.9rem;
|
||||
font-weight: 500;
|
||||
line-height: 1.4;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
}
|
||||
|
||||
.program-progress {
|
||||
margin-top: 4px;
|
||||
}
|
||||
}
|
||||
|
||||
// Dark theme support
|
||||
::ng-deep .dark-theme {
|
||||
.program-item {
|
||||
border-bottom-color: rgba(255, 255, 255, 0.08);
|
||||
|
||||
&.clickable:hover {
|
||||
background-color: rgba(255, 255, 255, 0.04);
|
||||
}
|
||||
|
||||
&.active {
|
||||
background-color: rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Progress bar (matching channel list)
|
||||
.epg-progress-track {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 3px;
|
||||
background: rgba(128, 128, 128, 0.2);
|
||||
border-radius: 2px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.epg-progress-fill {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
height: 100%;
|
||||
background: linear-gradient(90deg, #4f46e5 0%, #7c3aed 50%, #ec4899 100%);
|
||||
border-radius: 2px;
|
||||
transition: width 0.5s ease-out;
|
||||
box-shadow: 0 0 6px rgba(124, 58, 237, 0.4);
|
||||
}
|
||||
|
||||
// Empty state styling
|
||||
.epg-not-available {
|
||||
padding: 24px;
|
||||
text-align: center;
|
||||
font-family: 'DM Sans', sans-serif;
|
||||
font-size: 0.875rem;
|
||||
opacity: 0.6;
|
||||
font-style: italic;
|
||||
}
|
||||
@@ -1,10 +1,8 @@
|
||||
import { AsyncPipe } from '@angular/common';
|
||||
import { Component, inject, OnInit } from '@angular/core';
|
||||
import { ChangeDetectionStrategy, ChangeDetectorRef, Component, inject, OnInit } from '@angular/core';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { MatIconButton } from '@angular/material/button';
|
||||
import { MatDivider } from '@angular/material/divider';
|
||||
import { MatIcon } from '@angular/material/icon';
|
||||
import { MatListModule } from '@angular/material/list';
|
||||
import { MatTooltip } from '@angular/material/tooltip';
|
||||
import { MomentDatePipe } from '@iptvnator/pipes';
|
||||
import { Store } from '@ngrx/store';
|
||||
@@ -34,10 +32,8 @@ const DATE_FORMAT = 'YYYY-MM-DD';
|
||||
AsyncPipe,
|
||||
EpgListItemComponent,
|
||||
FormsModule,
|
||||
MatDivider,
|
||||
MatIcon,
|
||||
MatIconButton,
|
||||
MatListModule,
|
||||
MatTooltip,
|
||||
MomentDatePipe,
|
||||
TranslatePipe,
|
||||
@@ -45,10 +41,12 @@ const DATE_FORMAT = 'YYYY-MM-DD';
|
||||
selector: 'app-epg-list',
|
||||
templateUrl: './epg-list.component.html',
|
||||
styleUrls: ['./epg-list.component.scss'],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class EpgListComponent implements OnInit {
|
||||
private readonly store = inject(Store);
|
||||
private readonly epgService = inject(EpgService);
|
||||
private readonly cdr = inject(ChangeDetectorRef);
|
||||
|
||||
/** Channel info in EPG format */
|
||||
channel!: EpgChannel;
|
||||
@@ -169,6 +167,9 @@ export class EpgListComponent implements OnInit {
|
||||
// Clear the current EPG program when no programs available
|
||||
this.store.dispatch(resetActiveEpgProgram());
|
||||
}
|
||||
|
||||
// Trigger change detection for OnPush strategy
|
||||
this.cdr.markForCheck();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -224,6 +225,8 @@ export class EpgListComponent implements OnInit {
|
||||
this.store.dispatch(
|
||||
setCurrentEpgProgram({ program: this.playingNow })
|
||||
);
|
||||
// Trigger change detection for OnPush strategy
|
||||
this.cdr.markForCheck();
|
||||
});
|
||||
}
|
||||
|
||||
@@ -245,5 +248,33 @@ export class EpgListComponent implements OnInit {
|
||||
this.store.dispatch(setActiveEpgProgram({ program }));
|
||||
}
|
||||
this.playingNow = program;
|
||||
// Trigger change detection for OnPush strategy
|
||||
this.cdr.markForCheck();
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculates the progress percentage for the EPG program
|
||||
*/
|
||||
calculateProgress(program: EpgProgram): number {
|
||||
const now = new Date().getTime();
|
||||
const start = new Date(program.start).getTime();
|
||||
const stop = new Date(program.stop).getTime();
|
||||
|
||||
const total = stop - start;
|
||||
const elapsed = now - start;
|
||||
|
||||
const progress = Math.min(100, Math.max(0, (elapsed / total) * 100));
|
||||
|
||||
return progress;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if program is currently playing
|
||||
*/
|
||||
isProgramPlaying(program: EpgProgram): boolean {
|
||||
const isPlaying =
|
||||
this.timeNow >= program.start && this.timeNow <= program.stop;
|
||||
|
||||
return isPlaying;
|
||||
}
|
||||
}
|
||||
Generated
+1556
-103
File diff suppressed because it is too large.
Load diff
@@ -23,6 +23,22 @@
|
||||
"appId": "com.fourgray.iptvnator",
|
||||
"productName": "IPTVnator",
|
||||
"artifactName": "${name}-${version}-${os}-${arch}.${ext}",
|
||||
"directories": {
|
||||
"buildResources": "dist/apps",
|
||||
"output": "dist/executables"
|
||||
},
|
||||
"files": [
|
||||
"electron-backend/**/*",
|
||||
"web/**/*",
|
||||
"remote-control-web/**/*",
|
||||
"!**/*.map"
|
||||
],
|
||||
"extraMetadata": {
|
||||
"main": "electron-backend/main.js"
|
||||
},
|
||||
"extraResources": [
|
||||
"dist/apps/electron-backend/workers"
|
||||
],
|
||||
"mac": {
|
||||
"target": {
|
||||
"target": "dmg",
|
||||
@@ -185,6 +201,7 @@
|
||||
"drizzle-kit": "0.31.5",
|
||||
"electron": "^39.0.0",
|
||||
"electron-builder": "^26.0.12",
|
||||
"esbuild": "0.27.0",
|
||||
"eslint": "^9.8.0",
|
||||
"eslint-config-prettier": "^10.1.8",
|
||||
"eslint-plugin-import": "2.32.0",
|
||||
|
||||
Reference in new issue
Block a user