mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-16 06:06:18 -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
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user