mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-08 17:06:15 -08:00
Observe mpv `chapter-list` in the macOS and Windows addons and the frame-copy helper, carry it as `EmbeddedMpvSession.chapters`, and map it to `PlayerTimelineSegment[]` for `app-player-controls` when the host passes no catch-up segments. A closing-credits chapter now times the Up next card. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1252 lines
50 KiB
C++
1252 lines
50 KiB
C++
/*
|
|
* iptvnator-mpv-helper — frame-copy embedded MPV helper process
|
|
* (macOS + Linux + Windows; platform GL context in frame_helper_gl.h).
|
|
*
|
|
* One process = one playback session. Owns libmpv end to end: decodes,
|
|
* renders offscreen at viewport size, publishes BGRA frames into a shared
|
|
* memory ring (frame_shm.h), and plays audio directly through the OS.
|
|
*
|
|
* Control protocol: tab-separated commands on stdin, JSON events on stdout
|
|
* (see frame_helper_io.h). The `snapshot` event mirrors the TypeScript
|
|
* NativeEmbeddedMpvSessionSnapshot shape so the Electron-side adapter can
|
|
* cache it verbatim. Status semantics are ported from embedded_mpv.mm:
|
|
* END_FILE reason mapping, eof-reached => ended (keep-open), pause flag
|
|
* gated on a loaded path, only fatal/load errors flip the status.
|
|
*
|
|
* Usage:
|
|
* iptvnator_mpv_helper --shm-base /impv-<id> --width 1280 --height 720
|
|
* [--volume 0..1] [--hwdec auto]
|
|
* [--mpv-options-stdin]
|
|
*/
|
|
#include <mpv/client.h>
|
|
|
|
#include <algorithm>
|
|
#include <atomic>
|
|
#include <clocale>
|
|
#include <cmath>
|
|
#include <csignal>
|
|
#include <cstdio>
|
|
#include <cstring>
|
|
#include <ctime>
|
|
#include <iostream>
|
|
#include <mutex>
|
|
#include <set>
|
|
#include <string>
|
|
#include <thread>
|
|
#include <utility>
|
|
#include <vector>
|
|
|
|
#include "frame_helper_io.h"
|
|
#include "frame_helper_render.h"
|
|
|
|
namespace {
|
|
|
|
using frame_helper::Command;
|
|
using frame_helper::emitLine;
|
|
using frame_helper::GlContext;
|
|
using frame_helper::JsonWriter;
|
|
using frame_helper::RenderPipeline;
|
|
|
|
struct TrackInfo {
|
|
int64_t id = -1;
|
|
std::string title;
|
|
std::string language;
|
|
bool defaultTrack = false;
|
|
bool forced = false;
|
|
};
|
|
|
|
/* One entry of mpv's `chapter-list`: start time and optional title. */
|
|
struct ChapterInfo {
|
|
double timeSeconds = 0;
|
|
std::string title;
|
|
};
|
|
|
|
struct SnapshotState {
|
|
std::string status = "idle";
|
|
double positionSeconds = 0;
|
|
double durationSeconds = -1; /* <0 => null */
|
|
double volume = 1; /* 0..1 */
|
|
int64_t videoWidth = 0; /* mpv dwidth/dheight; 0 = unknown */
|
|
int64_t videoHeight = 0;
|
|
/* Stream diagnostics for the player's info popover. Sentinels mean
|
|
* "mpv has not answered yet" so a row is omitted rather than shown as 0. */
|
|
double fps = 0; /* estimated-vf-fps; <=0 = unknown */
|
|
double videoBitrate = 0; /* bits/s; <=0 = unknown */
|
|
double audioBitrate = 0;
|
|
std::string videoCodec; /* video-format, e.g. "h264" */
|
|
std::string audioCodec; /* audio-codec-name, e.g. "aac" */
|
|
std::string audioChannels; /* audio-params/channels, e.g. "5.1" */
|
|
int64_t audioSampleRate = 0; /* audio-params/samplerate; 0 unknown */
|
|
std::string container; /* file-format, e.g. "mpegts" */
|
|
double cacheDuration = -1; /* demuxer-cache-duration; <0 = unknown */
|
|
int64_t droppedFrames = -1; /* frame-drop-count; <0 = unknown */
|
|
int64_t decoderDroppedFrames = -1;
|
|
|
|
// MPV_FORMAT_NONE revokes just this observation, not unrelated values.
|
|
bool clearUnavailableStreamProperty(const std::string& name) {
|
|
if (name == "estimated-vf-fps") { fps = 0; }
|
|
else if (name == "video-bitrate") { videoBitrate = 0; }
|
|
else if (name == "audio-bitrate") { audioBitrate = 0; }
|
|
else if (name == "video-format") { videoCodec.clear(); }
|
|
else if (name == "audio-codec-name") { audioCodec.clear(); }
|
|
else if (name == "audio-params/channels") { audioChannels.clear(); }
|
|
else if (name == "audio-params/samplerate") { audioSampleRate = 0; }
|
|
else if (name == "file-format") { container.clear(); }
|
|
else if (name == "demuxer-cache-duration") { cacheDuration = -1; }
|
|
else if (name == "frame-drop-count") { droppedFrames = -1; }
|
|
else if (name == "decoder-frame-drop-count") { decoderDroppedFrames = -1; }
|
|
else if (name == "dwidth") { videoWidth = 0; }
|
|
else if (name == "dheight") { videoHeight = 0; }
|
|
else { return false; }
|
|
return true;
|
|
}
|
|
|
|
/* Every new file starts from "nothing reported yet": the popover must
|
|
* never show the previous stream's codec or bitrate. Kept next to the
|
|
* fields so adding one cannot forget the reset. */
|
|
void clearStreamStats() {
|
|
videoWidth = 0;
|
|
videoHeight = 0;
|
|
fps = 0;
|
|
videoBitrate = 0;
|
|
audioBitrate = 0;
|
|
videoCodec.clear();
|
|
audioCodec.clear();
|
|
audioChannels.clear();
|
|
audioSampleRate = 0;
|
|
container.clear();
|
|
cacheDuration = -1;
|
|
droppedFrames = -1;
|
|
decoderDroppedFrames = -1;
|
|
}
|
|
std::string streamUrl;
|
|
std::vector<TrackInfo> audioTracks;
|
|
int64_t selectedAudioTrackId = -1;
|
|
std::vector<TrackInfo> subtitleTracks;
|
|
int64_t selectedSubtitleTrackId = -1;
|
|
std::vector<ChapterInfo> chapters;
|
|
double playbackSpeed = 1;
|
|
std::string aspectOverride = "no";
|
|
bool recordingActive = false;
|
|
std::string recordingTargetPath;
|
|
std::string recordingStartedAt;
|
|
std::string recordingError;
|
|
std::string error;
|
|
/* True when `error` is the engine's own failure (a fatal libmpv log)
|
|
* rather than the stream's: the app must not reload media for it. */
|
|
bool engineError = false;
|
|
};
|
|
|
|
struct HelperState {
|
|
mpv_handle* mpv = nullptr;
|
|
RenderPipeline pipeline;
|
|
|
|
std::mutex mutex;
|
|
SnapshotState snapshot;
|
|
bool paused = false;
|
|
bool loadedPath = false;
|
|
bool dirty = true;
|
|
std::string lastEmittedStatus;
|
|
uint64_t lastEmitNs = 0;
|
|
/* Requested viewport in device pixels; the render target is the
|
|
* aspect-fit of the video inside it so letterbox bars are never
|
|
* rendered (and frames stay as small as possible). */
|
|
int viewportWidth = 0;
|
|
int viewportHeight = 0;
|
|
|
|
std::atomic<bool> running{true};
|
|
};
|
|
|
|
HelperState g_state;
|
|
|
|
constexpr uint64_t SNAPSHOT_EMIT_INTERVAL_NS = 250ull * 1000 * 1000;
|
|
|
|
std::string isoTimestampNow() {
|
|
char buffer[32];
|
|
const time_t now = time(nullptr);
|
|
struct tm utc;
|
|
#if defined(_WIN32)
|
|
gmtime_s(&utc, &now);
|
|
#else
|
|
gmtime_r(&now, &utc);
|
|
#endif
|
|
strftime(buffer, sizeof(buffer), "%Y-%m-%dT%H:%M:%SZ", &utc);
|
|
return buffer;
|
|
}
|
|
|
|
std::string tracksJson(const std::vector<TrackInfo>& tracks,
|
|
int64_t selectedId) {
|
|
std::string out = "[";
|
|
bool first = true;
|
|
for (const TrackInfo& track : tracks) {
|
|
JsonWriter writer;
|
|
writer.num("id", (double)track.id);
|
|
if (!track.title.empty()) writer.str("title", track.title);
|
|
if (!track.language.empty()) writer.str("language", track.language);
|
|
writer.boolean("selected", track.id == selectedId);
|
|
writer.boolean("defaultTrack", track.defaultTrack);
|
|
writer.boolean("forced", track.forced);
|
|
if (!first) out += ',';
|
|
first = false;
|
|
out += writer.finish();
|
|
}
|
|
out += ']';
|
|
return out;
|
|
}
|
|
|
|
std::string chaptersJson(const std::vector<ChapterInfo>& chapters) {
|
|
std::string out = "[";
|
|
bool first = true;
|
|
for (const ChapterInfo& chapter : chapters) {
|
|
JsonWriter writer;
|
|
writer.num("timeSeconds", chapter.timeSeconds);
|
|
if (!chapter.title.empty()) writer.str("title", chapter.title);
|
|
if (!first) out += ',';
|
|
first = false;
|
|
out += writer.finish();
|
|
}
|
|
out += ']';
|
|
return out;
|
|
}
|
|
|
|
/* Compose the `stats` object of the snapshot event. Always emitted, even when
|
|
* empty: the adapter merges snapshot events field by field, so an omitted key
|
|
* would leave the previous stream's numbers in place across a channel switch.
|
|
* Caller holds g_state.mutex. */
|
|
std::string composeStatsJsonLocked() {
|
|
const SnapshotState& s = g_state.snapshot;
|
|
JsonWriter stats;
|
|
if (s.fps > 0) {
|
|
stats.num("fps", s.fps);
|
|
}
|
|
if (s.videoBitrate > 0) {
|
|
stats.num("videoBitrateBps", s.videoBitrate);
|
|
}
|
|
if (s.audioBitrate > 0) {
|
|
stats.num("audioBitrateBps", s.audioBitrate);
|
|
}
|
|
if (!s.videoCodec.empty()) {
|
|
stats.str("videoCodec", s.videoCodec);
|
|
}
|
|
if (!s.audioCodec.empty()) {
|
|
stats.str("audioCodec", s.audioCodec);
|
|
}
|
|
if (!s.audioChannels.empty()) {
|
|
stats.str("audioChannels", s.audioChannels);
|
|
}
|
|
if (s.audioSampleRate > 0) {
|
|
stats.num("audioSampleRateHz", (double)s.audioSampleRate);
|
|
}
|
|
if (!s.container.empty()) {
|
|
stats.str("container", s.container);
|
|
}
|
|
if (s.cacheDuration >= 0) {
|
|
stats.num("bufferedAheadSeconds", s.cacheDuration);
|
|
}
|
|
if (s.droppedFrames >= 0 || s.decoderDroppedFrames >= 0) {
|
|
/* mpv counts render-time and decoder drops separately; the popover
|
|
* shows the one number a viewer cares about. */
|
|
const int64_t dropped = std::max<int64_t>(0, s.droppedFrames) +
|
|
std::max<int64_t>(0, s.decoderDroppedFrames);
|
|
stats.num("droppedFrames", (double)dropped);
|
|
}
|
|
return stats.finish();
|
|
}
|
|
|
|
/* Compose the snapshot event. Caller holds g_state.mutex. */
|
|
std::string composeSnapshotLocked() {
|
|
const SnapshotState& s = g_state.snapshot;
|
|
JsonWriter writer;
|
|
writer.str("event", "snapshot");
|
|
writer.str("status", s.status);
|
|
writer.num("positionSeconds", std::max(0.0, s.positionSeconds));
|
|
// Snapshots merge in main; publish zero so an old size cannot survive.
|
|
writer.num("videoWidth", (double)s.videoWidth);
|
|
writer.num("videoHeight", (double)s.videoHeight);
|
|
if (s.durationSeconds >= 0) {
|
|
writer.num("durationSeconds", s.durationSeconds);
|
|
} else {
|
|
writer.nullValue("durationSeconds");
|
|
}
|
|
writer.num("volume", std::clamp(s.volume, 0.0, 1.5));
|
|
writer.str("streamUrl", s.streamUrl);
|
|
writer.raw("audioTracks", tracksJson(s.audioTracks, s.selectedAudioTrackId));
|
|
if (s.selectedAudioTrackId >= 0) {
|
|
writer.num("selectedAudioTrackId", (double)s.selectedAudioTrackId);
|
|
} else {
|
|
writer.nullValue("selectedAudioTrackId");
|
|
}
|
|
writer.raw("subtitleTracks",
|
|
tracksJson(s.subtitleTracks, s.selectedSubtitleTrackId));
|
|
if (s.selectedSubtitleTrackId >= 0) {
|
|
writer.num("selectedSubtitleTrackId",
|
|
(double)s.selectedSubtitleTrackId);
|
|
} else {
|
|
writer.nullValue("selectedSubtitleTrackId");
|
|
}
|
|
/* Always emitted: the adapter merges snapshot events field by field, so
|
|
* an omitted key would keep the previous file's chapters. */
|
|
writer.raw("chapters", chaptersJson(s.chapters));
|
|
writer.num("playbackSpeed", s.playbackSpeed);
|
|
writer.str("aspectOverride", s.aspectOverride);
|
|
writer.raw("stats", composeStatsJsonLocked());
|
|
JsonWriter recording;
|
|
recording.boolean("active", s.recordingActive);
|
|
if (!s.recordingTargetPath.empty())
|
|
recording.str("targetPath", s.recordingTargetPath);
|
|
if (!s.recordingStartedAt.empty())
|
|
recording.str("startedAt", s.recordingStartedAt);
|
|
if (!s.recordingError.empty()) recording.str("error", s.recordingError);
|
|
writer.raw("recording", recording.finish());
|
|
if (!s.error.empty()) {
|
|
writer.str("error", s.error);
|
|
if (s.engineError) writer.str("errorOrigin", "engine");
|
|
}
|
|
return writer.finish();
|
|
}
|
|
|
|
/* Emit the snapshot when dirty: immediately on status changes, otherwise at
|
|
* most every SNAPSHOT_EMIT_INTERVAL_NS. Called from the mpv event thread. */
|
|
void maybeEmitSnapshot(bool force = false) {
|
|
std::string line;
|
|
{
|
|
std::lock_guard<std::mutex> lock(g_state.mutex);
|
|
if (!g_state.dirty && !force) return;
|
|
const uint64_t now = frame_helper::nowNs();
|
|
const bool statusChanged =
|
|
g_state.snapshot.status != g_state.lastEmittedStatus;
|
|
if (!force && !statusChanged &&
|
|
now - g_state.lastEmitNs < SNAPSHOT_EMIT_INTERVAL_NS) {
|
|
return;
|
|
}
|
|
g_state.dirty = false;
|
|
g_state.lastEmitNs = now;
|
|
g_state.lastEmittedStatus = g_state.snapshot.status;
|
|
line = composeSnapshotLocked();
|
|
}
|
|
emitLine(line);
|
|
}
|
|
|
|
void markDirtyLocked() { g_state.dirty = true; }
|
|
|
|
/* Aspect-fit of the current video inside the requested viewport. Caller
|
|
* holds g_state.mutex. Letterbox bars are handled by the renderer layout,
|
|
* not baked into frames. */
|
|
std::pair<int, int> computeRenderSizeLocked() {
|
|
const int vpW = g_state.viewportWidth;
|
|
const int vpH = g_state.viewportHeight;
|
|
const int64_t vw = g_state.snapshot.videoWidth;
|
|
const int64_t vh = g_state.snapshot.videoHeight;
|
|
if (vw < 16 || vh < 16 || vpW < 16 || vpH < 16) {
|
|
return {vpW, vpH};
|
|
}
|
|
const double scale =
|
|
std::min(vpW / (double)vw, vpH / (double)vh);
|
|
const int width =
|
|
std::max(16, (int)llround((double)vw * scale)) & ~1;
|
|
const int height =
|
|
std::max(16, (int)llround((double)vh * scale)) & ~1;
|
|
return {width, height};
|
|
}
|
|
|
|
void applyRenderSizeLocked() {
|
|
const auto [width, height] = computeRenderSizeLocked();
|
|
if (width >= 16 && height >= 16) {
|
|
g_state.pipeline.requestResize(width, height);
|
|
}
|
|
}
|
|
|
|
/* ---- track-list parsing (ported from embedded_mpv.mm) ------------------- */
|
|
|
|
void parseTrackNode(const mpv_node& trackNode, const char* wantedType,
|
|
std::vector<TrackInfo>& out) {
|
|
if (trackNode.format != MPV_FORMAT_NODE_MAP || !trackNode.u.list) return;
|
|
TrackInfo track;
|
|
bool matchesType = false;
|
|
const mpv_node_list& map = *trackNode.u.list;
|
|
for (int i = 0; i < map.num; i++) {
|
|
const char* key = map.keys[i];
|
|
const mpv_node& value = map.values[i];
|
|
if (!key) continue;
|
|
if (std::strcmp(key, "type") == 0 &&
|
|
value.format == MPV_FORMAT_STRING && value.u.string) {
|
|
matchesType = std::strcmp(value.u.string, wantedType) == 0;
|
|
} else if (std::strcmp(key, "id") == 0 &&
|
|
value.format == MPV_FORMAT_INT64) {
|
|
track.id = value.u.int64;
|
|
} else if (std::strcmp(key, "title") == 0 &&
|
|
value.format == MPV_FORMAT_STRING && value.u.string) {
|
|
track.title = value.u.string;
|
|
} else if (std::strcmp(key, "lang") == 0 &&
|
|
value.format == MPV_FORMAT_STRING && value.u.string) {
|
|
track.language = value.u.string;
|
|
} else if (std::strcmp(key, "default") == 0 &&
|
|
value.format == MPV_FORMAT_FLAG) {
|
|
track.defaultTrack = value.u.flag != 0;
|
|
} else if (std::strcmp(key, "forced") == 0 &&
|
|
value.format == MPV_FORMAT_FLAG) {
|
|
track.forced = value.u.flag != 0;
|
|
}
|
|
}
|
|
if (matchesType && track.id >= 0) {
|
|
out.push_back(std::move(track));
|
|
}
|
|
}
|
|
|
|
void updateTracksFromNode(const mpv_node& trackListNode) {
|
|
if (trackListNode.format != MPV_FORMAT_NODE_ARRAY ||
|
|
!trackListNode.u.list) {
|
|
return;
|
|
}
|
|
std::vector<TrackInfo> audio;
|
|
std::vector<TrackInfo> subs;
|
|
for (int i = 0; i < trackListNode.u.list->num; i++) {
|
|
parseTrackNode(trackListNode.u.list->values[i], "audio", audio);
|
|
parseTrackNode(trackListNode.u.list->values[i], "sub", subs);
|
|
}
|
|
g_state.snapshot.audioTracks = std::move(audio);
|
|
g_state.snapshot.subtitleTracks = std::move(subs);
|
|
}
|
|
|
|
/* mpv `chapter-list`: an array of { time, title? } maps in file order. */
|
|
void updateChaptersFromNode(const mpv_node& chapterListNode) {
|
|
std::vector<ChapterInfo> chapters;
|
|
if (chapterListNode.format == MPV_FORMAT_NODE_ARRAY &&
|
|
chapterListNode.u.list) {
|
|
for (int i = 0; i < chapterListNode.u.list->num; i++) {
|
|
const mpv_node& entry = chapterListNode.u.list->values[i];
|
|
if (entry.format != MPV_FORMAT_NODE_MAP || !entry.u.list) continue;
|
|
ChapterInfo chapter;
|
|
bool hasTime = false;
|
|
const mpv_node_list& map = *entry.u.list;
|
|
for (int k = 0; k < map.num; k++) {
|
|
const char* key = map.keys[k];
|
|
const mpv_node& value = map.values[k];
|
|
if (!key) continue;
|
|
if (std::strcmp(key, "time") == 0 &&
|
|
value.format == MPV_FORMAT_DOUBLE) {
|
|
chapter.timeSeconds = value.u.double_;
|
|
hasTime = std::isfinite(chapter.timeSeconds);
|
|
} else if (std::strcmp(key, "time") == 0 &&
|
|
value.format == MPV_FORMAT_INT64) {
|
|
chapter.timeSeconds = (double)value.u.int64;
|
|
hasTime = true;
|
|
} else if (std::strcmp(key, "title") == 0 &&
|
|
value.format == MPV_FORMAT_STRING &&
|
|
value.u.string) {
|
|
chapter.title = value.u.string;
|
|
}
|
|
}
|
|
if (hasTime) chapters.push_back(std::move(chapter));
|
|
}
|
|
}
|
|
g_state.snapshot.chapters = std::move(chapters);
|
|
}
|
|
|
|
bool parseTrackSelection(const char* value, int64_t& out) {
|
|
if (!value) return false;
|
|
char* end = nullptr;
|
|
const long long parsed = strtoll(value, &end, 10);
|
|
if (end == value || (end && *end != '\0')) return false;
|
|
out = parsed;
|
|
return true;
|
|
}
|
|
|
|
/* ---- mpv event loop (status semantics ported from embedded_mpv.mm) ------ */
|
|
|
|
void handlePropertyChange(const mpv_event_property& property) {
|
|
const std::string name = property.name ? property.name : "";
|
|
SnapshotState& s = g_state.snapshot;
|
|
|
|
if (property.format == MPV_FORMAT_NONE) {
|
|
if (s.clearUnavailableStreamProperty(name)) {
|
|
if (name == "dwidth" || name == "dheight") {
|
|
applyRenderSizeLocked();
|
|
}
|
|
markDirtyLocked();
|
|
}
|
|
return;
|
|
}
|
|
|
|
if (name == "time-pos" && property.format == MPV_FORMAT_DOUBLE &&
|
|
property.data) {
|
|
s.positionSeconds = *static_cast<double*>(property.data);
|
|
} else if (name == "duration" && property.format == MPV_FORMAT_DOUBLE &&
|
|
property.data) {
|
|
s.durationSeconds = *static_cast<double*>(property.data);
|
|
} else if (name == "pause" && property.format == MPV_FORMAT_FLAG &&
|
|
property.data) {
|
|
g_state.paused = *static_cast<int*>(property.data) != 0;
|
|
if (s.status != "loading" && s.status != "ended" &&
|
|
s.status != "error" && g_state.loadedPath) {
|
|
s.status = g_state.paused ? "paused" : "playing";
|
|
}
|
|
} else if (name == "eof-reached" && property.format == MPV_FORMAT_FLAG &&
|
|
property.data) {
|
|
if (*static_cast<int*>(property.data) != 0 && g_state.loadedPath) {
|
|
s.status = "ended";
|
|
}
|
|
} else if (name == "volume" && property.format == MPV_FORMAT_DOUBLE &&
|
|
property.data) {
|
|
s.volume = *static_cast<double*>(property.data) / 100.0;
|
|
} else if (name == "path" && property.format == MPV_FORMAT_STRING &&
|
|
property.data) {
|
|
const char* value = *static_cast<char**>(property.data);
|
|
s.streamUrl = value ? value : "";
|
|
} else if (name == "track-list" && property.format == MPV_FORMAT_NODE &&
|
|
property.data) {
|
|
updateTracksFromNode(*static_cast<mpv_node*>(property.data));
|
|
} else if (name == "chapter-list" &&
|
|
property.format == MPV_FORMAT_NODE && property.data) {
|
|
updateChaptersFromNode(*static_cast<mpv_node*>(property.data));
|
|
} else if (name == "aid" && property.format == MPV_FORMAT_STRING &&
|
|
property.data) {
|
|
int64_t selected = -1;
|
|
if (parseTrackSelection(*static_cast<char**>(property.data),
|
|
selected)) {
|
|
s.selectedAudioTrackId = selected;
|
|
} else {
|
|
s.selectedAudioTrackId = -1;
|
|
}
|
|
} else if (name == "sid" && property.format == MPV_FORMAT_STRING &&
|
|
property.data) {
|
|
int64_t selected = -1;
|
|
if (parseTrackSelection(*static_cast<char**>(property.data),
|
|
selected)) {
|
|
s.selectedSubtitleTrackId = selected;
|
|
} else {
|
|
s.selectedSubtitleTrackId = -1;
|
|
}
|
|
} else if (name == "speed" && property.format == MPV_FORMAT_DOUBLE &&
|
|
property.data) {
|
|
s.playbackSpeed = *static_cast<double*>(property.data);
|
|
} else if ((name == "dwidth" || name == "dheight") &&
|
|
property.format == MPV_FORMAT_INT64 && property.data) {
|
|
const int64_t value = *static_cast<int64_t*>(property.data);
|
|
if (name == "dwidth") {
|
|
s.videoWidth = value;
|
|
} else {
|
|
s.videoHeight = value;
|
|
}
|
|
applyRenderSizeLocked();
|
|
} else if (name == "estimated-vf-fps" &&
|
|
property.format == MPV_FORMAT_DOUBLE && property.data) {
|
|
s.fps = *static_cast<double*>(property.data);
|
|
} else if (name == "video-bitrate" &&
|
|
property.format == MPV_FORMAT_DOUBLE && property.data) {
|
|
s.videoBitrate = *static_cast<double*>(property.data);
|
|
} else if (name == "audio-bitrate" &&
|
|
property.format == MPV_FORMAT_DOUBLE && property.data) {
|
|
s.audioBitrate = *static_cast<double*>(property.data);
|
|
} else if (name == "demuxer-cache-duration" &&
|
|
property.format == MPV_FORMAT_DOUBLE && property.data) {
|
|
s.cacheDuration = *static_cast<double*>(property.data);
|
|
} else if (name == "audio-params/samplerate" &&
|
|
property.format == MPV_FORMAT_INT64 && property.data) {
|
|
s.audioSampleRate = *static_cast<int64_t*>(property.data);
|
|
} else if (name == "frame-drop-count" &&
|
|
property.format == MPV_FORMAT_INT64 && property.data) {
|
|
s.droppedFrames = *static_cast<int64_t*>(property.data);
|
|
} else if (name == "decoder-frame-drop-count" &&
|
|
property.format == MPV_FORMAT_INT64 && property.data) {
|
|
s.decoderDroppedFrames = *static_cast<int64_t*>(property.data);
|
|
} else if ((name == "video-format" || name == "audio-codec-name" ||
|
|
name == "audio-params/channels" || name == "file-format") &&
|
|
property.format == MPV_FORMAT_STRING && property.data) {
|
|
const char* value = *static_cast<char**>(property.data);
|
|
const std::string text = value ? value : "";
|
|
if (name == "video-format") {
|
|
s.videoCodec = text;
|
|
} else if (name == "audio-codec-name") {
|
|
s.audioCodec = text;
|
|
} else if (name == "audio-params/channels") {
|
|
s.audioChannels = text;
|
|
} else if (name == "file-format") {
|
|
s.container = text;
|
|
}
|
|
} else if (name == "video-aspect-override" &&
|
|
property.format == MPV_FORMAT_STRING && property.data) {
|
|
const char* value = *static_cast<char**>(property.data);
|
|
/* mpv reports the unset override as "-1.000000"; the renderer
|
|
* contract uses "no" for that state. */
|
|
s.aspectOverride =
|
|
value && *value && std::atof(value) > 0 ? value : "no";
|
|
} else {
|
|
return;
|
|
}
|
|
markDirtyLocked();
|
|
}
|
|
|
|
void runMpvEventLoop() {
|
|
while (g_state.running.load()) {
|
|
mpv_event* event = mpv_wait_event(g_state.mpv, 0.1);
|
|
if (!event) continue;
|
|
if (event->event_id == MPV_EVENT_NONE) {
|
|
maybeEmitSnapshot();
|
|
continue;
|
|
}
|
|
|
|
{
|
|
std::lock_guard<std::mutex> lock(g_state.mutex);
|
|
SnapshotState& s = g_state.snapshot;
|
|
switch (event->event_id) {
|
|
case MPV_EVENT_START_FILE:
|
|
s.status = "loading";
|
|
s.error.clear();
|
|
s.engineError = false;
|
|
s.clearStreamStats();
|
|
s.audioTracks.clear();
|
|
s.selectedAudioTrackId = -1;
|
|
s.subtitleTracks.clear();
|
|
s.selectedSubtitleTrackId = -1;
|
|
s.chapters.clear();
|
|
g_state.loadedPath = false;
|
|
markDirtyLocked();
|
|
break;
|
|
case MPV_EVENT_FILE_LOADED:
|
|
g_state.loadedPath = true;
|
|
s.status = g_state.paused ? "paused" : "playing";
|
|
markDirtyLocked();
|
|
break;
|
|
case MPV_EVENT_END_FILE: {
|
|
const auto* endFile =
|
|
static_cast<mpv_event_end_file*>(event->data);
|
|
if (endFile &&
|
|
endFile->reason == MPV_END_FILE_REASON_ERROR) {
|
|
s.status = "error";
|
|
s.error = endFile->error < 0
|
|
? mpv_error_string(endFile->error)
|
|
: "Playback failed.";
|
|
} else if (endFile &&
|
|
endFile->reason == MPV_END_FILE_REASON_EOF &&
|
|
g_state.running.load()) {
|
|
s.status = "ended";
|
|
} else if (endFile &&
|
|
endFile->reason ==
|
|
MPV_END_FILE_REASON_REDIRECT &&
|
|
g_state.running.load()) {
|
|
s.status = "loading";
|
|
s.error.clear();
|
|
s.engineError = false;
|
|
g_state.loadedPath = false;
|
|
} else if (g_state.running.load()) {
|
|
s.status = "idle";
|
|
}
|
|
markDirtyLocked();
|
|
break;
|
|
}
|
|
case MPV_EVENT_PROPERTY_CHANGE: {
|
|
const auto* property =
|
|
static_cast<mpv_event_property*>(event->data);
|
|
if (property) handlePropertyChange(*property);
|
|
break;
|
|
}
|
|
case MPV_EVENT_LOG_MESSAGE: {
|
|
const auto* logMessage =
|
|
static_cast<mpv_event_log_message*>(event->data);
|
|
if (!logMessage || !logMessage->level ||
|
|
!logMessage->text) {
|
|
break;
|
|
}
|
|
const std::string level = logMessage->level;
|
|
if (level == "error" || level == "fatal") {
|
|
s.error = logMessage->text;
|
|
markDirtyLocked();
|
|
}
|
|
if (level == "fatal") {
|
|
s.status = "error";
|
|
s.engineError = true;
|
|
}
|
|
emitLine(JsonWriter()
|
|
.str("event", "log")
|
|
.str("level", level)
|
|
.str("prefix", logMessage->prefix
|
|
? logMessage->prefix
|
|
: "mpv")
|
|
.str("text", logMessage->text)
|
|
.finish());
|
|
break;
|
|
}
|
|
case MPV_EVENT_SHUTDOWN:
|
|
g_state.running.store(false);
|
|
s.status = "closed";
|
|
markDirtyLocked();
|
|
break;
|
|
default:
|
|
break;
|
|
}
|
|
}
|
|
maybeEmitSnapshot();
|
|
}
|
|
maybeEmitSnapshot(true);
|
|
}
|
|
|
|
/* ---- command handling ---------------------------------------------------- */
|
|
|
|
void setPropertyString(const char* name, const std::string& value) {
|
|
mpv_set_property_string(g_state.mpv, name, value.c_str());
|
|
}
|
|
|
|
void handleLoadCommand(const Command& command) {
|
|
const std::string url = command.get("url");
|
|
if (url.empty()) return;
|
|
|
|
/* Loading a replacement stream stops any active recording first,
|
|
* mirroring the .mm behavior. */
|
|
{
|
|
std::lock_guard<std::mutex> lock(g_state.mutex);
|
|
if (g_state.snapshot.recordingActive) {
|
|
setPropertyString("stream-record", "");
|
|
g_state.snapshot.recordingActive = false;
|
|
g_state.snapshot.recordingStartedAt.clear();
|
|
}
|
|
g_state.snapshot.streamUrl = url;
|
|
g_state.snapshot.error.clear();
|
|
g_state.snapshot.engineError = false;
|
|
g_state.snapshot.status = "loading";
|
|
g_state.dirty = true;
|
|
}
|
|
|
|
/* Generic pass-through: every `opt.<name>` argument becomes a loadfile
|
|
* option, so the adapter controls start/user-agent/referrer/headers
|
|
* without protocol changes. */
|
|
std::string optionString;
|
|
for (const auto& [key, value] : command.args) {
|
|
if (key.rfind("opt.", 0) != 0 || value.empty()) continue;
|
|
if (!optionString.empty()) optionString += ',';
|
|
std::string escaped = value;
|
|
/* loadfile options string uses %len%value quoting to stay safe */
|
|
optionString += key.substr(4) + "=%" +
|
|
std::to_string(escaped.size()) + "%" + escaped;
|
|
}
|
|
|
|
const char* args[] = {"loadfile", url.c_str(), "replace",
|
|
optionString.empty() ? nullptr : optionString.c_str(),
|
|
nullptr};
|
|
/* mpv >= 0.38 expects an index argument before options; use the
|
|
* options-map-free form: loadfile <url> replace [options] */
|
|
int result;
|
|
if (optionString.empty()) {
|
|
const char* plain[] = {"loadfile", url.c_str(), "replace", nullptr};
|
|
result = mpv_command(g_state.mpv, plain);
|
|
} else {
|
|
const char* withOpts[] = {"loadfile", url.c_str(), "replace", "-1",
|
|
optionString.c_str(), nullptr};
|
|
result = mpv_command(g_state.mpv, withOpts);
|
|
if (result == MPV_ERROR_INVALID_PARAMETER) {
|
|
/* Older mpv without the index argument. */
|
|
result = mpv_command(g_state.mpv, args);
|
|
}
|
|
}
|
|
if (result < 0) {
|
|
std::lock_guard<std::mutex> lock(g_state.mutex);
|
|
g_state.snapshot.status = "error";
|
|
g_state.snapshot.error = mpv_error_string(result);
|
|
g_state.dirty = true;
|
|
}
|
|
}
|
|
|
|
void handleRecordCommand(const Command& command) {
|
|
const std::string path = command.get("path");
|
|
const int result = mpv_set_property_string(g_state.mpv, "stream-record",
|
|
path.c_str());
|
|
std::lock_guard<std::mutex> lock(g_state.mutex);
|
|
if (result < 0) {
|
|
g_state.snapshot.recordingError = mpv_error_string(result);
|
|
} else if (path.empty()) {
|
|
g_state.snapshot.recordingActive = false;
|
|
g_state.snapshot.recordingStartedAt.clear();
|
|
g_state.snapshot.recordingError.clear();
|
|
} else {
|
|
g_state.snapshot.recordingActive = true;
|
|
g_state.snapshot.recordingTargetPath = path;
|
|
g_state.snapshot.recordingStartedAt = isoTimestampNow();
|
|
g_state.snapshot.recordingError.clear();
|
|
}
|
|
g_state.dirty = true;
|
|
}
|
|
|
|
void handleCommand(const Command& command) {
|
|
if (command.name == "load") {
|
|
handleLoadCommand(command);
|
|
} else if (command.name == "pause") {
|
|
const int flag = command.get("value") == "1" ? 1 : 0;
|
|
mpv_set_property(g_state.mpv, "pause", MPV_FORMAT_FLAG,
|
|
const_cast<int*>(&flag));
|
|
} else if (command.name == "seek") {
|
|
const double seconds = command.getDouble("seconds", 0);
|
|
const std::string value = std::to_string(seconds);
|
|
const char* args[] = {"seek", value.c_str(), "absolute", nullptr};
|
|
mpv_command(g_state.mpv, args);
|
|
} else if (command.name == "seek-by") {
|
|
/* Relative step (arrow keys, ±10 s buttons). mpv resolves the delta
|
|
* against its own playback position and merges relative seeks that
|
|
* are still queued, so a burst of presses accumulates instead of
|
|
* collapsing onto one target computed from the renderer's stale
|
|
* snapshot. */
|
|
const double delta = command.getDouble("seconds", 0);
|
|
const std::string value = std::to_string(delta);
|
|
const char* args[] = {"seek", value.c_str(), "relative+exact",
|
|
nullptr};
|
|
mpv_command(g_state.mpv, args);
|
|
} else if (command.name == "volume") {
|
|
double percent =
|
|
std::clamp(command.getDouble("value", 1) * 100.0, 0.0, 100.0);
|
|
mpv_set_property(g_state.mpv, "volume", MPV_FORMAT_DOUBLE, &percent);
|
|
} else if (command.name == "aid" || command.name == "sid") {
|
|
const std::string value = command.get("value");
|
|
setPropertyString(command.name.c_str(),
|
|
value == "-1" ? "no" : value);
|
|
} else if (command.name == "sub-add") {
|
|
/* External subtitle file (.srt/.ass/.vtt/…): mpv parses and renders
|
|
* it natively; "select" makes it the active subtitle track. */
|
|
const std::string path = command.get("path");
|
|
if (!path.empty()) {
|
|
const char* args[] = {"sub-add", path.c_str(), "select", nullptr};
|
|
mpv_command(g_state.mpv, args);
|
|
}
|
|
} else if (command.name == "sub-delay") {
|
|
double delay =
|
|
std::clamp(command.getDouble("value", 0), -60.0, 60.0);
|
|
mpv_set_property(g_state.mpv, "sub-delay", MPV_FORMAT_DOUBLE, &delay);
|
|
} else if (command.name == "sub-scale") {
|
|
double scale = std::clamp(command.getDouble("value", 1), 0.25, 4.0);
|
|
mpv_set_property(g_state.mpv, "sub-scale", MPV_FORMAT_DOUBLE, &scale);
|
|
} else if (command.name == "sub-color") {
|
|
/* Affects text subtitles rendered by mpv's OSD path; ASS files keep
|
|
* their own embedded styling. */
|
|
setPropertyString("sub-color", command.get("value", "#FFFFFF"));
|
|
} else if (command.name == "speed") {
|
|
double speed = std::clamp(command.getDouble("value", 1), 0.25, 4.0);
|
|
mpv_set_property(g_state.mpv, "speed", MPV_FORMAT_DOUBLE, &speed);
|
|
} else if (command.name == "aspect") {
|
|
setPropertyString("video-aspect-override", command.get("value", "no"));
|
|
} else if (command.name == "record") {
|
|
handleRecordCommand(command);
|
|
} else if (command.name == "size") {
|
|
const int width = (int)command.getDouble("width", 0);
|
|
const int height = (int)command.getDouble("height", 0);
|
|
if (width >= 16 && height >= 16) {
|
|
std::lock_guard<std::mutex> lock(g_state.mutex);
|
|
g_state.viewportWidth = width;
|
|
g_state.viewportHeight = height;
|
|
applyRenderSizeLocked();
|
|
}
|
|
} else if (command.name == "quit") {
|
|
g_state.running.store(false);
|
|
mpv_wakeup(g_state.mpv);
|
|
}
|
|
}
|
|
|
|
void runStdinLoop() {
|
|
std::string line;
|
|
while (g_state.running.load() && std::getline(std::cin, line)) {
|
|
if (line.empty()) continue;
|
|
handleCommand(frame_helper::parseCommandLine(line));
|
|
}
|
|
/* stdin EOF => the parent process died or closed us: shut down. */
|
|
g_state.running.store(false);
|
|
mpv_wakeup(g_state.mpv);
|
|
}
|
|
|
|
void onRenderUpdate(void* pipeline) {
|
|
static_cast<RenderPipeline*>(pipeline)->notifyUpdate();
|
|
}
|
|
|
|
/* mpv's LUT scalers (the default lanczos/spline family) upload their weight
|
|
* texture with uninitialized row padding (reinit_scaler in
|
|
* video/out/gpu/video.c, still present in mpv 0.41 and master). Mesa's CPU
|
|
* rasterizers carry NaN/Inf from that padding through zero-weight linear
|
|
* filtering, so a session randomly renders pure black depending on heap
|
|
* contents. Software GL gets mpv's LUT-free bilinear scalers instead, which
|
|
* is also what it can afford; a session option for the same key wins. */
|
|
void applySoftwareRendererOptions(const std::set<std::string>& sessionKeys) {
|
|
static const std::pair<const char*, const char*> kOptions[] = {
|
|
{"scale", "bilinear"},
|
|
{"cscale", "bilinear"},
|
|
{"dscale", "bilinear"},
|
|
{"sigmoid-upscaling", "no"},
|
|
};
|
|
std::string applied;
|
|
std::string failed;
|
|
for (const auto& [key, value] : kOptions) {
|
|
if (sessionKeys.count(key) != 0) continue;
|
|
const int result = mpv_set_property_string(g_state.mpv, key, value);
|
|
std::string& list = result < 0 ? failed : applied;
|
|
list += (list.empty() ? "" : ", ") + std::string(key) + "=" + value;
|
|
if (result < 0) {
|
|
list += std::string(" (") + mpv_error_string(result) + ")";
|
|
}
|
|
}
|
|
std::fprintf(stderr, "software renderer: applied [%s]; failed [%s]\n",
|
|
applied.c_str(), failed.c_str());
|
|
}
|
|
|
|
struct HelperArgs {
|
|
std::string shmBase = "/impv";
|
|
std::string hwdec = "auto";
|
|
std::string audioDelay; /* seconds, mpv audio-delay passthrough */
|
|
/* Read the session's "key=value" libmpv options (network defaults plus
|
|
* the user's Settings > Playback lines) from the first stdin line and
|
|
* apply them after the built-in block so they override it. They never
|
|
* travel on argv: a credential-bearing option must not be readable
|
|
* through `ps` or /proc/<pid>/cmdline. */
|
|
bool mpvOptionsOnStdin = false;
|
|
int width = 1280;
|
|
int height = 720;
|
|
double volume = 1;
|
|
bool runtimeProbe = false;
|
|
};
|
|
|
|
HelperArgs parseArgs(int argc, char** argv) {
|
|
HelperArgs args;
|
|
for (int i = 1; i < argc; i++) {
|
|
const std::string arg = argv[i];
|
|
auto next = [&]() -> std::string {
|
|
return i + 1 < argc ? argv[++i] : "";
|
|
};
|
|
if (arg == "--shm-base") args.shmBase = next();
|
|
else if (arg == "--width") args.width = std::atoi(next().c_str());
|
|
else if (arg == "--height") args.height = std::atoi(next().c_str());
|
|
else if (arg == "--volume") args.volume = std::atof(next().c_str());
|
|
else if (arg == "--hwdec") args.hwdec = next();
|
|
else if (arg == "--audio-delay") args.audioDelay = next();
|
|
else if (arg == "--mpv-options-stdin") args.mpvOptionsOnStdin = true;
|
|
else if (arg == "--runtime-probe") args.runtimeProbe = true;
|
|
}
|
|
args.width = std::max(16, args.width);
|
|
args.height = std::max(16, args.height);
|
|
return args;
|
|
}
|
|
|
|
int runtimeProbeFailure(const std::string& reason,
|
|
const std::string& detail = "") {
|
|
JsonWriter writer;
|
|
writer.num("protocol", 1)
|
|
.boolean("usable", false)
|
|
.str("reason", reason);
|
|
if (!detail.empty()) writer.str("detail", detail);
|
|
emitLine(writer.finish());
|
|
return 1;
|
|
}
|
|
|
|
std::string libmpvClientApiVersion() {
|
|
const unsigned long version = mpv_client_api_version();
|
|
return std::to_string(version >> 16) + "." +
|
|
std::to_string(version & 0xffff);
|
|
}
|
|
|
|
std::string runtimeProbeShmName() {
|
|
#if defined(_WIN32)
|
|
const uint64_t processId = (uint64_t)GetCurrentProcessId();
|
|
#else
|
|
const uint64_t processId = (uint64_t)getpid();
|
|
#endif
|
|
return "/impv-fc-runtime-probe-" + std::to_string(processId);
|
|
}
|
|
|
|
/*
|
|
* Bounded startup capability probe: initialize an idle libmpv client and
|
|
* create the platform GL + mpv OpenGL render contexts, then create, validate,
|
|
* and destroy a minimal shared-memory ring. It deliberately does not create
|
|
* an FBO, open media, or enter either command loop.
|
|
*/
|
|
int runRuntimeProbe() {
|
|
mpv_handle* mpv = mpv_create();
|
|
if (!mpv) {
|
|
return runtimeProbeFailure("mpv-create-failed");
|
|
}
|
|
|
|
mpv_set_option_string(mpv, "vo", "libmpv");
|
|
mpv_set_option_string(mpv, "idle", "yes");
|
|
mpv_set_option_string(mpv, "input-default-bindings", "no");
|
|
mpv_set_option_string(mpv, "osc", "no");
|
|
const int initializeResult = mpv_initialize(mpv);
|
|
if (initializeResult < 0) {
|
|
const std::string error = mpv_error_string(initializeResult);
|
|
mpv_destroy(mpv);
|
|
return runtimeProbeFailure("mpv-initialize-failed", error);
|
|
}
|
|
|
|
GlContext gl;
|
|
std::string error;
|
|
if (!gl.create(error)) {
|
|
gl.destroy();
|
|
mpv_terminate_destroy(mpv);
|
|
return runtimeProbeFailure("gl-context-create-failed", error);
|
|
}
|
|
if (!gl.makeCurrent(error)) {
|
|
gl.destroy();
|
|
mpv_terminate_destroy(mpv);
|
|
return runtimeProbeFailure("gl-context-bind-failed", error);
|
|
}
|
|
|
|
mpv_opengl_init_params glInit = {
|
|
gl.procLoader(),
|
|
gl.procLoaderCtx(),
|
|
};
|
|
mpv_render_param renderParams[] = {
|
|
{MPV_RENDER_PARAM_API_TYPE,
|
|
const_cast<char*>(MPV_RENDER_API_TYPE_OPENGL)},
|
|
{MPV_RENDER_PARAM_OPENGL_INIT_PARAMS, &glInit},
|
|
{MPV_RENDER_PARAM_INVALID, nullptr},
|
|
};
|
|
mpv_render_context* renderContext = nullptr;
|
|
const int renderResult =
|
|
mpv_render_context_create(&renderContext, mpv, renderParams);
|
|
if (renderResult < 0 || !renderContext) {
|
|
const std::string renderError =
|
|
renderResult < 0 ? mpv_error_string(renderResult)
|
|
: "render context unavailable";
|
|
if (renderContext) mpv_render_context_free(renderContext);
|
|
gl.destroy();
|
|
mpv_terminate_destroy(mpv);
|
|
return runtimeProbeFailure("mpv-render-context-failed", renderError);
|
|
}
|
|
|
|
frame_helper::ShmRing runtimeProbeRing;
|
|
const std::string shmName = runtimeProbeShmName();
|
|
if (!runtimeProbeRing.create(shmName, 16, 16, 1)) {
|
|
runtimeProbeRing.destroy();
|
|
mpv_render_context_free(renderContext);
|
|
gl.destroy();
|
|
mpv_terminate_destroy(mpv);
|
|
return runtimeProbeFailure("shared-memory-create-failed");
|
|
}
|
|
const bool sharedMemoryInitialized =
|
|
runtimeProbeRing.base != nullptr &&
|
|
runtimeProbeRing.header != nullptr &&
|
|
runtimeProbeRing.header->magic == FRAME_SHM_MAGIC &&
|
|
runtimeProbeRing.header->version == FRAME_SHM_VERSION &&
|
|
runtimeProbeRing.header->width == 16 &&
|
|
runtimeProbeRing.header->height == 16 &&
|
|
runtimeProbeRing.header->generation == 1;
|
|
runtimeProbeRing.destroy();
|
|
if (!sharedMemoryInitialized) {
|
|
mpv_render_context_free(renderContext);
|
|
gl.destroy();
|
|
mpv_terminate_destroy(mpv);
|
|
return runtimeProbeFailure("shared-memory-initialize-failed");
|
|
}
|
|
|
|
const std::string libmpvVersion = libmpvClientApiVersion();
|
|
mpv_render_context_free(renderContext);
|
|
gl.destroy();
|
|
mpv_terminate_destroy(mpv);
|
|
emitLine(JsonWriter()
|
|
.num("protocol", 1)
|
|
.boolean("usable", true)
|
|
.str("libmpv", libmpvVersion)
|
|
.str("renderApi", gl.renderApiName())
|
|
.finish());
|
|
return 0;
|
|
}
|
|
|
|
} // namespace
|
|
|
|
int main(int argc, char** argv) {
|
|
std::setlocale(LC_NUMERIC, "C");
|
|
#if !defined(_WIN32)
|
|
signal(SIGPIPE, SIG_IGN);
|
|
#endif
|
|
const HelperArgs args = parseArgs(argc, argv);
|
|
if (args.runtimeProbe) {
|
|
return runRuntimeProbe();
|
|
}
|
|
|
|
g_state.mpv = mpv_create();
|
|
if (!g_state.mpv) {
|
|
emitLine(JsonWriter()
|
|
.str("event", "fatal")
|
|
.str("error", "mpv_create failed")
|
|
.finish());
|
|
return 1;
|
|
}
|
|
|
|
mpv_set_option_string(g_state.mpv, "vo", "libmpv");
|
|
mpv_set_option_string(g_state.mpv, "hwdec", args.hwdec.c_str());
|
|
mpv_set_option_string(g_state.mpv, "keep-open", "yes");
|
|
mpv_set_option_string(g_state.mpv, "idle", "yes");
|
|
mpv_set_option_string(g_state.mpv, "input-default-bindings", "no");
|
|
mpv_set_option_string(g_state.mpv, "osc", "no");
|
|
/* Keep parity with the native-view addons and the external MPV launch
|
|
* path: with mpv's youtube-dl hook enabled, a refused HTTP open falls
|
|
* through to yt-dlp and the session error reads "youtube-dl failed"
|
|
* instead of the load error. A user `ytdl=yes` session option
|
|
* (applied below, before mpv_initialize) still overrides it. */
|
|
mpv_set_option_string(g_state.mpv, "ytdl", "no");
|
|
if (!args.audioDelay.empty()) {
|
|
/* Compensates the frame-copy video-path latency to restore
|
|
* lip-sync; calibrated per platform, see the architecture doc. */
|
|
mpv_set_option_string(g_state.mpv, "audio-delay",
|
|
args.audioDelay.c_str());
|
|
}
|
|
std::set<std::string> sessionOptionKeys;
|
|
if (args.mpvOptionsOnStdin) {
|
|
/* `mpv-options\to000=<key=value>\to001=...` in the regular command
|
|
* encoding; zero-padded keys keep the application order (network
|
|
* defaults first, then the user's lines) through the field map. */
|
|
std::string preamble;
|
|
if (!std::getline(std::cin, preamble)) {
|
|
emitLine(JsonWriter()
|
|
.str("event", "fatal")
|
|
.str("error", "mpv-options preamble missing on stdin")
|
|
.finish());
|
|
return 1;
|
|
}
|
|
const frame_helper::Command preambleCommand =
|
|
frame_helper::parseCommandLine(preamble);
|
|
if (preambleCommand.name != "mpv-options") {
|
|
emitLine(JsonWriter()
|
|
.str("event", "fatal")
|
|
.str("error", "expected the mpv-options preamble on stdin")
|
|
.finish());
|
|
return 1;
|
|
}
|
|
std::vector<std::pair<std::string, std::string>> entries(
|
|
preambleCommand.args.begin(), preambleCommand.args.end());
|
|
std::sort(entries.begin(), entries.end());
|
|
for (const auto& entry : entries) {
|
|
const std::string& option = entry.second;
|
|
const size_t separator = option.find('=');
|
|
if (separator == std::string::npos || separator == 0 ||
|
|
separator + 1 >= option.size()) {
|
|
continue;
|
|
}
|
|
const std::string key = option.substr(0, separator);
|
|
const std::string value = option.substr(separator + 1);
|
|
const int optionResult = mpv_set_option_string(
|
|
g_state.mpv, key.c_str(), value.c_str());
|
|
if (optionResult >= 0) {
|
|
sessionOptionKeys.insert(key);
|
|
} else {
|
|
emitLine(JsonWriter()
|
|
.str("event", "log")
|
|
.str("level", "warn")
|
|
.str("prefix", "iptvnator")
|
|
.str("text", "rejected session option " + key +
|
|
": " +
|
|
mpv_error_string(optionResult))
|
|
.finish());
|
|
}
|
|
}
|
|
}
|
|
if (mpv_initialize(g_state.mpv) < 0) {
|
|
emitLine(JsonWriter()
|
|
.str("event", "fatal")
|
|
.str("error", "mpv_initialize failed")
|
|
.finish());
|
|
return 1;
|
|
}
|
|
mpv_request_log_messages(g_state.mpv, "warn");
|
|
|
|
double initialVolumePercent =
|
|
std::clamp(args.volume, 0.0, 1.0) * 100.0;
|
|
mpv_set_property(g_state.mpv, "volume", MPV_FORMAT_DOUBLE,
|
|
&initialVolumePercent);
|
|
|
|
mpv_observe_property(g_state.mpv, 1, "time-pos", MPV_FORMAT_DOUBLE);
|
|
mpv_observe_property(g_state.mpv, 2, "duration", MPV_FORMAT_DOUBLE);
|
|
mpv_observe_property(g_state.mpv, 3, "pause", MPV_FORMAT_FLAG);
|
|
mpv_observe_property(g_state.mpv, 4, "volume", MPV_FORMAT_DOUBLE);
|
|
mpv_observe_property(g_state.mpv, 5, "path", MPV_FORMAT_STRING);
|
|
mpv_observe_property(g_state.mpv, 6, "track-list", MPV_FORMAT_NODE);
|
|
mpv_observe_property(g_state.mpv, 7, "aid", MPV_FORMAT_STRING);
|
|
mpv_observe_property(g_state.mpv, 8, "sid", MPV_FORMAT_STRING);
|
|
mpv_observe_property(g_state.mpv, 9, "speed", MPV_FORMAT_DOUBLE);
|
|
mpv_observe_property(g_state.mpv, 10, "video-aspect-override",
|
|
MPV_FORMAT_STRING);
|
|
mpv_observe_property(g_state.mpv, 11, "eof-reached", MPV_FORMAT_FLAG);
|
|
mpv_observe_property(g_state.mpv, 12, "dwidth", MPV_FORMAT_INT64);
|
|
mpv_observe_property(g_state.mpv, 13, "dheight", MPV_FORMAT_INT64);
|
|
/* Stream diagnostics behind the player's info popover. They change often,
|
|
* but the snapshot emit stays throttled, so this adds no event traffic. */
|
|
mpv_observe_property(g_state.mpv, 14, "estimated-vf-fps",
|
|
MPV_FORMAT_DOUBLE);
|
|
mpv_observe_property(g_state.mpv, 15, "video-bitrate", MPV_FORMAT_DOUBLE);
|
|
mpv_observe_property(g_state.mpv, 16, "audio-bitrate", MPV_FORMAT_DOUBLE);
|
|
mpv_observe_property(g_state.mpv, 17, "video-format", MPV_FORMAT_STRING);
|
|
mpv_observe_property(g_state.mpv, 18, "audio-codec-name",
|
|
MPV_FORMAT_STRING);
|
|
mpv_observe_property(g_state.mpv, 19, "file-format", MPV_FORMAT_STRING);
|
|
mpv_observe_property(g_state.mpv, 20, "demuxer-cache-duration",
|
|
MPV_FORMAT_DOUBLE);
|
|
mpv_observe_property(g_state.mpv, 21, "frame-drop-count",
|
|
MPV_FORMAT_INT64);
|
|
mpv_observe_property(g_state.mpv, 22, "decoder-frame-drop-count",
|
|
MPV_FORMAT_INT64);
|
|
mpv_observe_property(g_state.mpv, 23, "audio-params/channels",
|
|
MPV_FORMAT_STRING);
|
|
mpv_observe_property(g_state.mpv, 24, "audio-params/samplerate",
|
|
MPV_FORMAT_INT64);
|
|
/* File chapters draw the timeline segments and mark closing credits. */
|
|
mpv_observe_property(g_state.mpv, 25, "chapter-list", MPV_FORMAT_NODE);
|
|
|
|
g_state.pipeline.onGenerationChanged = [](const std::string& name,
|
|
int width, int height,
|
|
uint32_t generation) {
|
|
emitLine(JsonWriter()
|
|
.str("event", "shm")
|
|
.str("name", name)
|
|
.num("width", width)
|
|
.num("height", height)
|
|
.num("generation", generation)
|
|
.finish());
|
|
};
|
|
|
|
g_state.pipeline.onSoftwareRenderer = [sessionOptionKeys] {
|
|
applySoftwareRendererOptions(sessionOptionKeys);
|
|
};
|
|
|
|
g_state.viewportWidth = args.width;
|
|
g_state.viewportHeight = args.height;
|
|
|
|
std::string startError;
|
|
if (!g_state.pipeline.start(g_state.mpv, args.shmBase, args.width,
|
|
args.height, startError)) {
|
|
emitLine(JsonWriter()
|
|
.str("event", "fatal")
|
|
.str("error", startError)
|
|
.finish());
|
|
return 1;
|
|
}
|
|
|
|
std::thread renderThread([] { g_state.pipeline.runLoop(); });
|
|
/* The render context is created inside the render thread; hook the
|
|
* update callback once it exists (or bail if GL init failed). */
|
|
while (g_state.pipeline.initState() == 0) {
|
|
std::this_thread::sleep_for(std::chrono::milliseconds(5));
|
|
}
|
|
if (g_state.pipeline.initState() < 0) {
|
|
renderThread.join();
|
|
mpv_terminate_destroy(g_state.mpv);
|
|
return 1;
|
|
}
|
|
mpv_render_context_set_update_callback(g_state.pipeline.renderContext(),
|
|
onRenderUpdate, &g_state.pipeline);
|
|
|
|
#if defined(_WIN32)
|
|
const double helperPid = (double)GetCurrentProcessId();
|
|
#else
|
|
const double helperPid = (double)getpid();
|
|
#endif
|
|
emitLine(JsonWriter()
|
|
.str("event", "hello")
|
|
.num("pid", helperPid)
|
|
.num("protocolVersion", 1)
|
|
.finish());
|
|
|
|
std::thread stdinThread(runStdinLoop);
|
|
runMpvEventLoop();
|
|
|
|
g_state.pipeline.stop();
|
|
renderThread.join();
|
|
mpv_terminate_destroy(g_state.mpv);
|
|
if (stdinThread.joinable()) {
|
|
/* stdin thread exits on EOF; detach if the pipe is still open. */
|
|
stdinThread.detach();
|
|
}
|
|
emitLine(JsonWriter().str("event", "bye").finish());
|
|
return 0;
|
|
}
|