mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-08 17:06:15 -08:00
feat: playback position persistence for all players and grid indicators
This commit is contained in:
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);
|
||||
|
||||
@@ -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 &&
|
||||
|
||||
@@ -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(
|
||||
|
||||
+3
-3
@@ -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()"
|
||||
|
||||
+53
-3
@@ -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);
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "Описание",
|
||||
|
||||
Vendored
+90
@@ -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,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)`,
|
||||
];
|
||||
|
||||
/**
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in new issue
Block a user