mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-14 21:26:15 -08:00
feat: add external playback support and UI enhancements
- Integrated external playback functionality in category and detail views. - Updated styles for play buttons to reflect different states (idle, launching, stop). - Enhanced episode cards to indicate launching and active external playback states. - Added new computed properties for managing external playback session states. - Improved user interface for playback buttons with dynamic labels and icons based on playback state. - Introduced new settings option to show external playback status bar on desktop. - Updated translations for new features and UI elements.
This commit is contained in:
1 parent
3a2114c932
commit
4732af8a38
46 files changed
+1705
-105
No files matched your search
@@ -0,0 +1,63 @@
|
||||
import { ExternalPlayerSession } from 'shared-interfaces';
|
||||
import { ExternalPlayerSessionRegistry } from './external-player-session-registry';
|
||||
|
||||
describe('ExternalPlayerSessionRegistry', () => {
|
||||
let updates: ExternalPlayerSession[];
|
||||
let registry: ExternalPlayerSessionRegistry;
|
||||
|
||||
beforeEach(() => {
|
||||
updates = [];
|
||||
registry = new ExternalPlayerSessionRegistry((session) => {
|
||||
updates.push(session);
|
||||
});
|
||||
});
|
||||
|
||||
it('creates launching sessions and marks them opened', () => {
|
||||
const session = registry.beginSession({
|
||||
player: 'mpv',
|
||||
title: 'Example',
|
||||
streamUrl: 'https://example.com/video.m3u8',
|
||||
});
|
||||
|
||||
expect(session.status).toBe('launching');
|
||||
expect(registry.getActiveSessionId()).toBe(session.id);
|
||||
|
||||
const opened = registry.markOpened(session.id);
|
||||
expect(opened?.status).toBe('opened');
|
||||
expect(updates.at(-1)?.status).toBe('opened');
|
||||
});
|
||||
|
||||
it('keeps close capability and closes the session explicitly', async () => {
|
||||
const close = jest.fn();
|
||||
const session = registry.beginSession({
|
||||
player: 'vlc',
|
||||
title: 'Example',
|
||||
streamUrl: 'https://example.com/video.m3u8',
|
||||
});
|
||||
|
||||
registry.attachCloser(session.id, close);
|
||||
|
||||
expect(registry.getSession(session.id)?.canClose).toBe(true);
|
||||
|
||||
const closed = await registry.closeSession(session.id);
|
||||
|
||||
expect(close).toHaveBeenCalled();
|
||||
expect(closed?.status).toBe('closed');
|
||||
expect(closed?.canClose).toBe(false);
|
||||
expect(registry.getActiveSessionId()).toBeNull();
|
||||
});
|
||||
|
||||
it('marks runtime failures as errors without clearing the active id', () => {
|
||||
const session = registry.beginSession({
|
||||
player: 'mpv',
|
||||
title: 'Example',
|
||||
streamUrl: 'https://example.com/video.m3u8',
|
||||
});
|
||||
|
||||
const errored = registry.markError(session.id, 'Failed to launch');
|
||||
|
||||
expect(errored?.status).toBe('error');
|
||||
expect(errored?.error).toBe('Failed to launch');
|
||||
expect(registry.getActiveSessionId()).toBe(session.id);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,137 @@
|
||||
import {
|
||||
ExternalPlayerName,
|
||||
ExternalPlayerSession,
|
||||
PlayerContentInfo,
|
||||
} from 'shared-interfaces';
|
||||
|
||||
interface CreateExternalPlayerSessionOptions {
|
||||
player: ExternalPlayerName;
|
||||
title: string;
|
||||
thumbnail?: string | null;
|
||||
streamUrl: string;
|
||||
contentInfo?: PlayerContentInfo;
|
||||
}
|
||||
|
||||
interface UpdateExternalPlayerSessionOptions {
|
||||
status?: ExternalPlayerSession['status'];
|
||||
error?: string;
|
||||
canClose?: boolean;
|
||||
}
|
||||
|
||||
interface ExternalPlayerSessionRuntime {
|
||||
snapshot: ExternalPlayerSession;
|
||||
close?: () => Promise<void> | void;
|
||||
}
|
||||
|
||||
export class ExternalPlayerSessionRegistry {
|
||||
private readonly sessions = new Map<string, ExternalPlayerSessionRuntime>();
|
||||
private activeSessionId: string | null = null;
|
||||
|
||||
constructor(
|
||||
private readonly onUpdate: (session: ExternalPlayerSession) => void
|
||||
) {}
|
||||
|
||||
beginSession(
|
||||
options: CreateExternalPlayerSessionOptions
|
||||
): ExternalPlayerSession {
|
||||
const startedAt = new Date().toISOString();
|
||||
const session: ExternalPlayerSession = {
|
||||
id: crypto.randomUUID(),
|
||||
player: options.player,
|
||||
status: 'launching',
|
||||
title: options.title,
|
||||
thumbnail: options.thumbnail ?? null,
|
||||
streamUrl: options.streamUrl,
|
||||
contentInfo: options.contentInfo,
|
||||
startedAt,
|
||||
updatedAt: startedAt,
|
||||
canClose: false,
|
||||
};
|
||||
|
||||
this.sessions.set(session.id, { snapshot: session });
|
||||
this.activeSessionId = session.id;
|
||||
this.onUpdate(session);
|
||||
return session;
|
||||
}
|
||||
|
||||
getActiveSessionId(): string | null {
|
||||
return this.activeSessionId;
|
||||
}
|
||||
|
||||
getSession(id: string): ExternalPlayerSession | null {
|
||||
return this.sessions.get(id)?.snapshot ?? null;
|
||||
}
|
||||
|
||||
attachCloser(
|
||||
id: string,
|
||||
close: () => Promise<void> | void
|
||||
): ExternalPlayerSession | null {
|
||||
const runtime = this.sessions.get(id);
|
||||
if (!runtime) {
|
||||
return null;
|
||||
}
|
||||
|
||||
runtime.close = close;
|
||||
return this.updateSession(id, { canClose: true });
|
||||
}
|
||||
|
||||
updateSession(
|
||||
id: string,
|
||||
options: UpdateExternalPlayerSessionOptions
|
||||
): ExternalPlayerSession | null {
|
||||
const runtime = this.sessions.get(id);
|
||||
if (!runtime) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const next: ExternalPlayerSession = {
|
||||
...runtime.snapshot,
|
||||
...options,
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
runtime.snapshot = next;
|
||||
this.onUpdate(next);
|
||||
return next;
|
||||
}
|
||||
|
||||
markOpened(id: string): ExternalPlayerSession | null {
|
||||
return this.updateSession(id, { status: 'opened' });
|
||||
}
|
||||
|
||||
markPlaying(id: string): ExternalPlayerSession | null {
|
||||
const current = this.getSession(id);
|
||||
if (!current || current.status === 'playing') {
|
||||
return current;
|
||||
}
|
||||
|
||||
return this.updateSession(id, { status: 'playing' });
|
||||
}
|
||||
|
||||
markClosed(id: string): ExternalPlayerSession | null {
|
||||
if (this.activeSessionId === id) {
|
||||
this.activeSessionId = null;
|
||||
}
|
||||
return this.updateSession(id, { status: 'closed', canClose: false });
|
||||
}
|
||||
|
||||
markError(id: string, error: string): ExternalPlayerSession | null {
|
||||
return this.updateSession(id, {
|
||||
status: 'error',
|
||||
error,
|
||||
canClose: false,
|
||||
});
|
||||
}
|
||||
|
||||
async closeSession(id: string): Promise<ExternalPlayerSession | null> {
|
||||
const runtime = this.sessions.get(id);
|
||||
if (!runtime) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
await runtime.close?.();
|
||||
} finally {
|
||||
return this.markClosed(id);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,10 +1,9 @@
|
||||
import { ipcMain } from 'electron';
|
||||
/* import {
|
||||
OPEN_MPV_PLAYER,
|
||||
OPEN_VLC_PLAYER,
|
||||
SET_MPV_PLAYER_PATH,
|
||||
SET_VLC_PLAYER_PATH,
|
||||
} from 'shared-interfaces'; */
|
||||
import {
|
||||
CLOSE_EXTERNAL_PLAYER_SESSION,
|
||||
EXTERNAL_PLAYER_SESSION_UPDATE,
|
||||
ExternalPlayerSession,
|
||||
} from 'shared-interfaces';
|
||||
import App from '../app';
|
||||
import {
|
||||
MPV_PLAYER_PATH,
|
||||
@@ -18,6 +17,7 @@ import { existsSync } from 'fs';
|
||||
import { AddressInfo, createConnection, createServer } from 'net';
|
||||
import path from 'path';
|
||||
import { getStalkerPlaybackContextHeaders } from '../services/stalker-playback-context.service';
|
||||
import { ExternalPlayerSessionRegistry } from './external-player-session-registry';
|
||||
|
||||
export default class PlayerEvents {
|
||||
static bootstrapPlayerEvents(): Electron.IpcMain {
|
||||
@@ -30,6 +30,16 @@ let mpvProcess: ChildProcess | null = null;
|
||||
let mpvSocketPath: string | null = null;
|
||||
let positionPollingInterval: NodeJS.Timeout | null = null;
|
||||
|
||||
function sendExternalPlayerSessionUpdate(session: ExternalPlayerSession) {
|
||||
if (App.mainWindow && !App.mainWindow.isDestroyed()) {
|
||||
App.mainWindow.webContents.send(EXTERNAL_PLAYER_SESSION_UPDATE, session);
|
||||
}
|
||||
}
|
||||
|
||||
const externalPlayerSessions = new ExternalPlayerSessionRegistry(
|
||||
sendExternalPlayerSessionUpdate
|
||||
);
|
||||
|
||||
// Helper function to send error notifications to the renderer
|
||||
function sendPlayerErrorNotification(player: 'MPV' | 'VLC', error: string) {
|
||||
if (App.mainWindow && !App.mainWindow.isDestroyed()) {
|
||||
@@ -190,7 +200,11 @@ function isStalkerDirectStreamProfile(
|
||||
String(userAgent).trim().toLowerCase() === 'ksplayer';
|
||||
}
|
||||
|
||||
function startPositionPolling(socketPath: string, contentInfo: any) {
|
||||
function startPositionPolling(
|
||||
socketPath: string,
|
||||
contentInfo: any,
|
||||
sessionId: string
|
||||
) {
|
||||
stopPositionPolling();
|
||||
|
||||
// Initial delay to let video load
|
||||
@@ -204,6 +218,7 @@ function startPositionPolling(socketPath: string, contentInfo: any) {
|
||||
// console.log(`[PlayerEvents] Polling MPV: pos=${position}, dur=${duration}`);
|
||||
|
||||
if (position !== null && App.mainWindow) {
|
||||
externalPlayerSessions.markPlaying(sessionId);
|
||||
/*
|
||||
console.log('[PlayerEvents] Sending playback-position-update', {
|
||||
positionSeconds: Math.floor(position),
|
||||
@@ -214,6 +229,7 @@ function startPositionPolling(socketPath: string, contentInfo: any) {
|
||||
App.mainWindow.webContents.send(
|
||||
'playback-position-update',
|
||||
{
|
||||
sessionId,
|
||||
positionSeconds: Math.floor(position),
|
||||
durationSeconds: duration
|
||||
? Math.floor(duration)
|
||||
@@ -275,7 +291,11 @@ async function getVlcProperty(port: number, command: string): Promise<string> {
|
||||
});
|
||||
}
|
||||
|
||||
function startVlcPositionPolling(port: number, contentInfo: any) {
|
||||
function startVlcPositionPolling(
|
||||
port: number,
|
||||
contentInfo: any,
|
||||
sessionId: string
|
||||
) {
|
||||
stopVlcPositionPolling();
|
||||
|
||||
// VLC needs time to start and bind port
|
||||
@@ -295,9 +315,11 @@ function startVlcPositionPolling(port: number, contentInfo: any) {
|
||||
const duration = parseInt(lenStr, 10);
|
||||
|
||||
if (!isNaN(position) && App.mainWindow) {
|
||||
externalPlayerSessions.markPlaying(sessionId);
|
||||
App.mainWindow.webContents.send(
|
||||
'playback-position-update',
|
||||
{
|
||||
sessionId,
|
||||
positionSeconds: position,
|
||||
durationSeconds: !isNaN(duration) ? duration : null,
|
||||
...contentInfo,
|
||||
@@ -344,6 +366,7 @@ ipcMain.handle(
|
||||
event,
|
||||
url: string,
|
||||
title: string,
|
||||
thumbnail?: string,
|
||||
userAgent?: string,
|
||||
referer?: string,
|
||||
origin?: string,
|
||||
@@ -351,6 +374,14 @@ ipcMain.handle(
|
||||
startTime?: number,
|
||||
headers?: Record<string, string>
|
||||
) => {
|
||||
const session = externalPlayerSessions.beginSession({
|
||||
player: 'mpv',
|
||||
title,
|
||||
thumbnail,
|
||||
streamUrl: url,
|
||||
contentInfo,
|
||||
});
|
||||
|
||||
try {
|
||||
const mpvPath = getMpvPath();
|
||||
const reuseInstance = store.get(MPV_REUSE_INSTANCE, false);
|
||||
@@ -441,6 +472,16 @@ ipcMain.handle(
|
||||
'Successfully loaded new URL in existing MPV instance'
|
||||
);
|
||||
|
||||
externalPlayerSessions.attachCloser(session.id, async () => {
|
||||
try {
|
||||
await sendMpvCommand('quit', []);
|
||||
} catch {
|
||||
if (mpvProcess && !mpvProcess.killed) {
|
||||
mpvProcess.kill();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Seek if startTime provided
|
||||
if (startTime) {
|
||||
await sendMpvCommand('seek', [
|
||||
@@ -451,12 +492,18 @@ ipcMain.handle(
|
||||
|
||||
// Restart polling with new content info
|
||||
if (contentInfo) {
|
||||
startPositionPolling(mpvSocketPath, contentInfo);
|
||||
startPositionPolling(
|
||||
mpvSocketPath,
|
||||
contentInfo,
|
||||
session.id
|
||||
);
|
||||
} else {
|
||||
stopPositionPolling();
|
||||
}
|
||||
|
||||
return;
|
||||
return (
|
||||
externalPlayerSessions.markOpened(session.id) ?? session
|
||||
);
|
||||
} catch (err) {
|
||||
console.error(
|
||||
'Failed to send command to existing MPV:',
|
||||
@@ -593,6 +640,10 @@ ipcMain.handle(
|
||||
mpvProcess = null;
|
||||
mpvSocketPath = null;
|
||||
stopPositionPolling();
|
||||
externalPlayerSessions.markError(
|
||||
session.id,
|
||||
`Failed to start MPV player: ${err.message}`
|
||||
);
|
||||
reject(
|
||||
new Error(
|
||||
`Failed to start MPV player: ${err.message}. Make sure MPV is installed and the path '${mpvPath}' is correct.`
|
||||
@@ -615,7 +666,14 @@ ipcMain.handle(
|
||||
'MPV',
|
||||
`MPV player closed unexpectedly (exit code: ${code})`
|
||||
);
|
||||
externalPlayerSessions.markError(
|
||||
session.id,
|
||||
`MPV player closed unexpectedly (exit code: ${code})`
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
externalPlayerSessions.markClosed(session.id);
|
||||
});
|
||||
|
||||
// Store the process reference if reuse is enabled
|
||||
@@ -631,9 +689,15 @@ ipcMain.handle(
|
||||
proc.unref();
|
||||
}
|
||||
|
||||
externalPlayerSessions.attachCloser(session.id, async () => {
|
||||
if (!proc.killed) {
|
||||
proc.kill();
|
||||
}
|
||||
});
|
||||
|
||||
// Start polling if content info is provided
|
||||
if (contentInfo) {
|
||||
startPositionPolling(socketPath, contentInfo);
|
||||
startPositionPolling(socketPath, contentInfo, session.id);
|
||||
}
|
||||
|
||||
// Resolve immediately if spawn succeeds (error event would fire if it fails)
|
||||
@@ -644,11 +708,17 @@ ipcMain.handle(
|
||||
}
|
||||
}, 100);
|
||||
});
|
||||
|
||||
return externalPlayerSessions.markOpened(session.id) ?? session;
|
||||
} catch (error) {
|
||||
console.error('Error opening MPV player:', error);
|
||||
mpvProcess = null;
|
||||
mpvSocketPath = null;
|
||||
stopPositionPolling();
|
||||
externalPlayerSessions.markError(
|
||||
session.id,
|
||||
error instanceof Error ? error.message : String(error)
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@@ -678,6 +748,7 @@ ipcMain.handle(
|
||||
event,
|
||||
url: string,
|
||||
title: string,
|
||||
thumbnail?: string,
|
||||
userAgent?: string,
|
||||
referer?: string,
|
||||
origin?: string,
|
||||
@@ -685,6 +756,14 @@ ipcMain.handle(
|
||||
startTime?: number,
|
||||
headers?: Record<string, string>
|
||||
) => {
|
||||
const session = externalPlayerSessions.beginSession({
|
||||
player: 'vlc',
|
||||
title,
|
||||
thumbnail,
|
||||
streamUrl: url,
|
||||
contentInfo,
|
||||
});
|
||||
|
||||
try {
|
||||
const vlcPath = getVlcPath();
|
||||
const fallbackHeaders = getStalkerPlaybackContextHeaders(url) ?? {};
|
||||
@@ -782,9 +861,15 @@ ipcMain.handle(
|
||||
stdio: 'ignore', // Use 'ignore' for detached to allow clean shutdown
|
||||
});
|
||||
|
||||
externalPlayerSessions.attachCloser(session.id, async () => {
|
||||
if (!proc.killed) {
|
||||
proc.kill();
|
||||
}
|
||||
});
|
||||
|
||||
// Start polling if we have port and content info and NOT retrying (RC disabled on retry)
|
||||
if (!isRetry && rcPort > 0 && contentInfo) {
|
||||
startVlcPositionPolling(rcPort, contentInfo);
|
||||
startVlcPositionPolling(rcPort, contentInfo, session.id);
|
||||
}
|
||||
|
||||
// Capture stdout
|
||||
@@ -820,6 +905,10 @@ ipcMain.handle(
|
||||
);
|
||||
spawnVlc(retryArgs, true);
|
||||
} else {
|
||||
externalPlayerSessions.markError(
|
||||
session.id,
|
||||
`Failed to start VLC player: ${err.message}`
|
||||
);
|
||||
reject(
|
||||
new Error(
|
||||
`Failed to start VLC player: ${err.message}. Make sure VLC is installed and the path '${vlcPath}' is correct.`
|
||||
@@ -855,35 +944,30 @@ ipcMain.handle(
|
||||
'VLC',
|
||||
`VLC player closed unexpectedly (exit code: ${code})`
|
||||
);
|
||||
externalPlayerSessions.markError(
|
||||
session.id,
|
||||
`VLC player closed unexpectedly (exit code: ${code})`
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// 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.
|
||||
|
||||
externalPlayerSessions.markClosed(session.id);
|
||||
});
|
||||
|
||||
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.
|
||||
};
|
||||
|
||||
// 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();
|
||||
});
|
||||
|
||||
return externalPlayerSessions.markOpened(session.id) ?? session;
|
||||
} catch (error) {
|
||||
console.error('Error opening VLC player:', error);
|
||||
externalPlayerSessions.markError(
|
||||
session.id,
|
||||
error instanceof Error ? error.message : String(error)
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@@ -894,6 +978,13 @@ ipcMain.handle('SET_VLC_PLAYER_PATH', (_event, vlcPlayerPath) => {
|
||||
store.set(VLC_PLAYER_PATH, vlcPlayerPath);
|
||||
});
|
||||
|
||||
ipcMain.handle(
|
||||
CLOSE_EXTERNAL_PLAYER_SESSION,
|
||||
async (_event, sessionId: string) => {
|
||||
return externalPlayerSessions.closeSession(sessionId);
|
||||
}
|
||||
);
|
||||
|
||||
function getMpvPath() {
|
||||
const customMpvPath = store.get(MPV_PLAYER_PATH);
|
||||
if (customMpvPath) {
|
||||
|
||||
Reference in new issue
Block a user