mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-16 22:26:16 -08:00
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:
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"
|
||||
>
|
||||
|
||||
+141
-3
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user