feat: playback position persistence for all players and grid indicators

This commit is contained in:
4gray committed 2026-01-18 11:40:27 +01:00
1 parent d218dc0ad2
commit adb89145a3
46 files changed
+2310 -146

No files matched your search

@@ -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'),
});
@@ -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';
@@ -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';
@@ -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;
}
}
);
@@ -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<number | null> {
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<string> {
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<void> {
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<void>((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<number> {
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);
});
});
});
}
@@ -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);
+8 -2
View File
@@ -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, PlayerDialogData>(
PlayerDialogComponent,
{
data: { streamUrl, title },
data: { streamUrl, title, contentInfo, startTime },
width: '80%',
maxWidth: '1200px',
maxHeight: '90vh',
@@ -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;
@@ -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);
<mat-card (click)="itemClicked.emit(item)">
@let poster = i.poster_url ?? i.cover;
<img
class="stream-icon"
[src]="
poster || './assets/images/default-poster.png'
"
(error)="
$event.target.src =
<div class="card-thumbnail-container">
<img
class="stream-icon"
[src]="
poster ||
'./assets/images/default-poster.png'
"
loading="lazy"
alt="logo"
/>
"
(error)="
$event.target.src =
'./assets/images/default-poster.png'
"
loading="lazy"
alt="logo"
/>
@if (i.progress && i.progress > 0) {
<app-progress-capsule [progress]="i.progress" />
}
@if (i.isWatched) {
<app-watched-badge
[isWatched]="true"
icon="check_circle"
/>
} @else if (i.hasSeriesProgress) {
<app-watched-badge
[isWatched]="true"
icon="remove_red_eye"
/>
}
</div>
@let rating = i.rating ?? i.rating_imdb;
@if (rating) {
<div
@@ -82,6 +101,8 @@ import { PlaylistErrorViewComponent } from '../../../xtream-tauri/playlist-error
MatProgressSpinner,
MatTooltip,
MatPaginatorModule,
ProgressCapsuleComponent,
WatchedBadgeComponent,
],
})
export class GridListComponent {
@@ -62,6 +62,7 @@
@if (mappedSeasons()) {
<app-season-container
[seasons]="mappedSeasons()"
[seriesId]="serial.id"
[isLoading]="
isVodSeriesSeasonsLoading() ||
(seasonContainer?.selectedSeason &&
+9 -1
View File
@@ -929,6 +929,13 @@ export const StalkerStore = signalStore(
title,
});
const playlist = this.currentPlaylist();
const contentInfo = {
playlistId: playlist._id,
contentXtreamId: Number(item.id),
contentType: episode ? 'episode' : 'vod',
seriesXtreamId: episode ? Number(item.id) : undefined, // For Stalker VOD series, use item.id as series ID
};
playerService.openPlayer(
url,
title,
@@ -937,7 +944,8 @@ export const StalkerStore = signalStore(
false,
playlist?.userAgent,
playlist?.referrer,
playlist?.origin
playlist?.origin,
contentInfo
);
} catch (error) {
console.error(
@@ -1,6 +1,6 @@
@if (selectedCategory()) {
@if (isStalker && selectedItem()) {
@if (contentType === 'vod') {
@if (contentType() === 'vod') {
@if (selectedItem().series?.length > 0) {
<!-- VOD item with embedded series (vclub) -->
<app-stalker-series-view [vodWithSeries]="selectedItem()" />
@@ -23,13 +23,13 @@
"
/>
}
} @else if (contentType === 'series') {
} @else if (contentType() === 'series') {
<app-stalker-series-view />
}
} @else {
<app-grid-list
[isLoading]="isPaginatedContentLoading()"
[items]="paginatedContent()"
[items]="contentWithProgress()"
(itemClicked)="onItemClick($event)"
[pageIndex]="pageIndex()"
[totalPages]="totalPages()"
@@ -1,4 +1,4 @@
import { Component, inject, OnInit } from '@angular/core';
import { Component, inject, OnInit, computed } from '@angular/core';
import { PageEvent } from '@angular/material/paginator';
import { ActivatedRoute, Router } from '@angular/router';
import { Store } from '@ngrx/store';
@@ -27,16 +27,55 @@ export class CategoryContentViewComponent implements OnInit {
private readonly router = inject(Router);
readonly isStalker = this.activatedRoute.snapshot.data['api'] === 'stalker';
readonly contentType = this.activatedRoute.snapshot.data['contentType'];
private readonly store = this.isStalker
? inject(StalkerStore)
: inject(XtreamStore);
readonly contentType = this.store.selectedContentType;
readonly limit = this.store.limit;
readonly pageIndex = this.store.page;
readonly pageSizeOptions = this.isStalker ? [14] : [10, 25, 50, 100];
readonly selectedCategory = this.store.getSelectedCategory;
readonly paginatedContent = this.store.getPaginatedContent;
readonly contentWithProgress = computed(() => {
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,
@@ -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<void> {
await this.playbackService.savePlaybackPosition(playlistId, data);
}
async getPlaybackPosition(
playlistId: string,
contentXtreamId: number,
contentType: 'vod' | 'episode'
): Promise<any | null> {
return this.playbackService.getPlaybackPosition(
playlistId,
contentXtreamId,
contentType
);
}
async getSeriesPlaybackPositions(
playlistId: string,
seriesXtreamId: number
): Promise<any[]> {
return this.playbackService.getSeriesPlaybackPositions(
playlistId,
seriesXtreamId
);
}
async getRecentPlaybackPositions(
playlistId: string,
limit?: number
): Promise<any[]> {
return this.playbackService.getRecentPlaybackPositions(
playlistId,
limit
);
}
async getAllPlaybackPositions(playlistId: string): Promise<any[]> {
return this.playbackService.getAllPlaybackPositions(playlistId);
}
async clearPlaybackPosition(
playlistId: string,
contentXtreamId: number,
contentType: 'vod' | 'episode'
): Promise<void> {
await this.playbackService.clearPlaybackPosition(
playlistId,
contentXtreamId,
contentType
);
}
// =========================================================================
// Cleanup Operations
// =========================================================================
@@ -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<void> {
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<any | null> {
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<any[]> {
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<any[]> {
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<any[]> {
const allPositions = this.getPlaybackPositionsFromStorage();
return allPositions[playlistId] || [];
}
async clearPlaybackPosition(
playlistId: string,
contentXtreamId: number,
contentType: 'vod' | 'episode'
): Promise<void> {
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<string, any[]> {
try {
const data = localStorage.getItem(STORAGE_KEYS.PLAYBACK_POSITIONS);
return data ? JSON.parse(data) : {};
} catch {
return {};
}
}
private savePlaybackPositionsToStorage(
positions: Record<string, any[]>
): 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)
// =========================================================================
@@ -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<XtreamContentItem | null>;
// =========================================================================
// Playback Position Operations
// =========================================================================
/**
* Save/update playback position for content
*/
savePlaybackPosition(
playlistId: string,
data: PlaybackPositionData
): Promise<void>;
/**
* Get playback position for a specific content item
*/
getPlaybackPosition(
playlistId: string,
contentXtreamId: number,
contentType: 'vod' | 'episode'
): Promise<PlaybackPositionData | null>;
/**
* Get all episode positions for a series (for highlighting watched episodes)
*/
getSeriesPlaybackPositions(
playlistId: string,
seriesXtreamId: number
): Promise<PlaybackPositionData[]>;
/**
* Get recently watched items with positions (for "Continue Watching" section)
*/
getRecentPlaybackPositions(
playlistId: string,
limit?: number
): Promise<PlaybackPositionData[]>;
/**
* Get all playback positions for a playlist (for grid view)
*/
getAllPlaybackPositions(playlistId: string): Promise<PlaybackPositionData[]>;
/**
* Clear playback position (mark as unwatched)
*/
clearPlaybackPosition(
playlistId: string,
contentXtreamId: number,
contentType: 'vod' | 'episode'
): Promise<void>;
// =========================================================================
// Cleanup Operations
// =========================================================================
@@ -1,6 +1,10 @@
<h2 mat-dialog-title>{{ title }}</h2>
<mat-dialog-content class="content">
<app-web-player-view [streamUrl]="streamUrl" />
<app-web-player-view
[streamUrl]="streamUrl"
[startTime]="data.startTime || 0"
(timeUpdate)="handleTimeUpdate($event)"
/>
</mat-dialog-content>
<mat-dialog-actions class="align-actions">
<button
@@ -7,10 +7,13 @@ import { MatSnackBar } from '@angular/material/snack-bar';
import { MatTooltip } from '@angular/material/tooltip';
import { TranslatePipe, TranslateService } from '@ngx-translate/core';
import { WebPlayerViewComponent } from 'shared-portals';
import { XtreamStore } from '../stores/xtream.store';
export interface PlayerDialogData {
streamUrl: string;
title: string;
contentInfo?: any;
startTime?: number;
}
@Component({
@@ -31,15 +34,33 @@ export class PlayerDialogComponent {
readonly data = inject<PlayerDialogData>(MAT_DIALOG_DATA);
private snackBar = inject(MatSnackBar);
private translateService = inject(TranslateService);
private readonly xtreamStore = inject(XtreamStore);
readonly title: string;
readonly streamUrl: string;
private lastSaveTime = 0;
constructor() {
this.streamUrl = this.data.streamUrl;
this.title = this.data.title;
}
handleTimeUpdate(event: { currentTime: number; duration: number }) {
if (!this.data.contentInfo) return;
const now = Date.now();
// Save every 15 seconds
if (now - this.lastSaveTime > 15000) {
this.lastSaveTime = now;
this.xtreamStore.savePosition(this.data.contentInfo.playlistId, {
...this.data.contentInfo,
positionSeconds: Math.floor(event.currentTime),
durationSeconds: Math.floor(event.duration),
});
}
}
showCopyNotification() {
this.snackBar.open(
this.translateService.instant('PORTALS.STREAM_URL_COPIED'),
@@ -52,6 +52,12 @@
<article
class="episode-card"
(click)="selectEpisode(episode)"
[class.episode-card--watched]="
isEpisodeWatched(episode)
"
[class.episode-card--in-progress]="
isEpisodeInProgress(episode)
"
>
<div class="episode-card__thumbnail">
@if (episodeInfo?.movie_image) {
@@ -69,6 +75,37 @@
<mat-icon>play_circle_outline</mat-icon>
</div>
}
@if (getEpisodeProgress(episode) > 0) {
<app-progress-capsule
[progress]="getEpisodeProgress(episode)"
/>
@if (getEpisodePositionText(episode); as time) {
<div class="episode-card__position-text">
{{ time }}
</div>
}
}
<app-watched-badge
[isWatched]="isEpisodeWatched(episode)"
/>
<div class="episode-card__actions">
<button
mat-icon-button
class="toggle-watched-btn"
(click)="toggleWatched($event, episode)"
[matTooltip]="
(isEpisodeWatched(episode)
? 'XTREAM.MARK_UNWATCHED'
: 'XTREAM.MARK_WATCHED') | translate
"
>
<mat-icon>{{
isEpisodeWatched(episode)
? 'check_circle'
: 'radio_button_unchecked'
}}</mat-icon>
</button>
</div>
<div class="episode-card__overlay">
<div class="episode-card__play">
<mat-icon>play_arrow</mat-icon>
@@ -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 {
@@ -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<Record<string, XtreamSerieEpisode[]>>();
readonly seriesId = input.required<number>(); // Add seriesId input
readonly isLoading = input<boolean>(false);
@Output() episodeClicked = new EventEmitter<any>();
@@ -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`;
}
}
@@ -69,6 +69,7 @@
<!-- Seasons & Episodes -->
<app-season-container
[seasons]="item.episodes"
[seriesId]="item.series_id"
(episodeClicked)="playEpisode($event)"
/>
</app-content-hero>
@@ -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
);
}
@@ -0,0 +1,55 @@
import { Component, input, computed } from '@angular/core';
@Component({
selector: 'app-progress-capsule',
standalone: true,
template: `
<div
class="progress-capsule"
[class.progress-capsule--watched]="isWatched()"
>
<div
class="progress-capsule__fill"
[style.width.%]="progress()"
></div>
</div>
`,
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<number>(); // 0-100
readonly isWatched = computed(() => this.progress() >= 90);
}
@@ -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()) {
<div class="watched-badge">
<mat-icon>{{ icon() }}</mat-icon>
</div>
}
`,
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<boolean>();
readonly icon = input<string>('check_circle');
}
@@ -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';
@@ -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<string, PlaybackPositionData>; // key: `${contentType}_${xtreamId}`
seriesPositions: Map<number, PlaybackPositionData[]>; // 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<void> {
const positions = await dataSource.getAllPlaybackPositions(
playlistId
);
console.log(
`[withPlaybackPositions] Loaded all positions (${positions.length})`
);
const positionsMap = new Map<string, PlaybackPositionData>();
const seriesMap = new Map<number, PlaybackPositionData[]>();
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<void> {
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<void> {
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<void> {
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<void> {
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();
}
},
})
);
}
@@ -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
);
},
@@ -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);
}
},
/**
@@ -61,10 +61,27 @@
<!-- Action Buttons -->
<ng-container hero-actions>
<button class="play-btn" (click)="playVod(item)">
<mat-icon>play_arrow</mat-icon>
{{ 'XTREAM.PLAY' | translate }}
</button>
@if (hasPlaybackPosition()) {
<button
class="play-btn play-btn--resume"
(click)="resumeVod(item)"
>
<mat-icon>play_arrow</mat-icon>
{{ 'XTREAM.RESUME' | translate }} {{ formatPosition() }}
</button>
<button
class="play-btn play-btn--restart"
(click)="playVod(item)"
>
<mat-icon>replay</mat-icon>
{{ 'XTREAM.RESTART' | translate }}
</button>
} @else {
<button class="play-btn" (click)="playVod(item)">
<mat-icon>play_arrow</mat-icon>
{{ 'XTREAM.PLAY' | translate }}
</button>
}
@if (!isFavorite()) {
<button class="favorite-btn" (click)="toggleFavorite()">
<mat-icon>add</mat-icon>
@@ -1,5 +1,5 @@
import { Location, SlicePipe } from '@angular/common';
import { Component, OnDestroy, OnInit, inject } from '@angular/core';
import { Component, OnDestroy, OnInit, inject, computed } from '@angular/core';
import { MatIcon } from '@angular/material/icon';
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
import { ActivatedRoute } from '@angular/router';
@@ -33,6 +33,15 @@ export class VodDetailsComponent implements OnInit, OnDestroy {
readonly isFavorite = this.xtreamStore.isFavorite;
readonly selectedItem = this.xtreamStore.selectedItem;
readonly hasPlaybackPosition = computed(() => {
const vodId = this.route.snapshot.params.vodId;
const inProgress = this.xtreamStore.isInProgress(Number(vodId), 'vod');
console.log(
`[VodDetails] hasPlaybackPosition check: vodId=${vodId}, inProgress=${inProgress}`
);
return inProgress;
});
ngOnInit(): void {
const { categoryId, vodId } = this.route.snapshot.params;
this.xtreamStore.fetchVodDetailsWithMetadata({ vodId, categoryId });
@@ -40,6 +49,10 @@ export class VodDetailsComponent implements OnInit, OnDestroy {
vodId,
this.xtreamStore.currentPlaylist().id
);
this.xtreamStore.loadVodPosition(
this.xtreamStore.currentPlaylist().id,
Number(vodId)
);
}
ngOnDestroy() {
@@ -50,13 +63,66 @@ export class VodDetailsComponent implements OnInit, OnDestroy {
this.addToRecentlyViewed();
const streamUrl = this.xtreamStore.constructVodStreamUrl(vodItem);
// Use route param vodId if available to match ngOnInit logic, otherwise fallback to item id
const routeVodId = this.route.snapshot.params.vodId;
const id = routeVodId
? Number(routeVodId)
: Number(
vodItem.movie_data?.stream_id || (vodItem as any).stream_id
);
console.log(`[VodDetails] playVod: Resolved ID=${id} for item`, vodItem);
const contentInfo = {
playlistId: this.xtreamStore.currentPlaylist().id,
contentXtreamId: id,
contentType: 'vod',
};
this.xtreamStore.openPlayer(
streamUrl,
vodItem.info.name ?? vodItem?.movie_data?.name,
vodItem.info.movie_image
vodItem.info.movie_image,
undefined,
contentInfo
);
}
resumeVod(vodItem: XtreamVodDetails) {
this.addToRecentlyViewed();
const vodId = Number(this.route.snapshot.params.vodId);
const position = this.xtreamStore
.playbackPositions()
.get(`vod_${vodId}`);
const streamUrl = this.xtreamStore.constructVodStreamUrl(vodItem);
// Use vodId from route (same as above)
const contentInfo = {
playlistId: this.xtreamStore.currentPlaylist().id,
contentXtreamId: vodId,
contentType: 'vod',
};
this.xtreamStore.openPlayer(
streamUrl,
vodItem.info.name ?? vodItem?.movie_data?.name,
vodItem.info.movie_image,
position?.positionSeconds,
contentInfo
);
}
formatPosition(): string {
const vodId = Number(this.route.snapshot.params.vodId);
const position = this.xtreamStore.playbackPositions().get(`vod_${vodId}`);
if (!position) return '';
const date = new Date(0);
date.setSeconds(position.positionSeconds);
const timeString = date.toISOString().substr(11, 8);
return timeString.startsWith('00:') ? timeString.substr(3) : timeString;
}
toggleFavorite() {
this.xtreamStore.toggleFavorite(
this.route.snapshot.params.vodId,
@@ -18,7 +18,6 @@ import { XtreamStore } from '../stores/xtream.store';
RouterOutlet,
TranslateModule,
],
providers: [XtreamStore],
})
export class XtreamShellComponent {
private readonly route = inject(ActivatedRoute);
+8
View File
@@ -285,6 +285,14 @@
"RATING": "Bewertung",
"AGE": "Alter",
"EPISODE": "Folge",
"RESUME": "Fortsetzen",
"RESTART": "Neu starten",
"CONTINUE_WATCHING": "Weiter schauen",
"WATCHED": "Angesehen",
"IN_PROGRESS": "In Wiedergabe",
"MARK_WATCHED": "Als angesehen markieren",
"MARK_UNWATCHED": "Als nicht angesehen markieren",
"EPISODES_WATCHED": "{{count}} von {{total}} Folgen angesehen",
"YEAR": "Jahr",
"YOUTUBE_TRAILER": "YouTube-Trailer",
"DESCRIPTION": "Beschreibung",
+1
View File
@@ -338,6 +338,7 @@
"CONTINUE_WATCHING": "Continue Watching",
"WATCHED": "Watched",
"IN_PROGRESS": "In Progress",
"MARK_WATCHED": "Mark as Watched",
"MARK_UNWATCHED": "Mark as Unwatched",
"EPISODES_WATCHED": "{{count}} of {{total}} episodes watched",
"ACCOUNT_INFO": {
+8
View File
@@ -224,6 +224,14 @@
"RATING": "Clasificación",
"AGE": "Edad",
"EPISODE": "Episodio",
"RESUME": "Continuar",
"RESTART": "Reiniciar",
"CONTINUE_WATCHING": "Continuar viendo",
"WATCHED": "Visto",
"IN_PROGRESS": "En progreso",
"MARK_WATCHED": "Marcar como visto",
"MARK_UNWATCHED": "Marcar como no visto",
"EPISODES_WATCHED": "{{count}} de {{total}} episodios vistos",
"YEAR": "Año",
"YOUTUBE_TRAILER": "Tráiler de YouTube",
"DESCRIPTION": "Descripción",
+8
View File
@@ -296,6 +296,14 @@
"EPISODE_RUN_TIME": "Длительность эпизода",
"AGE": "Возраст",
"EPISODE": "Эпизод",
"RESUME": "Продолжить",
"RESTART": "С начала",
"CONTINUE_WATCHING": "Продолжить просмотр",
"WATCHED": "Просмотрено",
"IN_PROGRESS": "В процессе",
"MARK_WATCHED": "Отметить как просмотренное",
"MARK_UNWATCHED": "Отметить как непросмотренное",
"EPISODES_WATCHED": "Просмотрено {{count}} из {{total}} эпизодов",
"YEAR": "Год",
"YOUTUBE_TRAILER": "YouTube-трейлер",
"DESCRIPTION": "Описание",
+90
View File
@@ -6,6 +6,96 @@ interface NodeModule {
interface Window {
process: any;
require: any;
electron: {
// Core
getAppVersion: () => Promise<string>;
platform: string;
// Listeners
onChannelChange: (callback: (data: { direction: 'up' | 'down' }) => void) => void;
onPlayerError: (callback: (data: { player: string; error: string; originalError: string }) => void) => void;
onEpgProgress: (callback: (data: { url: string; status: 'loading' | 'complete' | 'error'; stats?: { totalChannels: number; totalPrograms: number }; error?: string }) => void) => void;
onDbSaveContentProgress: (callback: (count: number) => void) => void;
removeDbSaveContentProgress: () => void;
onPlaybackPositionUpdate: (callback: (data: any) => void) => void;
removePlaybackPositionListener: () => void;
// Files & System
saveFileDialog: (defaultPath: string, filters?: { name: string; extensions: string[] }[]) => Promise<any>;
writeFile: (filePath: string, content: string) => Promise<void>;
getLocalIpAddresses: () => Promise<string[]>;
// Playlist
fetchPlaylistByUrl: (url: string, title?: string) => Promise<any>;
updatePlaylistFromFilePath: (filePath: string, title: string) => Promise<any>;
openPlaylistFromFile: () => Promise<any>;
autoUpdatePlaylists: (playlists: any[]) => Promise<any[]>;
dbDeleteAllPlaylists: () => Promise<void>;
// Player
setUserAgent: (userAgent: string, referer?: string) => Promise<void>;
openInMpv: (url: string, title: string, userAgent: string, referer?: string, origin?: string, contentInfo?: any, startTime?: number) => Promise<void>;
openInVlc: (url: string, title: string, userAgent: string, referer?: string, origin?: string, contentInfo?: any, startTime?: number) => Promise<void>;
setMpvPlayerPath: (mpvPlayerPath: string) => Promise<void>;
setVlcPlayerPath: (vlcPlayerPath: string) => Promise<void>;
// EPG
fetchEpg: (urls: string[]) => Promise<{ success: boolean; message?: string; skipped?: string[] }>;
getChannelPrograms: (channelId: string) => Promise<any>;
getEpgChannels: () => Promise<any>;
getEpgChannelsByRange: (skip: number, limit: number) => Promise<any>;
forceFetchEpg: (url: string) => Promise<void>;
clearEpgData: () => Promise<void>;
checkEpgFreshness: (urls: string[], maxAgeHours?: number) => Promise<any>;
searchEpgPrograms: (searchTerm: string, limit?: number) => Promise<any>;
// Settings & Requests
updateSettings: (settings: any) => Promise<void>;
getAiSettings: () => Promise<any>;
stalkerRequest: (payload: any) => Promise<any>;
xtreamRequest: (payload: any) => Promise<any>;
// Database - Playlists & Content
dbCreatePlaylist: (playlist: any) => Promise<void>;
dbGetPlaylist: (playlistId: string) => Promise<any>;
dbUpdatePlaylist: (playlistId: string, updates: any) => Promise<void>;
dbDeletePlaylist: (playlistId: string) => Promise<void>;
dbDeleteXtreamContent: (playlistId: string) => Promise<any>;
dbRestoreXtreamUserData: (playlistId: string, favoritedXtreamIds: number[], recentlyViewedXtreamIds: any[]) => Promise<void>;
// Database - Categories & Content
dbHasCategories: (playlistId: string, type: string) => Promise<boolean>;
dbGetCategories: (playlistId: string, type: string) => Promise<any[]>;
dbSaveCategories: (playlistId: string, categories: any[], type: string, hiddenCategoryXtreamIds?: number[]) => Promise<void>;
dbGetAllCategories: (playlistId: string, type: string) => Promise<any[]>;
dbUpdateCategoryVisibility: (categoryIds: number[], hidden: boolean) => Promise<void>;
dbHasContent: (playlistId: string, type: string) => Promise<boolean>;
dbGetContent: (playlistId: string, type: string) => Promise<any[]>;
dbSaveContent: (playlistId: string, streams: any[], type: string) => Promise<any>;
dbSearchContent: (playlistId: string, searchTerm: string, types: string[]) => Promise<any[]>;
dbGlobalSearch: (searchTerm: string, types: string[]) => Promise<any[]>;
dbGetContentByXtreamId: (xtreamId: number, playlistId: string) => Promise<any>;
// Database - Favorites & Recent
dbGetRecentlyViewed: () => Promise<any[]>;
dbClearRecentlyViewed: () => Promise<void>;
dbAddFavorite: (contentId: number, playlistId: string) => Promise<void>;
dbRemoveFavorite: (contentId: number, playlistId: string) => Promise<void>;
dbIsFavorite: (contentId: number, playlistId: string) => Promise<boolean>;
dbGetFavorites: (playlistId: string) => Promise<any[]>;
dbGetRecentItems: (playlistId: string) => Promise<any[]>;
dbAddRecentItem: (contentId: number, playlistId: string) => Promise<void>;
dbClearPlaylistRecentItems: (playlistId: string) => Promise<void>;
dbRemoveRecentItem: (contentId: number, playlistId: string) => Promise<void>;
// Database - Playback Positions
dbSavePlaybackPosition: (playlistId: string, data: any) => Promise<void>;
dbGetPlaybackPosition: (playlistId: string, contentXtreamId: number, contentType: 'vod' | 'episode') => Promise<any>;
dbGetSeriesPlaybackPositions: (playlistId: string, seriesXtreamId: number) => Promise<any[]>;
dbGetRecentPlaybackPositions: (playlistId: string, limit?: number) => Promise<any[]>;
dbGetAllPlaybackPositions: (playlistId: string) => Promise<any[]>;
dbClearPlaybackPosition: (playlistId: string, contentXtreamId: number, contentType: 'vod' | 'episode') => Promise<void>;
};
}
/* export interface ParsedPlaylist {
+1
View File
@@ -1,6 +1,7 @@
export * from './lib/data.service';
export * from './lib/database-electron.service';
export * from './lib/epg.service';
export * from './lib/playback-position.service';
export * from './lib/playlists.service';
export * from './lib/portal-status.service';
export * from './lib/sort.service';
@@ -0,0 +1,86 @@
import { Injectable } from '@angular/core';
@Injectable({
providedIn: 'root',
})
export class PlaybackPositionService {
async savePlaybackPosition(playlistId: string, data: any): Promise<void> {
try {
await window.electron.dbSavePlaybackPosition(playlistId, data);
} catch (error) {
console.error('Error saving playback position:', error);
}
}
async getPlaybackPosition(
playlistId: string,
contentXtreamId: number,
contentType: 'vod' | 'episode'
): Promise<any | null> {
try {
return await window.electron.dbGetPlaybackPosition(
playlistId,
contentXtreamId,
contentType
);
} catch (error) {
console.error('Error getting playback position:', error);
return null;
}
}
async getSeriesPlaybackPositions(
playlistId: string,
seriesXtreamId: number
): Promise<any[]> {
try {
return await window.electron.dbGetSeriesPlaybackPositions(
playlistId,
seriesXtreamId
);
} catch (error) {
console.error('Error getting series playback positions:', error);
return [];
}
}
async getRecentPlaybackPositions(
playlistId: string,
limit?: number
): Promise<any[]> {
try {
return await window.electron.dbGetRecentPlaybackPositions(
playlistId,
limit
);
} catch (error) {
console.error('Error getting recent playback positions:', error);
return [];
}
}
async getAllPlaybackPositions(playlistId: string): Promise<any[]> {
try {
return await window.electron.dbGetAllPlaybackPositions(playlistId);
} catch (error) {
console.error('Error getting all playback positions:', error);
return [];
}
}
async clearPlaybackPosition(
playlistId: string,
contentXtreamId: number,
contentType: 'vod' | 'episode'
): Promise<void> {
try {
await window.electron.dbClearPlaybackPosition(
playlistId,
contentXtreamId,
contentType
);
} catch (error) {
console.error('Error clearing playback position:', error);
}
}
}
@@ -157,6 +157,24 @@ const CREATE_TABLE_STATEMENTS = [
INSERT INTO epg_programs_fts(rowid, title, description, category)
VALUES (new.id, new.title, new.description, new.category);
END`,
// Playback Positions table
`CREATE TABLE IF NOT EXISTS playback_positions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
playlist_id TEXT NOT NULL,
content_xtream_id INTEGER NOT NULL,
content_type TEXT NOT NULL CHECK (content_type IN ('vod', 'episode')),
series_xtream_id INTEGER,
season_number INTEGER,
episode_number INTEGER,
position_seconds INTEGER NOT NULL DEFAULT 0,
duration_seconds INTEGER,
updated_at TEXT DEFAULT (datetime('now')),
FOREIGN KEY (playlist_id) REFERENCES playlists (id) ON DELETE CASCADE
)`,
`CREATE UNIQUE INDEX IF NOT EXISTS playback_positions_content_playlist_unique ON playback_positions(content_xtream_id, playlist_id, content_type)`,
`CREATE INDEX IF NOT EXISTS playback_positions_playlist_idx ON playback_positions(playlist_id)`,
`CREATE INDEX IF NOT EXISTS playback_positions_series_idx ON playback_positions(series_xtream_id)`,
`CREATE INDEX IF NOT EXISTS playback_positions_updated_idx ON playback_positions(updated_at)`,
];
/**
+45
View File
@@ -169,6 +169,48 @@ export const epgPrograms = sqliteTable(
})
);
// Playback Positions table
export const playbackPositions = sqliteTable(
'playback_positions',
{
id: integer('id').primaryKey({ autoIncrement: true }),
playlistId: text('playlist_id')
.notNull()
.references(() => playlists.id, { onDelete: 'cascade' }),
// For VOD: store xtream_id of the movie
// For Series: store episode ID (from XtreamSerieEpisode.id)
contentXtreamId: integer('content_xtream_id').notNull(),
// 'vod' | 'episode'
contentType: text('content_type', {
enum: ['vod', 'episode'],
}).notNull(),
// For episodes: store series xtream_id for grouping
seriesXtreamId: integer('series_xtream_id'),
// For episodes: store season and episode numbers for display
seasonNumber: integer('season_number'),
episodeNumber: integer('episode_number'),
// Playback position in seconds
positionSeconds: integer('position_seconds').notNull().default(0),
// Total duration in seconds (for percentage calculation)
durationSeconds: integer('duration_seconds'),
// Last updated timestamp
updatedAt: text('updated_at').default(sql`CURRENT_TIMESTAMP`),
},
(table) => ({
// Unique constraint: one position per content per playlist
contentPlaylistUnique: uniqueIndex(
'playback_positions_content_playlist_unique'
).on(table.contentXtreamId, table.playlistId, table.contentType),
playlistIdx: index('playback_positions_playlist_idx').on(
table.playlistId
),
seriesIdx: index('playback_positions_series_idx').on(
table.seriesXtreamId
),
updatedIdx: index('playback_positions_updated_idx').on(table.updatedAt),
})
);
// Type exports for TypeScript
export type Playlist = typeof playlists.$inferSelect;
export type NewPlaylist = typeof playlists.$inferInsert;
@@ -190,3 +232,6 @@ export type NewEpgChannel = typeof epgChannels.$inferInsert;
export type EpgProgramDb = typeof epgPrograms.$inferSelect;
export type NewEpgProgramDb = typeof epgPrograms.$inferInsert;
export type PlaybackPosition = typeof playbackPositions.$inferSelect;
export type NewPlaybackPosition = typeof playbackPositions.$inferInsert;
@@ -1,11 +1,13 @@
import {
Component,
ElementRef,
EventEmitter,
inject,
Input,
OnChanges,
OnDestroy,
OnInit,
Output,
SimpleChanges,
} from '@angular/core';
import Artplayer from 'artplayer';
@@ -36,6 +38,11 @@ export class ArtPlayerComponent implements OnInit, OnDestroy, OnChanges {
@Input() channel!: Channel;
@Input() volume = 1;
@Input() showCaptions = false;
@Input() startTime = 0;
@Output() timeUpdate = new EventEmitter<{
currentTime: number;
duration: number;
}>();
private player!: Artplayer;
private hls: Hls | null = null;
@@ -121,6 +128,19 @@ export class ArtPlayerComponent implements OnInit, OnDestroy, OnChanges {
},
},
});
if (this.startTime > 0) {
this.player.on('ready', () => {
this.player.seek = this.startTime;
});
}
this.player.on('video:timeupdate', () => {
this.timeUpdate.emit({
currentTime: this.player.currentTime,
duration: this.player.duration,
});
});
}
private getVideoType(url: string): string {
@@ -1,11 +1,13 @@
import {
Component,
ElementRef,
EventEmitter,
inject,
Input,
OnChanges,
OnDestroy,
OnInit,
Output,
SimpleChanges,
ViewChild,
} from '@angular/core';
@@ -27,6 +29,11 @@ export class HtmlVideoPlayerComponent implements OnInit, OnChanges, OnDestroy {
/** Channel to play */
@Input() channel!: Channel;
@Input() volume = 1;
@Input() startTime = 0;
@Output() timeUpdate = new EventEmitter<{
currentTime: number;
duration: number;
}>();
private readonly dataService = inject(DataService);
@@ -44,6 +51,19 @@ export class HtmlVideoPlayerComponent implements OnInit, OnChanges, OnDestroy {
this.videoPlayer.nativeElement.addEventListener('volumechange', () => {
this.onVolumeChange();
});
this.videoPlayer.nativeElement.addEventListener('loadedmetadata', () => {
if (this.startTime > 0) {
this.videoPlayer.nativeElement.currentTime = this.startTime;
}
});
this.videoPlayer.nativeElement.addEventListener('timeupdate', () => {
this.timeUpdate.emit({
currentTime: this.videoPlayer.nativeElement.currentTime,
duration: this.videoPlayer.nativeElement.duration,
});
});
}
/**
@@ -1,10 +1,12 @@
import {
Component,
ElementRef,
EventEmitter,
Input,
OnChanges,
OnDestroy,
OnInit,
Output,
SimpleChanges,
ViewChild,
ViewEncapsulation,
@@ -32,6 +34,11 @@ export class VjsPlayerComponent implements OnInit, OnChanges, OnDestroy {
/** VideoJs object */
player!: any;
@Input() volume = 1;
@Input() startTime = 0;
@Output() timeUpdate = new EventEmitter<{
currentTime: number;
duration: number;
}>();
/**
* Instantiate Video.js on component init
@@ -50,10 +57,23 @@ export class VjsPlayerComponent implements OnInit, OnChanges, OnDestroy {
);
this.player.volume(this.volume);
this.player.on('loadedmetadata', () => {
if (this.startTime > 0) {
this.player.currentTime(this.startTime);
}
});
this.player.on('volumechange', () => {
const currentVolume = this.player.volume();
localStorage.setItem('volume', currentVolume.toString());
});
this.player.on('timeupdate', () => {
this.timeUpdate.emit({
currentTime: this.player.currentTime(),
duration: this.player.duration(),
});
});
}
);
try {
@@ -1,9 +1,27 @@
@if (player === 'videojs') {
<app-vjs-player [options]="vjsOptions" />
<app-vjs-player
[options]="vjsOptions"
[startTime]="startTime()"
(timeUpdate)="timeUpdate.emit($event)"
/>
} @else if (player === 'html5') {
<app-html-video-player [channel]="$any(channel)" />
<app-html-video-player
[channel]="$any(channel)"
[startTime]="startTime()"
(timeUpdate)="timeUpdate.emit($event)"
/>
} @else if (player === 'artplayer') {
<app-art-player [channel]="$any(channel)" [volume]="1" [showCaptions]="false" />
<app-art-player
[channel]="$any(channel)"
[volume]="1"
[showCaptions]="false"
[startTime]="startTime()"
(timeUpdate)="timeUpdate.emit($event)"
/>
} @else {
<app-vjs-player [options]="vjsOptions" />
<app-vjs-player
[options]="vjsOptions"
[startTime]="startTime()"
(timeUpdate)="timeUpdate.emit($event)"
/>
}
@@ -1,5 +1,7 @@
import {
Component,
EventEmitter,
Output,
Signal,
ViewEncapsulation,
effect,
@@ -27,6 +29,11 @@ export class WebPlayerViewComponent {
storage = inject(StorageMap);
streamUrl = input.required<string>();
startTime = input<number>(0);
@Output() timeUpdate = new EventEmitter<{
currentTime: number;
duration: number;
}>();
settings = toSignal(
this.storage.get(STORE_KEY.Settings)