mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-08 17:06: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) {
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
import { ExternalPlayerSession } from 'shared-interfaces';
|
||||
import { ExternalPlaybackService } from './external-playback.service';
|
||||
|
||||
describe('ExternalPlaybackService', () => {
|
||||
let listener:
|
||||
| ((session: ExternalPlayerSession) => void)
|
||||
| undefined;
|
||||
let closeExternalPlayerSession: jest.Mock;
|
||||
let service: ExternalPlaybackService;
|
||||
|
||||
const createSession = (
|
||||
overrides: Partial<ExternalPlayerSession> = {}
|
||||
): ExternalPlayerSession => ({
|
||||
id: 'session-1',
|
||||
player: 'mpv',
|
||||
status: 'launching',
|
||||
title: 'Example',
|
||||
streamUrl: 'https://example.com/video.m3u8',
|
||||
contentInfo: {
|
||||
playlistId: 'playlist-1',
|
||||
contentXtreamId: 42,
|
||||
contentType: 'vod',
|
||||
},
|
||||
startedAt: '2026-03-07T10:00:00.000Z',
|
||||
updatedAt: '2026-03-07T10:00:00.000Z',
|
||||
canClose: true,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
listener = undefined;
|
||||
closeExternalPlayerSession = jest.fn().mockResolvedValue(null);
|
||||
|
||||
Object.defineProperty(window, 'electron', {
|
||||
configurable: true,
|
||||
value: {
|
||||
onExternalPlayerSessionUpdate: jest.fn((callback) => {
|
||||
listener = callback;
|
||||
return () => undefined;
|
||||
}),
|
||||
closeExternalPlayerSession,
|
||||
},
|
||||
});
|
||||
|
||||
service = new ExternalPlaybackService();
|
||||
});
|
||||
|
||||
it('tracks the latest launch and hides dismissed sessions until the next launch', () => {
|
||||
const first = createSession();
|
||||
listener?.(first);
|
||||
|
||||
expect(service.activeSession()).toEqual(first);
|
||||
expect(service.visibleSession()).toEqual(first);
|
||||
|
||||
service.dismissActiveSession();
|
||||
expect(service.visibleSession()).toBeNull();
|
||||
|
||||
const next = createSession({
|
||||
id: 'session-2',
|
||||
title: 'Another Example',
|
||||
});
|
||||
listener?.(next);
|
||||
|
||||
expect(service.visibleSession()).toEqual(next);
|
||||
});
|
||||
|
||||
it('matches only active non-error sessions to content info', () => {
|
||||
const playing = createSession({
|
||||
status: 'playing',
|
||||
updatedAt: '2026-03-07T10:00:05.000Z',
|
||||
});
|
||||
listener?.(playing);
|
||||
|
||||
expect(
|
||||
service.findMatchingSession({
|
||||
playlistId: 'playlist-1',
|
||||
contentXtreamId: 42,
|
||||
contentType: 'vod',
|
||||
})
|
||||
).toEqual(playing);
|
||||
|
||||
listener?.(
|
||||
createSession({
|
||||
status: 'error',
|
||||
error: 'Launch failed',
|
||||
updatedAt: '2026-03-07T10:00:06.000Z',
|
||||
})
|
||||
);
|
||||
|
||||
expect(
|
||||
service.findMatchingSession({
|
||||
playlistId: 'playlist-1',
|
||||
contentXtreamId: 42,
|
||||
contentType: 'vod',
|
||||
})
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it('delegates close requests for closable sessions', async () => {
|
||||
const session = createSession({ status: 'opened' });
|
||||
listener?.(session);
|
||||
|
||||
await service.closeActiveSession();
|
||||
|
||||
expect(closeExternalPlayerSession).toHaveBeenCalledWith(session.id);
|
||||
});
|
||||
|
||||
it('can close a specific session directly', async () => {
|
||||
const session = createSession({ id: 'session-2', status: 'playing' });
|
||||
|
||||
await service.closeSession(session);
|
||||
|
||||
expect(closeExternalPlayerSession).toHaveBeenCalledWith('session-2');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,92 @@
|
||||
import { computed, Injectable, signal } from '@angular/core';
|
||||
import { ExternalPlayerSession, PlayerContentInfo } from 'shared-interfaces';
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root',
|
||||
})
|
||||
export class ExternalPlaybackService {
|
||||
readonly activeSession = signal<ExternalPlayerSession | null>(null);
|
||||
private readonly dismissedSessionId = signal<string | null>(null);
|
||||
|
||||
readonly visibleSession = computed(() => {
|
||||
const session = this.activeSession();
|
||||
if (!session || session.status === 'closed') {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (this.dismissedSessionId() === session.id) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return session;
|
||||
});
|
||||
|
||||
constructor() {
|
||||
window.electron?.onExternalPlayerSessionUpdate?.((session) => {
|
||||
this.handleSessionUpdate(session);
|
||||
});
|
||||
}
|
||||
|
||||
dismissActiveSession(): void {
|
||||
const session = this.activeSession();
|
||||
if (!session) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.dismissedSessionId.set(session.id);
|
||||
}
|
||||
|
||||
async closeActiveSession(): Promise<void> {
|
||||
await this.closeSession(this.activeSession());
|
||||
}
|
||||
|
||||
async closeSession(
|
||||
session: ExternalPlayerSession | null | undefined
|
||||
): Promise<void> {
|
||||
if (!session?.canClose || !window.electron?.closeExternalPlayerSession) {
|
||||
return;
|
||||
}
|
||||
|
||||
await window.electron.closeExternalPlayerSession(session.id);
|
||||
}
|
||||
|
||||
findMatchingSession(
|
||||
contentInfo: PlayerContentInfo | null | undefined
|
||||
): ExternalPlayerSession | null {
|
||||
const session = this.activeSession();
|
||||
if (!contentInfo || !session?.contentInfo) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (session.status === 'error' || session.status === 'closed') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return this.matchesContent(contentInfo, session.contentInfo)
|
||||
? session
|
||||
: null;
|
||||
}
|
||||
|
||||
private handleSessionUpdate(session: ExternalPlayerSession): void {
|
||||
const current = this.activeSession();
|
||||
|
||||
if (!current || current.id === session.id || session.status === 'launching') {
|
||||
this.activeSession.set(session);
|
||||
}
|
||||
|
||||
if (session.status === 'launching') {
|
||||
this.dismissedSessionId.set(null);
|
||||
}
|
||||
}
|
||||
|
||||
private matchesContent(
|
||||
left: PlayerContentInfo,
|
||||
right: PlayerContentInfo
|
||||
): boolean {
|
||||
return (
|
||||
left.playlistId === right.playlistId &&
|
||||
left.contentType === right.contentType &&
|
||||
left.contentXtreamId === right.contentXtreamId
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { MatDialog } from '@angular/material/dialog';
|
||||
import { DataService } from 'services';
|
||||
import { VideoPlayer } from 'shared-interfaces';
|
||||
import { ExternalPlayerSession, VideoPlayer } from 'shared-interfaces';
|
||||
import { SettingsStore } from './settings-store.service';
|
||||
import { PlayerService } from './player.service';
|
||||
|
||||
@@ -51,8 +51,8 @@ describe('PlayerService', () => {
|
||||
expect(service.isEmbeddedPlayer(VideoPlayer.VLC)).toBe(false);
|
||||
});
|
||||
|
||||
it('uses the dialog fallback for embedded players', () => {
|
||||
service.openResolvedPlayback({
|
||||
it('uses the dialog fallback for embedded players', async () => {
|
||||
await service.openResolvedPlayback({
|
||||
streamUrl: 'https://example.com/video.mp4',
|
||||
title: 'Example Video',
|
||||
});
|
||||
@@ -60,4 +60,33 @@ describe('PlayerService', () => {
|
||||
expect(dialog.open).toHaveBeenCalled();
|
||||
expect(dataService.sendIpcEvent).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('forwards external playback launches through IPC', async () => {
|
||||
const session: ExternalPlayerSession = {
|
||||
id: 'session-1',
|
||||
player: 'mpv',
|
||||
status: 'opened',
|
||||
title: 'Example Video',
|
||||
streamUrl: 'https://example.com/video.mp4',
|
||||
startedAt: '2026-03-07T10:00:00.000Z',
|
||||
updatedAt: '2026-03-07T10:00:00.000Z',
|
||||
canClose: true,
|
||||
};
|
||||
settingsStore.player.mockReturnValue(VideoPlayer.MPV);
|
||||
dataService.sendIpcEvent.mockResolvedValue(session);
|
||||
|
||||
const result = await service.openResolvedPlayback({
|
||||
streamUrl: 'https://example.com/video.mp4',
|
||||
title: 'Example Video',
|
||||
});
|
||||
|
||||
expect(dataService.sendIpcEvent).toHaveBeenCalledWith(
|
||||
'OPEN_MPV_PLAYER',
|
||||
expect.objectContaining({
|
||||
url: 'https://example.com/video.mp4',
|
||||
title: 'Example Video',
|
||||
})
|
||||
);
|
||||
expect(result).toEqual(session);
|
||||
});
|
||||
});
|
||||
@@ -2,6 +2,7 @@ import { Injectable, inject } from '@angular/core';
|
||||
import { MatDialog } from '@angular/material/dialog';
|
||||
import { DataService } from 'services';
|
||||
import {
|
||||
ExternalPlayerSession,
|
||||
OPEN_MPV_PLAYER,
|
||||
OPEN_VLC_PLAYER,
|
||||
PlayerContentInfo,
|
||||
@@ -45,8 +46,8 @@ export class PlayerService {
|
||||
contentInfo?: PlayerContentInfo,
|
||||
startTime?: number,
|
||||
headers?: Record<string, string>
|
||||
) {
|
||||
this.openResolvedPlayback(
|
||||
): Promise<ExternalPlayerSession | void> {
|
||||
return this.openResolvedPlayback(
|
||||
{
|
||||
streamUrl,
|
||||
title,
|
||||
@@ -62,10 +63,10 @@ export class PlayerService {
|
||||
);
|
||||
}
|
||||
|
||||
openResolvedPlayback(
|
||||
async openResolvedPlayback(
|
||||
playback: ResolvedPortalPlayback,
|
||||
hideExternalInfoDialog = true
|
||||
) {
|
||||
): Promise<ExternalPlayerSession | void> {
|
||||
const player = this.settingsStore.player() ?? VideoPlayer.VideoJs;
|
||||
const {
|
||||
streamUrl,
|
||||
@@ -83,7 +84,7 @@ export class PlayerService {
|
||||
if (!hideExternalInfoDialog) {
|
||||
this.dialog.open(ExternalPlayerInfoDialogComponent);
|
||||
}
|
||||
this.dataService.sendIpcEvent(OPEN_MPV_PLAYER, {
|
||||
return await this.dataService.sendIpcEvent(OPEN_MPV_PLAYER, {
|
||||
url: streamUrl,
|
||||
title,
|
||||
thumbnail,
|
||||
@@ -98,7 +99,7 @@ export class PlayerService {
|
||||
if (!hideExternalInfoDialog) {
|
||||
this.dialog.open(ExternalPlayerInfoDialogComponent);
|
||||
}
|
||||
this.dataService.sendIpcEvent(OPEN_VLC_PLAYER, {
|
||||
return await this.dataService.sendIpcEvent(OPEN_VLC_PLAYER, {
|
||||
url: streamUrl,
|
||||
title,
|
||||
thumbnail,
|
||||
@@ -109,16 +110,16 @@ export class PlayerService {
|
||||
contentInfo,
|
||||
startTime,
|
||||
});
|
||||
} else {
|
||||
this.dialog.open<PlayerDialogComponent, PlayerDialogData>(
|
||||
PlayerDialogComponent,
|
||||
{
|
||||
data: { streamUrl, title, contentInfo, startTime },
|
||||
width: '80%',
|
||||
maxWidth: '1200px',
|
||||
maxHeight: '90vh',
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
this.dialog.open<PlayerDialogComponent, PlayerDialogData>(
|
||||
PlayerDialogComponent,
|
||||
{
|
||||
data: { streamUrl, title, contentInfo, startTime },
|
||||
width: '80%',
|
||||
maxWidth: '1200px',
|
||||
maxHeight: '90vh',
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -22,6 +22,7 @@ const DEFAULT_SETTINGS: Settings = {
|
||||
streamFormat: StreamFormat.M3u8StreamFormat,
|
||||
language: Language.ENGLISH,
|
||||
showCaptions: false,
|
||||
showExternalPlaybackBar: true,
|
||||
theme: Theme.LightTheme,
|
||||
mpvPlayerPath: '',
|
||||
mpvReuseInstance: false,
|
||||
@@ -73,6 +74,7 @@ export const SettingsStore = signalStore(
|
||||
streamFormat: store.streamFormat(),
|
||||
language: store.language(),
|
||||
showCaptions: store.showCaptions(),
|
||||
showExternalPlaybackBar: store.showExternalPlaybackBar(),
|
||||
theme: store.theme(),
|
||||
mpvPlayerPath: store.mpvPlayerPath(),
|
||||
mpvReuseInstance: store.mpvReuseInstance(),
|
||||
|
||||
@@ -205,6 +205,30 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (isDesktop) {
|
||||
<div class="setting-item">
|
||||
<div class="setting-item__meta">
|
||||
<h4>
|
||||
{{
|
||||
'SETTINGS.SHOW_EXTERNAL_PLAYBACK_BAR'
|
||||
| translate
|
||||
}}
|
||||
</h4>
|
||||
<p>
|
||||
{{
|
||||
'SETTINGS.SHOW_EXTERNAL_PLAYBACK_BAR_DESCRIPTION'
|
||||
| translate
|
||||
}}
|
||||
</p>
|
||||
</div>
|
||||
<div class="setting-item__control setting-item__toggle">
|
||||
<mat-checkbox
|
||||
formControlName="showExternalPlaybackBar"
|
||||
></mat-checkbox>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (settingsForm.value.player === 'mpv') {
|
||||
<div class="setting-item">
|
||||
<div class="setting-item__meta">
|
||||
|
||||
@@ -56,6 +56,7 @@ const DEFAULT_SETTINGS = {
|
||||
streamFormat: StreamFormat.M3u8StreamFormat,
|
||||
language: Language.ENGLISH,
|
||||
showCaptions: false,
|
||||
showExternalPlaybackBar: true,
|
||||
theme: Theme.LightTheme,
|
||||
mpvPlayerPath: '',
|
||||
vlcPlayerPath: '',
|
||||
|
||||
@@ -161,6 +161,7 @@ export class SettingsComponent implements OnInit, OnDestroy {
|
||||
streamFormat: StreamFormat.M3u8StreamFormat,
|
||||
language: Language.ENGLISH,
|
||||
showCaptions: false,
|
||||
showExternalPlaybackBar: true,
|
||||
theme: Theme.LightTheme,
|
||||
mpvPlayerPath: '',
|
||||
mpvReuseInstance: false,
|
||||
|
||||
+66
@@ -0,0 +1,66 @@
|
||||
<section
|
||||
class="external-playback-dock"
|
||||
[class.external-playback-dock--compact]="compact()"
|
||||
[class.external-playback-dock--error]="session().status === 'error'"
|
||||
>
|
||||
<div class="external-playback-dock__artwork">
|
||||
@if (session().thumbnail) {
|
||||
<img
|
||||
[src]="session().thumbnail!"
|
||||
[alt]="session().title"
|
||||
loading="lazy"
|
||||
/>
|
||||
} @else {
|
||||
<div class="external-playback-dock__placeholder">
|
||||
<mat-icon>play_circle</mat-icon>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="external-playback-dock__meta">
|
||||
<div class="external-playback-dock__topline">
|
||||
<span class="external-playback-dock__chip">{{ playerLabel() }}</span>
|
||||
@if (!compact()) {
|
||||
<span class="external-playback-dock__caption">
|
||||
External player
|
||||
</span>
|
||||
}
|
||||
</div>
|
||||
<div class="external-playback-dock__title">{{ session().title }}</div>
|
||||
<div class="external-playback-dock__status">
|
||||
@if (showSpinner()) {
|
||||
<mat-spinner diameter="14" strokeWidth="3"></mat-spinner>
|
||||
} @else {
|
||||
<mat-icon>{{
|
||||
session().status === 'error' ? 'error' : 'play_arrow'
|
||||
}}</mat-icon>
|
||||
}
|
||||
<span>{{ statusLabel() }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="external-playback-dock__actions">
|
||||
@if (showCloseButton()) {
|
||||
<button
|
||||
type="button"
|
||||
mat-stroked-button
|
||||
class="external-playback-dock__button"
|
||||
(click)="closeClicked.emit()"
|
||||
>
|
||||
<mat-icon>stop_circle</mat-icon>
|
||||
@if (!compact()) {
|
||||
<span>Close player</span>
|
||||
}
|
||||
</button>
|
||||
}
|
||||
<button
|
||||
type="button"
|
||||
mat-icon-button
|
||||
class="external-playback-dock__dismiss"
|
||||
(click)="dismissClicked.emit()"
|
||||
aria-label="Dismiss now playing panel"
|
||||
>
|
||||
<mat-icon>close</mat-icon>
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
+169
@@ -0,0 +1,169 @@
|
||||
:host {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.external-playback-dock {
|
||||
display: grid;
|
||||
grid-template-columns: auto minmax(0, 1fr) auto;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
min-height: 72px;
|
||||
padding: 12px 14px;
|
||||
border: 1px solid color-mix(in srgb, var(--mat-sys-outline-variant) 60%, transparent);
|
||||
border-radius: 20px;
|
||||
background:
|
||||
linear-gradient(
|
||||
135deg,
|
||||
color-mix(in srgb, var(--mat-sys-surface-container-high) 88%, white 12%),
|
||||
color-mix(in srgb, var(--mat-sys-surface-container) 92%, transparent)
|
||||
);
|
||||
box-shadow:
|
||||
0 18px 42px rgba(15, 23, 42, 0.12),
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.28);
|
||||
backdrop-filter: blur(18px);
|
||||
}
|
||||
|
||||
.external-playback-dock--error {
|
||||
border-color: color-mix(in srgb, var(--mat-sys-error) 50%, transparent);
|
||||
background:
|
||||
linear-gradient(
|
||||
135deg,
|
||||
color-mix(in srgb, var(--mat-sys-error-container) 72%, transparent),
|
||||
color-mix(in srgb, var(--mat-sys-surface-container) 90%, transparent)
|
||||
);
|
||||
}
|
||||
|
||||
.external-playback-dock--compact {
|
||||
min-height: 48px;
|
||||
padding: 8px 10px;
|
||||
gap: 10px;
|
||||
border-radius: 16px;
|
||||
box-shadow:
|
||||
0 12px 28px rgba(15, 23, 42, 0.16),
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.18);
|
||||
|
||||
.external-playback-dock__artwork {
|
||||
width: 38px;
|
||||
height: 38px;
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
.external-playback-dock__title {
|
||||
font-size: 0.86rem;
|
||||
}
|
||||
|
||||
.external-playback-dock__status {
|
||||
font-size: 0.72rem;
|
||||
}
|
||||
}
|
||||
|
||||
.external-playback-dock__artwork {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border-radius: 14px;
|
||||
overflow: hidden;
|
||||
background: color-mix(in srgb, var(--mat-sys-surface-container-highest) 92%, transparent);
|
||||
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.18);
|
||||
|
||||
img,
|
||||
.external-playback-dock__placeholder {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
img {
|
||||
object-fit: cover;
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
|
||||
.external-playback-dock__placeholder {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
color: var(--mat-sys-on-surface-variant);
|
||||
}
|
||||
|
||||
.external-playback-dock__meta {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.external-playback-dock__topline {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 3px;
|
||||
}
|
||||
|
||||
.external-playback-dock__chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
height: 20px;
|
||||
padding: 0 8px;
|
||||
border-radius: 999px;
|
||||
background: color-mix(in srgb, var(--mat-sys-primary-container) 82%, transparent);
|
||||
color: var(--mat-sys-on-primary-container);
|
||||
font-size: 0.68rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.08em;
|
||||
}
|
||||
|
||||
.external-playback-dock__caption {
|
||||
color: var(--mat-sys-on-surface-variant);
|
||||
font-size: 0.72rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
}
|
||||
|
||||
.external-playback-dock__title {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-size: 0.95rem;
|
||||
font-weight: 700;
|
||||
color: var(--mat-sys-on-surface);
|
||||
}
|
||||
|
||||
.external-playback-dock__status {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
margin-top: 4px;
|
||||
color: var(--mat-sys-on-surface-variant);
|
||||
font-size: 0.78rem;
|
||||
|
||||
mat-icon {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
mat-spinner {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.external-playback-dock__actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.external-playback-dock__button {
|
||||
border-radius: 999px;
|
||||
}
|
||||
|
||||
.external-playback-dock__dismiss {
|
||||
color: var(--mat-sys-on-surface-variant);
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.external-playback-dock {
|
||||
grid-template-columns: auto minmax(0, 1fr) auto;
|
||||
}
|
||||
|
||||
.external-playback-dock:not(.external-playback-dock--compact) {
|
||||
.external-playback-dock__button span {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
+52
@@ -0,0 +1,52 @@
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
import { By } from '@angular/platform-browser';
|
||||
import { ExternalPlayerSession } from 'shared-interfaces';
|
||||
import { ExternalPlaybackDockComponent } from './external-playback-dock.component';
|
||||
|
||||
describe('ExternalPlaybackDockComponent', () => {
|
||||
let fixture: ComponentFixture<ExternalPlaybackDockComponent>;
|
||||
let component: ExternalPlaybackDockComponent;
|
||||
|
||||
const session: ExternalPlayerSession = {
|
||||
id: 'session-1',
|
||||
player: 'mpv',
|
||||
status: 'launching',
|
||||
title: 'Example Video',
|
||||
streamUrl: 'https://example.com/video.mp4',
|
||||
startedAt: '2026-03-07T10:00:00.000Z',
|
||||
updatedAt: '2026-03-07T10:00:00.000Z',
|
||||
canClose: true,
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [ExternalPlaybackDockComponent],
|
||||
}).compileComponents();
|
||||
|
||||
fixture = TestBed.createComponent(ExternalPlaybackDockComponent);
|
||||
component = fixture.componentInstance;
|
||||
fixture.componentRef.setInput('session', session);
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
it('renders launch state copy for the active session', () => {
|
||||
const text = fixture.nativeElement.textContent;
|
||||
expect(text).toContain('Example Video');
|
||||
expect(text).toContain('Opening in MPV...');
|
||||
expect(text).toContain('Close player');
|
||||
});
|
||||
|
||||
it('emits close and dismiss actions', () => {
|
||||
const closeSpy = jest.fn();
|
||||
const dismissSpy = jest.fn();
|
||||
component.closeClicked.subscribe(closeSpy);
|
||||
component.dismissClicked.subscribe(dismissSpy);
|
||||
|
||||
const buttons = fixture.debugElement.queryAll(By.css('button'));
|
||||
buttons[0].nativeElement.click();
|
||||
buttons[1].nativeElement.click();
|
||||
|
||||
expect(closeSpy).toHaveBeenCalled();
|
||||
expect(dismissSpy).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
+52
@@ -0,0 +1,52 @@
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
computed,
|
||||
input,
|
||||
output,
|
||||
} from '@angular/core';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { MatIcon } from '@angular/material/icon';
|
||||
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
|
||||
import { ExternalPlayerSession } from 'shared-interfaces';
|
||||
|
||||
@Component({
|
||||
selector: 'app-external-playback-dock',
|
||||
imports: [MatButtonModule, MatIcon, MatProgressSpinnerModule],
|
||||
templateUrl: './external-playback-dock.component.html',
|
||||
styleUrl: './external-playback-dock.component.scss',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class ExternalPlaybackDockComponent {
|
||||
readonly session = input.required<ExternalPlayerSession>();
|
||||
readonly compact = input(false);
|
||||
|
||||
readonly closeClicked = output<void>();
|
||||
readonly dismissClicked = output<void>();
|
||||
|
||||
readonly playerLabel = computed(() => this.session().player.toUpperCase());
|
||||
readonly statusLabel = computed(() => {
|
||||
const session = this.session();
|
||||
const player = this.playerLabel();
|
||||
|
||||
switch (session.status) {
|
||||
case 'launching':
|
||||
return `Opening in ${player}...`;
|
||||
case 'opened':
|
||||
return `Opened in ${player}`;
|
||||
case 'playing':
|
||||
return `Playing in ${player}`;
|
||||
case 'error':
|
||||
return session.error || `${player} failed to launch`;
|
||||
default:
|
||||
return `${player} closed`;
|
||||
}
|
||||
});
|
||||
|
||||
readonly showSpinner = computed(
|
||||
() => this.session().status === 'launching'
|
||||
);
|
||||
readonly showCloseButton = computed(
|
||||
() => this.session().canClose && this.session().status !== 'error'
|
||||
);
|
||||
}
|
||||
+1
@@ -13,6 +13,7 @@
|
||||
<app-vod-details
|
||||
[item]="vodDetailsItem()"
|
||||
[isFavorite]="isFavorite()"
|
||||
[externalPlayback]="externalPlayback.activeSession()"
|
||||
(playClicked)="onPlayClicked($event)"
|
||||
(favoriteToggled)="onFavoriteToggled($event)"
|
||||
(backClicked)="onBackClicked()"
|
||||
|
||||
+9
-1
@@ -1,4 +1,11 @@
|
||||
import { ChangeDetectionStrategy, Component, input, output } from '@angular/core';
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
inject,
|
||||
input,
|
||||
output,
|
||||
} from '@angular/core';
|
||||
import { ExternalPlaybackService } from '../../../services/external-playback.service';
|
||||
import { VodDetailsItem } from 'shared-interfaces';
|
||||
import { VodDetailsComponent } from '../../../xtream-electron/vod-details/vod-details.component';
|
||||
import { StalkerSelectedVodItem } from '../../../stalker/models/stalker-favorite-item.interface';
|
||||
@@ -20,6 +27,7 @@ import { StalkerSeriesViewComponent } from '../../../stalker/stalker-series-view
|
||||
],
|
||||
})
|
||||
export class StalkerInlineDetailComponent {
|
||||
readonly externalPlayback = inject(ExternalPlaybackService);
|
||||
readonly categoryId = input<'vod' | 'series' | null>(null);
|
||||
readonly seriesItem = input<StalkerSelectedVodItem | null>(null);
|
||||
readonly isSeries = input<boolean>(false);
|
||||
|
||||
@@ -422,7 +422,7 @@ export class RecentlyViewedComponent {
|
||||
|
||||
if (this.isEmbeddedPlayer()) {
|
||||
} else {
|
||||
this.playerService.openResolvedPlayback(playback, true);
|
||||
void this.playerService.openResolvedPlayback(playback, true);
|
||||
}
|
||||
} catch (error) {
|
||||
this.logger.error('Failed to resolve ITV playback', error);
|
||||
|
||||
@@ -425,7 +425,7 @@ export class StalkerFavoritesComponent {
|
||||
|
||||
if (this.isEmbeddedPlayer()) {
|
||||
} else {
|
||||
this.playerService.openResolvedPlayback(playback, true);
|
||||
void this.playerService.openResolvedPlayback(playback, true);
|
||||
}
|
||||
} catch (error) {
|
||||
this.logger.error('Failed to resolve ITV playback', error);
|
||||
|
||||
+1
-1
@@ -289,7 +289,7 @@ export class StalkerLiveStreamLayoutComponent implements OnDestroy {
|
||||
if (isEmbeddedPlayer) {
|
||||
this.streamUrl = playback.streamUrl;
|
||||
} else {
|
||||
this.playerService.openResolvedPlayback(playback, true);
|
||||
void this.playerService.openResolvedPlayback(playback, true);
|
||||
}
|
||||
} catch (error) {
|
||||
this.logger.error('Playback failed', error);
|
||||
|
||||
@@ -72,6 +72,8 @@
|
||||
[seasons]="mappedSeasons()"
|
||||
[seriesId]="toSeriesId(serial.id)"
|
||||
[seriesTitle]="serial.info?.name"
|
||||
[openingEpisodeId]="openingEpisodeId()"
|
||||
[activeEpisodeId]="activeEpisodeId()"
|
||||
[isLoading]="
|
||||
isVodSeriesSeasonsLoading() ||
|
||||
(seasonContainer?.selectedSeason &&
|
||||
|
||||
@@ -33,6 +33,7 @@ import {
|
||||
import { StalkerSelectedVodItem, StalkerVodSource } from '../models';
|
||||
import { normalizeStalkerVodDetailsItem } from '../stalker-vod.utils';
|
||||
import { PlayerService } from '../../services/player.service';
|
||||
import { ExternalPlaybackService } from '../../services/external-playback.service';
|
||||
import { PortalInlinePlayerComponent } from '../../shared/components/portal-inline-player/portal-inline-player.component';
|
||||
|
||||
/**
|
||||
@@ -58,12 +59,15 @@ export class StalkerSeriesViewComponent {
|
||||
readonly stalkerStore = inject(StalkerStore);
|
||||
private readonly xtreamStore = inject(XtreamStore);
|
||||
private readonly playerService = inject(PlayerService);
|
||||
private readonly externalPlayback = inject(ExternalPlaybackService);
|
||||
private readonly snackBar = inject(MatSnackBar);
|
||||
private readonly translateService = inject(TranslateService);
|
||||
readonly backClicked = output<void>();
|
||||
private readonly logger = createLogger('StalkerSeriesView');
|
||||
readonly inlinePlayback = signal<ResolvedPortalPlayback | null>(null);
|
||||
private lastSaveTime = 0;
|
||||
readonly openingEpisodeId = signal<number | null>(null);
|
||||
readonly activeEpisodeId = signal<number | null>(null);
|
||||
|
||||
/**
|
||||
* Optional input for VOD items with embedded series array (vclub mode)
|
||||
@@ -136,6 +140,41 @@ export class StalkerSeriesViewComponent {
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
effect(() => {
|
||||
const session = this.externalPlayback.activeSession();
|
||||
const item = this.displayItem();
|
||||
const playlistId = this.stalkerStore.currentPlaylist()?._id;
|
||||
const seriesId = item ? this.toSeriesId(item.id) : 0;
|
||||
|
||||
if (
|
||||
!session?.contentInfo ||
|
||||
!playlistId ||
|
||||
!seriesId ||
|
||||
session.contentInfo.contentType !== 'episode' ||
|
||||
session.contentInfo.playlistId !== playlistId ||
|
||||
session.contentInfo.seriesXtreamId !== seriesId
|
||||
) {
|
||||
this.openingEpisodeId.set(null);
|
||||
this.activeEpisodeId.set(null);
|
||||
return;
|
||||
}
|
||||
|
||||
if (session.status === 'launching') {
|
||||
this.openingEpisodeId.set(session.contentInfo.contentXtreamId);
|
||||
this.activeEpisodeId.set(null);
|
||||
return;
|
||||
}
|
||||
|
||||
if (session.status === 'opened' || session.status === 'playing') {
|
||||
this.openingEpisodeId.set(null);
|
||||
this.activeEpisodeId.set(session.contentInfo.contentXtreamId);
|
||||
return;
|
||||
}
|
||||
|
||||
this.openingEpisodeId.set(null);
|
||||
this.activeEpisodeId.set(null);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -415,7 +454,7 @@ export class StalkerSeriesViewComponent {
|
||||
}
|
||||
|
||||
this.closeInlinePlayer();
|
||||
this.playerService.openResolvedPlayback(playback, true);
|
||||
void this.playerService.openResolvedPlayback(playback, true);
|
||||
} catch (error) {
|
||||
this.logger.error('Failed to start inline series playback', error);
|
||||
const errorMessage =
|
||||
|
||||
@@ -558,7 +558,7 @@ export function withStalkerPlayer() {
|
||||
startTime
|
||||
);
|
||||
|
||||
playerService.openResolvedPlayback(
|
||||
void playerService.openResolvedPlayback(
|
||||
playback,
|
||||
true
|
||||
);
|
||||
|
||||
@@ -81,6 +81,17 @@
|
||||
</button>
|
||||
</label>
|
||||
|
||||
@if (externalPlaybackSession(); as session) {
|
||||
<div class="header-now-playing">
|
||||
<app-external-playback-dock
|
||||
[session]="session"
|
||||
[compact]="true"
|
||||
(closeClicked)="closeActiveExternalSession()"
|
||||
(dismissClicked)="dismissActiveExternalSession()"
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
|
||||
<div class="header-actions">
|
||||
<button
|
||||
type="button"
|
||||
@@ -280,4 +291,16 @@
|
||||
<router-outlet />
|
||||
</main>
|
||||
</div>
|
||||
|
||||
@if (showExternalPlaybackBar()) {
|
||||
@if (externalPlaybackSession(); as session) {
|
||||
<footer class="workspace-now-playing">
|
||||
<app-external-playback-dock
|
||||
[session]="session"
|
||||
(closeClicked)="closeActiveExternalSession()"
|
||||
(dismissClicked)="dismissActiveExternalSession()"
|
||||
/>
|
||||
</footer>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
@@ -9,7 +9,7 @@
|
||||
.workspace-shell {
|
||||
display: grid;
|
||||
grid-template-columns: 60px 1fr;
|
||||
grid-template-rows: 56px 1fr;
|
||||
grid-template-rows: 56px minmax(0, 1fr) auto;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
@@ -21,7 +21,7 @@
|
||||
// ─── App Rail (Sidebar) ───────────────────────────────────────────────────────
|
||||
.app-rail {
|
||||
grid-column: 1;
|
||||
grid-row: 1 / span 2;
|
||||
grid-row: 1 / span 3;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
@@ -162,6 +162,13 @@
|
||||
app-region: drag;
|
||||
}
|
||||
|
||||
.header-now-playing {
|
||||
display: none;
|
||||
min-width: 0;
|
||||
flex: 0 1 280px;
|
||||
app-region: no-drag;
|
||||
}
|
||||
|
||||
.playlist-switcher {
|
||||
width: min(260px, 32vw);
|
||||
flex-shrink: 0;
|
||||
@@ -299,6 +306,11 @@
|
||||
}
|
||||
}
|
||||
|
||||
.workspace-now-playing {
|
||||
grid-column: 2;
|
||||
grid-row: 3;
|
||||
}
|
||||
|
||||
// ─── Context Panel ────────────────────────────────────────────────────────────
|
||||
.context-panel {
|
||||
border-right: 1px solid var(--mat-sys-outline-variant);
|
||||
@@ -426,10 +438,31 @@
|
||||
}
|
||||
|
||||
// ─── Responsive ───────────────────────────────────────────────────────────────
|
||||
@media (max-width: 960px) {
|
||||
.header-now-playing {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.workspace-now-playing {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.search-field {
|
||||
min-width: 120px;
|
||||
flex: 1 1 150px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 961px) {
|
||||
.workspace-now-playing {
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 780px) {
|
||||
.workspace-shell {
|
||||
grid-template-columns: 56px 1fr;
|
||||
grid-template-rows: 56px 1fr;
|
||||
grid-template-rows: 56px 1fr auto;
|
||||
}
|
||||
|
||||
.workspace-header {
|
||||
@@ -491,4 +524,8 @@
|
||||
grid-column: 1;
|
||||
grid-row: 3;
|
||||
}
|
||||
|
||||
.workspace-now-playing {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,8 @@ import { Store } from '@ngrx/store';
|
||||
import { of } from 'rxjs';
|
||||
import { PlaylistsService } from 'services';
|
||||
import { DownloadsService } from '../services/downloads.service';
|
||||
import { ExternalPlaybackService } from '../services/external-playback.service';
|
||||
import { SettingsStore } from '../services/settings-store.service';
|
||||
import { XtreamStore } from '../xtream-electron/stores/xtream.store';
|
||||
import { FavoritesContextService } from './favorites-context.service';
|
||||
import { SettingsContextService } from './settings-context.service';
|
||||
@@ -85,6 +87,21 @@ describe('WorkspaceShellComponent action matrix', () => {
|
||||
loadDownloads: jest.fn().mockResolvedValue(undefined),
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: ExternalPlaybackService,
|
||||
useValue: {
|
||||
activeSession: signal(null),
|
||||
visibleSession: signal(null),
|
||||
closeActiveSession: jest.fn(),
|
||||
dismissActiveSession: jest.fn(),
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: SettingsStore,
|
||||
useValue: {
|
||||
showExternalPlaybackBar: signal(true),
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: PlaylistsService,
|
||||
useValue: {
|
||||
|
||||
@@ -37,7 +37,10 @@ import {
|
||||
import { filter, firstValueFrom } from 'rxjs';
|
||||
import { PlaylistsService } from 'services';
|
||||
import { DownloadsService } from '../services/downloads.service';
|
||||
import { ExternalPlaybackService } from '../services/external-playback.service';
|
||||
import { SettingsStore } from '../services/settings-store.service';
|
||||
import { AddPlaylistDialogComponent } from '../shared/components/add-playlist/add-playlist-dialog.component';
|
||||
import { ExternalPlaybackDockComponent } from '../shared/components/external-playback-dock/external-playback-dock.component';
|
||||
import {
|
||||
buildPortalRailLinks,
|
||||
PortalRailLink,
|
||||
@@ -94,6 +97,7 @@ const SEARCH_INPUT_DEBOUNCE_MS = 350;
|
||||
MatMenuModule,
|
||||
MatTooltip,
|
||||
AddPlaylistMenuComponent,
|
||||
ExternalPlaybackDockComponent,
|
||||
PlaylistSwitcherComponent,
|
||||
TranslatePipe,
|
||||
RouterLink,
|
||||
@@ -114,6 +118,8 @@ export class WorkspaceShellComponent {
|
||||
private readonly xtreamStore = inject(XtreamStore);
|
||||
private readonly destroyRef = inject(DestroyRef);
|
||||
private readonly downloadsService = inject(DownloadsService);
|
||||
readonly externalPlayback = inject(ExternalPlaybackService);
|
||||
private readonly settingsStore = inject(SettingsStore);
|
||||
private readonly playlistsService = inject(PlaylistsService);
|
||||
private readonly dialog = inject(MatDialog);
|
||||
readonly favoritesCtx = inject(FavoritesContextService);
|
||||
@@ -210,6 +216,10 @@ export class WorkspaceShellComponent {
|
||||
readonly isGlobalDownloadsRoute = computed(() =>
|
||||
/^\/workspace\/downloads(?:\/)?(?:\?.*)?$/.test(this.currentUrl())
|
||||
);
|
||||
readonly externalPlaybackSession = this.externalPlayback.visibleSession;
|
||||
readonly showExternalPlaybackBar = computed(
|
||||
() => this.settingsStore.showExternalPlaybackBar?.() ?? true
|
||||
);
|
||||
readonly dashboardXtreamContext = computed<WorkspaceContext | null>(() => {
|
||||
if (!this.isDashboardRoute()) {
|
||||
return null;
|
||||
@@ -282,6 +292,14 @@ export class WorkspaceShellComponent {
|
||||
|
||||
return context?.provider === 'stalker';
|
||||
});
|
||||
|
||||
closeActiveExternalSession(): void {
|
||||
void this.externalPlayback.closeActiveSession();
|
||||
}
|
||||
|
||||
dismissActiveExternalSession(): void {
|
||||
this.externalPlayback.dismissActiveSession();
|
||||
}
|
||||
readonly searchPlaceholder = computed(() => {
|
||||
if (this.isSourcesRoute()) {
|
||||
return 'Search sources (all playlists)...';
|
||||
|
||||
+1
@@ -14,6 +14,7 @@
|
||||
[isFavorite]="isSelectedVodFavorite()"
|
||||
[playbackPosition]="selectedVodPlaybackPosition()"
|
||||
[inlinePlayback]="inlinePlayback()"
|
||||
[externalPlayback]="externalPlayback.activeSession()"
|
||||
(playClicked)="onVodPlay($event)"
|
||||
(resumeClicked)="onVodResume($event)"
|
||||
(favoriteToggled)="onVodFavoriteToggled($event)"
|
||||
|
||||
+3
-1
@@ -21,6 +21,7 @@ import {
|
||||
VodDetailsItem,
|
||||
} from 'shared-interfaces';
|
||||
import { DownloadsService } from '../../services/downloads.service';
|
||||
import { ExternalPlaybackService } from '../../services/external-playback.service';
|
||||
import { PlayerService } from '../../services/player.service';
|
||||
import { GridListComponent } from '../../shared/components/grid-list/grid-list.component';
|
||||
import { PlaylistErrorViewComponent } from '../../shared/components/playlist-error-view/playlist-error-view.component';
|
||||
@@ -90,6 +91,7 @@ export class CategoryContentViewComponent implements OnInit, OnDestroy {
|
||||
private readonly router = inject(Router);
|
||||
private readonly dataSource = inject(XTREAM_DATA_SOURCE);
|
||||
private readonly playerService = inject(PlayerService);
|
||||
readonly externalPlayback = inject(ExternalPlaybackService);
|
||||
private readonly snackBar = inject(MatSnackBar);
|
||||
private readonly translateService = inject(TranslateService);
|
||||
|
||||
@@ -692,7 +694,7 @@ export class CategoryContentViewComponent implements OnInit, OnDestroy {
|
||||
}
|
||||
|
||||
this.closeInlinePlayer();
|
||||
this.playerService.openResolvedPlayback(playback, true);
|
||||
void this.playerService.openResolvedPlayback(playback, true);
|
||||
} catch (error) {
|
||||
this.logger.error('Failed to start inline VOD playback', error);
|
||||
const errorMessage =
|
||||
|
||||
@@ -18,6 +18,20 @@
|
||||
--accent-color: #b81d24;
|
||||
--tag-bg: rgba(0, 0, 0, 0.06);
|
||||
--tag-border: rgba(0, 0, 0, 0.12);
|
||||
--play-idle-bg: linear-gradient(135deg, #fffaf2 0%, #ffffff 52%, #f7ecdf 100%);
|
||||
--play-idle-fg: #161310;
|
||||
--play-idle-border: rgba(88, 48, 18, 0.12);
|
||||
--play-idle-shadow: 0 18px 38px rgba(91, 56, 24, 0.14);
|
||||
--play-launching-bg: linear-gradient(135deg, #ffd36f 0%, #ffb14c 46%, #f57d35 100%);
|
||||
--play-launching-fg: #241103;
|
||||
--play-launching-border: rgba(142, 66, 0, 0.2);
|
||||
--play-launching-shadow: 0 22px 44px rgba(244, 136, 55, 0.32);
|
||||
--play-stop-bg: linear-gradient(135deg, #ffd1c8 0%, #ff9f8d 42%, #ef5e64 100%);
|
||||
--play-stop-fg: #321012;
|
||||
--play-stop-border: rgba(162, 38, 45, 0.18);
|
||||
--play-stop-shadow: 0 22px 46px rgba(214, 74, 86, 0.26);
|
||||
--play-icon-shell-bg: rgba(255, 255, 255, 0.32);
|
||||
--play-icon-shell-border: rgba(255, 255, 255, 0.3);
|
||||
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
@@ -40,6 +54,20 @@
|
||||
--tag-bg: rgba(255, 255, 255, 0.1);
|
||||
--tag-border: rgba(255, 255, 255, 0.2);
|
||||
--poster-shadow: 0 8px 40px rgba(0, 0, 0, 0.5);
|
||||
--play-idle-bg: linear-gradient(135deg, #fff5e6 0%, #f6efe5 58%, #e7dac9 100%);
|
||||
--play-idle-fg: #15110d;
|
||||
--play-idle-border: rgba(255, 255, 255, 0.18);
|
||||
--play-idle-shadow: 0 20px 42px rgba(0, 0, 0, 0.36);
|
||||
--play-launching-bg: linear-gradient(135deg, #ffdd79 0%, #ffb347 42%, #ef6a3a 100%);
|
||||
--play-launching-fg: #1f1105;
|
||||
--play-launching-border: rgba(255, 223, 136, 0.26);
|
||||
--play-launching-shadow: 0 24px 50px rgba(255, 153, 71, 0.3);
|
||||
--play-stop-bg: linear-gradient(135deg, #ffd7cf 0%, #ff9b8b 40%, #eb5961 100%);
|
||||
--play-stop-fg: #2d1012;
|
||||
--play-stop-border: rgba(255, 205, 205, 0.24);
|
||||
--play-stop-shadow: 0 24px 50px rgba(235, 89, 97, 0.24);
|
||||
--play-icon-shell-bg: rgba(255, 255, 255, 0.28);
|
||||
--play-icon-shell-border: rgba(255, 255, 255, 0.24);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
@@ -90,28 +118,198 @@
|
||||
// ============================================================================
|
||||
|
||||
.play-btn {
|
||||
--play-btn-bg: var(--play-idle-bg);
|
||||
--play-btn-fg: var(--play-idle-fg);
|
||||
--play-btn-border: var(--play-idle-border);
|
||||
--play-btn-shadow: var(--play-idle-shadow);
|
||||
|
||||
position: relative;
|
||||
isolation: isolate;
|
||||
overflow: hidden;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 12px 28px;
|
||||
gap: 12px;
|
||||
padding: 12px 26px;
|
||||
min-height: 54px;
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
background: #fff;
|
||||
color: #141414;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
letter-spacing: 0.01em;
|
||||
background: var(--play-btn-bg);
|
||||
background-size: 140% 140%;
|
||||
background-position: 0% 50%;
|
||||
color: var(--play-btn-fg);
|
||||
border: 1px solid var(--play-btn-border);
|
||||
border-radius: 16px;
|
||||
box-shadow: var(--play-btn-shadow);
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
transition:
|
||||
transform 220ms cubic-bezier(0.22, 1, 0.36, 1),
|
||||
background-position 280ms ease,
|
||||
box-shadow 260ms ease,
|
||||
border-color 220ms ease,
|
||||
color 220ms ease,
|
||||
filter 220ms ease;
|
||||
|
||||
&:hover {
|
||||
background: rgba(255, 255, 255, 0.85);
|
||||
transform: scale(1.02);
|
||||
&::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 1px;
|
||||
border-radius: inherit;
|
||||
background:
|
||||
linear-gradient(115deg, rgba(255, 255, 255, 0.34), transparent 36%),
|
||||
linear-gradient(180deg, rgba(255, 255, 255, 0.18), transparent 62%);
|
||||
opacity: 0.7;
|
||||
transition: opacity 220ms ease;
|
||||
z-index: -1;
|
||||
}
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
transform: translateY(-2px);
|
||||
background-position: 100% 50%;
|
||||
box-shadow:
|
||||
var(--play-btn-shadow),
|
||||
0 10px 20px rgba(255, 255, 255, 0.16) inset;
|
||||
}
|
||||
|
||||
&:hover:not(:disabled)::before {
|
||||
opacity: 0.95;
|
||||
}
|
||||
|
||||
&:active:not(:disabled) {
|
||||
transform: translateY(0);
|
||||
box-shadow: var(--play-btn-shadow);
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
cursor: progress;
|
||||
opacity: 1;
|
||||
filter: saturate(1.05);
|
||||
}
|
||||
|
||||
mat-icon {
|
||||
font-size: 24px;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
transition:
|
||||
transform 220ms cubic-bezier(0.22, 1, 0.36, 1),
|
||||
color 220ms ease,
|
||||
filter 220ms ease;
|
||||
}
|
||||
|
||||
&:hover:not(:disabled) .play-btn__icon {
|
||||
transform: scale(1.08);
|
||||
}
|
||||
|
||||
&__icon-shell {
|
||||
flex: 0 0 auto;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
border-radius: 999px;
|
||||
background: var(--play-icon-shell-bg);
|
||||
border: 1px solid var(--play-icon-shell-border);
|
||||
box-shadow:
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.38),
|
||||
0 6px 14px rgba(0, 0, 0, 0.08);
|
||||
backdrop-filter: blur(10px);
|
||||
transition:
|
||||
transform 220ms ease,
|
||||
background-color 220ms ease,
|
||||
border-color 220ms ease,
|
||||
box-shadow 220ms ease;
|
||||
}
|
||||
|
||||
&__label {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
&--state-launching,
|
||||
&--state-stop {
|
||||
color: var(--play-btn-fg);
|
||||
}
|
||||
|
||||
&--state-launching {
|
||||
--play-btn-bg: var(--play-launching-bg);
|
||||
--play-btn-fg: var(--play-launching-fg);
|
||||
--play-btn-border: var(--play-launching-border);
|
||||
--play-btn-shadow: var(--play-launching-shadow);
|
||||
animation: playButtonPulse 1.8s ease-in-out infinite;
|
||||
|
||||
.play-btn__icon-shell {
|
||||
box-shadow:
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.42),
|
||||
0 10px 22px rgba(184, 89, 26, 0.2);
|
||||
}
|
||||
|
||||
.play-btn__icon {
|
||||
animation: playIconLaunch 1.15s cubic-bezier(0.55, 0, 0.45, 1) infinite;
|
||||
}
|
||||
}
|
||||
|
||||
&--state-stop {
|
||||
--play-btn-bg: var(--play-stop-bg);
|
||||
--play-btn-fg: var(--play-stop-fg);
|
||||
--play-btn-border: var(--play-stop-border);
|
||||
--play-btn-shadow: var(--play-stop-shadow);
|
||||
|
||||
.play-btn__icon-shell {
|
||||
box-shadow:
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.4),
|
||||
0 12px 24px rgba(145, 38, 53, 0.18);
|
||||
}
|
||||
|
||||
.play-btn__icon {
|
||||
animation: playIconStop 1.2s ease-in-out infinite;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes playButtonPulse {
|
||||
0%,
|
||||
100% {
|
||||
box-shadow: var(--play-btn-shadow);
|
||||
}
|
||||
|
||||
50% {
|
||||
box-shadow:
|
||||
var(--play-btn-shadow),
|
||||
0 0 0 6px rgba(255, 190, 92, 0.14);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes playIconLaunch {
|
||||
0%,
|
||||
100% {
|
||||
transform: rotate(0deg) scale(1);
|
||||
}
|
||||
|
||||
50% {
|
||||
transform: rotate(18deg) scale(1.1);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes playIconStop {
|
||||
0%,
|
||||
100% {
|
||||
transform: scale(1);
|
||||
}
|
||||
|
||||
50% {
|
||||
transform: scale(1.08);
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.play-btn,
|
||||
.play-btn::before,
|
||||
.play-btn mat-icon,
|
||||
.play-btn__icon-shell {
|
||||
animation: none !important;
|
||||
transition-duration: 1ms !important;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -387,4 +585,4 @@
|
||||
text-decoration: none;
|
||||
padding: 0;
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
@@ -77,6 +77,12 @@
|
||||
[class.episode-card--in-progress]="
|
||||
isEpisodeInProgress(episode)
|
||||
"
|
||||
[class.episode-card--launching]="
|
||||
isEpisodeLaunching(episode)
|
||||
"
|
||||
[class.episode-card--active-external]="
|
||||
isEpisodeActiveExternal(episode)
|
||||
"
|
||||
>
|
||||
<div class="episode-card__thumbnail">
|
||||
@if (episodeInfo?.movie_image) {
|
||||
@@ -157,7 +163,15 @@
|
||||
</div>
|
||||
<div class="episode-card__overlay">
|
||||
<div class="episode-card__play">
|
||||
<mat-icon>play_arrow</mat-icon>
|
||||
@if (isEpisodeLaunching(episode)) {
|
||||
<mat-spinner diameter="26"></mat-spinner>
|
||||
} @else {
|
||||
<mat-icon>{{
|
||||
isEpisodeActiveExternal(episode)
|
||||
? 'equalizer'
|
||||
: 'play_arrow'
|
||||
}}</mat-icon>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
<div class="episode-card__number">
|
||||
@@ -192,6 +206,8 @@
|
||||
(click)="selectEpisode(episode)"
|
||||
[class.episode-list-item--watched]="isEpisodeWatched(episode)"
|
||||
[class.episode-list-item--in-progress]="isEpisodeInProgress(episode)"
|
||||
[class.episode-list-item--launching]="isEpisodeLaunching(episode)"
|
||||
[class.episode-list-item--active-external]="isEpisodeActiveExternal(episode)"
|
||||
>
|
||||
<div class="episode-list-item__number">
|
||||
{{ episode.episode_num }}
|
||||
@@ -237,8 +253,17 @@
|
||||
class="action-btn play-btn"
|
||||
(click)="selectEpisode(episode); $event.stopPropagation()"
|
||||
[matTooltip]="'Play'"
|
||||
[disabled]="isEpisodeLaunching(episode)"
|
||||
>
|
||||
<mat-icon>play_arrow</mat-icon>
|
||||
@if (isEpisodeLaunching(episode)) {
|
||||
<mat-spinner diameter="18"></mat-spinner>
|
||||
} @else {
|
||||
<mat-icon>{{
|
||||
isEpisodeActiveExternal(episode)
|
||||
? 'equalizer'
|
||||
: 'play_arrow'
|
||||
}}</mat-icon>
|
||||
}
|
||||
</button>
|
||||
@if (isElectron()) {
|
||||
@if (isEpisodeDownloaded(episode)) {
|
||||
|
||||
@@ -277,6 +277,23 @@
|
||||
}
|
||||
}
|
||||
|
||||
&--launching,
|
||||
&--active-external {
|
||||
border-color: color-mix(in srgb, var(--mat-sys-tertiary) 56%, transparent);
|
||||
box-shadow: 0 0 0 1px color-mix(in srgb, var(--mat-sys-tertiary) 28%, transparent);
|
||||
}
|
||||
|
||||
&--launching {
|
||||
.episode-card__overlay {
|
||||
opacity: 1;
|
||||
background: rgba(0, 0, 0, 0.56);
|
||||
}
|
||||
}
|
||||
|
||||
&--active-external {
|
||||
background: color-mix(in srgb, var(--mat-sys-tertiary-container) 18%, transparent);
|
||||
}
|
||||
|
||||
// Watched state
|
||||
&--watched {
|
||||
opacity: 0.7;
|
||||
@@ -676,6 +693,12 @@
|
||||
}
|
||||
}
|
||||
|
||||
&--launching,
|
||||
&--active-external {
|
||||
border-color: color-mix(in srgb, var(--mat-sys-tertiary) 46%, transparent);
|
||||
background: color-mix(in srgb, var(--mat-sys-tertiary-container) 12%, transparent);
|
||||
}
|
||||
|
||||
// Always show actions on touch devices
|
||||
@media (hover: none) {
|
||||
.episode-list-item__actions {
|
||||
|
||||
@@ -43,6 +43,8 @@ export class SeasonContainerComponent implements OnInit {
|
||||
readonly seriesId = input.required<number>();
|
||||
readonly seriesTitle = input<string>('');
|
||||
readonly isLoading = input<boolean>(false);
|
||||
readonly openingEpisodeId = input<number | null>(null);
|
||||
readonly activeEpisodeId = input<number | null>(null);
|
||||
|
||||
readonly isElectron = this.downloadsService.isAvailable;
|
||||
|
||||
@@ -221,6 +223,14 @@ export class SeasonContainerComponent implements OnInit {
|
||||
return this.xtreamStore.isInProgress(contentId, 'episode');
|
||||
}
|
||||
|
||||
isEpisodeLaunching(episode: XtreamSerieEpisode): boolean {
|
||||
return this.openingEpisodeId() === this.getEpisodeContentId(episode);
|
||||
}
|
||||
|
||||
isEpisodeActiveExternal(episode: XtreamSerieEpisode): boolean {
|
||||
return this.activeEpisodeId() === this.getEpisodeContentId(episode);
|
||||
}
|
||||
|
||||
getEpisodeProgress(episode: XtreamSerieEpisode) {
|
||||
const contentId = this.getEpisodeContentId(episode);
|
||||
return this.xtreamStore.getProgressPercent(contentId, 'episode');
|
||||
|
||||
@@ -80,6 +80,8 @@
|
||||
[seasons]="item.episodes"
|
||||
[seriesId]="item.series_id"
|
||||
[seriesTitle]="item.info?.name"
|
||||
[openingEpisodeId]="openingEpisodeId()"
|
||||
[activeEpisodeId]="activeEpisodeId()"
|
||||
(episodeClicked)="playEpisode($event)"
|
||||
/>
|
||||
</app-content-hero>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Location, SlicePipe } from '@angular/common';
|
||||
import { Component, inject, OnDestroy, OnInit, signal } from '@angular/core';
|
||||
import { Component, effect, inject, OnDestroy, OnInit, signal } from '@angular/core';
|
||||
import { MatIcon } from '@angular/material/icon';
|
||||
import { MatSnackBar } from '@angular/material/snack-bar';
|
||||
import { ActivatedRoute } from '@angular/router';
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
XtreamSerieEpisode,
|
||||
} from 'shared-interfaces';
|
||||
import { PlayerService } from '../../services/player.service';
|
||||
import { ExternalPlaybackService } from '../../services/external-playback.service';
|
||||
import { PortalInlinePlayerComponent } from '../../shared/components/portal-inline-player/portal-inline-player.component';
|
||||
import { SeasonContainerComponent } from '../season-container/season-container.component';
|
||||
import { XtreamStore } from '../stores/xtream.store';
|
||||
@@ -33,6 +34,7 @@ export class SerialDetailsComponent implements OnInit, OnDestroy {
|
||||
private readonly route = inject(ActivatedRoute);
|
||||
private readonly xtreamStore = inject(XtreamStore);
|
||||
private readonly playerService = inject(PlayerService);
|
||||
private readonly externalPlayback = inject(ExternalPlaybackService);
|
||||
private readonly snackBar = inject(MatSnackBar);
|
||||
private readonly translateService = inject(TranslateService);
|
||||
|
||||
@@ -43,6 +45,45 @@ export class SerialDetailsComponent implements OnInit, OnDestroy {
|
||||
readonly detailsError = this.xtreamStore.detailsError;
|
||||
readonly inlinePlayback = signal<ResolvedPortalPlayback | null>(null);
|
||||
private lastSaveTime = 0;
|
||||
readonly openingEpisodeId = signal<number | null>(null);
|
||||
readonly activeEpisodeId = signal<number | null>(null);
|
||||
|
||||
constructor() {
|
||||
effect(() => {
|
||||
const session = this.externalPlayback.activeSession();
|
||||
const selectedItem = this.selectedItem();
|
||||
const playlistId = this.xtreamStore.currentPlaylist()?.id;
|
||||
|
||||
if (
|
||||
!session?.contentInfo ||
|
||||
!selectedItem?.series_id ||
|
||||
!playlistId ||
|
||||
session.contentInfo.contentType !== 'episode' ||
|
||||
session.contentInfo.playlistId !== playlistId ||
|
||||
session.contentInfo.seriesXtreamId !==
|
||||
Number(selectedItem.series_id)
|
||||
) {
|
||||
this.openingEpisodeId.set(null);
|
||||
this.activeEpisodeId.set(null);
|
||||
return;
|
||||
}
|
||||
|
||||
if (session.status === 'launching') {
|
||||
this.openingEpisodeId.set(session.contentInfo.contentXtreamId);
|
||||
this.activeEpisodeId.set(null);
|
||||
return;
|
||||
}
|
||||
|
||||
if (session.status === 'opened' || session.status === 'playing') {
|
||||
this.openingEpisodeId.set(null);
|
||||
this.activeEpisodeId.set(session.contentInfo.contentXtreamId);
|
||||
return;
|
||||
}
|
||||
|
||||
this.openingEpisodeId.set(null);
|
||||
this.activeEpisodeId.set(null);
|
||||
});
|
||||
}
|
||||
|
||||
ngOnInit(): void {
|
||||
const { categoryId, serialId } = this.route.snapshot.params;
|
||||
@@ -153,6 +194,6 @@ export class SerialDetailsComponent implements OnInit, OnDestroy {
|
||||
}
|
||||
|
||||
this.closeInlinePlayer();
|
||||
this.playerService.openResolvedPlayback(playback, true);
|
||||
void this.playerService.openResolvedPlayback(playback, true);
|
||||
}
|
||||
}
|
||||
@@ -145,7 +145,7 @@ export function withPlayer() {
|
||||
const contentType =
|
||||
storeAny.selectedContentType?.() || 'vod';
|
||||
|
||||
playerService.openPlayer(
|
||||
void playerService.openPlayer(
|
||||
streamUrl,
|
||||
title,
|
||||
thumbnail,
|
||||
|
||||
@@ -64,22 +64,49 @@
|
||||
@if (hasPlaybackPosition()) {
|
||||
<button
|
||||
class="play-btn play-btn--resume"
|
||||
(click)="resumeVod(item)"
|
||||
(click)="onPrimaryAction(item)"
|
||||
[disabled]="isExternalLaunchPending()"
|
||||
[class.play-btn--state-launching]="externalPrimaryButtonState() === 'launching'"
|
||||
[class.play-btn--state-stop]="externalPrimaryButtonState() === 'stop'"
|
||||
>
|
||||
<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 }}
|
||||
<span class="play-btn__icon-shell" aria-hidden="true">
|
||||
<mat-icon class="play-btn__icon">{{ externalPrimaryIcon() }}</mat-icon>
|
||||
</span>
|
||||
<span class="play-btn__label">
|
||||
@if (externalPrimaryLabel(); as label) {
|
||||
{{ label }}
|
||||
} @else {
|
||||
{{ 'XTREAM.RESUME' | translate }} {{ formatPosition() }}
|
||||
}
|
||||
</span>
|
||||
</button>
|
||||
@if (!isExternalStopAction()) {
|
||||
<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
|
||||
class="play-btn"
|
||||
(click)="onPrimaryAction(item)"
|
||||
[disabled]="isExternalLaunchPending()"
|
||||
[class.play-btn--state-launching]="externalPrimaryButtonState() === 'launching'"
|
||||
[class.play-btn--state-stop]="externalPrimaryButtonState() === 'stop'"
|
||||
>
|
||||
<span class="play-btn__icon-shell" aria-hidden="true">
|
||||
<mat-icon class="play-btn__icon">{{ externalPrimaryIcon() }}</mat-icon>
|
||||
</span>
|
||||
<span class="play-btn__label">
|
||||
@if (externalPrimaryLabel(); as label) {
|
||||
{{ label }}
|
||||
} @else {
|
||||
{{ 'XTREAM.PLAY' | translate }}
|
||||
}
|
||||
</span>
|
||||
</button>
|
||||
}
|
||||
@if (!isFavorite()) {
|
||||
|
||||
@@ -9,7 +9,6 @@ import {
|
||||
signal,
|
||||
} from '@angular/core';
|
||||
import { MatIcon } from '@angular/material/icon';
|
||||
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
|
||||
import { MatSnackBar } from '@angular/material/snack-bar';
|
||||
import { ActivatedRoute } from '@angular/router';
|
||||
import { TranslateModule, TranslateService } from '@ngx-translate/core';
|
||||
@@ -20,6 +19,7 @@ import {
|
||||
XtreamVodDetails,
|
||||
} from 'shared-interfaces';
|
||||
import { DownloadsService } from '../../services/downloads.service';
|
||||
import { ExternalPlaybackService } from '../../services/external-playback.service';
|
||||
import { PlayerService } from '../../services/player.service';
|
||||
import { SettingsStore } from '../../services/settings-store.service';
|
||||
import { PortalInlinePlayerComponent } from '../../shared/components/portal-inline-player/portal-inline-player.component';
|
||||
@@ -46,7 +46,6 @@ import { createLogger } from '../../shared/utils/logger';
|
||||
SafePipe,
|
||||
SlicePipe,
|
||||
TranslateModule,
|
||||
MatProgressSpinnerModule,
|
||||
PortalInlinePlayerComponent,
|
||||
],
|
||||
})
|
||||
@@ -56,6 +55,7 @@ export class VodDetailsRouteComponent implements OnInit, OnDestroy {
|
||||
private route = inject(ActivatedRoute);
|
||||
private readonly xtreamStore = inject(XtreamStore);
|
||||
private readonly downloadsService = inject(DownloadsService);
|
||||
private readonly externalPlayback = inject(ExternalPlaybackService);
|
||||
private readonly playerService = inject(PlayerService);
|
||||
private readonly snackBar = inject(MatSnackBar);
|
||||
private readonly translateService = inject(TranslateService);
|
||||
@@ -71,6 +71,74 @@ export class VodDetailsRouteComponent implements OnInit, OnDestroy {
|
||||
readonly isLoadingDetails = this.xtreamStore.isLoadingDetails;
|
||||
readonly detailsError = this.xtreamStore.detailsError;
|
||||
private lastSaveTime = 0;
|
||||
readonly matchedExternalPlayback = computed(() => {
|
||||
const session = this.externalPlayback.activeSession();
|
||||
const vodId = Number(this.route.snapshot.params.vodId);
|
||||
const playlistId = this.xtreamStore.currentPlaylist()?.id;
|
||||
|
||||
if (
|
||||
!session?.contentInfo ||
|
||||
!playlistId ||
|
||||
session.status === 'error' ||
|
||||
session.status === 'closed'
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const contentInfo = session.contentInfo;
|
||||
if (
|
||||
contentInfo.playlistId !== playlistId ||
|
||||
contentInfo.contentType !== 'vod' ||
|
||||
contentInfo.contentXtreamId !== vodId
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return session;
|
||||
});
|
||||
readonly externalPrimaryLabel = computed(() => {
|
||||
const session = this.matchedExternalPlayback();
|
||||
if (!session) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const player = session.player.toUpperCase();
|
||||
switch (session.status) {
|
||||
case 'launching':
|
||||
return `Opening in ${player}...`;
|
||||
case 'opened':
|
||||
case 'playing':
|
||||
return `Stop ${player}`;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
});
|
||||
readonly externalPrimaryIcon = computed(() => {
|
||||
const session = this.matchedExternalPlayback();
|
||||
switch (session?.status) {
|
||||
case 'launching':
|
||||
return 'hourglass_top';
|
||||
case 'opened':
|
||||
case 'playing':
|
||||
return 'stop_circle';
|
||||
default:
|
||||
return 'play_arrow';
|
||||
}
|
||||
});
|
||||
readonly isExternalLaunchPending = computed(
|
||||
() => this.matchedExternalPlayback()?.status === 'launching'
|
||||
);
|
||||
readonly isExternalStopAction = computed(() => {
|
||||
const status = this.matchedExternalPlayback()?.status;
|
||||
return status === 'opened' || status === 'playing';
|
||||
});
|
||||
readonly externalPrimaryButtonState = computed(() => {
|
||||
if (this.isExternalLaunchPending()) {
|
||||
return 'launching';
|
||||
}
|
||||
|
||||
return this.isExternalStopAction() ? 'stop' : 'idle';
|
||||
});
|
||||
|
||||
readonly hasPlaybackPosition = computed(() => {
|
||||
const vodId = this.route.snapshot.params.vodId;
|
||||
@@ -182,6 +250,24 @@ export class VodDetailsRouteComponent implements OnInit, OnDestroy {
|
||||
this.startPlayback(playback);
|
||||
}
|
||||
|
||||
onPrimaryAction(vodItem: XtreamVodDetails): void {
|
||||
if (this.isExternalStopAction()) {
|
||||
void this.stopExternalPlayback();
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.hasPlaybackPosition()) {
|
||||
this.resumeVod(vodItem);
|
||||
return;
|
||||
}
|
||||
|
||||
this.playVod(vodItem);
|
||||
}
|
||||
|
||||
async stopExternalPlayback(): Promise<void> {
|
||||
await this.externalPlayback.closeSession(this.matchedExternalPlayback());
|
||||
}
|
||||
|
||||
formatPosition(): string {
|
||||
const vodId = Number(this.route.snapshot.params.vodId);
|
||||
const position = this.xtreamStore.playbackPositions().get(`vod_${vodId}`);
|
||||
@@ -260,7 +346,7 @@ export class VodDetailsRouteComponent implements OnInit, OnDestroy {
|
||||
}
|
||||
|
||||
this.closeInlinePlayer();
|
||||
this.playerService.openResolvedPlayback(playback, true);
|
||||
void this.playerService.openResolvedPlayback(playback, true);
|
||||
}
|
||||
|
||||
async downloadVod(vodItem: XtreamVodDetails) {
|
||||
|
||||
@@ -55,18 +55,48 @@
|
||||
<ng-container hero-actions>
|
||||
<!-- Resume/Play buttons -->
|
||||
@if (hasPlaybackPosition()) {
|
||||
<button class="play-btn play-btn--resume" (click)="onResume()">
|
||||
<mat-icon>play_arrow</mat-icon>
|
||||
{{ 'XTREAM.RESUME' | translate }} {{ formattedPosition() }}
|
||||
</button>
|
||||
<button class="play-btn play-btn--restart" (click)="onPlay()">
|
||||
<mat-icon>replay</mat-icon>
|
||||
{{ 'XTREAM.RESTART' | translate }}
|
||||
<button
|
||||
class="play-btn play-btn--resume"
|
||||
(click)="onPrimaryAction()"
|
||||
[disabled]="isExternalLaunchPending()"
|
||||
[class.play-btn--state-launching]="externalPrimaryButtonState() === 'launching'"
|
||||
[class.play-btn--state-stop]="externalPrimaryButtonState() === 'stop'"
|
||||
>
|
||||
<span class="play-btn__icon-shell" aria-hidden="true">
|
||||
<mat-icon class="play-btn__icon">{{ externalPrimaryIcon() }}</mat-icon>
|
||||
</span>
|
||||
<span class="play-btn__label">
|
||||
@if (externalPrimaryLabel(); as label) {
|
||||
{{ label }}
|
||||
} @else {
|
||||
{{ 'XTREAM.RESUME' | translate }} {{ formattedPosition() }}
|
||||
}
|
||||
</span>
|
||||
</button>
|
||||
@if (!isExternalStopAction()) {
|
||||
<button class="play-btn play-btn--restart" (click)="onPlay()">
|
||||
<mat-icon>replay</mat-icon>
|
||||
{{ 'XTREAM.RESTART' | translate }}
|
||||
</button>
|
||||
}
|
||||
} @else {
|
||||
<button class="play-btn" (click)="onPlay()">
|
||||
<mat-icon>play_arrow</mat-icon>
|
||||
{{ 'XTREAM.PLAY' | translate }}
|
||||
<button
|
||||
class="play-btn"
|
||||
(click)="onPrimaryAction()"
|
||||
[disabled]="isExternalLaunchPending()"
|
||||
[class.play-btn--state-launching]="externalPrimaryButtonState() === 'launching'"
|
||||
[class.play-btn--state-stop]="externalPrimaryButtonState() === 'stop'"
|
||||
>
|
||||
<span class="play-btn__icon-shell" aria-hidden="true">
|
||||
<mat-icon class="play-btn__icon">{{ externalPrimaryIcon() }}</mat-icon>
|
||||
</span>
|
||||
<span class="play-btn__label">
|
||||
@if (externalPrimaryLabel(); as label) {
|
||||
{{ label }}
|
||||
} @else {
|
||||
{{ 'XTREAM.PLAY' | translate }}
|
||||
}
|
||||
</span>
|
||||
</button>
|
||||
}
|
||||
|
||||
|
||||
@@ -7,16 +7,17 @@ import {
|
||||
OnDestroy,
|
||||
} from '@angular/core';
|
||||
import { MatIcon } from '@angular/material/icon';
|
||||
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
|
||||
import { TranslatePipe } from '@ngx-translate/core';
|
||||
import { ContentHeroComponent } from 'components';
|
||||
import {
|
||||
ExternalPlayerSession,
|
||||
ResolvedPortalPlayback,
|
||||
VodDetailsItem,
|
||||
normalizeVodDetails,
|
||||
getVodNumericId,
|
||||
} from 'shared-interfaces';
|
||||
import { DownloadsService } from '../../services/downloads.service';
|
||||
import { ExternalPlaybackService } from '../../services/external-playback.service';
|
||||
import { SafePipe } from '@iptvnator/pipes';
|
||||
import { StalkerStore } from '../../stalker/stalker.store';
|
||||
import { PortalInlinePlayerComponent } from '../../shared/components/portal-inline-player/portal-inline-player.component';
|
||||
@@ -47,7 +48,6 @@ import { PortalInlinePlayerComponent } from '../../shared/components/portal-inli
|
||||
imports: [
|
||||
ContentHeroComponent,
|
||||
MatIcon,
|
||||
MatProgressSpinnerModule,
|
||||
PortalInlinePlayerComponent,
|
||||
SafePipe,
|
||||
TranslatePipe,
|
||||
@@ -68,6 +68,9 @@ export class VodDetailsComponent implements OnDestroy {
|
||||
/** Inline playback payload for embedded players (managed by parent) */
|
||||
readonly inlinePlayback = input<ResolvedPortalPlayback | null>(null);
|
||||
|
||||
/** Active external playback session for launch state */
|
||||
readonly externalPlayback = input<ExternalPlayerSession | null>(null);
|
||||
|
||||
// ============ Outputs ============
|
||||
|
||||
/** Emitted when play button is clicked */
|
||||
@@ -100,6 +103,7 @@ export class VodDetailsComponent implements OnDestroy {
|
||||
// ============ Services ============
|
||||
|
||||
private readonly downloadsService = inject(DownloadsService);
|
||||
private readonly externalPlaybackService = inject(ExternalPlaybackService);
|
||||
private readonly stalkerStore = inject(StalkerStore);
|
||||
|
||||
// ============ Computed State ============
|
||||
@@ -154,6 +158,77 @@ export class VodDetailsComponent implements OnDestroy {
|
||||
return this.downloadsService.isDownloading(vodId, item.playlistId, 'vod');
|
||||
});
|
||||
|
||||
readonly matchedExternalPlayback = computed(() => {
|
||||
const session = this.externalPlayback();
|
||||
const item = this.item();
|
||||
if (
|
||||
!session?.contentInfo ||
|
||||
session.status === 'closed' ||
|
||||
session.status === 'error'
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const contentInfo = session.contentInfo;
|
||||
if (
|
||||
contentInfo.playlistId !== item.playlistId ||
|
||||
contentInfo.contentType !== 'vod' ||
|
||||
contentInfo.contentXtreamId !== getVodNumericId(item)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return session;
|
||||
});
|
||||
|
||||
readonly externalPrimaryLabel = computed(() => {
|
||||
const session = this.matchedExternalPlayback();
|
||||
if (!session) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const player = session.player.toUpperCase();
|
||||
switch (session.status) {
|
||||
case 'launching':
|
||||
return `Opening in ${player}...`;
|
||||
case 'opened':
|
||||
case 'playing':
|
||||
return `Stop ${player}`;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
});
|
||||
|
||||
readonly externalPrimaryIcon = computed(() => {
|
||||
const session = this.matchedExternalPlayback();
|
||||
switch (session?.status) {
|
||||
case 'launching':
|
||||
return 'hourglass_top';
|
||||
case 'opened':
|
||||
case 'playing':
|
||||
return 'stop_circle';
|
||||
default:
|
||||
return 'play_arrow';
|
||||
}
|
||||
});
|
||||
|
||||
readonly isExternalLaunchPending = computed(
|
||||
() => this.matchedExternalPlayback()?.status === 'launching'
|
||||
);
|
||||
|
||||
readonly isExternalStopAction = computed(() => {
|
||||
const status = this.matchedExternalPlayback()?.status;
|
||||
return status === 'opened' || status === 'playing';
|
||||
});
|
||||
|
||||
readonly externalPrimaryButtonState = computed(() => {
|
||||
if (this.isExternalLaunchPending()) {
|
||||
return 'launching';
|
||||
}
|
||||
|
||||
return this.isExternalStopAction() ? 'stop' : 'idle';
|
||||
});
|
||||
|
||||
// ============ Lifecycle ============
|
||||
|
||||
ngOnDestroy(): void {
|
||||
@@ -170,6 +245,20 @@ export class VodDetailsComponent implements OnDestroy {
|
||||
this.playClicked.emit(this.item());
|
||||
}
|
||||
|
||||
onPrimaryAction(): void {
|
||||
if (this.isExternalStopAction()) {
|
||||
void this.stopExternalPlayback();
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.hasPlaybackPosition()) {
|
||||
this.onResume();
|
||||
return;
|
||||
}
|
||||
|
||||
this.onPlay();
|
||||
}
|
||||
|
||||
/** Handle resume button click */
|
||||
onResume(): void {
|
||||
const pos = this.playbackPosition();
|
||||
@@ -214,6 +303,12 @@ export class VodDetailsComponent implements OnDestroy {
|
||||
this.streamUrlCopied.emit();
|
||||
}
|
||||
|
||||
async stopExternalPlayback(): Promise<void> {
|
||||
await this.externalPlaybackService.closeSession(
|
||||
this.matchedExternalPlayback()
|
||||
);
|
||||
}
|
||||
|
||||
/** Play from local downloaded file */
|
||||
async playFromLocal(): Promise<void> {
|
||||
const item = this.item();
|
||||
|
||||
@@ -250,6 +250,8 @@
|
||||
"REMOVE_ALL_DESCRIPTION": "Dadurch werden alle vorhandenen Wiedergabelisten aus der Anwendung entfernt.",
|
||||
"MPV_PLAYER_PATH": "MPV-Player-Pfad",
|
||||
"SHOW_CAPTIONS_DESCRIPTION": "Untertitel standardmäßig ein- oder ausblenden",
|
||||
"SHOW_EXTERNAL_PLAYBACK_BAR": "Now-Playing-Fußleiste anzeigen",
|
||||
"SHOW_EXTERNAL_PLAYBACK_BAR_DESCRIPTION": "Zeigt die Statusleiste für externe Player unten im Arbeitsbereich auf dem Desktop an.",
|
||||
"IMPORT_DATA": "Import",
|
||||
"ADD_EPG_SOURCE": "EPG-Quelle hinzufügen",
|
||||
"IMPORT_ERROR": "Importfehler. Bitte versuchen Sie es mit einer anderen Datei.",
|
||||
|
||||
@@ -194,6 +194,8 @@
|
||||
"LANGUAGE_DESCRIPTION": "Select language of the application",
|
||||
"THEME_DESCRIPTION": "Select visual theme of the application",
|
||||
"SHOW_CAPTIONS_DESCRIPTION": "Show or hide subtitles by default",
|
||||
"SHOW_EXTERNAL_PLAYBACK_BAR": "Show now playing footer",
|
||||
"SHOW_EXTERNAL_PLAYBACK_BAR_DESCRIPTION": "Keep the external-player status bar visible at the bottom of the workspace on desktop.",
|
||||
"VERSION_DESCRIPTION": "Current version of the application",
|
||||
"EPG_NOTE": "Currently EPG is available only in the electron-based version of IPTVnator.",
|
||||
"EPG_NOTE_URL_TEXT": "Check it here",
|
||||
|
||||
Vendored
+11
-2
@@ -1,4 +1,5 @@
|
||||
import 'jest-extended';
|
||||
import { ExternalPlayerSession } from './libs/shared/interfaces/src/lib/external-player-session.interface';
|
||||
import { Playlist } from './libs/shared/interfaces/src/lib/playlist.interface';
|
||||
|
||||
declare module 'video.js' {
|
||||
@@ -33,23 +34,25 @@ declare global {
|
||||
openInMpv: (
|
||||
url: string,
|
||||
title: string,
|
||||
thumbnail: string,
|
||||
userAgent: string,
|
||||
referer?: string,
|
||||
origin?: string,
|
||||
contentInfo?: any,
|
||||
startTime?: number,
|
||||
headers?: Record<string, string>
|
||||
) => void;
|
||||
) => Promise<ExternalPlayerSession>;
|
||||
openInVlc: (
|
||||
url: string,
|
||||
title: string,
|
||||
thumbnail: string,
|
||||
userAgent: string,
|
||||
referer?: string,
|
||||
origin?: string,
|
||||
contentInfo?: any,
|
||||
startTime?: number,
|
||||
headers?: Record<string, string>
|
||||
) => void;
|
||||
) => Promise<ExternalPlayerSession>;
|
||||
autoUpdatePlaylists: (playlists: Playlist[]) => Promise<Playlist[]>;
|
||||
fetchEpg: (
|
||||
urls: string[]
|
||||
@@ -80,6 +83,12 @@ declare global {
|
||||
}>;
|
||||
setMpvPlayerPath: (mpvPlayerPath: string) => Promise<void>;
|
||||
setVlcPlayerPath: (vlcPlayerPath: string) => Promise<void>;
|
||||
onExternalPlayerSessionUpdate?: (
|
||||
callback: (data: ExternalPlayerSession) => void
|
||||
) => () => void;
|
||||
closeExternalPlayerSession: (
|
||||
sessionId: string
|
||||
) => Promise<ExternalPlayerSession | null>;
|
||||
stalkerRequest: (payload: {
|
||||
url: string;
|
||||
macAddress: string;
|
||||
|
||||
@@ -31,6 +31,9 @@ export const SET_MPV_PLAYER_PATH = 'SET_MPV_PLAYER_PATH';
|
||||
export const SET_MPV_REUSE_INSTANCE = 'SET_MPV_REUSE_INSTANCE';
|
||||
export const OPEN_VLC_PLAYER = 'OPEN_VLC_PLAYER';
|
||||
export const SET_VLC_PLAYER_PATH = 'SET_VLC_PLAYER_PATH';
|
||||
export const CLOSE_EXTERNAL_PLAYER_SESSION = 'CLOSE_EXTERNAL_PLAYER_SESSION';
|
||||
export const EXTERNAL_PLAYER_SESSION_UPDATE =
|
||||
'EXTERNAL_PLAYER_SESSION_UPDATE';
|
||||
|
||||
// Xtream
|
||||
export const XTREAM_REQUEST = 'XTREAM_REQUEST';
|
||||
@@ -39,6 +42,7 @@ export const XTREAM_RESPONSE = 'XTREAM_RESPONSE';
|
||||
// Stalker
|
||||
export const STALKER_REQUEST = 'STALKER_REQUEST';
|
||||
export const STALKER_RESPONSE = 'STALKER_RESPONSE';
|
||||
export const PORTAL_DEBUG_EVENT = 'PORTAL_DEBUG_EVENT';
|
||||
|
||||
// Settings
|
||||
export const SETTINGS_UPDATE = 'SETTINGS_UPDATE';
|
||||
|
||||
@@ -11,4 +11,5 @@ export interface PlaybackPositionData {
|
||||
durationSeconds?: number;
|
||||
playlistId?: string;
|
||||
updatedAt?: string;
|
||||
sessionId?: string;
|
||||
}
|
||||
@@ -22,6 +22,8 @@ export interface Settings {
|
||||
streamFormat: StreamFormat;
|
||||
language: Language;
|
||||
showCaptions: boolean;
|
||||
/** Show the desktop footer bar for external playback status */
|
||||
showExternalPlaybackBar?: boolean;
|
||||
theme: Theme;
|
||||
mpvPlayerPath: string;
|
||||
mpvReuseInstance: boolean;
|
||||
|
||||
Reference in new issue
Block a user