Merge pull request #662 from 4gray/feat-store-backbutton-and-player-teardown

EPG Worker fix
This commit is contained in:
4gray authored and GitHub committed 2025-11-24 22:57:07 +01:00
commit 59081ef342
19 files changed
+2058 -317

No files matched your search

+6 -3
View File
@@ -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
]
}
+44
View File
@@ -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();
+9 -15
View File
@@ -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!');
+22 -10
View File
@@ -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,
+13 -2
View File
@@ -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: '';
+3 -1
View File
@@ -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;
}
}
+1556 -103
View File
File diff suppressed because it is too large. Load diff
+17
View File
@@ -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",