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:
4gray committed 2026-03-07 19:06:40 +01:00
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);
});
});
+17 -16
View File
@@ -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,
@@ -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>
@@ -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;
}
}
}
@@ -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();
});
});
@@ -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'
);
}
@@ -13,6 +13,7 @@
<app-vod-details
[item]="vodDetailsItem()"
[isFavorite]="isFavorite()"
[externalPlayback]="externalPlayback.activeSession()"
(playClicked)="onPlayClicked($event)"
(favoriteToggled)="onFavoriteToggled($event)"
(backClicked)="onBackClicked()"
@@ -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);
@@ -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)...';
@@ -14,6 +14,7 @@
[isFavorite]="isSelectedVodFavorite()"
[playbackPosition]="selectedVodPlaybackPosition()"
[inlinePlayback]="inlinePlayback()"
[externalPlayback]="externalPlayback.activeSession()"
(playClicked)="onVodPlay($event)"
(resumeClicked)="onVodResume($event)"
(favoriteToggled)="onVodFavoriteToggled($event)"
@@ -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 =
+209 -11
View File
@@ -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();
+2
View File
@@ -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.",
+2
View File
@@ -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
View File
@@ -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;