diff --git a/DOCS/HARMONY_FONT_STRATEGY.md b/DOCS/HARMONY_FONT_STRATEGY.md new file mode 100644 index 0000000000..dab57772db --- /dev/null +++ b/DOCS/HARMONY_FONT_STRATEGY.md @@ -0,0 +1,5 @@ +# Harmony 字体策略 + +第一优先级使用系统字体;应用字体只允许由产品明确提供的开源字体;libass 使用 mpv 已配置的字体 provider,不新增商业字体包。 + +中文、英文、日文、韩文采用字体 fallback 链,缺字时交给系统/provider 继续匹配。Playback Lab 只验证字幕渲染和缺字表现,不把字体文件或用户凭证提交到仓库。设备侧若 provider 行为与模拟器不同,应记录实际字体、脚本和缺字结果。 diff --git a/DOCS/NETWORK_PLAYBACK.md b/DOCS/NETWORK_PLAYBACK.md new file mode 100644 index 0000000000..8a979e120e --- /dev/null +++ b/DOCS/NETWORK_PLAYBACK.md @@ -0,0 +1,7 @@ +# 网络播放 + +网络播放复用 mpv/FFmpeg 的单一网络栈,不在产品中创建第二 HTTP client。支持范围是 mpv 已提供的 HTTP、HTTPS、HLS、重定向、Range seek、chunked 和大范围 seek 能力。 + +每次 load 先清空 http-header-fields,再设置本次请求的 headers;Cookie 作为本次请求的 Cookie header 发送,未启用 mpv 持久化 cookie 文件。请求可设置 user-agent、referrer、network-timeout、语言偏好和轨道选择。 + +网络诊断只报告实际可取得的 mpv 属性:协议、host、networkState、cache duration/bytes、demuxer、container、当前 bitrate,以及从实际日志识别到的 HTTP status/redirect count。没有对应属性或日志时返回 0/空值,不做推断。 diff --git a/DOCS/NETWORK_SECURITY.md b/DOCS/NETWORK_SECURITY.md new file mode 100644 index 0000000000..a564803c36 --- /dev/null +++ b/DOCS/NETWORK_SECURITY.md @@ -0,0 +1,15 @@ +# 网络安全 + +## Header 与会话隔离 + +每次 load 都覆盖 http-header-fields,不共享上一个 session 的 header、Cookie 或 URL。禁止把认证信息写入仓库、默认日志或诊断快照。Playback Lab 输入框可以编辑 header,但诊断默认只显示字段名和掩码值。 + +## 脱敏规则 + +Authorization、Proxy-Authorization、Cookie、X-Api-Key 以及 URL 中的 token、auth、key、signature、sig、expires 参数在错误文本和日志中替换为 [REDACTED]。原始 mpv 日志不得直接写入 OH 日志。 + +## TLS、重定向与凭证 + +默认保持 tls-verify=yes。mpv 网络后端负责 TLS、重定向、Range 和 HLS;本层不复制 HTTP 行为。重定向是否携带 header 由 mpv/FFmpeg 网络后端实际决定,若运行时没有可观测证据,不向上层承诺跨 host 凭证继承。Referer、User-Agent、header 仅属于当前加载请求。 + +实现依据:mpv 官方手册的 user-agent、referrer、cookies、http-header-fields、network-timeout、HLS 与 sub-add 选项说明。 diff --git a/DOCS/PHASE5_STATUS.md b/DOCS/PHASE5_STATUS.md new file mode 100644 index 0000000000..3fdfa49191 --- /dev/null +++ b/DOCS/PHASE5_STATUS.md @@ -0,0 +1,13 @@ +# Phase 5 状态 + +状态:CODE COMPLETE / emulator-network validation pending。 + +已完成: + +- 默认软件解码路径保持 hwdec=no,未启用 harmony-ohcodec-gl。 +- 新增 include/mpv/harmony_playback.h 稳定 C API;公共结构不包含 mpv_handle、mpv_node、AVFrame。 +- 请求字段、会话 generation、线程安全控制、稳定状态/错误、轨道、媒体信息和网络缓存诊断已实现。 +- Playback Lab 已接入请求对象、脱敏 header 编辑器、速度/音量/静音、外部字幕、轨道/媒体/网络面板。 +- 文档、测试矩阵和构建矩阵已补齐。 + +尚待真实环境确认:HTTPS/HLS、重定向 header 继承、鉴权资源、网络字幕和物理设备音频/视频输出。不可从本地编译结果推断这些运行时结论。 diff --git a/DOCS/PHASE5_TEST_MATRIX.md b/DOCS/PHASE5_TEST_MATRIX.md new file mode 100644 index 0000000000..169096a9ba --- /dev/null +++ b/DOCS/PHASE5_TEST_MATRIX.md @@ -0,0 +1,16 @@ +# Phase 5 测试矩阵 + +| 场景 | 方式 | 结果 | +|---|---|---| +| 本地 MP4 H264/AAC | Playback Lab | 编译覆盖,设备运行待确认 | +| 本地 MKV H264/AAC/SRT | Playback Lab | 编译覆盖,设备运行待确认 | +| 本地 MKV HEVC/AAC/ASS 软件解码 | software mpv | 编译覆盖,设备运行待确认 | +| HTTP/HTTPS MP4 | 受控测试源 | 网络环境待确认 | +| HLS、chunked、Range、大 seek | 受控测试源 | 网络环境待确认 | +| 多音轨、内置/外置 SRT/ASS、WebVTT | Playback Lab | API/UI 已接入,设备运行待确认 | +| Authorization/Cookie/Referer | 受控测试源 | 脱敏与隔离代码已覆盖,服务端待确认 | +| 重定向、403、404、500、断连、超时、TLS | 受控测试源 | 稳定错误映射已覆盖,网络环境待确认 | +| 默认 FFmpeg / FFmpeg-Harmony | scripts/build-mpv-ohos*.sh | 构建矩阵待执行 | +| Playback Lab HAP | hvigor assemble | 构建矩阵待执行 | + +产品禁止以 UI 定时器伪造 buffering;只有 mpv 事件/属性可改变对应诊断字段。 diff --git a/DOCS/PLAYBACK_PUBLIC_API.md b/DOCS/PLAYBACK_PUBLIC_API.md new file mode 100644 index 0000000000..107a8b4257 --- /dev/null +++ b/DOCS/PLAYBACK_PUBLIC_API.md @@ -0,0 +1,7 @@ +# Playback 公共 API + +mp_harmony_playback.h 是上层唯一稳定契约。PlaybackRequest 只携带 URL、标题、起始位置、User-Agent、Referer、Cookie、任意 HTTP headers、字幕源、语言偏好、轨道选择、速度和超时。空值或负值按头文件注释表示未设置。 + +公开控制命令:Play、Pause、TogglePause、SeekAbsolute、SeekRelative、SeekPercent、Stop、SetSpeed、SetVolume、Mute、SelectAudioTrack、SelectSubtitleTrack、DisableSubtitle、AddSubtitle、RemoveSubtitle、GetTracks、GetMediaInfo、GetPlaybackState。 + +所有控制入口串行化;请求中的字符串在调用返回前复制到 mpv 所需的临时节点,调用者可以立即释放输入内存。session id 随每次 load 增长,旧 playlist event 不得改变新会话状态。 diff --git a/DOCS/SUBTITLE_PIPELINE.md b/DOCS/SUBTITLE_PIPELINE.md new file mode 100644 index 0000000000..19707a11c5 --- /dev/null +++ b/DOCS/SUBTITLE_PIPELINE.md @@ -0,0 +1,7 @@ +# 字幕管线 + +内部字幕和外部字幕统一由 mpv/libass 管线处理。外部源通过 sub-add 加入当前 session,可选中或移除,不销毁播放 session;sub-remove 仅移除指定轨道。 + +目标格式:SRT、ASS/SSA、WebVTT;ASS 默认保留原样式与 libass override 语义。上层可开关、选择字幕、调整 delay、scale、position、override(具体控件按实际可用属性开放)。 + +编码优先 UTF-8;GBK、Big5、Shift-JIS 只在现有 FFmpeg/mpv/iconv 能识别时启用。无法识别或不支持时返回稳定 SUBTITLE 错误,不静默按错误编码显示。 diff --git a/DOCS/TRACK_MODEL.md b/DOCS/TRACK_MODEL.md new file mode 100644 index 0000000000..5ab97ce696 --- /dev/null +++ b/DOCS/TRACK_MODEL.md @@ -0,0 +1,5 @@ +# Track 模型 + +上层只接收固定 Track 字段:id、type、title、language、codec、default、forced、selected、external。mpv_node 只在实现层解析,不跨 NAPI/C API 边界。 + +音频通过 aid 选择,字幕通过 sid 选择或设置为 no。语言偏好使用 mpv 的 alang/slang,可接受 zh、zho、chi、en、eng、ja、jpn 等媒体元数据值。不可用的字段保持空值或 false。 diff --git a/MEMORY.md b/MEMORY.md index 5861524b26..51cbc1a2bb 100644 --- a/MEMORY.md +++ b/MEMORY.md @@ -1,7 +1,7 @@ # Velix-MPV-Core Memory - 项目:独立 HarmonyOS MPV 播放内核,禁止混入 Velix 主应用源码。 -- 当前阶段:Phase 4.5 CODE COMPLETE / DEVICE VALIDATION PENDING;GPU INTEROP MAPPER 未进入;FFmpeg-Harmony export contract 已构建验证,模拟器仅有既有 MPV/EGL smoke。 +- 当前阶段:Phase 5 CODE COMPLETE / emulator-network validation pending;GPU INTEROP MAPPER 未进入;默认软件解码与网络播放公共 API 已构建验证。 - 基线:MPV `182fa6ca49f455cadb884858f386e2f00540aeb7`,分支 `harmony-port`。 - SDK:OpenHarmony API 24 / 6.1.1.125 / `aarch64-linux-ohos` / Clang 15.0.4。 - 关键结论:首版 GLES+EGL+libmpv Render API;OHAudio AO;官方 FFmpeg OHCodec 路线。 @@ -13,4 +13,6 @@ - Phase 4:`IMGFMT_OHCODEC`、`hwdec=ohcodec`、API24 capability adapter、direct-surface experimental driver、软件构建矩阵、Playback Lab 诊断已提交;FFmpeg 保持 upstream。 - Phase 4 边界:官方 OHCodec hwcontext/decoder 仅提供 surface 输出,NativeBuffer→EGL 仅做禁用默认 API probe,未宣称零拷贝/HDR/真实硬件帧;详见 `DOCS/OHCODEC_GPU_INTEROP_DESIGN.md`。 - Phase 4.5 边界:FFmpeg export 模式不调用 RenderOutputBuffer,NativeBuffer/accessor/refcount/build probes 已完成;crop/fence/真实硬解仍需设备验证,`harmony-ohcodec-gl` 继续 disabled。 +- Phase 5:include/mpv/harmony_playback.h + player/harmony_playback.c 提供不暴露 mpv 类型的稳定 PlaybackRequest/Track/Media/Network/State/Error API;Playback Lab 已接入请求 headers、脱敏、轨道、字幕和诊断面板。 +- Phase 5 构建:默认、software、FFmpeg-Harmony 三套 AArch64 libmpv 构建及 Native/ArkTS 编译通过;HAP PackageHap 因当前环境缺 Java Runtime 未完成,网络/HLS/实体设备运行待验证。 - 索引:`memory/projects.md`、`memory/infra.md`、`memory/lessons.md`、`memory/2026-08-28.md`。 diff --git a/include/mpv/harmony_playback.h b/include/mpv/harmony_playback.h new file mode 100644 index 0000000000..badcaafda9 --- /dev/null +++ b/include/mpv/harmony_playback.h @@ -0,0 +1,218 @@ +/* Stable Harmony playback contract. This header intentionally does not + * expose libmpv, FFmpeg or render implementation types. */ +#ifndef MPV_HARMONY_PLAYBACK_H_ +#define MPV_HARMONY_PLAYBACK_H_ + +#include +#include + +#ifndef MP_HARMONY_EXPORT +#ifdef _WIN32 +#define MP_HARMONY_EXPORT __declspec(dllexport) +#elif defined(__GNUC__) || defined(__clang__) +#define MP_HARMONY_EXPORT __attribute__((visibility("default"))) +#else +#define MP_HARMONY_EXPORT +#endif +#endif + +#ifdef __cplusplus +extern "C" { +#endif + +#define MP_HARMONY_PLAYBACK_API_VERSION 1u +#define MP_HARMONY_TEXT 256 +#define MP_HARMONY_URL 2048 +#define MP_HARMONY_MAX_HEADERS 32 +#define MP_HARMONY_MAX_SUBTITLES 32 +#define MP_HARMONY_MAX_TRACKS 64 + +typedef struct mp_harmony_player mp_harmony_player; + +typedef enum mp_harmony_state { + MP_HARMONY_IDLE = 0, + MP_HARMONY_OPENING, + MP_HARMONY_BUFFERING, + MP_HARMONY_READY, + MP_HARMONY_PLAYING, + MP_HARMONY_PAUSED, + MP_HARMONY_SEEKING, + MP_HARMONY_ENDED, + MP_HARMONY_STOPPED, + MP_HARMONY_ERROR, + MP_HARMONY_RELEASED, +} mp_harmony_state; + +typedef enum mp_harmony_error_code { + MP_HARMONY_ERROR_NONE = 0, + MP_HARMONY_ERROR_NETWORK, + MP_HARMONY_ERROR_TLS, + MP_HARMONY_ERROR_HTTP, + MP_HARMONY_ERROR_DEMUX, + MP_HARMONY_ERROR_UNSUPPORTED_CONTAINER, + MP_HARMONY_ERROR_UNSUPPORTED_VIDEO_CODEC, + MP_HARMONY_ERROR_UNSUPPORTED_AUDIO_CODEC, + MP_HARMONY_ERROR_SUBTITLE, + MP_HARMONY_ERROR_DECODER, + MP_HARMONY_ERROR_AUDIO_OUTPUT, + MP_HARMONY_ERROR_VIDEO_OUTPUT, + MP_HARMONY_ERROR_SEEK, + MP_HARMONY_ERROR_FILE_NOT_FOUND, + MP_HARMONY_ERROR_PERMISSION, + MP_HARMONY_ERROR_UNKNOWN, +} mp_harmony_error_code; + +typedef enum mp_harmony_seek_mode { + MP_HARMONY_SEEK_RELATIVE = 0, + MP_HARMONY_SEEK_ABSOLUTE, + MP_HARMONY_SEEK_PERCENT, +} mp_harmony_seek_mode; + +typedef enum mp_harmony_track_type { + MP_HARMONY_TRACK_VIDEO = 0, + MP_HARMONY_TRACK_AUDIO, + MP_HARMONY_TRACK_SUBTITLE, +} mp_harmony_track_type; + +typedef struct mp_harmony_http_header { + const char *name; + const char *value; +} mp_harmony_http_header; + +typedef struct mp_harmony_subtitle_source { + const char *url; + const char *title; + const char *language; + const char *encoding; + int forced; + int select; +} mp_harmony_subtitle_source; + +typedef struct mp_harmony_playback_request { + const char *url; + const char *title; + int64_t start_position_ms; /* < 0 means unset. */ + const char *user_agent; + const char *referer; + const char *cookies; + const mp_harmony_http_header *http_headers; + size_t http_header_count; + const mp_harmony_subtitle_source *subtitle_files; + size_t subtitle_file_count; + const char *preferred_audio_language; + const char *preferred_subtitle_language; + int subtitle_enabled; /* < 0 means unset, 0 disables, 1 enables. */ + int audio_track_id; /* < 0 means auto. */ + int subtitle_track_id; /* < 0 means auto. */ + double playback_speed; /* <= 0 means unset. */ + double network_timeout; /* <= 0 means mpv default. */ +} mp_harmony_playback_request; + +typedef struct mp_harmony_track { + int id; + mp_harmony_track_type type; + char title[MP_HARMONY_TEXT]; + char language[MP_HARMONY_TEXT]; + char codec[MP_HARMONY_TEXT]; + int is_default; + int forced; + int selected; + int external; +} mp_harmony_track; + +typedef struct mp_harmony_media_info { + char container[MP_HARMONY_TEXT]; + char demuxer[MP_HARMONY_TEXT]; + char video_codec[MP_HARMONY_TEXT]; + char audio_codec[MP_HARMONY_TEXT]; + char pixel_format[MP_HARMONY_TEXT]; + char color_primaries[MP_HARMONY_TEXT]; + char color_transfer[MP_HARMONY_TEXT]; + char color_space[MP_HARMONY_TEXT]; + int width; + int height; + int bit_depth; + int audio_sample_rate; + int audio_channels; + char audio_channel_layout[MP_HARMONY_TEXT]; + double fps; + int64_t video_bitrate; + int64_t audio_bitrate; + int track_count; + int subtitle_count; +} mp_harmony_media_info; + +typedef struct mp_harmony_network_info { + char protocol[MP_HARMONY_TEXT]; + char host[MP_HARMONY_TEXT]; + char network_state[MP_HARMONY_TEXT]; + int http_status; + int redirect_count; + int buffering; + double cache_duration; + int64_t cache_bytes; + char demuxer[MP_HARMONY_TEXT]; + char container[MP_HARMONY_TEXT]; + int64_t current_bitrate; +} mp_harmony_network_info; + +typedef struct mp_harmony_playback_snapshot { + uint64_t session_id; + mp_harmony_state state; + mp_harmony_error_code error_code; + char error_message[MP_HARMONY_TEXT]; + double position; + double duration; + double fps; + int64_t dropped_frames; + int64_t first_frame_ms; + int64_t render_count; + int volume; + int muted; + double speed; + int track_count; + mp_harmony_media_info media; + mp_harmony_network_info network; +} mp_harmony_playback_snapshot; + +MP_HARMONY_EXPORT mp_harmony_player *mp_harmony_playback_create(void); +MP_HARMONY_EXPORT int mp_harmony_playback_initialize(mp_harmony_player *player); +MP_HARMONY_EXPORT void mp_harmony_playback_destroy(mp_harmony_player *player); +MP_HARMONY_EXPORT int mp_harmony_playback_load(mp_harmony_player *player, + const mp_harmony_playback_request *request); +MP_HARMONY_EXPORT int mp_harmony_playback_play(mp_harmony_player *player); +MP_HARMONY_EXPORT int mp_harmony_playback_pause(mp_harmony_player *player); +MP_HARMONY_EXPORT int mp_harmony_playback_toggle_pause(mp_harmony_player *player); +MP_HARMONY_EXPORT int mp_harmony_playback_stop(mp_harmony_player *player); +MP_HARMONY_EXPORT int mp_harmony_playback_seek(mp_harmony_player *player, mp_harmony_seek_mode mode, + double value, int exact, int keyframe); +MP_HARMONY_EXPORT int mp_harmony_playback_set_speed(mp_harmony_player *player, double speed); +MP_HARMONY_EXPORT int mp_harmony_playback_set_volume(mp_harmony_player *player, double volume); +MP_HARMONY_EXPORT int mp_harmony_playback_set_mute(mp_harmony_player *player, int muted); +MP_HARMONY_EXPORT int mp_harmony_playback_select_audio(mp_harmony_player *player, int track_id); +MP_HARMONY_EXPORT int mp_harmony_playback_select_subtitle(mp_harmony_player *player, int track_id); +MP_HARMONY_EXPORT int mp_harmony_playback_disable_subtitle(mp_harmony_player *player); +MP_HARMONY_EXPORT int mp_harmony_playback_add_subtitle(mp_harmony_player *player, + const mp_harmony_subtitle_source *source); +MP_HARMONY_EXPORT int mp_harmony_playback_remove_subtitle(mp_harmony_player *player, int track_id); +MP_HARMONY_EXPORT int mp_harmony_playback_set_subtitle_delay(mp_harmony_player *player, double seconds); +MP_HARMONY_EXPORT int mp_harmony_playback_set_subtitle_scale(mp_harmony_player *player, double scale); +MP_HARMONY_EXPORT int mp_harmony_playback_set_subtitle_position(mp_harmony_player *player, double position); +MP_HARMONY_EXPORT int mp_harmony_playback_set_ass_override(mp_harmony_player *player, const char *mode); +MP_HARMONY_EXPORT int mp_harmony_playback_get_tracks(mp_harmony_player *player, + mp_harmony_track *tracks, size_t capacity, + size_t *count); +MP_HARMONY_EXPORT int mp_harmony_playback_get_media_info(mp_harmony_player *player, + mp_harmony_media_info *info); +MP_HARMONY_EXPORT int mp_harmony_playback_get_network_info(mp_harmony_player *player, + mp_harmony_network_info *info); +MP_HARMONY_EXPORT int mp_harmony_playback_get_snapshot(mp_harmony_player *player, + mp_harmony_playback_snapshot *snapshot); +MP_HARMONY_EXPORT const char *mp_harmony_playback_state_name(mp_harmony_state state); +MP_HARMONY_EXPORT const char *mp_harmony_playback_error_name(mp_harmony_error_code error); + +#ifdef __cplusplus +} +#endif + +#endif diff --git a/memory/2026-08-29.md b/memory/2026-08-29.md index 6ddf73add5..ddeea4c7c1 100644 --- a/memory/2026-08-29.md +++ b/memory/2026-08-29.md @@ -3,3 +3,9 @@ 文件变更: ../Velix-FFmpeg-Harmony/libavcodec/ohdec.c, libavutil/hwcontext_oh.*, scripts/*, DOCS/*; Velix-MPV-Core/scripts/build-mpv-ohos*.sh, scripts/pkg-config-ohos.sh, DOCS/MPV_BUILD_REPORT.md, DOCS/PHASE4_STATUS.md, memory/* 教训: emulator 可用于设备连接与既有 HAP/EGL 日志复核,但不能把 compile/link probe 写成 FFmpeg export 硬解运行证据;Gitea 大 pack 需分块保留完整历史。 标签: #phase4-5 #ffmpeg #ohcodec #nativebuffer #prefix-switch #emulator #pending-device + +[PROJECT:Velix-MPV-Core] 完成 Phase 5 Playback Capability & Network Compatibility +结论: 新增不暴露 mpv 内部类型的稳定 Playback C API、请求/会话隔离、错误/状态/轨道/媒体/网络/字幕能力及 Playback Lab 诊断 UI;默认、software、FFmpeg-Harmony AArch64 构建与 Native/ArkTS 编译通过,HAP 打包因缺 Java Runtime 阻断,真实网络/实体设备待验证。 +文件变更: include/mpv/harmony_playback.h, player/harmony_playback.c, playback_lab/entry/src/main/cpp/{player.h,player.cpp,bridge.cpp}, playback_lab/entry/src/main/ets/pages/Index.ets, DOCS/PHASE5_*.md, DOCS/PLAYBACK_PUBLIC_API.md, DOCS/NETWORK_*.md, DOCS/TRACK_MODEL.md, DOCS/SUBTITLE_PIPELINE.md, DOCS/HARMONY_FONT_STRATEGY.md +教训: hvigor 的 CompileArkTS/BuildNativeWithNinja 可在无 Java 环境通过,但 PackageHap 仍需 JDK;HAP 不应把 Native/ArkTS 编译成功误报为完整打包成功。 +标签: #phase5 #playback-api #network #subtitle #redaction #software-decode #pending-runtime diff --git a/memory/infra.md b/memory/infra.md index c02f945683..50e9f1ddb2 100644 --- a/memory/infra.md +++ b/memory/infra.md @@ -15,7 +15,7 @@ - hdc:`/Applications/DevEco-Studio.app/Contents/sdk/default/openharmony/toolchains/hdc` - 模拟器目标:`127.0.0.1:5555`,HarmonyOS 6.1.0.126 / API 24 - Playback Lab 构建:在 `playback_lab/` 执行 `scripts/stage-mpv.sh` 后用 hvigor `--no-daemon assembleHap --mode module -p product=default` -- HAP 构建 Java:`/Applications/DevEco-Studio.app/Contents/jbr/Contents/Home`;未配置签名时产物为 `entry-default-unsigned.hap`。 +- HAP 构建:当前环境无可用 Java Runtime,hvigor 可完成 Native/ArkTS 编译但 PackageHap 阶段失败;安装 JDK 后重试。 - OHAudio:API 24 headers `ohaudio/native_audiostreambuilder.h` / `native_audiorenderer.h` / `native_audiostream_base.h`,动态库 `libohaudio.so`;Meson 使用 `-Dharmony-audio=enabled`。 - Phase 3 HAP:`playback_lab/entry/build/default/outputs/default/entry-default-unsigned.hap`;模拟器 H.264/AAC smoke target `127.0.0.1:5555`。 - Phase 4 构建入口:`scripts/build-mpv-ohos.sh`(OHCodec enabled)、`scripts/build-mpv-ohos-software.sh`(OHCodec disabled)、`scripts/build-all-ohos.sh`(全量矩阵);GL API probe 通过单独 Meson `harmony-ohcodec-gl=enabled` 构建验证。 diff --git a/memory/projects.md b/memory/projects.md index ffbe2370f9..b8be31ad14 100644 --- a/memory/projects.md +++ b/memory/projects.md @@ -2,7 +2,7 @@ ## Velix-MPV-Core -- 状态:Phase 4.5 CODE COMPLETE / DEVICE VALIDATION PENDING;GPU INTEROP MAPPER 未进入;FFmpeg-Harmony export contract 已完成独立构建/探针,实体机硬解验收未完成。 +- 状态:Phase 5 CODE COMPLETE / emulator-network validation pending;GPU INTEROP MAPPER 未进入;默认软件解码与网络播放公共 API 已完成,实体机/真实网络验收未完成。 - 已完成:远程闭环、固定依赖、隔离工具链、C/C++ 探针、FFmpeg/OpenSSL/字体栈/libplacebo、AArch64 `libmpv.so.2.5.0`、ABI/符号/污染/许可证审计。 - 产物:`build/ohos-arm64/prefix/lib/libmpv.so.2.5.0`,当前重建 34,314,184 bytes;切换构建位于 `build/ohos-arm64/prefix-mpv-ffmpeg-harmony/lib/libmpv.so.2.5.0`;DT_NEEDED 含 `libohaudio.so`。 - 已完成:独立 HAP、XComponent/OHNativeWindow、EGL ES3、libmpv OpenGL Render、软件解码本地 MP4 首帧、播放控制与释放重入。 @@ -11,3 +11,4 @@ - 边界:官方 OHCodec 上游没有非 surface 的可导入硬件帧路径,EGL/NativeBuffer 仅保留禁用默认探针;无零拷贝、HDR 或硬件帧产出声明。 - Phase 4.5:`../Velix-FFmpeg-Harmony` branch `harmony-ohcodec-export` 已推送 tip `990614364f`;`scripts/build-mpv-ohos-ffmpeg-harmony.sh` 已消费其 prefix 构建 MPV。 - 待办:实体机 H.264/HEVC capability、surface 生命周期/stride/crop/fence、真实 GPU 互操作与 A/V sync;报告见两个仓库的 `DOCS/OHCODEC_*.md` 与 `DOCS/PHASE4_5_STATUS.md`。 +- Phase 5 已完成:稳定 C API、请求/headers 会话隔离、错误/状态、轨道/媒体/网络/字幕控制、Playback Lab 诊断 UI 与文档;默认/软件/FFmpeg-Harmony 构建通过,HAP 打包缺 Java,HTTP/HLS/字幕运行验证待补。 diff --git a/meson.build b/meson.build index 922a623646..a907f15461 100644 --- a/meson.build +++ b/meson.build @@ -168,6 +168,7 @@ sources = files( ## Player 'player/audio.c', + 'player/harmony_playback.c', 'player/client.c', 'player/command.c', 'player/configfiles.c', @@ -1914,7 +1915,8 @@ if get_option('libmpv') description: 'mpv media player client library') headers = ['include/mpv/client.h', 'include/mpv/render.h', - 'include/mpv/render_gl.h', 'include/mpv/stream_cb.h'] + 'include/mpv/render_gl.h', 'include/mpv/stream_cb.h', + 'include/mpv/harmony_playback.h'] install_headers(headers, subdir: 'mpv') # Allow projects to build with libmpv by cloning into ./subprojects/mpv diff --git a/playback_lab/README.md b/playback_lab/README.md index ded67f88fe..6c991efd28 100644 --- a/playback_lab/README.md +++ b/playback_lab/README.md @@ -12,3 +12,5 @@ `stage-mpv.sh` 只把本地 Phase 1 产物复制到未跟踪的 HAP native 库目录,不把 34 MB 的二进制提交到仓库。 面板中的 `OHCodec Probe` 只请求 `hwdec=ohcodec`,不会把当前 EGL window 注入 Direct Surface。没有独占 `ohos_direct_surface` 时,MPV 必须回退软件解码;面板会显示 requested/actual decoder、hwdec state、capability 摘要和回退原因。 + +Phase 5 的网络请求对象、轨道/媒体/网络诊断和字幕控制见 `DOCS/PLAYBACK_PUBLIC_API.md` 及 `DOCS/NETWORK_SECURITY.md`。header 输入仅用于当前 load,诊断和日志脱敏;默认构建仍使用软件解码。 diff --git a/playback_lab/entry/src/main/cpp/bridge.cpp b/playback_lab/entry/src/main/cpp/bridge.cpp index 6964923dbc..c5394a9942 100644 --- a/playback_lab/entry/src/main/cpp/bridge.cpp +++ b/playback_lab/entry/src/main/cpp/bridge.cpp @@ -7,6 +7,7 @@ #include #include +#include #include #undef LOG_DOMAIN @@ -56,6 +57,94 @@ bool getDoubleArg(napi_env env, napi_callback_info info, double& value) return napi_get_value_double(env, argv[0], &value) == napi_ok; } +bool getNamedString(napi_env env, napi_value object, const char* name, std::string& value) +{ + bool has = false; + if (napi_has_named_property(env, object, name, &has) != napi_ok || !has) return false; + napi_value property = nullptr; + size_t size = 0; + if (napi_get_named_property(env, object, name, &property) != napi_ok || + napi_get_value_string_utf8(env, property, nullptr, 0, &size) != napi_ok) return false; + value.resize(size + 1); + if (napi_get_value_string_utf8(env, property, value.data(), value.size(), &size) != napi_ok) return false; + value.resize(size); + return true; +} + +bool getNamedDouble(napi_env env, napi_value object, const char* name, double& value) +{ + napi_value property = nullptr; + bool has = false; + return napi_has_named_property(env, object, name, &has) == napi_ok && has && + napi_get_named_property(env, object, name, &property) == napi_ok && + napi_get_value_double(env, property, &value) == napi_ok; +} + +bool getNamedInt64(napi_env env, napi_value object, const char* name, int64_t& value) +{ + double number = 0; + if (!getNamedDouble(env, object, name, number)) return false; + value = static_cast(number); + return true; +} + +bool getNamedBool(napi_env env, napi_value object, const char* name, bool& value) +{ + napi_value property = nullptr; + bool has = false; + return napi_has_named_property(env, object, name, &has) == napi_ok && has && + napi_get_named_property(env, object, name, &property) == napi_ok && + napi_get_value_bool(env, property, &value) == napi_ok; +} + +bool getRequest(napi_env env, napi_callback_info info, PlaybackRequest& request) +{ + size_t argc = 1; + napi_value argv[1] = {nullptr}; + if (napi_get_cb_info(env, info, &argc, argv, nullptr, nullptr) != napi_ok || argc < 1) return false; + napi_valuetype type = napi_undefined; + napi_typeof(env, argv[0], &type); + if (type == napi_string) { + return getStringArg(env, info, request.url); + } + if (type != napi_object) return false; + if (!getNamedString(env, argv[0], "url", request.url) || request.url.empty()) return false; + getNamedString(env, argv[0], "title", request.title); + getNamedString(env, argv[0], "userAgent", request.userAgent); + getNamedString(env, argv[0], "referer", request.referer); + getNamedString(env, argv[0], "cookies", request.cookies); + getNamedString(env, argv[0], "preferredAudioLanguage", request.preferredAudioLanguage); + getNamedString(env, argv[0], "preferredSubtitleLanguage", request.preferredSubtitleLanguage); + double number = 0; + if (getNamedDouble(env, argv[0], "playbackSpeed", number)) request.playbackSpeed = number; + if (getNamedDouble(env, argv[0], "networkTimeout", number)) request.networkTimeout = number; + int64_t start = -1; + if (getNamedInt64(env, argv[0], "startPositionMs", start)) request.startPositionMs = start; + int64_t track = -1; + if (getNamedInt64(env, argv[0], "audioTrackId", track)) request.audioTrackId = static_cast(track); + if (getNamedInt64(env, argv[0], "subtitleTrackId", track)) request.subtitleTrackId = static_cast(track); + bool enabled = true; + if (getNamedBool(env, argv[0], "subtitleEnabled", enabled)) request.subtitleEnabled = enabled ? 1 : 0; + + napi_value headers = nullptr; + bool hasHeaders = false; + if (napi_has_named_property(env, argv[0], "httpHeaders", &hasHeaders) == napi_ok && hasHeaders && + napi_get_named_property(env, argv[0], "httpHeaders", &headers) == napi_ok) { + uint32_t length = 0; + if (napi_get_array_length(env, headers, &length) == napi_ok) { + for (uint32_t i = 0; i < length && i < 32; ++i) { + napi_value item = nullptr; + if (napi_get_element(env, headers, i, &item) != napi_ok) continue; + PlaybackHeader header; + getNamedString(env, item, "name", header.name); + getNamedString(env, item, "value", header.value); + if (!header.name.empty()) request.headers.push_back(std::move(header)); + } + } + } + return true; +} + napi_value undefined(napi_env env) { napi_value result = nullptr; @@ -65,8 +154,8 @@ napi_value undefined(napi_env env) napi_value load(napi_env env, napi_callback_info info) { - std::string url; - if (getStringArg(env, info, url)) gPlayer.load(url); + PlaybackRequest request; + if (getRequest(env, info, request)) gPlayer.load(request); return undefined(env); } @@ -111,6 +200,12 @@ napi_value pause(napi_env env, napi_callback_info) return undefined(env); } +napi_value togglePause(napi_env env, napi_callback_info) +{ + gPlayer.togglePause(); + return undefined(env); +} + napi_value seek(napi_env env, napi_callback_info info) { double delta = 0; @@ -118,6 +213,138 @@ napi_value seek(napi_env env, napi_callback_info info) return undefined(env); } +napi_value seekAbsolute(napi_env env, napi_callback_info info) +{ + double seconds = 0; + if (getDoubleArg(env, info, seconds)) gPlayer.seekAbsolute(seconds, false, false); + return undefined(env); +} + +napi_value seekPercent(napi_env env, napi_callback_info info) +{ + double percent = 0; + if (getDoubleArg(env, info, percent)) gPlayer.seekPercent(percent, false, false); + return undefined(env); +} + +napi_value setSpeed(napi_env env, napi_callback_info info) +{ + double speed = 1; + napi_value result = nullptr; + napi_get_boolean(env, getDoubleArg(env, info, speed) && gPlayer.setSpeed(speed), &result); + return result; +} + +napi_value setVolume(napi_env env, napi_callback_info info) +{ + double volume = 100; + napi_value result = nullptr; + napi_get_boolean(env, getDoubleArg(env, info, volume) && gPlayer.setVolume(volume), &result); + return result; +} + +napi_value setMute(napi_env env, napi_callback_info info) +{ + size_t argc = 1; + napi_value argv[1] = {nullptr}; + bool muted = false; + if (napi_get_cb_info(env, info, &argc, argv, nullptr, nullptr) != napi_ok || argc < 1 || + napi_get_value_bool(env, argv[0], &muted) != napi_ok) return undefined(env); + napi_value result = nullptr; + napi_get_boolean(env, gPlayer.setMute(muted), &result); + return result; +} + +napi_value selectAudioTrack(napi_env env, napi_callback_info info) +{ + double id = -1; + napi_value result = nullptr; + napi_get_boolean(env, getDoubleArg(env, info, id) && gPlayer.selectAudioTrack(static_cast(id)), &result); + return result; +} + +napi_value selectSubtitleTrack(napi_env env, napi_callback_info info) +{ + double id = -1; + napi_value result = nullptr; + napi_get_boolean(env, getDoubleArg(env, info, id) && gPlayer.selectSubtitleTrack(static_cast(id)), &result); + return result; +} + +napi_value disableSubtitle(napi_env env, napi_callback_info) +{ + napi_value result = nullptr; + napi_get_boolean(env, gPlayer.disableSubtitle(), &result); + return result; +} + +napi_value addSubtitle(napi_env env, napi_callback_info info) +{ + PlaybackSubtitleSource source; + size_t argc = 1; + napi_value argv[1] = {nullptr}; + if (napi_get_cb_info(env, info, &argc, argv, nullptr, nullptr) != napi_ok || argc < 1) return undefined(env); + napi_valuetype type = napi_undefined; + napi_typeof(env, argv[0], &type); + if (type == napi_string) { + size_t size = 0; + napi_get_value_string_utf8(env, argv[0], nullptr, 0, &size); + source.url.resize(size + 1); + napi_get_value_string_utf8(env, argv[0], source.url.data(), source.url.size(), &size); + source.url.resize(size); + } else if (type == napi_object) { + getNamedString(env, argv[0], "url", source.url); + getNamedString(env, argv[0], "title", source.title); + getNamedString(env, argv[0], "language", source.language); + getNamedString(env, argv[0], "encoding", source.encoding); + getNamedBool(env, argv[0], "forced", source.forced); + getNamedBool(env, argv[0], "select", source.select); + } + napi_value result = nullptr; + napi_get_boolean(env, gPlayer.addSubtitle(source), &result); + return result; +} + +napi_value removeSubtitle(napi_env env, napi_callback_info info) +{ + double id = -1; + napi_value result = nullptr; + napi_get_boolean(env, getDoubleArg(env, info, id) && gPlayer.removeSubtitle(static_cast(id)), &result); + return result; +} + +napi_value setSubtitleDelay(napi_env env, napi_callback_info info) +{ + double seconds = 0; + napi_value result = nullptr; + napi_get_boolean(env, getDoubleArg(env, info, seconds) && gPlayer.setSubtitleDelay(seconds), &result); + return result; +} + +napi_value setSubtitleScale(napi_env env, napi_callback_info info) +{ + double scale = 1; + napi_value result = nullptr; + napi_get_boolean(env, getDoubleArg(env, info, scale) && gPlayer.setSubtitleScale(scale), &result); + return result; +} + +napi_value setSubtitlePosition(napi_env env, napi_callback_info info) +{ + double position = 100; + napi_value result = nullptr; + napi_get_boolean(env, getDoubleArg(env, info, position) && gPlayer.setSubtitlePosition(position), &result); + return result; +} + +napi_value setAssOverride(napi_env env, napi_callback_info info) +{ + std::string mode; + napi_value result = nullptr; + napi_get_boolean(env, getStringArg(env, info, mode) && gPlayer.setAssOverride(mode), &result); + return result; +} + napi_value stop(napi_env env, napi_callback_info) { gPlayer.stop(); @@ -155,6 +382,88 @@ napi_value getState(napi_env env, napi_callback_info) napi_set_named_property(env, object, "renderCount", value); napi_create_string_utf8(env, state.lastError.c_str(), NAPI_AUTO_LENGTH, &value); napi_set_named_property(env, object, "lastError", value); + napi_create_string_utf8(env, state.errorCode.c_str(), NAPI_AUTO_LENGTH, &value); + napi_set_named_property(env, object, "errorCode", value); + napi_create_double(env, static_cast(state.sessionId), &value); + napi_set_named_property(env, object, "sessionId", value); + napi_create_double(env, state.speed, &value); + napi_set_named_property(env, object, "speed", value); + napi_create_double(env, state.volume, &value); + napi_set_named_property(env, object, "volume", value); + napi_get_boolean(env, state.muted, &value); + napi_set_named_property(env, object, "muted", value); + napi_value network = nullptr; + napi_create_object(env, &network); + napi_create_string_utf8(env, state.network.protocol.c_str(), NAPI_AUTO_LENGTH, &value); + napi_set_named_property(env, network, "protocol", value); + napi_create_string_utf8(env, state.network.host.c_str(), NAPI_AUTO_LENGTH, &value); + napi_set_named_property(env, network, "host", value); + napi_create_string_utf8(env, state.network.networkState.c_str(), NAPI_AUTO_LENGTH, &value); + napi_set_named_property(env, network, "networkState", value); + napi_create_int32(env, state.network.httpStatus, &value); + napi_set_named_property(env, network, "httpStatus", value); + napi_create_int32(env, state.network.redirectCount, &value); + napi_set_named_property(env, network, "redirectCount", value); + napi_get_boolean(env, state.network.buffering, &value); + napi_set_named_property(env, network, "buffering", value); + napi_create_double(env, state.network.cacheDuration, &value); + napi_set_named_property(env, network, "cacheDuration", value); + napi_create_int64(env, state.network.cacheBytes, &value); + napi_set_named_property(env, network, "cacheBytes", value); + napi_create_string_utf8(env, state.network.demuxer.c_str(), NAPI_AUTO_LENGTH, &value); + napi_set_named_property(env, network, "demuxer", value); + napi_create_string_utf8(env, state.network.container.c_str(), NAPI_AUTO_LENGTH, &value); + napi_set_named_property(env, network, "container", value); + napi_create_int64(env, state.network.currentBitrate, &value); + napi_set_named_property(env, network, "currentBitrate", value); + napi_set_named_property(env, object, "network", network); + napi_value media = nullptr; + napi_create_object(env, &media); + napi_create_string_utf8(env, state.media.container.c_str(), NAPI_AUTO_LENGTH, &value); + napi_set_named_property(env, media, "container", value); + napi_create_string_utf8(env, state.media.demuxer.c_str(), NAPI_AUTO_LENGTH, &value); + napi_set_named_property(env, media, "demuxer", value); + napi_create_string_utf8(env, state.media.videoCodec.c_str(), NAPI_AUTO_LENGTH, &value); + napi_set_named_property(env, media, "videoCodec", value); + napi_create_string_utf8(env, state.media.audioCodec.c_str(), NAPI_AUTO_LENGTH, &value); + napi_set_named_property(env, media, "audioCodec", value); + napi_create_string_utf8(env, state.media.pixelFormat.c_str(), NAPI_AUTO_LENGTH, &value); + napi_set_named_property(env, media, "pixelFormat", value); + napi_create_int32(env, state.media.width, &value); + napi_set_named_property(env, media, "width", value); + napi_create_int32(env, state.media.height, &value); + napi_set_named_property(env, media, "height", value); + napi_create_double(env, state.media.fps, &value); + napi_set_named_property(env, media, "fps", value); + napi_create_int32(env, state.media.bitDepth, &value); + napi_set_named_property(env, media, "bitDepth", value); + napi_create_int32(env, state.media.audioSampleRate, &value); + napi_set_named_property(env, media, "audioSampleRate", value); + napi_create_int32(env, state.media.audioChannels, &value); + napi_set_named_property(env, media, "audioChannels", value); + napi_create_int32(env, state.media.trackCount, &value); + napi_set_named_property(env, media, "trackCount", value); + napi_create_int32(env, state.media.subtitleCount, &value); + napi_set_named_property(env, media, "subtitleCount", value); + napi_set_named_property(env, object, "media", media); + napi_value tracks = nullptr; + napi_create_array_with_length(env, state.tracks.size(), &tracks); + for (size_t i = 0; i < state.tracks.size(); ++i) { + const PlaybackTrack& track = state.tracks[i]; + napi_value item = nullptr; + napi_create_object(env, &item); + napi_create_int32(env, track.id, &value); napi_set_named_property(env, item, "id", value); + napi_create_string_utf8(env, track.type.c_str(), NAPI_AUTO_LENGTH, &value); napi_set_named_property(env, item, "type", value); + napi_create_string_utf8(env, track.title.c_str(), NAPI_AUTO_LENGTH, &value); napi_set_named_property(env, item, "title", value); + napi_create_string_utf8(env, track.language.c_str(), NAPI_AUTO_LENGTH, &value); napi_set_named_property(env, item, "language", value); + napi_create_string_utf8(env, track.codec.c_str(), NAPI_AUTO_LENGTH, &value); napi_set_named_property(env, item, "codec", value); + napi_get_boolean(env, track.isDefault, &value); napi_set_named_property(env, item, "default", value); + napi_get_boolean(env, track.forced, &value); napi_set_named_property(env, item, "forced", value); + napi_get_boolean(env, track.selected, &value); napi_set_named_property(env, item, "selected", value); + napi_get_boolean(env, track.external, &value); napi_set_named_property(env, item, "external", value); + napi_set_element(env, tracks, i, item); + } + napi_set_named_property(env, object, "tracks", tracks); napi_create_string_utf8(env, state.requestedDecoder.c_str(), NAPI_AUTO_LENGTH, &value); napi_set_named_property(env, object, "requestedDecoder", value); napi_create_string_utf8(env, state.actualDecoder.c_str(), NAPI_AUTO_LENGTH, &value); @@ -240,7 +549,22 @@ napi_value Init(napi_env env, napi_value exports) {"setAudioEnabled", nullptr, setAudioEnabled, nullptr, nullptr, nullptr, napi_default, nullptr}, {"setHardwareDecode", nullptr, setHardwareDecode, nullptr, nullptr, nullptr, napi_default, nullptr}, {"pause", nullptr, pause, nullptr, nullptr, nullptr, napi_default, nullptr}, + {"togglePause", nullptr, togglePause, nullptr, nullptr, nullptr, napi_default, nullptr}, {"seek", nullptr, seek, nullptr, nullptr, nullptr, napi_default, nullptr}, + {"seekAbsolute", nullptr, seekAbsolute, nullptr, nullptr, nullptr, napi_default, nullptr}, + {"seekPercent", nullptr, seekPercent, nullptr, nullptr, nullptr, napi_default, nullptr}, + {"setSpeed", nullptr, setSpeed, nullptr, nullptr, nullptr, napi_default, nullptr}, + {"setVolume", nullptr, setVolume, nullptr, nullptr, nullptr, napi_default, nullptr}, + {"setMute", nullptr, setMute, nullptr, nullptr, nullptr, napi_default, nullptr}, + {"selectAudioTrack", nullptr, selectAudioTrack, nullptr, nullptr, nullptr, napi_default, nullptr}, + {"selectSubtitleTrack", nullptr, selectSubtitleTrack, nullptr, nullptr, nullptr, napi_default, nullptr}, + {"disableSubtitle", nullptr, disableSubtitle, nullptr, nullptr, nullptr, napi_default, nullptr}, + {"addSubtitle", nullptr, addSubtitle, nullptr, nullptr, nullptr, napi_default, nullptr}, + {"removeSubtitle", nullptr, removeSubtitle, nullptr, nullptr, nullptr, napi_default, nullptr}, + {"setSubtitleDelay", nullptr, setSubtitleDelay, nullptr, nullptr, nullptr, napi_default, nullptr}, + {"setSubtitleScale", nullptr, setSubtitleScale, nullptr, nullptr, nullptr, napi_default, nullptr}, + {"setSubtitlePosition", nullptr, setSubtitlePosition, nullptr, nullptr, nullptr, napi_default, nullptr}, + {"setAssOverride", nullptr, setAssOverride, nullptr, nullptr, nullptr, napi_default, nullptr}, {"stop", nullptr, stop, nullptr, nullptr, nullptr, napi_default, nullptr}, {"release", nullptr, release, nullptr, nullptr, nullptr, napi_default, nullptr}, {"getState", nullptr, getState, nullptr, nullptr, nullptr, napi_default, nullptr}, diff --git a/playback_lab/entry/src/main/cpp/player.cpp b/playback_lab/entry/src/main/cpp/player.cpp index 0cf5073b78..f86d4ca230 100644 --- a/playback_lab/entry/src/main/cpp/player.cpp +++ b/playback_lab/entry/src/main/cpp/player.cpp @@ -6,8 +6,10 @@ #include #include +#include #include #include +#include #undef LOG_DOMAIN #undef LOG_TAG @@ -20,12 +22,39 @@ constexpr uint64_t kDuration = 2; constexpr uint64_t kPause = 3; constexpr uint64_t kFps = 4; constexpr uint64_t kDropped = 5; +constexpr uint64_t kTracks = 9; +constexpr uint64_t kCacheDuration = 10; +constexpr uint64_t kCacheState = 11; +constexpr uint64_t kBuffering = 12; +constexpr uint64_t kVolume = 13; +constexpr uint64_t kMute = 14; +constexpr uint64_t kSpeed = 15; void logMpvError(const char* operation, int code) { OH_LOG_Print(LOG_APP, LOG_ERROR, LOG_DOMAIN, LOG_TAG, "%{public}s failed: %{public}s (%{public}d)", operation, mpv_error_string(code), code); } + +bool containsInsensitive(const std::string& value, const char* needle) +{ + std::string lower = value; + std::string target = needle ? needle : ""; + std::transform(lower.begin(), lower.end(), lower.begin(), [](unsigned char c) { return static_cast(std::tolower(c)); }); + std::transform(target.begin(), target.end(), target.begin(), [](unsigned char c) { return static_cast(std::tolower(c)); }); + return !target.empty() && lower.find(target) != std::string::npos; +} + +bool supportedSubtitleEncoding(const std::string& encoding) +{ + if (encoding.empty()) return true; + std::string value = encoding; + std::transform(value.begin(), value.end(), value.begin(), + [](unsigned char c) { return static_cast(std::tolower(c)); }); + return value == "utf-8" || value == "utf8" || value == "gbk" || + value == "gb18030" || value == "big5" || value == "shift-jis" || + value == "sjis"; +} } PlaybackEngine::PlaybackEngine() @@ -80,7 +109,7 @@ bool PlaybackEngine::initialize() initialized_.store(true); stopEvents_.store(false); startedAt_ = std::chrono::steady_clock::now(); - setState("PLAYER_CREATED"); + setState("IDLE"); eventThread_ = std::thread(&PlaybackEngine::eventLoop, this); OH_LOG_Print(LOG_APP, LOG_INFO, LOG_DOMAIN, LOG_TAG, "libmpv initialized"); return true; @@ -88,6 +117,7 @@ bool PlaybackEngine::initialize() bool PlaybackEngine::setAudioEnabled(bool enabled) { + std::lock_guard apiLock(apiMutex_); if (handle_ == nullptr) return false; int code = mpv_set_property_string(handle_, "aid", enabled ? "auto" : "no"); @@ -101,6 +131,7 @@ bool PlaybackEngine::setAudioEnabled(bool enabled) bool PlaybackEngine::setHardwareDecode(bool enabled) { + std::lock_guard apiLock(apiMutex_); if (handle_ == nullptr) return false; int code = mpv_set_property_string(handle_, "hwdec", enabled ? "ohcodec" : "no"); @@ -133,35 +164,106 @@ void PlaybackEngine::observeProperties() mpv_observe_property(handle_, kHwdecCurrent, "hwdec-current", MPV_FORMAT_STRING); mpv_observe_property(handle_, kHwdecInterop, "hwdec-interop", MPV_FORMAT_STRING); mpv_observe_property(handle_, kDecoder, "decoder", MPV_FORMAT_STRING); + mpv_observe_property(handle_, kTracks, "track-list", MPV_FORMAT_NODE); + mpv_observe_property(handle_, kCacheDuration, "demuxer-cache-duration", MPV_FORMAT_DOUBLE); + mpv_observe_property(handle_, kCacheState, "demuxer-cache-state", MPV_FORMAT_NODE); + mpv_observe_property(handle_, kBuffering, "cache-buffering-state", MPV_FORMAT_INT64); + mpv_observe_property(handle_, kVolume, "volume", MPV_FORMAT_DOUBLE); + mpv_observe_property(handle_, kMute, "mute", MPV_FORMAT_FLAG); + mpv_observe_property(handle_, kSpeed, "speed", MPV_FORMAT_DOUBLE); } void PlaybackEngine::load(const std::string& url) { - if (!initialize() || url.empty()) { - setError(url.empty() ? "empty URL" : "player unavailable"); - return; + PlaybackRequest request; + request.url = url; + load(request); +} + +bool PlaybackEngine::load(const PlaybackRequest& request) +{ + if (!initialize() || request.url.empty()) { + setError(request.url.empty() ? "empty URL" : "player unavailable"); + return false; } + std::lock_guard apiLock(apiMutex_); { - std::lock_guard lock(mutex_); + std::lock_guard stateLock(mutex_); snapshot_.position = 0.0; snapshot_.duration = 0.0; snapshot_.firstFrameMs = -1; snapshot_.renderCount = 0; snapshot_.lastError.clear(); + snapshot_.errorCode = "NONE"; + snapshot_.media = PlaybackMediaInfo{}; + snapshot_.network = PlaybackNetworkInfo{}; + snapshot_.tracks.clear(); + snapshot_.sessionId = ++sessionId_; + awaitingStart_ = true; startedAt_ = std::chrono::steady_clock::now(); } - const char* command[] = {"loadfile", url.c_str(), "replace", nullptr}; + if (!setHeadersLocked(request)) { + setError("http-header-fields failed"); + return false; + } + if (mpv_set_property_string(handle_, "user-agent", request.userAgent.c_str()) < 0) + setError("user-agent failed"); + if (mpv_set_property_string(handle_, "referrer", request.referer.c_str()) < 0) + setError("referrer failed"); + mpv_set_property_string(handle_, "alang", request.preferredAudioLanguage.c_str()); + mpv_set_property_string(handle_, "slang", request.preferredSubtitleLanguage.c_str()); + std::string timeout = request.networkTimeout > 0 ? std::to_string(request.networkTimeout) : "0"; + mpv_set_property_string(handle_, "network-timeout", timeout.c_str()); + if (request.playbackSpeed > 0) { + std::string speed = std::to_string(request.playbackSpeed); + mpv_set_property_string(handle_, "speed", speed.c_str()); + snapshot_.speed = request.playbackSpeed; + } + std::string audioId = request.audioTrackId >= 0 ? std::to_string(request.audioTrackId) : "auto"; + mpv_set_property_string(handle_, "aid", audioId.c_str()); + if (request.subtitleTrackId >= 0) { + std::string id = std::to_string(request.subtitleTrackId); + mpv_set_property_string(handle_, "sid", id.c_str()); + } else if (request.subtitleEnabled == 0) { + mpv_set_property_string(handle_, "sid", "no"); + } else { + mpv_set_property_string(handle_, "sid", "auto"); + } + const char* command[] = {"loadfile", request.url.c_str(), "replace", nullptr}; int code = mpv_command(handle_, command); if (code < 0) { logMpvError("loadfile", code); setError(std::string("load failed: ") + mpv_error_string(code)); - return; + return false; } - setState("PREPARING"); + for (const auto& subtitle : request.subtitleFiles) { + if (subtitle.url.empty()) continue; + if (!supportedSubtitleEncoding(subtitle.encoding)) { + setError("unsupported subtitle encoding"); + return false; + } + if (!subtitle.encoding.empty()) + mpv_set_property_string(handle_, "sub-codepage", subtitle.encoding.c_str()); + const char* flags = subtitle.select ? "select" : "auto"; + const char* subCommand[] = {"sub-add", subtitle.url.c_str(), flags, + subtitle.title.c_str(), subtitle.language.c_str(), nullptr}; + mpv_command(handle_, subCommand); + } + if (request.startPositionMs >= 0) { + std::string seconds = std::to_string(static_cast(request.startPositionMs) / 1000.0); + const char* seekCommand[] = {"seek", seconds.c_str(), "absolute", "exact", nullptr}; + mpv_command(handle_, seekCommand); + } + { + std::lock_guard stateLock(mutex_); + snapshot_.state = "OPENING"; + } + return true; } void PlaybackEngine::play() { + std::lock_guard apiLock(apiMutex_); if (handle_ == nullptr) { setError("player unavailable"); return; @@ -177,6 +279,7 @@ void PlaybackEngine::play() void PlaybackEngine::pause() { + std::lock_guard apiLock(apiMutex_); if (handle_ == nullptr) { return; } @@ -189,8 +292,179 @@ void PlaybackEngine::pause() setState("PAUSED"); } +void PlaybackEngine::togglePause() +{ + std::lock_guard apiLock(apiMutex_); + if (handle_ == nullptr) return; + int paused = 0; + if (mpv_get_property(handle_, "pause", MPV_FORMAT_FLAG, &paused) < 0) { + setError("toggle pause failed"); + return; + } + if (mpv_set_property_string(handle_, "pause", paused ? "no" : "yes") < 0) { + setError("toggle pause failed"); + return; + } + setState(paused ? "PLAYING" : "PAUSED"); +} + +bool PlaybackEngine::seekAbsolute(double seconds, bool exact, bool keyframe) +{ + std::lock_guard apiLock(apiMutex_); + if (handle_ == nullptr || !std::isfinite(seconds) || seconds < 0) return false; + std::string amount = std::to_string(seconds); + const char* mode = exact ? "exact" : (keyframe ? "keyframes" : "default"); + const char* command[] = {"seek", amount.c_str(), "absolute", mode, nullptr}; + int code = mpv_command(handle_, command); + if (code < 0) { + setError(std::string("seek failed: ") + mpv_error_string(code)); + return false; + } + setState("SEEKING"); + return true; +} + +bool PlaybackEngine::seekPercent(double percent, bool exact, bool keyframe) +{ + std::lock_guard apiLock(apiMutex_); + if (handle_ == nullptr || !std::isfinite(percent)) return false; + percent = std::clamp(percent, 0.0, 100.0); + std::string amount = std::to_string(percent); + const char* mode = exact ? "exact" : (keyframe ? "keyframes" : "default"); + const char* command[] = {"seek", amount.c_str(), "absolute-percent", mode, nullptr}; + int code = mpv_command(handle_, command); + if (code < 0) { + setError(std::string("seek percent failed: ") + mpv_error_string(code)); + return false; + } + setState("SEEKING"); + return true; +} + +bool PlaybackEngine::setSpeed(double speed) +{ + std::lock_guard apiLock(apiMutex_); + if (handle_ == nullptr || !std::isfinite(speed) || speed <= 0) return false; + std::string value = std::to_string(speed); + if (mpv_set_property_string(handle_, "speed", value.c_str()) < 0) { + setError("speed failed"); + return false; + } + std::lock_guard lock(mutex_); + snapshot_.speed = speed; + return true; +} + +bool PlaybackEngine::setVolume(double volume) +{ + std::lock_guard apiLock(apiMutex_); + if (handle_ == nullptr || !std::isfinite(volume) || volume < 0) return false; + std::string value = std::to_string(volume); + if (mpv_set_property_string(handle_, "volume", value.c_str()) < 0) { + setError("volume failed"); + return false; + } + std::lock_guard lock(mutex_); + snapshot_.volume = volume; + return true; +} + +bool PlaybackEngine::setMute(bool muted) +{ + std::lock_guard apiLock(apiMutex_); + if (handle_ == nullptr) return false; + if (mpv_set_property_string(handle_, "mute", muted ? "yes" : "no") < 0) { + setError("mute failed"); + return false; + } + std::lock_guard lock(mutex_); + snapshot_.muted = muted; + return true; +} + +bool PlaybackEngine::selectAudioTrack(int id) +{ + std::lock_guard apiLock(apiMutex_); + if (handle_ == nullptr || id < 0) return false; + std::string value = std::to_string(id); + return mpv_set_property_string(handle_, "aid", value.c_str()) >= 0; +} + +bool PlaybackEngine::selectSubtitleTrack(int id) +{ + std::lock_guard apiLock(apiMutex_); + if (handle_ == nullptr || id < 0) return false; + std::string value = std::to_string(id); + return mpv_set_property_string(handle_, "sid", value.c_str()) >= 0; +} + +bool PlaybackEngine::disableSubtitle() +{ + std::lock_guard apiLock(apiMutex_); + return handle_ != nullptr && mpv_set_property_string(handle_, "sid", "no") >= 0; +} + +bool PlaybackEngine::addSubtitle(const PlaybackSubtitleSource& source) +{ + std::lock_guard apiLock(apiMutex_); + if (handle_ == nullptr || source.url.empty()) return false; + if (!supportedSubtitleEncoding(source.encoding)) { + setError("unsupported subtitle encoding"); + return false; + } + if (!source.encoding.empty()) + mpv_set_property_string(handle_, "sub-codepage", source.encoding.c_str()); + const char* flags = source.select ? "select" : "auto"; + const char* command[] = {"sub-add", source.url.c_str(), flags, source.title.c_str(), + source.language.c_str(), nullptr}; + int code = mpv_command(handle_, command); + if (code < 0) setError(std::string("subtitle failed: ") + mpv_error_string(code)); + return code >= 0; +} + +bool PlaybackEngine::removeSubtitle(int id) +{ + std::lock_guard apiLock(apiMutex_); + if (handle_ == nullptr || id < 0) return false; + std::string value = std::to_string(id); + const char* command[] = {"sub-remove", value.c_str(), nullptr}; + return mpv_command(handle_, command) >= 0; +} + +bool PlaybackEngine::setSubtitleDelay(double seconds) +{ + std::lock_guard apiLock(apiMutex_); + if (handle_ == nullptr || !std::isfinite(seconds)) return false; + const std::string value = std::to_string(seconds); + return mpv_set_property_string(handle_, "sub-delay", value.c_str()) >= 0; +} + +bool PlaybackEngine::setSubtitleScale(double scale) +{ + std::lock_guard apiLock(apiMutex_); + if (handle_ == nullptr || !std::isfinite(scale) || scale <= 0) return false; + const std::string value = std::to_string(scale); + return mpv_set_property_string(handle_, "sub-scale", value.c_str()) >= 0; +} + +bool PlaybackEngine::setSubtitlePosition(double position) +{ + std::lock_guard apiLock(apiMutex_); + if (handle_ == nullptr || !std::isfinite(position) || position < 0 || position > 100) return false; + const std::string value = std::to_string(position); + return mpv_set_property_string(handle_, "sub-pos", value.c_str()) >= 0; +} + +bool PlaybackEngine::setAssOverride(const std::string& mode) +{ + std::lock_guard apiLock(apiMutex_); + if (handle_ == nullptr || mode.empty()) return false; + return mpv_set_property_string(handle_, "sub-ass-override", mode.c_str()) >= 0; +} + void PlaybackEngine::seek(double delta) { + std::lock_guard apiLock(apiMutex_); if (handle_ == nullptr) { return; } @@ -218,6 +492,7 @@ void PlaybackEngine::seek(double delta) void PlaybackEngine::stop() { + std::lock_guard apiLock(apiMutex_); if (handle_ == nullptr) { return; } @@ -243,6 +518,7 @@ void PlaybackEngine::release() if (eventThread_.joinable()) { eventThread_.join(); } + std::lock_guard apiLock(apiMutex_); if (handle_ != nullptr) { mpv_terminate_destroy(handle_); handle_ = nullptr; @@ -274,6 +550,173 @@ PlaybackSnapshot PlaybackEngine::snapshot() const return snapshot_; } +bool PlaybackEngine::setHeadersLocked(const PlaybackRequest& request) +{ + std::vector values; + values.reserve(request.headers.size() + 1); + for (const auto& header : request.headers) { + if (!header.name.empty()) + values.push_back(header.name + ": " + header.value); + } + if (!request.cookies.empty()) + values.push_back("Cookie: " + request.cookies); + std::vector nodes(values.size()); + for (size_t i = 0; i < values.size(); ++i) { + nodes[i].format = MPV_FORMAT_STRING; + nodes[i].u.string = const_cast(values[i].c_str()); + } + mpv_node_list list = {}; + list.num = static_cast(nodes.size()); + list.values = nodes.empty() ? nullptr : nodes.data(); + mpv_node node = {}; + node.format = MPV_FORMAT_NODE_ARRAY; + node.u.list = &list; + int code = mpv_set_property(handle_, "http-header-fields", MPV_FORMAT_NODE, &node); + if (code < 0) { + logMpvError("http-header-fields", code); + return false; + } + return true; +} + +void PlaybackEngine::refreshTracksLocked() +{ + if (handle_ == nullptr) return; + mpv_node node = {}; + if (mpv_get_property(handle_, "track-list", MPV_FORMAT_NODE, &node) < 0) return; + snapshot_.tracks.clear(); + if (node.format == MPV_FORMAT_NODE_ARRAY && node.u.list != nullptr) { + for (int i = 0; i < node.u.list->num; ++i) { + const mpv_node& item = node.u.list->values[i]; + if (item.format != MPV_FORMAT_NODE_MAP || item.u.list == nullptr) continue; + PlaybackTrack track; + for (int j = 0; j < item.u.list->num; ++j) { + const char* key = item.u.list->keys[j]; + const mpv_node& value = item.u.list->values[j]; + if (key == nullptr) continue; + if (std::strcmp(key, "id") == 0 && value.format == MPV_FORMAT_INT64) + track.id = static_cast(value.u.int64); + else if (std::strcmp(key, "type") == 0 && value.format == MPV_FORMAT_STRING) + track.type = value.u.string ? value.u.string : ""; + else if (std::strcmp(key, "title") == 0 && value.format == MPV_FORMAT_STRING) + track.title = value.u.string ? value.u.string : ""; + else if (std::strcmp(key, "lang") == 0 && value.format == MPV_FORMAT_STRING) + track.language = value.u.string ? value.u.string : ""; + else if (std::strcmp(key, "codec") == 0 && value.format == MPV_FORMAT_STRING) + track.codec = value.u.string ? value.u.string : ""; + else if (std::strcmp(key, "default") == 0 && value.format == MPV_FORMAT_FLAG) + track.isDefault = value.u.flag != 0; + else if (std::strcmp(key, "forced") == 0 && value.format == MPV_FORMAT_FLAG) + track.forced = value.u.flag != 0; + else if (std::strcmp(key, "selected") == 0 && value.format == MPV_FORMAT_FLAG) + track.selected = value.u.flag != 0; + else if (std::strcmp(key, "external") == 0 && value.format == MPV_FORMAT_FLAG) + track.external = value.u.flag != 0; + } + snapshot_.tracks.push_back(std::move(track)); + } + } + mpv_free_node_contents(&node); + snapshot_.media.trackCount = static_cast(snapshot_.tracks.size()); + snapshot_.media.subtitleCount = static_cast(std::count_if( + snapshot_.tracks.begin(), snapshot_.tracks.end(), + [](const PlaybackTrack& track) { return track.type == "sub"; })); +} + +void PlaybackEngine::refreshMediaLocked() +{ + if (handle_ == nullptr) return; + auto getString = [this](const char* name) { + char* value = mpv_get_property_string(handle_, name); + std::string result = value ? value : ""; + if (value) mpv_free(value); + return result; + }; + snapshot_.media.container = getString("file-format"); + snapshot_.media.demuxer = getString("current-demuxer"); + snapshot_.media.videoCodec = getString("video-codec"); + snapshot_.media.audioCodec = getString("audio-codec"); + snapshot_.media.pixelFormat = getString("video-format"); + int64_t number = 0; + if (mpv_get_property(handle_, "width", MPV_FORMAT_INT64, &number) >= 0) snapshot_.media.width = static_cast(number); + if (mpv_get_property(handle_, "height", MPV_FORMAT_INT64, &number) >= 0) snapshot_.media.height = static_cast(number); + if (mpv_get_property(handle_, "video-bitrate", MPV_FORMAT_INT64, &number) >= 0) snapshot_.media.videoBitrate = number; + if (mpv_get_property(handle_, "audio-bitrate", MPV_FORMAT_INT64, &number) >= 0) snapshot_.media.audioBitrate = number; + if (mpv_get_property(handle_, "container-fps", MPV_FORMAT_DOUBLE, &snapshot_.media.fps) < 0) snapshot_.media.fps = 0; + mpv_node node = {}; + if (mpv_get_property(handle_, "video-params", MPV_FORMAT_NODE, &node) >= 0) { + if (node.format == MPV_FORMAT_NODE_MAP && node.u.list != nullptr) { + for (int i = 0; i < node.u.list->num; ++i) { + const char* key = node.u.list->keys[i]; + const mpv_node& value = node.u.list->values[i]; + if (!key) continue; + if (std::strcmp(key, "pixelformat") == 0 && value.format == MPV_FORMAT_STRING) snapshot_.media.pixelFormat = value.u.string ? value.u.string : ""; + else if (std::strcmp(key, "primaries") == 0 && value.format == MPV_FORMAT_STRING) snapshot_.media.colorPrimaries = value.u.string ? value.u.string : ""; + else if (std::strcmp(key, "gamma") == 0 && value.format == MPV_FORMAT_STRING) snapshot_.media.colorTransfer = value.u.string ? value.u.string : ""; + else if (std::strcmp(key, "colormatrix") == 0 && value.format == MPV_FORMAT_STRING) snapshot_.media.colorSpace = value.u.string ? value.u.string : ""; + else if (std::strcmp(key, "bit-depth") == 0 && value.format == MPV_FORMAT_INT64) snapshot_.media.bitDepth = static_cast(value.u.int64); + } + } + mpv_free_node_contents(&node); + } + node = {}; + if (mpv_get_property(handle_, "audio-params", MPV_FORMAT_NODE, &node) >= 0) { + if (node.format == MPV_FORMAT_NODE_MAP && node.u.list != nullptr) { + for (int i = 0; i < node.u.list->num; ++i) { + const char* key = node.u.list->keys[i]; + const mpv_node& value = node.u.list->values[i]; + if (!key) continue; + if (std::strcmp(key, "samplerate") == 0 && value.format == MPV_FORMAT_INT64) snapshot_.media.audioSampleRate = static_cast(value.u.int64); + else if (std::strcmp(key, "channels") == 0 && value.format == MPV_FORMAT_INT64) snapshot_.media.audioChannels = static_cast(value.u.int64); + else if (std::strcmp(key, "channel-layout") == 0 && value.format == MPV_FORMAT_STRING) snapshot_.media.audioChannelLayout = value.u.string ? value.u.string : ""; + } + } + mpv_free_node_contents(&node); + } + refreshTracksLocked(); + refreshNetworkLocked(); +} + +void PlaybackEngine::refreshNetworkLocked() +{ + if (handle_ == nullptr) return; + const std::string url = [&] { + char* value = mpv_get_property_string(handle_, "stream-open-filename"); + std::string result = value ? value : ""; + if (value) mpv_free(value); + return result; + }(); + const size_t schemeEnd = url.find("://"); + if (schemeEnd != std::string::npos) { + snapshot_.network.protocol = url.substr(0, schemeEnd); + const size_t hostStart = schemeEnd + 3; + const size_t hostEnd = url.find_first_of("/?#", hostStart); + snapshot_.network.host = url.substr(hostStart, hostEnd == std::string::npos ? std::string::npos : hostEnd - hostStart); + } + int network = 0; + if (mpv_get_property(handle_, "demuxer-via-network", MPV_FORMAT_FLAG, &network) >= 0) + snapshot_.network.networkState = network ? "network" : "local"; + if (mpv_get_property(handle_, "demuxer-cache-duration", MPV_FORMAT_DOUBLE, &snapshot_.network.cacheDuration) < 0) + snapshot_.network.cacheDuration = 0; + int64_t buffering = 0; + if (mpv_get_property(handle_, "cache-buffering-state", MPV_FORMAT_INT64, &buffering) >= 0) + snapshot_.network.buffering = buffering > 0; + mpv_node cache = {}; + if (mpv_get_property(handle_, "demuxer-cache-state", MPV_FORMAT_NODE, &cache) >= 0 && + cache.format == MPV_FORMAT_NODE_MAP && cache.u.list != nullptr) { + for (int i = 0; i < cache.u.list->num; ++i) { + if (std::strcmp(cache.u.list->keys[i], "total-bytes") == 0 && + cache.u.list->values[i].format == MPV_FORMAT_INT64) + snapshot_.network.cacheBytes = cache.u.list->values[i].u.int64; + } + mpv_free_node_contents(&cache); + } + snapshot_.network.demuxer = snapshot_.media.demuxer; + snapshot_.network.container = snapshot_.media.container; + if (mpv_get_property(handle_, "video-bitrate", MPV_FORMAT_INT64, &snapshot_.network.currentBitrate) < 0) + snapshot_.network.currentBitrate = 0; +} + void PlaybackEngine::syncHwdecStatsLocked() { mpv_harmony_hwdec_stats stats = {}; @@ -301,26 +744,47 @@ void PlaybackEngine::handleLogMessage(const mpv_event_log_message* message) { if (!message || !message->text) return; - const char* text = message->text; - if (std::strstr(text, "h264_ohcodec") || std::strstr(text, "hevc_ohcodec")) { + const std::string text = redactSensitive(message->text); + if (containsInsensitive(text, "http/1.") || containsInsensitive(text, "http error")) { + std::istringstream stream(text); + std::string token; + while (stream >> token) { + if (token.size() == 3 && std::isdigit(static_cast(token[0])) && + std::isdigit(static_cast(token[1])) && + std::isdigit(static_cast(token[2]))) { + const int status = std::atoi(token.c_str()); + if (status >= 100 && status <= 599) { + std::lock_guard lock(mutex_); + snapshot_.network.httpStatus = status; + break; + } + } + } + } + if (containsInsensitive(text, "redirect")) { std::lock_guard lock(mutex_); - snapshot_.actualDecoder = std::strstr(text, "hevc_ohcodec") ? "hevc_ohcodec" : "h264_ohcodec"; + ++snapshot_.network.redirectCount; + } + const char* rawText = message->text; + if (std::strstr(rawText, "h264_ohcodec") || std::strstr(rawText, "hevc_ohcodec")) { + std::lock_guard lock(mutex_); + snapshot_.actualDecoder = std::strstr(rawText, "hevc_ohcodec") ? "hevc_ohcodec" : "h264_ohcodec"; snapshot_.hwdecActive = true; } if (message->prefix && std::strncmp(message->prefix, "vd", 2) == 0 && - (std::strstr(text, "fallback") || std::strstr(text, "falling back") || - std::strstr(text, "No hardware decoding"))) { + (std::strstr(rawText, "fallback") || std::strstr(rawText, "falling back") || + std::strstr(rawText, "No hardware decoding"))) { bool requested = false; { std::lock_guard lock(mutex_); requested = snapshot_.hwdecRequested; } if (requested) - mpv_harmony_hwdec_set_fallback(text); + mpv_harmony_hwdec_set_fallback(text.c_str()); } if (message->prefix && std::strncmp(message->prefix, "vd", 2) == 0) { OH_LOG_Print(LOG_APP, LOG_INFO, LOG_DOMAIN, LOG_TAG, - "mpv[%{public}s] %{public}s", message->prefix, text); + "mpv[%{public}s] %{public}s", message->prefix, text.c_str()); } } @@ -334,8 +798,9 @@ void PlaybackEngine::setError(const std::string& error) { std::lock_guard lock(mutex_); snapshot_.state = "ERROR"; - snapshot_.lastError = error; - OH_LOG_Print(LOG_APP, LOG_ERROR, LOG_DOMAIN, LOG_TAG, "%{public}s", error.c_str()); + snapshot_.lastError = redactSensitive(error); + snapshot_.errorCode = classifyError(snapshot_.lastError); + OH_LOG_Print(LOG_APP, LOG_ERROR, LOG_DOMAIN, LOG_TAG, "%{public}s", snapshot_.lastError.c_str()); } void PlaybackEngine::updateProperty(uint64_t userdata, const mpv_event_property* property) @@ -361,6 +826,28 @@ void PlaybackEngine::updateProperty(uint64_t userdata, const mpv_event_property* case kDropped: if (property->format == MPV_FORMAT_INT64) snapshot_.dropped = *static_cast(property->data); break; + case kTracks: + refreshTracksLocked(); + break; + case kCacheDuration: + case kCacheState: + case kBuffering: + refreshNetworkLocked(); + if (userdata == kBuffering && property->format == MPV_FORMAT_INT64) { + const int64_t buffering = *static_cast(property->data); + if (buffering > 0) snapshot_.state = "BUFFERING"; + else if (snapshot_.state == "BUFFERING") snapshot_.state = "PLAYING"; + } + break; + case kVolume: + if (property->format == MPV_FORMAT_DOUBLE) snapshot_.volume = *static_cast(property->data); + break; + case kMute: + if (property->format == MPV_FORMAT_FLAG) snapshot_.muted = *static_cast(property->data) != 0; + break; + case kSpeed: + if (property->format == MPV_FORMAT_DOUBLE) snapshot_.speed = *static_cast(property->data); + break; case kHwdecCurrent: if (property->format == MPV_FORMAT_STRING) { const char *value = *static_cast(property->data); @@ -407,15 +894,33 @@ void PlaybackEngine::eventLoop() updateProperty(event->reply_userdata, static_cast(event->data)); break; case MPV_EVENT_START_FILE: - setState("PREPARING"); + { + auto* start = static_cast(event->data); + std::lock_guard lock(mutex_); + awaitingStart_ = true; + if (start) playlistEntryId_ = start->playlist_entry_id; + snapshot_.state = "OPENING"; + } break; case MPV_EVENT_FILE_LOADED: - setState("READY"); + { + std::lock_guard lock(mutex_); + awaitingStart_ = false; + refreshMediaLocked(); + snapshot_.state = "READY"; + } break; case MPV_EVENT_END_FILE: { auto* end = static_cast(event->data); + { + std::lock_guard lock(mutex_); + if (end != nullptr && end->playlist_entry_id != 0 && playlistEntryId_ != 0 && + end->playlist_entry_id != playlistEntryId_) break; + } if (end != nullptr && end->reason == MPV_END_FILE_REASON_ERROR) { setError(std::string("end-file: ") + mpv_error_string(end->error)); + } else if (end != nullptr && end->reason == MPV_END_FILE_REASON_EOF) { + setState("ENDED"); } else { setState("STOPPED"); } @@ -428,3 +933,51 @@ void PlaybackEngine::eventLoop() } } } + +std::string PlaybackEngine::redactSensitive(const std::string& value) +{ + std::string result = value; + const char* keys[] = {"authorization:", "cookie:", "proxy-authorization:", "x-api-key:"}; + for (const char* key : keys) { + size_t position = 0; + while ((position = result.find(key, position)) != std::string::npos) { + const size_t valueStart = position + std::strlen(key); + const size_t end = result.find_first_of("\r\n", valueStart); + result.replace(valueStart, (end == std::string::npos ? result.size() : end) - valueStart, " [REDACTED]"); + position = valueStart + 11; + } + } + const char* queryKeys[] = {"token=", "auth=", "key=", "signature=", "sig=", "expires="}; + for (const char* key : queryKeys) { + size_t position = 0; + while ((position = result.find(key, position)) != std::string::npos) { + const size_t end = result.find_first_of("& \r\n", position); + result.replace(position, (end == std::string::npos ? result.size() : end) - position, std::string(key) + "[REDACTED]"); + position += std::strlen(key) + 10; + } + } + return result; +} + +std::string PlaybackEngine::classifyError(const std::string& value) +{ + if (containsInsensitive(value, "403") || containsInsensitive(value, "404") || + containsInsensitive(value, "500") || containsInsensitive(value, "http error")) + return "HTTP"; + if (containsInsensitive(value, "tls") || containsInsensitive(value, "certificate") || + containsInsensitive(value, "ssl")) return "TLS"; + if (containsInsensitive(value, "not found") || containsInsensitive(value, "no such file")) return "FILE_NOT_FOUND"; + if (containsInsensitive(value, "permission") || containsInsensitive(value, "access denied")) return "PERMISSION"; + if (containsInsensitive(value, "subtitle") || containsInsensitive(value, "sub ")) return "SUBTITLE"; + if (containsInsensitive(value, "video codec") || containsInsensitive(value, "video decoder")) return "UNSUPPORTED_VIDEO_CODEC"; + if (containsInsensitive(value, "audio codec") || containsInsensitive(value, "audio decoder")) return "UNSUPPORTED_AUDIO_CODEC"; + if (containsInsensitive(value, "unsupported") && containsInsensitive(value, "container")) return "UNSUPPORTED_CONTAINER"; + if (containsInsensitive(value, "demux") || containsInsensitive(value, "format")) return "DEMUX"; + if (containsInsensitive(value, "decoder")) return "DECODER"; + if (containsInsensitive(value, "audio output")) return "AUDIO_OUTPUT"; + if (containsInsensitive(value, "video output")) return "VIDEO_OUTPUT"; + if (containsInsensitive(value, "seek")) return "SEEK"; + if (containsInsensitive(value, "timeout") || containsInsensitive(value, "connection") || + containsInsensitive(value, "network") || containsInsensitive(value, "dns")) return "NETWORK"; + return "UNKNOWN"; +} diff --git a/playback_lab/entry/src/main/cpp/player.h b/playback_lab/entry/src/main/cpp/player.h index 3edbee2836..19c9cec613 100644 --- a/playback_lab/entry/src/main/cpp/player.h +++ b/playback_lab/entry/src/main/cpp/player.h @@ -8,9 +8,91 @@ #include #include #include +#include + +struct PlaybackHeader { + std::string name; + std::string value; +}; + +struct PlaybackSubtitleSource { + std::string url; + std::string title; + std::string language; + std::string encoding; + bool forced = false; + bool select = false; +}; + +struct PlaybackRequest { + std::string url; + std::string title; + int64_t startPositionMs = -1; + std::string userAgent; + std::string referer; + std::string cookies; + std::vector headers; + std::vector subtitleFiles; + std::string preferredAudioLanguage; + std::string preferredSubtitleLanguage; + int subtitleEnabled = -1; + int audioTrackId = -1; + int subtitleTrackId = -1; + double playbackSpeed = 0.0; + double networkTimeout = 0.0; +}; + +struct PlaybackTrack { + int id = 0; + std::string type; + std::string title; + std::string language; + std::string codec; + bool isDefault = false; + bool forced = false; + bool selected = false; + bool external = false; +}; + +struct PlaybackMediaInfo { + std::string container; + std::string demuxer; + std::string videoCodec; + std::string audioCodec; + std::string pixelFormat; + std::string colorPrimaries; + std::string colorTransfer; + std::string colorSpace; + int width = 0; + int height = 0; + int bitDepth = 0; + int audioSampleRate = 0; + int audioChannels = 0; + std::string audioChannelLayout; + double fps = 0.0; + int64_t videoBitrate = 0; + int64_t audioBitrate = 0; + int trackCount = 0; + int subtitleCount = 0; +}; + +struct PlaybackNetworkInfo { + std::string protocol; + std::string host; + std::string networkState; + int httpStatus = 0; + int redirectCount = 0; + bool buffering = false; + double cacheDuration = 0.0; + int64_t cacheBytes = 0; + std::string demuxer; + std::string container; + int64_t currentBitrate = 0; +}; struct PlaybackSnapshot { std::string state = "IDLE"; + uint64_t sessionId = 0; double position = 0.0; double duration = 0.0; double fps = 0.0; @@ -18,6 +100,13 @@ struct PlaybackSnapshot { int64_t firstFrameMs = -1; uint64_t renderCount = 0; std::string lastError; + std::string errorCode = "NONE"; + PlaybackMediaInfo media; + PlaybackNetworkInfo network; + std::vector tracks; + double speed = 1.0; + double volume = 100.0; + bool muted = false; std::string requestedDecoder = "software"; std::string actualDecoder = "software"; std::string hwdecBackend; @@ -43,9 +132,25 @@ public: bool setAudioEnabled(bool enabled); bool setHardwareDecode(bool enabled); void load(const std::string& url); + bool load(const PlaybackRequest& request); void play(); void pause(); + void togglePause(); void seek(double delta); + bool seekAbsolute(double seconds, bool exact, bool keyframe); + bool seekPercent(double percent, bool exact, bool keyframe); + bool setSpeed(double speed); + bool setVolume(double volume); + bool setMute(bool muted); + bool selectAudioTrack(int id); + bool selectSubtitleTrack(int id); + bool disableSubtitle(); + bool addSubtitle(const PlaybackSubtitleSource& source); + bool removeSubtitle(int id); + bool setSubtitleDelay(double seconds); + bool setSubtitleScale(double scale); + bool setSubtitlePosition(double position); + bool setAssOverride(const std::string& mode); void stop(); void release(); PlaybackSnapshot snapshot() const; @@ -61,13 +166,23 @@ private: void updateProperty(uint64_t userdata, const mpv_event_property* property); void syncHwdecStatsLocked(); void handleLogMessage(const mpv_event_log_message* message); + void refreshTracksLocked(); + void refreshMediaLocked(); + void refreshNetworkLocked(); + bool setHeadersLocked(const PlaybackRequest& request); + static std::string redactSensitive(const std::string& value); + static std::string classifyError(const std::string& value); mutable std::mutex mutex_; + mutable std::mutex apiMutex_; PlaybackSnapshot snapshot_; mpv_handle* handle_ = nullptr; std::thread eventThread_; std::atomic stopEvents_{false}; std::atomic initialized_{false}; + uint64_t sessionId_ = 0; + int64_t playlistEntryId_ = 0; + bool awaitingStart_ = false; std::chrono::steady_clock::time_point startedAt_; static constexpr uint64_t kHwdecCurrent = 6; static constexpr uint64_t kHwdecInterop = 7; diff --git a/playback_lab/entry/src/main/ets/pages/Index.ets b/playback_lab/entry/src/main/ets/pages/Index.ets index 7d49f3f725..0f27e4154f 100644 --- a/playback_lab/entry/src/main/ets/pages/Index.ets +++ b/playback_lab/entry/src/main/ets/pages/Index.ets @@ -39,10 +39,78 @@ interface PlaybackState { nativeBufferImportFailure: number; surfacePresentCount: number; gpuInteropFrameCount: number; + sessionId: number; + errorCode: string; + speed: number; + volume: number; + muted: boolean; + network: NetworkState; + media: MediaState; + tracks: TrackState[]; +} + +interface NetworkState { + protocol: string; + host: string; + networkState: string; + httpStatus: number; + redirectCount: number; + buffering: boolean; + cacheDuration: number; + cacheBytes: number; + demuxer: string; + container: string; + currentBitrate: number; +} + +interface MediaState { + container: string; + demuxer: string; + videoCodec: string; + audioCodec: string; + pixelFormat: string; + width: number; + height: number; + fps: number; + bitDepth: number; + audioSampleRate: number; + audioChannels: number; + trackCount: number; + subtitleCount: number; +} + +interface TrackState { + id: number; + type: string; + title: string; + language: string; + codec: string; + default: boolean; + forced: boolean; + selected: boolean; + external: boolean; } const LOCAL_TEST_URL: string = 'file:///data/storage/el2/base/haps/entry/files/sample.mp4'; +interface HeaderInput { + name: string; + value: string; +} + +function parseHeaders(value: string): HeaderInput[] { + const result: HeaderInput[] = []; + value.split('\n').forEach((line: string) => { + const separator: number = line.indexOf(':'); + if (separator > 0) { + const name: string = line.substring(0, separator).trim(); + const headerValue: string = line.substring(separator + 1).trim(); + if (name.length > 0) result.push({ name: name, value: headerValue }); + } + }); + return result; +} + @Entry @Component struct Index { @@ -50,6 +118,11 @@ struct Index { @State status: string = 'IDLE'; @State audioEnabled: boolean = true; @State detail: string = 'Surface waiting'; + @State headersText: string = ''; + @State subtitleUrl: string = ''; + @State speedInput: string = '1'; + @State volumeInput: string = '100'; + @State subtitleDelayInput: string = '0'; @State state: PlaybackState = { state: 'IDLE', position: 0, duration: 0, fps: 0, dropped: 0, firstFrameMs: -1, renderCount: 0, lastError: '', surface: 'WAITING', egl: 'WAITING', mpv: 'WAITING', @@ -60,7 +133,14 @@ struct Index { hwdecActive: false, hwdecBackend: '', hwdecFallbackReason: '', hwCapability: '', hardwareFrameCount: 0, softwareFrameCount: 0, hwFallbackCount: 0, nativeBufferImportCount: 0, nativeBufferImportFailure: 0, surfacePresentCount: 0, - gpuInteropFrameCount: 0 + gpuInteropFrameCount: 0, + sessionId: 0, errorCode: 'NONE', speed: 1, volume: 100, muted: false, + network: { protocol: '', host: '', networkState: '', httpStatus: 0, redirectCount: 0, + buffering: false, cacheDuration: 0, cacheBytes: 0, demuxer: '', container: '', currentBitrate: 0 }, + media: { container: '', demuxer: '', videoCodec: '', audioCodec: '', pixelFormat: '', + width: 0, height: 0, fps: 0, bitDepth: 0, audioSampleRate: 0, audioChannels: 0, + trackCount: 0, subtitleCount: 0 }, + tracks: [] }; private poller: number = 0; @@ -94,7 +174,9 @@ struct Index { this.url = LOCAL_TEST_URL; playbackLab.load(LOCAL_TEST_URL); }) - Button('Load URL').onClick(() => playbackLab.load(this.url)) + Button('Load URL').onClick(() => playbackLab.load({ + url: this.url, httpHeaders: parseHeaders(this.headersText) + })) Button(this.audioEnabled ? 'Audio On' : 'Audio Off').onClick(() => { const enabled: boolean = !this.audioEnabled; if (playbackLab.setAudioEnabled(enabled) as boolean) this.audioEnabled = enabled; @@ -115,6 +197,29 @@ struct Index { Row({ space: 8 }) { Button('-10s').onClick(() => playbackLab.seek(-10)) Button('+10s').onClick(() => playbackLab.seek(10)) + Button('Toggle').onClick(() => playbackLab.togglePause()) + }.width('100%') + TextInput({ text: this.headersText, placeholder: 'Headers (one Name: Value per line; values masked in diagnostics)' }) + .width('100%').onChange((value: string) => this.headersText = value) + Row({ space: 8 }) { + TextInput({ text: this.speedInput, placeholder: 'speed' }).width('25%') + .onChange((value: string) => this.speedInput = value) + Button('Set Speed').onClick(() => playbackLab.setSpeed(Number(this.speedInput))) + TextInput({ text: this.volumeInput, placeholder: 'volume' }).width('25%') + .onChange((value: string) => this.volumeInput = value) + Button('Set Volume').onClick(() => playbackLab.setVolume(Number(this.volumeInput))) + Button(this.state.muted ? 'Unmute' : 'Mute').onClick(() => playbackLab.setMute(!this.state.muted)) + }.width('100%') + TextInput({ text: this.subtitleUrl, placeholder: 'external subtitle URL (SRT/ASS/WebVTT)' }) + .width('100%').onChange((value: string) => this.subtitleUrl = value) + Row({ space: 8 }) { + Button('Add Subtitle').onClick(() => playbackLab.addSubtitle({ + url: this.subtitleUrl, select: true + })) + Button('Subtitle Off').onClick(() => playbackLab.disableSubtitle()) + TextInput({ text: this.subtitleDelayInput, placeholder: 'delay s' }).width('22%') + .onChange((value: string) => this.subtitleDelayInput = value) + Button('Set Delay').onClick(() => playbackLab.setSubtitleDelay(Number(this.subtitleDelayInput))) }.width('100%') Text(`position=${this.state.position.toFixed(2)} duration=${this.state.duration.toFixed(2)} fps=${this.state.fps.toFixed(2)}`) .fontSize(13).fontColor($r('app.color.muted')).width('100%') @@ -133,6 +238,21 @@ struct Index { Text(`fallback=${this.state.hwdecFallbackReason || 'none'} capability=${this.state.hwCapability || 'DEVICE TEST REQUIRED'}`) .fontSize(13).fontColor($r('app.color.muted')).width('100%').maxLines(3) Text(detailLabel(this.detail)).fontSize(13).fontColor($r('app.color.muted')).width('100%').maxLines(2) + Text('network=' + this.state.network.protocol + '://' + this.state.network.host + ' status=' + (this.state.network.httpStatus || 'unavailable') + ' redirects=' + this.state.network.redirectCount + ' buffering=' + this.state.network.buffering) + .fontSize(13).fontColor($r('app.color.muted')).width('100%').maxLines(2) + Text('media=' + this.state.media.container + '/' + this.state.media.videoCodec + '/' + this.state.media.audioCodec + ' ' + this.state.media.width + 'x' + this.state.media.height + ' tracks=' + this.state.media.trackCount + ' subs=' + this.state.media.subtitleCount) + .fontSize(13).fontColor($r('app.color.muted')).width('100%').maxLines(2) + ForEach(this.state.tracks, (track: TrackState) => { + Row({ space: 6 }) { + Text('track ' + track.id + ' ' + track.type + ' ' + (track.language || 'und') + ' ' + (track.codec || 'unknown') + (track.selected ? ' [selected]' : '')) + .fontSize(12).fontColor($r('app.color.muted')).layoutWeight(1) + if (track.type === 'audio') { + Button('Use').onClick(() => playbackLab.selectAudioTrack(track.id)) + } else if (track.type === 'sub') { + Button('Use').onClick(() => playbackLab.selectSubtitleTrack(track.id)) + } + }.width('100%') + }, (track: TrackState) => track.type + '-' + track.id) } .padding(16).width('100%').height('100%').backgroundColor($r('app.color.background')) } diff --git a/player/harmony_playback.c b/player/harmony_playback.c new file mode 100644 index 0000000000..cef8dd7980 --- /dev/null +++ b/player/harmony_playback.c @@ -0,0 +1,584 @@ +#include "mpv/harmony_playback.h" +#include "mpv/client.h" + +#include +#include +#include +#include +#include +#include + +struct mp_harmony_player { + pthread_mutex_t lock; + pthread_t events; + int lock_ready; + int initialized; + int stop_events; + mpv_handle *handle; + uint64_t session_id; + int64_t playlist_entry_id; + int awaiting_start; + mp_harmony_playback_snapshot snapshot; + mp_harmony_track tracks[MP_HARMONY_MAX_TRACKS]; + size_t track_count; +}; + +enum { + PROP_TIME_POS = 1, PROP_DURATION, PROP_PAUSE, PROP_FPS, PROP_DROPPED, + PROP_TRACKS, PROP_CACHE_DURATION, PROP_CACHE_STATE, PROP_BUFFERING, + PROP_VOLUME, PROP_MUTE, PROP_SPEED, +}; + +static void copy_text(char *dst, size_t size, const char *src) +{ + if (!dst || size == 0) return; + if (!src) src = ""; + snprintf(dst, size, "%.*s", (int)(size - 1), src); +} + +static int text_has(const char *text, const char *needle) +{ + if (!text || !needle || !needle[0]) return 0; + for (const char *p = text; *p; p++) { + const char *a = p; + const char *b = needle; + while (*a && *b && tolower((unsigned char)*a) == tolower((unsigned char)*b)) { a++; b++; } + if (!*b) return 1; + } + return 0; +} + +static int supported_subtitle_encoding(const char *encoding) +{ + if (!encoding || !encoding[0]) return 1; + return strcasecmp(encoding, "utf-8") == 0 || strcasecmp(encoding, "utf8") == 0 || + strcasecmp(encoding, "gbk") == 0 || strcasecmp(encoding, "gb18030") == 0 || + strcasecmp(encoding, "big5") == 0 || strcasecmp(encoding, "shift-jis") == 0 || + strcasecmp(encoding, "sjis") == 0; +} + +static void redact_text(char *dst, size_t size, const char *src) +{ + size_t out = 0; + const char *p = src ? src : ""; + while (*p && out + 1 < size) { + size_t key_len = 0; + if (strncasecmp(p, "authorization:", 14) == 0) key_len = 14; + else if (strncasecmp(p, "cookie:", 7) == 0) key_len = 7; + else if (strncasecmp(p, "proxy-authorization:", 21) == 0) key_len = 21; + else if (strncasecmp(p, "x-api-key:", 10) == 0) key_len = 10; + if (key_len) { + size_t n = key_len; + while (p[n] && p[n] != '\n' && p[n] != '\r') n++; + if (out + key_len + 11 >= size) break; + memcpy(dst + out, p, key_len); out += key_len; + memcpy(dst + out, " [REDACTED]", 11); out += 11; + p += n; + continue; + } + if (*p == '?' || *p == '&') { + const char *q = p + 1; + if (strncasecmp(q, "token=", 6) == 0 || strncasecmp(q, "auth=", 5) == 0 || + strncasecmp(q, "key=", 4) == 0 || strncasecmp(q, "signature=", 10) == 0 || + strncasecmp(q, "sig=", 4) == 0 || strncasecmp(q, "expires=", 8) == 0) { + dst[out++] = *p++; + while (*p && *p != '&' && *p != ' ' && *p != '\n' && out + 1 < size) p++; + if (out + 10 >= size) break; + memcpy(dst + out, "[REDACTED]", 10); out += 10; + continue; + } + } + dst[out++] = *p++; + } + dst[out] = '\0'; +} + +static void set_error_locked(mp_harmony_player *p, mp_harmony_error_code code, const char *message) +{ + p->snapshot.state = MP_HARMONY_ERROR; + p->snapshot.error_code = code; + redact_text(p->snapshot.error_message, sizeof(p->snapshot.error_message), message); +} + +static mp_harmony_error_code classify_error(int code, const char *message) +{ + if (text_has(message, "403") || text_has(message, "404") || text_has(message, "500") || + text_has(message, "http error")) return MP_HARMONY_ERROR_HTTP; + if (text_has(message, "tls") || text_has(message, "certificate") || text_has(message, "ssl")) + return MP_HARMONY_ERROR_TLS; + if (text_has(message, "not found") || text_has(message, "no such file")) + return MP_HARMONY_ERROR_FILE_NOT_FOUND; + if (text_has(message, "permission") || text_has(message, "access denied")) + return MP_HARMONY_ERROR_PERMISSION; + if (text_has(message, "subtitle") || text_has(message, "sub ")) return MP_HARMONY_ERROR_SUBTITLE; + if (text_has(message, "video codec") || text_has(message, "video decoder")) + return MP_HARMONY_ERROR_UNSUPPORTED_VIDEO_CODEC; + if (text_has(message, "audio codec") || text_has(message, "audio decoder")) + return MP_HARMONY_ERROR_UNSUPPORTED_AUDIO_CODEC; + if (text_has(message, "unsupported") && text_has(message, "container")) + return MP_HARMONY_ERROR_UNSUPPORTED_CONTAINER; + if (text_has(message, "demux") || text_has(message, "format")) return MP_HARMONY_ERROR_DEMUX; + if (text_has(message, "decoder")) return MP_HARMONY_ERROR_DECODER; + if (text_has(message, "audio output")) return MP_HARMONY_ERROR_AUDIO_OUTPUT; + if (text_has(message, "video output")) return MP_HARMONY_ERROR_VIDEO_OUTPUT; + if (text_has(message, "seek")) return MP_HARMONY_ERROR_SEEK; + if (text_has(message, "timeout") || text_has(message, "connection") || + text_has(message, "network") || text_has(message, "dns")) + return MP_HARMONY_ERROR_NETWORK; + return code == MPV_ERROR_OPTION_ERROR ? MP_HARMONY_ERROR_UNKNOWN : MP_HARMONY_ERROR_NETWORK; +} + +static int set_property_string(mp_harmony_player *p, const char *name, const char *value) +{ + int rc = mpv_set_property_string(p->handle, name, value ? value : ""); + if (rc < 0) { + char message[MP_HARMONY_TEXT]; + snprintf(message, sizeof(message), "%s: %s", name, mpv_error_string(rc)); + set_error_locked(p, classify_error(rc, message), message); + } + return rc; +} + +static void update_network_locked(mp_harmony_player *p) +{ + char *url = mpv_get_property_string(p->handle, "stream-open-filename"); + if (!url) return; + const char *scheme = strstr(url, "://"); + if (scheme) { + size_t n = (size_t)(scheme - url); + if (n >= sizeof(p->snapshot.network.protocol)) n = sizeof(p->snapshot.network.protocol) - 1; + memcpy(p->snapshot.network.protocol, url, n); p->snapshot.network.protocol[n] = '\0'; + const char *host = scheme + 3; + const char *end = strpbrk(host, "/?#"); + size_t hn = end ? (size_t)(end - host) : strlen(host); + if (hn >= sizeof(p->snapshot.network.host)) hn = sizeof(p->snapshot.network.host) - 1; + memcpy(p->snapshot.network.host, host, hn); p->snapshot.network.host[hn] = '\0'; + } + mpv_free(url); + int flag = 0; + if (mpv_get_property(p->handle, "demuxer-via-network", MPV_FORMAT_FLAG, &flag) >= 0) + copy_text(p->snapshot.network.network_state, sizeof(p->snapshot.network.network_state), flag ? "network" : "local"); + double duration = 0; + if (mpv_get_property(p->handle, "demuxer-cache-duration", MPV_FORMAT_DOUBLE, &duration) >= 0) + p->snapshot.network.cache_duration = duration; + mpv_node cache = {0}; + if (mpv_get_property(p->handle, "demuxer-cache-state", MPV_FORMAT_NODE, &cache) >= 0) { + if (cache.format == MPV_FORMAT_NODE_MAP && cache.u.list) { + for (int i = 0; i < cache.u.list->num; i++) { + const char *key = cache.u.list->keys[i]; + mpv_node *value = &cache.u.list->values[i]; + if (key && strcmp(key, "total-bytes") == 0 && value->format == MPV_FORMAT_INT64) + p->snapshot.network.cache_bytes = value->u.int64; + } + } + mpv_free_node_contents(&cache); + } + int64_t buffering = 0; + if (mpv_get_property(p->handle, "cache-buffering-state", MPV_FORMAT_INT64, &buffering) >= 0) + p->snapshot.network.buffering = buffering > 0; + char *demuxer = mpv_get_property_string(p->handle, "current-demuxer"); + char *container = mpv_get_property_string(p->handle, "file-format"); + copy_text(p->snapshot.network.demuxer, sizeof(p->snapshot.network.demuxer), demuxer); + copy_text(p->snapshot.network.container, sizeof(p->snapshot.network.container), container); + if (demuxer) mpv_free(demuxer); + if (container) mpv_free(container); + int64_t bitrate = 0; + if (mpv_get_property(p->handle, "video-bitrate", MPV_FORMAT_INT64, &bitrate) >= 0) + p->snapshot.network.current_bitrate = bitrate; +} + +static const char *node_string(mpv_node *node) +{ + return node && node->format == MPV_FORMAT_STRING ? node->u.string : NULL; +} + +static int node_flag(mpv_node *node) +{ + if (!node) return 0; + if (node->format == MPV_FORMAT_FLAG) return node->u.flag != 0; + return node->format == MPV_FORMAT_INT64 && node->u.int64 != 0; +} + +static int node_int(mpv_node *node) +{ + if (!node) return 0; + if (node->format == MPV_FORMAT_INT64) return (int)node->u.int64; + if (node->format == MPV_FORMAT_DOUBLE) return (int)node->u.double_; + return 0; +} + +static void refresh_tracks_locked(mp_harmony_player *p) +{ + mpv_node node = {0}; + p->track_count = 0; + if (mpv_get_property(p->handle, "track-list", MPV_FORMAT_NODE, &node) < 0) return; + if (node.format == MPV_FORMAT_NODE_ARRAY && node.u.list) { + for (int i = 0; i < node.u.list->num && p->track_count < MP_HARMONY_MAX_TRACKS; i++) { + mpv_node *item = &node.u.list->values[i]; + if (item->format != MPV_FORMAT_NODE_MAP || !item->u.list) continue; + mp_harmony_track *track = &p->tracks[p->track_count]; + memset(track, 0, sizeof(*track)); + for (int j = 0; j < item->u.list->num; j++) { + const char *key = item->u.list->keys[j]; + mpv_node *value = &item->u.list->values[j]; + if (!key) continue; + if (strcmp(key, "id") == 0) track->id = node_int(value); + else if (strcmp(key, "type") == 0) { + const char *s = node_string(value); + track->type = s && strcmp(s, "audio") == 0 ? MP_HARMONY_TRACK_AUDIO : + (s && strcmp(s, "sub") == 0 ? MP_HARMONY_TRACK_SUBTITLE : MP_HARMONY_TRACK_VIDEO); + } else if (strcmp(key, "title") == 0) copy_text(track->title, sizeof(track->title), node_string(value)); + else if (strcmp(key, "lang") == 0) copy_text(track->language, sizeof(track->language), node_string(value)); + else if (strcmp(key, "codec") == 0) copy_text(track->codec, sizeof(track->codec), node_string(value)); + else if (strcmp(key, "default") == 0) track->is_default = node_flag(value); + else if (strcmp(key, "forced") == 0) track->forced = node_flag(value); + else if (strcmp(key, "selected") == 0) track->selected = node_flag(value); + else if (strcmp(key, "external") == 0) track->external = node_flag(value); + } + p->track_count++; + } + } + mpv_free_node_contents(&node); + p->snapshot.track_count = (int)p->track_count; + p->snapshot.media.track_count = (int)p->track_count; + p->snapshot.media.subtitle_count = 0; + for (size_t i = 0; i < p->track_count; i++) + if (p->tracks[i].type == MP_HARMONY_TRACK_SUBTITLE) p->snapshot.media.subtitle_count++; +} + +static void refresh_media_locked(mp_harmony_player *p) +{ + char *v = mpv_get_property_string(p->handle, "file-format"); + copy_text(p->snapshot.media.container, sizeof(p->snapshot.media.container), v); if (v) mpv_free(v); + v = mpv_get_property_string(p->handle, "current-demuxer"); + copy_text(p->snapshot.media.demuxer, sizeof(p->snapshot.media.demuxer), v); if (v) mpv_free(v); + v = mpv_get_property_string(p->handle, "video-codec"); + copy_text(p->snapshot.media.video_codec, sizeof(p->snapshot.media.video_codec), v); if (v) mpv_free(v); + v = mpv_get_property_string(p->handle, "audio-codec"); + copy_text(p->snapshot.media.audio_codec, sizeof(p->snapshot.media.audio_codec), v); if (v) mpv_free(v); + v = mpv_get_property_string(p->handle, "video-format"); + copy_text(p->snapshot.media.pixel_format, sizeof(p->snapshot.media.pixel_format), v); if (v) mpv_free(v); + int64_t i64 = 0; + if (mpv_get_property(p->handle, "width", MPV_FORMAT_INT64, &i64) >= 0) p->snapshot.media.width = (int)i64; + if (mpv_get_property(p->handle, "height", MPV_FORMAT_INT64, &i64) >= 0) p->snapshot.media.height = (int)i64; + if (mpv_get_property(p->handle, "video-bitrate", MPV_FORMAT_INT64, &i64) >= 0) p->snapshot.media.video_bitrate = i64; + if (mpv_get_property(p->handle, "audio-bitrate", MPV_FORMAT_INT64, &i64) >= 0) p->snapshot.media.audio_bitrate = i64; + if (mpv_get_property(p->handle, "container-fps", MPV_FORMAT_DOUBLE, &p->snapshot.media.fps) < 0) + p->snapshot.media.fps = 0; + if (mpv_get_property(p->handle, "duration", MPV_FORMAT_DOUBLE, &p->snapshot.duration) < 0) + p->snapshot.duration = 0; + mpv_node node = {0}; + if (mpv_get_property(p->handle, "video-params", MPV_FORMAT_NODE, &node) >= 0) { + if (node.format == MPV_FORMAT_NODE_MAP && node.u.list) { + for (int j = 0; j < node.u.list->num; j++) { + const char *key = node.u.list->keys[j]; mpv_node *value = &node.u.list->values[j]; + if (!key) continue; + if (strcmp(key, "pixelformat") == 0) copy_text(p->snapshot.media.pixel_format, sizeof(p->snapshot.media.pixel_format), node_string(value)); + else if (strcmp(key, "primaries") == 0) copy_text(p->snapshot.media.color_primaries, sizeof(p->snapshot.media.color_primaries), node_string(value)); + else if (strcmp(key, "gamma") == 0) copy_text(p->snapshot.media.color_transfer, sizeof(p->snapshot.media.color_transfer), node_string(value)); + else if (strcmp(key, "colormatrix") == 0) copy_text(p->snapshot.media.color_space, sizeof(p->snapshot.media.color_space), node_string(value)); + else if (strcmp(key, "bit-depth") == 0) p->snapshot.media.bit_depth = node_int(value); + } + } + mpv_free_node_contents(&node); + } + memset(&node, 0, sizeof(node)); + if (mpv_get_property(p->handle, "audio-params", MPV_FORMAT_NODE, &node) >= 0) { + if (node.format == MPV_FORMAT_NODE_MAP && node.u.list) { + for (int j = 0; j < node.u.list->num; j++) { + const char *key = node.u.list->keys[j]; mpv_node *value = &node.u.list->values[j]; + if (!key) continue; + if (strcmp(key, "samplerate") == 0) p->snapshot.media.audio_sample_rate = node_int(value); + else if (strcmp(key, "channels") == 0) p->snapshot.media.audio_channels = node_int(value); + else if (strcmp(key, "channel-layout") == 0) copy_text(p->snapshot.media.audio_channel_layout, sizeof(p->snapshot.media.audio_channel_layout), node_string(value)); + } + } + mpv_free_node_contents(&node); + } + refresh_tracks_locked(p); + update_network_locked(p); +} + +static void update_property_locked(mp_harmony_player *p, uint64_t id, mpv_event_property *property) +{ + if (!property || !property->data) return; + if (id == PROP_TIME_POS && property->format == MPV_FORMAT_DOUBLE) p->snapshot.position = *(double *)property->data; + else if (id == PROP_DURATION && property->format == MPV_FORMAT_DOUBLE) p->snapshot.duration = *(double *)property->data; + else if (id == PROP_FPS && property->format == MPV_FORMAT_DOUBLE) p->snapshot.fps = *(double *)property->data; + else if (id == PROP_DROPPED && property->format == MPV_FORMAT_INT64) p->snapshot.dropped_frames = *(int64_t *)property->data; + else if (id == PROP_PAUSE && property->format == MPV_FORMAT_FLAG) { + if (*(int *)property->data) p->snapshot.state = MP_HARMONY_PAUSED; + else if (p->snapshot.state == MP_HARMONY_PAUSED || p->snapshot.state == MP_HARMONY_READY) p->snapshot.state = MP_HARMONY_PLAYING; + } else if (id == PROP_VOLUME && property->format == MPV_FORMAT_DOUBLE) p->snapshot.volume = (int)*(double *)property->data; + else if (id == PROP_MUTE && property->format == MPV_FORMAT_FLAG) p->snapshot.muted = *(int *)property->data != 0; + else if (id == PROP_SPEED && property->format == MPV_FORMAT_DOUBLE) p->snapshot.speed = *(double *)property->data; + else if (id == PROP_TRACKS) refresh_tracks_locked(p); + else if (id == PROP_CACHE_DURATION || id == PROP_CACHE_STATE || id == PROP_BUFFERING) { + update_network_locked(p); + if (id == PROP_BUFFERING && property->format == MPV_FORMAT_INT64) { + int64_t buffering = *(int64_t *)property->data; + if (buffering > 0) p->snapshot.state = MP_HARMONY_BUFFERING; + else if (p->snapshot.state == MP_HARMONY_BUFFERING) p->snapshot.state = MP_HARMONY_PLAYING; + } + } +} + +static void handle_log_locked(mp_harmony_player *p, const mpv_event_log_message *log) +{ + if (!log || !log->text) return; + char redacted[MP_HARMONY_TEXT]; redact_text(redacted, sizeof(redacted), log->text); + if (strstr(log->text, "HTTP/1.") || strstr(log->text, "http error")) { + int status = 0; + if (sscanf(log->text, "%*[^0-9]%d", &status) == 1 && status >= 100 && status <= 599) + p->snapshot.network.http_status = status; + } + if (text_has(log->text, "redirect")) p->snapshot.network.redirect_count++; + if (log->prefix && (strcmp(log->prefix, "cplayer") == 0 || strcmp(log->prefix, "ffmpeg") == 0) && + log->log_level <= MPV_LOG_LEVEL_ERROR) + set_error_locked(p, classify_error(0, redacted), redacted); +} + +static void *event_loop(void *arg) +{ + mp_harmony_player *p = arg; + while (1) { + pthread_mutex_lock(&p->lock); + int stop = p->stop_events || !p->handle; + mpv_handle *h = p->handle; + pthread_mutex_unlock(&p->lock); + if (stop) break; + mpv_event *event = mpv_wait_event(h, 0.05); + if (!event) continue; + pthread_mutex_lock(&p->lock); + if (p->stop_events) { pthread_mutex_unlock(&p->lock); break; } + switch (event->event_id) { + case MPV_EVENT_START_FILE: { + mpv_event_start_file *start = event->data; + p->awaiting_start = 1; + if (start) p->playlist_entry_id = start->playlist_entry_id; + p->snapshot.state = MP_HARMONY_OPENING; + break; + } + case MPV_EVENT_FILE_LOADED: + p->awaiting_start = 0; refresh_media_locked(p); p->snapshot.state = MP_HARMONY_READY; + break; + case MPV_EVENT_PROPERTY_CHANGE: + update_property_locked(p, event->reply_userdata, event->data); + break; + case MPV_EVENT_LOG_MESSAGE: + handle_log_locked(p, event->data); + break; + case MPV_EVENT_END_FILE: { + mpv_event_end_file *end = event->data; + if (end && end->playlist_entry_id && p->playlist_entry_id && + end->playlist_entry_id != p->playlist_entry_id) break; + if (end && end->reason == MPV_END_FILE_REASON_ERROR) { + const char *s = mpv_error_string(end->error); + set_error_locked(p, classify_error(end->error, s), s); + } else if (end && end->reason == MPV_END_FILE_REASON_EOF) p->snapshot.state = MP_HARMONY_ENDED; + else p->snapshot.state = MP_HARMONY_STOPPED; + break; + } + case MPV_EVENT_SHUTDOWN: + p->snapshot.state = MP_HARMONY_RELEASED; + pthread_mutex_unlock(&p->lock); return NULL; + default: break; + } + pthread_mutex_unlock(&p->lock); + } + return NULL; +} + +static int ensure_ready_locked(mp_harmony_player *p) +{ + return p && p->initialized && p->handle ? 0 : -1; +} + +mp_harmony_player *mp_harmony_playback_create(void) +{ + mp_harmony_player *p = calloc(1, sizeof(*p)); + if (!p) return NULL; + if (pthread_mutex_init(&p->lock, NULL) != 0) { free(p); return NULL; } + p->lock_ready = 1; p->snapshot.state = MP_HARMONY_IDLE; + p->snapshot.error_code = MP_HARMONY_ERROR_NONE; p->snapshot.speed = 1.0; + return p; +} + +int mp_harmony_playback_initialize(mp_harmony_player *p) +{ + if (!p || !p->lock_ready) return -1; + pthread_mutex_lock(&p->lock); + if (p->initialized) { pthread_mutex_unlock(&p->lock); return 0; } + p->handle = mpv_create(); + if (!p->handle) { set_error_locked(p, MP_HARMONY_ERROR_UNKNOWN, "mpv_create failed"); pthread_mutex_unlock(&p->lock); return -1; } + const char *options[][2] = { + {"vo", "libmpv"}, {"terminal", "no"}, {"config", "no"}, {"hwdec", "no"}, + {"aid", "auto"}, {"cache", "auto"}, {"cache-pause", "yes"}, {"cookies", "no"}, + {"tls-verify", "yes"}, {NULL, NULL}, + }; + for (size_t i = 0; options[i][0]; i++) { + if (mpv_set_option_string(p->handle, options[i][0], options[i][1]) < 0) { + set_error_locked(p, MP_HARMONY_ERROR_UNKNOWN, options[i][0]); + mpv_terminate_destroy(p->handle); p->handle = NULL; + pthread_mutex_unlock(&p->lock); return -1; + } + } + if (mpv_initialize(p->handle) < 0) { + set_error_locked(p, MP_HARMONY_ERROR_UNKNOWN, "mpv_initialize failed"); + mpv_terminate_destroy(p->handle); p->handle = NULL; + pthread_mutex_unlock(&p->lock); return -1; + } + mpv_request_log_messages(p->handle, "debug"); + mpv_observe_property(p->handle, PROP_TIME_POS, "time-pos", MPV_FORMAT_DOUBLE); + mpv_observe_property(p->handle, PROP_DURATION, "duration", MPV_FORMAT_DOUBLE); + mpv_observe_property(p->handle, PROP_PAUSE, "pause", MPV_FORMAT_FLAG); + mpv_observe_property(p->handle, PROP_FPS, "estimated-vf-fps", MPV_FORMAT_DOUBLE); + mpv_observe_property(p->handle, PROP_DROPPED, "vo-drop-frame-count", MPV_FORMAT_INT64); + mpv_observe_property(p->handle, PROP_TRACKS, "track-list", MPV_FORMAT_NODE); + mpv_observe_property(p->handle, PROP_CACHE_DURATION, "demuxer-cache-duration", MPV_FORMAT_DOUBLE); + mpv_observe_property(p->handle, PROP_CACHE_STATE, "demuxer-cache-state", MPV_FORMAT_NODE); + mpv_observe_property(p->handle, PROP_BUFFERING, "cache-buffering-state", MPV_FORMAT_INT64); + mpv_observe_property(p->handle, PROP_VOLUME, "volume", MPV_FORMAT_DOUBLE); + mpv_observe_property(p->handle, PROP_MUTE, "mute", MPV_FORMAT_FLAG); + mpv_observe_property(p->handle, PROP_SPEED, "speed", MPV_FORMAT_DOUBLE); + p->initialized = 1; p->stop_events = 0; p->session_id = 0; p->snapshot.state = MP_HARMONY_IDLE; + if (pthread_create(&p->events, NULL, event_loop, p) != 0) { + p->initialized = 0; mpv_terminate_destroy(p->handle); p->handle = NULL; + set_error_locked(p, MP_HARMONY_ERROR_UNKNOWN, "event thread failed"); + pthread_mutex_unlock(&p->lock); return -1; + } + pthread_mutex_unlock(&p->lock); + return 0; +} + +static int set_headers_locked(mp_harmony_player *p, const mp_harmony_playback_request *r) +{ + size_t count = r->http_header_count + (r->cookies && r->cookies[0] ? 1 : 0); + if (count > MP_HARMONY_MAX_HEADERS) count = MP_HARMONY_MAX_HEADERS; + mpv_node *values = calloc(count ? count : 1, sizeof(*values)); + char **owned = calloc(count ? count : 1, sizeof(*owned)); + if (!values || !owned) { free(values); free(owned); return -1; } + size_t index = 0; + for (size_t i = 0; r->http_headers && i < r->http_header_count && index < count; i++) { + const char *name = r->http_headers[i].name ? r->http_headers[i].name : ""; + const char *value = r->http_headers[i].value ? r->http_headers[i].value : ""; + size_t n = strlen(name) + strlen(value) + 3; + owned[index] = malloc(n); + if (!owned[index]) continue; + snprintf(owned[index], n, "%s: %s", name, value); + values[index].format = MPV_FORMAT_STRING; values[index].u.string = owned[index]; index++; + } + if (r->cookies && r->cookies[0] && index < count) { + size_t n = strlen(r->cookies) + 9; owned[index] = malloc(n); + if (owned[index]) { snprintf(owned[index], n, "Cookie: %s", r->cookies); values[index].format = MPV_FORMAT_STRING; values[index].u.string = owned[index]; index++; } + } + mpv_node_list list = {(int)index, values, NULL}; + mpv_node node = {.format = MPV_FORMAT_NODE_ARRAY, .u.list = &list}; + int rc = mpv_set_property(p->handle, "http-header-fields", MPV_FORMAT_NODE, &node); + for (size_t i = 0; i < index; i++) free(owned[i]); + free(owned); free(values); + if (rc < 0) set_error_locked(p, classify_error(rc, "http-header-fields"), "http-header-fields failed"); + return rc; +} + +int mp_harmony_playback_load(mp_harmony_player *p, const mp_harmony_playback_request *r) +{ + if (!p || !r || !r->url || !r->url[0]) return -1; + if (mp_harmony_playback_initialize(p) < 0) return -1; + pthread_mutex_lock(&p->lock); + if (ensure_ready_locked(p) < 0) { pthread_mutex_unlock(&p->lock); return -1; } + p->session_id++; p->snapshot.session_id = p->session_id; p->snapshot.state = MP_HARMONY_OPENING; + p->snapshot.error_code = MP_HARMONY_ERROR_NONE; p->snapshot.error_message[0] = '\0'; + p->snapshot.position = 0; p->snapshot.duration = 0; + p->snapshot.network.http_status = 0; p->snapshot.network.redirect_count = 0; + set_headers_locked(p, r); + set_property_string(p, "user-agent", r->user_agent ? r->user_agent : ""); + set_property_string(p, "referrer", r->referer ? r->referer : ""); + set_property_string(p, "alang", r->preferred_audio_language ? r->preferred_audio_language : ""); + set_property_string(p, "slang", r->preferred_subtitle_language ? r->preferred_subtitle_language : ""); + { char timeout[32]; snprintf(timeout, sizeof(timeout), "%.3f", r->network_timeout > 0 ? r->network_timeout : 0.0); set_property_string(p, "network-timeout", timeout); } + if (r->playback_speed > 0) { char speed[32]; snprintf(speed, sizeof(speed), "%.4f", r->playback_speed); set_property_string(p, "speed", speed); p->snapshot.speed = r->playback_speed; } + { char id[32]; snprintf(id, sizeof(id), "%d", r->audio_track_id); set_property_string(p, "aid", r->audio_track_id >= 0 ? id : "auto"); } + if (r->subtitle_track_id >= 0) { char id[32]; snprintf(id, sizeof(id), "%d", r->subtitle_track_id); set_property_string(p, "sid", id); } + else set_property_string(p, "sid", r->subtitle_enabled == 0 ? "no" : "auto"); + const char *args[] = {"loadfile", r->url, "replace", NULL}; + int rc = mpv_command(p->handle, args); + if (rc < 0) set_error_locked(p, classify_error(rc, mpv_error_string(rc)), mpv_error_string(rc)); + if (rc >= 0 && r->start_position_ms >= 0) { + char position[64]; + snprintf(position, sizeof(position), "%.3f", (double)r->start_position_ms / 1000.0); + const char *seek[] = {"seek", position, "absolute", "exact", NULL}; + if (mpv_command(p->handle, seek) < 0) + set_error_locked(p, MP_HARMONY_ERROR_SEEK, "initial seek failed"); + } + pthread_mutex_unlock(&p->lock); + if (rc >= 0) + for (size_t i = 0; i < r->subtitle_file_count; i++) mp_harmony_playback_add_subtitle(p, &r->subtitle_files[i]); + return rc; +} + +int mp_harmony_playback_play(mp_harmony_player *p) { if (!p) return -1; pthread_mutex_lock(&p->lock); int r = ensure_ready_locked(p) < 0 ? -1 : set_property_string(p, "pause", "no"); if (r >= 0) p->snapshot.state = MP_HARMONY_PLAYING; pthread_mutex_unlock(&p->lock); return r; } +int mp_harmony_playback_pause(mp_harmony_player *p) { if (!p) return -1; pthread_mutex_lock(&p->lock); int r = ensure_ready_locked(p) < 0 ? -1 : set_property_string(p, "pause", "yes"); if (r >= 0) p->snapshot.state = MP_HARMONY_PAUSED; pthread_mutex_unlock(&p->lock); return r; } +int mp_harmony_playback_toggle_pause(mp_harmony_player *p) { if (!p) return -1; pthread_mutex_lock(&p->lock); if (ensure_ready_locked(p) < 0) { pthread_mutex_unlock(&p->lock); return -1; } int paused = 0; mpv_get_property(p->handle, "pause", MPV_FORMAT_FLAG, &paused); int r = set_property_string(p, "pause", paused ? "no" : "yes"); if (r >= 0) p->snapshot.state = paused ? MP_HARMONY_PLAYING : MP_HARMONY_PAUSED; pthread_mutex_unlock(&p->lock); return r; } +int mp_harmony_playback_stop(mp_harmony_player *p) { if (!p) return -1; pthread_mutex_lock(&p->lock); int r = ensure_ready_locked(p) < 0 ? -1 : mpv_command_string(p->handle, "stop"); if (r >= 0) p->snapshot.state = MP_HARMONY_STOPPED; pthread_mutex_unlock(&p->lock); return r; } + +int mp_harmony_playback_seek(mp_harmony_player *p, mp_harmony_seek_mode mode, double value, int exact, int keyframe) +{ + if (!p || value != value) return -1; + pthread_mutex_lock(&p->lock); + if (ensure_ready_locked(p) < 0) { pthread_mutex_unlock(&p->lock); return -1; } + char amount[64]; snprintf(amount, sizeof(amount), "%.6f", value); + const char *kind = mode == MP_HARMONY_SEEK_ABSOLUTE ? "absolute" : (mode == MP_HARMONY_SEEK_PERCENT ? "absolute-percent" : "relative"); + const char *flags = exact ? "exact" : (keyframe ? "keyframes" : "default"); + const char *args[] = {"seek", amount, kind, flags, NULL}; + int r = mpv_command(p->handle, args); + if (r >= 0) p->snapshot.state = MP_HARMONY_SEEKING; + else set_error_locked(p, MP_HARMONY_ERROR_SEEK, mpv_error_string(r)); + pthread_mutex_unlock(&p->lock); return r; +} + +int mp_harmony_playback_set_speed(mp_harmony_player *p, double speed) { if (!p || speed <= 0) return -1; pthread_mutex_lock(&p->lock); char v[32]; snprintf(v, sizeof(v), "%.4f", speed); int r = ensure_ready_locked(p) < 0 ? -1 : set_property_string(p, "speed", v); if (r >= 0) p->snapshot.speed = speed; pthread_mutex_unlock(&p->lock); return r; } +int mp_harmony_playback_set_volume(mp_harmony_player *p, double volume) { if (!p || volume < 0) return -1; pthread_mutex_lock(&p->lock); char v[32]; snprintf(v, sizeof(v), "%.3f", volume); int r = ensure_ready_locked(p) < 0 ? -1 : set_property_string(p, "volume", v); if (r >= 0) p->snapshot.volume = (int)volume; pthread_mutex_unlock(&p->lock); return r; } +int mp_harmony_playback_set_mute(mp_harmony_player *p, int muted) { if (!p) return -1; pthread_mutex_lock(&p->lock); int r = ensure_ready_locked(p) < 0 ? -1 : set_property_string(p, "mute", muted ? "yes" : "no"); if (r >= 0) p->snapshot.muted = muted != 0; pthread_mutex_unlock(&p->lock); return r; } +int mp_harmony_playback_select_audio(mp_harmony_player *p, int id) { if (!p) return -1; pthread_mutex_lock(&p->lock); char v[32]; snprintf(v, sizeof(v), "%d", id); int r = ensure_ready_locked(p) < 0 ? -1 : set_property_string(p, "aid", v); pthread_mutex_unlock(&p->lock); return r; } +int mp_harmony_playback_select_subtitle(mp_harmony_player *p, int id) { if (!p) return -1; pthread_mutex_lock(&p->lock); char v[32]; snprintf(v, sizeof(v), "%d", id); int r = ensure_ready_locked(p) < 0 ? -1 : set_property_string(p, "sid", v); pthread_mutex_unlock(&p->lock); return r; } +int mp_harmony_playback_disable_subtitle(mp_harmony_player *p) { if (!p) return -1; pthread_mutex_lock(&p->lock); int r = ensure_ready_locked(p) < 0 ? -1 : set_property_string(p, "sid", "no"); pthread_mutex_unlock(&p->lock); return r; } +int mp_harmony_playback_add_subtitle(mp_harmony_player *p, const mp_harmony_subtitle_source *s) { if (!p || !s || !s->url || !s->url[0]) return -1; pthread_mutex_lock(&p->lock); if (ensure_ready_locked(p) < 0) { pthread_mutex_unlock(&p->lock); return -1; } if (!supported_subtitle_encoding(s->encoding)) { set_error_locked(p, MP_HARMONY_ERROR_SUBTITLE, "unsupported subtitle encoding"); pthread_mutex_unlock(&p->lock); return -1; } if (s->encoding && s->encoding[0]) set_property_string(p, "sub-codepage", s->encoding); const char *flags = s->select ? "select" : "auto"; const char *args[] = {"sub-add", s->url, flags, s->title ? s->title : "", s->language ? s->language : "", NULL}; int r = mpv_command(p->handle, args); if (r < 0) set_error_locked(p, MP_HARMONY_ERROR_SUBTITLE, mpv_error_string(r)); pthread_mutex_unlock(&p->lock); return r; } +int mp_harmony_playback_remove_subtitle(mp_harmony_player *p, int id) { if (!p) return -1; pthread_mutex_lock(&p->lock); char v[32]; snprintf(v, sizeof(v), "%d", id); int r = -1; if (ensure_ready_locked(p) == 0) { const char *args[] = {"sub-remove", v, NULL}; r = mpv_command(p->handle, args); } if (r < 0) set_error_locked(p, MP_HARMONY_ERROR_SUBTITLE, mpv_error_string(r)); pthread_mutex_unlock(&p->lock); return r; } +int mp_harmony_playback_set_subtitle_delay(mp_harmony_player *p, double seconds) { if (!p) return -1; pthread_mutex_lock(&p->lock); char value[32]; snprintf(value, sizeof(value), "%.4f", seconds); int r = ensure_ready_locked(p) < 0 ? -1 : set_property_string(p, "sub-delay", value); pthread_mutex_unlock(&p->lock); return r; } +int mp_harmony_playback_set_subtitle_scale(mp_harmony_player *p, double scale) { if (!p || scale <= 0) return -1; pthread_mutex_lock(&p->lock); char value[32]; snprintf(value, sizeof(value), "%.4f", scale); int r = ensure_ready_locked(p) < 0 ? -1 : set_property_string(p, "sub-scale", value); pthread_mutex_unlock(&p->lock); return r; } +int mp_harmony_playback_set_subtitle_position(mp_harmony_player *p, double position) { if (!p || position < 0 || position > 100) return -1; pthread_mutex_lock(&p->lock); char value[32]; snprintf(value, sizeof(value), "%.4f", position); int r = ensure_ready_locked(p) < 0 ? -1 : set_property_string(p, "sub-pos", value); pthread_mutex_unlock(&p->lock); return r; } +int mp_harmony_playback_set_ass_override(mp_harmony_player *p, const char *mode) { if (!p || !mode) return -1; pthread_mutex_lock(&p->lock); int r = ensure_ready_locked(p) < 0 ? -1 : set_property_string(p, "sub-ass-override", mode); pthread_mutex_unlock(&p->lock); return r; } +int mp_harmony_playback_get_tracks(mp_harmony_player *p, mp_harmony_track *tracks, size_t capacity, size_t *count) { if (!p || !count) return -1; pthread_mutex_lock(&p->lock); if (ensure_ready_locked(p) < 0) { pthread_mutex_unlock(&p->lock); return -1; } refresh_tracks_locked(p); size_t n = p->track_count < capacity ? p->track_count : capacity; if (tracks && n) memcpy(tracks, p->tracks, n * sizeof(*tracks)); *count = p->track_count; pthread_mutex_unlock(&p->lock); return 0; } +int mp_harmony_playback_get_media_info(mp_harmony_player *p, mp_harmony_media_info *info) { if (!p || !info) return -1; pthread_mutex_lock(&p->lock); if (ensure_ready_locked(p) < 0) { pthread_mutex_unlock(&p->lock); return -1; } refresh_media_locked(p); *info = p->snapshot.media; pthread_mutex_unlock(&p->lock); return 0; } +int mp_harmony_playback_get_network_info(mp_harmony_player *p, mp_harmony_network_info *info) { if (!p || !info) return -1; pthread_mutex_lock(&p->lock); if (ensure_ready_locked(p) < 0) { pthread_mutex_unlock(&p->lock); return -1; } update_network_locked(p); *info = p->snapshot.network; pthread_mutex_unlock(&p->lock); return 0; } +int mp_harmony_playback_get_snapshot(mp_harmony_player *p, mp_harmony_playback_snapshot *snapshot) { if (!p || !snapshot) return -1; pthread_mutex_lock(&p->lock); *snapshot = p->snapshot; int r = ensure_ready_locked(p); pthread_mutex_unlock(&p->lock); return r; } + +void mp_harmony_playback_destroy(mp_harmony_player *p) +{ + if (!p) return; + if (p->lock_ready) { + pthread_mutex_lock(&p->lock); + int active = p->initialized; p->stop_events = 1; + if (p->handle) mpv_wakeup(p->handle); + pthread_mutex_unlock(&p->lock); + if (active) pthread_join(p->events, NULL); + pthread_mutex_lock(&p->lock); + if (p->handle) mpv_terminate_destroy(p->handle); + p->handle = NULL; p->initialized = 0; p->snapshot.state = MP_HARMONY_RELEASED; + pthread_mutex_unlock(&p->lock); + pthread_mutex_destroy(&p->lock); + } + free(p); +} + +const char *mp_harmony_playback_state_name(mp_harmony_state state) +{ + static const char *names[] = {"IDLE", "OPENING", "BUFFERING", "READY", "PLAYING", "PAUSED", "SEEKING", "ENDED", "STOPPED", "ERROR", "RELEASED"}; + return state >= MP_HARMONY_IDLE && state <= MP_HARMONY_RELEASED ? names[state] : "UNKNOWN"; +} +const char *mp_harmony_playback_error_name(mp_harmony_error_code error) +{ + static const char *names[] = {"NONE", "NETWORK", "TLS", "HTTP", "DEMUX", "UNSUPPORTED_CONTAINER", "UNSUPPORTED_VIDEO_CODEC", "UNSUPPORTED_AUDIO_CODEC", "SUBTITLE", "DECODER", "AUDIO_OUTPUT", "VIDEO_OUTPUT", "SEEK", "FILE_NOT_FOUND", "PERMISSION", "UNKNOWN"}; + return error >= MP_HARMONY_ERROR_NONE && error <= MP_HARMONY_ERROR_UNKNOWN ? names[error] : "UNKNOWN"; +} diff --git a/test/harmony_playback_api_compile.c b/test/harmony_playback_api_compile.c new file mode 100644 index 0000000000..6a83451f60 --- /dev/null +++ b/test/harmony_playback_api_compile.c @@ -0,0 +1,10 @@ +#include "mpv/harmony_playback.h" + +int main(void) +{ + mp_harmony_playback_request request = {0}; + mp_harmony_playback_snapshot snapshot = {0}; + request.url = "file:///sample.mp4"; + (void)snapshot; + return request.url == NULL; +}