- @if (speedMenuOpen()) {
+ @if (menus.speedOpen()) {
aspect_ratio
- @if (aspectMenuOpen()) {
+ @if (menus.aspectOpen()) {
>('viewport');
readonly playerRoot = viewChild>('playerRoot');
- readonly support = signal(null);
- readonly session = signal(null);
- readonly sessionId = signal(null);
- readonly volume = signal(this.readStoredVolume());
+
+ readonly support = this.controller.support;
+ readonly session = this.controller.session;
+ readonly stalled = this.controller.stalled;
+
+ readonly volume = signal(readStoredVolume());
readonly isFullscreen = signal(false);
readonly controlsVisible = signal(true);
- readonly volumePopoverOpen = signal(false);
- readonly audioMenuOpen = signal(false);
- readonly subtitleMenuOpen = signal(false);
- readonly speedMenuOpen = signal(false);
- readonly aspectMenuOpen = signal(false);
- readonly stalled = signal(false);
- readonly feedbackOverlay = signal<{
- icon: string;
- label: string;
- key: number;
- } | null>(null);
- private readonly retryNonce = signal(0);
+
+ readonly speedPresets = SPEED_PRESETS;
+ readonly aspectPresets = ASPECT_PRESETS;
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…';
- }
-
- return '';
- });
- readonly fullscreenLabel = computed(() =>
- this.isFullscreen() ? 'Exit fullscreen' : 'Enter fullscreen'
- );
- readonly audioTracks = computed(
- () => this.session()?.audioTracks ?? []
- );
- readonly subtitleTracks = computed(
- () => this.session()?.subtitleTracks ?? []
- );
- readonly hasSubtitleTracks = computed(
- () => this.subtitleTracks().length > 0
- );
- readonly selectedSubtitleTrackId = computed(
- () => this.session()?.selectedSubtitleTrackId ?? null
- );
- readonly playbackSpeed = computed(
- () => this.session()?.playbackSpeed ?? 1
- );
- readonly aspectOverride = computed(
- () => this.session()?.aspectOverride ?? 'no'
- );
readonly capabilities = computed(
() =>
this.support()?.capabilities ?? {
@@ -155,29 +101,69 @@ export class EmbeddedMpvPlayerComponent implements OnDestroy {
screenshot: false,
}
);
- readonly isErrored = computed(
- () => this.session()?.status === 'error'
+ readonly isLoading = computed(
+ () =>
+ !this.support() ||
+ this.session()?.status === 'loading' ||
+ (!this.session() && this.isSupported())
);
-
- readonly speedPresets: ReadonlyArray<{ value: number; label: string }> = [
- { value: 0.5, label: '0.5×' },
- { value: 0.75, label: '0.75×' },
- { value: 1, label: '1×' },
- { value: 1.25, label: '1.25×' },
- { value: 1.5, label: '1.5×' },
- { value: 2, label: '2×' },
- ];
-
- readonly aspectPresets: ReadonlyArray<{ value: string; label: string }> = [
- { value: 'no', label: 'Default' },
- { value: '16:9', label: '16:9' },
- { value: '4:3', label: '4:3' },
- { value: '21:9', label: '21:9' },
- { value: '2.35:1', label: '2.35:1' },
- ];
+ readonly isPaused = computed(
+ () =>
+ this.session()?.status === 'paused' ||
+ this.session()?.status === 'idle'
+ );
+ readonly isPlaying = computed(() => this.session()?.status === 'playing');
+ readonly isErrored = computed(() => this.session()?.status === 'error');
+ readonly canSeek = computed(
+ () => (this.session()?.durationSeconds ?? 0) > 0
+ );
+ readonly canFullscreen = computed(
+ () =>
+ typeof document !== 'undefined' &&
+ Boolean(this.playerRoot()?.nativeElement.requestFullscreen) &&
+ Boolean(document.exitFullscreen)
+ );
+ 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…';
+ }
+ 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 subtitleTracks = computed(
+ () => this.session()?.subtitleTracks ?? []
+ );
+ readonly hasSubtitleTracks = computed(
+ () => this.subtitleTracks().length > 0
+ );
+ readonly selectedSubtitleTrackId = computed(
+ () => this.session()?.selectedSubtitleTrackId ?? null
+ );
+ readonly playbackSpeed = computed(() => this.session()?.playbackSpeed ?? 1);
+ readonly aspectOverride = computed(
+ () => this.session()?.aspectOverride ?? 'no'
+ );
+ readonly volumeIcon = computed(() => volumeIcon(this.volume()));
+ readonly volumeLabel = computed(() => volumeLabel(this.volume()));
+ readonly timelineValue = computed(() =>
+ Math.max(0, this.session()?.positionSeconds ?? 0)
);
readonly controlsAreVisible = computed(
() =>
@@ -186,11 +172,7 @@ export class EmbeddedMpvPlayerComponent implements OnDestroy {
(this.controlsVisible() ||
this.isLoading() ||
this.isPaused() ||
- this.volumePopoverOpen() ||
- this.audioMenuOpen() ||
- this.subtitleMenuOpen() ||
- this.speedMenuOpen() ||
- this.aspectMenuOpen() ||
+ this.menus.anyOpen() ||
Boolean(this.statusLabel()))
);
readonly hideCursor = computed(
@@ -199,49 +181,16 @@ export class EmbeddedMpvPlayerComponent implements OnDestroy {
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 mutedVolume = 0;
private controlsHideTimer: number | null = null;
- private activeBoundsSync: (() => void) | null = null;
private volumeCloseTimer: number | null = null;
- private stalledTimer: number | null = null;
- private feedbackTimer: number | null = null;
- private feedbackKey = 0;
+
private readonly onDocumentPointerDown = (event: PointerEvent) => {
const playerRoot = this.playerRoot()?.nativeElement;
- const path = event.composedPath();
- if (!playerRoot || path.includes(playerRoot)) {
+ if (!playerRoot || event.composedPath().includes(playerRoot)) {
return;
}
-
this.closePopovers();
};
private readonly onDocumentPointerMove = (event: PointerEvent) => {
@@ -254,59 +203,13 @@ export class EmbeddedMpvPlayerComponent implements OnDestroy {
this.revealControls();
}
};
- private readonly onDocumentKeydown = (event: KeyboardEvent) => {
- if (event.key === 'Escape') {
- this.closePopovers();
- return;
- }
-
- if (this.shouldIgnoreShortcut(event)) {
- return;
- }
-
- switch (event.key) {
- case ' ':
- case 'k':
- case 'K':
- event.preventDefault();
- void this.togglePaused();
- return;
- case 'f':
- case 'F':
- event.preventDefault();
- void this.toggleFullscreen();
- return;
- case 'ArrowLeft':
- event.preventDefault();
- void this.seekBy(-5);
- return;
- case 'ArrowRight':
- event.preventDefault();
- void this.seekBy(5);
- return;
- case 'ArrowUp':
- event.preventDefault();
- this.adjustVolume(0.05);
- return;
- case 'ArrowDown':
- event.preventDefault();
- this.adjustVolume(-0.05);
- return;
- case 'm':
- case 'M':
- event.preventDefault();
- this.toggleMute();
- return;
- }
- };
- private mutedVolume = 0;
private readonly onFullscreenChange = () => {
const playerRoot = this.playerRoot()?.nativeElement;
this.isFullscreen.set(
Boolean(playerRoot && document.fullscreenElement === playerRoot)
);
this.revealControls();
- this.activeBoundsSync?.();
+ this.controller.triggerBoundsSync();
};
constructor() {
@@ -315,33 +218,33 @@ export class EmbeddedMpvPlayerComponent implements OnDestroy {
'fullscreenchange',
this.onFullscreenChange
);
- document.addEventListener(
- 'pointerdown',
- this.onDocumentPointerDown
- );
+ 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.',
- });
- }
+ this.controller.setOverlayActiveProvider(() =>
+ this.overlayVisibility.overlayActive()
+ );
+
+ this.shortcuts.attach({
+ isAvailable: () => !this.overlayVisibility.overlayActive(),
+ onEscape: () => this.closePopovers(),
+ togglePaused: () => void this.togglePaused(),
+ toggleFullscreen: () => void this.toggleFullscreen(),
+ seekBy: (delta) => void this.seekBy(delta),
+ adjustVolume: (delta) => this.adjustVolume(delta),
+ toggleMute: () => this.toggleMute(),
+ });
effect((onCleanup) => {
const viewport = this.viewport();
const playback = this.playback();
const support = this.support();
- this.retryNonce();
+ this.controller.retryToken();
if (
!viewport ||
@@ -352,177 +255,36 @@ export class EmbeddedMpvPlayerComponent implements OnDestroy {
return;
}
- let disposed = false;
- let activeSessionId: string | null = null;
- const host = viewport.nativeElement;
- const initialVolume = untracked(() => this.volume());
-
- const syncBounds = () => {
- if (!activeSessionId) {
- return;
- }
-
- const bounds = untracked(() =>
- this.overlayVisibility.overlayActive()
- )
- ? HIDDEN_BOUNDS
- : this.measureBounds(host);
-
- void window.electron
- .setEmbeddedMpvBounds(activeSessionId, bounds)
- .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,
- subtitleTracks: [],
- selectedSubtitleTrackId: null,
- playbackSpeed: 1,
- aspectOverride: 'no',
- 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);
- }
- });
+ const teardown = this.controller.startSession(
+ viewport.nativeElement,
+ playback,
+ this.volume()
+ );
+ onCleanup(teardown);
});
effect(() => {
this.overlayVisibility.overlayActive();
- untracked(() => this.activeBoundsSync?.());
+ this.controller.triggerBoundsSync();
});
effect(() => {
- const status = this.session()?.status ?? null;
- untracked(() => {
- if (status === 'loading') {
- if (this.stalledTimer === null) {
- this.stalledTimer = window.setTimeout(() => {
- this.stalled.set(true);
- this.stalledTimer = null;
- }, 30000);
- }
- return;
- }
-
- if (this.stalledTimer !== null) {
- clearTimeout(this.stalledTimer);
- this.stalledTimer = null;
- }
- if (this.stalled()) {
- this.stalled.set(false);
- }
+ const session = this.session();
+ if (!session) {
+ return;
+ }
+ this.volume.set(session.volume);
+ this.timeUpdate.emit({
+ currentTime: session.positionSeconds,
+ duration: session.durationSeconds ?? 0,
});
+ this.scheduleControlsHide();
});
}
ngOnDestroy(): void {
- this.unsubscribeSessionUpdate?.();
+ this.shortcuts.detach();
+ this.feedback.dispose();
if (typeof document !== 'undefined') {
document.removeEventListener(
'fullscreenchange',
@@ -536,27 +298,11 @@ export class EmbeddedMpvPlayerComponent implements OnDestroy {
'pointermove',
this.onDocumentPointerMove
);
- document.removeEventListener('keydown', this.onDocumentKeydown);
}
-
- if (this.boundsAnimationFrame !== null) {
- cancelAnimationFrame(this.boundsAnimationFrame);
- this.boundsAnimationFrame = null;
- }
-
if (this.volumeCloseTimer !== null) {
clearTimeout(this.volumeCloseTimer);
this.volumeCloseTimer = null;
}
- if (this.stalledTimer !== null) {
- clearTimeout(this.stalledTimer);
- this.stalledTimer = null;
- }
- if (this.feedbackTimer !== null) {
- clearTimeout(this.feedbackTimer);
- this.feedbackTimer = null;
- }
-
this.clearControlsHideTimer();
}
@@ -564,123 +310,6 @@ export class EmbeddedMpvPlayerComponent implements OnDestroy {
this.revealControls();
}
- async togglePaused(): Promise {
- 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 {
- 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 {
- this.revealControls();
- const session = this.session();
- if (!session?.id || !window.electron?.seekEmbeddedMpv) {
- return;
- }
-
- this.flashFeedback(
- deltaSeconds >= 0 ? 'forward_10' : 'replay_10',
- `${deltaSeconds >= 0 ? '+' : ''}${Math.round(deltaSeconds)}s`
- );
-
- 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 {
- 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);
- }
- }
-
- toggleAudioMenu(): void {
- const next = !this.audioMenuOpen();
- this.closeAllMenus();
- this.audioMenuOpen.set(next);
- this.revealControls();
- }
-
- toggleSubtitleMenu(): void {
- const next = !this.subtitleMenuOpen();
- this.closeAllMenus();
- this.subtitleMenuOpen.set(next);
- this.revealControls();
- }
-
- toggleSpeedMenu(): void {
- const next = !this.speedMenuOpen();
- this.closeAllMenus();
- this.speedMenuOpen.set(next);
- this.revealControls();
- }
-
- toggleAspectMenu(): void {
- const next = !this.aspectMenuOpen();
- this.closeAllMenus();
- this.aspectMenuOpen.set(next);
- this.revealControls();
- }
-
- private closeAllMenus(): void {
- this.audioMenuOpen.set(false);
- this.subtitleMenuOpen.set(false);
- this.speedMenuOpen.set(false);
- this.aspectMenuOpen.set(false);
- this.volumePopoverOpen.set(false);
- }
-
- onVolumeInput(event: Event): void {
- const nextVolume = Number((event.target as HTMLInputElement).value);
- this.applyVolume(nextVolume);
- this.revealControls(false);
- }
-
onPlayerDblClick(event: MouseEvent): void {
const target = event.target as HTMLElement | null;
if (target?.closest('button, input, [role="slider"]')) {
@@ -689,36 +318,56 @@ export class EmbeddedMpvPlayerComponent implements OnDestroy {
void this.toggleFullscreen();
}
- private adjustVolume(delta: number): void {
- const next = Math.max(0, Math.min(1, this.volume() + delta));
- this.applyVolume(next);
+ async togglePaused(): Promise {
this.revealControls();
- this.flashFeedback(
- this.volumeIconFor(next),
- `${Math.round(next * 100)}%`
- );
+ await this.controller.togglePaused();
}
- toggleMute(): void {
- if (this.volume() > 0) {
- this.mutedVolume = this.volume();
- this.applyVolume(0);
- this.flashFeedback('volume_off', 'Muted');
- } else {
- const restored = this.mutedVolume || 0.5;
- this.applyVolume(restored);
- this.flashFeedback(
- this.volumeIconFor(restored),
- `${Math.round(restored * 100)}%`
+ async toggleFullscreen(): Promise {
+ 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.controller.triggerBoundsSync();
+ }
+ }
+
+ async seekBy(deltaSeconds: number): Promise {
+ this.revealControls();
+ const ok = await this.controller.seekBy(deltaSeconds);
+ if (ok) {
+ this.feedback.flash(
+ deltaSeconds >= 0 ? 'forward_10' : 'replay_10',
+ `${deltaSeconds >= 0 ? '+' : ''}${Math.round(deltaSeconds)}s`
);
}
+ }
+
+ async onTimelineInput(event: Event): Promise {
this.revealControls();
+ const target = Number((event.target as HTMLInputElement).value);
+ await this.controller.seekTo(target);
+ }
+
+ onVolumeInput(event: Event): void {
+ const next = Number((event.target as HTMLInputElement).value);
+ this.applyVolume(next);
+ this.revealControls(false);
}
onVolumeWheel(event: WheelEvent): void {
event.preventDefault();
- const delta = event.deltaY > 0 ? -0.05 : 0.05;
- this.adjustVolume(delta);
+ this.adjustVolume(event.deltaY > 0 ? -0.05 : 0.05);
}
onVolumeHoverEnter(): void {
@@ -726,10 +375,7 @@ export class EmbeddedMpvPlayerComponent implements OnDestroy {
clearTimeout(this.volumeCloseTimer);
this.volumeCloseTimer = null;
}
- if (!this.volumePopoverOpen()) {
- this.volumePopoverOpen.set(true);
- this.audioMenuOpen.set(false);
- }
+ this.menus.open('volume');
}
onVolumeHoverLeave(): void {
@@ -737,194 +383,102 @@ export class EmbeddedMpvPlayerComponent implements OnDestroy {
clearTimeout(this.volumeCloseTimer);
}
this.volumeCloseTimer = window.setTimeout(() => {
- this.volumePopoverOpen.set(false);
+ this.menus.close('volume');
this.volumeCloseTimer = null;
- }, 220);
+ }, VOLUME_POPOVER_CLOSE_DELAY_MS);
}
- retry(): void {
- this.stalled.set(false);
- this.session.set(null);
- this.sessionId.set(null);
- this.retryNonce.update((n) => n + 1);
+ toggleMute(): void {
+ const current = this.volume();
+ if (current > 0) {
+ this.mutedVolume = current;
+ this.applyVolume(0);
+ this.feedback.flash('volume_off', 'Muted');
+ } else {
+ const restored = this.mutedVolume || 0.5;
+ this.applyVolume(restored);
+ this.feedback.flash(
+ volumeIcon(restored),
+ `${Math.round(restored * 100)}%`
+ );
+ }
+ this.revealControls();
}
- private volumeIconFor(value: number): string {
- if (value <= 0) {
- return 'volume_off';
- }
- return value < 0.5 ? 'volume_down' : 'volume_up';
+ toggleAudioMenu(): void {
+ this.menus.toggle('audio');
+ this.revealControls();
}
-
- private flashFeedback(icon: string, label: string): void {
- if (this.feedbackTimer !== null) {
- clearTimeout(this.feedbackTimer);
- }
- this.feedbackKey += 1;
- this.feedbackOverlay.set({
- icon,
- label,
- key: this.feedbackKey,
- });
- this.feedbackTimer = window.setTimeout(() => {
- this.feedbackOverlay.set(null);
- this.feedbackTimer = null;
- }, 700);
+ toggleSubtitleMenu(): void {
+ this.menus.toggle('subtitle');
+ this.revealControls();
}
-
- private applyVolume(nextVolume: number): void {
- this.volume.set(nextVolume);
- localStorage.setItem('volume', String(nextVolume));
-
- 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);
+ toggleSpeedMenu(): void {
+ this.menus.toggle('speed');
+ this.revealControls();
}
-
- private shouldIgnoreShortcut(event: KeyboardEvent): boolean {
- if (this.overlayVisibility.overlayActive()) {
- return true;
- }
- const target = event.target as HTMLElement | null;
- if (!target) {
- return false;
- }
- const tag = target.tagName;
- if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT') {
- return true;
- }
- return target.isContentEditable;
+ toggleAspectMenu(): void {
+ this.menus.toggle('aspect');
+ this.revealControls();
}
async selectAudioTrack(trackId: number): Promise {
- 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);
+ await this.controller.setAudioTrack(trackId);
+ this.menus.close('audio');
this.scheduleControlsHide();
}
async selectSubtitleTrack(trackId: number): Promise {
- const session = this.session();
- if (!session?.id || !window.electron?.setEmbeddedMpvSubtitleTrack) {
- return;
- }
-
this.revealControls(false);
- const updatedSession =
- await window.electron.setEmbeddedMpvSubtitleTrack(
- session.id,
- trackId
- );
- if (updatedSession) {
- this.session.set(updatedSession);
- }
- this.subtitleMenuOpen.set(false);
+ await this.controller.setSubtitleTrack(trackId);
+ this.menus.close('subtitle');
this.scheduleControlsHide();
}
async selectSpeed(speed: number): Promise {
- const session = this.session();
- if (!session?.id || !window.electron?.setEmbeddedMpvSpeed) {
- return;
- }
-
this.revealControls(false);
- const updatedSession = await window.electron.setEmbeddedMpvSpeed(
- session.id,
- speed
- );
- if (updatedSession) {
- this.session.set(updatedSession);
- }
- this.speedMenuOpen.set(false);
+ await this.controller.setSpeed(speed);
+ this.menus.close('speed');
this.scheduleControlsHide();
}
async selectAspect(aspect: string): Promise {
- const session = this.session();
- if (!session?.id || !window.electron?.setEmbeddedMpvAspect) {
- return;
- }
-
this.revealControls(false);
- const updatedSession = await window.electron.setEmbeddedMpvAspect(
- session.id,
- aspect
- );
- if (updatedSession) {
- this.session.set(updatedSession);
- }
- this.aspectMenuOpen.set(false);
+ await this.controller.setAspect(aspect);
+ this.menus.close('aspect');
this.scheduleControlsHide();
}
- speedLabel(speed: number): string {
- const value = Math.round(speed * 100) / 100;
- return Number.isInteger(value) ? `${value}×` : `${value}×`;
+ retry(): void {
+ this.controller.retry();
}
- aspectLabel(aspect: string): string {
- const preset = this.aspectPresets.find((p) => p.value === aspect);
- return preset?.label ?? aspect;
+ formatTime = formatTime;
+ trackLabel = audioTrackLabel;
+ subtitleLabel = subtitleTrackLabel;
+ speedLabel = speedLabel;
+ aspectLabel = aspectLabel;
+
+ private adjustVolume(delta: number): void {
+ const next = Math.max(0, Math.min(1, this.volume() + delta));
+ this.applyVolume(next);
+ this.revealControls();
+ this.feedback.flash(volumeIcon(next), `${Math.round(next * 100)}%`);
}
- subtitleLabel(track: EmbeddedMpvAudioTrack, index: number): string {
- const label = track.title || track.language || `Subtitle ${index + 1}`;
- return track.defaultTrack ? `${label} · Default` : label;
- }
-
- 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 applyVolume(value: number): void {
+ this.volume.set(value);
+ persistVolume(value);
+ void this.controller.applyVolume(value);
}
private closePopovers(): void {
- if (
- !this.volumePopoverOpen() &&
- !this.audioMenuOpen() &&
- !this.subtitleMenuOpen() &&
- !this.speedMenuOpen() &&
- !this.aspectMenuOpen()
- ) {
+ if (!this.menus.anyOpen()) {
return;
}
-
- this.closeAllMenus();
- this.activeBoundsSync?.();
+ this.menus.closeAll();
+ this.controller.triggerBoundsSync();
this.scheduleControlsHide();
}
@@ -939,35 +493,30 @@ export class EmbeddedMpvPlayerComponent implements OnDestroy {
private scheduleControlsHide(): void {
if (
!this.isPlaying() ||
- this.volumePopoverOpen() ||
- this.audioMenuOpen() ||
+ this.menus.anyOpen() ||
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.menus.anyOpen() &&
!this.statusLabel()
) {
this.controlsVisible.set(false);
}
- }, 2500);
+ }, HIDE_CONTROLS_DELAY_MS);
}
private clearControlsHideTimer(): void {
if (this.controlsHideTimer === null) {
return;
}
-
window.clearTimeout(this.controlsHideTimer);
this.controlsHideTimer = null;
}
@@ -977,7 +526,6 @@ export class EmbeddedMpvPlayerComponent implements OnDestroy {
if (!playerRoot) {
return false;
}
-
const rect = playerRoot.getBoundingClientRect();
return (
event.clientX >= rect.left &&
@@ -986,74 +534,6 @@ export class EmbeddedMpvPlayerComponent implements OnDestroy {
event.clientY <= rect.bottom
);
}
-
- private async loadSupport(): Promise {
- 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,
- subtitleTracks: [],
- selectedSubtitleTrackId: null,
- playbackSpeed: 1,
- aspectOverride: 'no',
- startedAt: now,
- updatedAt: now,
- };
- }
-
- private waitForStartupPaint(): Promise {
- 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));
- }
}
+
+export type EmbeddedMpvAudioTrackView = EmbeddedMpvAudioTrack;
diff --git a/libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-session-controller.ts b/libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-session-controller.ts
new file mode 100644
index 000000000..8362a0700
--- /dev/null
+++ b/libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-session-controller.ts
@@ -0,0 +1,395 @@
+import {
+ DestroyRef,
+ Injectable,
+ effect,
+ inject,
+ signal,
+ untracked,
+} from '@angular/core';
+import {
+ EmbeddedMpvSession,
+ EmbeddedMpvSupport,
+ ResolvedPortalPlayback,
+} from 'shared-interfaces';
+import { HIDDEN_BOUNDS, measureBounds } from './embedded-mpv-format.utils';
+
+const STALLED_TIMEOUT_MS = 30_000;
+
+@Injectable()
+export class EmbeddedMpvSessionController {
+ readonly support = signal(null);
+ readonly session = signal(null);
+ readonly sessionId = signal(null);
+ readonly stalled = signal(false);
+ readonly retryToken = signal(0);
+
+ private readonly destroyRef = inject(DestroyRef);
+ private readonly unsubscribeSessionUpdate?: () => void;
+
+ private overlayActiveProvider: () => boolean = () => false;
+ private activeBoundsSync: (() => void) | null = null;
+ private boundsAnimationFrame: number | null = null;
+ private stalledTimer: number | null = null;
+
+ constructor() {
+ this.unsubscribeSessionUpdate =
+ window.electron?.onEmbeddedMpvSessionUpdate?.((session) => {
+ if (session.id !== this.sessionId()) {
+ return;
+ }
+ this.session.set(session);
+ });
+
+ 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(() => {
+ const status = this.session()?.status ?? null;
+ untracked(() => this.handleStalledTracking(status));
+ });
+
+ this.destroyRef.onDestroy(() => {
+ this.unsubscribeSessionUpdate?.();
+ this.cancelStalledTimer();
+ if (this.boundsAnimationFrame !== null) {
+ cancelAnimationFrame(this.boundsAnimationFrame);
+ this.boundsAnimationFrame = null;
+ }
+ });
+ }
+
+ setOverlayActiveProvider(provider: () => boolean): void {
+ this.overlayActiveProvider = provider;
+ }
+
+ triggerBoundsSync(): void {
+ this.activeBoundsSync?.();
+ }
+
+ retry(): void {
+ this.stalled.set(false);
+ this.session.set(null);
+ this.sessionId.set(null);
+ this.retryToken.update((value) => value + 1);
+ }
+
+ /**
+ * Spin up an embedded MPV session bound to `host`. Returns a teardown
+ * function the caller must invoke when the host or playback changes (or
+ * the component tears down). All bounds and lifecycle bookkeeping lives
+ * here so the component can stay view-focused.
+ */
+ startSession(
+ host: HTMLElement,
+ playback: ResolvedPortalPlayback,
+ initialVolume: number
+ ): () => void {
+ let disposed = false;
+ let activeSessionId: string | null = null;
+
+ const syncBounds = () => {
+ if (!activeSessionId) {
+ return;
+ }
+ const bounds = this.overlayActiveProvider()
+ ? HIDDEN_BOUNDS
+ : measureBounds(host);
+ void window.electron
+ ?.setEmbeddedMpvBounds(activeSessionId, bounds)
+ .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 create = 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.'
+ );
+ }
+ if (prepared) {
+ this.support.set(prepared);
+ }
+
+ const created = await window.electron!.createEmbeddedMpvSession(
+ measureBounds(host),
+ playback.title,
+ initialVolume
+ );
+
+ if (disposed) {
+ await window.electron!.disposeEmbeddedMpvSession(created.id);
+ return;
+ }
+
+ activeSessionId = created.id;
+ this.sessionId.set(created.id);
+ this.session.set(created);
+ await window.electron!.loadEmbeddedMpvPlayback(created.id, playback);
+ scheduleBoundsSync();
+ };
+
+ void create().catch((error) =>
+ this.session.set(this.createErrorSession(playback, initialVolume, error))
+ );
+
+ return () => {
+ 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 id = activeSessionId;
+ activeSessionId = null;
+ this.sessionId.set(null);
+ this.session.set(null);
+
+ if (id) {
+ void window.electron?.disposeEmbeddedMpvSession(id);
+ }
+ };
+ }
+
+ async togglePaused(): Promise {
+ const session = this.session();
+ if (!session?.id || !window.electron?.setEmbeddedMpvPaused) {
+ return;
+ }
+ const updated = await window.electron.setEmbeddedMpvPaused(
+ session.id,
+ session.status !== 'paused'
+ );
+ if (updated) {
+ this.session.set(updated);
+ }
+ }
+
+ async seekBy(deltaSeconds: number): Promise {
+ const session = this.session();
+ if (!session?.id || !window.electron?.seekEmbeddedMpv) {
+ return false;
+ }
+ const next = Math.max(0, session.positionSeconds + deltaSeconds);
+ const updated = await window.electron.seekEmbeddedMpv(session.id, next);
+ if (updated) {
+ this.session.set(updated);
+ }
+ return true;
+ }
+
+ async seekTo(seconds: number): Promise {
+ const session = this.session();
+ if (!session?.id || !window.electron?.seekEmbeddedMpv) {
+ return;
+ }
+ const updated = await window.electron.seekEmbeddedMpv(session.id, seconds);
+ if (updated) {
+ this.session.set(updated);
+ }
+ }
+
+ async applyVolume(value: number): Promise {
+ const session = this.session();
+ if (!session?.id || !window.electron?.setEmbeddedMpvVolume) {
+ return;
+ }
+ const updated = await window.electron.setEmbeddedMpvVolume(session.id, value);
+ if (updated) {
+ this.session.set(updated);
+ }
+ }
+
+ async setAudioTrack(trackId: number): Promise {
+ const session = this.session();
+ if (!session?.id || !window.electron?.setEmbeddedMpvAudioTrack) {
+ return;
+ }
+ const updated = await window.electron.setEmbeddedMpvAudioTrack(
+ session.id,
+ trackId
+ );
+ if (updated) {
+ this.session.set(updated);
+ }
+ }
+
+ async setSubtitleTrack(trackId: number): Promise {
+ const session = this.session();
+ if (!session?.id || !window.electron?.setEmbeddedMpvSubtitleTrack) {
+ return;
+ }
+ const updated = await window.electron.setEmbeddedMpvSubtitleTrack(
+ session.id,
+ trackId
+ );
+ if (updated) {
+ this.session.set(updated);
+ }
+ }
+
+ async setSpeed(speed: number): Promise {
+ const session = this.session();
+ if (!session?.id || !window.electron?.setEmbeddedMpvSpeed) {
+ return;
+ }
+ const updated = await window.electron.setEmbeddedMpvSpeed(session.id, speed);
+ if (updated) {
+ this.session.set(updated);
+ }
+ }
+
+ async setAspect(aspect: string): Promise {
+ const session = this.session();
+ if (!session?.id || !window.electron?.setEmbeddedMpvAspect) {
+ return;
+ }
+ const updated = await window.electron.setEmbeddedMpvAspect(
+ session.id,
+ aspect
+ );
+ if (updated) {
+ this.session.set(updated);
+ }
+ }
+
+ private async loadSupport(): Promise {
+ 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 handleStalledTracking(
+ status: EmbeddedMpvSession['status'] | null
+ ): void {
+ if (status === 'loading') {
+ if (this.stalledTimer === null) {
+ this.stalledTimer = window.setTimeout(() => {
+ this.stalled.set(true);
+ this.stalledTimer = null;
+ }, STALLED_TIMEOUT_MS);
+ }
+ return;
+ }
+
+ this.cancelStalledTimer();
+ if (this.stalled()) {
+ this.stalled.set(false);
+ }
+ }
+
+ private cancelStalledTimer(): void {
+ if (this.stalledTimer !== null) {
+ clearTimeout(this.stalledTimer);
+ this.stalledTimer = null;
+ }
+ }
+
+ 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,
+ subtitleTracks: [],
+ selectedSubtitleTrackId: null,
+ playbackSpeed: 1,
+ aspectOverride: 'no',
+ startedAt: now,
+ updatedAt: now,
+ };
+ }
+
+ private createErrorSession(
+ playback: ResolvedPortalPlayback,
+ volume: number,
+ error: unknown
+ ): EmbeddedMpvSession {
+ const now = new Date().toISOString();
+ this.sessionId.set(null);
+ return {
+ id: 'embedded-mpv-error',
+ title: playback.title,
+ streamUrl: playback.streamUrl,
+ status: 'error',
+ positionSeconds: 0,
+ durationSeconds: null,
+ volume,
+ audioTracks: [],
+ selectedAudioTrackId: null,
+ subtitleTracks: [],
+ selectedSubtitleTrackId: null,
+ playbackSpeed: 1,
+ aspectOverride: 'no',
+ startedAt: now,
+ updatedAt: now,
+ error: error instanceof Error ? error.message : String(error),
+ };
+ }
+
+ private waitForStartupPaint(): Promise {
+ if (typeof requestAnimationFrame !== 'function') {
+ return Promise.resolve();
+ }
+ return new Promise((resolve) => {
+ requestAnimationFrame(() => {
+ requestAnimationFrame(() => resolve());
+ });
+ });
+ }
+}
diff --git a/libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-shortcuts.ts b/libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-shortcuts.ts
new file mode 100644
index 000000000..785cb6131
--- /dev/null
+++ b/libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-shortcuts.ts
@@ -0,0 +1,91 @@
+export interface EmbeddedMpvShortcutHandlers {
+ isAvailable: () => boolean;
+ onEscape: () => void;
+ togglePaused: () => void;
+ toggleFullscreen: () => void;
+ seekBy: (deltaSeconds: number) => void;
+ adjustVolume: (delta: number) => void;
+ toggleMute: () => void;
+}
+
+export class EmbeddedMpvShortcuts {
+ private handlers: EmbeddedMpvShortcutHandlers | null = null;
+ private readonly listener = (event: KeyboardEvent) => this.handle(event);
+
+ attach(handlers: EmbeddedMpvShortcutHandlers): void {
+ this.handlers = handlers;
+ if (typeof document !== 'undefined') {
+ document.addEventListener('keydown', this.listener);
+ }
+ }
+
+ detach(): void {
+ if (typeof document !== 'undefined') {
+ document.removeEventListener('keydown', this.listener);
+ }
+ this.handlers = null;
+ }
+
+ private handle(event: KeyboardEvent): void {
+ const handlers = this.handlers;
+ if (!handlers) {
+ return;
+ }
+
+ if (event.key === 'Escape') {
+ handlers.onEscape();
+ return;
+ }
+
+ if (this.shouldIgnore(event) || !handlers.isAvailable()) {
+ return;
+ }
+
+ switch (event.key) {
+ case ' ':
+ case 'k':
+ case 'K':
+ event.preventDefault();
+ handlers.togglePaused();
+ return;
+ case 'f':
+ case 'F':
+ event.preventDefault();
+ handlers.toggleFullscreen();
+ return;
+ case 'ArrowLeft':
+ event.preventDefault();
+ handlers.seekBy(-5);
+ return;
+ case 'ArrowRight':
+ event.preventDefault();
+ handlers.seekBy(5);
+ return;
+ case 'ArrowUp':
+ event.preventDefault();
+ handlers.adjustVolume(0.05);
+ return;
+ case 'ArrowDown':
+ event.preventDefault();
+ handlers.adjustVolume(-0.05);
+ return;
+ case 'm':
+ case 'M':
+ event.preventDefault();
+ handlers.toggleMute();
+ return;
+ }
+ }
+
+ private shouldIgnore(event: KeyboardEvent): boolean {
+ const target = event.target as HTMLElement | null;
+ if (!target) {
+ return false;
+ }
+ const tag = target.tagName;
+ if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT') {
+ return true;
+ }
+ return target.isContentEditable;
+ }
+}
diff --git a/libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-ui-state.ts b/libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-ui-state.ts
new file mode 100644
index 000000000..fee6a58c2
--- /dev/null
+++ b/libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-ui-state.ts
@@ -0,0 +1,106 @@
+import { computed, signal } from '@angular/core';
+
+export type EmbeddedMpvMenu =
+ | 'volume'
+ | 'audio'
+ | 'subtitle'
+ | 'speed'
+ | 'aspect';
+
+/**
+ * Tracks which menu/popover is currently open and exposes individual signals
+ * the template binds to. Only one menu can be open at a time.
+ */
+export class EmbeddedMpvMenuState {
+ readonly volumeOpen = signal(false);
+ readonly audioOpen = signal(false);
+ readonly subtitleOpen = signal(false);
+ readonly speedOpen = signal(false);
+ readonly aspectOpen = signal(false);
+
+ readonly anyOpen = computed(
+ () =>
+ this.volumeOpen() ||
+ this.audioOpen() ||
+ this.subtitleOpen() ||
+ this.speedOpen() ||
+ this.aspectOpen()
+ );
+
+ toggle(menu: EmbeddedMpvMenu): void {
+ const target = this.signalFor(menu);
+ const next = !target();
+ this.closeAll();
+ target.set(next);
+ }
+
+ open(menu: EmbeddedMpvMenu): void {
+ if (this.signalFor(menu)()) {
+ return;
+ }
+ this.closeAll();
+ this.signalFor(menu).set(true);
+ }
+
+ close(menu: EmbeddedMpvMenu): void {
+ this.signalFor(menu).set(false);
+ }
+
+ closeAll(): void {
+ this.volumeOpen.set(false);
+ this.audioOpen.set(false);
+ this.subtitleOpen.set(false);
+ this.speedOpen.set(false);
+ this.aspectOpen.set(false);
+ }
+
+ private signalFor(menu: EmbeddedMpvMenu) {
+ switch (menu) {
+ case 'volume':
+ return this.volumeOpen;
+ case 'audio':
+ return this.audioOpen;
+ case 'subtitle':
+ return this.subtitleOpen;
+ case 'speed':
+ return this.speedOpen;
+ case 'aspect':
+ return this.aspectOpen;
+ }
+ }
+}
+
+/**
+ * Transient feedback overlay shown when the user adjusts volume/seek/mute via
+ * keyboard. Caller calls flash() with an icon + label; auto-clears after the
+ * given duration.
+ */
+export class EmbeddedMpvFeedback {
+ readonly current = signal<{
+ icon: string;
+ label: string;
+ key: number;
+ } | null>(null);
+
+ private timer: number | null = null;
+ private nextKey = 0;
+
+ flash(icon: string, label: string, durationMs = 700): void {
+ if (this.timer !== null) {
+ clearTimeout(this.timer);
+ }
+ this.nextKey += 1;
+ this.current.set({ icon, label, key: this.nextKey });
+ this.timer = window.setTimeout(() => {
+ this.current.set(null);
+ this.timer = null;
+ }, durationMs);
+ }
+
+ dispose(): void {
+ if (this.timer !== null) {
+ clearTimeout(this.timer);
+ this.timer = null;
+ }
+ }
+}