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

@@ -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;