feat(player): add embedded-mpv player for macOS as experimental feature

- Introduced tooling for building and staging the macOS `libmpv` runtime for IPTVnator's embedded MPV player.
- Added `build-macos-runtime.mjs` for building an LGPL-compatible runtime from source.
- Created `stage-macos-runtime.mjs` for staging the built runtime artifacts.
- Implemented validation for the packaged embedded MPV runtime in `electron-after-pack.cjs` and `embedded-mpv-macos.cjs`.
- Updated packaging scripts to ensure the embedded MPV runtime is correctly integrated and validated during the build process.
- Added README files to document the expected layout and usage for the embedded MPV runtime artifacts.

Entire-Checkpoint: c6e522b4276c
This commit is contained in:
4gray committed 2026-04-27 00:32:14 +02:00
1 parent 10139955e7
commit 9f873e6bed
53 files changed
+6099 -49

No files matched your search

+1
View File
@@ -1,5 +1,6 @@
export * from './lib/art-player/art-player.component';
export * from './lib/audio-player/audio-player.component';
export * from './lib/embedded-mpv-player/embedded-mpv-player.component';
export * from './lib/external-player-info-dialog/external-player-info-dialog.component';
export * from './lib/html-video-player/html-video-player.component';
export * from './lib/portal-inline-player/portal-inline-player.component';
@@ -0,0 +1,232 @@
<div
#playerRoot
class="embedded-mpv-player"
[class.embedded-mpv-player--fullscreen]="isFullscreen()"
[class.embedded-mpv-player--controls-enabled]="
showControls() && isSupported()
"
[class.embedded-mpv-player--controls-visible]="controlsAreVisible()"
[class.embedded-mpv-player--cursor-hidden]="hideCursor()"
(pointerenter)="onPlayerInteraction()"
(pointermove)="onPlayerInteraction()"
(click)="onPlayerInteraction()"
(focusin)="onPlayerInteraction()"
>
<div #viewport class="embedded-mpv-player__viewport">
@if (statusLabel()) {
<div class="embedded-mpv-player__status">
{{ statusLabel() }}
</div>
}
</div>
@if (showControls() && isSupported()) {
<div
class="embedded-mpv-player__controls"
[class.embedded-mpv-player__controls--visible]="controlsAreVisible()"
[class.embedded-mpv-player__controls--panel-open]="
volumePopoverOpen() || audioMenuOpen()
"
>
@if (volumePopoverOpen()) {
<div
class="embedded-mpv-player__mode-panel embedded-mpv-player__mode-panel--volume"
>
<button
mat-icon-button
type="button"
class="embedded-mpv-player__control-button embedded-mpv-player__back-button"
(click)="showDefaultControls()"
aria-label="Back to player controls"
matTooltip="Back to controls"
matTooltipPosition="right"
>
<mat-icon>arrow_back</mat-icon>
</button>
<div
class="embedded-mpv-player__control-panel embedded-mpv-player__volume-panel"
role="dialog"
aria-label="Volume"
>
<mat-icon>{{ volumeIcon() }}</mat-icon>
<input
class="embedded-mpv-player__slider"
type="range"
min="0"
max="1"
step="0.05"
[value]="volume()"
[attr.aria-label]="volumeLabel()"
(input)="onVolumeInput($event)"
/>
<span class="embedded-mpv-player__volume-value">
{{ volumeLabel() }}
</span>
</div>
</div>
} @else if (audioMenuOpen()) {
<div
class="embedded-mpv-player__mode-panel embedded-mpv-player__mode-panel--audio"
>
<button
mat-icon-button
type="button"
class="embedded-mpv-player__control-button embedded-mpv-player__back-button"
(click)="showDefaultControls()"
aria-label="Back to player controls"
matTooltip="Back to controls"
matTooltipPosition="right"
>
<mat-icon>arrow_back</mat-icon>
</button>
<div
class="embedded-mpv-player__control-panel embedded-mpv-player__audio-menu"
role="menu"
aria-label="Audio tracks"
>
<div class="embedded-mpv-player__menu-title">
Audio tracks
</div>
<div class="embedded-mpv-player__audio-track-list">
@for (
track of audioTracks();
track track.id;
let index = $index
) {
<button
type="button"
role="menuitemradio"
[attr.aria-checked]="track.selected"
class="embedded-mpv-player__audio-track"
[class.embedded-mpv-player__audio-track--selected]="
track.selected
"
(click)="selectAudioTrack(track.id)"
>
<span>{{ trackLabel(track, index) }}</span>
@if (track.selected) {
<mat-icon>check</mat-icon>
}
</button>
}
</div>
</div>
</div>
} @else {
<div class="embedded-mpv-player__transport">
<button
mat-icon-button
type="button"
class="embedded-mpv-player__control-button"
[disabled]="isLoading()"
(click)="togglePaused()"
[attr.aria-label]="isPaused() ? 'Play' : 'Pause'"
[matTooltip]="isPaused() ? 'Play' : 'Pause'"
matTooltipPosition="right"
>
<mat-icon>{{
isPaused() ? 'play_arrow' : 'pause'
}}</mat-icon>
</button>
<button
mat-icon-button
type="button"
class="embedded-mpv-player__control-button"
[disabled]="!canSeek()"
(click)="seekBy(-10)"
aria-label="Back 10 seconds"
matTooltip="Back 10 seconds"
matTooltipPosition="right"
>
<mat-icon>replay_10</mat-icon>
</button>
<button
mat-icon-button
type="button"
class="embedded-mpv-player__control-button"
[disabled]="!canSeek()"
(click)="seekBy(10)"
aria-label="Forward 10 seconds"
matTooltip="Forward 10 seconds"
matTooltipPosition="right"
>
<mat-icon>forward_10</mat-icon>
</button>
</div>
<div class="embedded-mpv-player__timeline">
<input
class="embedded-mpv-player__slider"
type="range"
min="0"
[max]="session()?.durationSeconds ?? 0"
[value]="timelineValue()"
[disabled]="!canSeek()"
(input)="onTimelineInput($event)"
/>
<div class="embedded-mpv-player__time">
<span>{{
formatTime(session()?.positionSeconds)
}}</span>
<span>{{
canSeek()
? formatTime(session()?.durationSeconds)
: 'LIVE'
}}</span>
</div>
</div>
<div class="embedded-mpv-player__actions">
<div class="embedded-mpv-player__popover-anchor">
<button
mat-icon-button
type="button"
class="embedded-mpv-player__control-button"
(click)="toggleVolumePopover()"
[attr.aria-label]="volumeLabel()"
[matTooltip]="volumeLabel()"
matTooltipPosition="left"
>
<mat-icon>{{ volumeIcon() }}</mat-icon>
</button>
</div>
@if (hasAudioTracks()) {
<div class="embedded-mpv-player__popover-anchor">
<button
mat-icon-button
type="button"
class="embedded-mpv-player__control-button"
(click)="toggleAudioMenu()"
aria-label="Audio tracks"
matTooltip="Audio tracks"
matTooltipPosition="left"
>
<mat-icon>graphic_eq</mat-icon>
</button>
</div>
}
<button
mat-icon-button
type="button"
class="embedded-mpv-player__control-button"
[disabled]="!canFullscreen()"
(click)="toggleFullscreen()"
[attr.aria-label]="fullscreenLabel()"
[matTooltip]="fullscreenLabel()"
matTooltipPosition="left"
>
<mat-icon>{{
isFullscreen() ? 'fullscreen_exit' : 'fullscreen'
}}</mat-icon>
</button>
</div>
}
</div>
}
</div>
@@ -0,0 +1,292 @@
:host {
display: block;
height: 100%;
}
.embedded-mpv-player {
--embedded-mpv-accent: #7dd3fc;
--embedded-mpv-glass: rgba(6, 10, 18, 0.78);
--embedded-mpv-border: rgba(255, 255, 255, 0.12);
--embedded-mpv-controls-height: 96px;
position: relative;
display: block;
height: 100%;
min-height: 0;
background:
linear-gradient(180deg, rgba(10, 14, 24, 0.08), rgba(10, 14, 24, 0.24)),
#02050a;
border-radius: inherit;
overflow: hidden;
}
.embedded-mpv-player--cursor-hidden {
cursor: none;
}
.embedded-mpv-player:fullscreen,
.embedded-mpv-player--fullscreen {
width: 100vw;
height: 100vh;
border-radius: 0;
}
.embedded-mpv-player__viewport {
position: absolute;
inset: 0;
min-height: 0;
background:
radial-gradient(circle at top, rgba(96, 125, 139, 0.24), transparent 58%),
linear-gradient(180deg, rgba(6, 8, 13, 0.88), rgba(2, 4, 8, 0.98));
}
.embedded-mpv-player--controls-enabled .embedded-mpv-player__viewport {
bottom: var(--embedded-mpv-controls-height);
}
.embedded-mpv-player__status {
position: absolute;
inset: auto 16px 16px 16px;
padding: 10px 12px;
color: rgba(245, 247, 250, 0.94);
background: rgba(7, 10, 16, 0.72);
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 12px;
backdrop-filter: blur(14px);
font-size: 0.9rem;
line-height: 1.4;
pointer-events: none;
z-index: 1;
}
.embedded-mpv-player__controls {
position: absolute;
right: 0;
bottom: 0;
left: 0;
z-index: 2;
display: grid;
grid-template-columns: auto minmax(0, 1fr) auto;
gap: 18px;
align-items: center;
align-content: center;
height: var(--embedded-mpv-controls-height);
padding: 12px 18px 14px;
opacity: 0;
pointer-events: none;
transform: translateY(14px);
overflow: hidden;
background:
linear-gradient(180deg, rgba(2, 5, 10, 0.28), rgba(5, 9, 15, 0.88) 30%),
linear-gradient(180deg, rgba(12, 18, 29, 0.82), rgba(5, 9, 15, 0.94));
border-top: 1px solid rgba(255, 255, 255, 0.06);
box-shadow: 0 -28px 70px rgba(0, 0, 0, 0.46);
backdrop-filter: blur(18px);
transition:
opacity 160ms ease,
transform 180ms ease;
}
.embedded-mpv-player__controls--visible {
opacity: 1;
pointer-events: auto;
transform: translateY(0);
}
.embedded-mpv-player__controls--panel-open {
background:
linear-gradient(180deg, rgba(2, 5, 10, 0.42), rgba(5, 9, 15, 0.92) 28%),
linear-gradient(180deg, rgba(12, 18, 29, 0.9), rgba(5, 9, 15, 0.98));
}
.embedded-mpv-player__transport {
display: flex;
align-items: center;
gap: 4px;
}
.embedded-mpv-player__timeline {
display: flex;
flex-direction: column;
gap: 8px;
min-width: 0;
}
.embedded-mpv-player__time {
display: flex;
justify-content: space-between;
gap: 12px;
color: rgba(226, 232, 240, 0.84);
font-size: 0.78rem;
}
.embedded-mpv-player__actions {
display: flex;
align-items: center;
gap: 6px;
}
.embedded-mpv-player__popover-anchor {
position: relative;
display: flex;
}
.embedded-mpv-player__control-button {
position: relative;
}
.embedded-mpv-player__slider {
width: 100%;
accent-color: var(--embedded-mpv-accent);
}
.embedded-mpv-player__control-panel {
min-width: 0;
color: rgba(240, 247, 255, 0.94);
background:
linear-gradient(180deg, rgba(17, 26, 39, 0.9), rgba(5, 9, 16, 0.94));
border: 1px solid var(--embedded-mpv-border);
border-radius: 18px;
box-shadow:
0 18px 50px rgba(0, 0, 0, 0.42),
inset 0 1px 0 rgba(255, 255, 255, 0.08);
backdrop-filter: blur(18px);
}
.embedded-mpv-player__mode-panel {
display: grid;
grid-column: 1 / -1;
grid-template-columns: auto minmax(0, 1fr);
gap: 12px;
align-items: center;
min-width: 0;
}
.embedded-mpv-player__back-button {
color: rgba(241, 245, 249, 0.88);
background: rgba(255, 255, 255, 0.06);
}
.embedded-mpv-player__volume-panel {
display: grid;
grid-template-columns: auto minmax(140px, 1fr) auto;
gap: 12px;
align-items: center;
padding: 10px 12px;
}
.embedded-mpv-player__volume-panel mat-icon {
color: var(--embedded-mpv-accent);
font-size: 20px;
width: 20px;
height: 20px;
}
.embedded-mpv-player__volume-value {
color: rgba(226, 232, 240, 0.82);
font-size: 0.76rem;
white-space: nowrap;
}
.embedded-mpv-player__audio-menu {
display: grid;
grid-template-columns: auto minmax(0, 1fr);
gap: 12px;
align-items: center;
padding: 8px 10px;
}
.embedded-mpv-player__menu-title {
padding: 0 2px;
color: rgba(226, 232, 240, 0.7);
font-size: 0.72rem;
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
white-space: nowrap;
}
.embedded-mpv-player__audio-track-list {
display: flex;
gap: 6px;
min-width: 0;
overflow-x: auto;
overflow-y: hidden;
scrollbar-width: thin;
}
.embedded-mpv-player__audio-track {
display: flex;
flex: 0 0 auto;
min-width: 154px;
max-width: 240px;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 8px 10px;
color: rgba(241, 245, 249, 0.88);
background: transparent;
border: 0;
border-radius: 12px;
cursor: pointer;
font: inherit;
text-align: left;
}
.embedded-mpv-player__audio-track span {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.embedded-mpv-player__audio-track:hover,
.embedded-mpv-player__audio-track--selected {
color: #f8fafc;
background: rgba(125, 211, 252, 0.16);
}
.embedded-mpv-player__audio-track mat-icon {
color: var(--embedded-mpv-accent);
font-size: 18px;
width: 18px;
height: 18px;
}
.embedded-mpv-player :is(button[mat-icon-button]) {
color: rgba(241, 245, 249, 0.9);
}
.embedded-mpv-player :is(button[mat-icon-button]:hover) {
color: #ffffff;
background: rgba(125, 211, 252, 0.14);
}
@media (max-width: 720px) {
.embedded-mpv-player {
--embedded-mpv-controls-height: 148px;
}
.embedded-mpv-player__controls {
grid-template-columns: 1fr;
gap: 12px;
}
.embedded-mpv-player__actions {
justify-content: flex-end;
min-width: 0;
}
.embedded-mpv-player__volume-panel {
grid-template-columns: auto 1fr;
}
.embedded-mpv-player__volume-value {
grid-column: 1 / -1;
}
.embedded-mpv-player__mode-panel--audio
.embedded-mpv-player__audio-menu {
grid-template-columns: 1fr;
gap: 6px;
}
}
@@ -0,0 +1,686 @@
import {
ChangeDetectionStrategy,
Component,
ElementRef,
EventEmitter,
OnDestroy,
Output,
computed,
effect,
input,
signal,
untracked,
viewChild,
} from '@angular/core';
import { MatButtonModule } from '@angular/material/button';
import { MatIconModule } from '@angular/material/icon';
import { MatTooltipModule } from '@angular/material/tooltip';
import {
EmbeddedMpvAudioTrack,
EmbeddedMpvBounds,
EmbeddedMpvSession,
EmbeddedMpvSupport,
ResolvedPortalPlayback,
} from 'shared-interfaces';
@Component({
selector: 'app-embedded-mpv-player',
templateUrl: './embedded-mpv-player.component.html',
styleUrl: './embedded-mpv-player.component.scss',
imports: [MatButtonModule, MatIconModule, MatTooltipModule],
changeDetection: ChangeDetectionStrategy.OnPush,
host: {
class: 'embedded-mpv-player-host',
},
})
export class EmbeddedMpvPlayerComponent implements OnDestroy {
readonly playback = input.required<ResolvedPortalPlayback>();
readonly showControls = input(true);
@Output() timeUpdate = new EventEmitter<{
currentTime: number;
duration: number;
}>();
readonly viewport = viewChild<ElementRef<HTMLDivElement>>('viewport');
readonly playerRoot = viewChild<ElementRef<HTMLDivElement>>('playerRoot');
readonly support = signal<EmbeddedMpvSupport | null>(null);
readonly session = signal<EmbeddedMpvSession | null>(null);
readonly sessionId = signal<string | null>(null);
readonly volume = signal(this.readStoredVolume());
readonly isFullscreen = signal(false);
readonly controlsVisible = signal(true);
readonly volumePopoverOpen = signal(false);
readonly audioMenuOpen = signal(false);
readonly isSupported = computed(() => this.support()?.supported ?? false);
readonly canFullscreen = computed(
() =>
typeof document !== 'undefined' &&
Boolean(this.playerRoot()?.nativeElement.requestFullscreen) &&
Boolean(document.exitFullscreen)
);
readonly isLoading = computed(
() =>
!this.support() ||
this.session()?.status === 'loading' ||
(!this.session() && this.isSupported())
);
readonly isPaused = computed(
() => this.session()?.status === 'paused' || this.session()?.status === 'idle'
);
readonly isPlaying = computed(() => this.session()?.status === 'playing');
readonly canSeek = computed(
() => (this.session()?.durationSeconds ?? 0) > 0
);
readonly statusLabel = computed(() => {
const session = this.session();
if (session?.status === 'error') {
return session.error ?? 'Embedded MPV playback failed.';
}
if (!this.support()) {
return 'Checking embedded MPV support...';
}
if (!this.isSupported()) {
return (
this.support()?.reason ??
'Embedded MPV is not available in this environment.'
);
}
if (!session || session.status === 'loading') {
return 'Loading stream in MPV...';
}
return '';
});
readonly fullscreenLabel = computed(() =>
this.isFullscreen() ? 'Exit fullscreen' : 'Enter fullscreen'
);
readonly audioTracks = computed(
() => this.session()?.audioTracks ?? []
);
readonly hasAudioTracks = computed(() => this.audioTracks().length > 1);
readonly selectedAudioTrack = computed(
() => this.audioTracks().find((track) => track.selected) ?? null
);
readonly controlsAreVisible = computed(
() =>
this.showControls() &&
this.isSupported() &&
(this.controlsVisible() ||
this.isLoading() ||
this.isPaused() ||
this.volumePopoverOpen() ||
this.audioMenuOpen() ||
Boolean(this.statusLabel()))
);
readonly hideCursor = computed(
() =>
this.isFullscreen() &&
this.isPlaying() &&
!this.controlsAreVisible()
);
readonly volumeIcon = computed(() => {
const value = this.volume();
if (value <= 0) {
return 'volume_off';
}
return value < 0.5 ? 'volume_down' : 'volume_up';
});
readonly volumeLabel = computed(
() => `Volume ${Math.round(this.volume() * 100)}%`
);
readonly timelineValue = computed(() =>
Math.max(0, this.session()?.positionSeconds ?? 0)
);
private readonly unsubscribeSessionUpdate =
window.electron?.onEmbeddedMpvSessionUpdate?.((session) => {
if (session.id !== this.sessionId()) {
return;
}
this.session.set(session);
this.volume.set(session.volume);
this.timeUpdate.emit({
currentTime: session.positionSeconds,
duration: session.durationSeconds ?? 0,
});
this.scheduleControlsHide();
});
private boundsAnimationFrame: number | null = null;
private controlsHideTimer: number | null = null;
private activeBoundsSync: (() => void) | null = null;
private readonly onDocumentPointerDown = (event: PointerEvent) => {
const playerRoot = this.playerRoot()?.nativeElement;
const path = event.composedPath();
if (!playerRoot || path.includes(playerRoot)) {
return;
}
this.closePopovers();
};
private readonly onDocumentPointerMove = (event: PointerEvent) => {
const playerRoot = this.playerRoot()?.nativeElement;
if (
playerRoot &&
!event.composedPath().includes(playerRoot) &&
this.isPointerInsidePlayer(event)
) {
this.revealControls();
}
};
private readonly onDocumentKeydown = (event: KeyboardEvent) => {
if (event.key !== 'Escape') {
return;
}
this.closePopovers();
};
private readonly onFullscreenChange = () => {
const playerRoot = this.playerRoot()?.nativeElement;
this.isFullscreen.set(
Boolean(playerRoot && document.fullscreenElement === playerRoot)
);
this.revealControls();
this.activeBoundsSync?.();
};
constructor() {
if (typeof document !== 'undefined') {
document.addEventListener(
'fullscreenchange',
this.onFullscreenChange
);
document.addEventListener(
'pointerdown',
this.onDocumentPointerDown
);
document.addEventListener(
'pointermove',
this.onDocumentPointerMove,
{ passive: true }
);
document.addEventListener('keydown', this.onDocumentKeydown);
}
if (window.electron?.getEmbeddedMpvSupport) {
void this.loadSupport();
} else {
this.support.set({
supported: false,
platform: typeof window === 'undefined' ? 'web' : 'unknown',
reason: 'Embedded MPV requires the Electron desktop build.',
});
}
effect((onCleanup) => {
const viewport = this.viewport();
const playback = this.playback();
const support = this.support();
if (
!viewport ||
!playback.streamUrl ||
!support?.supported ||
!window.electron
) {
return;
}
let disposed = false;
let activeSessionId: string | null = null;
const host = viewport.nativeElement;
const initialVolume = untracked(() => this.volume());
const syncBounds = () => {
if (!activeSessionId) {
return;
}
void window.electron
.setEmbeddedMpvBounds(
activeSessionId,
this.measureBounds(host)
)
.catch(() => undefined);
};
const scheduleBoundsSync = () => {
if (this.boundsAnimationFrame !== null) {
cancelAnimationFrame(this.boundsAnimationFrame);
}
this.boundsAnimationFrame = requestAnimationFrame(() => {
this.boundsAnimationFrame = null;
syncBounds();
});
};
this.activeBoundsSync = scheduleBoundsSync;
const resizeObserver = new ResizeObserver(() => {
scheduleBoundsSync();
});
resizeObserver.observe(host);
window.addEventListener('resize', scheduleBoundsSync);
window.addEventListener('scroll', scheduleBoundsSync, true);
const createSession = async () => {
this.session.set(
this.createLoadingSession(playback, initialVolume)
);
await this.waitForStartupPaint();
if (disposed) {
return;
}
const prepared = await window.electron!.prepareEmbeddedMpv?.();
if (disposed) {
return;
}
if (prepared && !prepared.supported) {
throw new Error(
prepared.reason ??
'Embedded MPV is not available in this environment.'
);
}
const createdSession =
await window.electron!.createEmbeddedMpvSession(
this.measureBounds(host),
playback.title,
initialVolume
);
if (disposed) {
await window.electron!.disposeEmbeddedMpvSession(
createdSession.id
);
return;
}
activeSessionId = createdSession.id;
this.sessionId.set(createdSession.id);
this.session.set(createdSession);
await window.electron!.loadEmbeddedMpvPlayback(
createdSession.id,
playback
);
scheduleBoundsSync();
};
void createSession().catch((error) => {
const startedAt = new Date().toISOString();
this.sessionId.set(null);
this.session.set({
id: 'embedded-mpv-error',
title: playback.title,
streamUrl: playback.streamUrl,
status: 'error',
positionSeconds: 0,
durationSeconds: null,
volume: initialVolume,
audioTracks: [],
selectedAudioTrackId: null,
startedAt,
updatedAt: startedAt,
error:
error instanceof Error
? error.message
: String(error),
});
});
onCleanup(() => {
disposed = true;
resizeObserver.disconnect();
window.removeEventListener('resize', scheduleBoundsSync);
window.removeEventListener('scroll', scheduleBoundsSync, true);
if (this.activeBoundsSync === scheduleBoundsSync) {
this.activeBoundsSync = null;
}
if (this.boundsAnimationFrame !== null) {
cancelAnimationFrame(this.boundsAnimationFrame);
this.boundsAnimationFrame = null;
}
const sessionId = activeSessionId;
activeSessionId = null;
this.sessionId.set(null);
this.session.set(null);
if (sessionId) {
void window.electron?.disposeEmbeddedMpvSession(sessionId);
}
});
});
}
ngOnDestroy(): void {
this.unsubscribeSessionUpdate?.();
if (typeof document !== 'undefined') {
document.removeEventListener(
'fullscreenchange',
this.onFullscreenChange
);
document.removeEventListener(
'pointerdown',
this.onDocumentPointerDown
);
document.removeEventListener(
'pointermove',
this.onDocumentPointerMove
);
document.removeEventListener('keydown', this.onDocumentKeydown);
}
if (this.boundsAnimationFrame !== null) {
cancelAnimationFrame(this.boundsAnimationFrame);
this.boundsAnimationFrame = null;
}
this.clearControlsHideTimer();
}
onPlayerInteraction(): void {
this.revealControls();
}
async togglePaused(): Promise<void> {
this.revealControls();
const session = this.session();
if (!session?.id || !window.electron?.setEmbeddedMpvPaused) {
return;
}
const updatedSession = await window.electron.setEmbeddedMpvPaused(
session.id,
session.status !== 'paused'
);
if (updatedSession) {
this.session.set(updatedSession);
}
}
async toggleFullscreen(): Promise<void> {
this.revealControls();
const playerRoot = this.playerRoot()?.nativeElement;
if (!playerRoot || !this.canFullscreen()) {
return;
}
try {
if (document.fullscreenElement === playerRoot) {
await document.exitFullscreen();
} else {
await playerRoot.requestFullscreen();
}
} catch {
return;
} finally {
this.activeBoundsSync?.();
}
}
async seekBy(deltaSeconds: number): Promise<void> {
this.revealControls();
const session = this.session();
if (!session?.id || !window.electron?.seekEmbeddedMpv) {
return;
}
const nextPosition = Math.max(0, session.positionSeconds + deltaSeconds);
const updatedSession = await window.electron.seekEmbeddedMpv(
session.id,
nextPosition
);
if (updatedSession) {
this.session.set(updatedSession);
}
}
async onTimelineInput(event: Event): Promise<void> {
this.revealControls();
const session = this.session();
if (!session?.id || !window.electron?.seekEmbeddedMpv) {
return;
}
const target = Number((event.target as HTMLInputElement).value);
const updatedSession = await window.electron.seekEmbeddedMpv(
session.id,
target
);
if (updatedSession) {
this.session.set(updatedSession);
}
}
toggleVolumePopover(): void {
this.volumePopoverOpen.update((open) => !open);
if (this.volumePopoverOpen()) {
this.audioMenuOpen.set(false);
}
this.revealControls();
this.activeBoundsSync?.();
}
toggleAudioMenu(): void {
this.audioMenuOpen.update((open) => !open);
if (this.audioMenuOpen()) {
this.volumePopoverOpen.set(false);
}
this.revealControls();
this.activeBoundsSync?.();
}
showDefaultControls(): void {
this.volumePopoverOpen.set(false);
this.audioMenuOpen.set(false);
this.revealControls();
this.activeBoundsSync?.();
}
onVolumeInput(event: Event): void {
const nextVolume = Number((event.target as HTMLInputElement).value);
this.volume.set(nextVolume);
localStorage.setItem('volume', String(nextVolume));
this.revealControls(false);
const session = this.session();
if (!session?.id || !window.electron?.setEmbeddedMpvVolume) {
return;
}
void window.electron
.setEmbeddedMpvVolume(session.id, nextVolume)
.then((updatedSession) => {
if (updatedSession) {
this.session.set(updatedSession);
}
})
.catch(() => undefined);
}
async selectAudioTrack(trackId: number): Promise<void> {
const session = this.session();
if (!session?.id || !window.electron?.setEmbeddedMpvAudioTrack) {
return;
}
this.revealControls(false);
const updatedSession = await window.electron.setEmbeddedMpvAudioTrack(
session.id,
trackId
);
if (updatedSession) {
this.session.set(updatedSession);
}
this.audioMenuOpen.set(false);
this.scheduleControlsHide();
}
formatTime(value: number | null | undefined): string {
const safeValue = Math.max(0, Math.floor(value ?? 0));
const hours = Math.floor(safeValue / 3600);
const minutes = Math.floor((safeValue % 3600) / 60);
const seconds = safeValue % 60;
if (hours > 0) {
return `${hours}:${String(minutes).padStart(2, '0')}:${String(seconds).padStart(2, '0')}`;
}
return `${minutes}:${String(seconds).padStart(2, '0')}`;
}
trackLabel(track: EmbeddedMpvAudioTrack, index: number): string {
const label = track.title || track.language || `Audio ${index + 1}`;
return track.defaultTrack ? `${label} · Default` : label;
}
private closePopovers(): void {
if (!this.volumePopoverOpen() && !this.audioMenuOpen()) {
return;
}
this.volumePopoverOpen.set(false);
this.audioMenuOpen.set(false);
this.activeBoundsSync?.();
this.scheduleControlsHide();
}
private revealControls(scheduleHide = true): void {
this.controlsVisible.set(true);
if (scheduleHide) {
this.clearControlsHideTimer();
this.scheduleControlsHide();
}
}
private scheduleControlsHide(): void {
if (
!this.isPlaying() ||
this.volumePopoverOpen() ||
this.audioMenuOpen() ||
Boolean(this.statusLabel())
) {
this.clearControlsHideTimer();
return;
}
if (!this.controlsVisible() || this.controlsHideTimer !== null) {
return;
}
this.controlsHideTimer = window.setTimeout(() => {
if (
this.isPlaying() &&
!this.volumePopoverOpen() &&
!this.audioMenuOpen() &&
!this.statusLabel()
) {
this.controlsVisible.set(false);
}
}, 2500);
}
private clearControlsHideTimer(): void {
if (this.controlsHideTimer === null) {
return;
}
window.clearTimeout(this.controlsHideTimer);
this.controlsHideTimer = null;
}
private isPointerInsidePlayer(event: PointerEvent): boolean {
const playerRoot = this.playerRoot()?.nativeElement;
if (!playerRoot) {
return false;
}
const rect = playerRoot.getBoundingClientRect();
return (
event.clientX >= rect.left &&
event.clientX <= rect.right &&
event.clientY >= rect.top &&
event.clientY <= rect.bottom
);
}
private async loadSupport(): Promise<void> {
try {
this.support.set(await window.electron!.getEmbeddedMpvSupport());
} catch (error) {
this.support.set({
supported: false,
platform: window.electron?.platform ?? 'unknown',
reason:
error instanceof Error
? error.message
: String(error),
});
}
}
private createLoadingSession(
playback: ResolvedPortalPlayback,
volume: number
): EmbeddedMpvSession {
const now = new Date().toISOString();
return {
id: 'embedded-mpv-starting',
title: playback.title,
streamUrl: playback.streamUrl,
status: 'loading',
positionSeconds: 0,
durationSeconds: null,
volume,
audioTracks: [],
selectedAudioTrackId: null,
startedAt: now,
updatedAt: now,
};
}
private waitForStartupPaint(): Promise<void> {
if (typeof requestAnimationFrame !== 'function') {
return Promise.resolve();
}
return new Promise((resolve) => {
requestAnimationFrame(() => {
requestAnimationFrame(() => resolve());
});
});
}
private measureBounds(host: HTMLDivElement): EmbeddedMpvBounds {
const rect = host.getBoundingClientRect();
return {
x: Math.round(rect.left),
y: Math.round(rect.top),
width: Math.max(1, Math.round(rect.width)),
height: Math.max(1, Math.round(rect.height)),
};
}
private readStoredVolume(): number {
const rawValue = Number(localStorage.getItem('volume') ?? '1');
if (Number.isNaN(rawValue)) {
return 1;
}
return Math.max(0, Math.min(1, rawValue));
}
}
@@ -32,6 +32,8 @@
<div class="player-shell__viewport">
<app-web-player-view
[streamUrl]="streamUrl()"
[title]="title()"
[playback]="playback()"
[startTime]="startTime()"
(timeUpdate)="onTimeUpdate($event)"
/>
@@ -18,6 +18,11 @@
[startTime]="startTime()"
(timeUpdate)="timeUpdate.emit($event)"
/>
} @else if (player === 'embedded-mpv') {
<app-embedded-mpv-player
[playback]="resolvedPlayback()"
(timeUpdate)="timeUpdate.emit($event)"
/>
} @else {
<app-vjs-player
[options]="vjsOptions"
@@ -4,6 +4,7 @@
}
app-art-player,
app-embedded-mpv-player,
app-vjs-player,
app-html-video-player {
display: block;
@@ -4,6 +4,7 @@ import {
Output,
Signal,
ViewEncapsulation,
computed,
effect,
inject,
input,
@@ -11,8 +12,14 @@ import {
import { toSignal } from '@angular/core/rxjs-interop';
import { StorageMap } from '@ngx-pwa/local-storage';
import { getExtensionFromUrl } from 'm3u-utils';
import { Settings, STORE_KEY, VideoPlayer } from 'shared-interfaces';
import {
ResolvedPortalPlayback,
Settings,
STORE_KEY,
VideoPlayer,
} from 'shared-interfaces';
import { ArtPlayerComponent } from '../art-player/art-player.component';
import { EmbeddedMpvPlayerComponent } from '../embedded-mpv-player/embedded-mpv-player.component';
import { HtmlVideoPlayerComponent } from '../html-video-player/html-video-player.component';
import { VjsPlayerComponent } from '../vjs-player/vjs-player.component';
@@ -20,13 +27,20 @@ import { VjsPlayerComponent } from '../vjs-player/vjs-player.component';
selector: 'app-web-player-view',
templateUrl: './web-player-view.component.html',
styleUrls: ['./web-player-view.component.scss'],
imports: [ArtPlayerComponent, HtmlVideoPlayerComponent, VjsPlayerComponent],
imports: [
ArtPlayerComponent,
EmbeddedMpvPlayerComponent,
HtmlVideoPlayerComponent,
VjsPlayerComponent,
],
encapsulation: ViewEncapsulation.None,
})
export class WebPlayerViewComponent {
storage = inject(StorageMap);
streamUrl = input.required<string>();
title = input('');
playback = input<ResolvedPortalPlayback | null>(null);
startTime = input<number>(0);
@Output() timeUpdate = new EventEmitter<{
currentTime: number;
@@ -40,13 +54,26 @@ export class WebPlayerViewComponent {
channel!: { url: string };
player!: VideoPlayer;
vjsOptions!: { sources: { src: string; type: string }[] };
readonly resolvedPlayback = computed<ResolvedPortalPlayback>(() => {
const playback = this.playback();
if (playback) {
return playback;
}
return {
streamUrl: this.streamUrl(),
title: this.title() || this.streamUrl(),
startTime: this.startTime(),
};
});
constructor() {
effect(() => {
this.player = this.settings()?.player ?? VideoPlayer.VideoJs;
this.setChannel(this.streamUrl());
this.setVjsOptions(this.streamUrl());
const playback = this.resolvedPlayback();
this.setChannel(playback.streamUrl);
this.setVjsOptions(playback.streamUrl);
});
}