mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-16 22:26:16 -08:00
feat(playback): embedded mpv pre-0.22 hardening, Linux parity, and localization (#1122)
* fix(playback): harden embedded mpv session handling and support detection - guard the session controller against late startup rejections clobbering a newer session during fast channel zapping - exclude the refresh timestamp from the session-update dedup key so idle sessions stop re-emitting IPC updates every 500 ms - macOS: reconcile async loadfile replies by request id so a rejected seek/aid/speed on a live stream no longer flips the session to error - append --ozone-platform=x11 on Linux in main.ts so direct binary and AppImage launches match the packaged .desktop launcher behavior - return a sandbox-specific unsupported reason in Flatpak/Snap instead of asking the user to install mpv inside the sandbox - update the stale "macOS only" embedded MPV claim in CLAUDE.md Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(playback): populate Linux audio tracks and fix ARM Linux packaging - Linux poller now reads track-list/count each tick and walks the scalar track-list/N/* sub-properties when the count changes, so the audio-track menu is no longer empty; selection reconciles from the aid property - afterPack replaces the x64 embedded_mpv.node with an embedded-mpv-unavailable.txt marker in arm64/armv7l Linux packages, and package-layout verification rejects foreign-architecture addons while requiring the marker - extend native source invariants for the non-fatal async-reply rule (macOS) and the Linux track-list polling contract - document the Linux track-list mechanics and ARM packaging behavior in docs/architecture/embedded-mpv-native.md Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(playback): embedded mpv player UX polish and full localization - click on the video toggles pause (same action as Space) with a 250 ms grace period so double-click fullscreen cancels the pending pause; no DOM overlay is drawn — the dock transport icon is the feedback - timeline scrubbing previews the drag position locally and commits a single seek on release instead of one IPC seek per drag pixel - translate all player UI strings (controls, tooltips, aria-labels, status and recording messages) via new EMBEDDED_MPV.PLAYER.* keys, synced across en + 17 locales through the i18n-fill workflow - replace the legacy @Output() EventEmitter with the signal output() API Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(playback): react to language changes and respect ozone platform hint Address review feedback on #1122: - add a translationsTick signal (onLangChange/onTranslationChange/ onDefaultLangChange) read by every computed() and template helper that calls translate.instant(), so labels re-evaluate on a runtime language switch and when the translation file finishes loading after mount - suppress the Linux --ozone-platform=x11 fallback when the user set ELECTRON_OZONE_PLATFORM_HINT, matching the existing respect for an explicit --ozone-platform switch Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
3659de796c
commit
8eb0fe3261
38 files changed
+1823
-107
No files matched your search
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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),
|
||||
})
|
||||
);
|
||||
|
||||
|
||||
Reference in new issue
Block a user