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

+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),
})
);