mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-15 13:46:16 -08:00
feat: add external playback support and UI enhancements
- Integrated external playback functionality in category and detail views. - Updated styles for play buttons to reflect different states (idle, launching, stop). - Enhanced episode cards to indicate launching and active external playback states. - Added new computed properties for managing external playback session states. - Improved user interface for playback buttons with dynamic labels and icons based on playback state. - Introduced new settings option to show external playback status bar on desktop. - Updated translations for new features and UI elements.
This commit is contained in:
1 parent
3a2114c932
commit
4732af8a38
46 files changed
+1705
-105
No files matched your search
@@ -0,0 +1,115 @@
|
||||
import { ExternalPlayerSession } from 'shared-interfaces';
|
||||
import { ExternalPlaybackService } from './external-playback.service';
|
||||
|
||||
describe('ExternalPlaybackService', () => {
|
||||
let listener:
|
||||
| ((session: ExternalPlayerSession) => void)
|
||||
| undefined;
|
||||
let closeExternalPlayerSession: jest.Mock;
|
||||
let service: ExternalPlaybackService;
|
||||
|
||||
const createSession = (
|
||||
overrides: Partial<ExternalPlayerSession> = {}
|
||||
): ExternalPlayerSession => ({
|
||||
id: 'session-1',
|
||||
player: 'mpv',
|
||||
status: 'launching',
|
||||
title: 'Example',
|
||||
streamUrl: 'https://example.com/video.m3u8',
|
||||
contentInfo: {
|
||||
playlistId: 'playlist-1',
|
||||
contentXtreamId: 42,
|
||||
contentType: 'vod',
|
||||
},
|
||||
startedAt: '2026-03-07T10:00:00.000Z',
|
||||
updatedAt: '2026-03-07T10:00:00.000Z',
|
||||
canClose: true,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
listener = undefined;
|
||||
closeExternalPlayerSession = jest.fn().mockResolvedValue(null);
|
||||
|
||||
Object.defineProperty(window, 'electron', {
|
||||
configurable: true,
|
||||
value: {
|
||||
onExternalPlayerSessionUpdate: jest.fn((callback) => {
|
||||
listener = callback;
|
||||
return () => undefined;
|
||||
}),
|
||||
closeExternalPlayerSession,
|
||||
},
|
||||
});
|
||||
|
||||
service = new ExternalPlaybackService();
|
||||
});
|
||||
|
||||
it('tracks the latest launch and hides dismissed sessions until the next launch', () => {
|
||||
const first = createSession();
|
||||
listener?.(first);
|
||||
|
||||
expect(service.activeSession()).toEqual(first);
|
||||
expect(service.visibleSession()).toEqual(first);
|
||||
|
||||
service.dismissActiveSession();
|
||||
expect(service.visibleSession()).toBeNull();
|
||||
|
||||
const next = createSession({
|
||||
id: 'session-2',
|
||||
title: 'Another Example',
|
||||
});
|
||||
listener?.(next);
|
||||
|
||||
expect(service.visibleSession()).toEqual(next);
|
||||
});
|
||||
|
||||
it('matches only active non-error sessions to content info', () => {
|
||||
const playing = createSession({
|
||||
status: 'playing',
|
||||
updatedAt: '2026-03-07T10:00:05.000Z',
|
||||
});
|
||||
listener?.(playing);
|
||||
|
||||
expect(
|
||||
service.findMatchingSession({
|
||||
playlistId: 'playlist-1',
|
||||
contentXtreamId: 42,
|
||||
contentType: 'vod',
|
||||
})
|
||||
).toEqual(playing);
|
||||
|
||||
listener?.(
|
||||
createSession({
|
||||
status: 'error',
|
||||
error: 'Launch failed',
|
||||
updatedAt: '2026-03-07T10:00:06.000Z',
|
||||
})
|
||||
);
|
||||
|
||||
expect(
|
||||
service.findMatchingSession({
|
||||
playlistId: 'playlist-1',
|
||||
contentXtreamId: 42,
|
||||
contentType: 'vod',
|
||||
})
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it('delegates close requests for closable sessions', async () => {
|
||||
const session = createSession({ status: 'opened' });
|
||||
listener?.(session);
|
||||
|
||||
await service.closeActiveSession();
|
||||
|
||||
expect(closeExternalPlayerSession).toHaveBeenCalledWith(session.id);
|
||||
});
|
||||
|
||||
it('can close a specific session directly', async () => {
|
||||
const session = createSession({ id: 'session-2', status: 'playing' });
|
||||
|
||||
await service.closeSession(session);
|
||||
|
||||
expect(closeExternalPlayerSession).toHaveBeenCalledWith('session-2');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,92 @@
|
||||
import { computed, Injectable, signal } from '@angular/core';
|
||||
import { ExternalPlayerSession, PlayerContentInfo } from 'shared-interfaces';
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root',
|
||||
})
|
||||
export class ExternalPlaybackService {
|
||||
readonly activeSession = signal<ExternalPlayerSession | null>(null);
|
||||
private readonly dismissedSessionId = signal<string | null>(null);
|
||||
|
||||
readonly visibleSession = computed(() => {
|
||||
const session = this.activeSession();
|
||||
if (!session || session.status === 'closed') {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (this.dismissedSessionId() === session.id) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return session;
|
||||
});
|
||||
|
||||
constructor() {
|
||||
window.electron?.onExternalPlayerSessionUpdate?.((session) => {
|
||||
this.handleSessionUpdate(session);
|
||||
});
|
||||
}
|
||||
|
||||
dismissActiveSession(): void {
|
||||
const session = this.activeSession();
|
||||
if (!session) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.dismissedSessionId.set(session.id);
|
||||
}
|
||||
|
||||
async closeActiveSession(): Promise<void> {
|
||||
await this.closeSession(this.activeSession());
|
||||
}
|
||||
|
||||
async closeSession(
|
||||
session: ExternalPlayerSession | null | undefined
|
||||
): Promise<void> {
|
||||
if (!session?.canClose || !window.electron?.closeExternalPlayerSession) {
|
||||
return;
|
||||
}
|
||||
|
||||
await window.electron.closeExternalPlayerSession(session.id);
|
||||
}
|
||||
|
||||
findMatchingSession(
|
||||
contentInfo: PlayerContentInfo | null | undefined
|
||||
): ExternalPlayerSession | null {
|
||||
const session = this.activeSession();
|
||||
if (!contentInfo || !session?.contentInfo) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (session.status === 'error' || session.status === 'closed') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return this.matchesContent(contentInfo, session.contentInfo)
|
||||
? session
|
||||
: null;
|
||||
}
|
||||
|
||||
private handleSessionUpdate(session: ExternalPlayerSession): void {
|
||||
const current = this.activeSession();
|
||||
|
||||
if (!current || current.id === session.id || session.status === 'launching') {
|
||||
this.activeSession.set(session);
|
||||
}
|
||||
|
||||
if (session.status === 'launching') {
|
||||
this.dismissedSessionId.set(null);
|
||||
}
|
||||
}
|
||||
|
||||
private matchesContent(
|
||||
left: PlayerContentInfo,
|
||||
right: PlayerContentInfo
|
||||
): boolean {
|
||||
return (
|
||||
left.playlistId === right.playlistId &&
|
||||
left.contentType === right.contentType &&
|
||||
left.contentXtreamId === right.contentXtreamId
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { MatDialog } from '@angular/material/dialog';
|
||||
import { DataService } from 'services';
|
||||
import { VideoPlayer } from 'shared-interfaces';
|
||||
import { ExternalPlayerSession, VideoPlayer } from 'shared-interfaces';
|
||||
import { SettingsStore } from './settings-store.service';
|
||||
import { PlayerService } from './player.service';
|
||||
|
||||
@@ -51,8 +51,8 @@ describe('PlayerService', () => {
|
||||
expect(service.isEmbeddedPlayer(VideoPlayer.VLC)).toBe(false);
|
||||
});
|
||||
|
||||
it('uses the dialog fallback for embedded players', () => {
|
||||
service.openResolvedPlayback({
|
||||
it('uses the dialog fallback for embedded players', async () => {
|
||||
await service.openResolvedPlayback({
|
||||
streamUrl: 'https://example.com/video.mp4',
|
||||
title: 'Example Video',
|
||||
});
|
||||
@@ -60,4 +60,33 @@ describe('PlayerService', () => {
|
||||
expect(dialog.open).toHaveBeenCalled();
|
||||
expect(dataService.sendIpcEvent).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('forwards external playback launches through IPC', async () => {
|
||||
const session: ExternalPlayerSession = {
|
||||
id: 'session-1',
|
||||
player: 'mpv',
|
||||
status: 'opened',
|
||||
title: 'Example Video',
|
||||
streamUrl: 'https://example.com/video.mp4',
|
||||
startedAt: '2026-03-07T10:00:00.000Z',
|
||||
updatedAt: '2026-03-07T10:00:00.000Z',
|
||||
canClose: true,
|
||||
};
|
||||
settingsStore.player.mockReturnValue(VideoPlayer.MPV);
|
||||
dataService.sendIpcEvent.mockResolvedValue(session);
|
||||
|
||||
const result = await service.openResolvedPlayback({
|
||||
streamUrl: 'https://example.com/video.mp4',
|
||||
title: 'Example Video',
|
||||
});
|
||||
|
||||
expect(dataService.sendIpcEvent).toHaveBeenCalledWith(
|
||||
'OPEN_MPV_PLAYER',
|
||||
expect.objectContaining({
|
||||
url: 'https://example.com/video.mp4',
|
||||
title: 'Example Video',
|
||||
})
|
||||
);
|
||||
expect(result).toEqual(session);
|
||||
});
|
||||
});
|
||||
@@ -2,6 +2,7 @@ import { Injectable, inject } from '@angular/core';
|
||||
import { MatDialog } from '@angular/material/dialog';
|
||||
import { DataService } from 'services';
|
||||
import {
|
||||
ExternalPlayerSession,
|
||||
OPEN_MPV_PLAYER,
|
||||
OPEN_VLC_PLAYER,
|
||||
PlayerContentInfo,
|
||||
@@ -45,8 +46,8 @@ export class PlayerService {
|
||||
contentInfo?: PlayerContentInfo,
|
||||
startTime?: number,
|
||||
headers?: Record<string, string>
|
||||
) {
|
||||
this.openResolvedPlayback(
|
||||
): Promise<ExternalPlayerSession | void> {
|
||||
return this.openResolvedPlayback(
|
||||
{
|
||||
streamUrl,
|
||||
title,
|
||||
@@ -62,10 +63,10 @@ export class PlayerService {
|
||||
);
|
||||
}
|
||||
|
||||
openResolvedPlayback(
|
||||
async openResolvedPlayback(
|
||||
playback: ResolvedPortalPlayback,
|
||||
hideExternalInfoDialog = true
|
||||
) {
|
||||
): Promise<ExternalPlayerSession | void> {
|
||||
const player = this.settingsStore.player() ?? VideoPlayer.VideoJs;
|
||||
const {
|
||||
streamUrl,
|
||||
@@ -83,7 +84,7 @@ export class PlayerService {
|
||||
if (!hideExternalInfoDialog) {
|
||||
this.dialog.open(ExternalPlayerInfoDialogComponent);
|
||||
}
|
||||
this.dataService.sendIpcEvent(OPEN_MPV_PLAYER, {
|
||||
return await this.dataService.sendIpcEvent(OPEN_MPV_PLAYER, {
|
||||
url: streamUrl,
|
||||
title,
|
||||
thumbnail,
|
||||
@@ -98,7 +99,7 @@ export class PlayerService {
|
||||
if (!hideExternalInfoDialog) {
|
||||
this.dialog.open(ExternalPlayerInfoDialogComponent);
|
||||
}
|
||||
this.dataService.sendIpcEvent(OPEN_VLC_PLAYER, {
|
||||
return await this.dataService.sendIpcEvent(OPEN_VLC_PLAYER, {
|
||||
url: streamUrl,
|
||||
title,
|
||||
thumbnail,
|
||||
@@ -109,16 +110,16 @@ export class PlayerService {
|
||||
contentInfo,
|
||||
startTime,
|
||||
});
|
||||
} else {
|
||||
this.dialog.open<PlayerDialogComponent, PlayerDialogData>(
|
||||
PlayerDialogComponent,
|
||||
{
|
||||
data: { streamUrl, title, contentInfo, startTime },
|
||||
width: '80%',
|
||||
maxWidth: '1200px',
|
||||
maxHeight: '90vh',
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
this.dialog.open<PlayerDialogComponent, PlayerDialogData>(
|
||||
PlayerDialogComponent,
|
||||
{
|
||||
data: { streamUrl, title, contentInfo, startTime },
|
||||
width: '80%',
|
||||
maxWidth: '1200px',
|
||||
maxHeight: '90vh',
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -22,6 +22,7 @@ const DEFAULT_SETTINGS: Settings = {
|
||||
streamFormat: StreamFormat.M3u8StreamFormat,
|
||||
language: Language.ENGLISH,
|
||||
showCaptions: false,
|
||||
showExternalPlaybackBar: true,
|
||||
theme: Theme.LightTheme,
|
||||
mpvPlayerPath: '',
|
||||
mpvReuseInstance: false,
|
||||
@@ -73,6 +74,7 @@ export const SettingsStore = signalStore(
|
||||
streamFormat: store.streamFormat(),
|
||||
language: store.language(),
|
||||
showCaptions: store.showCaptions(),
|
||||
showExternalPlaybackBar: store.showExternalPlaybackBar(),
|
||||
theme: store.theme(),
|
||||
mpvPlayerPath: store.mpvPlayerPath(),
|
||||
mpvReuseInstance: store.mpvReuseInstance(),
|
||||
|
||||
Reference in new issue
Block a user