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

+1 -1
View File
@@ -615,7 +615,7 @@ This project uses modern Angular signal-based APIs and patterns. **ALWAYS** use
- Built-in HTML5 player with HLS.js or Video.js
- External players: MPV, VLC (via IPC to Electron backend)
- Embedded MPV (experimental, macOS only): renders libmpv into a custom Cocoa view inside the Electron window. Because the standard `--wid` path is bypassed, mpv's own screensaver inhibition does not apply, so `EmbeddedMpvNativeService` holds an Electron `powerSaveBlocker` (`prevent-display-sleep`) whenever any session's status is `playing`, and releases it on pause, dispose, or shutdown. Service: `apps/electron-backend/src/app/services/embedded-mpv-native.service.ts`.
- Embedded MPV (experimental, macOS/Windows/Linux): renders mpv video inside the Electron window through a native addon. macOS uses the libmpv render API in an `NSOpenGLView`; Windows uses in-process libmpv with `--wid` against an app-owned child `HWND`; Linux spawns an out-of-process `mpv --wid=<x11-window>` controlled over a JSON IPC socket (X11/XWayland only, requires system `mpv` on PATH; subtitles/speed/aspect/recording are not exported there). mpv's own screensaver inhibition does not apply to any of these paths, so `EmbeddedMpvNativeService` holds an Electron `powerSaveBlocker` (`prevent-display-sleep`) whenever any session's status is `playing`, and releases it on pause, dispose, or shutdown. Service: `apps/electron-backend/src/app/services/embedded-mpv-native.service.ts`; full architecture: `docs/architecture/embedded-mpv-native.md`.
**Radio Player**:
@@ -133,6 +133,7 @@ struct Session {
std::string pendingRecordingTargetPath;
std::string pendingRecordingStartedAt;
std::string pendingRecordingStopStartedAt;
uint64_t pendingPlaybackLoadRequestId = 0;
std::weak_ptr<Session>* renderCallbackContext = nullptr;
RenderBackend renderBackend = RenderBackend::OpenGL;
int renderWidthPixels = 0;
@@ -1091,6 +1092,27 @@ bool reconcileRecordingPropertyReply(
return false;
}
bool reconcilePlaybackLoadReply(
const std::shared_ptr<Session>& session,
uint64_t requestId,
int error
)
{
if (
requestId == 0 ||
requestId != session->pendingPlaybackLoadRequestId
) {
return false;
}
session->pendingPlaybackLoadRequestId = 0;
if (error < 0) {
session->snapshot.status = SessionStatus::Error;
session->snapshot.error = mpv_error_string(error);
}
return true;
}
double clampVolumePercent(double volume)
{
return std::clamp(volume, 0.0, 1.0) * 100.0;
@@ -1314,6 +1336,13 @@ void runEventLoop(const std::shared_ptr<Session>& session)
}
case MPV_EVENT_COMMAND_REPLY:
case MPV_EVENT_SET_PROPERTY_REPLY:
if (reconcilePlaybackLoadReply(
session,
event->reply_userdata,
event->error
)) {
break;
}
if (reconcileRecordingPropertyReply(
session,
event->reply_userdata,
@@ -1321,8 +1350,10 @@ void runEventLoop(const std::shared_ptr<Session>& session)
)) {
break;
}
// Only a failed loadfile may flip the session status: a
// rejected seek/aid/speed reply on a live stream must not
// surface the error UI while playback keeps running.
if (event->error < 0) {
session->snapshot.status = SessionStatus::Error;
session->snapshot.error = mpv_error_string(event->error);
}
break;
@@ -1862,20 +1893,7 @@ Napi::Value LoadPlayback(const Napi::CallbackInfo& info)
command.format = MPV_FORMAT_NODE_ARRAY;
command.u.list = &commandList;
const int commandResult = mpv_command_node_async(
session->handle,
nextAsyncRequestId(),
&command
);
if (commandResult < 0) {
updateSessionError(session, mpv_error_string(commandResult));
throw Napi::Error::New(
env,
std::string("Failed to load playback: ") +
mpv_error_string(commandResult)
);
}
const uint64_t loadPlaybackRequestId = nextAsyncRequestId();
{
std::lock_guard<std::mutex> lock(session->mutex);
session->snapshot.streamUrl = streamUrl;
@@ -1885,6 +1903,25 @@ Napi::Value LoadPlayback(const Napi::CallbackInfo& info)
session->snapshot.recordingTargetPath.clear();
session->snapshot.recordingStartedAt.clear();
session->snapshot.recordingError.clear();
session->pendingPlaybackLoadRequestId = loadPlaybackRequestId;
}
const int commandResult = mpv_command_node_async(
session->handle,
loadPlaybackRequestId,
&command
);
if (commandResult < 0) {
{
std::lock_guard<std::mutex> lock(session->mutex);
session->pendingPlaybackLoadRequestId = 0;
}
updateSessionError(session, mpv_error_string(commandResult));
throw Napi::Error::New(
env,
std::string("Failed to load playback: ") +
mpv_error_string(commandResult)
);
}
return env.Undefined();
@@ -145,6 +145,7 @@ struct Session {
#ifdef __linux__
pid_t mpvProcessId = -1;
std::string mpvIpcSocketPath;
int64_t mpvTrackListCount = -1;
#endif
};
@@ -873,6 +874,71 @@ bool sendLinuxMpvCommand(
return transactLinuxMpvIpc(socketPath, command, response);
}
std::optional<int64_t> queryLinuxMpvInteger(
const std::string& socketPath,
const std::string& propertyName)
{
const auto value = queryLinuxMpvString(socketPath, propertyName);
if (!value || value->empty()) {
return std::nullopt;
}
char* end = nullptr;
const long long parsed = std::strtoll(value->c_str(), &end, 10);
if (!end || *end != '\0') {
return std::nullopt;
}
return static_cast<int64_t>(parsed);
}
// The hand-rolled IPC reply parser only understands scalar `data` values, so
// the audio menu is built from `track-list/N/*` sub-properties instead of the
// nested `track-list` node. Callers gate this behind a `track-list/count`
// change: one scalar query per poll tick, a full walk only when tracks
// appear or disappear.
std::vector<AudioTrack> queryLinuxMpvAudioTracks(
const std::string& socketPath,
int64_t trackCount)
{
std::vector<AudioTrack> tracks;
for (int64_t index = 0; index < trackCount; index += 1) {
const std::string prefix =
"track-list/" + std::to_string(index) + "/";
const auto type = queryLinuxMpvString(socketPath, prefix + "type");
if (!type || *type != "audio") {
continue;
}
const auto id = queryLinuxMpvInteger(socketPath, prefix + "id");
if (!id) {
continue;
}
AudioTrack track;
track.id = *id;
if (const auto title =
queryLinuxMpvString(socketPath, prefix + "title")) {
track.title = *title;
}
if (const auto language =
queryLinuxMpvString(socketPath, prefix + "lang")) {
track.language = *language;
}
if (const auto defaultFlag =
queryLinuxMpvBoolean(socketPath, prefix + "default")) {
track.defaultTrack = *defaultFlag;
}
if (const auto forcedFlag =
queryLinuxMpvBoolean(socketPath, prefix + "forced")) {
track.forced = *forcedFlag;
}
tracks.push_back(track);
}
return tracks;
}
void refreshLinuxMpvSnapshot(const std::shared_ptr<Session>& session)
{
updateLinuxProcessState(session);
@@ -896,6 +962,20 @@ void refreshLinuxMpvSnapshot(const std::shared_ptr<Session>& session)
const auto volume = queryLinuxMpvNumber(socketPath, "volume");
const auto paused = queryLinuxMpvBoolean(socketPath, "pause");
const auto path = queryLinuxMpvString(socketPath, "path");
const auto trackCount =
queryLinuxMpvInteger(socketPath, "track-list/count");
const auto selectedAudioTrackId = queryLinuxMpvInteger(socketPath, "aid");
int64_t cachedTrackCount = -1;
{
std::lock_guard<std::mutex> lock(session->mutex);
cachedTrackCount = session->mpvTrackListCount;
}
std::optional<std::vector<AudioTrack>> refreshedAudioTracks;
if (trackCount && *trackCount != cachedTrackCount) {
refreshedAudioTracks =
queryLinuxMpvAudioTracks(socketPath, *trackCount);
}
std::lock_guard<std::mutex> lock(session->mutex);
if (
@@ -923,6 +1003,18 @@ void refreshLinuxMpvSnapshot(const std::shared_ptr<Session>& session)
if (path && !path->empty()) {
session->snapshot.streamUrl = *path;
}
if (trackCount && refreshedAudioTracks) {
session->mpvTrackListCount = *trackCount;
session->snapshot.audioTracks = std::move(*refreshedAudioTracks);
}
// `aid` reads back as a number when a track is selected and as a
// non-numeric value ("no"/false) when audio is disabled.
session->snapshot.selectedAudioTrackId =
selectedAudioTrackId ? *selectedAudioTrackId : -1;
for (auto& track : session->snapshot.audioTracks) {
track.selected =
track.id == session->snapshot.selectedAudioTrackId;
}
}
void runLinuxProcessPollLoop(std::shared_ptr<Session> session)
@@ -1030,6 +1122,7 @@ void loadLinuxProcessPlayback(
std::lock_guard<std::mutex> lock(session->mutex);
session->mpvProcessId = processId;
session->mpvIpcSocketPath = ipcSocketPath;
session->mpvTrackListCount = -1;
session->snapshot.positionSeconds = std::max(0.0, startTime);
session->snapshot.durationSeconds = -1.0;
session->snapshot.streamUrl = streamUrl;
@@ -217,6 +217,42 @@ describe('Embedded MPV native source recording invariants', () => {
);
});
it('keeps macOS non-load async MPV command failures non-fatal', () => {
expect(nativeSource).toContain('pendingPlaybackLoadRequestId');
expect(nativeSource).toContain('reconcilePlaybackLoadReply');
// Only the loadfile/recording reconcilers may flip the session
// status; a rejected seek/aid/speed reply on a live stream must not
// surface the error UI while playback keeps running.
const commandReplyCase = eventCase(
nativeSource,
'MPV_EVENT_COMMAND_REPLY',
'MPV_EVENT_SHUTDOWN'
);
expect(commandReplyCase).toContain('reconcilePlaybackLoadReply');
expect(commandReplyCase).toContain('reconcileRecordingPropertyReply');
expect(commandReplyCase).not.toContain('SessionStatus::Error');
});
it('populates the Linux audio track menu from scalar track-list sub-properties', () => {
expect(widCommonSource).toContain(
'queryLinuxMpvInteger(socketPath, "track-list/count")'
);
expect(widCommonSource).toContain(
'"track-list/" + std::to_string(index) + "/"'
);
expect(widCommonSource).toContain('queryLinuxMpvAudioTracks');
expect(widCommonSource).toContain(
'queryLinuxMpvInteger(socketPath, "aid")'
);
// Full track walks only happen when the track count changes; the
// per-tick cost stays at two extra scalar queries.
expect(widCommonSource).toContain(
'if (trackCount && *trackCount != cachedTrackCount) {'
);
expect(widCommonSource).toContain('session->mpvTrackListCount = -1;');
});
it('clears transient Windows/Linux MPV operation errors after healthy playback states', () => {
const fileLoadedCaseStart = widCommonSource.indexOf(
'case MPV_EVENT_FILE_LOADED:'
@@ -253,6 +253,24 @@ describe('EmbeddedMpvNativeService power blocker', () => {
}
});
it('suppresses session updates while the snapshot content is unchanged', () => {
jest.useFakeTimers();
startSession('s1', snapshot('playing'));
// Identical snapshot on every poll tick: only the refresh timestamp
// differs, which must not count as a change.
addon.getSessionSnapshot.mockImplementation(() => snapshot('playing'));
mainWindowSendMock.mockClear();
jest.advanceTimersByTime(2000);
expect(mainWindowSendMock).not.toHaveBeenCalled();
addon.getSessionSnapshot.mockImplementation(() =>
snapshot('playing', { positionSeconds: 1 })
);
jest.advanceTimersByTime(500);
expect(mainWindowSendMock).toHaveBeenCalledTimes(1);
});
it('acquires a single prevent-display-sleep blocker once a session is playing', () => {
startSession('s1', snapshot('loading'));
@@ -420,6 +438,37 @@ describe('EmbeddedMpvNativeService power blocker', () => {
});
});
it.each([
['Flatpak', 'FLATPAK_ID', 'com.fourgray.iptvnator'],
['Snap', 'SNAP', '/snap/iptvnator/1'],
])(
'explains that %s sandboxes cannot use a system mpv instead of asking to install it',
(_label, envKey, envValue) => {
Object.defineProperty(process, 'platform', {
value: 'linux',
});
delete process.env.WAYLAND_DISPLAY;
const originalValue = process.env[envKey];
process.env[envKey] = envValue;
mockSpawnSync.mockReturnValueOnce({
status: null,
error: Object.assign(new Error('not found'), {
code: 'ENOENT',
}),
});
try {
const support = service.getSupport();
expect(support.supported).toBe(false);
expect(support.reason).toContain('sandboxed Flatpak/Snap');
expect(support.reason).not.toContain('Install the mpv package');
} finally {
restoreEnv(envKey, originalValue);
}
}
);
it('caches the Linux mpv executable probe result', () => {
Object.defineProperty(process, 'platform', {
value: 'linux',
@@ -604,7 +604,10 @@ export class EmbeddedMpvNativeService {
session.streamUrl = payload.streamUrl;
session.updatedAt = payload.updatedAt;
session.lastStatus = payload.status;
const nextPayloadKey = JSON.stringify(payload);
// The refresh timestamp must not participate in the diff key,
// otherwise every poll tick looks like a change and the renderer
// receives an IPC update every 500 ms even while paused.
const nextPayloadKey = JSON.stringify({ ...payload, updatedAt: '' });
if (session.lastPayloadKey !== nextPayloadKey) {
session.lastPayloadKey = nextPayloadKey;
this.sendSessionUpdate(payload);
@@ -803,12 +806,20 @@ export class EmbeddedMpvNativeService {
timeout: 3000,
});
this.cachedLinuxMpvExecutableReason =
result.status === 0
? null
: 'Embedded MPV on Linux requires the mpv executable on PATH. Install the mpv package for your distribution and restart IPTVnator.';
result.status === 0 ? null : this.getLinuxMpvMissingMessage();
return this.cachedLinuxMpvExecutableReason;
}
private getLinuxMpvMissingMessage(): string {
// Flatpak/Snap sandboxes cannot see a host-installed mpv, so telling
// the user to install it would send them down a dead end.
if (process.env.FLATPAK_ID || process.env.SNAP) {
return 'Embedded MPV is not available in sandboxed Flatpak/Snap packages because they cannot access a system mpv executable. Use the built-in player, or install the .deb/.rpm/AppImage package to enable Embedded MPV.';
}
return 'Embedded MPV on Linux requires the mpv executable on PATH. Install the mpv package for your distribution and restart IPTVnator.';
}
private isInvalidLinuxWaylandWindowHandle(windowHandle: Buffer): boolean {
if (
process.platform !== 'linux' ||
+15
View File
@@ -33,6 +33,21 @@ import { environment } from './environments/environment';
app.setName('iptvnator');
// Packaged Linux launchers force X11 via the .desktop entry
// (electron-builder `executableArgs`), but direct binary/AppImage launches
// from a terminal bypass that entry. Embedded MPV supports X11/XWayland
// only, and its support probe requires the ozone switch to be present, so
// mirror the launcher behavior here. Explicit user intent wins: both an
// --ozone-platform switch and the ELECTRON_OZONE_PLATFORM_HINT env var
// suppress the fallback.
if (
process.platform === 'linux' &&
!app.commandLine.hasSwitch('ozone-platform') &&
!process.env.ELECTRON_OZONE_PLATFORM_HINT
) {
app.commandLine.appendSwitch('ozone-platform', 'x11');
}
const electronUserDataPath = getElectronUserDataPath();
if (electronUserDataPath) {
@@ -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 { ResolvedPortalPlayback } from '@iptvnator/shared/interfaces';
import {
EmbeddedMpvOverlayVisibilityService,
@@ -31,7 +32,7 @@ describe('EmbeddedMpvPlayerComponent recording status message', () => {
} as unknown as typeof window.electron;
await TestBed.configureTestingModule({
imports: [EmbeddedMpvPlayerHostComponent],
imports: [EmbeddedMpvPlayerHostComponent, TranslateModule.forRoot()],
providers: [
{
provide: EmbeddedMpvOverlayVisibilityService,
@@ -44,6 +45,17 @@ describe('EmbeddedMpvPlayerComponent recording status message', () => {
})
.compileComponents();
const translate = TestBed.inject(TranslateService);
translate.setTranslation('en', {
EMBEDDED_MPV: {
PLAYER: {
SAVED_TO: 'Saved to {{path}}',
RECORDING_SAVED: 'Recording saved',
},
},
});
translate.use('en');
fixture = TestBed.createComponent(EmbeddedMpvPlayerHostComponent);
fixture.detectChanges();
+43
View File
@@ -428,6 +428,49 @@
"UNMUTE": "إلغاء كتم الصوت",
"VOLUME": "مستوى الصوت"
},
"EMBEDDED_MPV": {
"PLAYER": {
"RETRY": "إعادة المحاولة",
"STALLED": "يستغرق بدء هذا البث وقتًا أطول من المتوقع.",
"PLAYBACK_FAILED": "فشل التشغيل عبر MPV المضمّن.",
"CHECKING_SUPPORT": "جارٍ التحقق من دعم MPV المضمّن...",
"NOT_AVAILABLE": "MPV المضمّن غير متاح في هذه البيئة.",
"LOADING_STREAM": "جارٍ تحميل البث…",
"PLAY": "تشغيل",
"PAUSE": "إيقاف مؤقت",
"PREVIOUS_EPISODE": "الحلقة السابقة",
"NEXT_EPISODE": "الحلقة التالية",
"BACK_10_SECONDS": "رجوع 10 ثوانٍ",
"FORWARD_10_SECONDS": "تقديم 10 ثوانٍ",
"LIVE_STREAM": "بث مباشر",
"MUTE": "كتم الصوت",
"UNMUTE": "إلغاء كتم الصوت",
"MUTED": "الصوت مكتوم",
"VOLUME": "مستوى الصوت",
"VOLUME_LABEL": "مستوى الصوت {{percent}}%",
"AUDIO_TRACKS": "مسارات الصوت",
"SUBTITLES": "الترجمات",
"SUBTITLES_OFF": "إيقاف",
"TRACK_DEFAULT": "افتراضي",
"AUDIO_TRACK_FALLBACK": "صوت {{index}}",
"SUBTITLE_TRACK_FALLBACK": "ترجمة {{index}}",
"PLAYBACK_SPEED": "سرعة التشغيل",
"SPEED_TOOLTIP": "السرعة: {{speed}}",
"ASPECT_RATIO": "نسبة العرض إلى الارتفاع",
"ASPECT_TOOLTIP": "النسبة: {{aspect}}",
"ASPECT_DEFAULT": "افتراضي",
"START_RECORDING": "بدء التسجيل",
"STOP_RECORDING": "إيقاف التسجيل",
"RECORDING": "جارٍ التسجيل",
"RECORDING_SAVED": "تم حفظ التسجيل",
"RECORDING_FAILED": "فشل التسجيل",
"RECORDING_FAILED_TO_START": "فشل بدء التسجيل.",
"RECORDING_FAILED_TO_STOP": "فشل إيقاف التسجيل.",
"SAVED_TO": "تم الحفظ في {{path}}",
"ENTER_FULLSCREEN": "ملء الشاشة",
"EXIT_FULLSCREEN": "الخروج من ملء الشاشة"
}
},
"PLAYBACK_DIAGNOSTICS": {
"UNSUPPORTED_CONTAINER": {
"TITLE": "This stream container is likely unsupported by the browser player",
+43
View File
@@ -428,6 +428,49 @@
"UNMUTE": "رجّع الصوت",
"VOLUME": "الصوت"
},
"EMBEDDED_MPV": {
"PLAYER": {
"RETRY": "حاول مرة أخرى",
"STALLED": "هاد البث كياخد وقت كثر من المتوقع باش يبدا.",
"PLAYBACK_FAILED": "فشل التشغيل بـ MPV المدمج.",
"CHECKING_SUPPORT": "جاري التحقق من دعم MPV المدمج...",
"NOT_AVAILABLE": "MPV المدمج ما متوفرش فهاد البيئة.",
"LOADING_STREAM": "جاري تشريج البث…",
"PLAY": "شغل",
"PAUSE": "وقّف",
"PREVIOUS_EPISODE": "الحلقة السابقة",
"NEXT_EPISODE": "الحلقة الجاية",
"BACK_10_SECONDS": "رجع 10 ثواني",
"FORWARD_10_SECONDS": "قدّم 10 ثواني",
"LIVE_STREAM": "بث مباشر",
"MUTE": "كتم الصوت",
"UNMUTE": "رجّع الصوت",
"MUTED": "الصوت مكتوم",
"VOLUME": "الصوت",
"VOLUME_LABEL": "الصوت {{percent}}%",
"AUDIO_TRACKS": "مسارات الصوت",
"SUBTITLES": "الترجمة",
"SUBTITLES_OFF": "معطلة",
"TRACK_DEFAULT": "افتراضي",
"AUDIO_TRACK_FALLBACK": "صوت {{index}}",
"SUBTITLE_TRACK_FALLBACK": "ترجمة {{index}}",
"PLAYBACK_SPEED": "سرعة التشغيل",
"SPEED_TOOLTIP": "السرعة: {{speed}}",
"ASPECT_RATIO": "نسبة الأبعاد",
"ASPECT_TOOLTIP": "الأبعاد: {{aspect}}",
"ASPECT_DEFAULT": "افتراضي",
"START_RECORDING": "بدا التسجيل",
"STOP_RECORDING": "وقّف التسجيل",
"RECORDING": "جاري التسجيل",
"RECORDING_SAVED": "التسجيل تحفظ",
"RECORDING_FAILED": "فشل التسجيل",
"RECORDING_FAILED_TO_START": "التسجيل ما قدرش يبدا.",
"RECORDING_FAILED_TO_STOP": "التسجيل ما قدرش يتوقف.",
"SAVED_TO": "تحفظ في {{path}}",
"ENTER_FULLSCREEN": "دخل لملء الشاشة",
"EXIT_FULLSCREEN": "خرج من ملء الشاشة"
}
},
"PLAYBACK_DIAGNOSTICS": {
"UNSUPPORTED_CONTAINER": {
"TITLE": "This stream container is likely unsupported by the browser player",
+43
View File
@@ -428,6 +428,49 @@
"UNMUTE": "Уключыць гук",
"VOLUME": "Гучнасць"
},
"EMBEDDED_MPV": {
"PLAYER": {
"RETRY": "Паўтарыць",
"STALLED": "Гэты паток запускаецца даўжэй, чым чакалася.",
"PLAYBACK_FAILED": "Не ўдалося прайграць праз убудаваны MPV.",
"CHECKING_SUPPORT": "Праверка падтрымкі ўбудаванага MPV...",
"NOT_AVAILABLE": "Убудаваны MPV недаступны ў гэтым асяроддзі.",
"LOADING_STREAM": "Загрузка патоку…",
"PLAY": "Прайграць",
"PAUSE": "Паўза",
"PREVIOUS_EPISODE": "Папярэдні эпізод",
"NEXT_EPISODE": "Наступны эпізод",
"BACK_10_SECONDS": "Назад на 10 секунд",
"FORWARD_10_SECONDS": "Наперад на 10 секунд",
"LIVE_STREAM": "Прамая трансляцыя",
"MUTE": "Адключыць гук",
"UNMUTE": "Уключыць гук",
"MUTED": "Гук адключаны",
"VOLUME": "Гучнасць",
"VOLUME_LABEL": "Гучнасць {{percent}}%",
"AUDIO_TRACKS": "Аўдыядарожкі",
"SUBTITLES": "Субтытры",
"SUBTITLES_OFF": "Выкл.",
"TRACK_DEFAULT": "Па змаўчанні",
"AUDIO_TRACK_FALLBACK": "Аўдыя {{index}}",
"SUBTITLE_TRACK_FALLBACK": "Субтытры {{index}}",
"PLAYBACK_SPEED": "Хуткасць прайгравання",
"SPEED_TOOLTIP": "Хуткасць: {{speed}}",
"ASPECT_RATIO": "Суадносіны бакоў",
"ASPECT_TOOLTIP": "Суадносіны: {{aspect}}",
"ASPECT_DEFAULT": "Па змаўчанні",
"START_RECORDING": "Пачаць запіс",
"STOP_RECORDING": "Спыніць запіс",
"RECORDING": "Ідзе запіс",
"RECORDING_SAVED": "Запіс захаваны",
"RECORDING_FAILED": "Памылка запісу",
"RECORDING_FAILED_TO_START": "Не ўдалося пачаць запіс.",
"RECORDING_FAILED_TO_STOP": "Не ўдалося спыніць запіс.",
"SAVED_TO": "Захавана ў {{path}}",
"ENTER_FULLSCREEN": "Увайсці ў поўнаэкранны рэжым",
"EXIT_FULLSCREEN": "Выйсці з поўнаэкраннага рэжыму"
}
},
"PLAYBACK_DIAGNOSTICS": {
"UNSUPPORTED_CONTAINER": {
"TITLE": "This stream container is likely unsupported by the browser player",
+43
View File
@@ -428,6 +428,49 @@
"UNMUTE": "Stummschaltung aufheben",
"VOLUME": "Lautstärke"
},
"EMBEDDED_MPV": {
"PLAYER": {
"RETRY": "Erneut versuchen",
"STALLED": "Der Start dieses Streams dauert länger als erwartet.",
"PLAYBACK_FAILED": "Die Wiedergabe mit eingebettetem MPV ist fehlgeschlagen.",
"CHECKING_SUPPORT": "Unterstützung für eingebettetes MPV wird geprüft...",
"NOT_AVAILABLE": "Eingebettetes MPV ist in dieser Umgebung nicht verfügbar.",
"LOADING_STREAM": "Stream wird geladen…",
"PLAY": "Wiedergabe",
"PAUSE": "Pause",
"PREVIOUS_EPISODE": "Vorherige Folge",
"NEXT_EPISODE": "Nächste Folge",
"BACK_10_SECONDS": "10 Sekunden zurück",
"FORWARD_10_SECONDS": "10 Sekunden vor",
"LIVE_STREAM": "Live-Stream",
"MUTE": "Stummschalten",
"UNMUTE": "Stummschaltung aufheben",
"MUTED": "Stummgeschaltet",
"VOLUME": "Lautstärke",
"VOLUME_LABEL": "Lautstärke {{percent}} %",
"AUDIO_TRACKS": "Tonspuren",
"SUBTITLES": "Untertitel",
"SUBTITLES_OFF": "Aus",
"TRACK_DEFAULT": "Standard",
"AUDIO_TRACK_FALLBACK": "Audio {{index}}",
"SUBTITLE_TRACK_FALLBACK": "Untertitel {{index}}",
"PLAYBACK_SPEED": "Wiedergabegeschwindigkeit",
"SPEED_TOOLTIP": "Geschwindigkeit: {{speed}}",
"ASPECT_RATIO": "Seitenverhältnis",
"ASPECT_TOOLTIP": "Seitenverhältnis: {{aspect}}",
"ASPECT_DEFAULT": "Standard",
"START_RECORDING": "Aufnahme starten",
"STOP_RECORDING": "Aufnahme stoppen",
"RECORDING": "Aufnahme",
"RECORDING_SAVED": "Aufnahme gespeichert",
"RECORDING_FAILED": "Aufnahme fehlgeschlagen",
"RECORDING_FAILED_TO_START": "Aufnahme konnte nicht gestartet werden.",
"RECORDING_FAILED_TO_STOP": "Aufnahme konnte nicht gestoppt werden.",
"SAVED_TO": "Gespeichert unter {{path}}",
"ENTER_FULLSCREEN": "Vollbild aktivieren",
"EXIT_FULLSCREEN": "Vollbild beenden"
}
},
"PLAYBACK_DIAGNOSTICS": {
"UNSUPPORTED_CONTAINER": {
"TITLE": "This stream container is likely unsupported by the browser player",
+43
View File
@@ -428,6 +428,49 @@
"UNMUTE": "Κατάργηση σίγασης",
"VOLUME": "Ένταση"
},
"EMBEDDED_MPV": {
"PLAYER": {
"RETRY": "Επανάληψη",
"STALLED": "Η έναρξη αυτής της ροής καθυστερεί περισσότερο από το αναμενόμενο.",
"PLAYBACK_FAILED": "Η αναπαραγωγή με το ενσωματωμένο MPV απέτυχε.",
"CHECKING_SUPPORT": "Έλεγχος υποστήριξης ενσωματωμένου MPV...",
"NOT_AVAILABLE": "Το ενσωματωμένο MPV δεν είναι διαθέσιμο σε αυτό το περιβάλλον.",
"LOADING_STREAM": "Φόρτωση ροής…",
"PLAY": "Αναπαραγωγή",
"PAUSE": "Παύση",
"PREVIOUS_EPISODE": "Προηγούμενο επεισόδιο",
"NEXT_EPISODE": "Επόμενο επεισόδιο",
"BACK_10_SECONDS": "Πίσω 10 δευτερόλεπτα",
"FORWARD_10_SECONDS": "Μπροστά 10 δευτερόλεπτα",
"LIVE_STREAM": "Ζωντανή ροή",
"MUTE": "Σίγαση",
"UNMUTE": "Κατάργηση σίγασης",
"MUTED": "Σε σίγαση",
"VOLUME": "Ένταση",
"VOLUME_LABEL": "Ένταση {{percent}}%",
"AUDIO_TRACKS": "Κομμάτια ήχου",
"SUBTITLES": "Υπότιτλοι",
"SUBTITLES_OFF": "Ανενεργοί",
"TRACK_DEFAULT": "Προεπιλογή",
"AUDIO_TRACK_FALLBACK": "Ήχος {{index}}",
"SUBTITLE_TRACK_FALLBACK": "Υπότιτλος {{index}}",
"PLAYBACK_SPEED": "Ταχύτητα αναπαραγωγής",
"SPEED_TOOLTIP": "Ταχύτητα: {{speed}}",
"ASPECT_RATIO": "Αναλογία εικόνας",
"ASPECT_TOOLTIP": "Αναλογία: {{aspect}}",
"ASPECT_DEFAULT": "Προεπιλογή",
"START_RECORDING": "Έναρξη εγγραφής",
"STOP_RECORDING": "Διακοπή εγγραφής",
"RECORDING": "Εγγραφή",
"RECORDING_SAVED": "Η εγγραφή αποθηκεύτηκε",
"RECORDING_FAILED": "Η εγγραφή απέτυχε",
"RECORDING_FAILED_TO_START": "Η έναρξη της εγγραφής απέτυχε.",
"RECORDING_FAILED_TO_STOP": "Η διακοπή της εγγραφής απέτυχε.",
"SAVED_TO": "Αποθηκεύτηκε στο {{path}}",
"ENTER_FULLSCREEN": "Μετάβαση σε πλήρη οθόνη",
"EXIT_FULLSCREEN": "Έξοδος από πλήρη οθόνη"
}
},
"PLAYBACK_DIAGNOSTICS": {
"UNSUPPORTED_CONTAINER": {
"TITLE": "This stream container is likely unsupported by the browser player",
+43
View File
@@ -428,6 +428,49 @@
"UNMUTE": "Unmute",
"VOLUME": "Volume"
},
"EMBEDDED_MPV": {
"PLAYER": {
"RETRY": "Retry",
"STALLED": "This stream is taking longer than expected to start.",
"PLAYBACK_FAILED": "Embedded MPV playback failed.",
"CHECKING_SUPPORT": "Checking embedded MPV support...",
"NOT_AVAILABLE": "Embedded MPV is not available in this environment.",
"LOADING_STREAM": "Loading stream…",
"PLAY": "Play",
"PAUSE": "Pause",
"PREVIOUS_EPISODE": "Previous episode",
"NEXT_EPISODE": "Next episode",
"BACK_10_SECONDS": "Back 10 seconds",
"FORWARD_10_SECONDS": "Forward 10 seconds",
"LIVE_STREAM": "Live stream",
"MUTE": "Mute",
"UNMUTE": "Unmute",
"MUTED": "Muted",
"VOLUME": "Volume",
"VOLUME_LABEL": "Volume {{percent}}%",
"AUDIO_TRACKS": "Audio tracks",
"SUBTITLES": "Subtitles",
"SUBTITLES_OFF": "Off",
"TRACK_DEFAULT": "Default",
"AUDIO_TRACK_FALLBACK": "Audio {{index}}",
"SUBTITLE_TRACK_FALLBACK": "Subtitle {{index}}",
"PLAYBACK_SPEED": "Playback speed",
"SPEED_TOOLTIP": "Speed: {{speed}}",
"ASPECT_RATIO": "Aspect ratio",
"ASPECT_TOOLTIP": "Aspect: {{aspect}}",
"ASPECT_DEFAULT": "Default",
"START_RECORDING": "Start recording",
"STOP_RECORDING": "Stop recording",
"RECORDING": "Recording",
"RECORDING_SAVED": "Recording saved",
"RECORDING_FAILED": "Recording failed",
"RECORDING_FAILED_TO_START": "Recording failed to start.",
"RECORDING_FAILED_TO_STOP": "Recording failed to stop.",
"SAVED_TO": "Saved to {{path}}",
"ENTER_FULLSCREEN": "Enter fullscreen",
"EXIT_FULLSCREEN": "Exit fullscreen"
}
},
"PLAYBACK_DIAGNOSTICS": {
"UNSUPPORTED_CONTAINER": {
"TITLE": "This stream container is likely unsupported by the browser player",
+43
View File
@@ -428,6 +428,49 @@
"UNMUTE": "Activar sonido",
"VOLUME": "Volumen"
},
"EMBEDDED_MPV": {
"PLAYER": {
"RETRY": "Reintentar",
"STALLED": "Este stream está tardando más de lo esperado en iniciarse.",
"PLAYBACK_FAILED": "Falló la reproducción de MPV integrado.",
"CHECKING_SUPPORT": "Comprobando compatibilidad con MPV integrado...",
"NOT_AVAILABLE": "MPV integrado no está disponible en este entorno.",
"LOADING_STREAM": "Cargando stream…",
"PLAY": "Reproducir",
"PAUSE": "Pausar",
"PREVIOUS_EPISODE": "Episodio anterior",
"NEXT_EPISODE": "Siguiente episodio",
"BACK_10_SECONDS": "Retroceder 10 segundos",
"FORWARD_10_SECONDS": "Avanzar 10 segundos",
"LIVE_STREAM": "Transmisión en vivo",
"MUTE": "Silenciar",
"UNMUTE": "Activar sonido",
"MUTED": "Silenciado",
"VOLUME": "Volumen",
"VOLUME_LABEL": "Volumen {{percent}}%",
"AUDIO_TRACKS": "Pistas de audio",
"SUBTITLES": "Subtítulos",
"SUBTITLES_OFF": "Desactivados",
"TRACK_DEFAULT": "Predeterminada",
"AUDIO_TRACK_FALLBACK": "Audio {{index}}",
"SUBTITLE_TRACK_FALLBACK": "Subtítulo {{index}}",
"PLAYBACK_SPEED": "Velocidad de reproducción",
"SPEED_TOOLTIP": "Velocidad: {{speed}}",
"ASPECT_RATIO": "Relación de aspecto",
"ASPECT_TOOLTIP": "Aspecto: {{aspect}}",
"ASPECT_DEFAULT": "Predeterminada",
"START_RECORDING": "Iniciar grabación",
"STOP_RECORDING": "Detener grabación",
"RECORDING": "Grabando",
"RECORDING_SAVED": "Grabación guardada",
"RECORDING_FAILED": "Error de grabación",
"RECORDING_FAILED_TO_START": "No se pudo iniciar la grabación.",
"RECORDING_FAILED_TO_STOP": "No se pudo detener la grabación.",
"SAVED_TO": "Guardada en {{path}}",
"ENTER_FULLSCREEN": "Entrar en pantalla completa",
"EXIT_FULLSCREEN": "Salir de pantalla completa"
}
},
"PLAYBACK_DIAGNOSTICS": {
"UNSUPPORTED_CONTAINER": {
"TITLE": "This stream container is likely unsupported by the browser player",
+43
View File
@@ -428,6 +428,49 @@
"UNMUTE": "Activer le son",
"VOLUME": "Volume"
},
"EMBEDDED_MPV": {
"PLAYER": {
"RETRY": "Réessayer",
"STALLED": "Ce flux met plus de temps que prévu à démarrer.",
"PLAYBACK_FAILED": "Échec de la lecture avec MPV intégré.",
"CHECKING_SUPPORT": "Vérification de la prise en charge de MPV intégré...",
"NOT_AVAILABLE": "MPV intégré n'est pas disponible dans cet environnement.",
"LOADING_STREAM": "Chargement du flux…",
"PLAY": "Lecture",
"PAUSE": "Pause",
"PREVIOUS_EPISODE": "Épisode précédent",
"NEXT_EPISODE": "Épisode suivant",
"BACK_10_SECONDS": "Reculer de 10 secondes",
"FORWARD_10_SECONDS": "Avancer de 10 secondes",
"LIVE_STREAM": "Diffusion en direct",
"MUTE": "Muet",
"UNMUTE": "Activer le son",
"MUTED": "Son coupé",
"VOLUME": "Volume",
"VOLUME_LABEL": "Volume {{percent}} %",
"AUDIO_TRACKS": "Pistes audio",
"SUBTITLES": "Sous-titres",
"SUBTITLES_OFF": "Désactivés",
"TRACK_DEFAULT": "Par défaut",
"AUDIO_TRACK_FALLBACK": "Audio {{index}}",
"SUBTITLE_TRACK_FALLBACK": "Sous-titre {{index}}",
"PLAYBACK_SPEED": "Vitesse de lecture",
"SPEED_TOOLTIP": "Vitesse : {{speed}}",
"ASPECT_RATIO": "Format d'image",
"ASPECT_TOOLTIP": "Format : {{aspect}}",
"ASPECT_DEFAULT": "Par défaut",
"START_RECORDING": "Démarrer l'enregistrement",
"STOP_RECORDING": "Arrêter l'enregistrement",
"RECORDING": "Enregistrement",
"RECORDING_SAVED": "Enregistrement sauvegardé",
"RECORDING_FAILED": "Échec de l'enregistrement",
"RECORDING_FAILED_TO_START": "L'enregistrement n'a pas pu démarrer.",
"RECORDING_FAILED_TO_STOP": "L'enregistrement n'a pas pu s'arrêter.",
"SAVED_TO": "Enregistré dans {{path}}",
"ENTER_FULLSCREEN": "Passer en plein écran",
"EXIT_FULLSCREEN": "Quitter le plein écran"
}
},
"PLAYBACK_DIAGNOSTICS": {
"UNSUPPORTED_CONTAINER": {
"TITLE": "This stream container is likely unsupported by the browser player",
+43
View File
@@ -428,6 +428,49 @@
"UNMUTE": "Attiva audio",
"VOLUME": "Volume"
},
"EMBEDDED_MPV": {
"PLAYER": {
"RETRY": "Riprova",
"STALLED": "L'avvio di questo stream sta richiedendo più tempo del previsto.",
"PLAYBACK_FAILED": "Riproduzione con MPV integrato non riuscita.",
"CHECKING_SUPPORT": "Verifica del supporto per MPV integrato...",
"NOT_AVAILABLE": "MPV integrato non è disponibile in questo ambiente.",
"LOADING_STREAM": "Caricamento stream…",
"PLAY": "Riproduci",
"PAUSE": "Pausa",
"PREVIOUS_EPISODE": "Episodio precedente",
"NEXT_EPISODE": "Episodio successivo",
"BACK_10_SECONDS": "Indietro di 10 secondi",
"FORWARD_10_SECONDS": "Avanti di 10 secondi",
"LIVE_STREAM": "Stream in diretta",
"MUTE": "Disattiva audio",
"UNMUTE": "Attiva audio",
"MUTED": "Audio disattivato",
"VOLUME": "Volume",
"VOLUME_LABEL": "Volume {{percent}}%",
"AUDIO_TRACKS": "Tracce audio",
"SUBTITLES": "Sottotitoli",
"SUBTITLES_OFF": "Disattivati",
"TRACK_DEFAULT": "Predefinita",
"AUDIO_TRACK_FALLBACK": "Audio {{index}}",
"SUBTITLE_TRACK_FALLBACK": "Sottotitoli {{index}}",
"PLAYBACK_SPEED": "Velocità di riproduzione",
"SPEED_TOOLTIP": "Velocità: {{speed}}",
"ASPECT_RATIO": "Proporzioni",
"ASPECT_TOOLTIP": "Proporzioni: {{aspect}}",
"ASPECT_DEFAULT": "Predefinite",
"START_RECORDING": "Avvia registrazione",
"STOP_RECORDING": "Interrompi registrazione",
"RECORDING": "Registrazione in corso",
"RECORDING_SAVED": "Registrazione salvata",
"RECORDING_FAILED": "Registrazione non riuscita",
"RECORDING_FAILED_TO_START": "Impossibile avviare la registrazione.",
"RECORDING_FAILED_TO_STOP": "Impossibile interrompere la registrazione.",
"SAVED_TO": "Salvato in {{path}}",
"ENTER_FULLSCREEN": "Attiva schermo intero",
"EXIT_FULLSCREEN": "Esci dallo schermo intero"
}
},
"PLAYBACK_DIAGNOSTICS": {
"UNSUPPORTED_CONTAINER": {
"TITLE": "This stream container is likely unsupported by the browser player",
+43
View File
@@ -428,6 +428,49 @@
"UNMUTE": "ミュート解除",
"VOLUME": "音量"
},
"EMBEDDED_MPV": {
"PLAYER": {
"RETRY": "再試行",
"STALLED": "このストリームの開始に予想より時間がかかっています。",
"PLAYBACK_FAILED": "組み込みMPVでの再生に失敗しました。",
"CHECKING_SUPPORT": "組み込みMPVのサポートを確認中...",
"NOT_AVAILABLE": "この環境では組み込みMPVは利用できません。",
"LOADING_STREAM": "ストリームを読み込み中…",
"PLAY": "再生",
"PAUSE": "一時停止",
"PREVIOUS_EPISODE": "前のエピソード",
"NEXT_EPISODE": "次のエピソード",
"BACK_10_SECONDS": "10秒戻る",
"FORWARD_10_SECONDS": "10秒進む",
"LIVE_STREAM": "ライブストリーム",
"MUTE": "ミュート",
"UNMUTE": "ミュート解除",
"MUTED": "ミュート中",
"VOLUME": "音量",
"VOLUME_LABEL": "音量 {{percent}}%",
"AUDIO_TRACKS": "音声トラック",
"SUBTITLES": "字幕",
"SUBTITLES_OFF": "オフ",
"TRACK_DEFAULT": "デフォルト",
"AUDIO_TRACK_FALLBACK": "音声 {{index}}",
"SUBTITLE_TRACK_FALLBACK": "字幕 {{index}}",
"PLAYBACK_SPEED": "再生速度",
"SPEED_TOOLTIP": "速度:{{speed}}",
"ASPECT_RATIO": "アスペクト比",
"ASPECT_TOOLTIP": "アスペクト比:{{aspect}}",
"ASPECT_DEFAULT": "デフォルト",
"START_RECORDING": "録画を開始",
"STOP_RECORDING": "録画を停止",
"RECORDING": "録画中",
"RECORDING_SAVED": "録画を保存しました",
"RECORDING_FAILED": "録画に失敗しました",
"RECORDING_FAILED_TO_START": "録画を開始できませんでした。",
"RECORDING_FAILED_TO_STOP": "録画を停止できませんでした。",
"SAVED_TO": "{{path}}に保存しました",
"ENTER_FULLSCREEN": "全画面表示",
"EXIT_FULLSCREEN": "全画面表示を終了"
}
},
"PLAYBACK_DIAGNOSTICS": {
"UNSUPPORTED_CONTAINER": {
"TITLE": "This stream container is likely unsupported by the browser player",
+43
View File
@@ -428,6 +428,49 @@
"UNMUTE": "음소거 해제",
"VOLUME": "볼륨"
},
"EMBEDDED_MPV": {
"PLAYER": {
"RETRY": "다시 시도",
"STALLED": "이 스트림의 시작이 예상보다 오래 걸리고 있습니다.",
"PLAYBACK_FAILED": "내장 MPV 재생에 실패했습니다.",
"CHECKING_SUPPORT": "내장 MPV 지원을 확인하는 중...",
"NOT_AVAILABLE": "이 환경에서는 내장 MPV를 사용할 수 없습니다.",
"LOADING_STREAM": "스트림을 불러오는 중…",
"PLAY": "재생",
"PAUSE": "일시정지",
"PREVIOUS_EPISODE": "이전 에피소드",
"NEXT_EPISODE": "다음 에피소드",
"BACK_10_SECONDS": "10초 뒤로",
"FORWARD_10_SECONDS": "10초 앞으로",
"LIVE_STREAM": "라이브 스트림",
"MUTE": "음소거",
"UNMUTE": "음소거 해제",
"MUTED": "음소거됨",
"VOLUME": "볼륨",
"VOLUME_LABEL": "볼륨 {{percent}}%",
"AUDIO_TRACKS": "오디오 트랙",
"SUBTITLES": "자막",
"SUBTITLES_OFF": "끄기",
"TRACK_DEFAULT": "기본",
"AUDIO_TRACK_FALLBACK": "오디오 {{index}}",
"SUBTITLE_TRACK_FALLBACK": "자막 {{index}}",
"PLAYBACK_SPEED": "재생 속도",
"SPEED_TOOLTIP": "속도: {{speed}}",
"ASPECT_RATIO": "화면 비율",
"ASPECT_TOOLTIP": "비율: {{aspect}}",
"ASPECT_DEFAULT": "기본",
"START_RECORDING": "녹화 시작",
"STOP_RECORDING": "녹화 중지",
"RECORDING": "녹화 중",
"RECORDING_SAVED": "녹화가 저장되었습니다",
"RECORDING_FAILED": "녹화 실패",
"RECORDING_FAILED_TO_START": "녹화를 시작하지 못했습니다.",
"RECORDING_FAILED_TO_STOP": "녹화를 중지하지 못했습니다.",
"SAVED_TO": "{{path}}에 저장되었습니다",
"ENTER_FULLSCREEN": "전체 화면",
"EXIT_FULLSCREEN": "전체 화면 종료"
}
},
"PLAYBACK_DIAGNOSTICS": {
"UNSUPPORTED_CONTAINER": {
"TITLE": "This stream container is likely unsupported by the browser player",
+43
View File
@@ -428,6 +428,49 @@
"UNMUTE": "Dempen opheffen",
"VOLUME": "Volume"
},
"EMBEDDED_MPV": {
"PLAYER": {
"RETRY": "Opnieuw proberen",
"STALLED": "Deze stream doet er langer over dan verwacht om te starten.",
"PLAYBACK_FAILED": "Afspelen via Embedded MPV is mislukt.",
"CHECKING_SUPPORT": "Ondersteuning voor Embedded MPV controleren...",
"NOT_AVAILABLE": "Embedded MPV is niet beschikbaar in deze omgeving.",
"LOADING_STREAM": "Stream laden…",
"PLAY": "Afspelen",
"PAUSE": "Pauzeren",
"PREVIOUS_EPISODE": "Vorige aflevering",
"NEXT_EPISODE": "Volgende aflevering",
"BACK_10_SECONDS": "10 seconden terug",
"FORWARD_10_SECONDS": "10 seconden vooruit",
"LIVE_STREAM": "Live stream",
"MUTE": "Dempen",
"UNMUTE": "Dempen opheffen",
"MUTED": "Gedempt",
"VOLUME": "Volume",
"VOLUME_LABEL": "Volume {{percent}}%",
"AUDIO_TRACKS": "Audiosporen",
"SUBTITLES": "Ondertiteling",
"SUBTITLES_OFF": "Uit",
"TRACK_DEFAULT": "Standaard",
"AUDIO_TRACK_FALLBACK": "Audio {{index}}",
"SUBTITLE_TRACK_FALLBACK": "Ondertitel {{index}}",
"PLAYBACK_SPEED": "Afspeelsnelheid",
"SPEED_TOOLTIP": "Snelheid: {{speed}}",
"ASPECT_RATIO": "Beeldverhouding",
"ASPECT_TOOLTIP": "Beeldverhouding: {{aspect}}",
"ASPECT_DEFAULT": "Standaard",
"START_RECORDING": "Opname starten",
"STOP_RECORDING": "Opname stoppen",
"RECORDING": "Opname",
"RECORDING_SAVED": "Opname opgeslagen",
"RECORDING_FAILED": "Opname mislukt",
"RECORDING_FAILED_TO_START": "Opname kon niet worden gestart.",
"RECORDING_FAILED_TO_STOP": "Opname kon niet worden gestopt.",
"SAVED_TO": "Opgeslagen in {{path}}",
"ENTER_FULLSCREEN": "Volledig scherm openen",
"EXIT_FULLSCREEN": "Volledig scherm sluiten"
}
},
"PLAYBACK_DIAGNOSTICS": {
"UNSUPPORTED_CONTAINER": {
"TITLE": "This stream container is likely unsupported by the browser player",
+43
View File
@@ -428,6 +428,49 @@
"UNMUTE": "Wyłącz wyciszenie",
"VOLUME": "Głośność"
},
"EMBEDDED_MPV": {
"PLAYER": {
"RETRY": "Spróbuj ponownie",
"STALLED": "Uruchamianie tego strumienia trwa dłużej niż zwykle.",
"PLAYBACK_FAILED": "Odtwarzanie we wbudowanym MPV nie powiodło się.",
"CHECKING_SUPPORT": "Sprawdzanie obsługi wbudowanego MPV...",
"NOT_AVAILABLE": "Wbudowane MPV nie jest dostępne w tym środowisku.",
"LOADING_STREAM": "Ładowanie strumienia…",
"PLAY": "Odtwórz",
"PAUSE": "Wstrzymaj",
"PREVIOUS_EPISODE": "Poprzedni odcinek",
"NEXT_EPISODE": "Następny odcinek",
"BACK_10_SECONDS": "Cofnij o 10 sekund",
"FORWARD_10_SECONDS": "Do przodu o 10 sekund",
"LIVE_STREAM": "Transmisja na żywo",
"MUTE": "Wycisz",
"UNMUTE": "Wyłącz wyciszenie",
"MUTED": "Wyciszono",
"VOLUME": "Głośność",
"VOLUME_LABEL": "Głośność {{percent}}%",
"AUDIO_TRACKS": "Ścieżki audio",
"SUBTITLES": "Napisy",
"SUBTITLES_OFF": "Wyłączone",
"TRACK_DEFAULT": "Domyślna",
"AUDIO_TRACK_FALLBACK": "Audio {{index}}",
"SUBTITLE_TRACK_FALLBACK": "Napisy {{index}}",
"PLAYBACK_SPEED": "Prędkość odtwarzania",
"SPEED_TOOLTIP": "Prędkość: {{speed}}",
"ASPECT_RATIO": "Proporcje obrazu",
"ASPECT_TOOLTIP": "Proporcje: {{aspect}}",
"ASPECT_DEFAULT": "Domyślne",
"START_RECORDING": "Rozpocznij nagrywanie",
"STOP_RECORDING": "Zatrzymaj nagrywanie",
"RECORDING": "Nagrywanie",
"RECORDING_SAVED": "Nagranie zapisane",
"RECORDING_FAILED": "Nagrywanie nie powiodło się",
"RECORDING_FAILED_TO_START": "Nie udało się rozpocząć nagrywania.",
"RECORDING_FAILED_TO_STOP": "Nie udało się zatrzymać nagrywania.",
"SAVED_TO": "Zapisano w {{path}}",
"ENTER_FULLSCREEN": "Włącz pełny ekran",
"EXIT_FULLSCREEN": "Wyłącz pełny ekran"
}
},
"PLAYBACK_DIAGNOSTICS": {
"UNSUPPORTED_CONTAINER": {
"TITLE": "This stream container is likely unsupported by the browser player",
+43
View File
@@ -428,6 +428,49 @@
"UNMUTE": "Ativar som",
"VOLUME": "Volume"
},
"EMBEDDED_MPV": {
"PLAYER": {
"RETRY": "Tentar novamente",
"STALLED": "Este stream está demorando mais do que o esperado para iniciar.",
"PLAYBACK_FAILED": "Falha na reprodução do MPV integrado.",
"CHECKING_SUPPORT": "Verificando suporte ao MPV integrado...",
"NOT_AVAILABLE": "O MPV integrado não está disponível neste ambiente.",
"LOADING_STREAM": "Carregando stream…",
"PLAY": "Reproduzir",
"PAUSE": "Pausar",
"PREVIOUS_EPISODE": "Episódio anterior",
"NEXT_EPISODE": "Próximo episódio",
"BACK_10_SECONDS": "Voltar 10 segundos",
"FORWARD_10_SECONDS": "Avançar 10 segundos",
"LIVE_STREAM": "Transmissão ao vivo",
"MUTE": "Silenciar",
"UNMUTE": "Ativar som",
"MUTED": "Silenciado",
"VOLUME": "Volume",
"VOLUME_LABEL": "Volume {{percent}}%",
"AUDIO_TRACKS": "Faixas de áudio",
"SUBTITLES": "Legendas",
"SUBTITLES_OFF": "Desativadas",
"TRACK_DEFAULT": "Padrão",
"AUDIO_TRACK_FALLBACK": "Áudio {{index}}",
"SUBTITLE_TRACK_FALLBACK": "Legenda {{index}}",
"PLAYBACK_SPEED": "Velocidade de reprodução",
"SPEED_TOOLTIP": "Velocidade: {{speed}}",
"ASPECT_RATIO": "Proporção da imagem",
"ASPECT_TOOLTIP": "Proporção: {{aspect}}",
"ASPECT_DEFAULT": "Padrão",
"START_RECORDING": "Iniciar gravação",
"STOP_RECORDING": "Parar gravação",
"RECORDING": "Gravando",
"RECORDING_SAVED": "Gravação salva",
"RECORDING_FAILED": "Falha na gravação",
"RECORDING_FAILED_TO_START": "Falha ao iniciar a gravação.",
"RECORDING_FAILED_TO_STOP": "Falha ao parar a gravação.",
"SAVED_TO": "Salvo em {{path}}",
"ENTER_FULLSCREEN": "Entrar em tela cheia",
"EXIT_FULLSCREEN": "Sair da tela cheia"
}
},
"PLAYBACK_DIAGNOSTICS": {
"UNSUPPORTED_CONTAINER": {
"TITLE": "This stream container is likely unsupported by the browser player",
+43
View File
@@ -428,6 +428,49 @@
"UNMUTE": "Включить звук",
"VOLUME": "Громкость"
},
"EMBEDDED_MPV": {
"PLAYER": {
"RETRY": "Повторить",
"STALLED": "Запуск этого потока занимает больше времени, чем ожидалось.",
"PLAYBACK_FAILED": "Не удалось воспроизвести через встроенный MPV.",
"CHECKING_SUPPORT": "Проверка поддержки встроенного MPV...",
"NOT_AVAILABLE": "Встроенный MPV недоступен в этой среде.",
"LOADING_STREAM": "Загрузка потока…",
"PLAY": "Воспроизвести",
"PAUSE": "Пауза",
"PREVIOUS_EPISODE": "Предыдущий эпизод",
"NEXT_EPISODE": "Следующий эпизод",
"BACK_10_SECONDS": "Назад на 10 секунд",
"FORWARD_10_SECONDS": "Вперёд на 10 секунд",
"LIVE_STREAM": "Прямой эфир",
"MUTE": "Отключить звук",
"UNMUTE": "Включить звук",
"MUTED": "Звук отключён",
"VOLUME": "Громкость",
"VOLUME_LABEL": "Громкость {{percent}}%",
"AUDIO_TRACKS": "Аудиодорожки",
"SUBTITLES": "Субтитры",
"SUBTITLES_OFF": "Выкл.",
"TRACK_DEFAULT": "По умолчанию",
"AUDIO_TRACK_FALLBACK": "Аудио {{index}}",
"SUBTITLE_TRACK_FALLBACK": "Субтитры {{index}}",
"PLAYBACK_SPEED": "Скорость воспроизведения",
"SPEED_TOOLTIP": "Скорость: {{speed}}",
"ASPECT_RATIO": "Соотношение сторон",
"ASPECT_TOOLTIP": "Соотношение: {{aspect}}",
"ASPECT_DEFAULT": "По умолчанию",
"START_RECORDING": "Начать запись",
"STOP_RECORDING": "Остановить запись",
"RECORDING": "Идёт запись",
"RECORDING_SAVED": "Запись сохранена",
"RECORDING_FAILED": "Ошибка записи",
"RECORDING_FAILED_TO_START": "Не удалось начать запись.",
"RECORDING_FAILED_TO_STOP": "Не удалось остановить запись.",
"SAVED_TO": "Сохранено в {{path}}",
"ENTER_FULLSCREEN": "Во весь экран",
"EXIT_FULLSCREEN": "Выйти из полноэкранного режима"
}
},
"PLAYBACK_DIAGNOSTICS": {
"UNSUPPORTED_CONTAINER": {
"TITLE": "Контейнер потока, вероятно, не поддерживается встроенным браузерным плеером",
+43
View File
@@ -428,6 +428,49 @@
"UNMUTE": "Sesi aç",
"VOLUME": "Ses seviyesi"
},
"EMBEDDED_MPV": {
"PLAYER": {
"RETRY": "Tekrar dene",
"STALLED": "Bu yayının başlaması beklenenden uzun sürüyor.",
"PLAYBACK_FAILED": "Gömülü MPV oynatması başarısız oldu.",
"CHECKING_SUPPORT": "Gömülü MPV desteği denetleniyor...",
"NOT_AVAILABLE": "Gömülü MPV bu ortamda kullanılamıyor.",
"LOADING_STREAM": "Yayın yükleniyor…",
"PLAY": "Oynat",
"PAUSE": "Duraklat",
"PREVIOUS_EPISODE": "Önceki bölüm",
"NEXT_EPISODE": "Sonraki bölüm",
"BACK_10_SECONDS": "10 saniye geri",
"FORWARD_10_SECONDS": "10 saniye ileri",
"LIVE_STREAM": "Canlı yayın",
"MUTE": "Sesi kapat",
"UNMUTE": "Sesi aç",
"MUTED": "Ses kapalı",
"VOLUME": "Ses seviyesi",
"VOLUME_LABEL": "Ses seviyesi %{{percent}}",
"AUDIO_TRACKS": "Ses parçaları",
"SUBTITLES": "Altyazılar",
"SUBTITLES_OFF": "Kapalı",
"TRACK_DEFAULT": "Varsayılan",
"AUDIO_TRACK_FALLBACK": "Ses {{index}}",
"SUBTITLE_TRACK_FALLBACK": "Altyazı {{index}}",
"PLAYBACK_SPEED": "Oynatma hızı",
"SPEED_TOOLTIP": "Hız: {{speed}}",
"ASPECT_RATIO": "En boy oranı",
"ASPECT_TOOLTIP": "En boy oranı: {{aspect}}",
"ASPECT_DEFAULT": "Varsayılan",
"START_RECORDING": "Kaydı başlat",
"STOP_RECORDING": "Kaydı durdur",
"RECORDING": "Kaydediliyor",
"RECORDING_SAVED": "Kayıt kaydedildi",
"RECORDING_FAILED": "Kayıt başarısız oldu",
"RECORDING_FAILED_TO_START": "Kayıt başlatılamadı.",
"RECORDING_FAILED_TO_STOP": "Kayıt durdurulamadı.",
"SAVED_TO": "{{path}} konumuna kaydedildi",
"ENTER_FULLSCREEN": "Tam ekrana geç",
"EXIT_FULLSCREEN": "Tam ekrandan çık"
}
},
"PLAYBACK_DIAGNOSTICS": {
"UNSUPPORTED_CONTAINER": {
"TITLE": "This stream container is likely unsupported by the browser player",
+43
View File
@@ -428,6 +428,49 @@
"UNMUTE": "取消静音",
"VOLUME": "音量"
},
"EMBEDDED_MPV": {
"PLAYER": {
"RETRY": "重试",
"STALLED": "该流的启动时间超出预期。",
"PLAYBACK_FAILED": "嵌入式 MPV 播放失败。",
"CHECKING_SUPPORT": "正在检查嵌入式 MPV 支持…",
"NOT_AVAILABLE": "嵌入式 MPV 在当前环境中不可用。",
"LOADING_STREAM": "正在加载流…",
"PLAY": "播放",
"PAUSE": "暂停",
"PREVIOUS_EPISODE": "上一集",
"NEXT_EPISODE": "下一集",
"BACK_10_SECONDS": "快退 10 秒",
"FORWARD_10_SECONDS": "快进 10 秒",
"LIVE_STREAM": "直播",
"MUTE": "静音",
"UNMUTE": "取消静音",
"MUTED": "已静音",
"VOLUME": "音量",
"VOLUME_LABEL": "音量 {{percent}}%",
"AUDIO_TRACKS": "音轨",
"SUBTITLES": "字幕",
"SUBTITLES_OFF": "关闭",
"TRACK_DEFAULT": "默认",
"AUDIO_TRACK_FALLBACK": "音轨 {{index}}",
"SUBTITLE_TRACK_FALLBACK": "字幕 {{index}}",
"PLAYBACK_SPEED": "播放速度",
"SPEED_TOOLTIP": "速度:{{speed}}",
"ASPECT_RATIO": "宽高比",
"ASPECT_TOOLTIP": "宽高比:{{aspect}}",
"ASPECT_DEFAULT": "默认",
"START_RECORDING": "开始录制",
"STOP_RECORDING": "停止录制",
"RECORDING": "正在录制",
"RECORDING_SAVED": "录制已保存",
"RECORDING_FAILED": "录制失败",
"RECORDING_FAILED_TO_START": "录制启动失败。",
"RECORDING_FAILED_TO_STOP": "录制停止失败。",
"SAVED_TO": "已保存至 {{path}}",
"ENTER_FULLSCREEN": "进入全屏",
"EXIT_FULLSCREEN": "退出全屏"
}
},
"PLAYBACK_DIAGNOSTICS": {
"UNSUPPORTED_CONTAINER": {
"TITLE": "This stream container is likely unsupported by the browser player",
+43
View File
@@ -428,6 +428,49 @@
"UNMUTE": "取消靜音",
"VOLUME": "音量"
},
"EMBEDDED_MPV": {
"PLAYER": {
"RETRY": "重試",
"STALLED": "此串流的啟動時間比預期長。",
"PLAYBACK_FAILED": "內嵌 MPV 播放失敗。",
"CHECKING_SUPPORT": "正在檢查內嵌 MPV 支援…",
"NOT_AVAILABLE": "內嵌 MPV 在此環境中無法使用。",
"LOADING_STREAM": "正在載入串流…",
"PLAY": "播放",
"PAUSE": "暫停",
"PREVIOUS_EPISODE": "上一集",
"NEXT_EPISODE": "下一集",
"BACK_10_SECONDS": "倒轉 10 秒",
"FORWARD_10_SECONDS": "快轉 10 秒",
"LIVE_STREAM": "現場直播",
"MUTE": "靜音",
"UNMUTE": "取消靜音",
"MUTED": "已靜音",
"VOLUME": "音量",
"VOLUME_LABEL": "音量 {{percent}}%",
"AUDIO_TRACKS": "音軌",
"SUBTITLES": "字幕",
"SUBTITLES_OFF": "關閉",
"TRACK_DEFAULT": "預設",
"AUDIO_TRACK_FALLBACK": "音軌 {{index}}",
"SUBTITLE_TRACK_FALLBACK": "字幕 {{index}}",
"PLAYBACK_SPEED": "播放速度",
"SPEED_TOOLTIP": "速度:{{speed}}",
"ASPECT_RATIO": "長寬比",
"ASPECT_TOOLTIP": "長寬比:{{aspect}}",
"ASPECT_DEFAULT": "預設",
"START_RECORDING": "開始錄製",
"STOP_RECORDING": "停止錄製",
"RECORDING": "錄製中",
"RECORDING_SAVED": "錄製已儲存",
"RECORDING_FAILED": "錄製失敗",
"RECORDING_FAILED_TO_START": "無法開始錄製。",
"RECORDING_FAILED_TO_STOP": "無法停止錄製。",
"SAVED_TO": "已儲存至 {{path}}",
"ENTER_FULLSCREEN": "進入全螢幕",
"EXIT_FULLSCREEN": "離開全螢幕"
}
},
"PLAYBACK_DIAGNOSTICS": {
"UNSUPPORTED_CONTAINER": {
"TITLE": "This stream container is likely unsupported by the browser player",
+8 -1
View File
@@ -40,7 +40,9 @@ Linux native Wayland embedding is not implemented. When Electron is started on X
## Linux Support Matrix
Embedded MPV on Linux is supported only for x64 desktop builds where Electron runs under X11 or Xwayland and an `mpv` executable is available on `PATH`. Native Wayland embedding is not supported in this implementation. Packaged Linux launchers pass `--ozone-platform=x11` so Wayland desktops use Xwayland when it is available.
Embedded MPV on Linux is supported only for x64 desktop builds where Electron runs under X11 or Xwayland and an `mpv` executable is available on `PATH`. Native Wayland embedding is not supported in this implementation. Packaged Linux launchers pass `--ozone-platform=x11` so Wayland desktops use Xwayland when it is available, and `main.ts` appends the same switch on Linux when it is absent so direct binary/AppImage launches from a terminal behave like launcher starts. Explicit user intent is never overridden: both a user-provided `--ozone-platform` switch and the `ELECTRON_OZONE_PLATFORM_HINT` environment variable suppress the fallback.
When the `mpv` executable probe fails inside a Flatpak or Snap sandbox (`FLATPAK_ID`/`SNAP` env present), the support reason explains that sandboxed packages cannot access a system mpv instead of asking the user to install it.
Current release-announcement wording should stay close to this:
@@ -101,6 +103,8 @@ Subtitle tracks mirror the audio-track contract: same `track-list` source, same
The renderer learns which features the loaded addon binary supports through the `EmbeddedMpvSupport.capabilities` field returned from `getEmbeddedMpvSupport()`. The service probes `typeof addon.<method> === 'function'` for each optional native export. Older addon binaries with the original audio-only surface return `capabilities: { subtitles: false, playbackSpeed: false, aspectOverride: false, screenshot: false, recording: false }`, and the renderer hides the corresponding controls instead of throwing at runtime. Linux intentionally does not export libmpv-only optional controls while it uses the process-isolated `mpv --wid` backend.
Linux audio-track discovery works differently from macOS/Windows because the hand-rolled JSON IPC reply parser only understands scalar `data` values: the poll loop reads `track-list/count` every tick and walks the scalar `track-list/N/{type,id,title,lang,default,forced}` sub-properties only when the count changes. The selected track is reconciled from the scalar `aid` property on every tick (`aid` reads back non-numeric when audio is disabled, which maps to "no selection"). Track switching still goes through `set_property aid` over the same socket.
## Session End And Series Navigation
`EmbeddedMpvSessionStatus` includes `ended` for successful EOF only. The native addon maps `MPV_EVENT_END_FILE` to:
@@ -113,6 +117,8 @@ The renderer learns which features the loaded addon binary supports through the
Renderer autoplay must use `ended` only. It must not treat `closed`, `idle`, or `error` as a request to continue to the next episode.
Async command/property replies are reconciled against pending request IDs on all platforms: only a failed `loadfile` reply (or a recording start/stop reply) may change the session status. A rejected seek, `aid`, or `speed` reply on a live stream records `snapshot.error` but must not flip a playing session to `error`, because playback continues.
The native addon also observes mpv's `eof-reached` property and maps a true value to `ended`. This is required because embedded sessions run with `keep-open=yes`; MPV can pause at EOF while keeping the file loaded, so relying only on `MPV_EVENT_END_FILE` can leave the renderer in a paused-at-end state and block series autoplay.
Series episode navigation is owned by the portal feature components and passed through the shared inline player to `EmbeddedMpvPlayerComponent`. The embedded MPV controls show `skip_previous` and `skip_next` buttons only for non-live series playback. The shared navigation payload contains `canPrevious`, `canNext`, and `autoplayEnabled`; the component disables previous/next at the current-season boundaries and guards the output handlers as well as the button disabled state.
@@ -198,6 +204,7 @@ Current development behavior:
Current release caveat:
- Release packaging requires a `vendored-lgpl` runtime manifest on macOS and Windows, and an `external-mpv-process` manifest on Linux.
- The Linux addon is built once per CI host architecture (x64). Linux packages for other architectures (arm64, armv7l) must not ship that foreign addon: `afterPack` replaces the native directory with an `embedded-mpv-unavailable.txt` marker explaining that embedded MPV is not bundled for that architecture, and package-layout verification rejects a foreign-architecture `embedded_mpv.node` while requiring the marker.
- macOS release packaging rejects embedded MPV binaries linked to `/opt/homebrew` or `/usr/local`.
- Windows release packaging verifies that the platform runtime file is present when Embedded MPV is required. Linux release packaging verifies that the addon and manifest are present and that no bundled `libmpv.so` files slipped into the package.
- Local development can opt into Homebrew `libmpv` only by setting `IPTVNATOR_EMBEDDED_MPV_ALLOW_HOMEBREW=1`; packaged release validation rejects that runtime origin.
@@ -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;
+34 -3
View File
@@ -1,5 +1,7 @@
const linuxAfterPack = require('./linux-after-pack.cjs');
const {
isForeignLinuxEmbeddedMpvArch,
resolveElectronBuilderArchName,
validatePackagedEmbeddedMpv,
} = require('./embedded-mpv-packaging.cjs');
const fs = require('fs');
@@ -41,6 +43,22 @@ function copyEmbeddedMpvNativeOutput(resourceDir, projectDir) {
fs.cpSync(sourceDir, destinationDir, { recursive: true });
}
function writeEmbeddedMpvUnavailableMarker(resourceDir, targetArch) {
const destinationDir = path.join(
resourceDir,
'app.asar.unpacked',
'electron-backend',
'native'
);
fs.rmSync(destinationDir, { recursive: true, force: true });
fs.mkdirSync(destinationDir, { recursive: true });
fs.writeFileSync(
path.join(destinationDir, 'embedded-mpv-unavailable.txt'),
`Embedded MPV is not bundled for ${targetArch} Linux builds yet. The built-in player and external MPV/VLC remain available.\n`
);
}
async function afterPackHook(params) {
await linuxAfterPack(params);
@@ -54,14 +72,27 @@ async function afterPackHook(params) {
);
const resourceDir = getResourceDir(params);
copyEmbeddedMpvNativeOutput(
resourceDir,
params.packager.projectDir ?? process.cwd()
const foreignArch = isForeignLinuxEmbeddedMpvArch(
params.electronPlatformName,
params.arch
);
if (foreignArch) {
const targetArch = resolveElectronBuilderArchName(params.arch);
log(
`embedded MPV addon is not built for ${targetArch}; packaging an unavailable marker instead`
);
writeEmbeddedMpvUnavailableMarker(resourceDir, targetArch);
} else {
copyEmbeddedMpvNativeOutput(
resourceDir,
params.packager.projectDir ?? process.cwd()
);
}
const errors = validatePackagedEmbeddedMpv(resourceDir, {
platform: params.electronPlatformName,
required: requireEmbeddedMpv,
foreignArch,
});
if (errors.length > 0) {
+125
View File
@@ -0,0 +1,125 @@
import assert from 'node:assert/strict';
import fs from 'node:fs';
import { createRequire } from 'node:module';
import os from 'node:os';
import { join } from 'node:path';
import test from 'node:test';
const require = createRequire(import.meta.url);
const {
isForeignLinuxEmbeddedMpvArch,
linuxUnpackedDirArch,
resolveElectronBuilderArchName,
validatePackagedEmbeddedMpv,
} = require('./embedded-mpv-packaging.cjs');
const X64_ADDON_ENV = { IPTVNATOR_EMBEDDED_MPV_ARCH: 'x64' };
test('resolveElectronBuilderArchName maps builder-util Arch enum values', () => {
assert.equal(resolveElectronBuilderArchName(0), 'ia32');
assert.equal(resolveElectronBuilderArchName(1), 'x64');
assert.equal(resolveElectronBuilderArchName(2), 'armv7l');
assert.equal(resolveElectronBuilderArchName(3), 'arm64');
assert.equal(resolveElectronBuilderArchName('arm64'), 'arm64');
assert.equal(resolveElectronBuilderArchName(99), null);
});
test('flags Linux packages whose arch differs from the built addon', () => {
assert.equal(isForeignLinuxEmbeddedMpvArch('linux', 3, X64_ADDON_ENV), true);
assert.equal(
isForeignLinuxEmbeddedMpvArch('linux', 'armv7l', X64_ADDON_ENV),
true
);
assert.equal(
isForeignLinuxEmbeddedMpvArch('linux', 'x64', X64_ADDON_ENV),
false
);
// Only Linux fans out foreign arches from one dist tree.
assert.equal(
isForeignLinuxEmbeddedMpvArch('darwin', 'arm64', X64_ADDON_ENV),
false
);
assert.equal(
isForeignLinuxEmbeddedMpvArch('win32', 'arm64', X64_ADDON_ENV),
false
);
});
test('derives package arch from electron-builder Linux output directory names', () => {
assert.equal(linuxUnpackedDirArch('linux-unpacked'), 'x64');
assert.equal(linuxUnpackedDirArch('linux-arm64-unpacked'), 'arm64');
assert.equal(linuxUnpackedDirArch('linux-armv7l-unpacked'), 'armv7l');
assert.equal(linuxUnpackedDirArch('mac-arm64'), null);
assert.equal(linuxUnpackedDirArch('win-unpacked'), null);
});
function createResourceDir(files) {
const resourceDir = fs.mkdtempSync(
join(os.tmpdir(), 'iptvnator-embedded-mpv-arch-')
);
const nativeDir = join(
resourceDir,
'app.asar.unpacked',
'electron-backend',
'native'
);
fs.mkdirSync(nativeDir, { recursive: true });
for (const [name, content] of Object.entries(files)) {
fs.writeFileSync(join(nativeDir, name), content);
}
return resourceDir;
}
test('foreign-arch validation requires the unavailable marker and forbids the addon', (t) => {
const withMarker = createResourceDir({
'embedded-mpv-unavailable.txt': 'not bundled for arm64\n',
});
const withAddon = createResourceDir({
'embedded-mpv-unavailable.txt': 'not bundled for arm64\n',
'embedded_mpv.node': 'x64 machine code',
});
const empty = createResourceDir({});
t.after(() => {
for (const dir of [withMarker, withAddon, empty]) {
fs.rmSync(dir, { recursive: true, force: true });
}
});
assert.deepEqual(
validatePackagedEmbeddedMpv(withMarker, {
platform: 'linux',
required: true,
foreignArch: true,
}),
[]
);
const addonErrors = validatePackagedEmbeddedMpv(withAddon, {
platform: 'linux',
required: true,
foreignArch: true,
});
assert.equal(addonErrors.length, 1);
assert.match(addonErrors[0], /must not ship in foreign-architecture/);
const markerErrors = validatePackagedEmbeddedMpv(empty, {
platform: 'linux',
required: true,
foreignArch: true,
});
assert.equal(markerErrors.length, 1);
assert.match(markerErrors[0], /unavailable marker/);
});
test('same-arch validation still requires the addon when embedded MPV is required', (t) => {
const empty = createResourceDir({});
t.after(() => fs.rmSync(empty, { recursive: true, force: true }));
const errors = validatePackagedEmbeddedMpv(empty, {
platform: 'linux',
required: true,
foreignArch: false,
});
assert.equal(errors.length, 1);
assert.match(errors[0], /Missing embedded MPV native addon/);
});
@@ -453,6 +453,52 @@ function normalizeEmbeddedMpvPlatform(value) {
return value ?? process.platform;
}
// electron-builder passes `arch` to afterPack as a builder-util Arch enum
// value; this table mirrors that enum's declaration order.
const ELECTRON_BUILDER_ARCH_NAMES = [
'ia32',
'x64',
'armv7l',
'arm64',
'universal',
];
function resolveElectronBuilderArchName(arch) {
if (typeof arch === 'string') {
return arch;
}
return ELECTRON_BUILDER_ARCH_NAMES[arch] ?? null;
}
function getEmbeddedMpvAddonArch(env = process.env) {
return env.IPTVNATOR_EMBEDDED_MPV_ARCH || process.arch;
}
/**
* The embedded MPV addon is compiled once per CI host (x64 on Linux), but
* electron-builder also produces arm64/armv7l Linux packages from the same
* dist output. Those packages must not ship a foreign-architecture
* `embedded_mpv.node` — it can never load and produces a cryptic error.
*/
function isForeignLinuxEmbeddedMpvArch(platform, targetArch, env = process.env) {
if (normalizeEmbeddedMpvPlatform(platform) !== 'linux') {
return false;
}
const archName = resolveElectronBuilderArchName(targetArch);
return Boolean(archName) && archName !== getEmbeddedMpvAddonArch(env);
}
// Maps electron-builder Linux output directory names (`linux-unpacked`,
// `linux-arm64-unpacked`, ...) to the package architecture. `null` when the
// name is not a Linux unpacked directory.
function linuxUnpackedDirArch(unpackedDirName) {
const match = /^linux-(?:([a-z0-9_]+)-)?unpacked$/.exec(unpackedDirName);
if (!match) {
return null;
}
return match[1] ?? 'x64';
}
function validatePackagedEmbeddedMpv(resourceDir, options = {}) {
const platform = normalizeEmbeddedMpvPlatform(options.platform);
const unpackedNativeDir = path.join(
@@ -469,6 +515,24 @@ function validatePackagedEmbeddedMpv(resourceDir, options = {}) {
);
const errors = [];
if (options.foreignArch) {
if (fs.existsSync(addonPath)) {
errors.push(
`Embedded MPV addon must not ship in foreign-architecture Linux packages: ${addonPath}`
);
}
const markerPath = path.join(
unpackedNativeDir,
'embedded-mpv-unavailable.txt'
);
if (!fs.existsSync(markerPath)) {
errors.push(
`Missing embedded MPV unavailable marker for foreign-architecture package: ${markerPath}`
);
}
return errors;
}
if (!fs.existsSync(addonPath)) {
if (options.required) {
errors.push(`Missing embedded MPV native addon: ${addonPath}`);
@@ -556,4 +620,8 @@ module.exports = {
validateNoForbiddenRuntimeLinks,
getPackagedRuntimeCandidates,
validatePackagedEmbeddedMpv,
getEmbeddedMpvAddonArch,
isForeignLinuxEmbeddedMpvArch,
linuxUnpackedDirArch,
resolveElectronBuilderArchName,
};
+3 -1
View File
@@ -19,11 +19,13 @@
"{workspaceRoot}/tools/packaging/generate-electron-builder-metadata.mjs",
"{workspaceRoot}/tools/packaging/asar-dependency-closure.mjs",
"{workspaceRoot}/tools/packaging/asar-dependency-closure.test.mjs",
"{workspaceRoot}/tools/packaging/embedded-mpv-packaging.cjs",
"{workspaceRoot}/tools/packaging/embedded-mpv-arch.test.mjs",
"{workspaceRoot}/tools/embedded-mpv/stage-runtime.mjs",
"{workspaceRoot}/tools/embedded-mpv/stage-windows-runtime-archive.mjs"
],
"options": {
"command": "node --test tools/packaging/electron-package-identity.test.mjs tools/packaging/asar-dependency-closure.test.mjs",
"command": "node --test tools/packaging/electron-package-identity.test.mjs tools/packaging/asar-dependency-closure.test.mjs tools/packaging/embedded-mpv-arch.test.mjs",
"cwd": "{workspaceRoot}"
}
},
@@ -7,7 +7,11 @@ import { inspectPackagedDependencyClosure } from './asar-dependency-closure.mjs'
const require = createRequire(import.meta.url);
const { extractFile, listPackage } = require('@electron/asar');
const { validatePackagedEmbeddedMpv } = require('./embedded-mpv-packaging.cjs');
const {
getEmbeddedMpvAddonArch,
linuxUnpackedDirArch,
validatePackagedEmbeddedMpv,
} = require('./embedded-mpv-packaging.cjs');
const args = process.argv.slice(2);
const normalizedArgs = args[0] === '--' ? args.slice(1) : args;
const [platform, arch = ''] = normalizedArgs;
@@ -610,6 +614,20 @@ function verifyPackagedDependencyClosure(resourceDir, errors) {
);
}
// The embedded MPV addon is built once per CI host (x64), but electron-builder
// emits arm64/armv7l Linux output directories from the same dist tree. Those
// must carry the unavailable marker instead of a foreign-architecture addon.
function isForeignArchLinuxResourceDir(resourceDir) {
if (platform !== 'linux') {
return false;
}
const dirArch = linuxUnpackedDirArch(
path.basename(path.dirname(resourceDir))
);
return Boolean(dirArch) && dirArch !== getEmbeddedMpvAddonArch();
}
function verifyResourceDir(resourceDir) {
const missingWorkers = workerFiles.filter(
(workerFile) =>
@@ -660,6 +678,7 @@ function verifyResourceDir(resourceDir) {
...validatePackagedEmbeddedMpv(resourceDir, {
platform,
required: embeddedMpvRequired,
foreignArch: isForeignArchLinuxResourceDir(resourceDir),
})
);