From 4732af8a38dc94ef629eef6da0add96f445fd12c Mon Sep 17 00:00:00 2001 From: 4gray Date: Sat, 7 Mar 2026 19:06:40 +0100 Subject: [PATCH] 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. --- .../external-player-session-registry.spec.ts | 63 +++++ .../external-player-session-registry.ts | 137 +++++++++++ .../src/app/events/player.events.ts | 151 +++++++++--- .../external-playback.service.spec.ts | 115 +++++++++ .../app/services/external-playback.service.ts | 92 ++++++++ .../src/app/services/player.service.spec.ts | 35 ++- apps/web/src/app/services/player.service.ts | 33 +-- .../app/services/settings-store.service.ts | 2 + .../src/app/settings/settings.component.html | 24 ++ .../app/settings/settings.component.spec.ts | 1 + .../src/app/settings/settings.component.ts | 1 + .../external-playback-dock.component.html | 66 ++++++ .../external-playback-dock.component.scss | 169 ++++++++++++++ .../external-playback-dock.component.spec.ts | 52 +++++ .../external-playback-dock.component.ts | 52 +++++ .../stalker-inline-detail.component.html | 1 + .../stalker-inline-detail.component.ts | 10 +- .../recently-viewed.component.ts | 2 +- .../stalker-favorites.component.ts | 2 +- .../stalker-live-stream-layout.component.ts | 2 +- .../stalker-series-view.component.html | 2 + .../stalker-series-view.component.ts | 41 +++- .../features/with-stalker-player.feature.ts | 2 +- .../workspace/workspace-shell.component.html | 23 ++ .../workspace/workspace-shell.component.scss | 43 +++- .../workspace-shell.component.spec.ts | 17 ++ .../workspace/workspace-shell.component.ts | 18 ++ .../category-content-view.component.html | 1 + .../category-content-view.component.ts | 4 +- .../src/app/xtream-electron/detail-view.scss | 220 +++++++++++++++++- .../season-container.component.html | 29 ++- .../season-container.component.scss | 23 ++ .../season-container.component.ts | 10 + .../serial-details.component.html | 2 + .../serial-details.component.ts | 45 +++- .../stores/features/with-player.feature.ts | 2 +- .../vod-details-route.component.html | 53 +++-- .../vod-details-route.component.ts | 92 +++++++- .../vod-details/vod-details.component.html | 50 +++- .../vod-details/vod-details.component.ts | 99 +++++++- apps/web/src/assets/i18n/de.json | 2 + apps/web/src/assets/i18n/en.json | 2 + global.d.ts | 13 +- .../shared/interfaces/src/lib/ipc-commands.ts | 4 + .../src/lib/playback-position.interface.ts | 1 + .../interfaces/src/lib/settings.interface.ts | 2 + 46 files changed, 1705 insertions(+), 105 deletions(-) create mode 100644 apps/electron-backend/src/app/events/external-player-session-registry.spec.ts create mode 100644 apps/electron-backend/src/app/events/external-player-session-registry.ts create mode 100644 apps/web/src/app/services/external-playback.service.spec.ts create mode 100644 apps/web/src/app/services/external-playback.service.ts create mode 100644 apps/web/src/app/shared/components/external-playback-dock/external-playback-dock.component.html create mode 100644 apps/web/src/app/shared/components/external-playback-dock/external-playback-dock.component.scss create mode 100644 apps/web/src/app/shared/components/external-playback-dock/external-playback-dock.component.spec.ts create mode 100644 apps/web/src/app/shared/components/external-playback-dock/external-playback-dock.component.ts diff --git a/apps/electron-backend/src/app/events/external-player-session-registry.spec.ts b/apps/electron-backend/src/app/events/external-player-session-registry.spec.ts new file mode 100644 index 000000000..00842f692 --- /dev/null +++ b/apps/electron-backend/src/app/events/external-player-session-registry.spec.ts @@ -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); + }); +}); diff --git a/apps/electron-backend/src/app/events/external-player-session-registry.ts b/apps/electron-backend/src/app/events/external-player-session-registry.ts new file mode 100644 index 000000000..cf4828e93 --- /dev/null +++ b/apps/electron-backend/src/app/events/external-player-session-registry.ts @@ -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; +} + +export class ExternalPlayerSessionRegistry { + private readonly sessions = new Map(); + 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 + ): 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 { + const runtime = this.sessions.get(id); + if (!runtime) { + return null; + } + + try { + await runtime.close?.(); + } finally { + return this.markClosed(id); + } + } +} diff --git a/apps/electron-backend/src/app/events/player.events.ts b/apps/electron-backend/src/app/events/player.events.ts index b085f00e5..1daf2f9d0 100644 --- a/apps/electron-backend/src/app/events/player.events.ts +++ b/apps/electron-backend/src/app/events/player.events.ts @@ -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 { }); } -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 ) => { + 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 ) => { + 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) { diff --git a/apps/web/src/app/services/external-playback.service.spec.ts b/apps/web/src/app/services/external-playback.service.spec.ts new file mode 100644 index 000000000..4dd3db10a --- /dev/null +++ b/apps/web/src/app/services/external-playback.service.spec.ts @@ -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 => ({ + 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'); + }); +}); diff --git a/apps/web/src/app/services/external-playback.service.ts b/apps/web/src/app/services/external-playback.service.ts new file mode 100644 index 000000000..6eeca0f8b --- /dev/null +++ b/apps/web/src/app/services/external-playback.service.ts @@ -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(null); + private readonly dismissedSessionId = signal(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 { + await this.closeSession(this.activeSession()); + } + + async closeSession( + session: ExternalPlayerSession | null | undefined + ): Promise { + 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 + ); + } +} diff --git a/apps/web/src/app/services/player.service.spec.ts b/apps/web/src/app/services/player.service.spec.ts index 44b876575..843d648ff 100644 --- a/apps/web/src/app/services/player.service.spec.ts +++ b/apps/web/src/app/services/player.service.spec.ts @@ -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); + }); }); diff --git a/apps/web/src/app/services/player.service.ts b/apps/web/src/app/services/player.service.ts index 12eb61aba..4fe99c16e 100644 --- a/apps/web/src/app/services/player.service.ts +++ b/apps/web/src/app/services/player.service.ts @@ -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 - ) { - this.openResolvedPlayback( + ): Promise { + return this.openResolvedPlayback( { streamUrl, title, @@ -62,10 +63,10 @@ export class PlayerService { ); } - openResolvedPlayback( + async openResolvedPlayback( playback: ResolvedPortalPlayback, hideExternalInfoDialog = true - ) { + ): Promise { 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, - { - data: { streamUrl, title, contentInfo, startTime }, - width: '80%', - maxWidth: '1200px', - maxHeight: '90vh', - } - ); } + + this.dialog.open( + PlayerDialogComponent, + { + data: { streamUrl, title, contentInfo, startTime }, + width: '80%', + maxWidth: '1200px', + maxHeight: '90vh', + } + ); } } diff --git a/apps/web/src/app/services/settings-store.service.ts b/apps/web/src/app/services/settings-store.service.ts index 12719bed9..3211d39b0 100644 --- a/apps/web/src/app/services/settings-store.service.ts +++ b/apps/web/src/app/services/settings-store.service.ts @@ -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(), diff --git a/apps/web/src/app/settings/settings.component.html b/apps/web/src/app/settings/settings.component.html index 82b7c6f5d..e6d75b8cc 100644 --- a/apps/web/src/app/settings/settings.component.html +++ b/apps/web/src/app/settings/settings.component.html @@ -205,6 +205,30 @@ + @if (isDesktop) { +
+
+

+ {{ + 'SETTINGS.SHOW_EXTERNAL_PLAYBACK_BAR' + | translate + }} +

+

+ {{ + 'SETTINGS.SHOW_EXTERNAL_PLAYBACK_BAR_DESCRIPTION' + | translate + }} +

+
+
+ +
+
+ } + @if (settingsForm.value.player === 'mpv') {
diff --git a/apps/web/src/app/settings/settings.component.spec.ts b/apps/web/src/app/settings/settings.component.spec.ts index 40b2c1b89..f5695abfe 100644 --- a/apps/web/src/app/settings/settings.component.spec.ts +++ b/apps/web/src/app/settings/settings.component.spec.ts @@ -56,6 +56,7 @@ const DEFAULT_SETTINGS = { streamFormat: StreamFormat.M3u8StreamFormat, language: Language.ENGLISH, showCaptions: false, + showExternalPlaybackBar: true, theme: Theme.LightTheme, mpvPlayerPath: '', vlcPlayerPath: '', diff --git a/apps/web/src/app/settings/settings.component.ts b/apps/web/src/app/settings/settings.component.ts index 7f9deef50..c6155deee 100644 --- a/apps/web/src/app/settings/settings.component.ts +++ b/apps/web/src/app/settings/settings.component.ts @@ -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, diff --git a/apps/web/src/app/shared/components/external-playback-dock/external-playback-dock.component.html b/apps/web/src/app/shared/components/external-playback-dock/external-playback-dock.component.html new file mode 100644 index 000000000..fc47e2859 --- /dev/null +++ b/apps/web/src/app/shared/components/external-playback-dock/external-playback-dock.component.html @@ -0,0 +1,66 @@ +
+
+ @if (session().thumbnail) { + + } @else { +
+ play_circle +
+ } +
+ +
+
+ {{ playerLabel() }} + @if (!compact()) { + + External player + + } +
+
{{ session().title }}
+
+ @if (showSpinner()) { + + } @else { + {{ + session().status === 'error' ? 'error' : 'play_arrow' + }} + } + {{ statusLabel() }} +
+
+ +
+ @if (showCloseButton()) { + + } + +
+
diff --git a/apps/web/src/app/shared/components/external-playback-dock/external-playback-dock.component.scss b/apps/web/src/app/shared/components/external-playback-dock/external-playback-dock.component.scss new file mode 100644 index 000000000..38fdcc55d --- /dev/null +++ b/apps/web/src/app/shared/components/external-playback-dock/external-playback-dock.component.scss @@ -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; + } + } +} diff --git a/apps/web/src/app/shared/components/external-playback-dock/external-playback-dock.component.spec.ts b/apps/web/src/app/shared/components/external-playback-dock/external-playback-dock.component.spec.ts new file mode 100644 index 000000000..68dc925be --- /dev/null +++ b/apps/web/src/app/shared/components/external-playback-dock/external-playback-dock.component.spec.ts @@ -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; + 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(); + }); +}); diff --git a/apps/web/src/app/shared/components/external-playback-dock/external-playback-dock.component.ts b/apps/web/src/app/shared/components/external-playback-dock/external-playback-dock.component.ts new file mode 100644 index 000000000..72928da8a --- /dev/null +++ b/apps/web/src/app/shared/components/external-playback-dock/external-playback-dock.component.ts @@ -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(); + readonly compact = input(false); + + readonly closeClicked = output(); + readonly dismissClicked = output(); + + 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' + ); +} diff --git a/apps/web/src/app/shared/components/stalker-inline-detail/stalker-inline-detail.component.html b/apps/web/src/app/shared/components/stalker-inline-detail/stalker-inline-detail.component.html index 8b6d5cdae..c69bb55aa 100644 --- a/apps/web/src/app/shared/components/stalker-inline-detail/stalker-inline-detail.component.html +++ b/apps/web/src/app/shared/components/stalker-inline-detail/stalker-inline-detail.component.html @@ -13,6 +13,7 @@ (null); readonly seriesItem = input(null); readonly isSeries = input(false); diff --git a/apps/web/src/app/stalker/recently-viewed/recently-viewed.component.ts b/apps/web/src/app/stalker/recently-viewed/recently-viewed.component.ts index 0618c8403..99914cea6 100644 --- a/apps/web/src/app/stalker/recently-viewed/recently-viewed.component.ts +++ b/apps/web/src/app/stalker/recently-viewed/recently-viewed.component.ts @@ -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); diff --git a/apps/web/src/app/stalker/stalker-favorites/stalker-favorites.component.ts b/apps/web/src/app/stalker/stalker-favorites/stalker-favorites.component.ts index 744cd176f..c8e5fa734 100644 --- a/apps/web/src/app/stalker/stalker-favorites/stalker-favorites.component.ts +++ b/apps/web/src/app/stalker/stalker-favorites/stalker-favorites.component.ts @@ -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); diff --git a/apps/web/src/app/stalker/stalker-live-stream-layout/stalker-live-stream-layout.component.ts b/apps/web/src/app/stalker/stalker-live-stream-layout/stalker-live-stream-layout.component.ts index f13c6350b..3d3bc684f 100644 --- a/apps/web/src/app/stalker/stalker-live-stream-layout/stalker-live-stream-layout.component.ts +++ b/apps/web/src/app/stalker/stalker-live-stream-layout/stalker-live-stream-layout.component.ts @@ -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); diff --git a/apps/web/src/app/stalker/stalker-series-view/stalker-series-view.component.html b/apps/web/src/app/stalker/stalker-series-view/stalker-series-view.component.html index f7983904c..27a868b08 100644 --- a/apps/web/src/app/stalker/stalker-series-view/stalker-series-view.component.html +++ b/apps/web/src/app/stalker/stalker-series-view/stalker-series-view.component.html @@ -72,6 +72,8 @@ [seasons]="mappedSeasons()" [seriesId]="toSeriesId(serial.id)" [seriesTitle]="serial.info?.name" + [openingEpisodeId]="openingEpisodeId()" + [activeEpisodeId]="activeEpisodeId()" [isLoading]=" isVodSeriesSeasonsLoading() || (seasonContainer?.selectedSeason && diff --git a/apps/web/src/app/stalker/stalker-series-view/stalker-series-view.component.ts b/apps/web/src/app/stalker/stalker-series-view/stalker-series-view.component.ts index 5cbb16407..b2ade7a73 100644 --- a/apps/web/src/app/stalker/stalker-series-view/stalker-series-view.component.ts +++ b/apps/web/src/app/stalker/stalker-series-view/stalker-series-view.component.ts @@ -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(); private readonly logger = createLogger('StalkerSeriesView'); readonly inlinePlayback = signal(null); private lastSaveTime = 0; + readonly openingEpisodeId = signal(null); + readonly activeEpisodeId = signal(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 = diff --git a/apps/web/src/app/stalker/stores/features/with-stalker-player.feature.ts b/apps/web/src/app/stalker/stores/features/with-stalker-player.feature.ts index 145f65be1..1fc1d53eb 100644 --- a/apps/web/src/app/stalker/stores/features/with-stalker-player.feature.ts +++ b/apps/web/src/app/stalker/stores/features/with-stalker-player.feature.ts @@ -558,7 +558,7 @@ export function withStalkerPlayer() { startTime ); - playerService.openResolvedPlayback( + void playerService.openResolvedPlayback( playback, true ); diff --git a/apps/web/src/app/workspace/workspace-shell.component.html b/apps/web/src/app/workspace/workspace-shell.component.html index 1923744e9..aaaf03ba9 100644 --- a/apps/web/src/app/workspace/workspace-shell.component.html +++ b/apps/web/src/app/workspace/workspace-shell.component.html @@ -81,6 +81,17 @@ + @if (externalPlaybackSession(); as session) { +
+ +
+ } +
+ + @if (showExternalPlaybackBar()) { + @if (externalPlaybackSession(); as session) { +
+ +
+ } + }
diff --git a/apps/web/src/app/workspace/workspace-shell.component.scss b/apps/web/src/app/workspace/workspace-shell.component.scss index 13fb58f77..1b29c39ba 100644 --- a/apps/web/src/app/workspace/workspace-shell.component.scss +++ b/apps/web/src/app/workspace/workspace-shell.component.scss @@ -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; + } } diff --git a/apps/web/src/app/workspace/workspace-shell.component.spec.ts b/apps/web/src/app/workspace/workspace-shell.component.spec.ts index 7a1a16e59..d4100e4cf 100644 --- a/apps/web/src/app/workspace/workspace-shell.component.spec.ts +++ b/apps/web/src/app/workspace/workspace-shell.component.spec.ts @@ -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: { diff --git a/apps/web/src/app/workspace/workspace-shell.component.ts b/apps/web/src/app/workspace/workspace-shell.component.ts index 2b95e9367..46d5aa5b0 100644 --- a/apps/web/src/app/workspace/workspace-shell.component.ts +++ b/apps/web/src/app/workspace/workspace-shell.component.ts @@ -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(() => { 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)...'; diff --git a/apps/web/src/app/xtream-electron/category-content-view/category-content-view.component.html b/apps/web/src/app/xtream-electron/category-content-view/category-content-view.component.html index 991d92f42..259aaf6c4 100644 --- a/apps/web/src/app/xtream-electron/category-content-view/category-content-view.component.html +++ b/apps/web/src/app/xtream-electron/category-content-view/category-content-view.component.html @@ -14,6 +14,7 @@ [isFavorite]="isSelectedVodFavorite()" [playbackPosition]="selectedVodPlaybackPosition()" [inlinePlayback]="inlinePlayback()" + [externalPlayback]="externalPlayback.activeSession()" (playClicked)="onVodPlay($event)" (resumeClicked)="onVodResume($event)" (favoriteToggled)="onVodFavoriteToggled($event)" diff --git a/apps/web/src/app/xtream-electron/category-content-view/category-content-view.component.ts b/apps/web/src/app/xtream-electron/category-content-view/category-content-view.component.ts index 4507fcc98..e23e255bd 100644 --- a/apps/web/src/app/xtream-electron/category-content-view/category-content-view.component.ts +++ b/apps/web/src/app/xtream-electron/category-content-view/category-content-view.component.ts @@ -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 = diff --git a/apps/web/src/app/xtream-electron/detail-view.scss b/apps/web/src/app/xtream-electron/detail-view.scss index 168be7462..a3fda850e 100644 --- a/apps/web/src/app/xtream-electron/detail-view.scss +++ b/apps/web/src/app/xtream-electron/detail-view.scss @@ -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; -} \ No newline at end of file +} diff --git a/apps/web/src/app/xtream-electron/season-container/season-container.component.html b/apps/web/src/app/xtream-electron/season-container/season-container.component.html index 6a0bed5a6..87d73bda6 100644 --- a/apps/web/src/app/xtream-electron/season-container/season-container.component.html +++ b/apps/web/src/app/xtream-electron/season-container/season-container.component.html @@ -77,6 +77,12 @@ [class.episode-card--in-progress]=" isEpisodeInProgress(episode) " + [class.episode-card--launching]=" + isEpisodeLaunching(episode) + " + [class.episode-card--active-external]=" + isEpisodeActiveExternal(episode) + " >
@if (episodeInfo?.movie_image) { @@ -157,7 +163,15 @@
- play_arrow + @if (isEpisodeLaunching(episode)) { + + } @else { + {{ + isEpisodeActiveExternal(episode) + ? 'equalizer' + : 'play_arrow' + }} + }
@@ -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)" >
{{ episode.episode_num }} @@ -237,8 +253,17 @@ class="action-btn play-btn" (click)="selectEpisode(episode); $event.stopPropagation()" [matTooltip]="'Play'" + [disabled]="isEpisodeLaunching(episode)" > - play_arrow + @if (isEpisodeLaunching(episode)) { + + } @else { + {{ + isEpisodeActiveExternal(episode) + ? 'equalizer' + : 'play_arrow' + }} + } @if (isElectron()) { @if (isEpisodeDownloaded(episode)) { diff --git a/apps/web/src/app/xtream-electron/season-container/season-container.component.scss b/apps/web/src/app/xtream-electron/season-container/season-container.component.scss index 1348c34ba..1190ee00e 100644 --- a/apps/web/src/app/xtream-electron/season-container/season-container.component.scss +++ b/apps/web/src/app/xtream-electron/season-container/season-container.component.scss @@ -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 { diff --git a/apps/web/src/app/xtream-electron/season-container/season-container.component.ts b/apps/web/src/app/xtream-electron/season-container/season-container.component.ts index 9e3b4c686..16ccaf673 100644 --- a/apps/web/src/app/xtream-electron/season-container/season-container.component.ts +++ b/apps/web/src/app/xtream-electron/season-container/season-container.component.ts @@ -43,6 +43,8 @@ export class SeasonContainerComponent implements OnInit { readonly seriesId = input.required(); readonly seriesTitle = input(''); readonly isLoading = input(false); + readonly openingEpisodeId = input(null); + readonly activeEpisodeId = input(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'); diff --git a/apps/web/src/app/xtream-electron/serial-details/serial-details.component.html b/apps/web/src/app/xtream-electron/serial-details/serial-details.component.html index 1112ed2e0..661dd9375 100644 --- a/apps/web/src/app/xtream-electron/serial-details/serial-details.component.html +++ b/apps/web/src/app/xtream-electron/serial-details/serial-details.component.html @@ -80,6 +80,8 @@ [seasons]="item.episodes" [seriesId]="item.series_id" [seriesTitle]="item.info?.name" + [openingEpisodeId]="openingEpisodeId()" + [activeEpisodeId]="activeEpisodeId()" (episodeClicked)="playEpisode($event)" /> diff --git a/apps/web/src/app/xtream-electron/serial-details/serial-details.component.ts b/apps/web/src/app/xtream-electron/serial-details/serial-details.component.ts index 024b1eed0..0cfda0846 100644 --- a/apps/web/src/app/xtream-electron/serial-details/serial-details.component.ts +++ b/apps/web/src/app/xtream-electron/serial-details/serial-details.component.ts @@ -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(null); private lastSaveTime = 0; + readonly openingEpisodeId = signal(null); + readonly activeEpisodeId = signal(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); } } diff --git a/apps/web/src/app/xtream-electron/stores/features/with-player.feature.ts b/apps/web/src/app/xtream-electron/stores/features/with-player.feature.ts index 54ee6f31f..b8cdd587e 100644 --- a/apps/web/src/app/xtream-electron/stores/features/with-player.feature.ts +++ b/apps/web/src/app/xtream-electron/stores/features/with-player.feature.ts @@ -145,7 +145,7 @@ export function withPlayer() { const contentType = storeAny.selectedContentType?.() || 'vod'; - playerService.openPlayer( + void playerService.openPlayer( streamUrl, title, thumbnail, diff --git a/apps/web/src/app/xtream-electron/vod-details/vod-details-route.component.html b/apps/web/src/app/xtream-electron/vod-details/vod-details-route.component.html index 296630586..313c8b743 100644 --- a/apps/web/src/app/xtream-electron/vod-details/vod-details-route.component.html +++ b/apps/web/src/app/xtream-electron/vod-details/vod-details-route.component.html @@ -64,22 +64,49 @@ @if (hasPlaybackPosition()) { - + @if (!isExternalStopAction()) { + + } } @else { - } @if (!isFavorite()) { diff --git a/apps/web/src/app/xtream-electron/vod-details/vod-details-route.component.ts b/apps/web/src/app/xtream-electron/vod-details/vod-details-route.component.ts index 5421b71a4..39c2f32ab 100644 --- a/apps/web/src/app/xtream-electron/vod-details/vod-details-route.component.ts +++ b/apps/web/src/app/xtream-electron/vod-details/vod-details-route.component.ts @@ -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 { + 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) { diff --git a/apps/web/src/app/xtream-electron/vod-details/vod-details.component.html b/apps/web/src/app/xtream-electron/vod-details/vod-details.component.html index e2923dd40..078ec83c7 100644 --- a/apps/web/src/app/xtream-electron/vod-details/vod-details.component.html +++ b/apps/web/src/app/xtream-electron/vod-details/vod-details.component.html @@ -55,18 +55,48 @@ @if (hasPlaybackPosition()) { - - + @if (!isExternalStopAction()) { + + } } @else { - } diff --git a/apps/web/src/app/xtream-electron/vod-details/vod-details.component.ts b/apps/web/src/app/xtream-electron/vod-details/vod-details.component.ts index a00423aef..e3901ffd7 100644 --- a/apps/web/src/app/xtream-electron/vod-details/vod-details.component.ts +++ b/apps/web/src/app/xtream-electron/vod-details/vod-details.component.ts @@ -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(null); + /** Active external playback session for launch state */ + readonly externalPlayback = input(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 { + await this.externalPlaybackService.closeSession( + this.matchedExternalPlayback() + ); + } + /** Play from local downloaded file */ async playFromLocal(): Promise { const item = this.item(); diff --git a/apps/web/src/assets/i18n/de.json b/apps/web/src/assets/i18n/de.json index 83f97cf00..7fb44a5a8 100644 --- a/apps/web/src/assets/i18n/de.json +++ b/apps/web/src/assets/i18n/de.json @@ -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.", diff --git a/apps/web/src/assets/i18n/en.json b/apps/web/src/assets/i18n/en.json index a1ff3035d..0435ae86c 100644 --- a/apps/web/src/assets/i18n/en.json +++ b/apps/web/src/assets/i18n/en.json @@ -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", diff --git a/global.d.ts b/global.d.ts index 131b3c621..d6d78d4ed 100644 --- a/global.d.ts +++ b/global.d.ts @@ -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 - ) => void; + ) => Promise; openInVlc: ( url: string, title: string, + thumbnail: string, userAgent: string, referer?: string, origin?: string, contentInfo?: any, startTime?: number, headers?: Record - ) => void; + ) => Promise; autoUpdatePlaylists: (playlists: Playlist[]) => Promise; fetchEpg: ( urls: string[] @@ -80,6 +83,12 @@ declare global { }>; setMpvPlayerPath: (mpvPlayerPath: string) => Promise; setVlcPlayerPath: (vlcPlayerPath: string) => Promise; + onExternalPlayerSessionUpdate?: ( + callback: (data: ExternalPlayerSession) => void + ) => () => void; + closeExternalPlayerSession: ( + sessionId: string + ) => Promise; stalkerRequest: (payload: { url: string; macAddress: string; diff --git a/libs/shared/interfaces/src/lib/ipc-commands.ts b/libs/shared/interfaces/src/lib/ipc-commands.ts index b0e41fdf2..38779a707 100644 --- a/libs/shared/interfaces/src/lib/ipc-commands.ts +++ b/libs/shared/interfaces/src/lib/ipc-commands.ts @@ -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'; diff --git a/libs/shared/interfaces/src/lib/playback-position.interface.ts b/libs/shared/interfaces/src/lib/playback-position.interface.ts index 6c50ca80f..935a2153b 100644 --- a/libs/shared/interfaces/src/lib/playback-position.interface.ts +++ b/libs/shared/interfaces/src/lib/playback-position.interface.ts @@ -11,4 +11,5 @@ export interface PlaybackPositionData { durationSeconds?: number; playlistId?: string; updatedAt?: string; + sessionId?: string; } diff --git a/libs/shared/interfaces/src/lib/settings.interface.ts b/libs/shared/interfaces/src/lib/settings.interface.ts index ff4d99175..6078de6bd 100644 --- a/libs/shared/interfaces/src/lib/settings.interface.ts +++ b/libs/shared/interfaces/src/lib/settings.interface.ts @@ -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;