feat(playback): embedded mpv pre-0.22 hardening, Linux parity, and localization (#1122)

* fix(playback): harden embedded mpv session handling and support detection

- guard the session controller against late startup rejections clobbering
  a newer session during fast channel zapping
- exclude the refresh timestamp from the session-update dedup key so idle
  sessions stop re-emitting IPC updates every 500 ms
- macOS: reconcile async loadfile replies by request id so a rejected
  seek/aid/speed on a live stream no longer flips the session to error
- append --ozone-platform=x11 on Linux in main.ts so direct binary and
  AppImage launches match the packaged .desktop launcher behavior
- return a sandbox-specific unsupported reason in Flatpak/Snap instead of
  asking the user to install mpv inside the sandbox
- update the stale "macOS only" embedded MPV claim in CLAUDE.md

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(playback): populate Linux audio tracks and fix ARM Linux packaging

- Linux poller now reads track-list/count each tick and walks the scalar
  track-list/N/* sub-properties when the count changes, so the audio-track
  menu is no longer empty; selection reconciles from the aid property
- afterPack replaces the x64 embedded_mpv.node with an
  embedded-mpv-unavailable.txt marker in arm64/armv7l Linux packages, and
  package-layout verification rejects foreign-architecture addons while
  requiring the marker
- extend native source invariants for the non-fatal async-reply rule
  (macOS) and the Linux track-list polling contract
- document the Linux track-list mechanics and ARM packaging behavior in
  docs/architecture/embedded-mpv-native.md

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(playback): embedded mpv player UX polish and full localization

- click on the video toggles pause (same action as Space) with a 250 ms
  grace period so double-click fullscreen cancels the pending pause; no
  DOM overlay is drawn — the dock transport icon is the feedback
- timeline scrubbing previews the drag position locally and commits a
  single seek on release instead of one IPC seek per drag pixel
- translate all player UI strings (controls, tooltips, aria-labels,
  status and recording messages) via new EMBEDDED_MPV.PLAYER.* keys,
  synced across en + 17 locales through the i18n-fill workflow
- replace the legacy @Output() EventEmitter with the signal output() API

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(playback): react to language changes and respect ozone platform hint

Address review feedback on #1122:
- add a translationsTick signal (onLangChange/onTranslationChange/
  onDefaultLangChange) read by every computed() and template helper that
  calls translate.instant(), so labels re-evaluate on a runtime language
  switch and when the translation file finishes loading after mount
- suppress the Linux --ozone-platform=x11 fallback when the user set
  ELECTRON_OZONE_PLATFORM_HINT, matching the existing respect for an
  explicit --ozone-platform switch

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
4grayandClaude Fable 5 authored and GitHub committed 2026-07-04 14:22:48 +02:00
1 parent 3659de796c
commit 8eb0fe3261
38 files changed
+1823 -107

No files matched your search

@@ -49,20 +49,39 @@ export function formatTime(value: number | null | undefined): string {
return `${minutes}:${String(seconds).padStart(2, '0')}`;
}
export interface TrackLabelTexts {
/** Used when the track carries neither title nor language. */
fallback?: string;
/** Suffix marker for the stream's default track. */
defaultLabel?: string;
}
export function audioTrackLabel(
track: EmbeddedMpvAudioTrack,
index: number
index: number,
texts: TrackLabelTexts = {}
): string {
const label = track.title || track.language || `Audio ${index + 1}`;
return track.defaultTrack ? `${label} · Default` : label;
const label =
track.title ||
track.language ||
(texts.fallback ?? `Audio ${index + 1}`);
return track.defaultTrack
? `${label} · ${texts.defaultLabel ?? 'Default'}`
: label;
}
export function subtitleTrackLabel(
track: EmbeddedMpvAudioTrack,
index: number
index: number,
texts: TrackLabelTexts = {}
): string {
const label = track.title || track.language || `Subtitle ${index + 1}`;
return track.defaultTrack ? `${label} · Default` : label;
const label =
track.title ||
track.language ||
(texts.fallback ?? `Subtitle ${index + 1}`);
return track.defaultTrack
? `${label} · ${texts.defaultLabel ?? 'Default'}`
: label;
}
export function speedLabel(speed: number): string {
@@ -13,7 +13,11 @@
(dblclick)="onPlayerDblClick($event)"
(focusin)="onPlayerInteraction()"
>
<div #viewport class="embedded-mpv-player__viewport">
<div
#viewport
class="embedded-mpv-player__viewport"
(click)="onViewportClick($event)"
>
@if (isErrored()) {
<div
class="embedded-mpv-player__stalled"
@@ -24,7 +28,7 @@
<p>{{ statusLabel() }}</p>
<button mat-stroked-button type="button" (click)="retry()">
<mat-icon>refresh</mat-icon>
<span>Retry</span>
<span>{{ 'EMBEDDED_MPV.PLAYER.RETRY' | translate }}</span>
</button>
</div>
} @else if (isLoading() && !stalled()) {
@@ -49,10 +53,10 @@
aria-live="assertive"
>
<mat-icon>warning_amber</mat-icon>
<p>This stream is taking longer than expected to start.</p>
<p>{{ 'EMBEDDED_MPV.PLAYER.STALLED' | translate }}</p>
<button mat-stroked-button type="button" (click)="retry()">
<mat-icon>refresh</mat-icon>
<span>Retry</span>
<span>{{ 'EMBEDDED_MPV.PLAYER.RETRY' | translate }}</span>
</button>
</div>
} @else if (statusLabel()) {
@@ -91,8 +95,14 @@
class="embedded-mpv-player__control-button"
[disabled]="isLoading()"
(click)="togglePaused()"
[attr.aria-label]="isPaused() ? 'Play' : 'Pause'"
[matTooltip]="isPaused() ? 'Play' : 'Pause'"
[attr.aria-label]="(isPaused()
? 'EMBEDDED_MPV.PLAYER.PLAY'
: 'EMBEDDED_MPV.PLAYER.PAUSE'
) | translate"
[matTooltip]="(isPaused()
? 'EMBEDDED_MPV.PLAYER.PLAY'
: 'EMBEDDED_MPV.PLAYER.PAUSE'
) | translate"
matTooltipPosition="above"
>
<mat-icon>{{
@@ -108,8 +118,12 @@
data-test-id="embedded-mpv-previous-episode"
[disabled]="isLoading() || !canPreviousEpisode()"
(click)="requestPreviousEpisode()"
aria-label="Previous episode"
matTooltip="Previous episode"
[attr.aria-label]="
'EMBEDDED_MPV.PLAYER.PREVIOUS_EPISODE' | translate
"
[matTooltip]="
'EMBEDDED_MPV.PLAYER.PREVIOUS_EPISODE' | translate
"
matTooltipPosition="above"
>
<mat-icon>skip_previous</mat-icon>
@@ -122,8 +136,12 @@
data-test-id="embedded-mpv-next-episode"
[disabled]="isLoading() || !canNextEpisode()"
(click)="requestNextEpisode()"
aria-label="Next episode"
matTooltip="Next episode"
[attr.aria-label]="
'EMBEDDED_MPV.PLAYER.NEXT_EPISODE' | translate
"
[matTooltip]="
'EMBEDDED_MPV.PLAYER.NEXT_EPISODE' | translate
"
matTooltipPosition="above"
>
<mat-icon>skip_next</mat-icon>
@@ -136,8 +154,12 @@
class="embedded-mpv-player__control-button"
[disabled]="!canSeek()"
(click)="seekBy(-10)"
aria-label="Back 10 seconds"
matTooltip="Back 10 seconds"
[attr.aria-label]="
'EMBEDDED_MPV.PLAYER.BACK_10_SECONDS' | translate
"
[matTooltip]="
'EMBEDDED_MPV.PLAYER.BACK_10_SECONDS' | translate
"
matTooltipPosition="above"
>
<mat-icon>replay_10</mat-icon>
@@ -149,8 +171,12 @@
class="embedded-mpv-player__control-button"
[disabled]="!canSeek()"
(click)="seekBy(10)"
aria-label="Forward 10 seconds"
matTooltip="Forward 10 seconds"
[attr.aria-label]="
'EMBEDDED_MPV.PLAYER.FORWARD_10_SECONDS' | translate
"
[matTooltip]="
'EMBEDDED_MPV.PLAYER.FORWARD_10_SECONDS' | translate
"
matTooltipPosition="above"
>
<mat-icon>forward_10</mat-icon>
@@ -174,9 +200,10 @@
[disabled]="!canSeek()"
[attr.aria-valuetext]="formatTime(timelineValue())"
(input)="onTimelineInput($event)"
(change)="onTimelineCommit($event)"
/>
<div class="embedded-mpv-player__time">
<span>{{ formatTime(session()?.positionSeconds) }}</span>
<span>{{ formatTime(timelineValue()) }}</span>
@if (canSeek()) {
<span>{{
formatTime(session()?.durationSeconds)
@@ -184,7 +211,9 @@
} @else if (isLivePlayback()) {
<span
class="embedded-mpv-player__live-badge"
aria-label="Live stream"
[attr.aria-label]="
'EMBEDDED_MPV.PLAYER.LIVE_STREAM' | translate
"
>
<span
class="embedded-mpv-player__live-dot"
@@ -228,8 +257,14 @@
type="button"
class="embedded-mpv-player__control-button"
(click)="toggleMute()"
[attr.aria-label]="volume() <= 0 ? 'Unmute' : 'Mute'"
[matTooltip]="volume() <= 0 ? 'Unmute' : 'Mute'"
[attr.aria-label]="(volume() <= 0
? 'EMBEDDED_MPV.PLAYER.UNMUTE'
: 'EMBEDDED_MPV.PLAYER.MUTE'
) | translate"
[matTooltip]="(volume() <= 0
? 'EMBEDDED_MPV.PLAYER.UNMUTE'
: 'EMBEDDED_MPV.PLAYER.MUTE'
) | translate"
matTooltipPosition="above"
>
<mat-icon>{{ volumeIcon() }}</mat-icon>
@@ -238,7 +273,9 @@
<div
class="embedded-mpv-player__control-panel embedded-mpv-player__volume-popover"
role="dialog"
aria-label="Volume"
[attr.aria-label]="
'EMBEDDED_MPV.PLAYER.VOLUME' | translate
"
>
<input
class="embedded-mpv-player__slider embedded-mpv-player__slider--volume"
@@ -267,8 +304,12 @@
class="embedded-mpv-player__control-button"
(click)="toggleAudioMenu()"
[attr.aria-expanded]="menus.audioOpen()"
aria-label="Audio tracks"
matTooltip="Audio tracks"
[attr.aria-label]="
'EMBEDDED_MPV.PLAYER.AUDIO_TRACKS' | translate
"
[matTooltip]="
'EMBEDDED_MPV.PLAYER.AUDIO_TRACKS' | translate
"
matTooltipPosition="above"
>
<mat-icon>graphic_eq</mat-icon>
@@ -277,10 +318,16 @@
<div
class="embedded-mpv-player__control-panel embedded-mpv-player__audio-popover"
role="menu"
aria-label="Audio tracks"
[attr.aria-label]="
'EMBEDDED_MPV.PLAYER.AUDIO_TRACKS'
| translate
"
>
<div class="embedded-mpv-player__menu-title">
Audio tracks
{{
'EMBEDDED_MPV.PLAYER.AUDIO_TRACKS'
| translate
}}
</div>
<div
class="embedded-mpv-player__audio-track-list"
@@ -322,8 +369,12 @@
class="embedded-mpv-player__control-button"
(click)="toggleSubtitleMenu()"
[attr.aria-expanded]="menus.subtitleOpen()"
aria-label="Subtitles"
matTooltip="Subtitles"
[attr.aria-label]="
'EMBEDDED_MPV.PLAYER.SUBTITLES' | translate
"
[matTooltip]="
'EMBEDDED_MPV.PLAYER.SUBTITLES' | translate
"
matTooltipPosition="above"
>
<mat-icon>{{
@@ -336,10 +387,15 @@
<div
class="embedded-mpv-player__control-panel embedded-mpv-player__audio-popover"
role="menu"
aria-label="Subtitles"
[attr.aria-label]="
'EMBEDDED_MPV.PLAYER.SUBTITLES' | translate
"
>
<div class="embedded-mpv-player__menu-title">
Subtitles
{{
'EMBEDDED_MPV.PLAYER.SUBTITLES'
| translate
}}
</div>
<div
class="embedded-mpv-player__audio-track-list"
@@ -356,7 +412,10 @@
"
(click)="selectSubtitleTrack(-1)"
>
<span>Off</span>
<span>{{
'EMBEDDED_MPV.PLAYER.SUBTITLES_OFF'
| translate
}}</span>
@if (
selectedSubtitleTrackId() === null
) {
@@ -403,10 +462,19 @@
(click)="toggleSpeedMenu()"
[attr.aria-expanded]="menus.speedOpen()"
[attr.aria-label]="
'Playback speed ' + speedLabel(playbackSpeed())
('EMBEDDED_MPV.PLAYER.PLAYBACK_SPEED'
| translate) +
' ' +
speedLabel(playbackSpeed())
"
[matTooltip]="
'Speed: ' + speedLabel(playbackSpeed())
'EMBEDDED_MPV.PLAYER.SPEED_TOOLTIP'
| translate
: {
speed: speedLabel(
playbackSpeed()
)
}
"
matTooltipPosition="above"
>
@@ -416,10 +484,16 @@
<div
class="embedded-mpv-player__control-panel embedded-mpv-player__audio-popover"
role="menu"
aria-label="Playback speed"
[attr.aria-label]="
'EMBEDDED_MPV.PLAYER.PLAYBACK_SPEED'
| translate
"
>
<div class="embedded-mpv-player__menu-title">
Speed
{{
'EMBEDDED_MPV.PLAYER.PLAYBACK_SPEED'
| translate
}}
</div>
<div
class="embedded-mpv-player__audio-track-list"
@@ -463,10 +537,19 @@
(click)="toggleAspectMenu()"
[attr.aria-expanded]="menus.aspectOpen()"
[attr.aria-label]="
'Aspect ratio ' + aspectLabel(aspectOverride())
('EMBEDDED_MPV.PLAYER.ASPECT_RATIO'
| translate) +
' ' +
aspectLabel(aspectOverride())
"
[matTooltip]="
'Aspect: ' + aspectLabel(aspectOverride())
'EMBEDDED_MPV.PLAYER.ASPECT_TOOLTIP'
| translate
: {
aspect: aspectLabel(
aspectOverride()
)
}
"
matTooltipPosition="above"
>
@@ -476,10 +559,16 @@
<div
class="embedded-mpv-player__control-panel embedded-mpv-player__audio-popover"
role="menu"
aria-label="Aspect ratio"
[attr.aria-label]="
'EMBEDDED_MPV.PLAYER.ASPECT_RATIO'
| translate
"
>
<div class="embedded-mpv-player__menu-title">
Aspect
{{
'EMBEDDED_MPV.PLAYER.ASPECT_RATIO'
| translate
}}
</div>
<div
class="embedded-mpv-player__audio-track-list"
@@ -529,10 +618,16 @@
(click)="toggleRecording()"
[attr.aria-pressed]="isRecording()"
[attr.aria-label]="
isRecording() ? 'Stop recording' : 'Start recording'
(isRecording()
? 'EMBEDDED_MPV.PLAYER.STOP_RECORDING'
: 'EMBEDDED_MPV.PLAYER.START_RECORDING'
) | translate
"
[matTooltip]="
isRecording() ? 'Stop recording' : 'Start recording'
(isRecording()
? 'EMBEDDED_MPV.PLAYER.STOP_RECORDING'
: 'EMBEDDED_MPV.PLAYER.START_RECORDING'
) | translate
"
matTooltipPosition="above"
>
@@ -1,6 +1,7 @@
import { Component, signal } from '@angular/core';
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { By } from '@angular/platform-browser';
import { TranslateModule, TranslateService } from '@ngx-translate/core';
import {
EmbeddedMpvSession,
ResolvedPortalPlayback,
@@ -96,7 +97,7 @@ describe('EmbeddedMpvPlayerComponent series navigation', () => {
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [EmbeddedMpvPlayerHostComponent],
imports: [EmbeddedMpvPlayerHostComponent, TranslateModule.forRoot()],
providers: [
{
provide: EmbeddedMpvOverlayVisibilityService,
@@ -194,12 +195,16 @@ describe('EmbeddedMpvPlayerComponent series navigation', () => {
).not.toBeNull();
expect(
fixture.debugElement.query(
By.css('button[aria-label="Back 10 seconds"]')
By.css(
'button[aria-label="EMBEDDED_MPV.PLAYER.BACK_10_SECONDS"]'
)
).nativeElement.disabled
).toBe(true);
expect(
fixture.debugElement.query(
By.css('button[aria-label="Forward 10 seconds"]')
By.css(
'button[aria-label="EMBEDDED_MPV.PLAYER.FORWARD_10_SECONDS"]'
)
).nativeElement.disabled
).toBe(true);
expect(
@@ -208,6 +213,139 @@ describe('EmbeddedMpvPlayerComponent series navigation', () => {
).toBe(true);
});
it('re-evaluates instant()-based labels when the language changes', () => {
const translate = TestBed.inject(TranslateService);
translate.setTranslation('en', {
EMBEDDED_MPV: { PLAYER: { ENTER_FULLSCREEN: 'Enter fullscreen' } },
});
translate.use('en');
expect(player.fullscreenLabel()).toBe('Enter fullscreen');
translate.setTranslation('de', {
EMBEDDED_MPV: { PLAYER: { ENTER_FULLSCREEN: 'Vollbild starten' } },
});
translate.use('de');
// translate.instant() is invisible to the signal graph; the
// translationsTick dependency must invalidate the computed.
expect(player.fullscreenLabel()).toBe('Vollbild starten');
});
describe('timeline scrubbing', () => {
const slider = () =>
fixture.debugElement.query(
By.css('.embedded-mpv-player__slider')
).nativeElement as HTMLInputElement;
const dispatch = (type: string, value: string) => {
slider().value = value;
slider().dispatchEvent(new Event(type, { bubbles: true }));
fixture.detectChanges();
};
it('previews the drag position locally without firing seeks', () => {
const seekTo = jest
.spyOn(controller, 'seekTo')
.mockResolvedValue(undefined);
dispatch('input', '55');
dispatch('input', '60');
expect(seekTo).not.toHaveBeenCalled();
expect(player.timelineValue()).toBe(60);
expect(fixture.nativeElement.textContent).toContain('01:00');
});
it('commits a single seek on release and returns to session position', () => {
const seekTo = jest
.spyOn(controller, 'seekTo')
.mockResolvedValue(undefined);
dispatch('input', '60');
dispatch('change', '60');
expect(seekTo).toHaveBeenCalledTimes(1);
expect(seekTo).toHaveBeenCalledWith(60);
expect(player.scrubPosition()).toBeNull();
// Back to the session-reported position once the scrub ends.
expect(player.timelineValue()).toBe(30);
});
});
describe('viewport click-to-pause', () => {
const clickViewport = () => {
fixture.debugElement
.query(By.css('.embedded-mpv-player__viewport'))
.nativeElement.dispatchEvent(
new MouseEvent('click', { bubbles: true })
);
};
beforeEach(() => {
jest.useFakeTimers();
});
afterEach(() => {
jest.useRealTimers();
});
it('toggles pause after the double-click grace period', () => {
const togglePaused = jest
.spyOn(controller, 'togglePaused')
.mockResolvedValue(undefined);
clickViewport();
expect(togglePaused).not.toHaveBeenCalled();
jest.advanceTimersByTime(300);
expect(togglePaused).toHaveBeenCalledTimes(1);
});
it('does not pause when the click turns into a double-click (fullscreen)', () => {
const togglePaused = jest
.spyOn(controller, 'togglePaused')
.mockResolvedValue(undefined);
clickViewport();
fixture.debugElement
.query(By.css('.embedded-mpv-player__viewport'))
.nativeElement.dispatchEvent(
new MouseEvent('dblclick', { bubbles: true })
);
jest.advanceTimersByTime(300);
expect(togglePaused).not.toHaveBeenCalled();
});
it('closes an open popover instead of pausing', () => {
const togglePaused = jest
.spyOn(controller, 'togglePaused')
.mockResolvedValue(undefined);
player.menus.open('audio');
clickViewport();
jest.advanceTimersByTime(300);
expect(player.menus.anyOpen()).toBe(false);
expect(togglePaused).not.toHaveBeenCalled();
});
it('ignores clicks while the session is loading', () => {
const togglePaused = jest
.spyOn(controller, 'togglePaused')
.mockResolvedValue(undefined);
controller.session.update((session) =>
session ? { ...session, status: 'loading' } : session
);
fixture.detectChanges();
clickViewport();
jest.advanceTimersByTime(300);
expect(togglePaused).not.toHaveBeenCalled();
});
});
it('does not label VOD or episode playback as live while duration is loading', () => {
controller.session.update((session) =>
session
@@ -2,9 +2,7 @@ import {
ChangeDetectionStrategy,
Component,
ElementRef,
EventEmitter,
OnDestroy,
Output,
computed,
effect,
inject,
@@ -18,6 +16,9 @@ import { MatButtonModule } from '@angular/material/button';
import { MatIconModule } from '@angular/material/icon';
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
import { MatTooltipModule } from '@angular/material/tooltip';
import { toSignal } from '@angular/core/rxjs-interop';
import { TranslatePipe, TranslateService } from '@ngx-translate/core';
import { merge } from 'rxjs';
import {
EmbeddedMpvAudioTrack,
ResolvedPortalPlayback,
@@ -44,12 +45,12 @@ import {
speedLabel,
subtitleTrackLabel,
volumeIcon,
volumeLabel,
} from './embedded-mpv-format.utils';
const HIDE_CONTROLS_DELAY_MS = 2500;
const RECORDING_MESSAGE_DISMISS_DELAY_MS = 5000;
const VOLUME_POPOVER_CLOSE_DELAY_MS = 220;
const VIEWPORT_CLICK_PAUSE_DELAY_MS = 250;
@Component({
selector: 'app-embedded-mpv-player',
@@ -60,6 +61,7 @@ const VOLUME_POPOVER_CLOSE_DELAY_MS = 220;
MatIconModule,
MatProgressSpinnerModule,
MatTooltipModule,
TranslatePipe,
],
providers: [EmbeddedMpvSessionController],
changeDetection: ChangeDetectionStrategy.OnPush,
@@ -73,7 +75,7 @@ export class EmbeddedMpvPlayerComponent implements OnDestroy {
readonly recordingFolder = input('');
readonly seriesNavigation = input<SeriesPlaybackNavigation | null>(null);
@Output() timeUpdate = new EventEmitter<{
readonly timeUpdate = output<{
currentTime: number;
duration: number;
}>();
@@ -84,6 +86,22 @@ export class EmbeddedMpvPlayerComponent implements OnDestroy {
private readonly overlayVisibility = inject(
EmbeddedMpvOverlayVisibilityService
);
private readonly translate = inject(TranslateService);
/**
* Ticks when the active language or a loaded translation file changes.
* translate.instant() is invisible to the signal graph, so every
* computed()/template helper that calls it must read this signal first —
* otherwise labels keep the previous language (or the raw key when the
* component mounts before the translation file finishes loading).
*/
private readonly translationsTick = toSignal(
merge(
this.translate.onLangChange,
this.translate.onTranslationChange,
this.translate.onDefaultLangChange
),
{ initialValue: null }
);
private readonly controller = inject(EmbeddedMpvSessionController);
private readonly shortcuts = new EmbeddedMpvShortcuts();
readonly menus = new EmbeddedMpvMenuState();
@@ -147,27 +165,40 @@ export class EmbeddedMpvPlayerComponent implements OnDestroy {
Boolean(document.exitFullscreen)
);
readonly statusLabel = computed(() => {
this.translationsTick();
const session = this.session();
if (session?.status === 'error') {
return session.error ?? 'Embedded MPV playback failed.';
return (
session.error ??
this.translate.instant('EMBEDDED_MPV.PLAYER.PLAYBACK_FAILED')
);
}
if (!this.support()) {
return 'Checking embedded MPV support...';
return this.translate.instant(
'EMBEDDED_MPV.PLAYER.CHECKING_SUPPORT'
);
}
if (!this.isSupported()) {
return (
this.support()?.reason ??
'Embedded MPV is not available in this environment.'
this.translate.instant('EMBEDDED_MPV.PLAYER.NOT_AVAILABLE')
);
}
if (!session || session.status === 'loading') {
return 'Loading stream…';
return this.translate.instant(
'EMBEDDED_MPV.PLAYER.LOADING_STREAM'
);
}
return '';
});
readonly fullscreenLabel = computed(() =>
this.isFullscreen() ? 'Exit fullscreen' : 'Enter fullscreen'
);
readonly fullscreenLabel = computed(() => {
this.translationsTick();
return this.translate.instant(
this.isFullscreen()
? 'EMBEDDED_MPV.PLAYER.EXIT_FULLSCREEN'
: 'EMBEDDED_MPV.PLAYER.ENTER_FULLSCREEN'
);
});
readonly audioTracks = computed(() => this.session()?.audioTracks ?? []);
readonly hasAudioTracks = computed(() => this.audioTracks().length > 1);
readonly subtitleTracks = computed(
@@ -184,9 +215,22 @@ export class EmbeddedMpvPlayerComponent implements OnDestroy {
() => 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 volumeLabel = computed(() => {
this.translationsTick();
return this.translate.instant('EMBEDDED_MPV.PLAYER.VOLUME_LABEL', {
percent: Math.round(this.volume() * 100),
});
});
/**
* Non-null while the user drags the timeline: the slider and time label
* preview this value locally and the single seek IPC call is deferred to
* the release (`change`) event instead of firing per drag pixel.
*/
readonly scrubPosition = signal<number | null>(null);
readonly timelineValue = computed(
() =>
this.scrubPosition() ??
Math.max(0, this.session()?.positionSeconds ?? 0)
);
readonly controlsAreVisible = computed(
() =>
@@ -250,6 +294,7 @@ export class EmbeddedMpvPlayerComponent implements OnDestroy {
private controlsHideTimer: number | null = null;
private volumeCloseTimer: number | null = null;
private recordingMessageTimer: number | null = null;
private viewportClickTimer: number | null = null;
private lastEndedSessionId: string | null = null;
private readonly recordingTick = signal(Date.now());
private readonly recordingMessage = signal<string | null>(null);
@@ -427,17 +472,48 @@ export class EmbeddedMpvPlayerComponent implements OnDestroy {
}
this.clearRecordingMessageTimer();
this.clearControlsHideTimer();
this.clearViewportClickTimer();
}
private clearViewportClickTimer(): void {
if (this.viewportClickTimer !== null) {
window.clearTimeout(this.viewportClickTimer);
this.viewportClickTimer = null;
}
}
onPlayerInteraction(): void {
this.revealControls();
}
onViewportClick(event: MouseEvent): void {
const target = event.target as HTMLElement | null;
if (target?.closest('button, input, [role="slider"]')) {
return;
}
if (this.menus.anyOpen()) {
this.menus.closeAll();
return;
}
if (this.isLoading() || this.isErrored() || this.stalled()) {
return;
}
// The pause is deferred so a double-click (fullscreen) can cancel it;
// pause feedback is the transport icon in the dock — the native video
// surface paints over any DOM overlay we could flash here.
this.clearViewportClickTimer();
this.viewportClickTimer = window.setTimeout(() => {
this.viewportClickTimer = null;
void this.togglePaused();
}, VIEWPORT_CLICK_PAUSE_DELAY_MS);
}
onPlayerDblClick(event: MouseEvent): void {
const target = event.target as HTMLElement | null;
if (target?.closest('button, input, [role="slider"]')) {
return;
}
this.clearViewportClickTimer();
void this.toggleFullscreen();
}
@@ -492,9 +568,16 @@ export class EmbeddedMpvPlayerComponent implements OnDestroy {
this.nextEpisodeRequested.emit();
}
async onTimelineInput(event: Event): Promise<void> {
onTimelineInput(event: Event): void {
this.revealControls();
this.scrubPosition.set(
Number((event.target as HTMLInputElement).value)
);
}
async onTimelineCommit(event: Event): Promise<void> {
const target = Number((event.target as HTMLInputElement).value);
this.scrubPosition.set(null);
await this.controller.seekTo(target);
}
@@ -532,7 +615,10 @@ export class EmbeddedMpvPlayerComponent implements OnDestroy {
if (current > 0) {
this.mutedVolume = current;
this.applyVolume(0);
this.feedback.flash('volume_off', 'Muted');
this.feedback.flash(
'volume_off',
this.translate.instant('EMBEDDED_MPV.PLAYER.MUTED')
);
} else {
const restored = this.mutedVolume || 0.5;
this.applyVolume(restored);
@@ -598,16 +684,31 @@ export class EmbeddedMpvPlayerComponent implements OnDestroy {
if (this.isRecording()) {
const recording = await this.controller.stopRecording();
if (recording?.targetPath) {
this.setRecordingMessage(`Saved to ${recording.targetPath}`, {
autoDismiss: true,
});
this.feedback.flash('check_circle', 'Recording saved', 1200);
this.setRecordingMessage(
this.translate.instant('EMBEDDED_MPV.PLAYER.SAVED_TO', {
path: recording.targetPath,
}),
{
autoDismiss: true,
}
);
this.feedback.flash(
'check_circle',
this.translate.instant(
'EMBEDDED_MPV.PLAYER.RECORDING_SAVED'
),
1200
);
} else if (recording?.error) {
this.setRecordingMessage(recording.error);
this.feedback.flash('error_outline', 'Recording failed', 1200);
this.flashRecordingFailed();
} else {
this.setRecordingMessage('Recording failed to stop.');
this.feedback.flash('error_outline', 'Recording failed', 1200);
this.setRecordingMessage(
this.translate.instant(
'EMBEDDED_MPV.PLAYER.RECORDING_FAILED_TO_STOP'
)
);
this.flashRecordingFailed();
}
this.scheduleControlsHide();
return;
@@ -619,25 +720,70 @@ export class EmbeddedMpvPlayerComponent implements OnDestroy {
this.playback().title
);
if (recording?.active) {
this.feedback.flash('fiber_manual_record', 'Recording', 900);
this.feedback.flash(
'fiber_manual_record',
this.translate.instant('EMBEDDED_MPV.PLAYER.RECORDING'),
900
);
} else if (recording?.error) {
this.setRecordingMessage(recording.error);
this.feedback.flash('error_outline', 'Recording failed', 1200);
this.flashRecordingFailed();
} else {
this.setRecordingMessage('Recording failed to start.');
this.feedback.flash('error_outline', 'Recording failed', 1200);
this.setRecordingMessage(
this.translate.instant(
'EMBEDDED_MPV.PLAYER.RECORDING_FAILED_TO_START'
)
);
this.flashRecordingFailed();
}
}
private flashRecordingFailed(): void {
this.feedback.flash(
'error_outline',
this.translate.instant('EMBEDDED_MPV.PLAYER.RECORDING_FAILED'),
1200
);
}
retry(): void {
this.controller.retry();
}
// These helpers run inside the template's reactive context, so the
// translationsTick() read makes them re-evaluate on language changes.
formatTime = formatTime;
trackLabel = audioTrackLabel;
subtitleLabel = subtitleTrackLabel;
trackLabel = (track: EmbeddedMpvAudioTrack, index: number) => {
this.translationsTick();
return audioTrackLabel(track, index, {
fallback: this.translate.instant(
'EMBEDDED_MPV.PLAYER.AUDIO_TRACK_FALLBACK',
{ index: index + 1 }
),
defaultLabel: this.translate.instant(
'EMBEDDED_MPV.PLAYER.TRACK_DEFAULT'
),
});
};
subtitleLabel = (track: EmbeddedMpvAudioTrack, index: number) => {
this.translationsTick();
return subtitleTrackLabel(track, index, {
fallback: this.translate.instant(
'EMBEDDED_MPV.PLAYER.SUBTITLE_TRACK_FALLBACK',
{ index: index + 1 }
),
defaultLabel: this.translate.instant(
'EMBEDDED_MPV.PLAYER.TRACK_DEFAULT'
),
});
};
speedLabel = speedLabel;
aspectLabel = aspectLabel;
aspectLabel = (aspect: string) => {
this.translationsTick();
return aspect === 'no'
? this.translate.instant('EMBEDDED_MPV.PLAYER.ASPECT_DEFAULT')
: aspectLabel(aspect);
};
private setRecordingMessage(
message: string | null,
@@ -189,6 +189,39 @@ describe('EmbeddedMpvSessionController', () => {
expect(controller.sessionId()).toBeNull();
});
it('ignores a late startup rejection after teardown so it cannot clobber a newer session', async () => {
let rejectPrepare: ((error: Error) => void) | null = null;
electron.prepareEmbeddedMpv.mockImplementationOnce(
() =>
new Promise((_resolve, reject) => {
rejectPrepare = reject;
})
);
const controller = TestBed.inject(EmbeddedMpvSessionController);
const teardown = controller.startSession(
createHost(),
createPlayback(),
0.5
);
await waitFor(
() => rejectPrepare !== null,
'startup to reach prepareEmbeddedMpv'
);
teardown();
// A newer session replaces the torn-down one (fast channel zapping).
const newer = createSession({ id: 'mpv-2' });
controller.sessionId.set('mpv-2');
controller.session.set(newer);
rejectPrepare?.(new Error('native module missing'));
await new Promise((resolve) => window.setTimeout(resolve, 0));
expect(controller.session()).toBe(newer);
expect(controller.sessionId()).toBe('mpv-2');
});
it('forwards playback commands and updates session snapshots', async () => {
const controller = TestBed.inject(EmbeddedMpvSessionController);
controller.sessionId.set('mpv-1');
@@ -185,11 +185,17 @@ export class EmbeddedMpvSessionController {
scheduleBoundsSync();
};
void create().catch((error) =>
void create().catch((error) => {
// A rejection can land after teardown (fast channel zapping):
// writing the error session then would clobber the state of the
// session that replaced this one and null its sessionId.
if (disposed) {
return;
}
this.session.set(
this.createErrorSession(playback, initialVolume, error)
)
);
);
});
return () => {
disposed = true;