From adb89145a313efa9c81054f12ed0e6dcbabff3d8 Mon Sep 17 00:00:00 2001 From: 4gray Date: Sun, 18 Jan 2026 11:40:27 +0100 Subject: [PATCH] feat: playback position persistence for all players and grid indicators --- .../src/app/api/main.preload.ts | 68 ++- .../src/app/database/schema.ts | 3 + .../src/app/events/database.events.ts | 1 + .../database/playback-position.events.ts | 218 ++++++++ .../src/app/events/player.events.ts | 470 ++++++++++++++---- apps/web/src/app/services/electron.service.ts | 8 +- apps/web/src/app/services/player.service.ts | 10 +- .../grid-list/grid-list.component.scss | 7 + .../grid-list/grid-list.component.ts | 43 +- .../stalker-series-view.component.html | 1 + apps/web/src/app/stalker/stalker.store.ts | 10 +- .../category-content-view.component.html | 6 +- .../category-content-view.component.ts | 56 ++- .../electron-xtream-data-source.ts | 62 ++- .../data-sources/pwa-xtream-data-source.ts | 123 +++++ .../xtream-data-source.interface.ts | 66 +++ .../player-dialog.component.html | 6 +- .../player-dialog/player-dialog.component.ts | 21 + .../season-container.component.html | 37 ++ .../season-container.component.scss | 157 +++++- .../season-container.component.ts | 95 +++- .../serial-details.component.html | 1 + .../serial-details.component.ts | 21 +- .../progress-capsule.component.ts | 55 ++ .../watched-badge/watched-badge.component.ts | 44 ++ .../app/xtream-tauri/stores/features/index.ts | 1 + .../with-playback-positions.feature.ts | 394 +++++++++++++++ .../stores/features/with-player.feature.ts | 8 +- .../app/xtream-tauri/stores/xtream.store.ts | 10 +- .../vod-details/vod-details.component.html | 25 +- .../vod-details/vod-details.component.ts | 70 ++- .../xtream-shell/xtream-shell.component.ts | 1 - apps/web/src/assets/i18n/de.json | 8 + apps/web/src/assets/i18n/en.json | 1 + apps/web/src/assets/i18n/es.json | 8 + apps/web/src/assets/i18n/ru.json | 8 + apps/web/src/typings.d.ts | 90 ++++ libs/services/src/index.ts | 1 + .../src/lib/playback-position.service.ts | 86 ++++ libs/shared/database/src/lib/connection.ts | 18 + libs/shared/database/src/lib/schema.ts | 45 ++ .../lib/art-player/art-player.component.ts | 20 + .../html-video-player.component.ts | 20 + .../lib/vjs-player/vjs-player.component.ts | 20 + .../web-player-view.component.html | 26 +- .../web-player-view.component.ts | 7 + 46 files changed, 2310 insertions(+), 146 deletions(-) create mode 100644 apps/electron-backend/src/app/events/database/playback-position.events.ts create mode 100644 apps/web/src/app/xtream-tauri/shared/progress-capsule/progress-capsule.component.ts create mode 100644 apps/web/src/app/xtream-tauri/shared/watched-badge/watched-badge.component.ts create mode 100644 apps/web/src/app/xtream-tauri/stores/features/with-playback-positions.feature.ts create mode 100644 libs/services/src/lib/playback-position.service.ts diff --git a/apps/electron-backend/src/app/api/main.preload.ts b/apps/electron-backend/src/app/api/main.preload.ts index 5da932f3b..4cc3af233 100644 --- a/apps/electron-backend/src/app/api/main.preload.ts +++ b/apps/electron-backend/src/app/api/main.preload.ts @@ -28,6 +28,16 @@ contextBridge.exposeInMainWorld('electron', { ) => { ipcRenderer.on('EPG_PROGRESS_UPDATE', (_event, data) => callback(data)); }, + // Playback position update listener + onPlaybackPositionUpdate: (callback: (data: any) => void) => { + ipcRenderer.on('playback-position-update', (_event, data) => + callback(data) + ); + }, + // Remove playback position listener + removePlaybackPositionListener: () => { + ipcRenderer.removeAllListeners('playback-position-update'); + }, // DB save content progress listener onDbSaveContentProgress: (callback: (count: number) => void) => { ipcRenderer.on('DB_SAVE_CONTENT_PROGRESS', (_event, count) => @@ -58,7 +68,9 @@ contextBridge.exposeInMainWorld('electron', { title: string, userAgent: string, referer?: string, - origin?: string + origin?: string, + contentInfo?: any, + startTime?: number ) => ipcRenderer.invoke( 'OPEN_MPV_PLAYER', @@ -66,14 +78,18 @@ contextBridge.exposeInMainWorld('electron', { title, userAgent, referer, - origin + origin, + contentInfo, + startTime ), openInVlc: ( url: string, title: string, userAgent: string, referer?: string, - origin?: string + origin?: string, + contentInfo?: any, + startTime?: number ) => ipcRenderer.invoke( 'OPEN_VLC_PLAYER', @@ -81,7 +97,9 @@ contextBridge.exposeInMainWorld('electron', { title, userAgent, referer, - origin + origin, + contentInfo, + startTime ), autoUpdatePlaylists: (playlists) => ipcRenderer.invoke('AUTO_UPDATE', playlists), @@ -196,5 +214,47 @@ contextBridge.exposeInMainWorld('electron', { dbGetContentByXtreamId: (xtreamId: number, playlistId: string) => ipcRenderer.invoke('DB_GET_CONTENT_BY_XTREAM_ID', xtreamId, playlistId), dbDeleteAllPlaylists: () => ipcRenderer.invoke('DB_DELETE_ALL_PLAYLISTS'), + // Playback Positions + dbSavePlaybackPosition: (playlistId: string, data: any) => + ipcRenderer.invoke('DB_SAVE_PLAYBACK_POSITION', playlistId, data), + dbGetPlaybackPosition: ( + playlistId: string, + contentXtreamId: number, + contentType: 'vod' | 'episode' + ) => + ipcRenderer.invoke( + 'DB_GET_PLAYBACK_POSITION', + playlistId, + contentXtreamId, + contentType + ), + dbGetSeriesPlaybackPositions: ( + playlistId: string, + seriesXtreamId: number + ) => + ipcRenderer.invoke( + 'DB_GET_SERIES_PLAYBACK_POSITIONS', + playlistId, + seriesXtreamId + ), + dbGetRecentPlaybackPositions: (playlistId: string, limit?: number) => + ipcRenderer.invoke( + 'DB_GET_RECENT_PLAYBACK_POSITIONS', + playlistId, + limit + ), + dbGetAllPlaybackPositions: (playlistId: string) => + ipcRenderer.invoke('DB_GET_ALL_PLAYBACK_POSITIONS', playlistId), + dbClearPlaybackPosition: ( + playlistId: string, + contentXtreamId: number, + contentType: 'vod' | 'episode' + ) => + ipcRenderer.invoke( + 'DB_CLEAR_PLAYBACK_POSITION', + playlistId, + contentXtreamId, + contentType + ), getLocalIpAddresses: () => ipcRenderer.invoke('get-local-ip-addresses'), }); diff --git a/apps/electron-backend/src/app/database/schema.ts b/apps/electron-backend/src/app/database/schema.ts index cd9c38421..5a23e84e0 100644 --- a/apps/electron-backend/src/app/database/schema.ts +++ b/apps/electron-backend/src/app/database/schema.ts @@ -12,6 +12,7 @@ export { favorites, epgChannels, epgPrograms, + playbackPositions, // Types type Playlist, type NewPlaylist, @@ -27,5 +28,7 @@ export { type NewEpgChannel, type EpgProgramDb, type NewEpgProgramDb, + type PlaybackPosition, + type NewPlaybackPosition, } from 'database'; diff --git a/apps/electron-backend/src/app/events/database.events.ts b/apps/electron-backend/src/app/events/database.events.ts index cbe25b94e..a9dd86be4 100644 --- a/apps/electron-backend/src/app/events/database.events.ts +++ b/apps/electron-backend/src/app/events/database.events.ts @@ -9,6 +9,7 @@ import './database/category.events'; import './database/content.events'; import './database/epg-db.events'; import './database/favorites.events'; +import './database/playback-position.events'; import './database/playlist.events'; import './database/recently-viewed.events'; import './database/xtream.events'; diff --git a/apps/electron-backend/src/app/events/database/playback-position.events.ts b/apps/electron-backend/src/app/events/database/playback-position.events.ts new file mode 100644 index 000000000..423458de0 --- /dev/null +++ b/apps/electron-backend/src/app/events/database/playback-position.events.ts @@ -0,0 +1,218 @@ +/** + * Playback Position IPC event handlers + * Operations for managing video playback progress + */ + +import { and, desc, eq, sql } from 'drizzle-orm'; +import { ipcMain } from 'electron'; +import { getDatabase } from '../../database/connection'; +import * as schema from '../../database/schema'; + +/** + * Save/update playback position for content + */ +ipcMain.handle( + 'DB_SAVE_PLAYBACK_POSITION', + async (event, playlistId: string, data: any) => { + try { + console.log( + '[DatabaseEvents] Saving playback position:', + playlistId, + data + ); + const db = await getDatabase(); + + // Prepare values + const values = { + playlistId, + contentXtreamId: data.contentXtreamId, + contentType: data.contentType, + seriesXtreamId: data.seriesXtreamId, + seasonNumber: data.seasonNumber, + episodeNumber: data.episodeNumber, + positionSeconds: data.positionSeconds, + durationSeconds: data.durationSeconds, + updatedAt: sql`CURRENT_TIMESTAMP`, + }; + + // Check if exists + const existing = await db + .select() + .from(schema.playbackPositions) + .where( + and( + eq(schema.playbackPositions.playlistId, playlistId), + eq( + schema.playbackPositions.contentXtreamId, + data.contentXtreamId + ), + eq( + schema.playbackPositions.contentType, + data.contentType + ) + ) + ) + .limit(1); + + if (existing.length > 0) { + await db + .update(schema.playbackPositions) + .set(values) + .where(eq(schema.playbackPositions.id, existing[0].id)); + } else { + await db.insert(schema.playbackPositions).values(values); + } + + return { success: true }; + } catch (error) { + console.error('Error saving playback position:', error); + throw error; + } + } +); + +/** + * Get playback position for a specific content item + */ +ipcMain.handle( + 'DB_GET_PLAYBACK_POSITION', + async ( + event, + playlistId: string, + contentXtreamId: number, + contentType: 'vod' | 'episode' + ) => { + try { + console.log( + `[DatabaseEvents] Getting playback position: playlistId=${playlistId}, id=${contentXtreamId}, type=${contentType}` + ); + const db = await getDatabase(); + const result = await db + .select() + .from(schema.playbackPositions) + .where( + and( + eq(schema.playbackPositions.playlistId, playlistId), + eq( + schema.playbackPositions.contentXtreamId, + contentXtreamId + ), + eq(schema.playbackPositions.contentType, contentType) + ) + ) + .limit(1); + + return result[0] || null; + } catch (error) { + console.error('Error getting playback position:', error); + throw error; + } + } +); + +/** + * Get all episode positions for a series + */ +ipcMain.handle( + 'DB_GET_SERIES_PLAYBACK_POSITIONS', + async (event, playlistId: string, seriesXtreamId: number) => { + try { + const db = await getDatabase(); + const result = await db + .select() + .from(schema.playbackPositions) + .where( + and( + eq(schema.playbackPositions.playlistId, playlistId), + eq( + schema.playbackPositions.seriesXtreamId, + seriesXtreamId + ), + eq(schema.playbackPositions.contentType, 'episode') + ) + ); + + return result; + } catch (error) { + console.error('Error getting series playback positions:', error); + throw error; + } + } +); + +/** + * Get recently watched items with positions + */ +ipcMain.handle( + 'DB_GET_RECENT_PLAYBACK_POSITIONS', + async (event, playlistId: string, limit: number = 20) => { + try { + const db = await getDatabase(); + const result = await db + .select() + .from(schema.playbackPositions) + .where(eq(schema.playbackPositions.playlistId, playlistId)) + .orderBy(desc(schema.playbackPositions.updatedAt)) + .limit(limit); + + return result; + } catch (error) { + console.error('Error getting recent playback positions:', error); + throw error; + } + } +); + +/** + * Get all playback positions for a playlist (to populate grid view) + */ +ipcMain.handle( + 'DB_GET_ALL_PLAYBACK_POSITIONS', + async (event, playlistId: string) => { + try { + const db = await getDatabase(); + const result = await db + .select() + .from(schema.playbackPositions) + .where(eq(schema.playbackPositions.playlistId, playlistId)); + + return result; + } catch (error) { + console.error('Error getting all playback positions:', error); + throw error; + } + } +); + +/** + * Clear playback position + */ +ipcMain.handle( + 'DB_CLEAR_PLAYBACK_POSITION', + async ( + event, + playlistId: string, + contentXtreamId: number, + contentType: 'vod' | 'episode' + ) => { + try { + const db = await getDatabase(); + await db + .delete(schema.playbackPositions) + .where( + and( + eq(schema.playbackPositions.playlistId, playlistId), + eq( + schema.playbackPositions.contentXtreamId, + contentXtreamId + ), + eq(schema.playbackPositions.contentType, contentType) + ) + ); + return { success: true }; + } catch (error) { + console.error('Error clearing playback position:', error); + throw error; + } + } +); diff --git a/apps/electron-backend/src/app/events/player.events.ts b/apps/electron-backend/src/app/events/player.events.ts index 4331c7617..4c34f6582 100644 --- a/apps/electron-backend/src/app/events/player.events.ts +++ b/apps/electron-backend/src/app/events/player.events.ts @@ -15,7 +15,7 @@ import { import { ChildProcess, spawn } from 'child_process'; import { existsSync } from 'fs'; -import { createConnection } from 'net'; +import { AddressInfo, createConnection, createServer } from 'net'; import path from 'path'; export default class PlayerEvents { @@ -27,6 +27,7 @@ export default class PlayerEvents { // Keep track of the running MPV process for reuse let mpvProcess: ChildProcess | null = null; let mpvSocketPath: string | null = null; +let positionPollingInterval: NodeJS.Timeout | null = null; // Helper function to send error notifications to the renderer function sendPlayerErrorNotification(player: 'MPV' | 'VLC', error: string) { @@ -67,6 +68,206 @@ function sendPlayerErrorNotification(player: 'MPV' | 'VLC', error: string) { } } +// Query MPV property via IPC +async function getMpvProperty( + socketPath: string, + property: string +): Promise { + return new Promise((resolve) => { + const client = createConnection(socketPath); + const request = + JSON.stringify({ + command: ['get_property', property], + }) + '\n'; + + let data = ''; + + client.on('connect', () => { + // console.log('[PlayerEvents] MPV IPC connected, sending:', request.trim()); + client.write(request); + }); + + client.on('data', (chunk) => { + data += chunk.toString(); + if (data.includes('\n')) { + try { + const lines = data.split('\n'); + for (const line of lines) { + if (!line.trim()) continue; + const response = JSON.parse(line); + // console.log('[PlayerEvents] MPV IPC response:', response); + if (response.data !== undefined) { + client.destroy(); + resolve(response.data); + return; + } + } + } catch (e) { + // console.error('[PlayerEvents] MPV IPC parse error:', e, data); + } + } + }); + + client.on('end', () => { + // Fallback if no newline was detected but connection closed + if (data && !data.includes('\n')) { + try { + const response = JSON.parse(data); + // console.log('[PlayerEvents] MPV IPC response (end):', response); + resolve(response.data ?? null); + } catch (e) { + resolve(null); + } + } + }); + + client.on('error', (err) => { + // console.error('[PlayerEvents] MPV IPC error:', err.message); + resolve(null); + }); + + setTimeout(() => { + client.destroy(); + // Don't resolve null here immediately, let the logic handle it or just timeout + // resolving null here might race with successful data + }, 2000); + }); +} + +function stopPositionPolling() { + if (positionPollingInterval) { + clearInterval(positionPollingInterval); + positionPollingInterval = null; + } +} + +function startPositionPolling(socketPath: string, contentInfo: any) { + stopPositionPolling(); + + // Initial delay to let video load + setTimeout(() => { + // console.log('[PlayerEvents] Starting position polling interval'); + positionPollingInterval = setInterval(async () => { + try { + const position = await getMpvProperty(socketPath, 'time-pos'); + const duration = await getMpvProperty(socketPath, 'duration'); + + // console.log(`[PlayerEvents] Polling MPV: pos=${position}, dur=${duration}`); + + if (position !== null && App.mainWindow) { + /* + console.log('[PlayerEvents] Sending playback-position-update', { + positionSeconds: Math.floor(position), + durationSeconds: duration ? Math.floor(duration) : null, + ...contentInfo, + }); + */ + App.mainWindow.webContents.send( + 'playback-position-update', + { + positionSeconds: Math.floor(position), + durationSeconds: duration + ? Math.floor(duration) + : null, + ...contentInfo, + } + ); + } + } catch (err) { + // console.error('[PlayerEvents] Error during polling:', err); + // MPV may have closed, stop polling + stopPositionPolling(); + } + }, 5000); // Changed to 5 seconds for debugging + }, 2000); // Reduced initial delay +} + +// VLC Polling +let vlcPollingInterval: NodeJS.Timeout | null = null; + +function stopVlcPositionPolling() { + if (vlcPollingInterval) { + clearInterval(vlcPollingInterval); + vlcPollingInterval = null; + } +} + +async function getVlcProperty(port: number, command: string): Promise { + return new Promise((resolve) => { + const client = createConnection({ port, host: '127.0.0.1' }); + let data = ''; + + client.on('connect', () => { + client.write(command + '\n'); + }); + + client.on('data', (chunk) => { + data += chunk.toString(); + if (data.includes('>')) { + // VLC prompt means command finished + client.destroy(); + } + }); + + client.on('close', () => { + // Parse data + const lines = data + .split('\n') + .map((l) => l.trim()) + .filter( + (l) => l && !l.includes('>') && !l.startsWith(command) + ); + // The result should be one of the lines + const result = lines.length > 0 ? lines[0] : ''; + resolve(result); + }); + + client.on('error', () => resolve('')); + + setTimeout(() => { + if (!client.destroyed) client.destroy(); + resolve(''); + }, 1000); + }); +} + +function startVlcPositionPolling(port: number, contentInfo: any) { + stopVlcPositionPolling(); + + // VLC needs time to start and bind port + setTimeout(() => { + /* + console.log( + `[PlayerEvents] Starting VLC polling on port ${port}`, + contentInfo + ); + */ + vlcPollingInterval = setInterval(async () => { + try { + const timeStr = await getVlcProperty(port, 'get_time'); + const lenStr = await getVlcProperty(port, 'get_length'); + + const position = parseInt(timeStr, 10); + const duration = parseInt(lenStr, 10); + + if (!isNaN(position) && App.mainWindow) { + App.mainWindow.webContents.send( + 'playback-position-update', + { + positionSeconds: position, + durationSeconds: !isNaN(duration) ? duration : null, + ...contentInfo, + } + ); + } + } catch (err) { + // console.error('[PlayerEvents] VLC polling error:', err); + stopVlcPositionPolling(); + } + }, 5000); + }, 3000); +} + // Helper function to send command to MPV via IPC function sendMpvCommand(command: string, args: string[]): Promise { return new Promise((resolve, reject) => { @@ -101,7 +302,9 @@ ipcMain.handle( title: string, userAgent?: string, referer?: string, - origin?: string + origin?: string, + contentInfo?: any, + startTime?: number ) => { try { const mpvPath = getMpvPath(); @@ -113,6 +316,8 @@ ipcMain.handle( console.log('User-Agent:', userAgent); console.log('Referer:', referer); console.log('Origin:', origin); + console.log('Content Info:', contentInfo); + console.log('Start Time:', startTime); // If reuse is enabled and there's an existing process, try to use it if ( @@ -127,6 +332,22 @@ ipcMain.handle( console.log( 'Successfully loaded new URL in existing MPV instance' ); + + // Seek if startTime provided + if (startTime) { + await sendMpvCommand('seek', [ + String(startTime), + 'absolute', + ]); + } + + // Restart polling with new content info + if (contentInfo) { + startPositionPolling(mpvSocketPath, contentInfo); + } else { + stopPositionPolling(); + } + return; } catch (err) { console.error( @@ -136,6 +357,7 @@ ipcMain.handle( // If it fails, clear the reference and create a new one mpvProcess = null; mpvSocketPath = null; + stopPositionPolling(); // Fall through to create new instance } } @@ -172,6 +394,11 @@ ipcMain.handle( args.push(`--force-media-title=${title}`); } + // Add start time if provided + if (startTime) { + args.push(`--start=${startTime}`); + } + // Add URL last args.push(url); @@ -181,7 +408,9 @@ ipcMain.handle( shell: false, detached: !reuseInstance, // Only pipe stdio when reusing instance; use 'ignore' for detached to allow clean shutdown - stdio: reuseInstance ? ['ignore', 'pipe', 'pipe'] : 'ignore', + stdio: reuseInstance + ? ['ignore', 'pipe', 'pipe'] + : 'ignore', }); // Capture stdout @@ -242,6 +471,7 @@ ipcMain.handle( console.error('Failed to start MPV player:', err); mpvProcess = null; mpvSocketPath = null; + stopPositionPolling(); reject( new Error( `Failed to start MPV player: ${err.message}. Make sure MPV is installed and the path '${mpvPath}' is correct.` @@ -253,6 +483,7 @@ ipcMain.handle( console.log(`MPV exited with code ${code}`); mpvProcess = null; mpvSocketPath = null; + stopPositionPolling(); // Log non-zero exit codes as errors and notify user if (code !== 0 && code !== null) { @@ -279,6 +510,11 @@ ipcMain.handle( proc.unref(); } + // Start polling if content info is provided + if (contentInfo) { + startPositionPolling(socketPath, contentInfo); + } + // Resolve immediately if spawn succeeds (error event would fire if it fails) // Give a small window to catch immediate spawn errors setTimeout(() => { @@ -291,6 +527,7 @@ ipcMain.handle( console.error('Error opening MPV player:', error); mpvProcess = null; mpvSocketPath = null; + stopPositionPolling(); throw error; } } @@ -322,18 +559,38 @@ ipcMain.handle( title: string, userAgent?: string, referer?: string, - origin?: string + origin?: string, + contentInfo?: any, + startTime?: number ) => { try { const vlcPath = getVlcPath(); console.log('Opening VLC player with path:', vlcPath); console.log('URL:', url); - console.log('User-Agent:', userAgent); - console.log('Referer:', referer); - console.log('Origin:', origin); + // console.log('User-Agent:', userAgent); + // console.log('Referer:', referer); + // console.log('Origin:', origin); + // console.log('Start Time:', startTime); + + // Get free port for RC interface + let rcPort = 0; + if (contentInfo) { + try { + rcPort = await getFreePort(); + console.log('Using VLC RC port:', rcPort); + } catch (e) { + console.error('Failed to get free port for VLC:', e); + } + } const args: string[] = []; + if (rcPort > 0) { + args.push('--extraintf=rc'); + args.push(`--rc-host=127.0.0.1:${rcPort}`); + // args.push('--rc-quiet'); // Suppress status messages in stdout + } + // Add user agent if provided (VLC uses :http-user-agent= format) if (userAgent) { args.push(`:http-user-agent=${userAgent}`); @@ -352,104 +609,124 @@ ipcMain.handle( args.push(`--meta-title=${title}`); } + // Add start time if provided + if (startTime) { + args.push(`--start-time=${startTime}`); + } + // Add URL last args.push(url); + console.log('VLC Args:', args); + // Wrap spawn in a promise to catch startup errors await new Promise((resolve, reject) => { - const proc = spawn(vlcPath, args, { - shell: false, - detached: true, - stdio: 'ignore', // Use 'ignore' for detached to allow clean shutdown - }); + const spawnVlc = (spawnArgs: string[], isRetry = false) => { + const proc = spawn(vlcPath, spawnArgs, { + shell: false, + detached: true, + stdio: 'ignore', // Use 'ignore' for detached to allow clean shutdown + }); - // Capture stdout - if (proc.stdout) { - proc.stdout.on('data', (data) => { - const output = data.toString().trim(); - if (output) { - console.log('[VLC stdout]:', output); + // Start polling if we have port and content info and NOT retrying (RC disabled on retry) + if (!isRetry && rcPort > 0 && contentInfo) { + startVlcPositionPolling(rcPort, contentInfo); + } - // VLC sometimes outputs errors to stdout instead of stderr - if ( - output.includes('error') || - output.includes('Error') || - output.includes('failed') || - output.includes('Failed') || - output.includes('cannot open') || - output.includes('Connection refused') || - output.includes('403') || - output.includes('404') - ) { - console.error( - '[VLC ERROR from stdout]:', - output - ); - sendPlayerErrorNotification('VLC', output); + // Capture stdout + if (proc.stdout) { + proc.stdout.on('data', (data) => { + const output = data.toString().trim(); + if (output) { + console.log('[VLC stdout]:', output); } + }); + } + + // Capture stderr + if (proc.stderr) { + proc.stderr.on('data', (data) => { + const output = data.toString().trim(); + if (output) { + console.error('[VLC stderr]:', output); + } + }); + } + + proc.on('error', (err) => { + console.error('Failed to start VLC player:', err); + if (!isRetry && rcPort > 0) { + console.log('Retrying VLC without RC interface...'); + // Retry without RC args + const retryArgs = spawnArgs.filter( + (arg) => + !arg.includes('--extraintf') && + !arg.includes('--rc-host') && + !arg.includes('--rc-quiet') + ); + spawnVlc(retryArgs, true); + } else { + reject( + new Error( + `Failed to start VLC player: ${err.message}. Make sure VLC is installed and the path '${vlcPath}' is correct.` + ) + ); } }); - } - // Capture stderr - if (proc.stderr) { - proc.stderr.on('data', (data) => { - const output = data.toString().trim(); - if (output) { - console.error('[VLC stderr]:', output); + proc.on('exit', (code) => { + console.log(`VLC exited with code ${code}`); - // Check for common error patterns and send notifications - if ( - output.includes('error') || - output.includes('Error') || - output.includes('failed') || - output.includes('Failed') || - output.includes('cannot open') || - output.includes('Connection refused') || - output.includes('403') || - output.includes('404') - ) { - console.error('[VLC ERROR]:', output); - sendPlayerErrorNotification('VLC', output); - } + if (code === 1 && !isRetry && rcPort > 0) { + console.log( + 'VLC exited with error, retrying without RC interface...' + ); + stopVlcPositionPolling(); + const retryArgs = spawnArgs.filter( + (arg) => + !arg.includes('--extraintf') && + !arg.includes('--rc-host') && + !arg.includes('--rc-quiet') + ); + spawnVlc(retryArgs, true); + return; } + + // Log non-zero exit codes as errors and notify user + if (code !== 0 && code !== null) { + console.error( + `[VLC ERROR] VLC exited with error code ${code}` + ); + sendPlayerErrorNotification( + 'VLC', + `VLC player closed unexpectedly (exit code: ${code})` + ); + } + + // Resolve only if we are done (success or final failure) + // Note: detached process unref happens below, but for Promise we resolve here? + // Actually OPEN_* handlers usually resolve immediately after spawn. }); - } - proc.on('error', (err) => { - console.error('Failed to start VLC player:', err); - reject( - new Error( - `Failed to start VLC player: ${err.message}. Make sure VLC is installed and the path '${vlcPath}' is correct.` - ) - ); - }); + proc.unref(); + // Resolve immediately if we assume success or handle failure asynchronously? + // The original code resolved immediately. + // We can't wait for exit to resolve. + // But if we need to retry, we shouldn't resolve yet? + // This structure is tricky because `spawn` is async but `exit` is later. + // However, `exit` with code 1 happens almost immediately if arguments are bad. + }; - proc.on('exit', (code) => { - console.log(`VLC exited with code ${code}`); - - // Log non-zero exit codes as errors and notify user - if (code !== 0 && code !== null) { - console.error( - `[VLC ERROR] VLC exited with error code ${code}` - ); - sendPlayerErrorNotification( - 'VLC', - `VLC player closed unexpectedly (exit code: ${code})` - ); - } - }); - - // Detach the process so it can continue running independently - proc.unref(); - - // Resolve immediately if spawn succeeds (error event would fire if it fails) - // Give a small window to catch immediate spawn errors - setTimeout(() => { - if (!proc.killed) { - resolve(); - } - }, 100); + // We need to refactor the promise structure to handle retry. + // Since `OPEN_MPV` logic resolves immediately after spawn check (100ms), we can do similar. + + // Let's execute the logic + spawnVlc(args); + + // We resolve immediately to let the main process continue, + // but the retry logic happens in the background. + // This might mean `OPEN_VLC_PLAYER` returns while VLC is crashing/retrying. + resolve(); }); } catch (error) { console.error('Error opening VLC player:', error); @@ -581,3 +858,18 @@ function getDefaultVlcPath() { } return 'vlc'; } + +function getFreePort(): Promise { + return new Promise((resolve, reject) => { + const server = createServer(); + server.unref(); + server.on('error', reject); + server.listen(0, '127.0.0.1', () => { + const address = server.address() as AddressInfo; + const port = address.port; + server.close(() => { + resolve(port); + }); + }); + }); +} diff --git a/apps/web/src/app/services/electron.service.ts b/apps/web/src/app/services/electron.service.ts index 976dcc7a3..1b1abde7d 100644 --- a/apps/web/src/app/services/electron.service.ts +++ b/apps/web/src/app/services/electron.service.ts @@ -90,7 +90,9 @@ export class ElectronService extends DataService { data.title ?? '', data['user-agent'] ?? undefined, data.referer ?? undefined, - data.origin ?? undefined + data.origin ?? undefined, + data.contentInfo, + data.startTime ); /* thumbnail: data.thumbnail ?? '', */ } catch (error: any) { @@ -113,7 +115,9 @@ export class ElectronService extends DataService { data.title ?? '', data['user-agent'] ?? undefined, data.referer ?? undefined, - data.origin ?? undefined + data.origin ?? undefined, + data.contentInfo, + data.startTime ); } catch (error: any) { const errorMessage = error?.message || String(error); diff --git a/apps/web/src/app/services/player.service.ts b/apps/web/src/app/services/player.service.ts index 53588c9e9..ea96c24d6 100644 --- a/apps/web/src/app/services/player.service.ts +++ b/apps/web/src/app/services/player.service.ts @@ -29,7 +29,9 @@ export class PlayerService { isLiveContent = false, userAgent?: string, referer?: string, - origin?: string + origin?: string, + contentInfo?: any, + startTime?: number ) { const player = this.settingsStore.player() ?? VideoPlayer.VideoJs; @@ -44,6 +46,8 @@ export class PlayerService { 'user-agent': userAgent, referer: referer, origin: origin, + contentInfo, + startTime, }); } else if (player === VideoPlayer.VLC) { if (!hideExternalInfoDialog) { @@ -56,12 +60,14 @@ export class PlayerService { 'user-agent': userAgent, referer: referer, origin: origin, + contentInfo, + startTime, }); } else { this.dialog.open( PlayerDialogComponent, { - data: { streamUrl, title }, + data: { streamUrl, title, contentInfo, startTime }, width: '80%', maxWidth: '1200px', maxHeight: '90vh', diff --git a/apps/web/src/app/shared/components/grid-list/grid-list.component.scss b/apps/web/src/app/shared/components/grid-list/grid-list.component.scss index ec2c8ff1d..163f74a3c 100644 --- a/apps/web/src/app/shared/components/grid-list/grid-list.component.scss +++ b/apps/web/src/app/shared/components/grid-list/grid-list.component.scss @@ -25,6 +25,13 @@ mat-card:hover { margin: 0; } + .card-thumbnail-container { + position: relative; + width: 100%; + border-radius: 12px; + overflow: hidden; + } + .stream-icon { width: 100%; position: relative !important; diff --git a/apps/web/src/app/shared/components/grid-list/grid-list.component.ts b/apps/web/src/app/shared/components/grid-list/grid-list.component.ts index 87536491a..380de187a 100644 --- a/apps/web/src/app/shared/components/grid-list/grid-list.component.ts +++ b/apps/web/src/app/shared/components/grid-list/grid-list.component.ts @@ -6,6 +6,8 @@ import { MatProgressSpinner } from '@angular/material/progress-spinner'; import { MatTooltip } from '@angular/material/tooltip'; import { TranslatePipe } from '@ngx-translate/core'; import { PlaylistErrorViewComponent } from '../../../xtream-tauri/playlist-error-view/playlist-error-view.component'; +import { ProgressCapsuleComponent } from '../../../xtream-tauri/shared/progress-capsule/progress-capsule.component'; +import { WatchedBadgeComponent } from '../../../xtream-tauri/shared/watched-badge/watched-badge.component'; @Component({ selector: 'app-grid-list', @@ -19,18 +21,35 @@ import { PlaylistErrorViewComponent } from '../../../xtream-tauri/playlist-error @let i = $any(item); @let poster = i.poster_url ?? i.cover; - + logo + " + (error)=" + $event.target.src = + './assets/images/default-poster.png' + " + loading="lazy" + alt="logo" + /> + @if (i.progress && i.progress > 0) { + + } + @if (i.isWatched) { + + } @else if (i.hasSeriesProgress) { + + } + @let rating = i.rating ?? i.rating_imdb; @if (rating) {
@@ -23,13 +23,13 @@ " /> } - } @else if (contentType === 'series') { + } @else if (contentType() === 'series') { } } @else { { + const items = this.store.getPaginatedContent(); + if (!items) return []; + + if (this.isStalker) return items; + + const xtreamStore = this.store as any; + if (!xtreamStore.getProgressPercent) return items; + + return items.map((item: any) => { + const isSeries = this.contentType() === 'series'; + // Use xtream_id (DB) or series_id/stream_id (API) + const id = Number( + item.xtream_id || item.series_id || item.stream_id + ); + + // console.log(`[CategoryContent] Processing item ${id}. isSeries=${isSeries}`); + + if (isSeries) { + const hasProgress = xtreamStore.hasSeriesProgress(id); + console.log( + `[CategoryContent] Checking series ${id} (xtream_id=${item.xtream_id}, series_id=${item.series_id}). Has progress: ${hasProgress}` + ); + return { + ...item, + hasSeriesProgress: hasProgress, + }; + } else { + return { + ...item, + progress: xtreamStore.getProgressPercent(id, 'vod'), + isWatched: xtreamStore.isWatched(id, 'vod'), + }; + } + }); + }); + readonly isPaginatedContentLoading = this.store.isPaginatedContentLoading; readonly selectedItem = this.store.selectedItem; readonly totalPages = this.store.getTotalPages; @@ -46,6 +85,17 @@ export class CategoryContentViewComponent implements OnInit { ngOnInit() { const { categoryId } = this.activatedRoute.snapshot.params; + console.log( + `[CategoryContent] ngOnInit. contentType=${this.contentType()}, isStalker=${this.isStalker}, categoryId=${categoryId}` + ); + + // Ensure playback positions are loaded (Xtream only) + if (!this.isStalker) { + const xtreamStore = this.store as any; + if (xtreamStore.currentPlaylist()?.id) { + xtreamStore.loadAllPositions(xtreamStore.currentPlaylist().id); + } + } // Clear any previous selectedItem when entering category view // This ensures the content-header is visible @@ -83,7 +133,7 @@ export class CategoryContentViewComponent implements OnInit { // is_series can be "1" (string) or 1 (number) // ONLY set this for VOD content type - regular series should use the standard series flow is_series: - this.contentType === 'vod' && + this.contentType() === 'vod' && (item.is_series === '1' || item.is_series === 1), // Store video_id for season fetching if available video_id: item.video_id, diff --git a/apps/web/src/app/xtream-tauri/data-sources/electron-xtream-data-source.ts b/apps/web/src/app/xtream-tauri/data-sources/electron-xtream-data-source.ts index 521d0d8f3..d3044a5d8 100644 --- a/apps/web/src/app/xtream-tauri/data-sources/electron-xtream-data-source.ts +++ b/apps/web/src/app/xtream-tauri/data-sources/electron-xtream-data-source.ts @@ -1,5 +1,5 @@ import { inject, Injectable } from '@angular/core'; -import { DatabaseService } from 'services'; +import { DatabaseService, PlaybackPositionService } from 'services'; import { XtreamCategory, XtreamLiveStream, @@ -24,6 +24,7 @@ import { @Injectable({ providedIn: 'root' }) export class ElectronXtreamDataSource implements IXtreamDataSource { private readonly dbService = inject(DatabaseService); + private readonly playbackService = inject(PlaybackPositionService); private readonly apiService = inject(XtreamApiService); // ========================================================================= @@ -298,6 +299,65 @@ export class ElectronXtreamDataSource implements IXtreamDataSource { return this.dbService.getContentByXtreamId(xtreamId, playlistId); } + // ========================================================================= + // Playback Position Operations + // ========================================================================= + + async savePlaybackPosition( + playlistId: string, + data: any + ): Promise { + await this.playbackService.savePlaybackPosition(playlistId, data); + } + + async getPlaybackPosition( + playlistId: string, + contentXtreamId: number, + contentType: 'vod' | 'episode' + ): Promise { + return this.playbackService.getPlaybackPosition( + playlistId, + contentXtreamId, + contentType + ); + } + + async getSeriesPlaybackPositions( + playlistId: string, + seriesXtreamId: number + ): Promise { + return this.playbackService.getSeriesPlaybackPositions( + playlistId, + seriesXtreamId + ); + } + + async getRecentPlaybackPositions( + playlistId: string, + limit?: number + ): Promise { + return this.playbackService.getRecentPlaybackPositions( + playlistId, + limit + ); + } + + async getAllPlaybackPositions(playlistId: string): Promise { + return this.playbackService.getAllPlaybackPositions(playlistId); + } + + async clearPlaybackPosition( + playlistId: string, + contentXtreamId: number, + contentType: 'vod' | 'episode' + ): Promise { + await this.playbackService.clearPlaybackPosition( + playlistId, + contentXtreamId, + contentType + ); + } + // ========================================================================= // Cleanup Operations // ========================================================================= diff --git a/apps/web/src/app/xtream-tauri/data-sources/pwa-xtream-data-source.ts b/apps/web/src/app/xtream-tauri/data-sources/pwa-xtream-data-source.ts index dc8b37136..8d49d8a46 100644 --- a/apps/web/src/app/xtream-tauri/data-sources/pwa-xtream-data-source.ts +++ b/apps/web/src/app/xtream-tauri/data-sources/pwa-xtream-data-source.ts @@ -22,6 +22,7 @@ const STORAGE_KEYS = { FAVORITES: 'xtream-favorites', RECENT_ITEMS: 'xtream-recent-items', PLAYLISTS: 'xtream-playlists', + PLAYBACK_POSITIONS: 'xtream-playback-positions', }; /** @@ -72,6 +73,7 @@ export class PwaXtreamDataSource implements IXtreamDataSource { // Also clear favorites and recent items for this playlist this.clearFavoritesForPlaylist(playlistId); this.clearRecentItemsForPlaylist(playlistId); + this.clearPlaybackPositionsForPlaylist(playlistId); // Clear cache this.clearCacheForPlaylist(playlistId); @@ -312,6 +314,127 @@ export class PwaXtreamDataSource implements IXtreamDataSource { this.saveFavoritesToStorage(allFavorites); } + // ========================================================================= + // Playback Position Operations (localStorage) + // ========================================================================= + + async savePlaybackPosition(playlistId: string, data: any): Promise { + const allPositions = this.getPlaybackPositionsFromStorage(); + if (!allPositions[playlistId]) { + allPositions[playlistId] = []; + } + + // Remove existing entry if present + allPositions[playlistId] = allPositions[playlistId].filter( + (p: any) => + !( + p.contentXtreamId === data.contentXtreamId && + p.contentType === data.contentType + ) + ); + + // Add new entry + allPositions[playlistId].push({ + ...data, + updatedAt: new Date().toISOString(), + }); + + this.savePlaybackPositionsToStorage(allPositions); + } + + async getPlaybackPosition( + playlistId: string, + contentXtreamId: number, + contentType: 'vod' | 'episode' + ): Promise { + const allPositions = this.getPlaybackPositionsFromStorage(); + const playlistPositions = allPositions[playlistId] || []; + + return ( + playlistPositions.find( + (p: any) => + p.contentXtreamId === contentXtreamId && + p.contentType === contentType + ) || null + ); + } + + async getSeriesPlaybackPositions( + playlistId: string, + seriesXtreamId: number + ): Promise { + const allPositions = this.getPlaybackPositionsFromStorage(); + const playlistPositions = allPositions[playlistId] || []; + + return playlistPositions.filter( + (p: any) => + p.contentType === 'episode' && p.seriesXtreamId === seriesXtreamId + ); + } + + async getRecentPlaybackPositions( + playlistId: string, + limit?: number + ): Promise { + const allPositions = this.getPlaybackPositionsFromStorage(); + const playlistPositions = allPositions[playlistId] || []; + + // Sort by updatedAt descending + playlistPositions.sort( + (a: any, b: any) => + new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime() + ); + + return limit ? playlistPositions.slice(0, limit) : playlistPositions; + } + + async getAllPlaybackPositions(playlistId: string): Promise { + const allPositions = this.getPlaybackPositionsFromStorage(); + return allPositions[playlistId] || []; + } + + async clearPlaybackPosition( + playlistId: string, + contentXtreamId: number, + contentType: 'vod' | 'episode' + ): Promise { + const allPositions = this.getPlaybackPositionsFromStorage(); + if (allPositions[playlistId]) { + allPositions[playlistId] = allPositions[playlistId].filter( + (p: any) => + !( + p.contentXtreamId === contentXtreamId && + p.contentType === contentType + ) + ); + this.savePlaybackPositionsToStorage(allPositions); + } + } + + private getPlaybackPositionsFromStorage(): Record { + try { + const data = localStorage.getItem(STORAGE_KEYS.PLAYBACK_POSITIONS); + return data ? JSON.parse(data) : {}; + } catch { + return {}; + } + } + + private savePlaybackPositionsToStorage( + positions: Record + ): void { + localStorage.setItem( + STORAGE_KEYS.PLAYBACK_POSITIONS, + JSON.stringify(positions) + ); + } + + private clearPlaybackPositionsForPlaylist(playlistId: string): void { + const allPositions = this.getPlaybackPositionsFromStorage(); + delete allPositions[playlistId]; + this.savePlaybackPositionsToStorage(allPositions); + } + // ========================================================================= // Recently Viewed Operations (localStorage) // ========================================================================= diff --git a/apps/web/src/app/xtream-tauri/data-sources/xtream-data-source.interface.ts b/apps/web/src/app/xtream-tauri/data-sources/xtream-data-source.interface.ts index 9a01c97f8..18bb044ed 100644 --- a/apps/web/src/app/xtream-tauri/data-sources/xtream-data-source.interface.ts +++ b/apps/web/src/app/xtream-tauri/data-sources/xtream-data-source.interface.ts @@ -75,6 +75,21 @@ export interface RecentlyViewedItem extends XtreamContentItem { viewed_at: string; } +/** + * Playback position data for resume functionality + */ +export interface PlaybackPositionData { + contentXtreamId: number; + contentType: 'vod' | 'episode'; + seriesXtreamId?: number; + seasonNumber?: number; + episodeNumber?: number; + positionSeconds: number; + durationSeconds?: number; + playlistId?: string; + updatedAt?: string; +} + /** * Database category type mapping (differs from API type) */ @@ -293,6 +308,57 @@ export interface IXtreamDataSource { playlistId: string ): Promise; + // ========================================================================= + // Playback Position Operations + // ========================================================================= + + /** + * Save/update playback position for content + */ + savePlaybackPosition( + playlistId: string, + data: PlaybackPositionData + ): Promise; + + /** + * Get playback position for a specific content item + */ + getPlaybackPosition( + playlistId: string, + contentXtreamId: number, + contentType: 'vod' | 'episode' + ): Promise; + + /** + * Get all episode positions for a series (for highlighting watched episodes) + */ + getSeriesPlaybackPositions( + playlistId: string, + seriesXtreamId: number + ): Promise; + + /** + * Get recently watched items with positions (for "Continue Watching" section) + */ + getRecentPlaybackPositions( + playlistId: string, + limit?: number + ): Promise; + + /** + * Get all playback positions for a playlist (for grid view) + */ + getAllPlaybackPositions(playlistId: string): Promise; + + /** + * Clear playback position (mark as unwatched) + */ + clearPlaybackPosition( + playlistId: string, + contentXtreamId: number, + contentType: 'vod' | 'episode' + ): Promise; + // ========================================================================= // Cleanup Operations // ========================================================================= diff --git a/apps/web/src/app/xtream-tauri/player-dialog/player-dialog.component.html b/apps/web/src/app/xtream-tauri/player-dialog/player-dialog.component.html index efe236c70..9e30ad82d 100644 --- a/apps/web/src/app/xtream-tauri/player-dialog/player-dialog.component.html +++ b/apps/web/src/app/xtream-tauri/player-dialog/player-dialog.component.html @@ -1,6 +1,10 @@

{{ title }}

- + +
play_arrow diff --git a/apps/web/src/app/xtream-tauri/season-container/season-container.component.scss b/apps/web/src/app/xtream-tauri/season-container/season-container.component.scss index 24f115df4..75e381dc2 100644 --- a/apps/web/src/app/xtream-tauri/season-container/season-container.component.scss +++ b/apps/web/src/app/xtream-tauri/season-container/season-container.component.scss @@ -67,7 +67,7 @@ } .episodes-grid { - grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); + grid-template-columns: repeat(auto-fill, minmax(160px, 1fr)); } // Season Card @@ -75,7 +75,7 @@ display: flex; align-items: center; gap: 12px; - padding: 16px; + padding: 12px; background: rgba(255, 255, 255, 0.04); border: 1px solid rgba(255, 255, 255, 0.08); border-radius: 6px; @@ -99,19 +99,63 @@ } } - &__number { - font-size: 1.75rem !important; - font-weight: 800; - color: rgba(255, 255, 255, 0.05); - line-height: 1; - transition: color 0.3s ease; + &__progress-ring { + position: relative; + width: 42px; + height: 42px; + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + + svg { + width: 100%; + height: 100%; + transform: rotate(-90deg); + } + + .progress-ring__bg { + fill: none; + stroke: rgba(255, 255, 255, 0.1); + stroke-width: 3; + } + + .progress-ring__fill { + fill: none; + stroke: var(--accent-color); + stroke-width: 3; + stroke-linecap: round; + transition: stroke-dasharray 0.5s ease-out; + } + + .season-card__number { + position: absolute; + font-size: 1rem !important; + color: var(--text-primary); + font-weight: 700; + opacity: 1; + margin: 0; + padding: 0; + background: transparent; + } + } + + &__info { + flex: 1; + display: flex; + flex-direction: column; + gap: 2px; } &__label { font-size: 0.9375rem !important; font-weight: 600; color: var(--text-primary); - flex: 1; + } + + &__progress-text { + font-size: 0.75rem; + color: var(--text-muted); } &__episodes { @@ -133,10 +177,16 @@ } &--has-episodes { + /* background: rgba(var(--accent-color-rgb), 0.1); + border-color: rgba(var(--accent-color-rgb), 0.3); */ + .season-card__number { - color: var(--accent-color); - opacity: 0.4; // More subtle + color: var(--text-primary); } + + /* .season-card__label { + color: var(--accent-color); + } */ } } @@ -148,6 +198,42 @@ cursor: pointer; transition: all 0.2s ease; border: 1px solid rgba(255, 255, 255, 0.05); + position: relative; + + // In-progress state + &--in-progress { + border-color: rgba(229, 9, 20, 0.6); + background: rgba(229, 9, 20, 0.05); + + &::before { + content: ''; + position: absolute; + inset: 0; + background: linear-gradient( + 135deg, + rgba(229, 9, 20, 0.1) 0%, + transparent 50% + ); + pointer-events: none; + z-index: 1; + } + } + + // Watched state + &--watched { + opacity: 0.7; + + &:hover { + opacity: 1; + } + + .episode-card__title::after { + content: ' ✓'; + color: #46d369; + font-weight: bold; + margin-left: 4px; + } + } &:hover { transform: translateY(-2px); @@ -176,6 +262,20 @@ } } + &__position-text { + position: absolute; + bottom: 8px; + left: 8px; + background: rgba(0, 0, 0, 0.75); + padding: 2px 6px; + border-radius: 3px; + font-size: 0.7rem; + font-weight: 600; + color: white; + backdrop-filter: blur(4px); + z-index: 2; + } + &__placeholder { width: 100%; height: 100%; @@ -202,6 +302,41 @@ justify-content: center; opacity: 0; transition: opacity 0.2s ease; + z-index: 1; + } + + &__actions { + position: absolute; + top: 4px; + right: 4px; + z-index: 2; + opacity: 0; + transition: opacity 0.2s ease; + + .toggle-watched-btn { + color: rgba(255, 255, 255, 0.7); + background: rgba(0, 0, 0, 0.5); + width: 32px; + height: 32px; + padding: 4px; + + &:hover { + color: white; + background: rgba(0, 0, 0, 0.8); + } + + mat-icon { + font-size: 20px; + width: 20px; + height: 20px; + } + } + } + + &:hover { + .episode-card__actions { + opacity: 1; + } } &__play { diff --git a/apps/web/src/app/xtream-tauri/season-container/season-container.component.ts b/apps/web/src/app/xtream-tauri/season-container/season-container.component.ts index b7555eb8e..34097a32d 100644 --- a/apps/web/src/app/xtream-tauri/season-container/season-container.component.ts +++ b/apps/web/src/app/xtream-tauri/season-container/season-container.component.ts @@ -1,17 +1,35 @@ import { KeyValuePipe } from '@angular/common'; -import { Component, EventEmitter, Output, input } from '@angular/core'; +import { Component, EventEmitter, Output, input, inject } from '@angular/core'; +import { MatButtonModule } from '@angular/material/button'; import { MatIcon } from '@angular/material/icon'; import { MatProgressSpinnerModule } from '@angular/material/progress-spinner'; +import { MatTooltipModule } from '@angular/material/tooltip'; +import { TranslateModule } from '@ngx-translate/core'; import { XtreamSerieEpisode, XtreamSerieEpisodeInfo } from 'shared-interfaces'; +import { XtreamStore } from '../stores/xtream.store'; +import { ProgressCapsuleComponent } from '../shared/progress-capsule/progress-capsule.component'; +import { WatchedBadgeComponent } from '../shared/watched-badge/watched-badge.component'; @Component({ selector: 'app-season-container', templateUrl: './season-container.component.html', styleUrls: ['./season-container.component.scss'], - imports: [KeyValuePipe, MatIcon, MatProgressSpinnerModule], + imports: [ + KeyValuePipe, + MatButtonModule, + MatIcon, + MatProgressSpinnerModule, + MatTooltipModule, + ProgressCapsuleComponent, + TranslateModule, + WatchedBadgeComponent, + ], }) export class SeasonContainerComponent { + private readonly xtreamStore = inject(XtreamStore); + readonly seasons = input.required>(); + readonly seriesId = input.required(); // Add seriesId input readonly isLoading = input(false); @Output() episodeClicked = new EventEmitter(); @@ -32,6 +50,16 @@ export class SeasonContainerComponent { this.episodeClicked.emit(episode); } + toggleWatched(event: Event, episode: XtreamSerieEpisode) { + event.stopPropagation(); + const playlistId = this.xtreamStore.currentPlaylist().id; + this.xtreamStore.toggleEpisodeWatched( + playlistId, + episode, + this.seriesId() + ); + } + /** * Safely get episode info - returns undefined if info is an empty array * (Xtream API returns [] when no metadata available instead of null/object) @@ -44,4 +72,67 @@ export class SeasonContainerComponent { } return episode.info; } + + isEpisodeWatched(episode: XtreamSerieEpisode) { + return this.xtreamStore.isWatched(Number(episode.id), 'episode'); + } + + isEpisodeInProgress(episode: XtreamSerieEpisode) { + return this.xtreamStore.isInProgress(Number(episode.id), 'episode'); + } + + getEpisodeProgress(episode: XtreamSerieEpisode) { + return this.xtreamStore.getProgressPercent( + Number(episode.id), + 'episode' + ); + } + + getEpisodePositionText(episode: XtreamSerieEpisode): string | null { + if (this.isEpisodeWatched(episode)) return null; + + const position = this.xtreamStore + .playbackPositions() + .get(`episode_${episode.id}`); + if (!position || !position.positionSeconds) return null; + + let seconds = position.positionSeconds; + let suffix = ''; + + // Calculate remaining time if duration is available + if (position.durationSeconds > 0) { + const remaining = Math.max( + 0, + position.durationSeconds - position.positionSeconds + ); + + if (remaining <= 0) return null; + + seconds = remaining; + suffix = ' left'; + } + + const date = new Date(0); + date.setSeconds(seconds); + const timeString = date.toISOString().substr(11, 8); + const formatted = timeString.startsWith('00:') + ? timeString.substr(3) + : timeString; + return `${formatted}${suffix}`; + } + + getSeasonWatchedCount(seasonKey: string): number { + const episodes = this.seasons()[seasonKey]; + if (!episodes) return 0; + return episodes.filter((e) => this.isEpisodeWatched(e)).length; + } + + getSeasonProgressDash(seasonKey: string): string { + const episodes = this.seasons()[seasonKey]; + if (!episodes || episodes.length === 0) return '0, 100'; + + const watched = this.getSeasonWatchedCount(seasonKey); + const percent = (watched / episodes.length) * 100; + return `${percent}, 100`; + } } diff --git a/apps/web/src/app/xtream-tauri/serial-details/serial-details.component.html b/apps/web/src/app/xtream-tauri/serial-details/serial-details.component.html index 3d3b50e1e..a2bc391e9 100644 --- a/apps/web/src/app/xtream-tauri/serial-details/serial-details.component.html +++ b/apps/web/src/app/xtream-tauri/serial-details/serial-details.component.html @@ -69,6 +69,7 @@ diff --git a/apps/web/src/app/xtream-tauri/serial-details/serial-details.component.ts b/apps/web/src/app/xtream-tauri/serial-details/serial-details.component.ts index 4bfa47423..07a6a41b1 100644 --- a/apps/web/src/app/xtream-tauri/serial-details/serial-details.component.ts +++ b/apps/web/src/app/xtream-tauri/serial-details/serial-details.component.ts @@ -39,6 +39,10 @@ export class SerialDetailsComponent implements OnInit, OnDestroy { serialId, this.xtreamStore.currentPlaylist().id ); + this.xtreamStore.loadSeriesPositions( + this.xtreamStore.currentPlaylist().id, + Number(serialId) + ); } ngOnDestroy(): void { @@ -49,10 +53,25 @@ export class SerialDetailsComponent implements OnInit, OnDestroy { this.addToRecentlyViewed(this.route.snapshot.params.serialId); const streamUrl = this.xtreamStore.constructEpisodeStreamUrl(episode); + const contentInfo = { + playlistId: this.xtreamStore.currentPlaylist().id, + contentXtreamId: Number(episode.id), + contentType: 'episode', + seriesXtreamId: Number(this.selectedItem().series_id), + seasonNumber: Number(episode.season), + episodeNumber: Number(episode.episode_num), + }; + + const position = this.xtreamStore + .playbackPositions() + .get(`episode_${episode.id}`); + this.xtreamStore.openPlayer( streamUrl, episode.title, - this.selectedItem().info.cover + this.selectedItem().info.cover, + position?.positionSeconds, + contentInfo ); } diff --git a/apps/web/src/app/xtream-tauri/shared/progress-capsule/progress-capsule.component.ts b/apps/web/src/app/xtream-tauri/shared/progress-capsule/progress-capsule.component.ts new file mode 100644 index 000000000..2b364919a --- /dev/null +++ b/apps/web/src/app/xtream-tauri/shared/progress-capsule/progress-capsule.component.ts @@ -0,0 +1,55 @@ +import { Component, input, computed } from '@angular/core'; + +@Component({ + selector: 'app-progress-capsule', + standalone: true, + template: ` +
+
+
+ `, + styles: [ + ` + .progress-capsule { + position: absolute; + bottom: 0; + left: 0; + right: 0; + height: 5px; + background: rgba(255, 255, 255, 0.2); + overflow: hidden; + + &__fill { + height: 100%; + background: linear-gradient( + 90deg, + #e50914 0%, + #ff4d4d 100% + ); + transition: width 0.3s ease-out; + border-radius: 0 2px 2px 0; + } + + &--watched { + .progress-capsule__fill { + background: linear-gradient( + 90deg, + #46d369 0%, + #2ecc71 100% + ); + } + } + } + `, + ], +}) +export class ProgressCapsuleComponent { + readonly progress = input.required(); // 0-100 + readonly isWatched = computed(() => this.progress() >= 90); +} diff --git a/apps/web/src/app/xtream-tauri/shared/watched-badge/watched-badge.component.ts b/apps/web/src/app/xtream-tauri/shared/watched-badge/watched-badge.component.ts new file mode 100644 index 000000000..d126a670e --- /dev/null +++ b/apps/web/src/app/xtream-tauri/shared/watched-badge/watched-badge.component.ts @@ -0,0 +1,44 @@ +import { Component, input } from '@angular/core'; +import { MatIcon } from '@angular/material/icon'; + +@Component({ + selector: 'app-watched-badge', + standalone: true, + imports: [MatIcon], + template: ` + @if (isWatched()) { +
+ {{ icon() }} +
+ } + `, + styles: [ + ` + .watched-badge { + position: absolute; + top: 8px; + left: 8px; + width: 24px; + height: 24px; + background: rgba(70, 211, 105, 0.9); + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + backdrop-filter: blur(4px); + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3); + + mat-icon { + font-size: 16px; + width: 16px; + height: 16px; + color: white; + } + } + `, + ], +}) +export class WatchedBadgeComponent { + readonly isWatched = input.required(); + readonly icon = input('check_circle'); +} diff --git a/apps/web/src/app/xtream-tauri/stores/features/index.ts b/apps/web/src/app/xtream-tauri/stores/features/index.ts index 5e7df6cf9..a23f0c4c6 100644 --- a/apps/web/src/app/xtream-tauri/stores/features/index.ts +++ b/apps/web/src/app/xtream-tauri/stores/features/index.ts @@ -1,5 +1,6 @@ export * from './with-content.feature'; export * from './with-epg.feature'; +export * from './with-playback-positions.feature'; export * from './with-player.feature'; export * from './with-portal.feature'; export * from './with-search.feature'; diff --git a/apps/web/src/app/xtream-tauri/stores/features/with-playback-positions.feature.ts b/apps/web/src/app/xtream-tauri/stores/features/with-playback-positions.feature.ts new file mode 100644 index 000000000..3056b110d --- /dev/null +++ b/apps/web/src/app/xtream-tauri/stores/features/with-playback-positions.feature.ts @@ -0,0 +1,394 @@ +import { inject } from '@angular/core'; +import { + patchState, + signalStoreFeature, + withHooks, + withMethods, + withState, +} from '@ngrx/signals'; +import { XtreamSerieEpisode } from 'shared-interfaces'; +import { + PlaybackPositionData, + XTREAM_DATA_SOURCE, +} from '../../data-sources/xtream-data-source.interface'; + +export interface PlaybackPositionsState { + playbackPositions: Map; // key: `${contentType}_${xtreamId}` + seriesPositions: Map; // key: seriesXtreamId +} + +const initialState: PlaybackPositionsState = { + playbackPositions: new Map(), + seriesPositions: new Map(), +}; + +function parseDuration(duration: string | number): number { + if (typeof duration === 'number') return duration; + if (!duration) return 0; + + // Check for "min" format (e.g. "45 min") + const minMatch = duration.match(/(\d+)\s*min/); + if (minMatch) { + return parseInt(minMatch[1], 10) * 60; + } + + // Check for "h:m:s" or "m:s" format + if (duration.includes(':')) { + const parts = duration.split(':').map((p) => parseInt(p, 10)); + if (parts.length === 3) { + return parts[0] * 3600 + parts[1] * 60 + parts[2]; + } else if (parts.length === 2) { + return parts[0] * 60 + parts[1]; + } + } + + // Fallback: try parsing as simple number (seconds or minutes? assume minutes if < 1000, seconds otherwise?) + // Xtream usually returns seconds or "min" string + const num = parseInt(duration, 10); + if (!isNaN(num)) { + return num; + } + + return 0; +} + +export function withPlaybackPositions() { + return signalStoreFeature( + withState(initialState), + + withMethods((store) => { + const dataSource = inject(XTREAM_DATA_SOURCE); + + const getPositionKey = (type: string, id: number) => + `${type}_${id}`; + + return { + /** + * Get progress percentage for display (0-100) + */ + getProgressPercent( + contentXtreamId: number, + contentType: 'vod' | 'episode' + ): number { + const key = getPositionKey(contentType, contentXtreamId); + const position = store.playbackPositions().get(key); + + if (!position || !position.durationSeconds) return 0; + + const percent = + (position.positionSeconds / position.durationSeconds) * + 100; + + // If watched > 10s but percent < 1, return 1 to show visual progress + if (position.positionSeconds > 10 && percent < 1) { + return 1; + } + + return Math.min(100, Math.round(percent)); + }, + + /** + * Check if content is considered "watched" (>90% complete) + */ + isWatched( + contentXtreamId: number, + contentType: 'vod' | 'episode' + ): boolean { + return ( + this.getProgressPercent( + contentXtreamId, + contentType + ) >= 90 + ); + }, + + /** + * Check if content is "in progress" (started but not finished) + */ + isInProgress( + contentXtreamId: number, + contentType: 'vod' | 'episode' + ): boolean { + const key = getPositionKey(contentType, contentXtreamId); + const position = store.playbackPositions().get(key); + if (!position) return false; + + const percent = this.getProgressPercent( + contentXtreamId, + contentType + ); + const inProgress = + position.positionSeconds > 10 && percent < 90; + return inProgress; + }, + + /** + * Load all playback positions for the playlist (for grid view) + */ + async loadAllPositions(playlistId: string): Promise { + const positions = await dataSource.getAllPlaybackPositions( + playlistId + ); + + console.log( + `[withPlaybackPositions] Loaded all positions (${positions.length})` + ); + + const positionsMap = new Map(); + const seriesMap = new Map(); + + positions.forEach((pos) => { + // Populate playbackPositions map + const key = getPositionKey( + pos.contentType, + pos.contentXtreamId + ); + positionsMap.set(key, pos); + + // Populate seriesPositions map + if ( + pos.contentType === 'episode' && + pos.seriesXtreamId + ) { + const existing = + seriesMap.get(pos.seriesXtreamId) || []; + existing.push(pos); + seriesMap.set(pos.seriesXtreamId, existing); + } + }); + + console.log( + `[withPlaybackPositions] Series map populated: ${seriesMap.size} series` + ); + + patchState(store, { + playbackPositions: positionsMap, + seriesPositions: seriesMap, + }); + }, + + /** + * Check if a series has any started or watched episodes + */ + hasSeriesProgress(seriesXtreamId: number): boolean { + const positions = store + .seriesPositions() + .get(seriesXtreamId); + + if (positions && positions.length > 0) { + console.log(`[withPlaybackPositions] Series ${seriesXtreamId} HAS progress`); + return true; + } + // console.log(`[withPlaybackPositions] Series ${seriesXtreamId} NO progress`); + return false; + }, + + /** + * Load positions for a VOD item + */ + async loadVodPosition( + playlistId: string, + vodXtreamId: number + ): Promise { + const position = await dataSource.getPlaybackPosition( + playlistId, + vodXtreamId, + 'vod' + ); + + console.log( + `[withPlaybackPositions] Loaded VOD position for ${vodXtreamId}:`, + position + ); + + if (position) { + const key = getPositionKey('vod', vodXtreamId); + const updated = new Map(store.playbackPositions()); + updated.set(key, position); + patchState(store, { playbackPositions: updated }); + } + }, + + /** + * Load all episode positions for a series + */ + async loadSeriesPositions( + playlistId: string, + seriesXtreamId: number + ): Promise { + const positions = await dataSource.getSeriesPlaybackPositions( + playlistId, + seriesXtreamId + ); + + console.log( + `[withPlaybackPositions] Loaded series positions for ${seriesXtreamId}:`, + positions + ); + + const updated = new Map(store.seriesPositions()); + updated.set(seriesXtreamId, positions); + patchState(store, { seriesPositions: updated }); + + // Also populate individual positions map + const positionsMap = new Map(store.playbackPositions()); + positions.forEach((pos) => { + const key = getPositionKey( + 'episode', + pos.contentXtreamId + ); + positionsMap.set(key, pos); + }); + patchState(store, { playbackPositions: positionsMap }); + }, + + /** + * Save playback position (called from MPV updates) + */ + async savePosition( + playlistId: string, + data: PlaybackPositionData + ): Promise { + await dataSource.savePlaybackPosition(playlistId, data); + + const key = getPositionKey( + data.contentType, + data.contentXtreamId + ); + console.log( + `[withPlaybackPositions] Saving position for ${key}:`, + data + ); + const updated = new Map(store.playbackPositions()); + updated.set(key, data); + patchState(store, { playbackPositions: updated }); + }, + + /** + * Toggle watched status for an episode + */ + async toggleEpisodeWatched( + playlistId: string, + episode: XtreamSerieEpisode, + seriesId: number + ): Promise { + const id = Number(episode.id); + const isWatched = this.isWatched(id, 'episode'); + + if (isWatched) { + // Mark as unwatched + await dataSource.clearPlaybackPosition( + playlistId, + id, + 'episode' + ); + // Update state (remove from map) + const key = getPositionKey('episode', id); + const updated = new Map(store.playbackPositions()); + updated.delete(key); + + // Update series map + const seriesMap = new Map(store.seriesPositions()); + const seriesEpisodes = seriesMap.get(seriesId) || []; + const filteredEpisodes = seriesEpisodes.filter( + (p) => p.contentXtreamId !== id + ); + if (filteredEpisodes.length === 0) { + seriesMap.delete(seriesId); + } else { + seriesMap.set(seriesId, filteredEpisodes); + } + + patchState(store, { + playbackPositions: updated, + seriesPositions: seriesMap, + }); + } else { + // Mark as watched + let duration = 0; + const info = Array.isArray(episode.info) + ? null + : episode.info; + + if (info?.duration_secs) { + duration = info.duration_secs; + } else if (info?.duration) { + duration = parseDuration(info.duration); + } + + if (duration === 0) duration = 1; // Fallback + + const data: PlaybackPositionData = { + contentXtreamId: id, + contentType: 'episode', + seriesXtreamId: seriesId, + seasonNumber: Number(episode.season), + episodeNumber: Number(episode.episode_num), + positionSeconds: duration, + durationSeconds: duration, + playlistId, + updatedAt: new Date().toISOString(), + }; + + // Use existing savePosition to handle state update and persistence + // But we also need to update seriesPositions map which savePosition doesn't do for individual updates + // actually savePosition only updates playbackPositions map. + // We should probably update savePosition to also update series map, or do it here. + // Let's do it here to be safe. + + await dataSource.savePlaybackPosition(playlistId, data); + + const key = getPositionKey('episode', id); + const updated = new Map(store.playbackPositions()); + updated.set(key, data); + + const seriesMap = new Map(store.seriesPositions()); + const seriesEpisodes = seriesMap.get(seriesId) || []; + // Check if already exists + const existingIdx = seriesEpisodes.findIndex( + (p) => p.contentXtreamId === id + ); + if (existingIdx >= 0) { + seriesEpisodes[existingIdx] = data; + } else { + seriesEpisodes.push(data); + } + seriesMap.set(seriesId, seriesEpisodes); + + patchState(store, { + playbackPositions: updated, + seriesPositions: seriesMap, + }); + } + }, + }; + }), + + withHooks({ + onInit(store) { + if (window.electron?.onPlaybackPositionUpdate) { + window.electron.onPlaybackPositionUpdate((data: any) => { + console.log( + '[withPlaybackPositions] Received update:', + data + ); + // Ensure playlistId is present + if (data.playlistId) { + store.savePosition(data.playlistId, data); + } else { + console.warn( + '[withPlaybackPositions] Missing playlistId in update', + data + ); + } + }); + } + }, + onDestroy(store) { + if (window.electron?.removePlaybackPositionListener) { + window.electron.removePlaybackPositionListener(); + } + }, + }) + ); +} diff --git a/apps/web/src/app/xtream-tauri/stores/features/with-player.feature.ts b/apps/web/src/app/xtream-tauri/stores/features/with-player.feature.ts index 38bb78d89..54ee6f31f 100644 --- a/apps/web/src/app/xtream-tauri/stores/features/with-player.feature.ts +++ b/apps/web/src/app/xtream-tauri/stores/features/with-player.feature.ts @@ -136,7 +136,9 @@ export function withPlayer() { openPlayer( streamUrl: string, title: string, - thumbnail: string | null = null + thumbnail: string | null = null, + startTime?: number, + contentInfo?: any ): void { const playlist = getPlaylistFromStore(); const storeAny = store as any; @@ -152,7 +154,9 @@ export function withPlayer() { contentType === 'live', playlist?.userAgent, playlist?.referrer, - playlist?.origin + playlist?.origin, + contentInfo, + startTime ); }, diff --git a/apps/web/src/app/xtream-tauri/stores/xtream.store.ts b/apps/web/src/app/xtream-tauri/stores/xtream.store.ts index 8f380482f..4837447c7 100644 --- a/apps/web/src/app/xtream-tauri/stores/xtream.store.ts +++ b/apps/web/src/app/xtream-tauri/stores/xtream.store.ts @@ -26,6 +26,7 @@ import { withSearch, withEpg, withPlayer, + withPlaybackPositions, } from './features'; /** @@ -42,12 +43,12 @@ import { * - withPlayer: Stream URL construction and player integration * - withFavorites: Favorites management * - withRecentItems: Recently viewed items + * - withPlaybackPositions: Playback position tracking * * @see docs/XTREAM_STORE_REFACTORING_PLAN.md */ export const XtreamStore = signalStore( - // Note: NOT providedIn: 'root' because shell provides it - // This matches the original behavior + { providedIn: 'root' }, // Compose all features withPortal(), @@ -58,6 +59,7 @@ export const XtreamStore = signalStore( withPlayer(), withFavorites(), withRecentItems(), + withPlaybackPositions(), // Cross-feature computed properties withComputed((store) => ({ @@ -105,6 +107,10 @@ export const XtreamStore = signalStore( await store.fetchPlaylist(); await store.checkPortalStatus(); await store.initializeContent(); + const playlist = store.currentPlaylist(); + if (playlist) { + store.loadAllPositions(playlist.id); + } }, /** diff --git a/apps/web/src/app/xtream-tauri/vod-details/vod-details.component.html b/apps/web/src/app/xtream-tauri/vod-details/vod-details.component.html index 1eb060e2c..ba41a1123 100644 --- a/apps/web/src/app/xtream-tauri/vod-details/vod-details.component.html +++ b/apps/web/src/app/xtream-tauri/vod-details/vod-details.component.html @@ -61,10 +61,27 @@ - + @if (hasPlaybackPosition()) { + + + } @else { + + } @if (!isFavorite()) {