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,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(),